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>
This commit is contained in:
286
markitect/spaces/history/events.py
Normal file
286
markitect/spaces/history/events.py
Normal file
@@ -0,0 +1,286 @@
|
||||
"""
|
||||
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)
|
||||
Reference in New Issue
Block a user