Files
markitect-main/markitect/spaces/history/events.py
tegwick 4588cbeee8 feat(spaces): implement Phase 8 Git History Tracking
Implements optional git-based version control for information spaces:
- HistoryConfig model for configuring history tracking
- Commit, Branch, HistoryEntry, DiffResult models
- IHistoryBackend and IHistoryQuery interfaces
- GitHistoryBackend using git CLI for version control
- GitHistoryEventHandler for event-driven auto-commits
- HistoryEventCoordinator for managing space history
- HistoryQueryService for high-level history queries
- Automatic commits on DOCUMENT_ADDED/REMOVED/CONTENT_CHANGED events
- Support for:
  * Commit log with pagination and filtering
  * Diff between versions
  * File content at specific versions
  * Branch creation and switching
  * Version restoration
  * Uncommitted changes detection
- 43 comprehensive unit tests with git availability checks

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-08 18:03:35 +01:00

287 lines
8.6 KiB
Python

"""
Event handlers for history tracking.
Subscribes to space events and triggers commits automatically.
"""
from pathlib import Path
from typing import Dict, Any, Optional, Callable
import logging
from ..events import SpaceEvent, SpaceEventType, EventBus
from .interfaces import IHistoryBackend
from .models import HistoryConfig, Commit
logger = logging.getLogger(__name__)
class GitHistoryEventHandler:
"""
Event handler that commits changes to git on document events.
Subscribes to document events and automatically creates commits
when documents are added, removed, or modified.
"""
def __init__(
self,
backend: IHistoryBackend,
config: HistoryConfig,
directory_resolver: Callable[[str], Optional[Path]],
):
"""
Initialize the event handler.
Args:
backend: History backend to use
config: History configuration
directory_resolver: Function that resolves space_id to directory path
"""
self._backend = backend
self._config = config
self._directory_resolver = directory_resolver
self._event_bus: Optional[EventBus] = None
self._handler_ids: Dict[SpaceEventType, str] = {}
def register(self, event_bus: EventBus) -> None:
"""
Register handlers with an event bus.
Args:
event_bus: Event bus to register with
"""
self._event_bus = event_bus
# Map event type names to SpaceEventType
event_type_map = {
"DOCUMENT_ADDED": SpaceEventType.DOCUMENT_ADDED,
"DOCUMENT_REMOVED": SpaceEventType.DOCUMENT_REMOVED,
"DOCUMENT_CONTENT_CHANGED": SpaceEventType.DOCUMENT_CONTENT_CHANGED,
"DOCUMENT_MOVED": SpaceEventType.DOCUMENT_MOVED,
}
for event_name in self._config.commit_on_events:
event_type = event_type_map.get(event_name)
if event_type:
handler_id = event_bus.subscribe(event_type, self._handle_event)
self._handler_ids[event_type] = handler_id
def unregister(self) -> None:
"""Unregister handlers from the event bus."""
if self._event_bus:
for event_type, handler_id in self._handler_ids.items():
self._event_bus.unsubscribe(event_type, handler_id)
self._handler_ids.clear()
self._event_bus = None
def _handle_event(self, event: SpaceEvent) -> None:
"""
Handle a space event.
Args:
event: The event to handle
"""
if not self._config.enabled or not self._config.auto_commit:
return
space_id = event.space_id
if not space_id:
return
directory = self._directory_resolver(space_id)
if not directory:
logger.warning(f"No directory found for space {space_id}")
return
try:
self._commit_for_event(event, directory)
except Exception as e:
logger.error(f"Failed to commit for event {event.event_type}: {e}")
def _commit_for_event(self, event: SpaceEvent, directory: Path) -> Optional[Commit]:
"""
Create a commit for an event.
Args:
event: The triggering event
directory: Space directory
Returns:
The created commit, or None if no commit was made
"""
# Ensure history is initialized
if not self._backend.is_initialized(directory):
self._backend.initialize(directory, self._config)
# Check for changes
if not self._backend.has_changes(directory):
return None
# Build commit message
message = self._build_commit_message(event)
# Build metadata
metadata = {
"space_id": event.space_id,
"event_type": event.event_type.value,
"event_id": event.event_id,
}
# Get specific files to commit if available
files = None
if event.payload:
doc_path = event.payload.get("space_path") or event.payload.get("path")
if doc_path:
# Normalize path
if doc_path.startswith("/"):
doc_path = doc_path[1:]
files = [doc_path]
# Create commit
author = f"{self._config.author_name} <{self._config.author_email}>"
return self._backend.commit(
directory=directory,
message=message,
files=files,
author=author,
metadata=metadata,
)
def _build_commit_message(self, event: SpaceEvent) -> str:
"""
Build a commit message for an event.
Args:
event: The event
Returns:
Commit message string
"""
payload = event.payload or {}
if event.event_type == SpaceEventType.DOCUMENT_ADDED:
path = payload.get("space_path", "document")
return f"Add document: {path}"
elif event.event_type == SpaceEventType.DOCUMENT_REMOVED:
path = payload.get("space_path", "document")
return f"Remove document: {path}"
elif event.event_type == SpaceEventType.DOCUMENT_CONTENT_CHANGED:
doc_id = payload.get("document_id", "document")
return f"Update document: {doc_id}"
elif event.event_type == SpaceEventType.DOCUMENT_MOVED:
old_path = payload.get("old_path", "old")
new_path = payload.get("new_path", "new")
return f"Move document: {old_path} -> {new_path}"
else:
return f"Auto-commit: {event.event_type.value}"
def commit_changes(
self,
space_id: str,
message: str,
files: Optional[list] = None,
) -> Optional[Commit]:
"""
Manually trigger a commit.
Args:
space_id: Space identifier
message: Commit message
files: Specific files to commit
Returns:
The created commit, or None if failed
"""
directory = self._directory_resolver(space_id)
if not directory:
return None
if not self._backend.is_initialized(directory):
self._backend.initialize(directory, self._config)
if not self._backend.has_changes(directory):
return None
author = f"{self._config.author_name} <{self._config.author_email}>"
return self._backend.commit(
directory=directory,
message=message,
files=files,
author=author,
metadata={"space_id": space_id, "manual": True},
)
class HistoryEventCoordinator:
"""
Coordinates history tracking across multiple spaces.
Manages event handlers for spaces with history enabled.
"""
def __init__(
self,
backend: IHistoryBackend,
event_bus: EventBus,
directory_resolver: Callable[[str], Optional[Path]],
):
"""
Initialize the coordinator.
Args:
backend: History backend
event_bus: Event bus for subscriptions
directory_resolver: Function to resolve space_id to directory
"""
self._backend = backend
self._event_bus = event_bus
self._directory_resolver = directory_resolver
self._handlers: Dict[str, GitHistoryEventHandler] = {}
def enable_history(self, space_id: str, config: HistoryConfig) -> None:
"""
Enable history tracking for a space.
Args:
space_id: Space identifier
config: History configuration
"""
if space_id in self._handlers:
return
handler = GitHistoryEventHandler(
backend=self._backend,
config=config,
directory_resolver=self._directory_resolver,
)
handler.register(self._event_bus)
self._handlers[space_id] = handler
# Initialize git in the space directory
directory = self._directory_resolver(space_id)
if directory:
self._backend.initialize(directory, config)
def disable_history(self, space_id: str) -> None:
"""
Disable history tracking for a space.
Args:
space_id: Space identifier
"""
handler = self._handlers.pop(space_id, None)
if handler:
handler.unregister()
def is_enabled(self, space_id: str) -> bool:
"""Check if history is enabled for a space."""
return space_id in self._handlers
def get_handler(self, space_id: str) -> Optional[GitHistoryEventHandler]:
"""Get the event handler for a space."""
return self._handlers.get(space_id)