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:
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