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:
2026-02-08 18:03:35 +01:00
parent 727ce4d3c5
commit 4588cbeee8
8 changed files with 2587 additions and 9 deletions

View File

@@ -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",
]

View File

@@ -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",
]

View 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)

View 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

View 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

View 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", {}),
)

View 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)

View 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()