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:
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", {}),
|
||||
)
|
||||
Reference in New Issue
Block a user