Files
Aperant/tests/test_worktree.py
T
StillKnotKnown d024eec105 fix(merge): resolve multiple merge-related issues (ACS-194, ACS-179, ACS-174, ACS-163) (#885)
* fix(merge): resolve multiple merge-related issues (ACS-194, ACS-179, ACS-174, ACS-163)

- ACS-179: Fix TypeError in print_conflict_info - handle both string and dict conflict formats
- ACS-174: Handle git hook failures during merge - skip checkout if already on target branch
- ACS-163: Fix merge failure fallthrough - return False immediately when merge fails
- ACS-194: Improve AI merge success rate - add Haiku→Sonnet fallback with enhanced prompts

All fixes include regression tests.

* fix: improve merge robustness and fix test issues

This commit addresses multiple issues related to merge functionality
and test quality:

1. workspace.py:
   - Fix case-insensitive natural language pattern matching to detect
     AI explanations returned instead of code

2. worktree.py:
   - Guard against None stderr when handling git hook failures
   - Improve error messages for merge hook handling

3. workspace/display.py:
   - Improve print_conflict_info() to properly categorize conflicts
     (marker vs AI merge failures)
   - Add severity indicators (🔴 for high, 🟡 for medium)
   - Use shlex.quote() for proper git add command quoting
   - Provide clearer guidance for different conflict types

4. tests/test_merge_ai_resolver.py:
   - Fix test assertions to match actual AI_MERGE_SYSTEM_PROMPT content
     (check for "intelligently" and "task's intent" instead of
      non-existent "semantic understanding")

5. tests/test_workspace.py:
   - Add side-effect verification for merge failure tests
   - Remove unused fixtures
   - Add assertions for severity emoji indicators

6. tests/test_worktree.py:
   - Add subprocess return code checks for better test reliability

Related: ACS-194, ACS-179, ACS-174, ACS-163

* fix: improve display formatting and use proper imports

1. display.py:
   - Fix trailing space when severity_icon is empty (low/unknown severity)
   - Only add leading space to icon when icon is non-empty

2. workspace/__init__.py:
   - Export AI_MERGE_SYSTEM_PROMPT and _build_merge_prompt
   - Add to __all__ for public API access

3. test_merge_ai_resolver.py:
   - Use standard imports from core.workspace package
   - Remove fragile importlib.util dynamic loading

* fix: address all 6 review findings

1. workspace.py:
   - Fix parameter naming: max_thinking -> max_thinking_tokens
     (matches codebase convention used in 25+ locations)
   - Extract hardcoded model constants: MERGE_FAST_MODEL,
     MERGE_CAPABLE_MODEL, MERGE_FAST_THINKING, MERGE_COMPLEX_THINKING
   - Improve natural language detection: check patterns at START of line
     and require absence of code patterns to reduce false positives

2. display.py:
   - Add critical severity icon handling ( for critical severity)

3. worktree.py:
   - Fix inconsistent stderr handling: use truthiness check for both
     branches (empty strings show '<no stderr>')

4. test_workspace.py:
   - Remove unused imports: patch, MagicMock from unittest.mock

Related: ACS-194

---------

Co-authored-by: StillKnotKnown <stillknotknown@users.noreply.github.com>
Co-authored-by: Andy <119136210+AndyMik90@users.noreply.github.com>
2026-01-11 07:23:33 +01:00

510 lines
19 KiB
Python

#!/usr/bin/env python3
"""
Tests for Git Worktree Management
=================================
Tests the worktree.py module functionality including:
- Worktree creation and removal
- Staging worktree management
- Branch operations
- Merge operations
- Change tracking
- Worktree cleanup and age detection
"""
import subprocess
import time
from datetime import datetime, timedelta
from pathlib import Path
import pytest
from worktree import WorktreeManager
class TestWorktreeManagerInitialization:
"""Tests for WorktreeManager initialization."""
def test_init_with_valid_git_repo(self, temp_git_repo: Path):
"""Manager initializes correctly with valid git repo."""
manager = WorktreeManager(temp_git_repo)
assert manager.project_dir == temp_git_repo
assert manager.worktrees_dir == temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
assert manager.base_branch is not None
def test_init_prefers_main_over_current_branch(self, temp_git_repo: Path):
"""Manager prefers main/master over current branch when detecting base branch."""
# Create and switch to a new branch
subprocess.run(
["git", "checkout", "-b", "feature-branch"],
cwd=temp_git_repo, capture_output=True
)
# Even though we're on feature-branch, manager should prefer main
manager = WorktreeManager(temp_git_repo)
assert manager.base_branch == "main"
def test_init_falls_back_to_current_branch(self, temp_git_repo: Path):
"""Manager falls back to current branch when main/master don't exist."""
# Delete main branch to force fallback
subprocess.run(
["git", "checkout", "-b", "feature-branch"],
cwd=temp_git_repo, capture_output=True
)
subprocess.run(
["git", "branch", "-D", "main"],
cwd=temp_git_repo, capture_output=True
)
manager = WorktreeManager(temp_git_repo)
assert manager.base_branch == "feature-branch"
def test_init_with_explicit_base_branch(self, temp_git_repo: Path):
"""Manager uses explicitly provided base branch."""
manager = WorktreeManager(temp_git_repo, base_branch="main")
assert manager.base_branch == "main"
def test_setup_creates_worktrees_directory(self, temp_git_repo: Path):
"""Setup creates the worktrees directory."""
manager = WorktreeManager(temp_git_repo)
manager.setup()
assert manager.worktrees_dir.exists()
assert manager.worktrees_dir.is_dir()
class TestWorktreeCreation:
"""Tests for creating worktrees."""
def test_create_worktree(self, temp_git_repo: Path):
"""Can create a new worktree."""
manager = WorktreeManager(temp_git_repo)
manager.setup()
info = manager.create_worktree("test-spec")
assert info.path.exists()
assert info.branch == "auto-claude/test-spec"
assert info.is_active is True
assert (info.path / "README.md").exists()
def test_create_worktree_with_spec_name(self, temp_git_repo: Path):
"""Worktree branch is derived from spec name."""
manager = WorktreeManager(temp_git_repo)
manager.setup()
info = manager.create_worktree("my-feature-spec")
assert info.branch == "auto-claude/my-feature-spec"
def test_get_or_create_replaces_existing_worktree(self, temp_git_repo: Path):
"""get_or_create_worktree returns existing worktree."""
manager = WorktreeManager(temp_git_repo)
manager.setup()
info1 = manager.create_worktree("test-spec")
# Create a file in the worktree
(info1.path / "test-file.txt").write_text("test")
# get_or_create should return existing
info2 = manager.get_or_create_worktree("test-spec")
assert info2.path.exists()
# The test file should still be there (same worktree)
assert (info2.path / "test-file.txt").exists()
class TestWorktreeRemoval:
"""Tests for removing worktrees."""
def test_remove_worktree(self, temp_git_repo: Path):
"""Can remove a worktree."""
manager = WorktreeManager(temp_git_repo)
manager.setup()
info = manager.create_worktree("test-spec")
manager.remove_worktree("test-spec")
assert not info.path.exists()
def test_remove_with_delete_branch(self, temp_git_repo: Path):
"""Removing worktree can also delete the branch."""
manager = WorktreeManager(temp_git_repo)
manager.setup()
info = manager.create_worktree("test-spec")
branch_name = info.branch
manager.remove_worktree("test-spec", delete_branch=True)
# Verify branch is deleted
result = subprocess.run(
["git", "branch", "--list", branch_name],
cwd=temp_git_repo, capture_output=True, text=True
)
assert branch_name not in result.stdout
class TestWorktreeCommitAndMerge:
"""Tests for commit and merge operations."""
def test_merge_worktree(self, temp_git_repo: Path):
"""Can merge a worktree back to main."""
manager = WorktreeManager(temp_git_repo)
manager.setup()
# Create a worktree with changes
worker_info = manager.create_worktree("worker-spec")
(worker_info.path / "worker-file.txt").write_text("worker content")
subprocess.run(["git", "add", "."], cwd=worker_info.path, capture_output=True)
subprocess.run(
["git", "commit", "-m", "Worker commit"],
cwd=worker_info.path, capture_output=True
)
# Merge worktree back to main
result = manager.merge_worktree("worker-spec", delete_after=False)
assert result is True
# Verify file is in main branch
subprocess.run(["git", "checkout", manager.base_branch], cwd=temp_git_repo, capture_output=True)
assert (temp_git_repo / "worker-file.txt").exists()
def test_merge_worktree_already_on_target_branch(self, temp_git_repo: Path):
"""merge_worktree succeeds when already on target branch (ACS-174)."""
manager = WorktreeManager(temp_git_repo)
manager.setup()
# Ensure we're on the base branch
result = subprocess.run(["git", "checkout", manager.base_branch], cwd=temp_git_repo, capture_output=True)
assert result.returncode == 0, f"Checkout failed: {result.stderr}"
# Create a worktree with changes
worker_info = manager.create_worktree("worker-spec")
(worker_info.path / "worker-file.txt").write_text("worker content")
result = subprocess.run(["git", "add", "."], cwd=worker_info.path, capture_output=True)
assert result.returncode == 0, f"Git add failed: {result.stderr}"
result = subprocess.run(
["git", "commit", "-m", "Worker commit"],
cwd=worker_info.path, capture_output=True
)
assert result.returncode == 0, f"Commit failed: {result.stderr}"
# Already on target branch, should skip checkout and still merge successfully
result = manager.merge_worktree("worker-spec", delete_after=False)
assert result is True
# Verify file is in main branch
assert (temp_git_repo / "worker-file.txt").exists()
class TestChangeTracking:
"""Tests for tracking changes in worktrees."""
def test_has_uncommitted_changes_false(self, temp_git_repo: Path):
"""has_uncommitted_changes returns False when clean."""
manager = WorktreeManager(temp_git_repo)
manager.setup()
assert manager.has_uncommitted_changes() is False
def test_has_uncommitted_changes_true(self, temp_git_repo: Path):
"""has_uncommitted_changes returns True when dirty."""
manager = WorktreeManager(temp_git_repo)
manager.setup()
# Make uncommitted changes
(temp_git_repo / "dirty.txt").write_text("uncommitted")
assert manager.has_uncommitted_changes() is True
def test_get_change_summary(self, temp_git_repo: Path):
"""get_change_summary returns correct counts."""
manager = WorktreeManager(temp_git_repo)
manager.setup()
info = manager.create_worktree("test-spec")
# Make various changes
(info.path / "new-file.txt").write_text("new")
(info.path / "README.md").write_text("modified")
subprocess.run(["git", "add", "."], cwd=info.path, capture_output=True)
subprocess.run(
["git", "commit", "-m", "Changes"],
cwd=info.path, capture_output=True
)
summary = manager.get_change_summary("test-spec")
assert summary["new_files"] == 1 # new-file.txt
assert summary["modified_files"] == 1 # README.md
def test_get_changed_files(self, temp_git_repo: Path):
"""get_changed_files returns list of changed files."""
manager = WorktreeManager(temp_git_repo)
manager.setup()
info = manager.create_worktree("test-spec")
# Make changes
(info.path / "added.txt").write_text("new file")
subprocess.run(["git", "add", "."], cwd=info.path, capture_output=True)
subprocess.run(
["git", "commit", "-m", "Add file"],
cwd=info.path, capture_output=True
)
files = manager.get_changed_files("test-spec")
assert len(files) > 0
file_names = [f[1] for f in files]
assert "added.txt" in file_names
class TestWorktreeUtilities:
"""Tests for utility methods."""
def test_list_worktrees(self, temp_git_repo: Path):
"""list_all_worktrees returns active worktrees."""
manager = WorktreeManager(temp_git_repo)
manager.setup()
manager.create_worktree("spec-1")
manager.create_worktree("spec-2")
worktrees = manager.list_all_worktrees()
assert len(worktrees) == 2
def test_get_info(self, temp_git_repo: Path):
"""get_worktree_info returns correct WorktreeInfo."""
manager = WorktreeManager(temp_git_repo)
manager.setup()
manager.create_worktree("test-spec")
info = manager.get_worktree_info("test-spec")
assert info is not None
assert info.branch == "auto-claude/test-spec"
def test_get_worktree_path(self, temp_git_repo: Path):
"""get_worktree_path returns correct path."""
manager = WorktreeManager(temp_git_repo)
manager.setup()
info = manager.create_worktree("test-spec")
path = manager.get_worktree_path("test-spec")
assert path == info.path
def test_cleanup_all(self, temp_git_repo: Path):
"""cleanup_all removes all worktrees."""
manager = WorktreeManager(temp_git_repo)
manager.setup()
manager.create_worktree("spec-1")
manager.create_worktree("spec-2")
manager.create_worktree("spec-3")
manager.cleanup_all()
assert len(manager.list_all_worktrees()) == 0
def test_cleanup_stale_worktrees(self, temp_git_repo: Path):
"""cleanup_stale_worktrees removes directories without git tracking."""
manager = WorktreeManager(temp_git_repo)
manager.setup()
# Create a stale worktree directory (exists but not tracked by git)
stale_dir = manager.worktrees_dir / "stale-worktree"
stale_dir.mkdir(parents=True, exist_ok=True)
# This should clean up the stale directory
manager.cleanup_stale_worktrees()
# Stale directory should be removed
assert not stale_dir.exists()
def test_get_test_commands_python(self, temp_git_repo: Path):
"""get_test_commands detects Python project commands."""
manager = WorktreeManager(temp_git_repo)
manager.setup()
info = manager.create_worktree("test-spec")
# Create requirements.txt
(info.path / "requirements.txt").write_text("flask\n")
commands = manager.get_test_commands("test-spec")
assert any("pip" in cmd for cmd in commands)
def test_get_test_commands_node(self, temp_git_repo: Path):
"""get_test_commands detects Node.js project commands."""
manager = WorktreeManager(temp_git_repo)
manager.setup()
info = manager.create_worktree("test-spec-node")
# Create package.json
(info.path / "package.json").write_text('{"name": "test"}')
commands = manager.get_test_commands("test-spec-node")
assert any("npm" in cmd for cmd in commands)
class TestWorktreeCleanup:
"""Tests for worktree cleanup and age detection functionality."""
def test_get_worktree_stats_includes_age(self, temp_git_repo: Path):
"""Worktree stats include last commit date and age in days."""
manager = WorktreeManager(temp_git_repo)
manager.setup()
info = manager.create_worktree("test-spec")
# Make a commit in the worktree
test_file = info.path / "test.txt"
test_file.write_text("test")
subprocess.run(["git", "add", "."], cwd=info.path, capture_output=True)
subprocess.run(
["git", "commit", "-m", "test commit"], cwd=info.path, capture_output=True
)
# Get stats
stats = manager._get_worktree_stats("test-spec")
assert stats["last_commit_date"] is not None
assert isinstance(stats["last_commit_date"], datetime)
assert stats["days_since_last_commit"] is not None
assert stats["days_since_last_commit"] == 0 # Just committed
def test_get_old_worktrees(self, temp_git_repo: Path):
"""get_old_worktrees identifies worktrees based on age threshold."""
manager = WorktreeManager(temp_git_repo)
manager.setup()
# Create a worktree with a commit
info = manager.create_worktree("test-spec")
test_file = info.path / "test.txt"
test_file.write_text("test")
subprocess.run(["git", "add", "."], cwd=info.path, capture_output=True)
subprocess.run(
["git", "commit", "-m", "test commit"], cwd=info.path, capture_output=True
)
# Should not be considered old with default threshold (30 days)
old_worktrees = manager.get_old_worktrees(days_threshold=30)
assert len(old_worktrees) == 0
# Should be considered old with 0 day threshold
old_worktrees = manager.get_old_worktrees(days_threshold=0)
assert len(old_worktrees) == 1
assert "test-spec" in old_worktrees
def test_get_old_worktrees_with_stats(self, temp_git_repo: Path):
"""get_old_worktrees returns full WorktreeInfo when include_stats=True."""
manager = WorktreeManager(temp_git_repo)
manager.setup()
# Create a worktree with a commit
info = manager.create_worktree("test-spec")
test_file = info.path / "test.txt"
test_file.write_text("test")
subprocess.run(["git", "add", "."], cwd=info.path, capture_output=True)
subprocess.run(
["git", "commit", "-m", "test commit"], cwd=info.path, capture_output=True
)
# Get old worktrees with stats
old_worktrees = manager.get_old_worktrees(days_threshold=0, include_stats=True)
assert len(old_worktrees) == 1
assert old_worktrees[0].spec_name == "test-spec"
assert old_worktrees[0].days_since_last_commit is not None
def test_cleanup_old_worktrees_dry_run(self, temp_git_repo: Path):
"""cleanup_old_worktrees dry run does not remove worktrees."""
manager = WorktreeManager(temp_git_repo)
manager.setup()
# Create a worktree with a commit
info = manager.create_worktree("test-spec")
test_file = info.path / "test.txt"
test_file.write_text("test")
subprocess.run(["git", "add", "."], cwd=info.path, capture_output=True)
subprocess.run(
["git", "commit", "-m", "test commit"], cwd=info.path, capture_output=True
)
# Dry run should not remove anything
removed, failed = manager.cleanup_old_worktrees(days_threshold=0, dry_run=True)
assert len(removed) == 0
assert len(failed) == 0
assert info.path.exists() # Worktree still exists
def test_cleanup_old_worktrees_removes_old(self, temp_git_repo: Path):
"""cleanup_old_worktrees removes worktrees older than threshold."""
manager = WorktreeManager(temp_git_repo)
manager.setup()
# Create a worktree with a commit
info = manager.create_worktree("test-spec")
test_file = info.path / "test.txt"
test_file.write_text("test")
subprocess.run(["git", "add", "."], cwd=info.path, capture_output=True)
subprocess.run(
["git", "commit", "-m", "test commit"], cwd=info.path, capture_output=True
)
# Actually remove with 0 day threshold
removed, failed = manager.cleanup_old_worktrees(days_threshold=0, dry_run=False)
assert len(removed) == 1
assert "test-spec" in removed
assert len(failed) == 0
assert not info.path.exists() # Worktree should be removed
def test_get_worktree_count_warning(self, temp_git_repo: Path):
"""get_worktree_count_warning returns appropriate warnings based on count."""
manager = WorktreeManager(temp_git_repo)
manager.setup()
# No warning with few worktrees
warning = manager.get_worktree_count_warning(warning_threshold=10)
assert warning is None
# Create 11 worktrees to trigger warning
for i in range(11):
info = manager.create_worktree(f"test-spec-{i}")
test_file = info.path / "test.txt"
test_file.write_text("test")
subprocess.run(["git", "add", "."], cwd=info.path, capture_output=True)
subprocess.run(
["git", "commit", "-m", "test commit"],
cwd=info.path,
capture_output=True,
)
warning = manager.get_worktree_count_warning(warning_threshold=10)
assert warning is not None
assert "WARNING" in warning
def test_get_worktree_count_critical_warning(self, temp_git_repo: Path):
"""get_worktree_count_warning returns critical warning for high counts."""
manager = WorktreeManager(temp_git_repo)
manager.setup()
# Create 21 worktrees to trigger critical warning
for i in range(21):
info = manager.create_worktree(f"test-spec-{i}")
test_file = info.path / "test.txt"
test_file.write_text("test")
subprocess.run(["git", "add", "."], cwd=info.path, capture_output=True)
subprocess.run(
["git", "commit", "-m", "test commit"],
cwd=info.path,
capture_output=True,
)
warning = manager.get_worktree_count_warning(critical_threshold=20)
assert warning is not None
assert "CRITICAL" in warning