Coverage for little_loops / worktree_utils.py: 16%
64 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-26 17:38 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-26 17:38 -0500
1"""Shared worktree setup and cleanup utilities.
3Used by ll-parallel, ll-sprint, and ll-loop to create and remove isolated git
4worktrees with consistent file-copy behavior.
5"""
7from __future__ import annotations
9import os
10import re
11import shutil
12import subprocess
13from pathlib import Path
14from typing import TYPE_CHECKING
16if TYPE_CHECKING:
17 from little_loops.logger import Logger
18 from little_loops.parallel.git_lock import GitLock
21def setup_worktree(
22 repo_path: Path,
23 worktree_path: Path,
24 branch_name: str,
25 copy_files: list[str],
26 logger: Logger,
27 git_lock: GitLock,
28 base_branch: str | None = None,
29) -> None:
30 """Create a git worktree on a new branch and copy essential files.
32 Copies the .claude/ directory (for project root detection by Claude Code)
33 and any additional files listed in copy_files. Writes a session marker so
34 orphan-cleanup routines can identify this process's worktrees.
36 Args:
37 repo_path: Path to the main repository.
38 worktree_path: Destination path for the new worktree.
39 branch_name: Name of the new branch to create.
40 copy_files: File paths (relative to repo_path) to copy into the worktree.
41 logger: Logger instance.
42 git_lock: Thread-safe git lock for serializing repo operations.
43 base_branch: Optional commit-ish to fork the new branch from. When None,
44 the branch forks from the current HEAD of repo_path (existing behavior).
45 When provided, validated before use; fails fast if unresolvable.
47 Raises:
48 RuntimeError: If git worktree creation fails or base_branch does not resolve.
49 """
50 if worktree_path.exists():
51 cleanup_worktree(worktree_path, repo_path, logger, git_lock, delete_branch=True)
53 if base_branch is not None:
54 verify_result = git_lock.run(
55 ["rev-parse", "--verify", base_branch],
56 cwd=repo_path,
57 timeout=10,
58 )
59 if verify_result.returncode != 0:
60 raise RuntimeError(f"Branch '{base_branch}' does not resolve: {verify_result.stderr}")
62 worktree_args = ["worktree", "add", "-b", branch_name, str(worktree_path)]
63 if base_branch is not None:
64 worktree_args.append(base_branch)
66 result = git_lock.run(
67 worktree_args,
68 cwd=repo_path,
69 timeout=60,
70 )
71 if result.returncode != 0:
72 raise RuntimeError(f"Failed to create worktree: {result.stderr}")
74 # Copy git identity so commits inside the worktree have the right author
75 for config_key in ["user.email", "user.name"]:
76 value_result = git_lock.run(["config", config_key], cwd=repo_path)
77 if value_result.returncode == 0 and value_result.stdout.strip():
78 subprocess.run(
79 ["git", "config", config_key, value_result.stdout.strip()],
80 cwd=worktree_path,
81 capture_output=True,
82 )
84 # Copy .claude/ to establish project root for Claude Code (BUG-007)
85 claude_dir = repo_path / ".claude"
86 if claude_dir.exists() and claude_dir.is_dir():
87 dest_claude_dir = worktree_path / ".claude"
88 if dest_claude_dir.exists():
89 shutil.rmtree(dest_claude_dir)
90 shutil.copytree(claude_dir, dest_claude_dir)
91 logger.info("Copied .claude/ directory to worktree")
93 # Copy additional configured files
94 for file_path in copy_files:
95 if file_path.startswith(".claude/"):
96 continue # already covered by the copytree above
97 src = repo_path / file_path
98 if src.exists():
99 if src.is_dir():
100 logger.warning(
101 f"Skipping '{file_path}' in copy_files: "
102 "is a directory (use symlinks or copytree for directories)"
103 )
104 continue
105 dest = worktree_path / file_path
106 dest.parent.mkdir(parents=True, exist_ok=True)
107 shutil.copy2(src, dest)
108 logger.info(f"Copied {file_path} to worktree")
109 else:
110 logger.debug(f"Skipped {file_path} (not found in main repo)")
112 logger.info(f"Created worktree at {worktree_path} on branch {branch_name}")
114 # Write session marker for orphan cleanup (BUG-579)
115 if worktree_path.exists():
116 marker_path = worktree_path / f".ll-session-{os.getpid()}"
117 marker_path.write_text(str(os.getpid()))
120def cleanup_worktree(
121 worktree_path: Path,
122 repo_path: Path,
123 logger: Logger,
124 git_lock: GitLock,
125 delete_branch: bool = True,
126) -> None:
127 """Remove a git worktree and optionally its associated branch.
129 Args:
130 worktree_path: Path to the worktree to remove.
131 repo_path: Path to the main repository.
132 logger: Logger instance.
133 git_lock: Thread-safe git lock for serializing repo operations.
134 delete_branch: If True, detect and delete the worktree's branch after removal.
135 """
136 if not worktree_path.exists():
137 return
139 branch_name: str | None = None
140 if delete_branch:
141 branch_result = subprocess.run(
142 ["git", "rev-parse", "--abbrev-ref", "HEAD"],
143 cwd=worktree_path,
144 capture_output=True,
145 text=True,
146 )
147 branch_name = branch_result.stdout.strip() if branch_result.returncode == 0 else None
149 git_lock.run(["worktree", "unlock", str(worktree_path)], cwd=repo_path, timeout=10)
150 git_lock.run(
151 ["worktree", "remove", "--force", str(worktree_path)],
152 cwd=repo_path,
153 timeout=30,
154 )
156 if worktree_path.exists():
157 shutil.rmtree(worktree_path, ignore_errors=True)
159 if delete_branch and branch_name:
160 git_lock.run(["branch", "-D", branch_name], cwd=repo_path, timeout=10)
161 logger.info(f"Deleted branch {branch_name}")
164def _is_ll_worktree(name: str) -> bool:
165 """Return True if the directory name matches an ll-managed worktree naming pattern.
167 Matches both ll-parallel worker dirs (``worker-<issue>-<timestamp>``) and
168 ll-loop worktree dirs (``<YYYYMMDD>-<HHMMSS>-<safe-name>``).
169 """
170 return name.startswith("worker-") or re.match(r"^\d{8}-\d{6}-", name) is not None