""" Tests for Phase 8: History Tracking. Tests git-based version control, event-driven commits, and history queries. """ import pytest import tempfile import subprocess from pathlib import Path from datetime import datetime from markitect.spaces.history import ( # Models Commit, Branch, HistoryEntry, DiffResult, DiffLine, DiffType, HistoryConfig, # Backend GitHistoryBackend, GitError, # Events GitHistoryEventHandler, HistoryEventCoordinator, # Queries HistoryQueryService, ) from markitect.spaces import ( SpaceEvent, SpaceEventType, EventBus, ) # =========================================================================== # Fixtures # =========================================================================== def check_git_available() -> bool: """Check if git is available.""" try: result = subprocess.run( ["git", "--version"], capture_output=True, timeout=5, ) return result.returncode == 0 except (FileNotFoundError, subprocess.TimeoutExpired): return False # Skip all tests if git is not available pytestmark = pytest.mark.skipif( not check_git_available(), reason="git not available" ) @pytest.fixture def temp_dir(): """Create a temporary directory.""" with tempfile.TemporaryDirectory() as tmpdir: yield Path(tmpdir) @pytest.fixture def git_backend(): """Create a git backend instance.""" return GitHistoryBackend() @pytest.fixture def history_config(): """Create a history configuration.""" return HistoryConfig( enabled=True, backend="git", auto_commit=True, author_name="Test User", author_email="test@example.com", ) @pytest.fixture def initialized_repo(temp_dir, git_backend, history_config): """Create an initialized git repository.""" git_backend.initialize(temp_dir, history_config) return temp_dir @pytest.fixture def event_bus(): """Create an event bus.""" return EventBus() # =========================================================================== # HistoryConfig Tests # =========================================================================== class TestHistoryConfig: """Tests for HistoryConfig model.""" def test_default_config(self): """Test default configuration.""" config = HistoryConfig() assert config.enabled is False assert config.backend == "git" assert config.auto_commit is True assert config.author_name == "markitect" def test_config_with_custom_values(self): """Test configuration with custom values.""" config = HistoryConfig( enabled=True, backend="git", author_name="John Doe", author_email="john@example.com", directory="/path/to/space", ) assert config.enabled is True assert config.author_name == "John Doe" assert config.directory == "/path/to/space" def test_config_serialization(self): """Test config to/from dict.""" config = HistoryConfig( enabled=True, author_name="Test", commit_on_events=["DOCUMENT_ADDED"], ) data = config.to_dict() restored = HistoryConfig.from_dict(data) assert restored.enabled == config.enabled assert restored.author_name == config.author_name assert restored.commit_on_events == config.commit_on_events # =========================================================================== # Commit Model Tests # =========================================================================== class TestCommitModel: """Tests for Commit model.""" def test_create_commit(self): """Test creating a commit.""" commit = Commit( id="abc123", message="Initial commit", author="Test User ", ) assert commit.id == "abc123" assert commit.message == "Initial commit" assert commit.short_id == "abc123" def test_commit_serialization(self): """Test commit to/from dict.""" commit = Commit( id="abc123def456", message="Test commit", author="Test ", files_changed=["file1.md", "file2.md"], metadata={"key": "value"}, ) data = commit.to_dict() restored = Commit.from_dict(data) assert restored.id == commit.id assert restored.message == commit.message assert restored.files_changed == commit.files_changed assert restored.metadata == commit.metadata def test_short_id(self): """Test short commit ID.""" commit = Commit(id="abcdef1234567890", message="Test") assert commit.short_id == "abcdef1" # Short ID for short hash short_commit = Commit(id="abc", message="Test") assert short_commit.short_id == "abc" # =========================================================================== # DiffResult Tests # =========================================================================== class TestDiffResult: """Tests for DiffResult model.""" def test_create_diff(self): """Test creating a diff result.""" diff = DiffResult( path="test.md", old_version="v1", new_version="v2", ) assert diff.path == "test.md" assert diff.additions == 0 assert diff.deletions == 0 def test_unified_diff_format(self): """Test unified diff generation.""" diff = DiffResult(path="test.md") diff.lines = [ DiffLine(DiffType.CONTEXT, "unchanged line", 1, 1), DiffLine(DiffType.ADDITION, "added line", None, 2), DiffLine(DiffType.DELETION, "removed line", 2, None), ] diff.additions = 1 diff.deletions = 1 unified = diff.to_unified_diff() assert "--- a/test.md" in unified assert "+++ b/test.md" in unified assert "+added line" in unified assert "-removed line" in unified assert " unchanged line" in unified # =========================================================================== # GitHistoryBackend Tests # =========================================================================== class TestGitHistoryBackend: """Tests for GitHistoryBackend.""" def test_initialize_creates_repo(self, temp_dir, git_backend, history_config): """Test initializing a git repository.""" git_backend.initialize(temp_dir, history_config) assert (temp_dir / ".git").exists() assert git_backend.is_initialized(temp_dir) def test_initialize_idempotent(self, temp_dir, git_backend, history_config): """Test that initialize can be called multiple times.""" git_backend.initialize(temp_dir, history_config) git_backend.initialize(temp_dir, history_config) assert git_backend.is_initialized(temp_dir) def test_is_initialized_false_for_non_repo(self, temp_dir, git_backend): """Test is_initialized returns False for non-repo.""" assert not git_backend.is_initialized(temp_dir) def test_commit_creates_commit(self, initialized_repo, git_backend): """Test creating a commit.""" # Create a file test_file = initialized_repo / "test.md" test_file.write_text("# Test") # Commit commit = git_backend.commit( initialized_repo, "Add test file", author="Test User ", ) assert commit.message == "Add test file" assert commit.id is not None assert "test.md" in commit.files_changed def test_commit_with_metadata(self, initialized_repo, git_backend): """Test commit with metadata trailers.""" test_file = initialized_repo / "doc.md" test_file.write_text("Content") commit = git_backend.commit( initialized_repo, "Add doc", metadata={"space_id": "space-1", "event_type": "test"}, ) # Git trailers are stored as "key: value" in commit body # They should be parseable assert commit.message == "Add doc" assert commit.id is not None # Metadata parsing from git trailers may vary by git version # Just verify commit succeeds def test_commit_specific_files(self, initialized_repo, git_backend): """Test committing specific files.""" file1 = initialized_repo / "file1.md" file2 = initialized_repo / "file2.md" file1.write_text("File 1") file2.write_text("File 2") # Commit only file1 commit = git_backend.commit( initialized_repo, "Add file1", files=["file1.md"], ) assert "file1.md" in commit.files_changed # file2 should still be uncommitted assert git_backend.has_changes(initialized_repo) def test_commit_no_changes_raises_error(self, initialized_repo, git_backend): """Test that committing with no changes raises error.""" with pytest.raises(GitError, match="No changes"): git_backend.commit(initialized_repo, "Empty commit") def test_get_commit(self, initialized_repo, git_backend): """Test getting a commit by ID.""" test_file = initialized_repo / "test.md" test_file.write_text("Content") commit = git_backend.commit(initialized_repo, "Test commit") retrieved = git_backend.get_commit(initialized_repo, commit.id) assert retrieved is not None assert retrieved.id == commit.id assert retrieved.message == "Test commit" def test_get_commit_not_found(self, initialized_repo, git_backend): """Test getting non-existent commit returns None.""" result = git_backend.get_commit(initialized_repo, "nonexistent") assert result is None def test_get_log(self, initialized_repo, git_backend): """Test getting commit history.""" # Create multiple commits for i in range(3): file = initialized_repo / f"file{i}.md" file.write_text(f"File {i}") git_backend.commit(initialized_repo, f"Commit {i}") # Get log commits = git_backend.get_log(initialized_repo, limit=10) # Should have initial commit + 3 new commits assert len(commits) >= 3 assert commits[0].message == "Commit 2" # Most recent first assert commits[1].message == "Commit 1" def test_get_log_with_limit(self, initialized_repo, git_backend): """Test log with limit.""" for i in range(5): file = initialized_repo / f"file{i}.md" file.write_text(f"File {i}") git_backend.commit(initialized_repo, f"Commit {i}") commits = git_backend.get_log(initialized_repo, limit=2) assert len(commits) == 2 def test_get_log_with_path_filter(self, initialized_repo, git_backend): """Test log filtered by path.""" file1 = initialized_repo / "file1.md" file2 = initialized_repo / "file2.md" file1.write_text("File 1") git_backend.commit(initialized_repo, "Add file1") file2.write_text("File 2") git_backend.commit(initialized_repo, "Add file2") file1.write_text("File 1 updated") git_backend.commit(initialized_repo, "Update file1") # Get log for file1 only commits = git_backend.get_log(initialized_repo, path="file1.md") # Should have 2 commits for file1 (add and update) assert len(commits) >= 2 assert all("file1" in c.message for c in commits[:2]) def test_get_diff(self, initialized_repo, git_backend): """Test getting diff between commits.""" file = initialized_repo / "test.md" file.write_text("Line 1\nLine 2\n") commit1 = git_backend.commit(initialized_repo, "Initial") file.write_text("Line 1\nLine 2 modified\nLine 3\n") commit2 = git_backend.commit(initialized_repo, "Update") diffs = git_backend.get_diff(initialized_repo, commit1.id, commit2.id) assert len(diffs) > 0 diff = diffs[0] assert diff.path == "test.md" assert diff.additions > 0 or diff.deletions > 0 def test_checkout_version(self, initialized_repo, git_backend): """Test checking out a specific version.""" file = initialized_repo / "test.md" file.write_text("Version 1") commit1 = git_backend.commit(initialized_repo, "V1") file.write_text("Version 2") git_backend.commit(initialized_repo, "V2") # Checkout file at commit1 success = git_backend.checkout(initialized_repo, commit1.id, "test.md") assert success assert file.read_text() == "Version 1" def test_get_file_at_version(self, initialized_repo, git_backend): """Test getting file content at specific version.""" file = initialized_repo / "test.md" file.write_text("Version 1") commit1 = git_backend.commit(initialized_repo, "V1") file.write_text("Version 2") git_backend.commit(initialized_repo, "V2") # Get content at commit1 content = git_backend.get_file_at_version( initialized_repo, "test.md", commit1.id ) assert content == "Version 1" def test_get_file_at_version_not_found(self, initialized_repo, git_backend): """Test getting non-existent file returns None.""" content = git_backend.get_file_at_version( initialized_repo, "nonexistent.md", "HEAD" ) assert content is None def test_list_branches(self, initialized_repo, git_backend): """Test listing branches.""" branches = git_backend.list_branches(initialized_repo) assert len(branches) >= 1 # Should have at least main/master branch assert any(b.name in ["main", "master"] for b in branches) def test_create_branch(self, initialized_repo, git_backend): """Test creating a branch.""" # Create a commit first file = initialized_repo / "test.md" file.write_text("Content") git_backend.commit(initialized_repo, "Add file") branch = git_backend.create_branch(initialized_repo, "feature") assert branch.name == "feature" assert branch.head_commit_id is not None branches = git_backend.list_branches(initialized_repo) assert any(b.name == "feature" for b in branches) def test_switch_branch(self, initialized_repo, git_backend): """Test switching branches.""" # Create commit and branch file = initialized_repo / "test.md" file.write_text("Content") git_backend.commit(initialized_repo, "Add file") git_backend.create_branch(initialized_repo, "feature") # Switch to feature branch success = git_backend.switch_branch(initialized_repo, "feature") assert success current = git_backend.get_current_branch(initialized_repo) assert current == "feature" def test_get_current_branch(self, initialized_repo, git_backend): """Test getting current branch.""" current = git_backend.get_current_branch(initialized_repo) assert current in ["main", "master"] # Depends on git version def test_has_changes(self, initialized_repo, git_backend): """Test checking for uncommitted changes.""" # No changes initially assert not git_backend.has_changes(initialized_repo) # Add a file file = initialized_repo / "test.md" file.write_text("Content") # Should have changes now assert git_backend.has_changes(initialized_repo) # Commit git_backend.commit(initialized_repo, "Add file") # No changes after commit assert not git_backend.has_changes(initialized_repo) # =========================================================================== # GitHistoryEventHandler Tests # =========================================================================== class TestGitHistoryEventHandler: """Tests for event-driven commits.""" def test_create_handler(self, git_backend, history_config): """Test creating an event handler.""" def resolver(space_id): return Path(f"/tmp/{space_id}") handler = GitHistoryEventHandler(git_backend, history_config, resolver) assert handler is not None def test_handler_registers_with_bus( self, git_backend, history_config, event_bus ): """Test handler registration.""" def resolver(space_id): return Path(f"/tmp/{space_id}") handler = GitHistoryEventHandler(git_backend, history_config, resolver) handler.register(event_bus) # Should have registered handlers assert len(event_bus._handlers) > 0 handler.unregister() def test_handler_creates_commit_on_event( self, temp_dir, git_backend, history_config, event_bus ): """Test that handler commits on document events.""" space_id = "test-space" def resolver(sid): return temp_dir if sid == space_id else None # Initialize repo git_backend.initialize(temp_dir, history_config) # Create initial file file = temp_dir / "doc.md" file.write_text("Initial content") git_backend.commit(temp_dir, "Initial commit") # Create handler handler = GitHistoryEventHandler(git_backend, history_config, resolver) handler.register(event_bus) # Modify file file.write_text("Updated content") # Emit event event = SpaceEvent( event_type=SpaceEventType.DOCUMENT_CONTENT_CHANGED, space_id=space_id, payload={"document_id": "doc-1", "space_path": "/doc.md"}, ) event_bus.emit(event) # Should have created a new commit commits = git_backend.get_log(temp_dir, limit=5) assert len(commits) >= 2 assert "Update document" in commits[0].message handler.unregister() def test_handler_disabled_when_not_enabled( self, temp_dir, git_backend, event_bus ): """Test handler does nothing when disabled.""" config = HistoryConfig(enabled=False) space_id = "test-space" def resolver(sid): return temp_dir if sid == space_id else None handler = GitHistoryEventHandler(git_backend, config, resolver) handler.register(event_bus) # Initialize repo manually git_backend.initialize(temp_dir, config) file = temp_dir / "doc.md" file.write_text("Content") # Emit event - should not commit since disabled event = SpaceEvent( event_type=SpaceEventType.DOCUMENT_ADDED, space_id=space_id, payload={"space_path": "/doc.md"}, ) event_bus.emit(event) # Should still have uncommitted changes assert git_backend.has_changes(temp_dir) handler.unregister() # =========================================================================== # HistoryQueryService Tests # =========================================================================== class TestHistoryQueryService: """Tests for HistoryQueryService.""" def test_create_service(self, git_backend): """Test creating a query service.""" def resolver(space_id): return Path(f"/tmp/{space_id}") service = HistoryQueryService(git_backend, resolver) assert service is not None def test_get_history(self, initialized_repo, git_backend): """Test getting history entries.""" space_id = "test-space" def resolver(sid): return initialized_repo if sid == space_id else None service = HistoryQueryService(git_backend, resolver) # Create commits for i in range(3): file = initialized_repo / f"file{i}.md" file.write_text(f"Content {i}") git_backend.commit(initialized_repo, f"Add file {i}") # Get history history = service.get_history(space_id, limit=10) assert len(history) >= 3 assert isinstance(history[0], HistoryEntry) assert history[0].commit.message.startswith("Add file") def test_get_version_content(self, initialized_repo, git_backend): """Test getting content at specific version.""" space_id = "test-space" def resolver(sid): return initialized_repo if sid == space_id else None service = HistoryQueryService(git_backend, resolver) # Create versions file = initialized_repo / "doc.md" file.write_text("Version 1") commit1 = git_backend.commit(initialized_repo, "V1") file.write_text("Version 2") git_backend.commit(initialized_repo, "V2") # Get content at V1 content = service.get_version_content(space_id, "/doc.md", commit1.id) assert content == "Version 1" def test_compare_versions(self, initialized_repo, git_backend): """Test comparing two versions.""" space_id = "test-space" def resolver(sid): return initialized_repo if sid == space_id else None service = HistoryQueryService(git_backend, resolver) file = initialized_repo / "doc.md" file.write_text("Line 1\n") commit1 = git_backend.commit(initialized_repo, "V1") file.write_text("Line 1\nLine 2\n") commit2 = git_backend.commit(initialized_repo, "V2") diff = service.compare_versions(space_id, "/doc.md", commit1.id, commit2.id) assert diff is not None assert diff.additions > 0 def test_search_history(self, initialized_repo, git_backend): """Test searching commit messages.""" space_id = "test-space" def resolver(sid): return initialized_repo if sid == space_id else None service = HistoryQueryService(git_backend, resolver) # Create commits with different messages file = initialized_repo / "doc.md" file.write_text("V1") git_backend.commit(initialized_repo, "Add feature X") file.write_text("V2") git_backend.commit(initialized_repo, "Fix bug Y") file.write_text("V3") git_backend.commit(initialized_repo, "Update feature X") # Search for "feature" results = service.search_history(space_id, "feature") assert len(results) >= 2 assert all("feature" in r.commit.message.lower() for r in results) def test_restore_version(self, initialized_repo, git_backend): """Test restoring a document to previous version.""" space_id = "test-space" def resolver(sid): return initialized_repo if sid == space_id else None service = HistoryQueryService(git_backend, resolver) file = initialized_repo / "doc.md" file.write_text("Version 1") commit1 = git_backend.commit(initialized_repo, "V1") file.write_text("Version 2") git_backend.commit(initialized_repo, "V2") # Restore to V1 success = service.restore_version(space_id, "/doc.md", commit1.id) assert success assert file.read_text() == "Version 1" def test_has_history(self, initialized_repo, git_backend): """Test checking if space has history.""" space1 = "space-with-history" space2 = "space-without-history" # Create fresh directory for space2 with tempfile.TemporaryDirectory() as tmpdir: no_history_dir = Path(tmpdir) def resolver(sid): if sid == space1: return initialized_repo elif sid == space2: return no_history_dir return None service = HistoryQueryService(git_backend, resolver) assert service.has_history(space1) assert not service.has_history(space2) def test_get_branches(self, initialized_repo, git_backend): """Test getting branch names.""" space_id = "test-space" def resolver(sid): return initialized_repo if sid == space_id else None service = HistoryQueryService(git_backend, resolver) # Create a commit first file = initialized_repo / "doc.md" file.write_text("Content") git_backend.commit(initialized_repo, "Add file") # Create branch git_backend.create_branch(initialized_repo, "feature") branches = service.get_branches(space_id) assert "feature" in branches def test_has_uncommitted_changes(self, initialized_repo, git_backend): """Test checking for uncommitted changes.""" space_id = "test-space" def resolver(sid): return initialized_repo if sid == space_id else None service = HistoryQueryService(git_backend, resolver) # No changes initially assert not service.has_uncommitted_changes(space_id) # Add file file = initialized_repo / "doc.md" file.write_text("Content") # Should have changes assert service.has_uncommitted_changes(space_id) # =========================================================================== # Integration Tests # =========================================================================== class TestHistoryIntegration: """Integration tests for history tracking.""" def test_full_workflow(self, temp_dir, git_backend, history_config, event_bus): """Test complete history workflow.""" space_id = "test-space" def resolver(sid): return temp_dir if sid == space_id else None # Initialize git_backend.initialize(temp_dir, history_config) # Create query service query_service = HistoryQueryService(git_backend, resolver) # Create event handler event_handler = GitHistoryEventHandler( git_backend, history_config, resolver ) event_handler.register(event_bus) # Add initial file file = temp_dir / "intro.md" file.write_text("# Introduction") git_backend.commit(temp_dir, "Initial commit") # Emit document added event file2 = temp_dir / "chapter1.md" file2.write_text("# Chapter 1") event = SpaceEvent( event_type=SpaceEventType.DOCUMENT_ADDED, space_id=space_id, payload={"space_path": "/chapter1.md"}, ) event_bus.emit(event) # Should have 2 commits history = query_service.get_history(space_id) assert len(history) >= 2 # Get specific version (find the commit where intro.md was added) commits = git_backend.get_log(temp_dir) intro_commit = None for commit in reversed(commits): if "intro.md" in commit.files_changed: intro_commit = commit break if intro_commit: old_content = query_service.get_version_content( space_id, "/intro.md", intro_commit.id ) assert old_content == "# Introduction" # Compare versions file.write_text("# Introduction\nUpdated") git_backend.commit(temp_dir, "Update intro") diff = query_service.compare_versions( space_id, "/intro.md", commits[-1].id, "HEAD" ) assert diff is not None assert diff.additions > 0 event_handler.unregister()