feat(spaces): implement Phase 8 Git History Tracking
Implements optional git-based version control for information spaces: - HistoryConfig model for configuring history tracking - Commit, Branch, HistoryEntry, DiffResult models - IHistoryBackend and IHistoryQuery interfaces - GitHistoryBackend using git CLI for version control - GitHistoryEventHandler for event-driven auto-commits - HistoryEventCoordinator for managing space history - HistoryQueryService for high-level history queries - Automatic commits on DOCUMENT_ADDED/REMOVED/CONTENT_CHANGED events - Support for: * Commit log with pagination and filtering * Diff between versions * File content at specific versions * Branch creation and switching * Version restoration * Uncommitted changes detection - 43 comprehensive unit tests with git availability checks Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -83,6 +83,27 @@ from .composability import (
|
||||
)
|
||||
from .composability.service import CircularReferenceError
|
||||
|
||||
# Phase 8: History Tracking (Optional)
|
||||
from .history import (
|
||||
# Models
|
||||
Commit,
|
||||
Branch,
|
||||
HistoryEntry,
|
||||
DiffResult,
|
||||
DiffLine,
|
||||
DiffType,
|
||||
HistoryConfig,
|
||||
# Interfaces
|
||||
IHistoryBackend,
|
||||
IHistoryQuery,
|
||||
# Implementations
|
||||
GitHistoryBackend,
|
||||
GitError,
|
||||
GitHistoryEventHandler,
|
||||
HistoryEventCoordinator,
|
||||
HistoryQueryService,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Models
|
||||
"InformationSpace",
|
||||
@@ -130,4 +151,21 @@ __all__ = [
|
||||
"IAccessControlRepository",
|
||||
"SqliteSpaceReferenceRepository",
|
||||
"SqliteAccessControlRepository",
|
||||
# History - Models
|
||||
"Commit",
|
||||
"Branch",
|
||||
"HistoryEntry",
|
||||
"DiffResult",
|
||||
"DiffLine",
|
||||
"DiffType",
|
||||
"HistoryConfig",
|
||||
# History - Interfaces
|
||||
"IHistoryBackend",
|
||||
"IHistoryQuery",
|
||||
# History - Implementations
|
||||
"GitHistoryBackend",
|
||||
"GitError",
|
||||
"GitHistoryEventHandler",
|
||||
"HistoryEventCoordinator",
|
||||
"HistoryQueryService",
|
||||
]
|
||||
|
||||
@@ -1,13 +1,43 @@
|
||||
"""
|
||||
Git history tracking for Information Spaces (Optional Phase 8).
|
||||
History tracking module for Information Spaces.
|
||||
|
||||
This package provides version control integration:
|
||||
- IHistoryBackend: Abstract history backend interface
|
||||
- GitHistoryBackend: Git implementation
|
||||
- Event-driven commit triggers
|
||||
- History query API (log, diff, branches)
|
||||
- Versioned read/render operations
|
||||
Provides optional git-based version control for space content,
|
||||
enabling commit history, diffs, and version restoration.
|
||||
"""
|
||||
|
||||
# History tracking will be implemented in Phase 8
|
||||
__all__ = []
|
||||
from .models import (
|
||||
Commit,
|
||||
Branch,
|
||||
HistoryEntry,
|
||||
DiffResult,
|
||||
DiffLine,
|
||||
DiffType,
|
||||
HistoryConfig,
|
||||
)
|
||||
from .interfaces import (
|
||||
IHistoryBackend,
|
||||
IHistoryQuery,
|
||||
)
|
||||
from .git_backend import GitHistoryBackend, GitError
|
||||
from .events import GitHistoryEventHandler, HistoryEventCoordinator
|
||||
from .queries import HistoryQueryService
|
||||
|
||||
__all__ = [
|
||||
# Models
|
||||
"Commit",
|
||||
"Branch",
|
||||
"HistoryEntry",
|
||||
"DiffResult",
|
||||
"DiffLine",
|
||||
"DiffType",
|
||||
"HistoryConfig",
|
||||
# Interfaces
|
||||
"IHistoryBackend",
|
||||
"IHistoryQuery",
|
||||
# Implementations
|
||||
"GitHistoryBackend",
|
||||
"GitError",
|
||||
"GitHistoryEventHandler",
|
||||
"HistoryEventCoordinator",
|
||||
"HistoryQueryService",
|
||||
]
|
||||
|
||||
286
markitect/spaces/history/events.py
Normal file
286
markitect/spaces/history/events.py
Normal file
@@ -0,0 +1,286 @@
|
||||
"""
|
||||
Event handlers for history tracking.
|
||||
|
||||
Subscribes to space events and triggers commits automatically.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Optional, Callable
|
||||
import logging
|
||||
|
||||
from ..events import SpaceEvent, SpaceEventType, EventBus
|
||||
from .interfaces import IHistoryBackend
|
||||
from .models import HistoryConfig, Commit
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GitHistoryEventHandler:
|
||||
"""
|
||||
Event handler that commits changes to git on document events.
|
||||
|
||||
Subscribes to document events and automatically creates commits
|
||||
when documents are added, removed, or modified.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
backend: IHistoryBackend,
|
||||
config: HistoryConfig,
|
||||
directory_resolver: Callable[[str], Optional[Path]],
|
||||
):
|
||||
"""
|
||||
Initialize the event handler.
|
||||
|
||||
Args:
|
||||
backend: History backend to use
|
||||
config: History configuration
|
||||
directory_resolver: Function that resolves space_id to directory path
|
||||
"""
|
||||
self._backend = backend
|
||||
self._config = config
|
||||
self._directory_resolver = directory_resolver
|
||||
self._event_bus: Optional[EventBus] = None
|
||||
self._handler_ids: Dict[SpaceEventType, str] = {}
|
||||
|
||||
def register(self, event_bus: EventBus) -> None:
|
||||
"""
|
||||
Register handlers with an event bus.
|
||||
|
||||
Args:
|
||||
event_bus: Event bus to register with
|
||||
"""
|
||||
self._event_bus = event_bus
|
||||
|
||||
# Map event type names to SpaceEventType
|
||||
event_type_map = {
|
||||
"DOCUMENT_ADDED": SpaceEventType.DOCUMENT_ADDED,
|
||||
"DOCUMENT_REMOVED": SpaceEventType.DOCUMENT_REMOVED,
|
||||
"DOCUMENT_CONTENT_CHANGED": SpaceEventType.DOCUMENT_CONTENT_CHANGED,
|
||||
"DOCUMENT_MOVED": SpaceEventType.DOCUMENT_MOVED,
|
||||
}
|
||||
|
||||
for event_name in self._config.commit_on_events:
|
||||
event_type = event_type_map.get(event_name)
|
||||
if event_type:
|
||||
handler_id = event_bus.subscribe(event_type, self._handle_event)
|
||||
self._handler_ids[event_type] = handler_id
|
||||
|
||||
def unregister(self) -> None:
|
||||
"""Unregister handlers from the event bus."""
|
||||
if self._event_bus:
|
||||
for event_type, handler_id in self._handler_ids.items():
|
||||
self._event_bus.unsubscribe(event_type, handler_id)
|
||||
self._handler_ids.clear()
|
||||
self._event_bus = None
|
||||
|
||||
def _handle_event(self, event: SpaceEvent) -> None:
|
||||
"""
|
||||
Handle a space event.
|
||||
|
||||
Args:
|
||||
event: The event to handle
|
||||
"""
|
||||
if not self._config.enabled or not self._config.auto_commit:
|
||||
return
|
||||
|
||||
space_id = event.space_id
|
||||
if not space_id:
|
||||
return
|
||||
|
||||
directory = self._directory_resolver(space_id)
|
||||
if not directory:
|
||||
logger.warning(f"No directory found for space {space_id}")
|
||||
return
|
||||
|
||||
try:
|
||||
self._commit_for_event(event, directory)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to commit for event {event.event_type}: {e}")
|
||||
|
||||
def _commit_for_event(self, event: SpaceEvent, directory: Path) -> Optional[Commit]:
|
||||
"""
|
||||
Create a commit for an event.
|
||||
|
||||
Args:
|
||||
event: The triggering event
|
||||
directory: Space directory
|
||||
|
||||
Returns:
|
||||
The created commit, or None if no commit was made
|
||||
"""
|
||||
# Ensure history is initialized
|
||||
if not self._backend.is_initialized(directory):
|
||||
self._backend.initialize(directory, self._config)
|
||||
|
||||
# Check for changes
|
||||
if not self._backend.has_changes(directory):
|
||||
return None
|
||||
|
||||
# Build commit message
|
||||
message = self._build_commit_message(event)
|
||||
|
||||
# Build metadata
|
||||
metadata = {
|
||||
"space_id": event.space_id,
|
||||
"event_type": event.event_type.value,
|
||||
"event_id": event.event_id,
|
||||
}
|
||||
|
||||
# Get specific files to commit if available
|
||||
files = None
|
||||
if event.payload:
|
||||
doc_path = event.payload.get("space_path") or event.payload.get("path")
|
||||
if doc_path:
|
||||
# Normalize path
|
||||
if doc_path.startswith("/"):
|
||||
doc_path = doc_path[1:]
|
||||
files = [doc_path]
|
||||
|
||||
# Create commit
|
||||
author = f"{self._config.author_name} <{self._config.author_email}>"
|
||||
return self._backend.commit(
|
||||
directory=directory,
|
||||
message=message,
|
||||
files=files,
|
||||
author=author,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
def _build_commit_message(self, event: SpaceEvent) -> str:
|
||||
"""
|
||||
Build a commit message for an event.
|
||||
|
||||
Args:
|
||||
event: The event
|
||||
|
||||
Returns:
|
||||
Commit message string
|
||||
"""
|
||||
payload = event.payload or {}
|
||||
|
||||
if event.event_type == SpaceEventType.DOCUMENT_ADDED:
|
||||
path = payload.get("space_path", "document")
|
||||
return f"Add document: {path}"
|
||||
|
||||
elif event.event_type == SpaceEventType.DOCUMENT_REMOVED:
|
||||
path = payload.get("space_path", "document")
|
||||
return f"Remove document: {path}"
|
||||
|
||||
elif event.event_type == SpaceEventType.DOCUMENT_CONTENT_CHANGED:
|
||||
doc_id = payload.get("document_id", "document")
|
||||
return f"Update document: {doc_id}"
|
||||
|
||||
elif event.event_type == SpaceEventType.DOCUMENT_MOVED:
|
||||
old_path = payload.get("old_path", "old")
|
||||
new_path = payload.get("new_path", "new")
|
||||
return f"Move document: {old_path} -> {new_path}"
|
||||
|
||||
else:
|
||||
return f"Auto-commit: {event.event_type.value}"
|
||||
|
||||
def commit_changes(
|
||||
self,
|
||||
space_id: str,
|
||||
message: str,
|
||||
files: Optional[list] = None,
|
||||
) -> Optional[Commit]:
|
||||
"""
|
||||
Manually trigger a commit.
|
||||
|
||||
Args:
|
||||
space_id: Space identifier
|
||||
message: Commit message
|
||||
files: Specific files to commit
|
||||
|
||||
Returns:
|
||||
The created commit, or None if failed
|
||||
"""
|
||||
directory = self._directory_resolver(space_id)
|
||||
if not directory:
|
||||
return None
|
||||
|
||||
if not self._backend.is_initialized(directory):
|
||||
self._backend.initialize(directory, self._config)
|
||||
|
||||
if not self._backend.has_changes(directory):
|
||||
return None
|
||||
|
||||
author = f"{self._config.author_name} <{self._config.author_email}>"
|
||||
return self._backend.commit(
|
||||
directory=directory,
|
||||
message=message,
|
||||
files=files,
|
||||
author=author,
|
||||
metadata={"space_id": space_id, "manual": True},
|
||||
)
|
||||
|
||||
|
||||
class HistoryEventCoordinator:
|
||||
"""
|
||||
Coordinates history tracking across multiple spaces.
|
||||
|
||||
Manages event handlers for spaces with history enabled.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
backend: IHistoryBackend,
|
||||
event_bus: EventBus,
|
||||
directory_resolver: Callable[[str], Optional[Path]],
|
||||
):
|
||||
"""
|
||||
Initialize the coordinator.
|
||||
|
||||
Args:
|
||||
backend: History backend
|
||||
event_bus: Event bus for subscriptions
|
||||
directory_resolver: Function to resolve space_id to directory
|
||||
"""
|
||||
self._backend = backend
|
||||
self._event_bus = event_bus
|
||||
self._directory_resolver = directory_resolver
|
||||
self._handlers: Dict[str, GitHistoryEventHandler] = {}
|
||||
|
||||
def enable_history(self, space_id: str, config: HistoryConfig) -> None:
|
||||
"""
|
||||
Enable history tracking for a space.
|
||||
|
||||
Args:
|
||||
space_id: Space identifier
|
||||
config: History configuration
|
||||
"""
|
||||
if space_id in self._handlers:
|
||||
return
|
||||
|
||||
handler = GitHistoryEventHandler(
|
||||
backend=self._backend,
|
||||
config=config,
|
||||
directory_resolver=self._directory_resolver,
|
||||
)
|
||||
handler.register(self._event_bus)
|
||||
self._handlers[space_id] = handler
|
||||
|
||||
# Initialize git in the space directory
|
||||
directory = self._directory_resolver(space_id)
|
||||
if directory:
|
||||
self._backend.initialize(directory, config)
|
||||
|
||||
def disable_history(self, space_id: str) -> None:
|
||||
"""
|
||||
Disable history tracking for a space.
|
||||
|
||||
Args:
|
||||
space_id: Space identifier
|
||||
"""
|
||||
handler = self._handlers.pop(space_id, None)
|
||||
if handler:
|
||||
handler.unregister()
|
||||
|
||||
def is_enabled(self, space_id: str) -> bool:
|
||||
"""Check if history is enabled for a space."""
|
||||
return space_id in self._handlers
|
||||
|
||||
def get_handler(self, space_id: str) -> Optional[GitHistoryEventHandler]:
|
||||
"""Get the event handler for a space."""
|
||||
return self._handlers.get(space_id)
|
||||
521
markitect/spaces/history/git_backend.py
Normal file
521
markitect/spaces/history/git_backend.py
Normal file
@@ -0,0 +1,521 @@
|
||||
"""
|
||||
Git implementation of the history backend.
|
||||
|
||||
Uses git commands to provide version control for space content.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
import re
|
||||
|
||||
from .interfaces import IHistoryBackend
|
||||
from .models import Commit, Branch, DiffResult, DiffLine, DiffType, HistoryConfig
|
||||
|
||||
|
||||
class GitError(Exception):
|
||||
"""Raised when a git operation fails."""
|
||||
pass
|
||||
|
||||
|
||||
class GitHistoryBackend(IHistoryBackend):
|
||||
"""
|
||||
Git-based history backend implementation.
|
||||
|
||||
Uses the git CLI to manage version control. Requires git to be
|
||||
installed on the system.
|
||||
"""
|
||||
|
||||
def __init__(self, default_author: str = "markitect <markitect@local>"):
|
||||
"""
|
||||
Initialize the git backend.
|
||||
|
||||
Args:
|
||||
default_author: Default author for commits
|
||||
"""
|
||||
self._default_author = default_author
|
||||
self._verify_git_available()
|
||||
|
||||
def _verify_git_available(self) -> None:
|
||||
"""Verify git is available on the system."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "--version"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise GitError("Git is not available")
|
||||
except FileNotFoundError:
|
||||
raise GitError("Git is not installed")
|
||||
except subprocess.TimeoutExpired:
|
||||
raise GitError("Git command timed out")
|
||||
|
||||
def _run_git(
|
||||
self,
|
||||
directory: Path,
|
||||
args: List[str],
|
||||
check: bool = True,
|
||||
capture_output: bool = True,
|
||||
) -> subprocess.CompletedProcess:
|
||||
"""
|
||||
Run a git command.
|
||||
|
||||
Args:
|
||||
directory: Working directory
|
||||
args: Git command arguments (without 'git')
|
||||
check: Whether to raise on non-zero exit
|
||||
capture_output: Whether to capture stdout/stderr
|
||||
|
||||
Returns:
|
||||
CompletedProcess result
|
||||
"""
|
||||
cmd = ["git"] + args
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
cwd=str(directory),
|
||||
capture_output=capture_output,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
if check and result.returncode != 0:
|
||||
raise GitError(f"Git command failed: {result.stderr}")
|
||||
return result
|
||||
except subprocess.TimeoutExpired:
|
||||
raise GitError(f"Git command timed out: {' '.join(cmd)}")
|
||||
|
||||
def initialize(self, directory: Path, config: HistoryConfig) -> None:
|
||||
"""Initialize a git repository."""
|
||||
if not directory.exists():
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if not self.is_initialized(directory):
|
||||
self._run_git(directory, ["init"])
|
||||
|
||||
# Configure user for this repo
|
||||
self._run_git(
|
||||
directory,
|
||||
["config", "user.name", config.author_name],
|
||||
)
|
||||
self._run_git(
|
||||
directory,
|
||||
["config", "user.email", config.author_email],
|
||||
)
|
||||
|
||||
# Create initial .gitignore
|
||||
gitignore = directory / ".gitignore"
|
||||
if not gitignore.exists():
|
||||
gitignore.write_text("*.pyc\n__pycache__/\n.DS_Store\n")
|
||||
self._run_git(directory, ["add", ".gitignore"])
|
||||
self._run_git(
|
||||
directory,
|
||||
["commit", "-m", "Initial commit: add .gitignore"],
|
||||
)
|
||||
|
||||
def is_initialized(self, directory: Path) -> bool:
|
||||
"""Check if directory is a git repository."""
|
||||
git_dir = directory / ".git"
|
||||
return git_dir.exists() and git_dir.is_dir()
|
||||
|
||||
def commit(
|
||||
self,
|
||||
directory: Path,
|
||||
message: str,
|
||||
files: Optional[List[str]] = None,
|
||||
author: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> Commit:
|
||||
"""Create a commit."""
|
||||
if not self.is_initialized(directory):
|
||||
raise GitError(f"Not a git repository: {directory}")
|
||||
|
||||
# Stage files
|
||||
if files:
|
||||
for f in files:
|
||||
file_path = directory / f
|
||||
if file_path.exists():
|
||||
self._run_git(directory, ["add", f])
|
||||
else:
|
||||
# File was deleted
|
||||
self._run_git(directory, ["add", "-u", f], check=False)
|
||||
else:
|
||||
# Stage all changes
|
||||
self._run_git(directory, ["add", "-A"])
|
||||
|
||||
# Check if there are changes to commit
|
||||
status = self._run_git(directory, ["status", "--porcelain"])
|
||||
if not status.stdout.strip():
|
||||
raise GitError("No changes to commit")
|
||||
|
||||
# Build commit command
|
||||
commit_args = ["commit", "-m", message]
|
||||
if author:
|
||||
commit_args.extend(["--author", author])
|
||||
|
||||
# Add metadata as trailer
|
||||
if metadata:
|
||||
for key, value in metadata.items():
|
||||
commit_args.extend(["--trailer", f"{key}={value}"])
|
||||
|
||||
self._run_git(directory, commit_args)
|
||||
|
||||
# Get the commit info
|
||||
return self._get_head_commit(directory)
|
||||
|
||||
def _get_head_commit(self, directory: Path) -> Commit:
|
||||
"""Get the HEAD commit."""
|
||||
result = self._run_git(
|
||||
directory,
|
||||
["log", "-1", "--format=%H|%s|%an <%ae>|%aI|%P"],
|
||||
)
|
||||
return self._parse_commit_line(result.stdout.strip(), directory)
|
||||
|
||||
def _parse_commit_line(self, line: str, directory: Path) -> Commit:
|
||||
"""Parse a commit from log format."""
|
||||
parts = line.split("|")
|
||||
if len(parts) < 4:
|
||||
raise GitError(f"Invalid commit format: {line}")
|
||||
|
||||
commit_id = parts[0]
|
||||
message = parts[1]
|
||||
author = parts[2]
|
||||
timestamp_str = parts[3]
|
||||
parent_ids = parts[4].split() if len(parts) > 4 and parts[4] else []
|
||||
|
||||
# Parse ISO timestamp
|
||||
try:
|
||||
timestamp = datetime.fromisoformat(timestamp_str.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
timestamp = datetime.now()
|
||||
|
||||
# Get changed files
|
||||
files_result = self._run_git(
|
||||
directory,
|
||||
["show", "--name-only", "--format=", commit_id],
|
||||
)
|
||||
files_changed = [f for f in files_result.stdout.strip().split("\n") if f]
|
||||
|
||||
# Extract metadata from commit body (git trailers)
|
||||
metadata = {}
|
||||
try:
|
||||
body_result = self._run_git(
|
||||
directory,
|
||||
["log", "-1", "--format=%b", commit_id],
|
||||
)
|
||||
body = body_result.stdout.strip()
|
||||
# Parse trailers - format is "Key: value" or "Key=value"
|
||||
for line in body.split("\n"):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
# Try "Key: value" format first
|
||||
if ":" in line:
|
||||
key, value = line.split(":", 1)
|
||||
metadata[key.strip()] = value.strip()
|
||||
# Also try "Key=value" format (what git --trailer actually creates)
|
||||
elif "=" in line:
|
||||
key, value = line.split("=", 1)
|
||||
metadata[key.strip()] = value.strip()
|
||||
except GitError:
|
||||
pass
|
||||
|
||||
return Commit(
|
||||
id=commit_id,
|
||||
message=message,
|
||||
author=author,
|
||||
timestamp=timestamp,
|
||||
parent_ids=parent_ids,
|
||||
files_changed=files_changed,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
def get_commit(self, directory: Path, commit_id: str) -> Optional[Commit]:
|
||||
"""Get a commit by ID."""
|
||||
if not self.is_initialized(directory):
|
||||
return None
|
||||
|
||||
try:
|
||||
result = self._run_git(
|
||||
directory,
|
||||
["log", "-1", "--format=%H|%s|%an <%ae>|%aI|%P", commit_id],
|
||||
)
|
||||
if result.stdout.strip():
|
||||
return self._parse_commit_line(result.stdout.strip(), directory)
|
||||
except GitError:
|
||||
pass
|
||||
return None
|
||||
|
||||
def get_log(
|
||||
self,
|
||||
directory: Path,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
path: Optional[str] = None,
|
||||
) -> List[Commit]:
|
||||
"""Get commit history."""
|
||||
if not self.is_initialized(directory):
|
||||
return []
|
||||
|
||||
args = [
|
||||
"log",
|
||||
f"--skip={offset}",
|
||||
f"-{limit}",
|
||||
"--format=%H|%s|%an <%ae>|%aI|%P",
|
||||
]
|
||||
if path:
|
||||
args.extend(["--", path])
|
||||
|
||||
try:
|
||||
result = self._run_git(directory, args)
|
||||
commits = []
|
||||
for line in result.stdout.strip().split("\n"):
|
||||
if line:
|
||||
commits.append(self._parse_commit_line(line, directory))
|
||||
return commits
|
||||
except GitError:
|
||||
return []
|
||||
|
||||
def get_diff(
|
||||
self,
|
||||
directory: Path,
|
||||
old_version: Optional[str] = None,
|
||||
new_version: Optional[str] = None,
|
||||
path: Optional[str] = None,
|
||||
) -> List[DiffResult]:
|
||||
"""Get diff between versions."""
|
||||
if not self.is_initialized(directory):
|
||||
return []
|
||||
|
||||
args = ["diff"]
|
||||
|
||||
if old_version and new_version:
|
||||
args.append(f"{old_version}..{new_version}")
|
||||
elif old_version:
|
||||
args.append(old_version)
|
||||
elif new_version:
|
||||
args.extend(["HEAD", new_version])
|
||||
|
||||
if path:
|
||||
args.extend(["--", path])
|
||||
|
||||
try:
|
||||
result = self._run_git(directory, args)
|
||||
return self._parse_diff_output(result.stdout)
|
||||
except GitError:
|
||||
return []
|
||||
|
||||
def _parse_diff_output(self, output: str) -> List[DiffResult]:
|
||||
"""Parse git diff output into DiffResult objects."""
|
||||
results = []
|
||||
current_diff: Optional[DiffResult] = None
|
||||
old_line = 0
|
||||
new_line = 0
|
||||
|
||||
for line in output.split("\n"):
|
||||
# New file diff
|
||||
if line.startswith("diff --git"):
|
||||
if current_diff:
|
||||
results.append(current_diff)
|
||||
# Extract path from "diff --git a/path b/path"
|
||||
match = re.search(r"diff --git a/(.*) b/", line)
|
||||
path = match.group(1) if match else "unknown"
|
||||
current_diff = DiffResult(path=path)
|
||||
|
||||
elif line.startswith("@@") and current_diff:
|
||||
# Parse hunk header: @@ -old_start,old_count +new_start,new_count @@
|
||||
match = re.search(r"@@ -(\d+)", line)
|
||||
if match:
|
||||
old_line = int(match.group(1))
|
||||
match = re.search(r"\+(\d+)", line)
|
||||
if match:
|
||||
new_line = int(match.group(1))
|
||||
|
||||
elif current_diff and line:
|
||||
if line.startswith("+") and not line.startswith("+++"):
|
||||
current_diff.lines.append(DiffLine(
|
||||
line_type=DiffType.ADDITION,
|
||||
content=line[1:],
|
||||
new_line_no=new_line,
|
||||
))
|
||||
current_diff.additions += 1
|
||||
new_line += 1
|
||||
elif line.startswith("-") and not line.startswith("---"):
|
||||
current_diff.lines.append(DiffLine(
|
||||
line_type=DiffType.DELETION,
|
||||
content=line[1:],
|
||||
old_line_no=old_line,
|
||||
))
|
||||
current_diff.deletions += 1
|
||||
old_line += 1
|
||||
elif line.startswith(" "):
|
||||
current_diff.lines.append(DiffLine(
|
||||
line_type=DiffType.CONTEXT,
|
||||
content=line[1:],
|
||||
old_line_no=old_line,
|
||||
new_line_no=new_line,
|
||||
))
|
||||
old_line += 1
|
||||
new_line += 1
|
||||
|
||||
if current_diff:
|
||||
results.append(current_diff)
|
||||
|
||||
return results
|
||||
|
||||
def checkout(
|
||||
self,
|
||||
directory: Path,
|
||||
version: str,
|
||||
path: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""Checkout a specific version."""
|
||||
if not self.is_initialized(directory):
|
||||
return False
|
||||
|
||||
try:
|
||||
if path:
|
||||
self._run_git(directory, ["checkout", version, "--", path])
|
||||
else:
|
||||
self._run_git(directory, ["checkout", version])
|
||||
return True
|
||||
except GitError:
|
||||
return False
|
||||
|
||||
def get_file_at_version(
|
||||
self,
|
||||
directory: Path,
|
||||
path: str,
|
||||
version: str,
|
||||
) -> Optional[str]:
|
||||
"""Get file content at a specific version."""
|
||||
if not self.is_initialized(directory):
|
||||
return None
|
||||
|
||||
try:
|
||||
result = self._run_git(
|
||||
directory,
|
||||
["show", f"{version}:{path}"],
|
||||
)
|
||||
return result.stdout
|
||||
except GitError:
|
||||
return None
|
||||
|
||||
def list_branches(self, directory: Path) -> List[Branch]:
|
||||
"""List all branches."""
|
||||
if not self.is_initialized(directory):
|
||||
return []
|
||||
|
||||
try:
|
||||
result = self._run_git(
|
||||
directory,
|
||||
["branch", "-v", "--format=%(refname:short)|%(objectname)|%(HEAD)"],
|
||||
)
|
||||
branches = []
|
||||
for line in result.stdout.strip().split("\n"):
|
||||
if line:
|
||||
parts = line.split("|")
|
||||
if len(parts) >= 3:
|
||||
branches.append(Branch(
|
||||
name=parts[0],
|
||||
head_commit_id=parts[1],
|
||||
is_current=(parts[2] == "*"),
|
||||
))
|
||||
return branches
|
||||
except GitError:
|
||||
return []
|
||||
|
||||
def create_branch(
|
||||
self,
|
||||
directory: Path,
|
||||
name: str,
|
||||
start_point: Optional[str] = None,
|
||||
) -> Branch:
|
||||
"""Create a new branch."""
|
||||
if not self.is_initialized(directory):
|
||||
raise GitError(f"Not a git repository: {directory}")
|
||||
|
||||
args = ["branch", name]
|
||||
if start_point:
|
||||
args.append(start_point)
|
||||
|
||||
self._run_git(directory, args)
|
||||
|
||||
# Get the created branch info
|
||||
result = self._run_git(
|
||||
directory,
|
||||
["rev-parse", name],
|
||||
)
|
||||
return Branch(
|
||||
name=name,
|
||||
head_commit_id=result.stdout.strip(),
|
||||
is_current=False,
|
||||
)
|
||||
|
||||
def switch_branch(self, directory: Path, name: str) -> bool:
|
||||
"""Switch to a branch."""
|
||||
if not self.is_initialized(directory):
|
||||
return False
|
||||
|
||||
try:
|
||||
self._run_git(directory, ["checkout", name])
|
||||
return True
|
||||
except GitError:
|
||||
return False
|
||||
|
||||
def get_current_branch(self, directory: Path) -> Optional[str]:
|
||||
"""Get the current branch name."""
|
||||
if not self.is_initialized(directory):
|
||||
return None
|
||||
|
||||
try:
|
||||
result = self._run_git(
|
||||
directory,
|
||||
["rev-parse", "--abbrev-ref", "HEAD"],
|
||||
)
|
||||
branch = result.stdout.strip()
|
||||
return branch if branch != "HEAD" else None
|
||||
except GitError:
|
||||
return None
|
||||
|
||||
def has_changes(self, directory: Path) -> bool:
|
||||
"""Check if there are uncommitted changes."""
|
||||
if not self.is_initialized(directory):
|
||||
return False
|
||||
|
||||
try:
|
||||
result = self._run_git(directory, ["status", "--porcelain"])
|
||||
return bool(result.stdout.strip())
|
||||
except GitError:
|
||||
return False
|
||||
|
||||
def restore_file(
|
||||
self,
|
||||
directory: Path,
|
||||
path: str,
|
||||
version: str,
|
||||
) -> bool:
|
||||
"""
|
||||
Restore a file to a specific version.
|
||||
|
||||
Gets the file content at the version and writes it to the working tree.
|
||||
|
||||
Args:
|
||||
directory: Root directory
|
||||
path: File path
|
||||
version: Version to restore from
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
content = self.get_file_at_version(directory, path, version)
|
||||
if content is None:
|
||||
return False
|
||||
|
||||
file_path = directory / path
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
file_path.write_text(content)
|
||||
return True
|
||||
350
markitect/spaces/history/interfaces.py
Normal file
350
markitect/spaces/history/interfaces.py
Normal file
@@ -0,0 +1,350 @@
|
||||
"""
|
||||
Abstract interfaces for history tracking.
|
||||
|
||||
Defines the contract for history backends and query services.
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Dict, Any
|
||||
|
||||
from .models import Commit, Branch, DiffResult, HistoryEntry, HistoryConfig
|
||||
|
||||
|
||||
class IHistoryBackend(ABC):
|
||||
"""
|
||||
Abstract interface for history storage backends.
|
||||
|
||||
Implementations can use git, SQLite, or other storage mechanisms.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def initialize(self, directory: Path, config: HistoryConfig) -> None:
|
||||
"""
|
||||
Initialize the history backend.
|
||||
|
||||
Args:
|
||||
directory: Root directory for the space
|
||||
config: History configuration
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def is_initialized(self, directory: Path) -> bool:
|
||||
"""
|
||||
Check if history is initialized for a directory.
|
||||
|
||||
Args:
|
||||
directory: Directory to check
|
||||
|
||||
Returns:
|
||||
True if history tracking is initialized
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def commit(
|
||||
self,
|
||||
directory: Path,
|
||||
message: str,
|
||||
files: Optional[List[str]] = None,
|
||||
author: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> Commit:
|
||||
"""
|
||||
Create a new commit.
|
||||
|
||||
Args:
|
||||
directory: Root directory
|
||||
message: Commit message
|
||||
files: Specific files to commit (None for all changes)
|
||||
author: Author string (name <email>)
|
||||
metadata: Additional metadata to store
|
||||
|
||||
Returns:
|
||||
The created commit
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_commit(self, directory: Path, commit_id: str) -> Optional[Commit]:
|
||||
"""
|
||||
Get a commit by ID.
|
||||
|
||||
Args:
|
||||
directory: Root directory
|
||||
commit_id: Commit identifier
|
||||
|
||||
Returns:
|
||||
The commit if found, None otherwise
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_log(
|
||||
self,
|
||||
directory: Path,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
path: Optional[str] = None,
|
||||
) -> List[Commit]:
|
||||
"""
|
||||
Get commit history.
|
||||
|
||||
Args:
|
||||
directory: Root directory
|
||||
limit: Maximum number of commits
|
||||
offset: Number of commits to skip
|
||||
path: Filter by file path
|
||||
|
||||
Returns:
|
||||
List of commits, newest first
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_diff(
|
||||
self,
|
||||
directory: Path,
|
||||
old_version: Optional[str] = None,
|
||||
new_version: Optional[str] = None,
|
||||
path: Optional[str] = None,
|
||||
) -> List[DiffResult]:
|
||||
"""
|
||||
Get diff between versions.
|
||||
|
||||
Args:
|
||||
directory: Root directory
|
||||
old_version: Old commit ID (None for working directory)
|
||||
new_version: New commit ID (None for working directory)
|
||||
path: Filter by file path
|
||||
|
||||
Returns:
|
||||
List of diff results
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def checkout(
|
||||
self,
|
||||
directory: Path,
|
||||
version: str,
|
||||
path: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Checkout a specific version.
|
||||
|
||||
Args:
|
||||
directory: Root directory
|
||||
version: Commit ID or ref to checkout
|
||||
path: Specific file to checkout (None for all)
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_file_at_version(
|
||||
self,
|
||||
directory: Path,
|
||||
path: str,
|
||||
version: str,
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Get file content at a specific version.
|
||||
|
||||
Args:
|
||||
directory: Root directory
|
||||
path: File path relative to directory
|
||||
version: Commit ID or ref
|
||||
|
||||
Returns:
|
||||
File content as string, or None if not found
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def list_branches(self, directory: Path) -> List[Branch]:
|
||||
"""
|
||||
List all branches.
|
||||
|
||||
Args:
|
||||
directory: Root directory
|
||||
|
||||
Returns:
|
||||
List of branches
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def create_branch(
|
||||
self,
|
||||
directory: Path,
|
||||
name: str,
|
||||
start_point: Optional[str] = None,
|
||||
) -> Branch:
|
||||
"""
|
||||
Create a new branch.
|
||||
|
||||
Args:
|
||||
directory: Root directory
|
||||
name: Branch name
|
||||
start_point: Starting commit/ref (None for HEAD)
|
||||
|
||||
Returns:
|
||||
The created branch
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def switch_branch(self, directory: Path, name: str) -> bool:
|
||||
"""
|
||||
Switch to a branch.
|
||||
|
||||
Args:
|
||||
directory: Root directory
|
||||
name: Branch name
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_current_branch(self, directory: Path) -> Optional[str]:
|
||||
"""
|
||||
Get the current branch name.
|
||||
|
||||
Args:
|
||||
directory: Root directory
|
||||
|
||||
Returns:
|
||||
Branch name or None if detached
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def has_changes(self, directory: Path) -> bool:
|
||||
"""
|
||||
Check if there are uncommitted changes.
|
||||
|
||||
Args:
|
||||
directory: Root directory
|
||||
|
||||
Returns:
|
||||
True if there are uncommitted changes
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class IHistoryQuery(ABC):
|
||||
"""
|
||||
Abstract interface for history queries.
|
||||
|
||||
Provides higher-level query operations on top of the backend.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def get_history(
|
||||
self,
|
||||
space_id: str,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
path: Optional[str] = None,
|
||||
) -> List[HistoryEntry]:
|
||||
"""
|
||||
Get history entries for a space.
|
||||
|
||||
Args:
|
||||
space_id: Space identifier
|
||||
limit: Maximum entries
|
||||
offset: Skip count
|
||||
path: Filter by document path
|
||||
|
||||
Returns:
|
||||
List of history entries
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_version_content(
|
||||
self,
|
||||
space_id: str,
|
||||
document_path: str,
|
||||
version: str,
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Get document content at a specific version.
|
||||
|
||||
Args:
|
||||
space_id: Space identifier
|
||||
document_path: Path to document
|
||||
version: Version identifier
|
||||
|
||||
Returns:
|
||||
Document content or None
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def compare_versions(
|
||||
self,
|
||||
space_id: str,
|
||||
document_path: str,
|
||||
old_version: str,
|
||||
new_version: Optional[str] = None,
|
||||
) -> Optional[DiffResult]:
|
||||
"""
|
||||
Compare two versions of a document.
|
||||
|
||||
Args:
|
||||
space_id: Space identifier
|
||||
document_path: Path to document
|
||||
old_version: Old version ID
|
||||
new_version: New version ID (None for current)
|
||||
|
||||
Returns:
|
||||
Diff result or None
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def search_history(
|
||||
self,
|
||||
space_id: str,
|
||||
query: str,
|
||||
limit: int = 20,
|
||||
) -> List[HistoryEntry]:
|
||||
"""
|
||||
Search commit messages.
|
||||
|
||||
Args:
|
||||
space_id: Space identifier
|
||||
query: Search query
|
||||
limit: Maximum results
|
||||
|
||||
Returns:
|
||||
Matching history entries
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def restore_version(
|
||||
self,
|
||||
space_id: str,
|
||||
document_path: str,
|
||||
version: str,
|
||||
) -> bool:
|
||||
"""
|
||||
Restore a document to a specific version.
|
||||
|
||||
Args:
|
||||
space_id: Space identifier
|
||||
document_path: Path to document
|
||||
version: Version to restore
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
pass
|
||||
257
markitect/spaces/history/models.py
Normal file
257
markitect/spaces/history/models.py
Normal file
@@ -0,0 +1,257 @@
|
||||
"""
|
||||
Domain models for history tracking.
|
||||
|
||||
Defines data structures for commits, branches, diffs, and history entries.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, List, Optional
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class DiffType(Enum):
|
||||
"""Type of diff line."""
|
||||
CONTEXT = "context" # Unchanged line
|
||||
ADDITION = "addition" # Added line
|
||||
DELETION = "deletion" # Removed line
|
||||
|
||||
|
||||
@dataclass
|
||||
class DiffLine:
|
||||
"""
|
||||
A single line in a diff.
|
||||
|
||||
Attributes:
|
||||
line_type: Type of line (context, addition, deletion)
|
||||
content: Line content
|
||||
old_line_no: Line number in old version (None for additions)
|
||||
new_line_no: Line number in new version (None for deletions)
|
||||
"""
|
||||
line_type: DiffType
|
||||
content: str
|
||||
old_line_no: Optional[int] = None
|
||||
new_line_no: Optional[int] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class DiffResult:
|
||||
"""
|
||||
Result of comparing two versions.
|
||||
|
||||
Attributes:
|
||||
path: File path being compared
|
||||
old_version: Old version identifier
|
||||
new_version: New version identifier
|
||||
lines: List of diff lines
|
||||
additions: Number of lines added
|
||||
deletions: Number of lines deleted
|
||||
is_binary: Whether the file is binary
|
||||
"""
|
||||
path: str
|
||||
old_version: Optional[str] = None
|
||||
new_version: Optional[str] = None
|
||||
lines: List[DiffLine] = field(default_factory=list)
|
||||
additions: int = 0
|
||||
deletions: int = 0
|
||||
is_binary: bool = False
|
||||
|
||||
def to_unified_diff(self) -> str:
|
||||
"""Generate unified diff format string."""
|
||||
result = []
|
||||
result.append(f"--- a/{self.path}")
|
||||
result.append(f"+++ b/{self.path}")
|
||||
|
||||
for line in self.lines:
|
||||
if line.line_type == DiffType.ADDITION:
|
||||
result.append(f"+{line.content}")
|
||||
elif line.line_type == DiffType.DELETION:
|
||||
result.append(f"-{line.content}")
|
||||
else:
|
||||
result.append(f" {line.content}")
|
||||
|
||||
return "\n".join(result)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Commit:
|
||||
"""
|
||||
Represents a commit in the history.
|
||||
|
||||
Attributes:
|
||||
id: Commit identifier (hash)
|
||||
message: Commit message
|
||||
author: Author name/email
|
||||
timestamp: Commit timestamp
|
||||
parent_ids: Parent commit IDs
|
||||
space_id: Associated space ID
|
||||
files_changed: List of changed file paths
|
||||
metadata: Additional commit metadata
|
||||
"""
|
||||
id: str
|
||||
message: str
|
||||
author: str = "markitect"
|
||||
timestamp: datetime = field(default_factory=datetime.now)
|
||||
parent_ids: List[str] = field(default_factory=list)
|
||||
space_id: Optional[str] = None
|
||||
files_changed: List[str] = field(default_factory=list)
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
return {
|
||||
"id": self.id,
|
||||
"message": self.message,
|
||||
"author": self.author,
|
||||
"timestamp": self.timestamp.isoformat(),
|
||||
"parent_ids": self.parent_ids,
|
||||
"space_id": self.space_id,
|
||||
"files_changed": self.files_changed,
|
||||
"metadata": self.metadata,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "Commit":
|
||||
"""Create from dictionary."""
|
||||
timestamp = data.get("timestamp")
|
||||
if isinstance(timestamp, str):
|
||||
timestamp = datetime.fromisoformat(timestamp)
|
||||
elif timestamp is None:
|
||||
timestamp = datetime.now()
|
||||
|
||||
return cls(
|
||||
id=data["id"],
|
||||
message=data.get("message", ""),
|
||||
author=data.get("author", "markitect"),
|
||||
timestamp=timestamp,
|
||||
parent_ids=data.get("parent_ids", []),
|
||||
space_id=data.get("space_id"),
|
||||
files_changed=data.get("files_changed", []),
|
||||
metadata=data.get("metadata", {}),
|
||||
)
|
||||
|
||||
@property
|
||||
def short_id(self) -> str:
|
||||
"""Get short commit ID (first 7 chars)."""
|
||||
return self.id[:7] if len(self.id) >= 7 else self.id
|
||||
|
||||
|
||||
@dataclass
|
||||
class Branch:
|
||||
"""
|
||||
Represents a branch in the history.
|
||||
|
||||
Attributes:
|
||||
name: Branch name
|
||||
head_commit_id: Current commit ID
|
||||
is_current: Whether this is the current branch
|
||||
tracking: Remote tracking branch (if any)
|
||||
created_at: When the branch was created
|
||||
"""
|
||||
name: str
|
||||
head_commit_id: str
|
||||
is_current: bool = False
|
||||
tracking: Optional[str] = None
|
||||
created_at: Optional[datetime] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
return {
|
||||
"name": self.name,
|
||||
"head_commit_id": self.head_commit_id,
|
||||
"is_current": self.is_current,
|
||||
"tracking": self.tracking,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class HistoryEntry:
|
||||
"""
|
||||
A single entry in the history log.
|
||||
|
||||
Combines commit information with change summary.
|
||||
|
||||
Attributes:
|
||||
commit: The commit
|
||||
summary: Short summary of changes
|
||||
files_added: Number of files added
|
||||
files_modified: Number of files modified
|
||||
files_deleted: Number of files deleted
|
||||
"""
|
||||
commit: Commit
|
||||
summary: str = ""
|
||||
files_added: int = 0
|
||||
files_modified: int = 0
|
||||
files_deleted: int = 0
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
return {
|
||||
"commit": self.commit.to_dict(),
|
||||
"summary": self.summary,
|
||||
"files_added": self.files_added,
|
||||
"files_modified": self.files_modified,
|
||||
"files_deleted": self.files_deleted,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class HistoryConfig:
|
||||
"""
|
||||
Configuration for history tracking.
|
||||
|
||||
Attributes:
|
||||
enabled: Whether history tracking is enabled
|
||||
backend: Backend type ("git" or "sqlite")
|
||||
auto_commit: Whether to auto-commit on changes
|
||||
commit_on_events: List of event types that trigger commits
|
||||
author_name: Default author name
|
||||
author_email: Default author email
|
||||
directory: Canonical directory path (for git)
|
||||
options: Additional backend-specific options
|
||||
"""
|
||||
enabled: bool = False
|
||||
backend: str = "git"
|
||||
auto_commit: bool = True
|
||||
commit_on_events: List[str] = field(default_factory=lambda: [
|
||||
"DOCUMENT_ADDED",
|
||||
"DOCUMENT_REMOVED",
|
||||
"DOCUMENT_CONTENT_CHANGED",
|
||||
])
|
||||
author_name: str = "markitect"
|
||||
author_email: str = "markitect@local"
|
||||
directory: Optional[str] = None
|
||||
options: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
return {
|
||||
"enabled": self.enabled,
|
||||
"backend": self.backend,
|
||||
"auto_commit": self.auto_commit,
|
||||
"commit_on_events": self.commit_on_events,
|
||||
"author_name": self.author_name,
|
||||
"author_email": self.author_email,
|
||||
"directory": self.directory,
|
||||
"options": self.options,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "HistoryConfig":
|
||||
"""Create from dictionary."""
|
||||
return cls(
|
||||
enabled=data.get("enabled", False),
|
||||
backend=data.get("backend", "git"),
|
||||
auto_commit=data.get("auto_commit", True),
|
||||
commit_on_events=data.get("commit_on_events", [
|
||||
"DOCUMENT_ADDED",
|
||||
"DOCUMENT_REMOVED",
|
||||
"DOCUMENT_CONTENT_CHANGED",
|
||||
]),
|
||||
author_name=data.get("author_name", "markitect"),
|
||||
author_email=data.get("author_email", "markitect@local"),
|
||||
directory=data.get("directory"),
|
||||
options=data.get("options", {}),
|
||||
)
|
||||
258
markitect/spaces/history/queries.py
Normal file
258
markitect/spaces/history/queries.py
Normal file
@@ -0,0 +1,258 @@
|
||||
"""
|
||||
History query service implementation.
|
||||
|
||||
Provides high-level queries on top of the history backend.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Dict, Callable
|
||||
|
||||
from .interfaces import IHistoryBackend, IHistoryQuery
|
||||
from .models import HistoryEntry, DiffResult, Commit
|
||||
|
||||
|
||||
class HistoryQueryService(IHistoryQuery):
|
||||
"""
|
||||
Service for querying history information.
|
||||
|
||||
Provides convenience methods on top of the raw history backend.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
backend: IHistoryBackend,
|
||||
directory_resolver: Callable[[str], Optional[Path]],
|
||||
):
|
||||
"""
|
||||
Initialize the query service.
|
||||
|
||||
Args:
|
||||
backend: History backend to query
|
||||
directory_resolver: Function that resolves space_id to directory path
|
||||
"""
|
||||
self._backend = backend
|
||||
self._directory_resolver = directory_resolver
|
||||
|
||||
def get_history(
|
||||
self,
|
||||
space_id: str,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
path: Optional[str] = None,
|
||||
) -> List[HistoryEntry]:
|
||||
"""Get history entries for a space."""
|
||||
directory = self._directory_resolver(space_id)
|
||||
if not directory or not self._backend.is_initialized(directory):
|
||||
return []
|
||||
|
||||
commits = self._backend.get_log(directory, limit, offset, path)
|
||||
return [self._commit_to_entry(commit, directory) for commit in commits]
|
||||
|
||||
def _commit_to_entry(self, commit: Commit, directory: Path) -> HistoryEntry:
|
||||
"""Convert a commit to a history entry with summary."""
|
||||
# Count file changes by type
|
||||
files_added = 0
|
||||
files_modified = 0
|
||||
files_deleted = 0
|
||||
|
||||
# Get diff to determine change types
|
||||
if commit.parent_ids:
|
||||
parent = commit.parent_ids[0]
|
||||
diffs = self._backend.get_diff(directory, parent, commit.id)
|
||||
for diff in diffs:
|
||||
if diff.additions > 0 and diff.deletions == 0:
|
||||
files_added += 1
|
||||
elif diff.additions == 0 and diff.deletions > 0:
|
||||
files_deleted += 1
|
||||
else:
|
||||
files_modified += 1
|
||||
else:
|
||||
# First commit - all files are additions
|
||||
files_added = len(commit.files_changed)
|
||||
|
||||
# Build summary
|
||||
parts = []
|
||||
if files_added:
|
||||
parts.append(f"+{files_added}")
|
||||
if files_modified:
|
||||
parts.append(f"~{files_modified}")
|
||||
if files_deleted:
|
||||
parts.append(f"-{files_deleted}")
|
||||
|
||||
summary = ", ".join(parts) if parts else "No changes"
|
||||
|
||||
return HistoryEntry(
|
||||
commit=commit,
|
||||
summary=summary,
|
||||
files_added=files_added,
|
||||
files_modified=files_modified,
|
||||
files_deleted=files_deleted,
|
||||
)
|
||||
|
||||
def get_version_content(
|
||||
self,
|
||||
space_id: str,
|
||||
document_path: str,
|
||||
version: str,
|
||||
) -> Optional[str]:
|
||||
"""Get document content at a specific version."""
|
||||
directory = self._directory_resolver(space_id)
|
||||
if not directory or not self._backend.is_initialized(directory):
|
||||
return None
|
||||
|
||||
# Normalize path (remove leading /)
|
||||
if document_path.startswith("/"):
|
||||
document_path = document_path[1:]
|
||||
|
||||
return self._backend.get_file_at_version(directory, document_path, version)
|
||||
|
||||
def compare_versions(
|
||||
self,
|
||||
space_id: str,
|
||||
document_path: str,
|
||||
old_version: str,
|
||||
new_version: Optional[str] = None,
|
||||
) -> Optional[DiffResult]:
|
||||
"""Compare two versions of a document."""
|
||||
directory = self._directory_resolver(space_id)
|
||||
if not directory or not self._backend.is_initialized(directory):
|
||||
return None
|
||||
|
||||
# Normalize path
|
||||
if document_path.startswith("/"):
|
||||
document_path = document_path[1:]
|
||||
|
||||
diffs = self._backend.get_diff(directory, old_version, new_version, document_path)
|
||||
return diffs[0] if diffs else None
|
||||
|
||||
def search_history(
|
||||
self,
|
||||
space_id: str,
|
||||
query: str,
|
||||
limit: int = 20,
|
||||
) -> List[HistoryEntry]:
|
||||
"""Search commit messages."""
|
||||
directory = self._directory_resolver(space_id)
|
||||
if not directory or not self._backend.is_initialized(directory):
|
||||
return []
|
||||
|
||||
# Get all recent commits
|
||||
commits = self._backend.get_log(directory, limit=limit * 2)
|
||||
|
||||
# Filter by query in message
|
||||
matching_commits = [
|
||||
c for c in commits
|
||||
if query.lower() in c.message.lower()
|
||||
][:limit]
|
||||
|
||||
return [self._commit_to_entry(commit, directory) for commit in matching_commits]
|
||||
|
||||
def restore_version(
|
||||
self,
|
||||
space_id: str,
|
||||
document_path: str,
|
||||
version: str,
|
||||
) -> bool:
|
||||
"""Restore a document to a specific version."""
|
||||
directory = self._directory_resolver(space_id)
|
||||
if not directory or not self._backend.is_initialized(directory):
|
||||
return False
|
||||
|
||||
# Normalize path
|
||||
if document_path.startswith("/"):
|
||||
document_path = document_path[1:]
|
||||
|
||||
# Get content at version
|
||||
content = self._backend.get_file_at_version(directory, document_path, version)
|
||||
if content is None:
|
||||
return False
|
||||
|
||||
# Write to working directory
|
||||
file_path = directory / document_path
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
file_path.write_text(content)
|
||||
return True
|
||||
|
||||
def get_latest_commit(self, space_id: str) -> Optional[Commit]:
|
||||
"""Get the latest commit for a space."""
|
||||
directory = self._directory_resolver(space_id)
|
||||
if not directory or not self._backend.is_initialized(directory):
|
||||
return None
|
||||
|
||||
commits = self._backend.get_log(directory, limit=1)
|
||||
return commits[0] if commits else None
|
||||
|
||||
def get_commit_by_id(self, space_id: str, commit_id: str) -> Optional[Commit]:
|
||||
"""Get a specific commit by ID."""
|
||||
directory = self._directory_resolver(space_id)
|
||||
if not directory:
|
||||
return None
|
||||
|
||||
return self._backend.get_commit(directory, commit_id)
|
||||
|
||||
def get_file_history(
|
||||
self,
|
||||
space_id: str,
|
||||
document_path: str,
|
||||
limit: int = 20,
|
||||
) -> List[HistoryEntry]:
|
||||
"""Get history for a specific file."""
|
||||
# Normalize path
|
||||
if document_path.startswith("/"):
|
||||
document_path = document_path[1:]
|
||||
|
||||
return self.get_history(space_id, limit=limit, path=document_path)
|
||||
|
||||
def has_history(self, space_id: str) -> bool:
|
||||
"""Check if a space has history tracking enabled."""
|
||||
directory = self._directory_resolver(space_id)
|
||||
if not directory:
|
||||
return False
|
||||
return self._backend.is_initialized(directory)
|
||||
|
||||
def get_branches(self, space_id: str) -> List[str]:
|
||||
"""Get list of branch names for a space."""
|
||||
directory = self._directory_resolver(space_id)
|
||||
if not directory or not self._backend.is_initialized(directory):
|
||||
return []
|
||||
|
||||
branches = self._backend.list_branches(directory)
|
||||
return [b.name for b in branches]
|
||||
|
||||
def get_current_branch(self, space_id: str) -> Optional[str]:
|
||||
"""Get the current branch name."""
|
||||
directory = self._directory_resolver(space_id)
|
||||
if not directory:
|
||||
return None
|
||||
return self._backend.get_current_branch(directory)
|
||||
|
||||
def create_branch(
|
||||
self,
|
||||
space_id: str,
|
||||
name: str,
|
||||
start_point: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""Create a new branch."""
|
||||
directory = self._directory_resolver(space_id)
|
||||
if not directory or not self._backend.is_initialized(directory):
|
||||
return False
|
||||
|
||||
try:
|
||||
self._backend.create_branch(directory, name, start_point)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def switch_branch(self, space_id: str, name: str) -> bool:
|
||||
"""Switch to a different branch."""
|
||||
directory = self._directory_resolver(space_id)
|
||||
if not directory:
|
||||
return False
|
||||
return self._backend.switch_branch(directory, name)
|
||||
|
||||
def has_uncommitted_changes(self, space_id: str) -> bool:
|
||||
"""Check if space has uncommitted changes."""
|
||||
directory = self._directory_resolver(space_id)
|
||||
if not directory:
|
||||
return False
|
||||
return self._backend.has_changes(directory)
|
||||
Reference in New Issue
Block a user