auto-claude: 162-fix-worktree-error-on-repeated-task-starts (#1453)

* auto-claude: subtask-1-1 - Add _branch_exists() helper method to check if a branch exists

* auto-claude: subtask-1-2 - Add _worktree_is_registered() helper to check if worktree is tracked by git

This helper method uses 'git worktree list --porcelain' to determine if a
worktree path is registered with git. This is useful for detecting orphaned
worktree directories that need cleanup during idempotent worktree creation.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* auto-claude: subtask-1-3 - Refactor create_worktree() to be idempotent

- Run git worktree prune first to clean orphaned references
- Check if worktree already exists and is valid (return existing)
- Handle stale worktree directories (cleanup before recreation)
- Reuse existing branches without -b flag when branch exists
- Only use -b flag when creating new branch

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* auto-claude: subtask-1-4 - Add test_create_worktree_idempotent test case

* auto-claude: subtask-1-5 - Add test_create_worktree_branch_exists_no_worktree

Add test case that verifies create_worktree() correctly reuses an existing
branch when the worktree directory is missing. The test:
1. Creates a worktree to establish the branch
2. Removes the worktree but keeps the branch (delete_branch=False)
3. Verifies the branch still exists
4. Calls create_worktree() again - should succeed by reusing the branch
5. Verifies the worktree is recreated with the same branch name

* auto-claude: subtask-1-6 - Add test_create_worktree_stale_directory test case

Add test that verifies create_worktree() correctly handles the stale
directory scenario where a worktree directory exists on disk but is
not registered with git. The test:
1. Creates a worktree normally
2. Force-removes git tracking but recreates the directory
3. Verifies create_worktree() cleans up stale directory and recreates

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* style: Fix ruff formatting in worktree.py

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: Handle edge cases in idempotent worktree creation

- Handle corrupted worktrees (registered but unreadable) by force removing
  and recreating them (NEW-001)
- Add thread-safety documentation to create_worktree docstring (NEW-002)
- Add defensive check for malformed porcelain output parsing (NEW-003)
- Use os.path.samefile() for accurate path comparison on case-insensitive
  filesystems like macOS HFS+/APFS and Windows NTFS (NEW-004)
- Add test for stale directory with existing branch scenario (NEW-005)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: Add error handling for worktree cleanup operations

- NCR-001: Check if stale directory still exists after rmtree and raise
  WorktreeError with clear message about permission issues or file locks
- NCR-002: Check return code of corrupted worktree removal and raise
  WorktreeError if force remove fails
- NCR-003: Use git show-ref --verify refs/heads/{branch} instead of
  git rev-parse to specifically check for local branches, avoiding
  false positives from tags or other refs with the same name

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Andy
2026-01-25 11:31:40 +01:00
committed by GitHub
parent 31f116db52
commit b955badf7f
2 changed files with 467 additions and 85 deletions
+135 -26
View File
@@ -378,6 +378,66 @@ class WorktreeManager:
return "auto-claude"
return None
def _branch_exists(self, branch_name: str) -> bool:
"""
Check if a local branch exists in the repository.
Uses git show-ref to specifically check for local branches, avoiding
false positives from tags or other refs with the same name.
Args:
branch_name: The name of the branch to check (e.g., 'auto-claude/my-spec')
Returns:
True if the local branch exists, False otherwise.
"""
result = self._run_git(["show-ref", "--verify", f"refs/heads/{branch_name}"])
return result.returncode == 0
def _worktree_is_registered(self, worktree_path: Path) -> bool:
"""
Check if a worktree path is registered with git.
This determines if git tracks the worktree even if the directory exists.
Useful for detecting orphaned worktree directories that need cleanup.
Args:
worktree_path: The path to the worktree directory to check.
Returns:
True if the worktree is registered with git, False otherwise.
"""
result = self._run_git(["worktree", "list", "--porcelain"])
if result.returncode != 0:
return False
# Parse porcelain output to get registered worktree paths
# Format: "worktree /path/to/worktree" for each worktree
registered_paths = set()
for line in result.stdout.split("\n"):
if line.startswith("worktree "):
parts = line.split(" ", 1)
if len(parts) == 2:
registered_paths.add(Path(parts[1]))
# Check if worktree_path matches any registered path
# Use samefile() for accurate comparison on case-insensitive filesystems
resolved_path = worktree_path.resolve()
for registered_path in registered_paths:
# Try samefile first (handles case-insensitivity and symlinks)
try:
if resolved_path.exists() and registered_path.exists():
if os.path.samefile(resolved_path, registered_path):
return True
except OSError:
pass
# Fallback to normalized case comparison for non-existent paths
if os.path.normcase(str(resolved_path)) == os.path.normcase(
str(registered_path)
):
return True
return False
def _get_worktree_stats(self, spec_name: str) -> dict:
"""Get diff statistics for a worktree."""
worktree_path = self.get_worktree_path(spec_name)
@@ -467,13 +527,25 @@ class WorktreeManager:
def create_worktree(self, spec_name: str) -> WorktreeInfo:
"""
Create a worktree for a spec.
Create a worktree for a spec (idempotent).
This method is idempotent - calling it multiple times with the same spec_name
will succeed regardless of prior state. It handles:
- Existing valid worktrees (returns existing)
- Corrupted worktrees (force removes and recreates)
- Orphaned worktree references (prunes them)
- Stale worktree directories (cleans them up)
- Existing branches without worktrees (reuses the branch)
Note:
This method is NOT thread-safe for concurrent calls with the same spec_name.
If concurrent access is needed, implement external locking.
Args:
spec_name: The spec folder name (e.g., "002-implement-memory")
Returns:
WorktreeInfo for the created worktree
WorktreeInfo for the created or existing worktree
Raises:
WorktreeError: If a branch namespace conflict exists or worktree creation fails
@@ -481,7 +553,11 @@ class WorktreeManager:
worktree_path = self.get_worktree_path(spec_name)
branch_name = self.get_branch_name(spec_name)
# Check for branch namespace conflict (e.g., 'auto-claude' blocking 'auto-claude/*')
# Step 1: Prune orphaned worktree references first
# This cleans up any stale references that might block operations
self._run_git(["worktree", "prune"])
# Step 2: Check for branch namespace conflict (e.g., 'auto-claude' blocking 'auto-claude/*')
conflicting_branch = self._check_branch_namespace_conflict()
if conflicting_branch:
raise WorktreeError(
@@ -494,14 +570,41 @@ class WorktreeManager:
f" git branch -m {conflicting_branch} {conflicting_branch}-backup"
)
# Remove existing if present (from crashed previous run)
if worktree_path.exists():
self._run_git(["worktree", "remove", "--force", str(worktree_path)])
# Step 3: Check if worktree already exists and is valid
if worktree_path.exists() and self._worktree_is_registered(worktree_path):
# Worktree exists and is tracked by git - return existing (idempotent)
existing = self.get_worktree_info(spec_name)
if existing:
print(
f"Using existing worktree: {worktree_path.name} on branch {existing.branch}"
)
return existing
else:
# Worktree is registered but corrupted (e.g., unreadable HEAD)
# Force remove the registration and let it be recreated
print(f"Removing corrupted worktree registration: {worktree_path.name}")
remove_result = self._run_git(
["worktree", "remove", "--force", str(worktree_path)]
)
if remove_result.returncode != 0:
raise WorktreeError(
f"Failed to remove corrupted worktree: {remove_result.stderr}"
)
# Delete branch if it exists (from previous attempt)
self._run_git(["branch", "-D", branch_name])
# Step 4: Handle stale worktree directory (exists but not registered with git)
if worktree_path.exists() and not self._worktree_is_registered(worktree_path):
print(f"Removing stale worktree directory: {worktree_path.name}")
shutil.rmtree(worktree_path, ignore_errors=True)
if worktree_path.exists():
raise WorktreeError(
f"Failed to remove stale worktree directory: {worktree_path}\n"
f"This may be due to permission issues or file locks."
)
# Fetch latest from remote to ensure we have the most up-to-date code
# Step 5: Check if branch already exists
branch_exists = self._branch_exists(branch_name)
# Step 6: Fetch latest from remote to ensure we have the most up-to-date code
# GitHub/remote is the source of truth, not the local branch
fetch_result = self._run_git(["fetch", "origin", self.base_branch])
if fetch_result.returncode != 0:
@@ -510,25 +613,31 @@ class WorktreeManager:
)
print("Falling back to local branch...")
# Determine the start point for the worktree
# Prefer origin/{base_branch} (remote) over local branch to ensure we have latest code
remote_ref = f"origin/{self.base_branch}"
start_point = self.base_branch # Default to local branch
# Check if remote ref exists and use it as the source of truth
check_remote = self._run_git(["rev-parse", "--verify", remote_ref])
if check_remote.returncode == 0:
start_point = remote_ref
print(f"Creating worktree from remote: {remote_ref}")
# Step 7: Create the worktree
if branch_exists:
# Branch exists - attach worktree to existing branch (no -b flag)
print(f"Reusing existing branch: {branch_name}")
result = self._run_git(["worktree", "add", str(worktree_path), branch_name])
else:
print(
f"Remote ref {remote_ref} not found, using local branch: {self.base_branch}"
)
# Branch doesn't exist - create new branch from remote or local base
# Determine the start point for the worktree
remote_ref = f"origin/{self.base_branch}"
start_point = self.base_branch # Default to local branch
# Create worktree with new branch from the start point (remote preferred)
result = self._run_git(
["worktree", "add", "-b", branch_name, str(worktree_path), start_point]
)
# Check if remote ref exists and use it as the source of truth
check_remote = self._run_git(["rev-parse", "--verify", remote_ref])
if check_remote.returncode == 0:
start_point = remote_ref
print(f"Creating worktree from remote: {remote_ref}")
else:
print(
f"Remote ref {remote_ref} not found, using local branch: {self.base_branch}"
)
# Create worktree with new branch from the start point
result = self._run_git(
["worktree", "add", "-b", branch_name, str(worktree_path), start_point]
)
if result.returncode != 0:
raise WorktreeError(
+332 -59
View File
@@ -29,7 +29,10 @@ class TestWorktreeManagerInitialization:
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.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):
@@ -37,7 +40,8 @@ class TestWorktreeManagerInitialization:
# Create and switch to a new branch
subprocess.run(
["git", "checkout", "-b", "feature-branch"],
cwd=temp_git_repo, capture_output=True
cwd=temp_git_repo,
capture_output=True,
)
# Even though we're on feature-branch, manager should prefer main
@@ -49,11 +53,11 @@ class TestWorktreeManagerInitialization:
# Delete main branch to force fallback
subprocess.run(
["git", "checkout", "-b", "feature-branch"],
cwd=temp_git_repo, capture_output=True
cwd=temp_git_repo,
capture_output=True,
)
subprocess.run(
["git", "branch", "-D", "main"],
cwd=temp_git_repo, capture_output=True
["git", "branch", "-D", "main"], cwd=temp_git_repo, capture_output=True
)
manager = WorktreeManager(temp_git_repo)
@@ -113,6 +117,187 @@ class TestWorktreeCreation:
# The test file should still be there (same worktree)
assert (info2.path / "test-file.txt").exists()
def test_create_worktree_idempotent(self, temp_git_repo: Path):
"""create_worktree succeeds when called twice with same spec name."""
manager = WorktreeManager(temp_git_repo)
manager.setup()
# First creation should succeed
info1 = manager.create_worktree("test-spec")
assert info1.path.exists()
assert info1.branch == "auto-claude/test-spec"
# Create a file in the worktree to verify it's preserved
(info1.path / "test-file.txt").write_text("test content")
# Second creation should also succeed (idempotent)
info2 = manager.create_worktree("test-spec")
# Should return valid worktree info
assert info2.path.exists()
assert info2.branch == "auto-claude/test-spec"
# The test file should still be there (same worktree returned)
assert (info2.path / "test-file.txt").exists()
assert (info2.path / "test-file.txt").read_text() == "test content"
def test_create_worktree_branch_exists_no_worktree(self, temp_git_repo: Path):
"""create_worktree reuses existing branch when worktree is missing."""
manager = WorktreeManager(temp_git_repo)
manager.setup()
# Create initial worktree
info1 = manager.create_worktree("test-spec")
branch_name = info1.branch
assert info1.path.exists()
assert branch_name == "auto-claude/test-spec"
# Remove worktree but keep the branch (delete_branch=False is default)
manager.remove_worktree("test-spec", delete_branch=False)
# Verify worktree directory is gone
assert not info1.path.exists()
# Verify branch still exists
result = subprocess.run(
["git", "branch", "--list", branch_name],
cwd=temp_git_repo,
capture_output=True,
text=True,
)
assert branch_name in result.stdout, (
"Branch should still exist after worktree removal"
)
# Create worktree again - should succeed by reusing existing branch
info2 = manager.create_worktree("test-spec")
# Should return valid worktree info with the same branch
assert info2.path.exists()
assert info2.branch == branch_name
assert info2.is_active is True
# README should exist (copied from base branch)
assert (info2.path / "README.md").exists()
def test_create_worktree_stale_directory(self, temp_git_repo: Path):
"""create_worktree cleans up stale directory and recreates worktree."""
manager = WorktreeManager(temp_git_repo)
manager.setup()
# Create a worktree normally
info = manager.create_worktree("test-spec")
worktree_path = info.path
branch_name = info.branch
assert worktree_path.exists()
# Add a file to the worktree so we can verify it gets cleaned up
(worktree_path / "test-file.txt").write_text("test content")
# Force-remove the worktree from git's tracking, but leave directory intact
# This simulates a stale state where directory exists but git doesn't track it
result = subprocess.run(
["git", "worktree", "remove", "--force", str(worktree_path)],
cwd=temp_git_repo,
capture_output=True,
)
assert result.returncode == 0, (
f"Failed to force remove worktree: {result.stderr}"
)
# Recreate the directory manually to simulate stale state
# (git worktree remove also deletes the directory, so we recreate it)
worktree_path.mkdir(parents=True, exist_ok=True)
(worktree_path / "stale-file.txt").write_text("stale content")
# Verify directory exists but is not tracked by git
assert worktree_path.exists()
wt_list_result = subprocess.run(
["git", "worktree", "list", "--porcelain"],
cwd=temp_git_repo,
capture_output=True,
text=True,
)
assert str(worktree_path) not in wt_list_result.stdout, (
"Worktree should not be registered"
)
# Now create_worktree should clean up the stale directory and recreate successfully
info2 = manager.create_worktree("test-spec")
# Should return valid worktree info
assert info2.path.exists()
assert info2.branch == branch_name
assert info2.is_active is True
# README should exist (from base branch)
assert (info2.path / "README.md").exists()
# Stale file should be gone (directory was cleaned up)
assert not (info2.path / "stale-file.txt").exists()
def test_create_worktree_stale_directory_with_existing_branch(
self, temp_git_repo: Path
):
"""create_worktree handles stale directory when branch already exists."""
manager = WorktreeManager(temp_git_repo)
manager.setup()
# Create a worktree normally
info = manager.create_worktree("test-spec")
worktree_path = info.path
branch_name = info.branch
assert worktree_path.exists()
# Unregister the worktree but KEEP the branch
# Use 'git worktree remove' which removes directory, then manually recreate stale dir
# But first we need to ensure the branch survives
result = subprocess.run(
["git", "worktree", "remove", "--force", str(worktree_path)],
cwd=temp_git_repo,
capture_output=True,
)
assert result.returncode == 0, f"Failed to remove worktree: {result.stderr}"
# Verify branch still exists (git worktree remove doesn't delete branch)
result = subprocess.run(
["git", "branch", "--list", branch_name],
cwd=temp_git_repo,
capture_output=True,
text=True,
)
assert branch_name in result.stdout, (
"Branch should still exist after worktree removal"
)
# Recreate stale directory manually (simulates orphaned directory)
worktree_path.mkdir(parents=True, exist_ok=True)
(worktree_path / "stale-file.txt").write_text("stale content")
# Verify: directory exists, worktree NOT registered, branch EXISTS
assert worktree_path.exists()
wt_list_result = subprocess.run(
["git", "worktree", "list", "--porcelain"],
cwd=temp_git_repo,
capture_output=True,
text=True,
)
assert str(worktree_path) not in wt_list_result.stdout, (
"Worktree should not be registered"
)
# Now create_worktree should:
# 1. Detect stale directory (not registered)
# 2. Clean up stale directory
# 3. Detect existing branch
# 4. Reuse existing branch (no -b flag)
info2 = manager.create_worktree("test-spec")
# Should return valid worktree info with SAME branch (reused)
assert info2.path.exists()
assert info2.branch == branch_name
assert info2.is_active is True
# README should exist (from branch content)
assert (info2.path / "README.md").exists()
# Stale file should be gone (directory was cleaned up before worktree add)
assert not (info2.path / "stale-file.txt").exists()
class TestWorktreeRemoval:
"""Tests for removing worktrees."""
@@ -139,7 +324,9 @@ class TestWorktreeRemoval:
# Verify branch is deleted
result = subprocess.run(
["git", "branch", "--list", branch_name],
cwd=temp_git_repo, capture_output=True, text=True
cwd=temp_git_repo,
capture_output=True,
text=True,
)
assert branch_name not in result.stdout
@@ -158,7 +345,8 @@ class TestWorktreeCommitAndMerge:
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
cwd=worker_info.path,
capture_output=True,
)
# Merge worktree back to main
@@ -167,7 +355,11 @@ class TestWorktreeCommitAndMerge:
assert result is True
# Verify file is in main branch
subprocess.run(["git", "checkout", manager.base_branch], cwd=temp_git_repo, capture_output=True)
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):
@@ -176,17 +368,24 @@ class TestWorktreeCommitAndMerge:
manager.setup()
# Ensure we're on the base branch
result = subprocess.run(["git", "checkout", manager.base_branch], cwd=temp_git_repo, capture_output=True)
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)
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
cwd=worker_info.path,
capture_output=True,
)
assert result.returncode == 0, f"Commit failed: {result.stderr}"
@@ -206,13 +405,18 @@ class TestWorktreeCommitAndMerge:
# Create a worktree with changes
worker_info = manager.create_worktree("worker-spec")
(worker_info.path / "worker-file.txt").write_text("worker content")
add_result = subprocess.run(["git", "add", "."], cwd=worker_info.path, capture_output=True)
add_result = subprocess.run(
["git", "add", "."], cwd=worker_info.path, capture_output=True
)
assert add_result.returncode == 0, f"git add failed: {add_result.stderr}"
commit_result = subprocess.run(
["git", "commit", "-m", "Worker commit"],
cwd=worker_info.path, capture_output=True
cwd=worker_info.path,
capture_output=True,
)
assert commit_result.returncode == 0, (
f"git commit failed: {commit_result.stderr}"
)
assert commit_result.returncode == 0, f"git commit failed: {commit_result.stderr}"
# First merge succeeds
result = manager.merge_worktree("worker-spec", delete_after=False)
@@ -222,7 +426,9 @@ class TestWorktreeCommitAndMerge:
result = manager.merge_worktree("worker-spec", delete_after=False)
assert result is True
def test_merge_worktree_already_up_to_date_with_no_commit(self, temp_git_repo: Path):
def test_merge_worktree_already_up_to_date_with_no_commit(
self, temp_git_repo: Path
):
"""merge_worktree with no_commit=True succeeds when already up to date (ACS-226)."""
manager = WorktreeManager(temp_git_repo)
manager.setup()
@@ -230,30 +436,44 @@ class TestWorktreeCommitAndMerge:
# Create a worktree with changes
worker_info = manager.create_worktree("worker-spec")
(worker_info.path / "worker-file.txt").write_text("worker content")
add_result = subprocess.run(["git", "add", "."], cwd=worker_info.path, capture_output=True)
add_result = subprocess.run(
["git", "add", "."], cwd=worker_info.path, capture_output=True
)
assert add_result.returncode == 0, f"git add failed: {add_result.stderr}"
commit_result = subprocess.run(
["git", "commit", "-m", "Worker commit"],
cwd=worker_info.path, capture_output=True
cwd=worker_info.path,
capture_output=True,
)
assert commit_result.returncode == 0, (
f"git commit failed: {commit_result.stderr}"
)
assert commit_result.returncode == 0, f"git commit failed: {commit_result.stderr}"
# First merge with no_commit succeeds
result = manager.merge_worktree("worker-spec", no_commit=True, delete_after=False)
result = manager.merge_worktree(
"worker-spec", no_commit=True, delete_after=False
)
assert result is True
# Commit the staged changes
merge_commit_result = subprocess.run(
["git", "commit", "-m", "Merge commit"],
cwd=temp_git_repo, capture_output=True
cwd=temp_git_repo,
capture_output=True,
)
assert merge_commit_result.returncode == 0, (
f"git commit failed: {merge_commit_result.stderr}"
)
assert merge_commit_result.returncode == 0, f"git commit failed: {merge_commit_result.stderr}"
# Second merge should also succeed (already up to date)
result = manager.merge_worktree("worker-spec", no_commit=True, delete_after=False)
result = manager.merge_worktree(
"worker-spec", no_commit=True, delete_after=False
)
assert result is True
def test_merge_worktree_already_up_to_date_with_delete_after(self, temp_git_repo: Path):
def test_merge_worktree_already_up_to_date_with_delete_after(
self, temp_git_repo: Path
):
"""merge_worktree with delete_after=True succeeds when already up to date (ACS-226)."""
manager = WorktreeManager(temp_git_repo)
manager.setup()
@@ -262,13 +482,18 @@ class TestWorktreeCommitAndMerge:
worker_info = manager.create_worktree("worker-spec")
branch_name = worker_info.branch
(worker_info.path / "worker-file.txt").write_text("worker content")
add_result = subprocess.run(["git", "add", "."], cwd=worker_info.path, capture_output=True)
add_result = subprocess.run(
["git", "add", "."], cwd=worker_info.path, capture_output=True
)
assert add_result.returncode == 0, f"git add failed: {add_result.stderr}"
commit_result = subprocess.run(
["git", "commit", "-m", "Worker commit"],
cwd=worker_info.path, capture_output=True
cwd=worker_info.path,
capture_output=True,
)
assert commit_result.returncode == 0, (
f"git commit failed: {commit_result.stderr}"
)
assert commit_result.returncode == 0, f"git commit failed: {commit_result.stderr}"
# First merge succeeds
result = manager.merge_worktree("worker-spec", delete_after=False)
@@ -284,9 +509,13 @@ class TestWorktreeCommitAndMerge:
# Verify branch was deleted
branch_list_result = subprocess.run(
["git", "branch", "--list", branch_name],
cwd=temp_git_repo, capture_output=True, text=True
cwd=temp_git_repo,
capture_output=True,
text=True,
)
assert branch_name not in branch_list_result.stdout, (
f"Branch {branch_name} should be deleted"
)
assert branch_name not in branch_list_result.stdout, f"Branch {branch_name} should be deleted"
def test_merge_worktree_conflict_detection(self, temp_git_repo: Path):
"""merge_worktree correctly detects and handles merge conflicts."""
@@ -295,34 +524,49 @@ class TestWorktreeCommitAndMerge:
# Create initial file on base branch
(temp_git_repo / "shared.txt").write_text("base content")
add_result = subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
add_result = subprocess.run(
["git", "add", "."], cwd=temp_git_repo, capture_output=True
)
assert add_result.returncode == 0, f"git add failed: {add_result.stderr}"
commit_result = subprocess.run(
["git", "commit", "-m", "Add shared file"],
cwd=temp_git_repo, capture_output=True
cwd=temp_git_repo,
capture_output=True,
)
assert commit_result.returncode == 0, (
f"git commit failed: {commit_result.stderr}"
)
assert commit_result.returncode == 0, f"git commit failed: {commit_result.stderr}"
# Create worktree with conflicting change
worker_info = manager.create_worktree("worker-spec")
(worker_info.path / "shared.txt").write_text("worker content")
add_result = subprocess.run(["git", "add", "."], cwd=worker_info.path, capture_output=True)
add_result = subprocess.run(
["git", "add", "."], cwd=worker_info.path, capture_output=True
)
assert add_result.returncode == 0, f"git add failed: {add_result.stderr}"
commit_result = subprocess.run(
["git", "commit", "-m", "Worker change"],
cwd=worker_info.path, capture_output=True
cwd=worker_info.path,
capture_output=True,
)
assert commit_result.returncode == 0, (
f"git commit failed: {commit_result.stderr}"
)
assert commit_result.returncode == 0, f"git commit failed: {commit_result.stderr}"
# Make conflicting change on base branch
(temp_git_repo / "shared.txt").write_text("base change")
add_result = subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
add_result = subprocess.run(
["git", "add", "."], cwd=temp_git_repo, capture_output=True
)
assert add_result.returncode == 0, f"git add failed: {add_result.stderr}"
commit_result = subprocess.run(
["git", "commit", "-m", "Base change"],
cwd=temp_git_repo, capture_output=True
cwd=temp_git_repo,
capture_output=True,
)
assert commit_result.returncode == 0, (
f"git commit failed: {commit_result.stderr}"
)
assert commit_result.returncode == 0, f"git commit failed: {commit_result.stderr}"
# Merge should detect conflict and fail
result = manager.merge_worktree("worker-spec", delete_after=False)
@@ -332,18 +576,25 @@ class TestWorktreeCommitAndMerge:
# Check that MERGE_HEAD does not exist
merge_head_result = subprocess.run(
["git", "rev-parse", "--verify", "MERGE_HEAD"],
cwd=temp_git_repo, capture_output=True
cwd=temp_git_repo,
capture_output=True,
)
assert merge_head_result.returncode != 0, (
"MERGE_HEAD should not exist after abort"
)
assert merge_head_result.returncode != 0, "MERGE_HEAD should not exist after abort"
# Verify git status shows no unmerged/conflict status codes
git_status = subprocess.run(
["git", "status", "--porcelain"],
cwd=temp_git_repo, capture_output=True, text=True
cwd=temp_git_repo,
capture_output=True,
text=True,
)
# Should have no output (clean working directory)
assert git_status.returncode == 0
assert not git_status.stdout.strip(), f"Expected clean status, got: {git_status.stdout}"
assert not git_status.stdout.strip(), (
f"Expected clean status, got: {git_status.stdout}"
)
def test_merge_worktree_conflict_with_no_commit(self, temp_git_repo: Path):
"""merge_worktree with no_commit=True handles conflicts correctly."""
@@ -352,54 +603,78 @@ class TestWorktreeCommitAndMerge:
# Create initial file on base branch
(temp_git_repo / "shared.txt").write_text("base content")
add_result = subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
add_result = subprocess.run(
["git", "add", "."], cwd=temp_git_repo, capture_output=True
)
assert add_result.returncode == 0, f"git add failed: {add_result.stderr}"
commit_result = subprocess.run(
["git", "commit", "-m", "Add shared file"],
cwd=temp_git_repo, capture_output=True
cwd=temp_git_repo,
capture_output=True,
)
assert commit_result.returncode == 0, (
f"git commit failed: {commit_result.stderr}"
)
assert commit_result.returncode == 0, f"git commit failed: {commit_result.stderr}"
# Create worktree with conflicting change
worker_info = manager.create_worktree("worker-spec")
(worker_info.path / "shared.txt").write_text("worker content")
add_result = subprocess.run(["git", "add", "."], cwd=worker_info.path, capture_output=True)
add_result = subprocess.run(
["git", "add", "."], cwd=worker_info.path, capture_output=True
)
assert add_result.returncode == 0, f"git add failed: {add_result.stderr}"
commit_result = subprocess.run(
["git", "commit", "-m", "Worker change"],
cwd=worker_info.path, capture_output=True
cwd=worker_info.path,
capture_output=True,
)
assert commit_result.returncode == 0, (
f"git commit failed: {commit_result.stderr}"
)
assert commit_result.returncode == 0, f"git commit failed: {commit_result.stderr}"
# Make conflicting change on base branch
(temp_git_repo / "shared.txt").write_text("base change")
add_result = subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
add_result = subprocess.run(
["git", "add", "."], cwd=temp_git_repo, capture_output=True
)
assert add_result.returncode == 0, f"git add failed: {add_result.stderr}"
commit_result = subprocess.run(
["git", "commit", "-m", "Base change"],
cwd=temp_git_repo, capture_output=True
cwd=temp_git_repo,
capture_output=True,
)
assert commit_result.returncode == 0, (
f"git commit failed: {commit_result.stderr}"
)
assert commit_result.returncode == 0, f"git commit failed: {commit_result.stderr}"
# Merge with no_commit should detect conflict and fail
result = manager.merge_worktree("worker-spec", no_commit=True, delete_after=False)
result = manager.merge_worktree(
"worker-spec", no_commit=True, delete_after=False
)
assert result is False
# Verify merge was aborted (no merge state exists)
# Check that MERGE_HEAD does not exist
merge_head_result = subprocess.run(
["git", "rev-parse", "--verify", "MERGE_HEAD"],
cwd=temp_git_repo, capture_output=True
cwd=temp_git_repo,
capture_output=True,
)
assert merge_head_result.returncode != 0, (
"MERGE_HEAD should not exist after abort"
)
assert merge_head_result.returncode != 0, "MERGE_HEAD should not exist after abort"
# Verify git status shows no staged/unstaged changes
git_status = subprocess.run(
["git", "status", "--porcelain"],
cwd=temp_git_repo, capture_output=True, text=True
cwd=temp_git_repo,
capture_output=True,
text=True,
)
assert git_status.returncode == 0
assert not git_status.stdout.strip(), f"Expected clean status, got: {git_status.stdout}"
assert not git_status.stdout.strip(), (
f"Expected clean status, got: {git_status.stdout}"
)
class TestChangeTracking:
@@ -433,8 +708,7 @@ class TestChangeTracking:
(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
["git", "commit", "-m", "Changes"], cwd=info.path, capture_output=True
)
summary = manager.get_change_summary("test-spec")
@@ -452,8 +726,7 @@ class TestChangeTracking:
(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
["git", "commit", "-m", "Add file"], cwd=info.path, capture_output=True
)
files = manager.get_changed_files("test-spec")