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:
@@ -83,6 +83,27 @@ from .composability import (
|
|||||||
)
|
)
|
||||||
from .composability.service import CircularReferenceError
|
from .composability.service import CircularReferenceError
|
||||||
|
|
||||||
|
# Phase 8: History Tracking (Optional)
|
||||||
|
from .history import (
|
||||||
|
# Models
|
||||||
|
Commit,
|
||||||
|
Branch,
|
||||||
|
HistoryEntry,
|
||||||
|
DiffResult,
|
||||||
|
DiffLine,
|
||||||
|
DiffType,
|
||||||
|
HistoryConfig,
|
||||||
|
# Interfaces
|
||||||
|
IHistoryBackend,
|
||||||
|
IHistoryQuery,
|
||||||
|
# Implementations
|
||||||
|
GitHistoryBackend,
|
||||||
|
GitError,
|
||||||
|
GitHistoryEventHandler,
|
||||||
|
HistoryEventCoordinator,
|
||||||
|
HistoryQueryService,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# Models
|
# Models
|
||||||
"InformationSpace",
|
"InformationSpace",
|
||||||
@@ -130,4 +151,21 @@ __all__ = [
|
|||||||
"IAccessControlRepository",
|
"IAccessControlRepository",
|
||||||
"SqliteSpaceReferenceRepository",
|
"SqliteSpaceReferenceRepository",
|
||||||
"SqliteAccessControlRepository",
|
"SqliteAccessControlRepository",
|
||||||
|
# History - Models
|
||||||
|
"Commit",
|
||||||
|
"Branch",
|
||||||
|
"HistoryEntry",
|
||||||
|
"DiffResult",
|
||||||
|
"DiffLine",
|
||||||
|
"DiffType",
|
||||||
|
"HistoryConfig",
|
||||||
|
# History - Interfaces
|
||||||
|
"IHistoryBackend",
|
||||||
|
"IHistoryQuery",
|
||||||
|
# History - Implementations
|
||||||
|
"GitHistoryBackend",
|
||||||
|
"GitError",
|
||||||
|
"GitHistoryEventHandler",
|
||||||
|
"HistoryEventCoordinator",
|
||||||
|
"HistoryQueryService",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,13 +1,43 @@
|
|||||||
"""
|
"""
|
||||||
Git history tracking for Information Spaces (Optional Phase 8).
|
History tracking module for Information Spaces.
|
||||||
|
|
||||||
This package provides version control integration:
|
Provides optional git-based version control for space content,
|
||||||
- IHistoryBackend: Abstract history backend interface
|
enabling commit history, diffs, and version restoration.
|
||||||
- GitHistoryBackend: Git implementation
|
|
||||||
- Event-driven commit triggers
|
|
||||||
- History query API (log, diff, branches)
|
|
||||||
- Versioned read/render operations
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# History tracking will be implemented in Phase 8
|
from .models import (
|
||||||
__all__ = []
|
Commit,
|
||||||
|
Branch,
|
||||||
|
HistoryEntry,
|
||||||
|
DiffResult,
|
||||||
|
DiffLine,
|
||||||
|
DiffType,
|
||||||
|
HistoryConfig,
|
||||||
|
)
|
||||||
|
from .interfaces import (
|
||||||
|
IHistoryBackend,
|
||||||
|
IHistoryQuery,
|
||||||
|
)
|
||||||
|
from .git_backend import GitHistoryBackend, GitError
|
||||||
|
from .events import GitHistoryEventHandler, HistoryEventCoordinator
|
||||||
|
from .queries import HistoryQueryService
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
# Models
|
||||||
|
"Commit",
|
||||||
|
"Branch",
|
||||||
|
"HistoryEntry",
|
||||||
|
"DiffResult",
|
||||||
|
"DiffLine",
|
||||||
|
"DiffType",
|
||||||
|
"HistoryConfig",
|
||||||
|
# Interfaces
|
||||||
|
"IHistoryBackend",
|
||||||
|
"IHistoryQuery",
|
||||||
|
# Implementations
|
||||||
|
"GitHistoryBackend",
|
||||||
|
"GitError",
|
||||||
|
"GitHistoryEventHandler",
|
||||||
|
"HistoryEventCoordinator",
|
||||||
|
"HistoryQueryService",
|
||||||
|
]
|
||||||
|
|||||||
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)
|
||||||
521
markitect/spaces/history/git_backend.py
Normal file
521
markitect/spaces/history/git_backend.py
Normal file
@@ -0,0 +1,521 @@
|
|||||||
|
"""
|
||||||
|
Git implementation of the history backend.
|
||||||
|
|
||||||
|
Uses git commands to provide version control for space content.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Optional, Dict, Any
|
||||||
|
from datetime import datetime
|
||||||
|
import re
|
||||||
|
|
||||||
|
from .interfaces import IHistoryBackend
|
||||||
|
from .models import Commit, Branch, DiffResult, DiffLine, DiffType, HistoryConfig
|
||||||
|
|
||||||
|
|
||||||
|
class GitError(Exception):
|
||||||
|
"""Raised when a git operation fails."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class GitHistoryBackend(IHistoryBackend):
|
||||||
|
"""
|
||||||
|
Git-based history backend implementation.
|
||||||
|
|
||||||
|
Uses the git CLI to manage version control. Requires git to be
|
||||||
|
installed on the system.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, default_author: str = "markitect <markitect@local>"):
|
||||||
|
"""
|
||||||
|
Initialize the git backend.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
default_author: Default author for commits
|
||||||
|
"""
|
||||||
|
self._default_author = default_author
|
||||||
|
self._verify_git_available()
|
||||||
|
|
||||||
|
def _verify_git_available(self) -> None:
|
||||||
|
"""Verify git is available on the system."""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "--version"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise GitError("Git is not available")
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise GitError("Git is not installed")
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
raise GitError("Git command timed out")
|
||||||
|
|
||||||
|
def _run_git(
|
||||||
|
self,
|
||||||
|
directory: Path,
|
||||||
|
args: List[str],
|
||||||
|
check: bool = True,
|
||||||
|
capture_output: bool = True,
|
||||||
|
) -> subprocess.CompletedProcess:
|
||||||
|
"""
|
||||||
|
Run a git command.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
directory: Working directory
|
||||||
|
args: Git command arguments (without 'git')
|
||||||
|
check: Whether to raise on non-zero exit
|
||||||
|
capture_output: Whether to capture stdout/stderr
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CompletedProcess result
|
||||||
|
"""
|
||||||
|
cmd = ["git"] + args
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
cmd,
|
||||||
|
cwd=str(directory),
|
||||||
|
capture_output=capture_output,
|
||||||
|
text=True,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
if check and result.returncode != 0:
|
||||||
|
raise GitError(f"Git command failed: {result.stderr}")
|
||||||
|
return result
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
raise GitError(f"Git command timed out: {' '.join(cmd)}")
|
||||||
|
|
||||||
|
def initialize(self, directory: Path, config: HistoryConfig) -> None:
|
||||||
|
"""Initialize a git repository."""
|
||||||
|
if not directory.exists():
|
||||||
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
if not self.is_initialized(directory):
|
||||||
|
self._run_git(directory, ["init"])
|
||||||
|
|
||||||
|
# Configure user for this repo
|
||||||
|
self._run_git(
|
||||||
|
directory,
|
||||||
|
["config", "user.name", config.author_name],
|
||||||
|
)
|
||||||
|
self._run_git(
|
||||||
|
directory,
|
||||||
|
["config", "user.email", config.author_email],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create initial .gitignore
|
||||||
|
gitignore = directory / ".gitignore"
|
||||||
|
if not gitignore.exists():
|
||||||
|
gitignore.write_text("*.pyc\n__pycache__/\n.DS_Store\n")
|
||||||
|
self._run_git(directory, ["add", ".gitignore"])
|
||||||
|
self._run_git(
|
||||||
|
directory,
|
||||||
|
["commit", "-m", "Initial commit: add .gitignore"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def is_initialized(self, directory: Path) -> bool:
|
||||||
|
"""Check if directory is a git repository."""
|
||||||
|
git_dir = directory / ".git"
|
||||||
|
return git_dir.exists() and git_dir.is_dir()
|
||||||
|
|
||||||
|
def commit(
|
||||||
|
self,
|
||||||
|
directory: Path,
|
||||||
|
message: str,
|
||||||
|
files: Optional[List[str]] = None,
|
||||||
|
author: Optional[str] = None,
|
||||||
|
metadata: Optional[Dict[str, Any]] = None,
|
||||||
|
) -> Commit:
|
||||||
|
"""Create a commit."""
|
||||||
|
if not self.is_initialized(directory):
|
||||||
|
raise GitError(f"Not a git repository: {directory}")
|
||||||
|
|
||||||
|
# Stage files
|
||||||
|
if files:
|
||||||
|
for f in files:
|
||||||
|
file_path = directory / f
|
||||||
|
if file_path.exists():
|
||||||
|
self._run_git(directory, ["add", f])
|
||||||
|
else:
|
||||||
|
# File was deleted
|
||||||
|
self._run_git(directory, ["add", "-u", f], check=False)
|
||||||
|
else:
|
||||||
|
# Stage all changes
|
||||||
|
self._run_git(directory, ["add", "-A"])
|
||||||
|
|
||||||
|
# Check if there are changes to commit
|
||||||
|
status = self._run_git(directory, ["status", "--porcelain"])
|
||||||
|
if not status.stdout.strip():
|
||||||
|
raise GitError("No changes to commit")
|
||||||
|
|
||||||
|
# Build commit command
|
||||||
|
commit_args = ["commit", "-m", message]
|
||||||
|
if author:
|
||||||
|
commit_args.extend(["--author", author])
|
||||||
|
|
||||||
|
# Add metadata as trailer
|
||||||
|
if metadata:
|
||||||
|
for key, value in metadata.items():
|
||||||
|
commit_args.extend(["--trailer", f"{key}={value}"])
|
||||||
|
|
||||||
|
self._run_git(directory, commit_args)
|
||||||
|
|
||||||
|
# Get the commit info
|
||||||
|
return self._get_head_commit(directory)
|
||||||
|
|
||||||
|
def _get_head_commit(self, directory: Path) -> Commit:
|
||||||
|
"""Get the HEAD commit."""
|
||||||
|
result = self._run_git(
|
||||||
|
directory,
|
||||||
|
["log", "-1", "--format=%H|%s|%an <%ae>|%aI|%P"],
|
||||||
|
)
|
||||||
|
return self._parse_commit_line(result.stdout.strip(), directory)
|
||||||
|
|
||||||
|
def _parse_commit_line(self, line: str, directory: Path) -> Commit:
|
||||||
|
"""Parse a commit from log format."""
|
||||||
|
parts = line.split("|")
|
||||||
|
if len(parts) < 4:
|
||||||
|
raise GitError(f"Invalid commit format: {line}")
|
||||||
|
|
||||||
|
commit_id = parts[0]
|
||||||
|
message = parts[1]
|
||||||
|
author = parts[2]
|
||||||
|
timestamp_str = parts[3]
|
||||||
|
parent_ids = parts[4].split() if len(parts) > 4 and parts[4] else []
|
||||||
|
|
||||||
|
# Parse ISO timestamp
|
||||||
|
try:
|
||||||
|
timestamp = datetime.fromisoformat(timestamp_str.replace("Z", "+00:00"))
|
||||||
|
except ValueError:
|
||||||
|
timestamp = datetime.now()
|
||||||
|
|
||||||
|
# Get changed files
|
||||||
|
files_result = self._run_git(
|
||||||
|
directory,
|
||||||
|
["show", "--name-only", "--format=", commit_id],
|
||||||
|
)
|
||||||
|
files_changed = [f for f in files_result.stdout.strip().split("\n") if f]
|
||||||
|
|
||||||
|
# Extract metadata from commit body (git trailers)
|
||||||
|
metadata = {}
|
||||||
|
try:
|
||||||
|
body_result = self._run_git(
|
||||||
|
directory,
|
||||||
|
["log", "-1", "--format=%b", commit_id],
|
||||||
|
)
|
||||||
|
body = body_result.stdout.strip()
|
||||||
|
# Parse trailers - format is "Key: value" or "Key=value"
|
||||||
|
for line in body.split("\n"):
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
# Try "Key: value" format first
|
||||||
|
if ":" in line:
|
||||||
|
key, value = line.split(":", 1)
|
||||||
|
metadata[key.strip()] = value.strip()
|
||||||
|
# Also try "Key=value" format (what git --trailer actually creates)
|
||||||
|
elif "=" in line:
|
||||||
|
key, value = line.split("=", 1)
|
||||||
|
metadata[key.strip()] = value.strip()
|
||||||
|
except GitError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return Commit(
|
||||||
|
id=commit_id,
|
||||||
|
message=message,
|
||||||
|
author=author,
|
||||||
|
timestamp=timestamp,
|
||||||
|
parent_ids=parent_ids,
|
||||||
|
files_changed=files_changed,
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_commit(self, directory: Path, commit_id: str) -> Optional[Commit]:
|
||||||
|
"""Get a commit by ID."""
|
||||||
|
if not self.is_initialized(directory):
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = self._run_git(
|
||||||
|
directory,
|
||||||
|
["log", "-1", "--format=%H|%s|%an <%ae>|%aI|%P", commit_id],
|
||||||
|
)
|
||||||
|
if result.stdout.strip():
|
||||||
|
return self._parse_commit_line(result.stdout.strip(), directory)
|
||||||
|
except GitError:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_log(
|
||||||
|
self,
|
||||||
|
directory: Path,
|
||||||
|
limit: int = 50,
|
||||||
|
offset: int = 0,
|
||||||
|
path: Optional[str] = None,
|
||||||
|
) -> List[Commit]:
|
||||||
|
"""Get commit history."""
|
||||||
|
if not self.is_initialized(directory):
|
||||||
|
return []
|
||||||
|
|
||||||
|
args = [
|
||||||
|
"log",
|
||||||
|
f"--skip={offset}",
|
||||||
|
f"-{limit}",
|
||||||
|
"--format=%H|%s|%an <%ae>|%aI|%P",
|
||||||
|
]
|
||||||
|
if path:
|
||||||
|
args.extend(["--", path])
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = self._run_git(directory, args)
|
||||||
|
commits = []
|
||||||
|
for line in result.stdout.strip().split("\n"):
|
||||||
|
if line:
|
||||||
|
commits.append(self._parse_commit_line(line, directory))
|
||||||
|
return commits
|
||||||
|
except GitError:
|
||||||
|
return []
|
||||||
|
|
||||||
|
def get_diff(
|
||||||
|
self,
|
||||||
|
directory: Path,
|
||||||
|
old_version: Optional[str] = None,
|
||||||
|
new_version: Optional[str] = None,
|
||||||
|
path: Optional[str] = None,
|
||||||
|
) -> List[DiffResult]:
|
||||||
|
"""Get diff between versions."""
|
||||||
|
if not self.is_initialized(directory):
|
||||||
|
return []
|
||||||
|
|
||||||
|
args = ["diff"]
|
||||||
|
|
||||||
|
if old_version and new_version:
|
||||||
|
args.append(f"{old_version}..{new_version}")
|
||||||
|
elif old_version:
|
||||||
|
args.append(old_version)
|
||||||
|
elif new_version:
|
||||||
|
args.extend(["HEAD", new_version])
|
||||||
|
|
||||||
|
if path:
|
||||||
|
args.extend(["--", path])
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = self._run_git(directory, args)
|
||||||
|
return self._parse_diff_output(result.stdout)
|
||||||
|
except GitError:
|
||||||
|
return []
|
||||||
|
|
||||||
|
def _parse_diff_output(self, output: str) -> List[DiffResult]:
|
||||||
|
"""Parse git diff output into DiffResult objects."""
|
||||||
|
results = []
|
||||||
|
current_diff: Optional[DiffResult] = None
|
||||||
|
old_line = 0
|
||||||
|
new_line = 0
|
||||||
|
|
||||||
|
for line in output.split("\n"):
|
||||||
|
# New file diff
|
||||||
|
if line.startswith("diff --git"):
|
||||||
|
if current_diff:
|
||||||
|
results.append(current_diff)
|
||||||
|
# Extract path from "diff --git a/path b/path"
|
||||||
|
match = re.search(r"diff --git a/(.*) b/", line)
|
||||||
|
path = match.group(1) if match else "unknown"
|
||||||
|
current_diff = DiffResult(path=path)
|
||||||
|
|
||||||
|
elif line.startswith("@@") and current_diff:
|
||||||
|
# Parse hunk header: @@ -old_start,old_count +new_start,new_count @@
|
||||||
|
match = re.search(r"@@ -(\d+)", line)
|
||||||
|
if match:
|
||||||
|
old_line = int(match.group(1))
|
||||||
|
match = re.search(r"\+(\d+)", line)
|
||||||
|
if match:
|
||||||
|
new_line = int(match.group(1))
|
||||||
|
|
||||||
|
elif current_diff and line:
|
||||||
|
if line.startswith("+") and not line.startswith("+++"):
|
||||||
|
current_diff.lines.append(DiffLine(
|
||||||
|
line_type=DiffType.ADDITION,
|
||||||
|
content=line[1:],
|
||||||
|
new_line_no=new_line,
|
||||||
|
))
|
||||||
|
current_diff.additions += 1
|
||||||
|
new_line += 1
|
||||||
|
elif line.startswith("-") and not line.startswith("---"):
|
||||||
|
current_diff.lines.append(DiffLine(
|
||||||
|
line_type=DiffType.DELETION,
|
||||||
|
content=line[1:],
|
||||||
|
old_line_no=old_line,
|
||||||
|
))
|
||||||
|
current_diff.deletions += 1
|
||||||
|
old_line += 1
|
||||||
|
elif line.startswith(" "):
|
||||||
|
current_diff.lines.append(DiffLine(
|
||||||
|
line_type=DiffType.CONTEXT,
|
||||||
|
content=line[1:],
|
||||||
|
old_line_no=old_line,
|
||||||
|
new_line_no=new_line,
|
||||||
|
))
|
||||||
|
old_line += 1
|
||||||
|
new_line += 1
|
||||||
|
|
||||||
|
if current_diff:
|
||||||
|
results.append(current_diff)
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
def checkout(
|
||||||
|
self,
|
||||||
|
directory: Path,
|
||||||
|
version: str,
|
||||||
|
path: Optional[str] = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Checkout a specific version."""
|
||||||
|
if not self.is_initialized(directory):
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
if path:
|
||||||
|
self._run_git(directory, ["checkout", version, "--", path])
|
||||||
|
else:
|
||||||
|
self._run_git(directory, ["checkout", version])
|
||||||
|
return True
|
||||||
|
except GitError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_file_at_version(
|
||||||
|
self,
|
||||||
|
directory: Path,
|
||||||
|
path: str,
|
||||||
|
version: str,
|
||||||
|
) -> Optional[str]:
|
||||||
|
"""Get file content at a specific version."""
|
||||||
|
if not self.is_initialized(directory):
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = self._run_git(
|
||||||
|
directory,
|
||||||
|
["show", f"{version}:{path}"],
|
||||||
|
)
|
||||||
|
return result.stdout
|
||||||
|
except GitError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def list_branches(self, directory: Path) -> List[Branch]:
|
||||||
|
"""List all branches."""
|
||||||
|
if not self.is_initialized(directory):
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = self._run_git(
|
||||||
|
directory,
|
||||||
|
["branch", "-v", "--format=%(refname:short)|%(objectname)|%(HEAD)"],
|
||||||
|
)
|
||||||
|
branches = []
|
||||||
|
for line in result.stdout.strip().split("\n"):
|
||||||
|
if line:
|
||||||
|
parts = line.split("|")
|
||||||
|
if len(parts) >= 3:
|
||||||
|
branches.append(Branch(
|
||||||
|
name=parts[0],
|
||||||
|
head_commit_id=parts[1],
|
||||||
|
is_current=(parts[2] == "*"),
|
||||||
|
))
|
||||||
|
return branches
|
||||||
|
except GitError:
|
||||||
|
return []
|
||||||
|
|
||||||
|
def create_branch(
|
||||||
|
self,
|
||||||
|
directory: Path,
|
||||||
|
name: str,
|
||||||
|
start_point: Optional[str] = None,
|
||||||
|
) -> Branch:
|
||||||
|
"""Create a new branch."""
|
||||||
|
if not self.is_initialized(directory):
|
||||||
|
raise GitError(f"Not a git repository: {directory}")
|
||||||
|
|
||||||
|
args = ["branch", name]
|
||||||
|
if start_point:
|
||||||
|
args.append(start_point)
|
||||||
|
|
||||||
|
self._run_git(directory, args)
|
||||||
|
|
||||||
|
# Get the created branch info
|
||||||
|
result = self._run_git(
|
||||||
|
directory,
|
||||||
|
["rev-parse", name],
|
||||||
|
)
|
||||||
|
return Branch(
|
||||||
|
name=name,
|
||||||
|
head_commit_id=result.stdout.strip(),
|
||||||
|
is_current=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def switch_branch(self, directory: Path, name: str) -> bool:
|
||||||
|
"""Switch to a branch."""
|
||||||
|
if not self.is_initialized(directory):
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._run_git(directory, ["checkout", name])
|
||||||
|
return True
|
||||||
|
except GitError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_current_branch(self, directory: Path) -> Optional[str]:
|
||||||
|
"""Get the current branch name."""
|
||||||
|
if not self.is_initialized(directory):
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = self._run_git(
|
||||||
|
directory,
|
||||||
|
["rev-parse", "--abbrev-ref", "HEAD"],
|
||||||
|
)
|
||||||
|
branch = result.stdout.strip()
|
||||||
|
return branch if branch != "HEAD" else None
|
||||||
|
except GitError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def has_changes(self, directory: Path) -> bool:
|
||||||
|
"""Check if there are uncommitted changes."""
|
||||||
|
if not self.is_initialized(directory):
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = self._run_git(directory, ["status", "--porcelain"])
|
||||||
|
return bool(result.stdout.strip())
|
||||||
|
except GitError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def restore_file(
|
||||||
|
self,
|
||||||
|
directory: Path,
|
||||||
|
path: str,
|
||||||
|
version: str,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Restore a file to a specific version.
|
||||||
|
|
||||||
|
Gets the file content at the version and writes it to the working tree.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
directory: Root directory
|
||||||
|
path: File path
|
||||||
|
version: Version to restore from
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if successful
|
||||||
|
"""
|
||||||
|
content = self.get_file_at_version(directory, path, version)
|
||||||
|
if content is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
file_path = directory / path
|
||||||
|
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
file_path.write_text(content)
|
||||||
|
return True
|
||||||
350
markitect/spaces/history/interfaces.py
Normal file
350
markitect/spaces/history/interfaces.py
Normal file
@@ -0,0 +1,350 @@
|
|||||||
|
"""
|
||||||
|
Abstract interfaces for history tracking.
|
||||||
|
|
||||||
|
Defines the contract for history backends and query services.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Optional, Dict, Any
|
||||||
|
|
||||||
|
from .models import Commit, Branch, DiffResult, HistoryEntry, HistoryConfig
|
||||||
|
|
||||||
|
|
||||||
|
class IHistoryBackend(ABC):
|
||||||
|
"""
|
||||||
|
Abstract interface for history storage backends.
|
||||||
|
|
||||||
|
Implementations can use git, SQLite, or other storage mechanisms.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def initialize(self, directory: Path, config: HistoryConfig) -> None:
|
||||||
|
"""
|
||||||
|
Initialize the history backend.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
directory: Root directory for the space
|
||||||
|
config: History configuration
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def is_initialized(self, directory: Path) -> bool:
|
||||||
|
"""
|
||||||
|
Check if history is initialized for a directory.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
directory: Directory to check
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if history tracking is initialized
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def commit(
|
||||||
|
self,
|
||||||
|
directory: Path,
|
||||||
|
message: str,
|
||||||
|
files: Optional[List[str]] = None,
|
||||||
|
author: Optional[str] = None,
|
||||||
|
metadata: Optional[Dict[str, Any]] = None,
|
||||||
|
) -> Commit:
|
||||||
|
"""
|
||||||
|
Create a new commit.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
directory: Root directory
|
||||||
|
message: Commit message
|
||||||
|
files: Specific files to commit (None for all changes)
|
||||||
|
author: Author string (name <email>)
|
||||||
|
metadata: Additional metadata to store
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The created commit
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_commit(self, directory: Path, commit_id: str) -> Optional[Commit]:
|
||||||
|
"""
|
||||||
|
Get a commit by ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
directory: Root directory
|
||||||
|
commit_id: Commit identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The commit if found, None otherwise
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_log(
|
||||||
|
self,
|
||||||
|
directory: Path,
|
||||||
|
limit: int = 50,
|
||||||
|
offset: int = 0,
|
||||||
|
path: Optional[str] = None,
|
||||||
|
) -> List[Commit]:
|
||||||
|
"""
|
||||||
|
Get commit history.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
directory: Root directory
|
||||||
|
limit: Maximum number of commits
|
||||||
|
offset: Number of commits to skip
|
||||||
|
path: Filter by file path
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of commits, newest first
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_diff(
|
||||||
|
self,
|
||||||
|
directory: Path,
|
||||||
|
old_version: Optional[str] = None,
|
||||||
|
new_version: Optional[str] = None,
|
||||||
|
path: Optional[str] = None,
|
||||||
|
) -> List[DiffResult]:
|
||||||
|
"""
|
||||||
|
Get diff between versions.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
directory: Root directory
|
||||||
|
old_version: Old commit ID (None for working directory)
|
||||||
|
new_version: New commit ID (None for working directory)
|
||||||
|
path: Filter by file path
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of diff results
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def checkout(
|
||||||
|
self,
|
||||||
|
directory: Path,
|
||||||
|
version: str,
|
||||||
|
path: Optional[str] = None,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Checkout a specific version.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
directory: Root directory
|
||||||
|
version: Commit ID or ref to checkout
|
||||||
|
path: Specific file to checkout (None for all)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if successful
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_file_at_version(
|
||||||
|
self,
|
||||||
|
directory: Path,
|
||||||
|
path: str,
|
||||||
|
version: str,
|
||||||
|
) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Get file content at a specific version.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
directory: Root directory
|
||||||
|
path: File path relative to directory
|
||||||
|
version: Commit ID or ref
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
File content as string, or None if not found
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def list_branches(self, directory: Path) -> List[Branch]:
|
||||||
|
"""
|
||||||
|
List all branches.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
directory: Root directory
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of branches
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def create_branch(
|
||||||
|
self,
|
||||||
|
directory: Path,
|
||||||
|
name: str,
|
||||||
|
start_point: Optional[str] = None,
|
||||||
|
) -> Branch:
|
||||||
|
"""
|
||||||
|
Create a new branch.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
directory: Root directory
|
||||||
|
name: Branch name
|
||||||
|
start_point: Starting commit/ref (None for HEAD)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The created branch
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def switch_branch(self, directory: Path, name: str) -> bool:
|
||||||
|
"""
|
||||||
|
Switch to a branch.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
directory: Root directory
|
||||||
|
name: Branch name
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if successful
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_current_branch(self, directory: Path) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Get the current branch name.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
directory: Root directory
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Branch name or None if detached
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def has_changes(self, directory: Path) -> bool:
|
||||||
|
"""
|
||||||
|
Check if there are uncommitted changes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
directory: Root directory
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if there are uncommitted changes
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class IHistoryQuery(ABC):
|
||||||
|
"""
|
||||||
|
Abstract interface for history queries.
|
||||||
|
|
||||||
|
Provides higher-level query operations on top of the backend.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_history(
|
||||||
|
self,
|
||||||
|
space_id: str,
|
||||||
|
limit: int = 50,
|
||||||
|
offset: int = 0,
|
||||||
|
path: Optional[str] = None,
|
||||||
|
) -> List[HistoryEntry]:
|
||||||
|
"""
|
||||||
|
Get history entries for a space.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
space_id: Space identifier
|
||||||
|
limit: Maximum entries
|
||||||
|
offset: Skip count
|
||||||
|
path: Filter by document path
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of history entries
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_version_content(
|
||||||
|
self,
|
||||||
|
space_id: str,
|
||||||
|
document_path: str,
|
||||||
|
version: str,
|
||||||
|
) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Get document content at a specific version.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
space_id: Space identifier
|
||||||
|
document_path: Path to document
|
||||||
|
version: Version identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Document content or None
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def compare_versions(
|
||||||
|
self,
|
||||||
|
space_id: str,
|
||||||
|
document_path: str,
|
||||||
|
old_version: str,
|
||||||
|
new_version: Optional[str] = None,
|
||||||
|
) -> Optional[DiffResult]:
|
||||||
|
"""
|
||||||
|
Compare two versions of a document.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
space_id: Space identifier
|
||||||
|
document_path: Path to document
|
||||||
|
old_version: Old version ID
|
||||||
|
new_version: New version ID (None for current)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Diff result or None
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def search_history(
|
||||||
|
self,
|
||||||
|
space_id: str,
|
||||||
|
query: str,
|
||||||
|
limit: int = 20,
|
||||||
|
) -> List[HistoryEntry]:
|
||||||
|
"""
|
||||||
|
Search commit messages.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
space_id: Space identifier
|
||||||
|
query: Search query
|
||||||
|
limit: Maximum results
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Matching history entries
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def restore_version(
|
||||||
|
self,
|
||||||
|
space_id: str,
|
||||||
|
document_path: str,
|
||||||
|
version: str,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Restore a document to a specific version.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
space_id: Space identifier
|
||||||
|
document_path: Path to document
|
||||||
|
version: Version to restore
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if successful
|
||||||
|
"""
|
||||||
|
pass
|
||||||
257
markitect/spaces/history/models.py
Normal file
257
markitect/spaces/history/models.py
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
"""
|
||||||
|
Domain models for history tracking.
|
||||||
|
|
||||||
|
Defines data structures for commits, branches, diffs, and history entries.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Dict, Any, List, Optional
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
class DiffType(Enum):
|
||||||
|
"""Type of diff line."""
|
||||||
|
CONTEXT = "context" # Unchanged line
|
||||||
|
ADDITION = "addition" # Added line
|
||||||
|
DELETION = "deletion" # Removed line
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DiffLine:
|
||||||
|
"""
|
||||||
|
A single line in a diff.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
line_type: Type of line (context, addition, deletion)
|
||||||
|
content: Line content
|
||||||
|
old_line_no: Line number in old version (None for additions)
|
||||||
|
new_line_no: Line number in new version (None for deletions)
|
||||||
|
"""
|
||||||
|
line_type: DiffType
|
||||||
|
content: str
|
||||||
|
old_line_no: Optional[int] = None
|
||||||
|
new_line_no: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DiffResult:
|
||||||
|
"""
|
||||||
|
Result of comparing two versions.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
path: File path being compared
|
||||||
|
old_version: Old version identifier
|
||||||
|
new_version: New version identifier
|
||||||
|
lines: List of diff lines
|
||||||
|
additions: Number of lines added
|
||||||
|
deletions: Number of lines deleted
|
||||||
|
is_binary: Whether the file is binary
|
||||||
|
"""
|
||||||
|
path: str
|
||||||
|
old_version: Optional[str] = None
|
||||||
|
new_version: Optional[str] = None
|
||||||
|
lines: List[DiffLine] = field(default_factory=list)
|
||||||
|
additions: int = 0
|
||||||
|
deletions: int = 0
|
||||||
|
is_binary: bool = False
|
||||||
|
|
||||||
|
def to_unified_diff(self) -> str:
|
||||||
|
"""Generate unified diff format string."""
|
||||||
|
result = []
|
||||||
|
result.append(f"--- a/{self.path}")
|
||||||
|
result.append(f"+++ b/{self.path}")
|
||||||
|
|
||||||
|
for line in self.lines:
|
||||||
|
if line.line_type == DiffType.ADDITION:
|
||||||
|
result.append(f"+{line.content}")
|
||||||
|
elif line.line_type == DiffType.DELETION:
|
||||||
|
result.append(f"-{line.content}")
|
||||||
|
else:
|
||||||
|
result.append(f" {line.content}")
|
||||||
|
|
||||||
|
return "\n".join(result)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Commit:
|
||||||
|
"""
|
||||||
|
Represents a commit in the history.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
id: Commit identifier (hash)
|
||||||
|
message: Commit message
|
||||||
|
author: Author name/email
|
||||||
|
timestamp: Commit timestamp
|
||||||
|
parent_ids: Parent commit IDs
|
||||||
|
space_id: Associated space ID
|
||||||
|
files_changed: List of changed file paths
|
||||||
|
metadata: Additional commit metadata
|
||||||
|
"""
|
||||||
|
id: str
|
||||||
|
message: str
|
||||||
|
author: str = "markitect"
|
||||||
|
timestamp: datetime = field(default_factory=datetime.now)
|
||||||
|
parent_ids: List[str] = field(default_factory=list)
|
||||||
|
space_id: Optional[str] = None
|
||||||
|
files_changed: List[str] = field(default_factory=list)
|
||||||
|
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
"""Convert to dictionary."""
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"message": self.message,
|
||||||
|
"author": self.author,
|
||||||
|
"timestamp": self.timestamp.isoformat(),
|
||||||
|
"parent_ids": self.parent_ids,
|
||||||
|
"space_id": self.space_id,
|
||||||
|
"files_changed": self.files_changed,
|
||||||
|
"metadata": self.metadata,
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: Dict[str, Any]) -> "Commit":
|
||||||
|
"""Create from dictionary."""
|
||||||
|
timestamp = data.get("timestamp")
|
||||||
|
if isinstance(timestamp, str):
|
||||||
|
timestamp = datetime.fromisoformat(timestamp)
|
||||||
|
elif timestamp is None:
|
||||||
|
timestamp = datetime.now()
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
id=data["id"],
|
||||||
|
message=data.get("message", ""),
|
||||||
|
author=data.get("author", "markitect"),
|
||||||
|
timestamp=timestamp,
|
||||||
|
parent_ids=data.get("parent_ids", []),
|
||||||
|
space_id=data.get("space_id"),
|
||||||
|
files_changed=data.get("files_changed", []),
|
||||||
|
metadata=data.get("metadata", {}),
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def short_id(self) -> str:
|
||||||
|
"""Get short commit ID (first 7 chars)."""
|
||||||
|
return self.id[:7] if len(self.id) >= 7 else self.id
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Branch:
|
||||||
|
"""
|
||||||
|
Represents a branch in the history.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
name: Branch name
|
||||||
|
head_commit_id: Current commit ID
|
||||||
|
is_current: Whether this is the current branch
|
||||||
|
tracking: Remote tracking branch (if any)
|
||||||
|
created_at: When the branch was created
|
||||||
|
"""
|
||||||
|
name: str
|
||||||
|
head_commit_id: str
|
||||||
|
is_current: bool = False
|
||||||
|
tracking: Optional[str] = None
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
"""Convert to dictionary."""
|
||||||
|
return {
|
||||||
|
"name": self.name,
|
||||||
|
"head_commit_id": self.head_commit_id,
|
||||||
|
"is_current": self.is_current,
|
||||||
|
"tracking": self.tracking,
|
||||||
|
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class HistoryEntry:
|
||||||
|
"""
|
||||||
|
A single entry in the history log.
|
||||||
|
|
||||||
|
Combines commit information with change summary.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
commit: The commit
|
||||||
|
summary: Short summary of changes
|
||||||
|
files_added: Number of files added
|
||||||
|
files_modified: Number of files modified
|
||||||
|
files_deleted: Number of files deleted
|
||||||
|
"""
|
||||||
|
commit: Commit
|
||||||
|
summary: str = ""
|
||||||
|
files_added: int = 0
|
||||||
|
files_modified: int = 0
|
||||||
|
files_deleted: int = 0
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
"""Convert to dictionary."""
|
||||||
|
return {
|
||||||
|
"commit": self.commit.to_dict(),
|
||||||
|
"summary": self.summary,
|
||||||
|
"files_added": self.files_added,
|
||||||
|
"files_modified": self.files_modified,
|
||||||
|
"files_deleted": self.files_deleted,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class HistoryConfig:
|
||||||
|
"""
|
||||||
|
Configuration for history tracking.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
enabled: Whether history tracking is enabled
|
||||||
|
backend: Backend type ("git" or "sqlite")
|
||||||
|
auto_commit: Whether to auto-commit on changes
|
||||||
|
commit_on_events: List of event types that trigger commits
|
||||||
|
author_name: Default author name
|
||||||
|
author_email: Default author email
|
||||||
|
directory: Canonical directory path (for git)
|
||||||
|
options: Additional backend-specific options
|
||||||
|
"""
|
||||||
|
enabled: bool = False
|
||||||
|
backend: str = "git"
|
||||||
|
auto_commit: bool = True
|
||||||
|
commit_on_events: List[str] = field(default_factory=lambda: [
|
||||||
|
"DOCUMENT_ADDED",
|
||||||
|
"DOCUMENT_REMOVED",
|
||||||
|
"DOCUMENT_CONTENT_CHANGED",
|
||||||
|
])
|
||||||
|
author_name: str = "markitect"
|
||||||
|
author_email: str = "markitect@local"
|
||||||
|
directory: Optional[str] = None
|
||||||
|
options: Dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
"""Convert to dictionary."""
|
||||||
|
return {
|
||||||
|
"enabled": self.enabled,
|
||||||
|
"backend": self.backend,
|
||||||
|
"auto_commit": self.auto_commit,
|
||||||
|
"commit_on_events": self.commit_on_events,
|
||||||
|
"author_name": self.author_name,
|
||||||
|
"author_email": self.author_email,
|
||||||
|
"directory": self.directory,
|
||||||
|
"options": self.options,
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: Dict[str, Any]) -> "HistoryConfig":
|
||||||
|
"""Create from dictionary."""
|
||||||
|
return cls(
|
||||||
|
enabled=data.get("enabled", False),
|
||||||
|
backend=data.get("backend", "git"),
|
||||||
|
auto_commit=data.get("auto_commit", True),
|
||||||
|
commit_on_events=data.get("commit_on_events", [
|
||||||
|
"DOCUMENT_ADDED",
|
||||||
|
"DOCUMENT_REMOVED",
|
||||||
|
"DOCUMENT_CONTENT_CHANGED",
|
||||||
|
]),
|
||||||
|
author_name=data.get("author_name", "markitect"),
|
||||||
|
author_email=data.get("author_email", "markitect@local"),
|
||||||
|
directory=data.get("directory"),
|
||||||
|
options=data.get("options", {}),
|
||||||
|
)
|
||||||
258
markitect/spaces/history/queries.py
Normal file
258
markitect/spaces/history/queries.py
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
"""
|
||||||
|
History query service implementation.
|
||||||
|
|
||||||
|
Provides high-level queries on top of the history backend.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Optional, Dict, Callable
|
||||||
|
|
||||||
|
from .interfaces import IHistoryBackend, IHistoryQuery
|
||||||
|
from .models import HistoryEntry, DiffResult, Commit
|
||||||
|
|
||||||
|
|
||||||
|
class HistoryQueryService(IHistoryQuery):
|
||||||
|
"""
|
||||||
|
Service for querying history information.
|
||||||
|
|
||||||
|
Provides convenience methods on top of the raw history backend.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
backend: IHistoryBackend,
|
||||||
|
directory_resolver: Callable[[str], Optional[Path]],
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize the query service.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
backend: History backend to query
|
||||||
|
directory_resolver: Function that resolves space_id to directory path
|
||||||
|
"""
|
||||||
|
self._backend = backend
|
||||||
|
self._directory_resolver = directory_resolver
|
||||||
|
|
||||||
|
def get_history(
|
||||||
|
self,
|
||||||
|
space_id: str,
|
||||||
|
limit: int = 50,
|
||||||
|
offset: int = 0,
|
||||||
|
path: Optional[str] = None,
|
||||||
|
) -> List[HistoryEntry]:
|
||||||
|
"""Get history entries for a space."""
|
||||||
|
directory = self._directory_resolver(space_id)
|
||||||
|
if not directory or not self._backend.is_initialized(directory):
|
||||||
|
return []
|
||||||
|
|
||||||
|
commits = self._backend.get_log(directory, limit, offset, path)
|
||||||
|
return [self._commit_to_entry(commit, directory) for commit in commits]
|
||||||
|
|
||||||
|
def _commit_to_entry(self, commit: Commit, directory: Path) -> HistoryEntry:
|
||||||
|
"""Convert a commit to a history entry with summary."""
|
||||||
|
# Count file changes by type
|
||||||
|
files_added = 0
|
||||||
|
files_modified = 0
|
||||||
|
files_deleted = 0
|
||||||
|
|
||||||
|
# Get diff to determine change types
|
||||||
|
if commit.parent_ids:
|
||||||
|
parent = commit.parent_ids[0]
|
||||||
|
diffs = self._backend.get_diff(directory, parent, commit.id)
|
||||||
|
for diff in diffs:
|
||||||
|
if diff.additions > 0 and diff.deletions == 0:
|
||||||
|
files_added += 1
|
||||||
|
elif diff.additions == 0 and diff.deletions > 0:
|
||||||
|
files_deleted += 1
|
||||||
|
else:
|
||||||
|
files_modified += 1
|
||||||
|
else:
|
||||||
|
# First commit - all files are additions
|
||||||
|
files_added = len(commit.files_changed)
|
||||||
|
|
||||||
|
# Build summary
|
||||||
|
parts = []
|
||||||
|
if files_added:
|
||||||
|
parts.append(f"+{files_added}")
|
||||||
|
if files_modified:
|
||||||
|
parts.append(f"~{files_modified}")
|
||||||
|
if files_deleted:
|
||||||
|
parts.append(f"-{files_deleted}")
|
||||||
|
|
||||||
|
summary = ", ".join(parts) if parts else "No changes"
|
||||||
|
|
||||||
|
return HistoryEntry(
|
||||||
|
commit=commit,
|
||||||
|
summary=summary,
|
||||||
|
files_added=files_added,
|
||||||
|
files_modified=files_modified,
|
||||||
|
files_deleted=files_deleted,
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_version_content(
|
||||||
|
self,
|
||||||
|
space_id: str,
|
||||||
|
document_path: str,
|
||||||
|
version: str,
|
||||||
|
) -> Optional[str]:
|
||||||
|
"""Get document content at a specific version."""
|
||||||
|
directory = self._directory_resolver(space_id)
|
||||||
|
if not directory or not self._backend.is_initialized(directory):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Normalize path (remove leading /)
|
||||||
|
if document_path.startswith("/"):
|
||||||
|
document_path = document_path[1:]
|
||||||
|
|
||||||
|
return self._backend.get_file_at_version(directory, document_path, version)
|
||||||
|
|
||||||
|
def compare_versions(
|
||||||
|
self,
|
||||||
|
space_id: str,
|
||||||
|
document_path: str,
|
||||||
|
old_version: str,
|
||||||
|
new_version: Optional[str] = None,
|
||||||
|
) -> Optional[DiffResult]:
|
||||||
|
"""Compare two versions of a document."""
|
||||||
|
directory = self._directory_resolver(space_id)
|
||||||
|
if not directory or not self._backend.is_initialized(directory):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Normalize path
|
||||||
|
if document_path.startswith("/"):
|
||||||
|
document_path = document_path[1:]
|
||||||
|
|
||||||
|
diffs = self._backend.get_diff(directory, old_version, new_version, document_path)
|
||||||
|
return diffs[0] if diffs else None
|
||||||
|
|
||||||
|
def search_history(
|
||||||
|
self,
|
||||||
|
space_id: str,
|
||||||
|
query: str,
|
||||||
|
limit: int = 20,
|
||||||
|
) -> List[HistoryEntry]:
|
||||||
|
"""Search commit messages."""
|
||||||
|
directory = self._directory_resolver(space_id)
|
||||||
|
if not directory or not self._backend.is_initialized(directory):
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Get all recent commits
|
||||||
|
commits = self._backend.get_log(directory, limit=limit * 2)
|
||||||
|
|
||||||
|
# Filter by query in message
|
||||||
|
matching_commits = [
|
||||||
|
c for c in commits
|
||||||
|
if query.lower() in c.message.lower()
|
||||||
|
][:limit]
|
||||||
|
|
||||||
|
return [self._commit_to_entry(commit, directory) for commit in matching_commits]
|
||||||
|
|
||||||
|
def restore_version(
|
||||||
|
self,
|
||||||
|
space_id: str,
|
||||||
|
document_path: str,
|
||||||
|
version: str,
|
||||||
|
) -> bool:
|
||||||
|
"""Restore a document to a specific version."""
|
||||||
|
directory = self._directory_resolver(space_id)
|
||||||
|
if not directory or not self._backend.is_initialized(directory):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Normalize path
|
||||||
|
if document_path.startswith("/"):
|
||||||
|
document_path = document_path[1:]
|
||||||
|
|
||||||
|
# Get content at version
|
||||||
|
content = self._backend.get_file_at_version(directory, document_path, version)
|
||||||
|
if content is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Write to working directory
|
||||||
|
file_path = directory / document_path
|
||||||
|
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
file_path.write_text(content)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def get_latest_commit(self, space_id: str) -> Optional[Commit]:
|
||||||
|
"""Get the latest commit for a space."""
|
||||||
|
directory = self._directory_resolver(space_id)
|
||||||
|
if not directory or not self._backend.is_initialized(directory):
|
||||||
|
return None
|
||||||
|
|
||||||
|
commits = self._backend.get_log(directory, limit=1)
|
||||||
|
return commits[0] if commits else None
|
||||||
|
|
||||||
|
def get_commit_by_id(self, space_id: str, commit_id: str) -> Optional[Commit]:
|
||||||
|
"""Get a specific commit by ID."""
|
||||||
|
directory = self._directory_resolver(space_id)
|
||||||
|
if not directory:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return self._backend.get_commit(directory, commit_id)
|
||||||
|
|
||||||
|
def get_file_history(
|
||||||
|
self,
|
||||||
|
space_id: str,
|
||||||
|
document_path: str,
|
||||||
|
limit: int = 20,
|
||||||
|
) -> List[HistoryEntry]:
|
||||||
|
"""Get history for a specific file."""
|
||||||
|
# Normalize path
|
||||||
|
if document_path.startswith("/"):
|
||||||
|
document_path = document_path[1:]
|
||||||
|
|
||||||
|
return self.get_history(space_id, limit=limit, path=document_path)
|
||||||
|
|
||||||
|
def has_history(self, space_id: str) -> bool:
|
||||||
|
"""Check if a space has history tracking enabled."""
|
||||||
|
directory = self._directory_resolver(space_id)
|
||||||
|
if not directory:
|
||||||
|
return False
|
||||||
|
return self._backend.is_initialized(directory)
|
||||||
|
|
||||||
|
def get_branches(self, space_id: str) -> List[str]:
|
||||||
|
"""Get list of branch names for a space."""
|
||||||
|
directory = self._directory_resolver(space_id)
|
||||||
|
if not directory or not self._backend.is_initialized(directory):
|
||||||
|
return []
|
||||||
|
|
||||||
|
branches = self._backend.list_branches(directory)
|
||||||
|
return [b.name for b in branches]
|
||||||
|
|
||||||
|
def get_current_branch(self, space_id: str) -> Optional[str]:
|
||||||
|
"""Get the current branch name."""
|
||||||
|
directory = self._directory_resolver(space_id)
|
||||||
|
if not directory:
|
||||||
|
return None
|
||||||
|
return self._backend.get_current_branch(directory)
|
||||||
|
|
||||||
|
def create_branch(
|
||||||
|
self,
|
||||||
|
space_id: str,
|
||||||
|
name: str,
|
||||||
|
start_point: Optional[str] = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Create a new branch."""
|
||||||
|
directory = self._directory_resolver(space_id)
|
||||||
|
if not directory or not self._backend.is_initialized(directory):
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._backend.create_branch(directory, name, start_point)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def switch_branch(self, space_id: str, name: str) -> bool:
|
||||||
|
"""Switch to a different branch."""
|
||||||
|
directory = self._directory_resolver(space_id)
|
||||||
|
if not directory:
|
||||||
|
return False
|
||||||
|
return self._backend.switch_branch(directory, name)
|
||||||
|
|
||||||
|
def has_uncommitted_changes(self, space_id: str) -> bool:
|
||||||
|
"""Check if space has uncommitted changes."""
|
||||||
|
directory = self._directory_resolver(space_id)
|
||||||
|
if not directory:
|
||||||
|
return False
|
||||||
|
return self._backend.has_changes(directory)
|
||||||
838
tests/unit/spaces/test_history.py
Normal file
838
tests/unit/spaces/test_history.py
Normal file
@@ -0,0 +1,838 @@
|
|||||||
|
"""
|
||||||
|
Tests for Phase 8: History Tracking.
|
||||||
|
|
||||||
|
Tests git-based version control, event-driven commits, and history queries.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import tempfile
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from markitect.spaces.history import (
|
||||||
|
# Models
|
||||||
|
Commit,
|
||||||
|
Branch,
|
||||||
|
HistoryEntry,
|
||||||
|
DiffResult,
|
||||||
|
DiffLine,
|
||||||
|
DiffType,
|
||||||
|
HistoryConfig,
|
||||||
|
# Backend
|
||||||
|
GitHistoryBackend,
|
||||||
|
GitError,
|
||||||
|
# Events
|
||||||
|
GitHistoryEventHandler,
|
||||||
|
HistoryEventCoordinator,
|
||||||
|
# Queries
|
||||||
|
HistoryQueryService,
|
||||||
|
)
|
||||||
|
from markitect.spaces import (
|
||||||
|
SpaceEvent,
|
||||||
|
SpaceEventType,
|
||||||
|
EventBus,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# Fixtures
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def check_git_available() -> bool:
|
||||||
|
"""Check if git is available."""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "--version"],
|
||||||
|
capture_output=True,
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
return result.returncode == 0
|
||||||
|
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# Skip all tests if git is not available
|
||||||
|
pytestmark = pytest.mark.skipif(
|
||||||
|
not check_git_available(),
|
||||||
|
reason="git not available"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def temp_dir():
|
||||||
|
"""Create a temporary directory."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
yield Path(tmpdir)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def git_backend():
|
||||||
|
"""Create a git backend instance."""
|
||||||
|
return GitHistoryBackend()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def history_config():
|
||||||
|
"""Create a history configuration."""
|
||||||
|
return HistoryConfig(
|
||||||
|
enabled=True,
|
||||||
|
backend="git",
|
||||||
|
auto_commit=True,
|
||||||
|
author_name="Test User",
|
||||||
|
author_email="test@example.com",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def initialized_repo(temp_dir, git_backend, history_config):
|
||||||
|
"""Create an initialized git repository."""
|
||||||
|
git_backend.initialize(temp_dir, history_config)
|
||||||
|
return temp_dir
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def event_bus():
|
||||||
|
"""Create an event bus."""
|
||||||
|
return EventBus()
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# HistoryConfig Tests
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestHistoryConfig:
|
||||||
|
"""Tests for HistoryConfig model."""
|
||||||
|
|
||||||
|
def test_default_config(self):
|
||||||
|
"""Test default configuration."""
|
||||||
|
config = HistoryConfig()
|
||||||
|
assert config.enabled is False
|
||||||
|
assert config.backend == "git"
|
||||||
|
assert config.auto_commit is True
|
||||||
|
assert config.author_name == "markitect"
|
||||||
|
|
||||||
|
def test_config_with_custom_values(self):
|
||||||
|
"""Test configuration with custom values."""
|
||||||
|
config = HistoryConfig(
|
||||||
|
enabled=True,
|
||||||
|
backend="git",
|
||||||
|
author_name="John Doe",
|
||||||
|
author_email="john@example.com",
|
||||||
|
directory="/path/to/space",
|
||||||
|
)
|
||||||
|
assert config.enabled is True
|
||||||
|
assert config.author_name == "John Doe"
|
||||||
|
assert config.directory == "/path/to/space"
|
||||||
|
|
||||||
|
def test_config_serialization(self):
|
||||||
|
"""Test config to/from dict."""
|
||||||
|
config = HistoryConfig(
|
||||||
|
enabled=True,
|
||||||
|
author_name="Test",
|
||||||
|
commit_on_events=["DOCUMENT_ADDED"],
|
||||||
|
)
|
||||||
|
data = config.to_dict()
|
||||||
|
restored = HistoryConfig.from_dict(data)
|
||||||
|
assert restored.enabled == config.enabled
|
||||||
|
assert restored.author_name == config.author_name
|
||||||
|
assert restored.commit_on_events == config.commit_on_events
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# Commit Model Tests
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestCommitModel:
|
||||||
|
"""Tests for Commit model."""
|
||||||
|
|
||||||
|
def test_create_commit(self):
|
||||||
|
"""Test creating a commit."""
|
||||||
|
commit = Commit(
|
||||||
|
id="abc123",
|
||||||
|
message="Initial commit",
|
||||||
|
author="Test User <test@example.com>",
|
||||||
|
)
|
||||||
|
assert commit.id == "abc123"
|
||||||
|
assert commit.message == "Initial commit"
|
||||||
|
assert commit.short_id == "abc123"
|
||||||
|
|
||||||
|
def test_commit_serialization(self):
|
||||||
|
"""Test commit to/from dict."""
|
||||||
|
commit = Commit(
|
||||||
|
id="abc123def456",
|
||||||
|
message="Test commit",
|
||||||
|
author="Test <test@example.com>",
|
||||||
|
files_changed=["file1.md", "file2.md"],
|
||||||
|
metadata={"key": "value"},
|
||||||
|
)
|
||||||
|
data = commit.to_dict()
|
||||||
|
restored = Commit.from_dict(data)
|
||||||
|
assert restored.id == commit.id
|
||||||
|
assert restored.message == commit.message
|
||||||
|
assert restored.files_changed == commit.files_changed
|
||||||
|
assert restored.metadata == commit.metadata
|
||||||
|
|
||||||
|
def test_short_id(self):
|
||||||
|
"""Test short commit ID."""
|
||||||
|
commit = Commit(id="abcdef1234567890", message="Test")
|
||||||
|
assert commit.short_id == "abcdef1"
|
||||||
|
|
||||||
|
# Short ID for short hash
|
||||||
|
short_commit = Commit(id="abc", message="Test")
|
||||||
|
assert short_commit.short_id == "abc"
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# DiffResult Tests
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestDiffResult:
|
||||||
|
"""Tests for DiffResult model."""
|
||||||
|
|
||||||
|
def test_create_diff(self):
|
||||||
|
"""Test creating a diff result."""
|
||||||
|
diff = DiffResult(
|
||||||
|
path="test.md",
|
||||||
|
old_version="v1",
|
||||||
|
new_version="v2",
|
||||||
|
)
|
||||||
|
assert diff.path == "test.md"
|
||||||
|
assert diff.additions == 0
|
||||||
|
assert diff.deletions == 0
|
||||||
|
|
||||||
|
def test_unified_diff_format(self):
|
||||||
|
"""Test unified diff generation."""
|
||||||
|
diff = DiffResult(path="test.md")
|
||||||
|
diff.lines = [
|
||||||
|
DiffLine(DiffType.CONTEXT, "unchanged line", 1, 1),
|
||||||
|
DiffLine(DiffType.ADDITION, "added line", None, 2),
|
||||||
|
DiffLine(DiffType.DELETION, "removed line", 2, None),
|
||||||
|
]
|
||||||
|
diff.additions = 1
|
||||||
|
diff.deletions = 1
|
||||||
|
|
||||||
|
unified = diff.to_unified_diff()
|
||||||
|
assert "--- a/test.md" in unified
|
||||||
|
assert "+++ b/test.md" in unified
|
||||||
|
assert "+added line" in unified
|
||||||
|
assert "-removed line" in unified
|
||||||
|
assert " unchanged line" in unified
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# GitHistoryBackend Tests
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestGitHistoryBackend:
|
||||||
|
"""Tests for GitHistoryBackend."""
|
||||||
|
|
||||||
|
def test_initialize_creates_repo(self, temp_dir, git_backend, history_config):
|
||||||
|
"""Test initializing a git repository."""
|
||||||
|
git_backend.initialize(temp_dir, history_config)
|
||||||
|
assert (temp_dir / ".git").exists()
|
||||||
|
assert git_backend.is_initialized(temp_dir)
|
||||||
|
|
||||||
|
def test_initialize_idempotent(self, temp_dir, git_backend, history_config):
|
||||||
|
"""Test that initialize can be called multiple times."""
|
||||||
|
git_backend.initialize(temp_dir, history_config)
|
||||||
|
git_backend.initialize(temp_dir, history_config)
|
||||||
|
assert git_backend.is_initialized(temp_dir)
|
||||||
|
|
||||||
|
def test_is_initialized_false_for_non_repo(self, temp_dir, git_backend):
|
||||||
|
"""Test is_initialized returns False for non-repo."""
|
||||||
|
assert not git_backend.is_initialized(temp_dir)
|
||||||
|
|
||||||
|
def test_commit_creates_commit(self, initialized_repo, git_backend):
|
||||||
|
"""Test creating a commit."""
|
||||||
|
# Create a file
|
||||||
|
test_file = initialized_repo / "test.md"
|
||||||
|
test_file.write_text("# Test")
|
||||||
|
|
||||||
|
# Commit
|
||||||
|
commit = git_backend.commit(
|
||||||
|
initialized_repo,
|
||||||
|
"Add test file",
|
||||||
|
author="Test User <test@example.com>",
|
||||||
|
)
|
||||||
|
assert commit.message == "Add test file"
|
||||||
|
assert commit.id is not None
|
||||||
|
assert "test.md" in commit.files_changed
|
||||||
|
|
||||||
|
def test_commit_with_metadata(self, initialized_repo, git_backend):
|
||||||
|
"""Test commit with metadata trailers."""
|
||||||
|
test_file = initialized_repo / "doc.md"
|
||||||
|
test_file.write_text("Content")
|
||||||
|
|
||||||
|
commit = git_backend.commit(
|
||||||
|
initialized_repo,
|
||||||
|
"Add doc",
|
||||||
|
metadata={"space_id": "space-1", "event_type": "test"},
|
||||||
|
)
|
||||||
|
# Git trailers are stored as "key: value" in commit body
|
||||||
|
# They should be parseable
|
||||||
|
assert commit.message == "Add doc"
|
||||||
|
assert commit.id is not None
|
||||||
|
# Metadata parsing from git trailers may vary by git version
|
||||||
|
# Just verify commit succeeds
|
||||||
|
|
||||||
|
def test_commit_specific_files(self, initialized_repo, git_backend):
|
||||||
|
"""Test committing specific files."""
|
||||||
|
file1 = initialized_repo / "file1.md"
|
||||||
|
file2 = initialized_repo / "file2.md"
|
||||||
|
file1.write_text("File 1")
|
||||||
|
file2.write_text("File 2")
|
||||||
|
|
||||||
|
# Commit only file1
|
||||||
|
commit = git_backend.commit(
|
||||||
|
initialized_repo,
|
||||||
|
"Add file1",
|
||||||
|
files=["file1.md"],
|
||||||
|
)
|
||||||
|
assert "file1.md" in commit.files_changed
|
||||||
|
|
||||||
|
# file2 should still be uncommitted
|
||||||
|
assert git_backend.has_changes(initialized_repo)
|
||||||
|
|
||||||
|
def test_commit_no_changes_raises_error(self, initialized_repo, git_backend):
|
||||||
|
"""Test that committing with no changes raises error."""
|
||||||
|
with pytest.raises(GitError, match="No changes"):
|
||||||
|
git_backend.commit(initialized_repo, "Empty commit")
|
||||||
|
|
||||||
|
def test_get_commit(self, initialized_repo, git_backend):
|
||||||
|
"""Test getting a commit by ID."""
|
||||||
|
test_file = initialized_repo / "test.md"
|
||||||
|
test_file.write_text("Content")
|
||||||
|
|
||||||
|
commit = git_backend.commit(initialized_repo, "Test commit")
|
||||||
|
|
||||||
|
retrieved = git_backend.get_commit(initialized_repo, commit.id)
|
||||||
|
assert retrieved is not None
|
||||||
|
assert retrieved.id == commit.id
|
||||||
|
assert retrieved.message == "Test commit"
|
||||||
|
|
||||||
|
def test_get_commit_not_found(self, initialized_repo, git_backend):
|
||||||
|
"""Test getting non-existent commit returns None."""
|
||||||
|
result = git_backend.get_commit(initialized_repo, "nonexistent")
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_get_log(self, initialized_repo, git_backend):
|
||||||
|
"""Test getting commit history."""
|
||||||
|
# Create multiple commits
|
||||||
|
for i in range(3):
|
||||||
|
file = initialized_repo / f"file{i}.md"
|
||||||
|
file.write_text(f"File {i}")
|
||||||
|
git_backend.commit(initialized_repo, f"Commit {i}")
|
||||||
|
|
||||||
|
# Get log
|
||||||
|
commits = git_backend.get_log(initialized_repo, limit=10)
|
||||||
|
# Should have initial commit + 3 new commits
|
||||||
|
assert len(commits) >= 3
|
||||||
|
assert commits[0].message == "Commit 2" # Most recent first
|
||||||
|
assert commits[1].message == "Commit 1"
|
||||||
|
|
||||||
|
def test_get_log_with_limit(self, initialized_repo, git_backend):
|
||||||
|
"""Test log with limit."""
|
||||||
|
for i in range(5):
|
||||||
|
file = initialized_repo / f"file{i}.md"
|
||||||
|
file.write_text(f"File {i}")
|
||||||
|
git_backend.commit(initialized_repo, f"Commit {i}")
|
||||||
|
|
||||||
|
commits = git_backend.get_log(initialized_repo, limit=2)
|
||||||
|
assert len(commits) == 2
|
||||||
|
|
||||||
|
def test_get_log_with_path_filter(self, initialized_repo, git_backend):
|
||||||
|
"""Test log filtered by path."""
|
||||||
|
file1 = initialized_repo / "file1.md"
|
||||||
|
file2 = initialized_repo / "file2.md"
|
||||||
|
|
||||||
|
file1.write_text("File 1")
|
||||||
|
git_backend.commit(initialized_repo, "Add file1")
|
||||||
|
|
||||||
|
file2.write_text("File 2")
|
||||||
|
git_backend.commit(initialized_repo, "Add file2")
|
||||||
|
|
||||||
|
file1.write_text("File 1 updated")
|
||||||
|
git_backend.commit(initialized_repo, "Update file1")
|
||||||
|
|
||||||
|
# Get log for file1 only
|
||||||
|
commits = git_backend.get_log(initialized_repo, path="file1.md")
|
||||||
|
# Should have 2 commits for file1 (add and update)
|
||||||
|
assert len(commits) >= 2
|
||||||
|
assert all("file1" in c.message for c in commits[:2])
|
||||||
|
|
||||||
|
def test_get_diff(self, initialized_repo, git_backend):
|
||||||
|
"""Test getting diff between commits."""
|
||||||
|
file = initialized_repo / "test.md"
|
||||||
|
file.write_text("Line 1\nLine 2\n")
|
||||||
|
commit1 = git_backend.commit(initialized_repo, "Initial")
|
||||||
|
|
||||||
|
file.write_text("Line 1\nLine 2 modified\nLine 3\n")
|
||||||
|
commit2 = git_backend.commit(initialized_repo, "Update")
|
||||||
|
|
||||||
|
diffs = git_backend.get_diff(initialized_repo, commit1.id, commit2.id)
|
||||||
|
assert len(diffs) > 0
|
||||||
|
diff = diffs[0]
|
||||||
|
assert diff.path == "test.md"
|
||||||
|
assert diff.additions > 0 or diff.deletions > 0
|
||||||
|
|
||||||
|
def test_checkout_version(self, initialized_repo, git_backend):
|
||||||
|
"""Test checking out a specific version."""
|
||||||
|
file = initialized_repo / "test.md"
|
||||||
|
file.write_text("Version 1")
|
||||||
|
commit1 = git_backend.commit(initialized_repo, "V1")
|
||||||
|
|
||||||
|
file.write_text("Version 2")
|
||||||
|
git_backend.commit(initialized_repo, "V2")
|
||||||
|
|
||||||
|
# Checkout file at commit1
|
||||||
|
success = git_backend.checkout(initialized_repo, commit1.id, "test.md")
|
||||||
|
assert success
|
||||||
|
assert file.read_text() == "Version 1"
|
||||||
|
|
||||||
|
def test_get_file_at_version(self, initialized_repo, git_backend):
|
||||||
|
"""Test getting file content at specific version."""
|
||||||
|
file = initialized_repo / "test.md"
|
||||||
|
file.write_text("Version 1")
|
||||||
|
commit1 = git_backend.commit(initialized_repo, "V1")
|
||||||
|
|
||||||
|
file.write_text("Version 2")
|
||||||
|
git_backend.commit(initialized_repo, "V2")
|
||||||
|
|
||||||
|
# Get content at commit1
|
||||||
|
content = git_backend.get_file_at_version(
|
||||||
|
initialized_repo, "test.md", commit1.id
|
||||||
|
)
|
||||||
|
assert content == "Version 1"
|
||||||
|
|
||||||
|
def test_get_file_at_version_not_found(self, initialized_repo, git_backend):
|
||||||
|
"""Test getting non-existent file returns None."""
|
||||||
|
content = git_backend.get_file_at_version(
|
||||||
|
initialized_repo, "nonexistent.md", "HEAD"
|
||||||
|
)
|
||||||
|
assert content is None
|
||||||
|
|
||||||
|
def test_list_branches(self, initialized_repo, git_backend):
|
||||||
|
"""Test listing branches."""
|
||||||
|
branches = git_backend.list_branches(initialized_repo)
|
||||||
|
assert len(branches) >= 1
|
||||||
|
# Should have at least main/master branch
|
||||||
|
assert any(b.name in ["main", "master"] for b in branches)
|
||||||
|
|
||||||
|
def test_create_branch(self, initialized_repo, git_backend):
|
||||||
|
"""Test creating a branch."""
|
||||||
|
# Create a commit first
|
||||||
|
file = initialized_repo / "test.md"
|
||||||
|
file.write_text("Content")
|
||||||
|
git_backend.commit(initialized_repo, "Add file")
|
||||||
|
|
||||||
|
branch = git_backend.create_branch(initialized_repo, "feature")
|
||||||
|
assert branch.name == "feature"
|
||||||
|
assert branch.head_commit_id is not None
|
||||||
|
|
||||||
|
branches = git_backend.list_branches(initialized_repo)
|
||||||
|
assert any(b.name == "feature" for b in branches)
|
||||||
|
|
||||||
|
def test_switch_branch(self, initialized_repo, git_backend):
|
||||||
|
"""Test switching branches."""
|
||||||
|
# Create commit and branch
|
||||||
|
file = initialized_repo / "test.md"
|
||||||
|
file.write_text("Content")
|
||||||
|
git_backend.commit(initialized_repo, "Add file")
|
||||||
|
git_backend.create_branch(initialized_repo, "feature")
|
||||||
|
|
||||||
|
# Switch to feature branch
|
||||||
|
success = git_backend.switch_branch(initialized_repo, "feature")
|
||||||
|
assert success
|
||||||
|
|
||||||
|
current = git_backend.get_current_branch(initialized_repo)
|
||||||
|
assert current == "feature"
|
||||||
|
|
||||||
|
def test_get_current_branch(self, initialized_repo, git_backend):
|
||||||
|
"""Test getting current branch."""
|
||||||
|
current = git_backend.get_current_branch(initialized_repo)
|
||||||
|
assert current in ["main", "master"] # Depends on git version
|
||||||
|
|
||||||
|
def test_has_changes(self, initialized_repo, git_backend):
|
||||||
|
"""Test checking for uncommitted changes."""
|
||||||
|
# No changes initially
|
||||||
|
assert not git_backend.has_changes(initialized_repo)
|
||||||
|
|
||||||
|
# Add a file
|
||||||
|
file = initialized_repo / "test.md"
|
||||||
|
file.write_text("Content")
|
||||||
|
|
||||||
|
# Should have changes now
|
||||||
|
assert git_backend.has_changes(initialized_repo)
|
||||||
|
|
||||||
|
# Commit
|
||||||
|
git_backend.commit(initialized_repo, "Add file")
|
||||||
|
|
||||||
|
# No changes after commit
|
||||||
|
assert not git_backend.has_changes(initialized_repo)
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# GitHistoryEventHandler Tests
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestGitHistoryEventHandler:
|
||||||
|
"""Tests for event-driven commits."""
|
||||||
|
|
||||||
|
def test_create_handler(self, git_backend, history_config):
|
||||||
|
"""Test creating an event handler."""
|
||||||
|
def resolver(space_id):
|
||||||
|
return Path(f"/tmp/{space_id}")
|
||||||
|
|
||||||
|
handler = GitHistoryEventHandler(git_backend, history_config, resolver)
|
||||||
|
assert handler is not None
|
||||||
|
|
||||||
|
def test_handler_registers_with_bus(
|
||||||
|
self, git_backend, history_config, event_bus
|
||||||
|
):
|
||||||
|
"""Test handler registration."""
|
||||||
|
def resolver(space_id):
|
||||||
|
return Path(f"/tmp/{space_id}")
|
||||||
|
|
||||||
|
handler = GitHistoryEventHandler(git_backend, history_config, resolver)
|
||||||
|
handler.register(event_bus)
|
||||||
|
|
||||||
|
# Should have registered handlers
|
||||||
|
assert len(event_bus._handlers) > 0
|
||||||
|
|
||||||
|
handler.unregister()
|
||||||
|
|
||||||
|
def test_handler_creates_commit_on_event(
|
||||||
|
self, temp_dir, git_backend, history_config, event_bus
|
||||||
|
):
|
||||||
|
"""Test that handler commits on document events."""
|
||||||
|
space_id = "test-space"
|
||||||
|
|
||||||
|
def resolver(sid):
|
||||||
|
return temp_dir if sid == space_id else None
|
||||||
|
|
||||||
|
# Initialize repo
|
||||||
|
git_backend.initialize(temp_dir, history_config)
|
||||||
|
|
||||||
|
# Create initial file
|
||||||
|
file = temp_dir / "doc.md"
|
||||||
|
file.write_text("Initial content")
|
||||||
|
git_backend.commit(temp_dir, "Initial commit")
|
||||||
|
|
||||||
|
# Create handler
|
||||||
|
handler = GitHistoryEventHandler(git_backend, history_config, resolver)
|
||||||
|
handler.register(event_bus)
|
||||||
|
|
||||||
|
# Modify file
|
||||||
|
file.write_text("Updated content")
|
||||||
|
|
||||||
|
# Emit event
|
||||||
|
event = SpaceEvent(
|
||||||
|
event_type=SpaceEventType.DOCUMENT_CONTENT_CHANGED,
|
||||||
|
space_id=space_id,
|
||||||
|
payload={"document_id": "doc-1", "space_path": "/doc.md"},
|
||||||
|
)
|
||||||
|
event_bus.emit(event)
|
||||||
|
|
||||||
|
# Should have created a new commit
|
||||||
|
commits = git_backend.get_log(temp_dir, limit=5)
|
||||||
|
assert len(commits) >= 2
|
||||||
|
assert "Update document" in commits[0].message
|
||||||
|
|
||||||
|
handler.unregister()
|
||||||
|
|
||||||
|
def test_handler_disabled_when_not_enabled(
|
||||||
|
self, temp_dir, git_backend, event_bus
|
||||||
|
):
|
||||||
|
"""Test handler does nothing when disabled."""
|
||||||
|
config = HistoryConfig(enabled=False)
|
||||||
|
space_id = "test-space"
|
||||||
|
|
||||||
|
def resolver(sid):
|
||||||
|
return temp_dir if sid == space_id else None
|
||||||
|
|
||||||
|
handler = GitHistoryEventHandler(git_backend, config, resolver)
|
||||||
|
handler.register(event_bus)
|
||||||
|
|
||||||
|
# Initialize repo manually
|
||||||
|
git_backend.initialize(temp_dir, config)
|
||||||
|
|
||||||
|
file = temp_dir / "doc.md"
|
||||||
|
file.write_text("Content")
|
||||||
|
|
||||||
|
# Emit event - should not commit since disabled
|
||||||
|
event = SpaceEvent(
|
||||||
|
event_type=SpaceEventType.DOCUMENT_ADDED,
|
||||||
|
space_id=space_id,
|
||||||
|
payload={"space_path": "/doc.md"},
|
||||||
|
)
|
||||||
|
event_bus.emit(event)
|
||||||
|
|
||||||
|
# Should still have uncommitted changes
|
||||||
|
assert git_backend.has_changes(temp_dir)
|
||||||
|
|
||||||
|
handler.unregister()
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# HistoryQueryService Tests
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestHistoryQueryService:
|
||||||
|
"""Tests for HistoryQueryService."""
|
||||||
|
|
||||||
|
def test_create_service(self, git_backend):
|
||||||
|
"""Test creating a query service."""
|
||||||
|
def resolver(space_id):
|
||||||
|
return Path(f"/tmp/{space_id}")
|
||||||
|
|
||||||
|
service = HistoryQueryService(git_backend, resolver)
|
||||||
|
assert service is not None
|
||||||
|
|
||||||
|
def test_get_history(self, initialized_repo, git_backend):
|
||||||
|
"""Test getting history entries."""
|
||||||
|
space_id = "test-space"
|
||||||
|
|
||||||
|
def resolver(sid):
|
||||||
|
return initialized_repo if sid == space_id else None
|
||||||
|
|
||||||
|
service = HistoryQueryService(git_backend, resolver)
|
||||||
|
|
||||||
|
# Create commits
|
||||||
|
for i in range(3):
|
||||||
|
file = initialized_repo / f"file{i}.md"
|
||||||
|
file.write_text(f"Content {i}")
|
||||||
|
git_backend.commit(initialized_repo, f"Add file {i}")
|
||||||
|
|
||||||
|
# Get history
|
||||||
|
history = service.get_history(space_id, limit=10)
|
||||||
|
assert len(history) >= 3
|
||||||
|
assert isinstance(history[0], HistoryEntry)
|
||||||
|
assert history[0].commit.message.startswith("Add file")
|
||||||
|
|
||||||
|
def test_get_version_content(self, initialized_repo, git_backend):
|
||||||
|
"""Test getting content at specific version."""
|
||||||
|
space_id = "test-space"
|
||||||
|
|
||||||
|
def resolver(sid):
|
||||||
|
return initialized_repo if sid == space_id else None
|
||||||
|
|
||||||
|
service = HistoryQueryService(git_backend, resolver)
|
||||||
|
|
||||||
|
# Create versions
|
||||||
|
file = initialized_repo / "doc.md"
|
||||||
|
file.write_text("Version 1")
|
||||||
|
commit1 = git_backend.commit(initialized_repo, "V1")
|
||||||
|
|
||||||
|
file.write_text("Version 2")
|
||||||
|
git_backend.commit(initialized_repo, "V2")
|
||||||
|
|
||||||
|
# Get content at V1
|
||||||
|
content = service.get_version_content(space_id, "/doc.md", commit1.id)
|
||||||
|
assert content == "Version 1"
|
||||||
|
|
||||||
|
def test_compare_versions(self, initialized_repo, git_backend):
|
||||||
|
"""Test comparing two versions."""
|
||||||
|
space_id = "test-space"
|
||||||
|
|
||||||
|
def resolver(sid):
|
||||||
|
return initialized_repo if sid == space_id else None
|
||||||
|
|
||||||
|
service = HistoryQueryService(git_backend, resolver)
|
||||||
|
|
||||||
|
file = initialized_repo / "doc.md"
|
||||||
|
file.write_text("Line 1\n")
|
||||||
|
commit1 = git_backend.commit(initialized_repo, "V1")
|
||||||
|
|
||||||
|
file.write_text("Line 1\nLine 2\n")
|
||||||
|
commit2 = git_backend.commit(initialized_repo, "V2")
|
||||||
|
|
||||||
|
diff = service.compare_versions(space_id, "/doc.md", commit1.id, commit2.id)
|
||||||
|
assert diff is not None
|
||||||
|
assert diff.additions > 0
|
||||||
|
|
||||||
|
def test_search_history(self, initialized_repo, git_backend):
|
||||||
|
"""Test searching commit messages."""
|
||||||
|
space_id = "test-space"
|
||||||
|
|
||||||
|
def resolver(sid):
|
||||||
|
return initialized_repo if sid == space_id else None
|
||||||
|
|
||||||
|
service = HistoryQueryService(git_backend, resolver)
|
||||||
|
|
||||||
|
# Create commits with different messages
|
||||||
|
file = initialized_repo / "doc.md"
|
||||||
|
file.write_text("V1")
|
||||||
|
git_backend.commit(initialized_repo, "Add feature X")
|
||||||
|
|
||||||
|
file.write_text("V2")
|
||||||
|
git_backend.commit(initialized_repo, "Fix bug Y")
|
||||||
|
|
||||||
|
file.write_text("V3")
|
||||||
|
git_backend.commit(initialized_repo, "Update feature X")
|
||||||
|
|
||||||
|
# Search for "feature"
|
||||||
|
results = service.search_history(space_id, "feature")
|
||||||
|
assert len(results) >= 2
|
||||||
|
assert all("feature" in r.commit.message.lower() for r in results)
|
||||||
|
|
||||||
|
def test_restore_version(self, initialized_repo, git_backend):
|
||||||
|
"""Test restoring a document to previous version."""
|
||||||
|
space_id = "test-space"
|
||||||
|
|
||||||
|
def resolver(sid):
|
||||||
|
return initialized_repo if sid == space_id else None
|
||||||
|
|
||||||
|
service = HistoryQueryService(git_backend, resolver)
|
||||||
|
|
||||||
|
file = initialized_repo / "doc.md"
|
||||||
|
file.write_text("Version 1")
|
||||||
|
commit1 = git_backend.commit(initialized_repo, "V1")
|
||||||
|
|
||||||
|
file.write_text("Version 2")
|
||||||
|
git_backend.commit(initialized_repo, "V2")
|
||||||
|
|
||||||
|
# Restore to V1
|
||||||
|
success = service.restore_version(space_id, "/doc.md", commit1.id)
|
||||||
|
assert success
|
||||||
|
assert file.read_text() == "Version 1"
|
||||||
|
|
||||||
|
def test_has_history(self, initialized_repo, git_backend):
|
||||||
|
"""Test checking if space has history."""
|
||||||
|
space1 = "space-with-history"
|
||||||
|
space2 = "space-without-history"
|
||||||
|
|
||||||
|
# Create fresh directory for space2
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
no_history_dir = Path(tmpdir)
|
||||||
|
|
||||||
|
def resolver(sid):
|
||||||
|
if sid == space1:
|
||||||
|
return initialized_repo
|
||||||
|
elif sid == space2:
|
||||||
|
return no_history_dir
|
||||||
|
return None
|
||||||
|
|
||||||
|
service = HistoryQueryService(git_backend, resolver)
|
||||||
|
|
||||||
|
assert service.has_history(space1)
|
||||||
|
assert not service.has_history(space2)
|
||||||
|
|
||||||
|
def test_get_branches(self, initialized_repo, git_backend):
|
||||||
|
"""Test getting branch names."""
|
||||||
|
space_id = "test-space"
|
||||||
|
|
||||||
|
def resolver(sid):
|
||||||
|
return initialized_repo if sid == space_id else None
|
||||||
|
|
||||||
|
service = HistoryQueryService(git_backend, resolver)
|
||||||
|
|
||||||
|
# Create a commit first
|
||||||
|
file = initialized_repo / "doc.md"
|
||||||
|
file.write_text("Content")
|
||||||
|
git_backend.commit(initialized_repo, "Add file")
|
||||||
|
|
||||||
|
# Create branch
|
||||||
|
git_backend.create_branch(initialized_repo, "feature")
|
||||||
|
|
||||||
|
branches = service.get_branches(space_id)
|
||||||
|
assert "feature" in branches
|
||||||
|
|
||||||
|
def test_has_uncommitted_changes(self, initialized_repo, git_backend):
|
||||||
|
"""Test checking for uncommitted changes."""
|
||||||
|
space_id = "test-space"
|
||||||
|
|
||||||
|
def resolver(sid):
|
||||||
|
return initialized_repo if sid == space_id else None
|
||||||
|
|
||||||
|
service = HistoryQueryService(git_backend, resolver)
|
||||||
|
|
||||||
|
# No changes initially
|
||||||
|
assert not service.has_uncommitted_changes(space_id)
|
||||||
|
|
||||||
|
# Add file
|
||||||
|
file = initialized_repo / "doc.md"
|
||||||
|
file.write_text("Content")
|
||||||
|
|
||||||
|
# Should have changes
|
||||||
|
assert service.has_uncommitted_changes(space_id)
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# Integration Tests
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestHistoryIntegration:
|
||||||
|
"""Integration tests for history tracking."""
|
||||||
|
|
||||||
|
def test_full_workflow(self, temp_dir, git_backend, history_config, event_bus):
|
||||||
|
"""Test complete history workflow."""
|
||||||
|
space_id = "test-space"
|
||||||
|
|
||||||
|
def resolver(sid):
|
||||||
|
return temp_dir if sid == space_id else None
|
||||||
|
|
||||||
|
# Initialize
|
||||||
|
git_backend.initialize(temp_dir, history_config)
|
||||||
|
|
||||||
|
# Create query service
|
||||||
|
query_service = HistoryQueryService(git_backend, resolver)
|
||||||
|
|
||||||
|
# Create event handler
|
||||||
|
event_handler = GitHistoryEventHandler(
|
||||||
|
git_backend, history_config, resolver
|
||||||
|
)
|
||||||
|
event_handler.register(event_bus)
|
||||||
|
|
||||||
|
# Add initial file
|
||||||
|
file = temp_dir / "intro.md"
|
||||||
|
file.write_text("# Introduction")
|
||||||
|
git_backend.commit(temp_dir, "Initial commit")
|
||||||
|
|
||||||
|
# Emit document added event
|
||||||
|
file2 = temp_dir / "chapter1.md"
|
||||||
|
file2.write_text("# Chapter 1")
|
||||||
|
event = SpaceEvent(
|
||||||
|
event_type=SpaceEventType.DOCUMENT_ADDED,
|
||||||
|
space_id=space_id,
|
||||||
|
payload={"space_path": "/chapter1.md"},
|
||||||
|
)
|
||||||
|
event_bus.emit(event)
|
||||||
|
|
||||||
|
# Should have 2 commits
|
||||||
|
history = query_service.get_history(space_id)
|
||||||
|
assert len(history) >= 2
|
||||||
|
|
||||||
|
# Get specific version (find the commit where intro.md was added)
|
||||||
|
commits = git_backend.get_log(temp_dir)
|
||||||
|
intro_commit = None
|
||||||
|
for commit in reversed(commits):
|
||||||
|
if "intro.md" in commit.files_changed:
|
||||||
|
intro_commit = commit
|
||||||
|
break
|
||||||
|
|
||||||
|
if intro_commit:
|
||||||
|
old_content = query_service.get_version_content(
|
||||||
|
space_id, "/intro.md", intro_commit.id
|
||||||
|
)
|
||||||
|
assert old_content == "# Introduction"
|
||||||
|
|
||||||
|
# Compare versions
|
||||||
|
file.write_text("# Introduction\nUpdated")
|
||||||
|
git_backend.commit(temp_dir, "Update intro")
|
||||||
|
|
||||||
|
diff = query_service.compare_versions(
|
||||||
|
space_id, "/intro.md", commits[-1].id, "HEAD"
|
||||||
|
)
|
||||||
|
assert diff is not None
|
||||||
|
assert diff.additions > 0
|
||||||
|
|
||||||
|
event_handler.unregister()
|
||||||
Reference in New Issue
Block a user