Coverage for little_loops / worktree_utils.py: 98%
57 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-05-22 16:19 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-05-22 16:19 -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) -> None:
29 """Create a git worktree on a new branch and copy essential files.
31 Copies the .claude/ directory (for project root detection by Claude Code)
32 and any additional files listed in copy_files. Writes a session marker so
33 orphan-cleanup routines can identify this process's worktrees.
35 Args:
36 repo_path: Path to the main repository.
37 worktree_path: Destination path for the new worktree.
38 branch_name: Name of the new branch to create.
39 copy_files: File paths (relative to repo_path) to copy into the worktree.
40 logger: Logger instance.
41 git_lock: Thread-safe git lock for serializing repo operations.
43 Raises:
44 RuntimeError: If git worktree creation fails.
45 """
46 if worktree_path.exists():
47 cleanup_worktree(worktree_path, repo_path, logger, git_lock, delete_branch=True)
49 result = git_lock.run(
50 ["worktree", "add", "-b", branch_name, str(worktree_path)],
51 cwd=repo_path,
52 timeout=60,
53 )
54 if result.returncode != 0:
55 raise RuntimeError(f"Failed to create worktree: {result.stderr}")
57 # Copy git identity so commits inside the worktree have the right author
58 for config_key in ["user.email", "user.name"]:
59 value_result = git_lock.run(["config", config_key], cwd=repo_path)
60 if value_result.returncode == 0 and value_result.stdout.strip():
61 subprocess.run(
62 ["git", "config", config_key, value_result.stdout.strip()],
63 cwd=worktree_path,
64 capture_output=True,
65 )
67 # Copy .claude/ to establish project root for Claude Code (BUG-007)
68 claude_dir = repo_path / ".claude"
69 if claude_dir.exists() and claude_dir.is_dir():
70 dest_claude_dir = worktree_path / ".claude"
71 if dest_claude_dir.exists():
72 shutil.rmtree(dest_claude_dir)
73 shutil.copytree(claude_dir, dest_claude_dir)
74 logger.info("Copied .claude/ directory to worktree")
76 # Copy additional configured files
77 for file_path in copy_files:
78 if file_path.startswith(".claude/"):
79 continue # already covered by the copytree above
80 src = repo_path / file_path
81 if src.exists():
82 if src.is_dir():
83 logger.warning(
84 f"Skipping '{file_path}' in copy_files: "
85 "is a directory (use symlinks or copytree for directories)"
86 )
87 continue
88 dest = worktree_path / file_path
89 dest.parent.mkdir(parents=True, exist_ok=True)
90 shutil.copy2(src, dest)
91 logger.info(f"Copied {file_path} to worktree")
92 else:
93 logger.debug(f"Skipped {file_path} (not found in main repo)")
95 logger.info(f"Created worktree at {worktree_path} on branch {branch_name}")
97 # Write session marker for orphan cleanup (BUG-579)
98 if worktree_path.exists():
99 marker_path = worktree_path / f".ll-session-{os.getpid()}"
100 marker_path.write_text(str(os.getpid()))
103def cleanup_worktree(
104 worktree_path: Path,
105 repo_path: Path,
106 logger: Logger,
107 git_lock: GitLock,
108 delete_branch: bool = True,
109) -> None:
110 """Remove a git worktree and optionally its associated branch.
112 Args:
113 worktree_path: Path to the worktree to remove.
114 repo_path: Path to the main repository.
115 logger: Logger instance.
116 git_lock: Thread-safe git lock for serializing repo operations.
117 delete_branch: If True, detect and delete the worktree's branch after removal.
118 """
119 if not worktree_path.exists():
120 return
122 branch_name: str | None = None
123 if delete_branch:
124 branch_result = subprocess.run(
125 ["git", "rev-parse", "--abbrev-ref", "HEAD"],
126 cwd=worktree_path,
127 capture_output=True,
128 text=True,
129 )
130 branch_name = branch_result.stdout.strip() if branch_result.returncode == 0 else None
132 git_lock.run(["worktree", "unlock", str(worktree_path)], cwd=repo_path, timeout=10)
133 git_lock.run(
134 ["worktree", "remove", "--force", str(worktree_path)],
135 cwd=repo_path,
136 timeout=30,
137 )
139 if worktree_path.exists():
140 shutil.rmtree(worktree_path, ignore_errors=True)
142 if delete_branch and branch_name:
143 git_lock.run(["branch", "-D", branch_name], cwd=repo_path, timeout=10)
144 logger.info(f"Deleted branch {branch_name}")
147def _is_ll_worktree(name: str) -> bool:
148 """Return True if the directory name matches an ll-managed worktree naming pattern.
150 Matches both ll-parallel worker dirs (``worker-<issue>-<timestamp>``) and
151 ll-loop worktree dirs (``<YYYYMMDD>-<HHMMSS>-<safe-name>``).
152 """
153 return name.startswith("worker-") or re.match(r"^\d{8}-\d{6}-", name) is not None