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>
287 lines
8.6 KiB
Python
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)
|