Files
markitect-main/markitect/spaces/history/models.py
tegwick 4588cbeee8 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>
2026-02-08 18:03:35 +01:00

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