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>
258 lines
7.6 KiB
Python
258 lines
7.6 KiB
Python
"""
|
|
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", {}),
|
|
)
|