Coverage for little_loops / worktree_utils.py: 0%
53 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-04-11 23:19 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-04-11 23: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 shutil
11import subprocess
12from pathlib import Path
13from typing import TYPE_CHECKING
15if TYPE_CHECKING:
16 from little_loops.logger import Logger
17 from little_loops.parallel.git_lock import GitLock
20def setup_worktree(
21 repo_path: Path,
22 worktree_path: Path,
23 branch_name: str,
24 copy_files: list[str],
25 logger: Logger,
26 git_lock: GitLock,
27) -> None:
28 """Create a git worktree on a new branch and copy essential files.
30 Copies the .claude/ directory (for project root detection by Claude Code)
31 and any additional files listed in copy_files. Writes a session marker so
32 orphan-cleanup routines can identify this process's worktrees.
34 Args:
35 repo_path: Path to the main repository.
36 worktree_path: Destination path for the new worktree.
37 branch_name: Name of the new branch to create.
38 copy_files: File paths (relative to repo_path) to copy into the worktree.
39 logger: Logger instance.
40 git_lock: Thread-safe git lock for serializing repo operations.
42 Raises:
43 RuntimeError: If git worktree creation fails.
44 """
45 if worktree_path.exists():
46 cleanup_worktree(worktree_path, repo_path, logger, git_lock, delete_branch=True)
48 result = git_lock.run(
49 ["worktree", "add", "-b", branch_name, str(worktree_path)],
50 cwd=repo_path,
51 timeout=60,
52 )
53 if result.returncode != 0:
54 raise RuntimeError(f"Failed to create worktree: {result.stderr}")
56 # Copy git identity so commits inside the worktree have the right author
57 for config_key in ["user.email", "user.name"]:
58 value_result = git_lock.run(["config", config_key], cwd=repo_path)
59 if value_result.returncode == 0 and value_result.stdout.strip():
60 subprocess.run(
61 ["git", "config", config_key, value_result.stdout.strip()],
62 cwd=worktree_path,
63 capture_output=True,
64 )
66 # Copy .claude/ to establish project root for Claude Code (BUG-007)
67 claude_dir = repo_path / ".claude"
68 if claude_dir.exists() and claude_dir.is_dir():
69 dest_claude_dir = worktree_path / ".claude"
70 if dest_claude_dir.exists():
71 shutil.rmtree(dest_claude_dir)
72 shutil.copytree(claude_dir, dest_claude_dir)
73 logger.info("Copied .claude/ directory to worktree")
75 # Copy additional configured files
76 for file_path in copy_files:
77 if file_path.startswith(".claude/"):
78 continue # already covered by the copytree above
79 src = repo_path / file_path
80 if src.exists():
81 if src.is_dir():
82 logger.warning(
83 f"Skipping '{file_path}' in copy_files: "
84 "is a directory (use symlinks or copytree for directories)"
85 )
86 continue
87 dest = worktree_path / file_path
88 dest.parent.mkdir(parents=True, exist_ok=True)
89 shutil.copy2(src, dest)
90 logger.info(f"Copied {file_path} to worktree")
91 else:
92 logger.debug(f"Skipped {file_path} (not found in main repo)")
94 logger.info(f"Created worktree at {worktree_path} on branch {branch_name}")
96 # Write session marker for orphan cleanup (BUG-579)
97 if worktree_path.exists():
98 marker_path = worktree_path / f".ll-session-{os.getpid()}"
99 marker_path.write_text(str(os.getpid()))
102def cleanup_worktree(
103 worktree_path: Path,
104 repo_path: Path,
105 logger: Logger,
106 git_lock: GitLock,
107 delete_branch: bool = True,
108) -> None:
109 """Remove a git worktree and optionally its associated branch.
111 Args:
112 worktree_path: Path to the worktree to remove.
113 repo_path: Path to the main repository.
114 logger: Logger instance.
115 git_lock: Thread-safe git lock for serializing repo operations.
116 delete_branch: If True, detect and delete the worktree's branch after removal.
117 """
118 if not worktree_path.exists():
119 return
121 branch_name: str | None = None
122 if delete_branch:
123 branch_result = subprocess.run(
124 ["git", "rev-parse", "--abbrev-ref", "HEAD"],
125 cwd=worktree_path,
126 capture_output=True,
127 text=True,
128 )
129 branch_name = branch_result.stdout.strip() if branch_result.returncode == 0 else None
131 git_lock.run(
132 ["worktree", "remove", "--force", str(worktree_path)],
133 cwd=repo_path,
134 timeout=30,
135 )
137 if worktree_path.exists():
138 shutil.rmtree(worktree_path, ignore_errors=True)
140 if delete_branch and branch_name:
141 git_lock.run(["branch", "-D", branch_name], cwd=repo_path, timeout=10)
142 logger.info(f"Deleted branch {branch_name}")