Files
markitect-main/markitect/legacy/git_tracker.py
tegwick a367628cab feat: Complete Issue #39 - Database CLI Reorganization with Comprehensive Legacy Compatibility System
## Database Command Reorganization
- Add new db-prefixed commands: db-query, db-schema, db-delete, db-status
- Maintain backward compatibility with deprecation warnings for query/schema commands
- Implement lazy database initialization to reduce CLI coupling
- Add command-specific --database options for flexibility

## Legacy Compatibility Framework
- Create comprehensive legacy compatibility system in markitect/legacy_compat.py
- Support versioned legacy switches (--legacy-v39-pre) for smooth transitions
- Implement git commit binding for version tracking (Issue #39: v39-pre → 3168de4)
- Add environment-based legacy mode detection for test environments
- Create graduated deprecation warning system (DEPRECATED → LEGACY → SUNSET)

## Legacy Agent System
- Implement intelligent legacy lifecycle management agent
- Add 8 CLI commands for legacy interface management (status, analyze, migrate, cleanup, etc.)
- Create automated maintenance with usage analytics and data-driven decisions
- Provide comprehensive safety features with backup and rollback capabilities

## Test Architecture Enhancement
- Add 18 comprehensive tests for Issue #39 functionality (16 passing, 2 skipped by design)
- Configure pytest.ini with MARKITECT_LEGACY_MODE=39-pre for automatic legacy support
- Update test count to 466 total tests across 7 architectural layers
- Identify 5 legacy interface tests for future recreation without legacy dependencies

## Documentation & Roadmap Updates
- Update NEXT.md with completed Issues #39 and #40
- Document failing tests requiring recreation with pure db- commands
- Add comprehensive legacy agent documentation
- Update development priorities and capability descriptions

## Architecture Achievements
- Simplified CLI architecture with reduced coupling between commands and global state
- Created reusable legacy compatibility framework for future breaking changes
- Established systematic approach to interface deprecation and migration
- Maintained 461/466 tests passing (5 legacy interface tests flagged for recreation)

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-30 17:28:39 +02:00

408 lines
13 KiB
Python

"""
Git State Tracker - Bind legacy versions to specific git commits.
Provides functionality to track git repository state and bind legacy versions
to specific commits, enabling precise version restoration and compatibility.
"""
import os
import subprocess
import json
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Optional, Tuple, Any
from dataclasses import dataclass, asdict
from .exceptions import GitStateError
@dataclass
class GitState:
"""Represents a git repository state."""
commit_hash: str
commit_message: str
author: str
date: str
branch: str
tag: Optional[str] = None
is_dirty: bool = False
modified_files: List[str] = None
def __post_init__(self):
if self.modified_files is None:
self.modified_files = []
@dataclass
class LegacyBinding:
"""Represents a binding between a legacy version and git state."""
command: str
version: str
git_state: GitState
bound_at: str
description: str = ""
validation_files: List[str] = None
def __post_init__(self):
if self.validation_files is None:
self.validation_files = []
class GitStateTracker:
"""
Tracks git repository state and manages version bindings.
Responsibilities:
- Capture current git state information
- Bind legacy versions to specific commits
- Validate git state for legacy implementations
- Restore git state for testing legacy versions
- Track changes between versions
"""
def __init__(self, repo_path: Optional[Path] = None):
"""
Initialize the git state tracker.
Args:
repo_path: Path to the git repository (default: current directory)
"""
self.repo_path = repo_path or Path.cwd()
self._bindings: Dict[str, Dict[str, LegacyBinding]] = {}
def get_current_state(self) -> GitState:
"""
Get the current git repository state.
Returns:
GitState object representing current state
Raises:
GitStateError: If git operations fail
"""
try:
# Get current commit information
commit_info = self._run_git_command([
'log', '-1', '--format=%H|%s|%an|%ai'
]).strip()
if not commit_info:
raise GitStateError("No commits found in repository")
hash_val, message, author, date = commit_info.split('|', 3)
# Get current branch
try:
branch = self._run_git_command(['rev-parse', '--abbrev-ref', 'HEAD']).strip()
except subprocess.CalledProcessError:
branch = "HEAD" # Detached HEAD state
# Check for tags on current commit
try:
tag = self._run_git_command(['describe', '--exact-match', '--tags', 'HEAD']).strip()
except subprocess.CalledProcessError:
tag = None
# Check if repository is dirty
status_output = self._run_git_command(['status', '--porcelain'])
is_dirty = bool(status_output.strip())
# Get modified files if dirty
modified_files = []
if is_dirty:
modified_files = [
line[3:] for line in status_output.strip().split('\n')
if line.strip()
]
return GitState(
commit_hash=hash_val,
commit_message=message,
author=author,
date=date,
branch=branch,
tag=tag,
is_dirty=is_dirty,
modified_files=modified_files
)
except subprocess.CalledProcessError as e:
raise GitStateError(f"Git command failed: {e}")
except Exception as e:
raise GitStateError(f"Failed to get git state: {e}")
def bind_version_to_commit(
self,
command: str,
version: str,
commit_hash: Optional[str] = None,
description: str = "",
validation_files: List[str] = None
) -> LegacyBinding:
"""
Bind a legacy version to a specific git commit.
Args:
command: Command name
version: Version identifier
commit_hash: Git commit hash (default: current commit)
description: Description of this binding
validation_files: Files to validate for this version
Returns:
LegacyBinding object
Raises:
GitStateError: If git operations fail
"""
if validation_files is None:
validation_files = []
# Get git state for the specified or current commit
if commit_hash:
git_state = self._get_commit_state(commit_hash)
else:
git_state = self.get_current_state()
commit_hash = git_state.commit_hash
# Create binding
binding = LegacyBinding(
command=command,
version=version,
git_state=git_state,
bound_at=datetime.now().isoformat(),
description=description,
validation_files=validation_files
)
# Store binding
if command not in self._bindings:
self._bindings[command] = {}
self._bindings[command][version] = binding
return binding
def get_version_binding(self, command: str, version: str) -> Optional[LegacyBinding]:
"""
Get the git binding for a specific version.
Args:
command: Command name
version: Version identifier
Returns:
LegacyBinding if found, None otherwise
"""
return self._bindings.get(command, {}).get(version)
def get_commit_for_version(self, command: str, version: str) -> Optional[str]:
"""
Get the git commit hash for a legacy version.
Args:
command: Command name
version: Version identifier
Returns:
Commit hash if found, None otherwise
"""
binding = self.get_version_binding(command, version)
return binding.git_state.commit_hash if binding else None
def validate_version_files(self, command: str, version: str) -> Dict[str, bool]:
"""
Validate that files exist for a legacy version.
Args:
command: Command name
version: Version identifier
Returns:
Dictionary mapping file paths to existence status
"""
binding = self.get_version_binding(command, version)
if not binding or not binding.validation_files:
return {}
validation_results = {}
for file_path in binding.validation_files:
full_path = self.repo_path / file_path
validation_results[file_path] = full_path.exists()
return validation_results
def get_changes_since_version(self, command: str, version: str) -> Dict[str, List[str]]:
"""
Get changes made since a legacy version was bound.
Args:
command: Command name
version: Version identifier
Returns:
Dictionary with added, modified, and deleted files
"""
binding = self.get_version_binding(command, version)
if not binding:
raise GitStateError(f"No binding found for {command} {version}")
try:
# Get diff between bound commit and current state
diff_output = self._run_git_command([
'diff', '--name-status', binding.git_state.commit_hash, 'HEAD'
])
changes = {
'added': [],
'modified': [],
'deleted': []
}
for line in diff_output.strip().split('\n'):
if not line:
continue
status, filename = line.split('\t', 1)
if status == 'A':
changes['added'].append(filename)
elif status == 'M':
changes['modified'].append(filename)
elif status == 'D':
changes['deleted'].append(filename)
return changes
except subprocess.CalledProcessError as e:
raise GitStateError(f"Failed to get changes: {e}")
def create_version_snapshot(self, command: str, version: str, output_dir: Path):
"""
Create a snapshot of files at the time a version was bound.
Args:
command: Command name
version: Version identifier
output_dir: Directory to write snapshot files
Raises:
GitStateError: If git operations fail
"""
binding = self.get_version_binding(command, version)
if not binding:
raise GitStateError(f"No binding found for {command} {version}")
output_dir.mkdir(parents=True, exist_ok=True)
try:
# Export files from the bound commit
if binding.validation_files:
for file_path in binding.validation_files:
try:
content = self._run_git_command([
'show', f"{binding.git_state.commit_hash}:{file_path}"
])
output_file = output_dir / file_path
output_file.parent.mkdir(parents=True, exist_ok=True)
output_file.write_text(content, encoding='utf-8')
except subprocess.CalledProcessError:
# File might not have existed at that commit
pass
# Write metadata
metadata = {
'command': command,
'version': version,
'git_state': asdict(binding.git_state),
'bound_at': binding.bound_at,
'description': binding.description,
'validation_files': binding.validation_files
}
metadata_file = output_dir / 'version_metadata.json'
metadata_file.write_text(json.dumps(metadata, indent=2), encoding='utf-8')
except subprocess.CalledProcessError as e:
raise GitStateError(f"Failed to create snapshot: {e}")
def _get_commit_state(self, commit_hash: str) -> GitState:
"""Get git state for a specific commit."""
try:
# Get commit information
commit_info = self._run_git_command([
'log', '-1', '--format=%H|%s|%an|%ai', commit_hash
]).strip()
hash_val, message, author, date = commit_info.split('|', 3)
# Check for tags on this commit
try:
tag = self._run_git_command([
'describe', '--exact-match', '--tags', commit_hash
]).strip()
except subprocess.CalledProcessError:
tag = None
return GitState(
commit_hash=hash_val,
commit_message=message,
author=author,
date=date,
branch="unknown", # Can't determine branch for historical commit
tag=tag,
is_dirty=False,
modified_files=[]
)
except subprocess.CalledProcessError as e:
raise GitStateError(f"Invalid commit hash {commit_hash}: {e}")
def _run_git_command(self, args: List[str]) -> str:
"""Run a git command and return output."""
cmd = ['git'] + args
try:
result = subprocess.run(
cmd,
cwd=self.repo_path,
capture_output=True,
text=True,
check=True
)
return result.stdout
except subprocess.CalledProcessError as e:
raise GitStateError(f"Git command failed: {' '.join(cmd)}\n{e.stderr}")
def export_bindings(self) -> Dict[str, Any]:
"""Export all version bindings for backup/sharing."""
bindings_data = {}
for command, versions in self._bindings.items():
bindings_data[command] = {}
for version, binding in versions.items():
bindings_data[command][version] = asdict(binding)
return {
'version': '1.0',
'exported_at': datetime.now().isoformat(),
'bindings': bindings_data
}
def import_bindings(self, data: Dict[str, Any]):
"""Import version bindings from exported data."""
if data.get('version') != '1.0':
raise GitStateError("Unsupported bindings format version")
for command, versions in data.get('bindings', {}).items():
if command not in self._bindings:
self._bindings[command] = {}
for version, binding_data in versions.items():
git_state = GitState(**binding_data['git_state'])
binding = LegacyBinding(
command=binding_data['command'],
version=binding_data['version'],
git_state=git_state,
bound_at=binding_data['bound_at'],
description=binding_data.get('description', ''),
validation_files=binding_data.get('validation_files', [])
)
self._bindings[command][version] = binding