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)