Files
markitect-main/markitect/spaces/history/queries.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

259 lines
8.3 KiB
Python

"""
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)