Coverage for little_loops / worktree_utils.py: 16%
64 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-16 13:08 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-16 13:08 -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(
61 f"Branch '{base_branch}' does not resolve: {verify_result.stderr}"
62 )
64 worktree_args = ["worktree", "add", "-b", branch_name, str(worktree_path)]
65 if base_branch is not None:
66 worktree_args.append(base_branch)
68 result = git_lock.run(
69 worktree_args,
70 cwd=repo_path,
71 timeout=60,
72 )
73 if result.returncode != 0:
74 raise RuntimeError(f"Failed to create worktree: {result.stderr}")
76 # Copy git identity so commits inside the worktree have the right author
77 for config_key in ["user.email", "user.name"]:
78 value_result = git_lock.run(["config", config_key], cwd=repo_path)
79 if value_result.returncode == 0 and value_result.stdout.strip():
80 subprocess.run(
81 ["git", "config", config_key, value_result.stdout.strip()],
82 cwd=worktree_path,
83 capture_output=True,
84 )
86 # Copy .claude/ to establish project root for Claude Code (BUG-007)
87 claude_dir = repo_path / ".claude"
88 if claude_dir.exists() and claude_dir.is_dir():
89 dest_claude_dir = worktree_path / ".claude"
90 if dest_claude_dir.exists():
91 shutil.rmtree(dest_claude_dir)
92 shutil.copytree(claude_dir, dest_claude_dir)
93 logger.info("Copied .claude/ directory to worktree")
95 # Copy additional configured files
96 for file_path in copy_files:
97 if file_path.startswith(".claude/"):
98 continue # already covered by the copytree above
99 src = repo_path / file_path
100 if src.exists():
101 if src.is_dir():
102 logger.warning(
103 f"Skipping '{file_path}' in copy_files: "
104 "is a directory (use symlinks or copytree for directories)"
105 )
106 continue
107 dest = worktree_path / file_path
108 dest.parent.mkdir(parents=True, exist_ok=True)
109 shutil.copy2(src, dest)
110 logger.info(f"Copied {file_path} to worktree")
111 else:
112 logger.debug(f"Skipped {file_path} (not found in main repo)")
114 logger.info(f"Created worktree at {worktree_path} on branch {branch_name}")
116 # Write session marker for orphan cleanup (BUG-579)
117 if worktree_path.exists():
118 marker_path = worktree_path / f".ll-session-{os.getpid()}"
119 marker_path.write_text(str(os.getpid()))
122def cleanup_worktree(
123 worktree_path: Path,
124 repo_path: Path,
125 logger: Logger,
126 git_lock: GitLock,
127 delete_branch: bool = True,
128) -> None:
129 """Remove a git worktree and optionally its associated branch.
131 Args:
132 worktree_path: Path to the worktree to remove.
133 repo_path: Path to the main repository.
134 logger: Logger instance.
135 git_lock: Thread-safe git lock for serializing repo operations.
136 delete_branch: If True, detect and delete the worktree's branch after removal.
137 """
138 if not worktree_path.exists():
139 return
141 branch_name: str | None = None
142 if delete_branch:
143 branch_result = subprocess.run(
144 ["git", "rev-parse", "--abbrev-ref", "HEAD"],
145 cwd=worktree_path,
146 capture_output=True,
147 text=True,
148 )
149 branch_name = branch_result.stdout.strip() if branch_result.returncode == 0 else None
151 git_lock.run(["worktree", "unlock", str(worktree_path)], cwd=repo_path, timeout=10)
152 git_lock.run(
153 ["worktree", "remove", "--force", str(worktree_path)],
154 cwd=repo_path,
155 timeout=30,
156 )
158 if worktree_path.exists():
159 shutil.rmtree(worktree_path, ignore_errors=True)
161 if delete_branch and branch_name:
162 git_lock.run(["branch", "-D", branch_name], cwd=repo_path, timeout=10)
163 logger.info(f"Deleted branch {branch_name}")
166def _is_ll_worktree(name: str) -> bool:
167 """Return True if the directory name matches an ll-managed worktree naming pattern.
169 Matches both ll-parallel worker dirs (``worker-<issue>-<timestamp>``) and
170 ll-loop worktree dirs (``<YYYYMMDD>-<HHMMSS>-<safe-name>``).
171 """
172 return name.startswith("worker-") or re.match(r"^\d{8}-\d{6}-", name) is not None