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
|
||||
|
||||
# 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__ = [
|
||||
# Models
|
||||
"InformationSpace",
|
||||
@@ -130,4 +151,21 @@ __all__ = [
|
||||
"IAccessControlRepository",
|
||||
"SqliteSpaceReferenceRepository",
|
||||
"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:
|
||||
- IHistoryBackend: Abstract history backend interface
|
||||
- GitHistoryBackend: Git implementation
|
||||
- Event-driven commit triggers
|
||||
- History query API (log, diff, branches)
|
||||
- Versioned read/render operations
|
||||
Provides optional git-based version control for space content,
|
||||
enabling commit history, diffs, and version restoration.
|
||||
"""
|
||||
|
||||
# History tracking will be implemented in Phase 8
|
||||
__all__ = []
|
||||
from .models import (
|
||||
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