claude-team-mcp 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- claude_team_mcp/__init__.py +24 -0
- claude_team_mcp/__main__.py +8 -0
- claude_team_mcp/cli_backends/__init__.py +44 -0
- claude_team_mcp/cli_backends/base.py +132 -0
- claude_team_mcp/cli_backends/claude.py +110 -0
- claude_team_mcp/cli_backends/codex.py +110 -0
- claude_team_mcp/colors.py +108 -0
- claude_team_mcp/formatting.py +120 -0
- claude_team_mcp/idle_detection.py +488 -0
- claude_team_mcp/iterm_utils.py +1119 -0
- claude_team_mcp/names.py +427 -0
- claude_team_mcp/profile.py +364 -0
- claude_team_mcp/registry.py +426 -0
- claude_team_mcp/schemas/__init__.py +5 -0
- claude_team_mcp/schemas/codex.py +267 -0
- claude_team_mcp/server.py +390 -0
- claude_team_mcp/session_state.py +1058 -0
- claude_team_mcp/subprocess_cache.py +119 -0
- claude_team_mcp/tools/__init__.py +52 -0
- claude_team_mcp/tools/adopt_worker.py +122 -0
- claude_team_mcp/tools/annotate_worker.py +57 -0
- claude_team_mcp/tools/bd_help.py +42 -0
- claude_team_mcp/tools/check_idle_workers.py +98 -0
- claude_team_mcp/tools/close_workers.py +194 -0
- claude_team_mcp/tools/discover_workers.py +129 -0
- claude_team_mcp/tools/examine_worker.py +56 -0
- claude_team_mcp/tools/list_workers.py +76 -0
- claude_team_mcp/tools/list_worktrees.py +106 -0
- claude_team_mcp/tools/message_workers.py +311 -0
- claude_team_mcp/tools/read_worker_logs.py +158 -0
- claude_team_mcp/tools/spawn_workers.py +634 -0
- claude_team_mcp/tools/wait_idle_workers.py +148 -0
- claude_team_mcp/utils/__init__.py +17 -0
- claude_team_mcp/utils/constants.py +87 -0
- claude_team_mcp/utils/errors.py +87 -0
- claude_team_mcp/utils/worktree_detection.py +79 -0
- claude_team_mcp/worker_prompt.py +350 -0
- claude_team_mcp/worktree.py +532 -0
- claude_team_mcp-0.4.0.dist-info/METADATA +414 -0
- claude_team_mcp-0.4.0.dist-info/RECORD +42 -0
- claude_team_mcp-0.4.0.dist-info/WHEEL +4 -0
- claude_team_mcp-0.4.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,532 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Git worktree utilities for worker session isolation.
|
|
3
|
+
|
|
4
|
+
Provides functions to create, remove, and list git worktrees, enabling
|
|
5
|
+
each worker session to operate in its own isolated working directory
|
|
6
|
+
while sharing the same repository history.
|
|
7
|
+
|
|
8
|
+
Two worktree strategies are supported:
|
|
9
|
+
|
|
10
|
+
1. External worktrees (legacy):
|
|
11
|
+
~/.claude-team/worktrees/{repo-path-hash}/{worker-name}-{timestamp}/
|
|
12
|
+
- Created outside the target repo to avoid polluting it
|
|
13
|
+
- No .gitignore modifications needed
|
|
14
|
+
|
|
15
|
+
2. Local worktrees (preferred):
|
|
16
|
+
{repo}/.worktrees/{bead-annotation}/ or {name-uuid-annotation}/
|
|
17
|
+
- Kept within the repo for easier discovery and cleanup
|
|
18
|
+
- Automatically adds .worktrees to .gitignore
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import hashlib
|
|
22
|
+
import re
|
|
23
|
+
import subprocess
|
|
24
|
+
import time
|
|
25
|
+
import uuid
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Optional
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# Base directory for all worktrees (outside any repo)
|
|
31
|
+
WORKTREE_BASE_DIR = Path.home() / ".claude-team" / "worktrees"
|
|
32
|
+
|
|
33
|
+
# Local worktree directory name within repos
|
|
34
|
+
LOCAL_WORKTREE_DIR = ".worktrees"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def slugify(text: str) -> str:
|
|
38
|
+
"""
|
|
39
|
+
Convert text to a URL/filesystem-friendly slug.
|
|
40
|
+
|
|
41
|
+
Converts to lowercase, replaces spaces and special chars with dashes,
|
|
42
|
+
and removes consecutive dashes.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
text: The text to slugify
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
A lowercase, dash-separated string safe for filenames/URLs
|
|
49
|
+
|
|
50
|
+
Example:
|
|
51
|
+
slugify("Add local worktrees support") # "add-local-worktrees-support"
|
|
52
|
+
slugify("Fix Bug #123") # "fix-bug-123"
|
|
53
|
+
"""
|
|
54
|
+
# Convert to lowercase
|
|
55
|
+
text = text.lower()
|
|
56
|
+
# Replace spaces and underscores with dashes
|
|
57
|
+
text = re.sub(r"[\s_]+", "-", text)
|
|
58
|
+
# Remove any characters that aren't alphanumeric or dashes
|
|
59
|
+
text = re.sub(r"[^a-z0-9-]", "", text)
|
|
60
|
+
# Collapse multiple dashes
|
|
61
|
+
text = re.sub(r"-+", "-", text)
|
|
62
|
+
# Strip leading/trailing dashes
|
|
63
|
+
text = text.strip("-")
|
|
64
|
+
return text
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def ensure_gitignore_entry(repo_path: Path, entry: str) -> bool:
|
|
68
|
+
"""
|
|
69
|
+
Ensure an entry exists in the repository's .gitignore file.
|
|
70
|
+
|
|
71
|
+
Creates the .gitignore file if it doesn't exist. Adds the entry
|
|
72
|
+
on a new line if not already present.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
repo_path: Path to the repository root
|
|
76
|
+
entry: The gitignore entry to add (e.g., ".worktrees")
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
True if the entry was added, False if it already existed
|
|
80
|
+
|
|
81
|
+
Example:
|
|
82
|
+
ensure_gitignore_entry(Path("/path/to/repo"), ".worktrees")
|
|
83
|
+
"""
|
|
84
|
+
gitignore_path = Path(repo_path) / ".gitignore"
|
|
85
|
+
|
|
86
|
+
# Check if entry already exists
|
|
87
|
+
if gitignore_path.exists():
|
|
88
|
+
content = gitignore_path.read_text()
|
|
89
|
+
lines = content.splitlines()
|
|
90
|
+
|
|
91
|
+
# Check for exact match (with or without trailing slash)
|
|
92
|
+
entry_variants = {entry, entry + "/", entry.rstrip("/")}
|
|
93
|
+
for line in lines:
|
|
94
|
+
stripped = line.strip()
|
|
95
|
+
if stripped in entry_variants:
|
|
96
|
+
return False
|
|
97
|
+
|
|
98
|
+
# Entry not found, append it
|
|
99
|
+
# Ensure there's a newline before our entry if file doesn't end with one
|
|
100
|
+
if content and not content.endswith("\n"):
|
|
101
|
+
content += "\n"
|
|
102
|
+
content += f"{entry}\n"
|
|
103
|
+
gitignore_path.write_text(content)
|
|
104
|
+
return True
|
|
105
|
+
else:
|
|
106
|
+
# Create new .gitignore with the entry
|
|
107
|
+
gitignore_path.write_text(f"{entry}\n")
|
|
108
|
+
return True
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class WorktreeError(Exception):
|
|
112
|
+
"""Raised when a git worktree operation fails."""
|
|
113
|
+
|
|
114
|
+
pass
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def get_repo_hash(repo_path: Path) -> str:
|
|
118
|
+
"""
|
|
119
|
+
Generate a short hash from a repository path.
|
|
120
|
+
|
|
121
|
+
Used to create unique subdirectories for each repo's worktrees.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
repo_path: Absolute path to the repository
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
8-character hex hash of the repo path
|
|
128
|
+
"""
|
|
129
|
+
return hashlib.sha256(str(repo_path).encode()).hexdigest()[:8]
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def get_worktree_base_for_repo(repo_path: Path) -> Path:
|
|
133
|
+
"""
|
|
134
|
+
Get the base directory for a repo's worktrees.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
repo_path: Path to the main repository
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
Path to ~/.claude-team/worktrees/{repo-hash}/
|
|
141
|
+
"""
|
|
142
|
+
repo_path = Path(repo_path).resolve()
|
|
143
|
+
repo_hash = get_repo_hash(repo_path)
|
|
144
|
+
return WORKTREE_BASE_DIR / repo_hash
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def create_worktree(
|
|
148
|
+
repo_path: Path,
|
|
149
|
+
worktree_name: str,
|
|
150
|
+
branch: Optional[str] = None,
|
|
151
|
+
timestamp: Optional[int] = None,
|
|
152
|
+
) -> Path:
|
|
153
|
+
"""
|
|
154
|
+
Create a git worktree for a worker.
|
|
155
|
+
|
|
156
|
+
Creates a new worktree at:
|
|
157
|
+
~/.claude-team/worktrees/{repo-hash}/{worktree_name}-{timestamp}/
|
|
158
|
+
|
|
159
|
+
If a branch is specified and doesn't exist, it will be created from HEAD.
|
|
160
|
+
If no branch is specified, creates a detached HEAD worktree.
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
repo_path: Path to the main repository
|
|
164
|
+
worktree_name: Name for the worktree (worker name, e.g., "John-abc123")
|
|
165
|
+
branch: Branch to checkout (creates new branch from HEAD if doesn't exist)
|
|
166
|
+
timestamp: Unix timestamp for directory name (defaults to current time)
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
Path to the created worktree
|
|
170
|
+
|
|
171
|
+
Raises:
|
|
172
|
+
WorktreeError: If the git worktree command fails
|
|
173
|
+
|
|
174
|
+
Example:
|
|
175
|
+
path = create_worktree(
|
|
176
|
+
repo_path=Path("/path/to/repo"),
|
|
177
|
+
worktree_name="John-abc123",
|
|
178
|
+
branch="John-abc123"
|
|
179
|
+
)
|
|
180
|
+
# Returns: Path("~/.claude-team/worktrees/a1b2c3d4/John-abc123-1703001234")
|
|
181
|
+
"""
|
|
182
|
+
repo_path = Path(repo_path).resolve()
|
|
183
|
+
|
|
184
|
+
# Generate worktree path outside the repo
|
|
185
|
+
if timestamp is None:
|
|
186
|
+
timestamp = int(time.time())
|
|
187
|
+
worktree_dir_name = f"{worktree_name}-{timestamp}"
|
|
188
|
+
base_dir = get_worktree_base_for_repo(repo_path)
|
|
189
|
+
worktree_path = base_dir / worktree_dir_name
|
|
190
|
+
|
|
191
|
+
# Ensure base directory exists
|
|
192
|
+
base_dir.mkdir(parents=True, exist_ok=True)
|
|
193
|
+
|
|
194
|
+
# Check if worktree already exists
|
|
195
|
+
if worktree_path.exists():
|
|
196
|
+
raise WorktreeError(f"Worktree already exists at {worktree_path}")
|
|
197
|
+
|
|
198
|
+
# Build the git worktree add command
|
|
199
|
+
cmd = ["git", "-C", str(repo_path), "worktree", "add"]
|
|
200
|
+
|
|
201
|
+
if branch:
|
|
202
|
+
# Check if branch exists
|
|
203
|
+
branch_check = subprocess.run(
|
|
204
|
+
["git", "-C", str(repo_path), "rev-parse", "--verify", f"refs/heads/{branch}"],
|
|
205
|
+
capture_output=True,
|
|
206
|
+
text=True,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
if branch_check.returncode == 0:
|
|
210
|
+
# Branch exists, check it out
|
|
211
|
+
cmd.extend([str(worktree_path), branch])
|
|
212
|
+
else:
|
|
213
|
+
# Branch doesn't exist, create it with -b
|
|
214
|
+
cmd.extend(["-b", branch, str(worktree_path)])
|
|
215
|
+
else:
|
|
216
|
+
# No branch specified, create detached HEAD
|
|
217
|
+
cmd.extend(["--detach", str(worktree_path)])
|
|
218
|
+
|
|
219
|
+
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
220
|
+
|
|
221
|
+
if result.returncode != 0:
|
|
222
|
+
raise WorktreeError(f"Failed to create worktree: {result.stderr.strip()}")
|
|
223
|
+
|
|
224
|
+
return worktree_path
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def create_local_worktree(
|
|
228
|
+
repo_path: Path,
|
|
229
|
+
worker_name: str,
|
|
230
|
+
bead_id: Optional[str] = None,
|
|
231
|
+
annotation: Optional[str] = None,
|
|
232
|
+
) -> Path:
|
|
233
|
+
"""
|
|
234
|
+
Create a git worktree in the repo's .worktrees/ directory.
|
|
235
|
+
|
|
236
|
+
Creates a new worktree at:
|
|
237
|
+
{repo}/.worktrees/{bead_id}-{annotation}/ (if bead_id provided)
|
|
238
|
+
{repo}/.worktrees/{worker_name}-{uuid}-{annotation}/ (otherwise)
|
|
239
|
+
|
|
240
|
+
The branch name matches the worktree directory name for consistency.
|
|
241
|
+
Automatically adds .worktrees to .gitignore if not present.
|
|
242
|
+
|
|
243
|
+
Args:
|
|
244
|
+
repo_path: Path to the main repository
|
|
245
|
+
worker_name: Name of the worker (used in fallback naming)
|
|
246
|
+
bead_id: Optional bead issue ID (e.g., "cic-abc123")
|
|
247
|
+
annotation: Optional annotation for the worktree
|
|
248
|
+
|
|
249
|
+
Returns:
|
|
250
|
+
Path to the created worktree
|
|
251
|
+
|
|
252
|
+
Raises:
|
|
253
|
+
WorktreeError: If the git worktree command fails
|
|
254
|
+
|
|
255
|
+
Example:
|
|
256
|
+
# With bead ID
|
|
257
|
+
path = create_local_worktree(
|
|
258
|
+
repo_path=Path("/path/to/repo"),
|
|
259
|
+
worker_name="Groucho",
|
|
260
|
+
bead_id="cic-abc",
|
|
261
|
+
annotation="Add local worktrees"
|
|
262
|
+
)
|
|
263
|
+
# Returns: Path("/path/to/repo/.worktrees/cic-abc-add-local-worktrees")
|
|
264
|
+
|
|
265
|
+
# Without bead ID
|
|
266
|
+
path = create_local_worktree(
|
|
267
|
+
repo_path=Path("/path/to/repo"),
|
|
268
|
+
worker_name="Groucho",
|
|
269
|
+
annotation="Fix bug"
|
|
270
|
+
)
|
|
271
|
+
# Returns: Path("/path/to/repo/.worktrees/groucho-a1b2c3d4-fix-bug")
|
|
272
|
+
"""
|
|
273
|
+
repo_path = Path(repo_path).resolve()
|
|
274
|
+
|
|
275
|
+
# Build the worktree directory name
|
|
276
|
+
if bead_id:
|
|
277
|
+
# Bead-based naming: {bead_id}-{annotation}
|
|
278
|
+
if annotation:
|
|
279
|
+
dir_name = f"{bead_id}-{slugify(annotation)}"
|
|
280
|
+
else:
|
|
281
|
+
dir_name = bead_id
|
|
282
|
+
else:
|
|
283
|
+
# Fallback naming: {worker_name}-{uuid}-{annotation}
|
|
284
|
+
short_uuid = uuid.uuid4().hex[:8]
|
|
285
|
+
name_slug = slugify(worker_name)
|
|
286
|
+
if annotation:
|
|
287
|
+
dir_name = f"{name_slug}-{short_uuid}-{slugify(annotation)}"
|
|
288
|
+
else:
|
|
289
|
+
dir_name = f"{name_slug}-{short_uuid}"
|
|
290
|
+
|
|
291
|
+
# Worktree path inside the repo
|
|
292
|
+
worktrees_dir = repo_path / LOCAL_WORKTREE_DIR
|
|
293
|
+
worktree_path = worktrees_dir / dir_name
|
|
294
|
+
|
|
295
|
+
# Ensure .worktrees is in .gitignore
|
|
296
|
+
ensure_gitignore_entry(repo_path, LOCAL_WORKTREE_DIR)
|
|
297
|
+
|
|
298
|
+
# Ensure .worktrees directory exists
|
|
299
|
+
worktrees_dir.mkdir(parents=True, exist_ok=True)
|
|
300
|
+
|
|
301
|
+
# Check if worktree already exists
|
|
302
|
+
if worktree_path.exists():
|
|
303
|
+
raise WorktreeError(f"Worktree already exists at {worktree_path}")
|
|
304
|
+
|
|
305
|
+
# Branch name matches directory name for clarity
|
|
306
|
+
branch_name = dir_name
|
|
307
|
+
|
|
308
|
+
# Build the git worktree add command
|
|
309
|
+
cmd = ["git", "-C", str(repo_path), "worktree", "add"]
|
|
310
|
+
|
|
311
|
+
# Check if branch exists
|
|
312
|
+
branch_check = subprocess.run(
|
|
313
|
+
["git", "-C", str(repo_path), "rev-parse", "--verify", f"refs/heads/{branch_name}"],
|
|
314
|
+
capture_output=True,
|
|
315
|
+
text=True,
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
if branch_check.returncode == 0:
|
|
319
|
+
# Branch exists, check it out
|
|
320
|
+
cmd.extend([str(worktree_path), branch_name])
|
|
321
|
+
else:
|
|
322
|
+
# Branch doesn't exist, create it with -b
|
|
323
|
+
cmd.extend(["-b", branch_name, str(worktree_path)])
|
|
324
|
+
|
|
325
|
+
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
326
|
+
|
|
327
|
+
if result.returncode != 0:
|
|
328
|
+
raise WorktreeError(f"Failed to create local worktree: {result.stderr.strip()}")
|
|
329
|
+
|
|
330
|
+
return worktree_path
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def remove_worktree(
|
|
334
|
+
repo_path: Path,
|
|
335
|
+
worktree_path: Path,
|
|
336
|
+
force: bool = True,
|
|
337
|
+
) -> bool:
|
|
338
|
+
"""
|
|
339
|
+
Remove a worktree directory (does NOT delete the branch).
|
|
340
|
+
|
|
341
|
+
The branch is intentionally kept alive so that commits can be
|
|
342
|
+
cherry-picked before manual cleanup.
|
|
343
|
+
|
|
344
|
+
Args:
|
|
345
|
+
repo_path: Path to the main repository
|
|
346
|
+
worktree_path: Full path to the worktree to remove
|
|
347
|
+
force: If True, force removal even with uncommitted changes
|
|
348
|
+
|
|
349
|
+
Returns:
|
|
350
|
+
True if worktree was successfully removed
|
|
351
|
+
|
|
352
|
+
Raises:
|
|
353
|
+
WorktreeError: If the git worktree remove command fails
|
|
354
|
+
|
|
355
|
+
Example:
|
|
356
|
+
success = remove_worktree(
|
|
357
|
+
repo_path=Path("/path/to/repo"),
|
|
358
|
+
worktree_path=Path("~/.claude-team/worktrees/a1b2c3d4/John-abc123-1703001234")
|
|
359
|
+
)
|
|
360
|
+
"""
|
|
361
|
+
repo_path = Path(repo_path).resolve()
|
|
362
|
+
worktree_path = Path(worktree_path).resolve()
|
|
363
|
+
|
|
364
|
+
cmd = ["git", "-C", str(repo_path), "worktree", "remove"]
|
|
365
|
+
|
|
366
|
+
if force:
|
|
367
|
+
cmd.append("--force")
|
|
368
|
+
|
|
369
|
+
cmd.append(str(worktree_path))
|
|
370
|
+
|
|
371
|
+
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
372
|
+
|
|
373
|
+
if result.returncode != 0:
|
|
374
|
+
# Check if worktree doesn't exist (not an error)
|
|
375
|
+
if "is not a working tree" in result.stderr or "No such file" in result.stderr:
|
|
376
|
+
return True
|
|
377
|
+
raise WorktreeError(f"Failed to remove worktree: {result.stderr.strip()}")
|
|
378
|
+
|
|
379
|
+
# Also run prune to clean up stale worktree references
|
|
380
|
+
subprocess.run(
|
|
381
|
+
["git", "-C", str(repo_path), "worktree", "prune"],
|
|
382
|
+
capture_output=True,
|
|
383
|
+
text=True,
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
return True
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def list_git_worktrees(repo_path: Path) -> list[dict]:
|
|
390
|
+
"""
|
|
391
|
+
List all worktrees registered with git for a repository.
|
|
392
|
+
|
|
393
|
+
Parses the porcelain output of git worktree list to provide
|
|
394
|
+
structured information about each worktree.
|
|
395
|
+
|
|
396
|
+
Args:
|
|
397
|
+
repo_path: Path to the repository
|
|
398
|
+
|
|
399
|
+
Returns:
|
|
400
|
+
List of dicts, each containing:
|
|
401
|
+
- path: Path to the worktree
|
|
402
|
+
- branch: Branch name (or None if detached HEAD)
|
|
403
|
+
- commit: Current HEAD commit hash
|
|
404
|
+
- bare: True if this is the bare repository entry
|
|
405
|
+
- detached: True if HEAD is detached
|
|
406
|
+
|
|
407
|
+
Raises:
|
|
408
|
+
WorktreeError: If the git worktree list command fails
|
|
409
|
+
|
|
410
|
+
Example:
|
|
411
|
+
worktrees = list_git_worktrees(Path("/path/to/repo"))
|
|
412
|
+
for wt in worktrees:
|
|
413
|
+
print(f"{wt['path']}: {wt['branch'] or 'detached'}")
|
|
414
|
+
"""
|
|
415
|
+
repo_path = Path(repo_path).resolve()
|
|
416
|
+
|
|
417
|
+
result = subprocess.run(
|
|
418
|
+
["git", "-C", str(repo_path), "worktree", "list", "--porcelain"],
|
|
419
|
+
capture_output=True,
|
|
420
|
+
text=True,
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
if result.returncode != 0:
|
|
424
|
+
raise WorktreeError(f"Failed to list worktrees: {result.stderr.strip()}")
|
|
425
|
+
|
|
426
|
+
worktrees = []
|
|
427
|
+
current_worktree: dict = {}
|
|
428
|
+
|
|
429
|
+
for line in result.stdout.strip().split("\n"):
|
|
430
|
+
if not line:
|
|
431
|
+
# Empty line separates worktree entries
|
|
432
|
+
if current_worktree:
|
|
433
|
+
worktrees.append(current_worktree)
|
|
434
|
+
current_worktree = {}
|
|
435
|
+
continue
|
|
436
|
+
|
|
437
|
+
if line.startswith("worktree "):
|
|
438
|
+
current_worktree["path"] = Path(line[9:])
|
|
439
|
+
current_worktree["branch"] = None
|
|
440
|
+
current_worktree["commit"] = None
|
|
441
|
+
current_worktree["bare"] = False
|
|
442
|
+
current_worktree["detached"] = False
|
|
443
|
+
elif line.startswith("HEAD "):
|
|
444
|
+
current_worktree["commit"] = line[5:]
|
|
445
|
+
elif line.startswith("branch "):
|
|
446
|
+
# Branch is in format "refs/heads/branch-name"
|
|
447
|
+
branch_ref = line[7:]
|
|
448
|
+
if branch_ref.startswith("refs/heads/"):
|
|
449
|
+
current_worktree["branch"] = branch_ref[11:]
|
|
450
|
+
else:
|
|
451
|
+
current_worktree["branch"] = branch_ref
|
|
452
|
+
elif line == "bare":
|
|
453
|
+
current_worktree["bare"] = True
|
|
454
|
+
elif line == "detached":
|
|
455
|
+
current_worktree["detached"] = True
|
|
456
|
+
|
|
457
|
+
# Don't forget the last entry
|
|
458
|
+
if current_worktree:
|
|
459
|
+
worktrees.append(current_worktree)
|
|
460
|
+
|
|
461
|
+
return worktrees
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def list_local_worktrees(repo_path: Path) -> list[dict]:
|
|
465
|
+
"""
|
|
466
|
+
List all local worktrees in a repository's .worktrees/ directory.
|
|
467
|
+
|
|
468
|
+
Finds worktrees in {repo}/.worktrees/ and cross-references them
|
|
469
|
+
with git's worktree list to determine registration status.
|
|
470
|
+
|
|
471
|
+
Args:
|
|
472
|
+
repo_path: Path to the repository
|
|
473
|
+
|
|
474
|
+
Returns:
|
|
475
|
+
List of dicts, each containing:
|
|
476
|
+
- path: Path to the worktree directory
|
|
477
|
+
- name: Worktree directory name (e.g., "cic-abc-fix-bug")
|
|
478
|
+
- branch: Branch name (if found in git worktree list)
|
|
479
|
+
- commit: Current HEAD commit hash (if found)
|
|
480
|
+
- registered: True if git knows about this worktree
|
|
481
|
+
- exists: True if the directory exists on disk
|
|
482
|
+
|
|
483
|
+
Example:
|
|
484
|
+
worktrees = list_local_worktrees(Path("/path/to/repo"))
|
|
485
|
+
for wt in worktrees:
|
|
486
|
+
status = "active" if wt["registered"] else "orphaned"
|
|
487
|
+
print(f"{wt['name']}: {status}")
|
|
488
|
+
"""
|
|
489
|
+
repo_path = Path(repo_path).resolve()
|
|
490
|
+
local_worktrees_dir = repo_path / LOCAL_WORKTREE_DIR
|
|
491
|
+
|
|
492
|
+
# Get git's view of worktrees
|
|
493
|
+
try:
|
|
494
|
+
git_worktrees = list_git_worktrees(repo_path)
|
|
495
|
+
except WorktreeError:
|
|
496
|
+
git_worktrees = []
|
|
497
|
+
|
|
498
|
+
git_worktree_paths = {str(wt["path"]) for wt in git_worktrees}
|
|
499
|
+
|
|
500
|
+
worktrees = []
|
|
501
|
+
|
|
502
|
+
# Check if .worktrees directory exists
|
|
503
|
+
if not local_worktrees_dir.exists():
|
|
504
|
+
return worktrees
|
|
505
|
+
|
|
506
|
+
# Scan the directory for worktree folders
|
|
507
|
+
for item in local_worktrees_dir.iterdir():
|
|
508
|
+
if not item.is_dir():
|
|
509
|
+
continue
|
|
510
|
+
|
|
511
|
+
wt_path_str = str(item.resolve())
|
|
512
|
+
registered = wt_path_str in git_worktree_paths
|
|
513
|
+
|
|
514
|
+
# Find matching git worktree info if registered
|
|
515
|
+
git_info = None
|
|
516
|
+
for gwt in git_worktrees:
|
|
517
|
+
if str(gwt["path"]) == wt_path_str:
|
|
518
|
+
git_info = gwt
|
|
519
|
+
break
|
|
520
|
+
|
|
521
|
+
worktrees.append({
|
|
522
|
+
"path": item,
|
|
523
|
+
"name": item.name,
|
|
524
|
+
"branch": git_info["branch"] if git_info else None,
|
|
525
|
+
"commit": git_info["commit"] if git_info else None,
|
|
526
|
+
"registered": registered,
|
|
527
|
+
"exists": True,
|
|
528
|
+
})
|
|
529
|
+
|
|
530
|
+
return worktrees
|
|
531
|
+
|
|
532
|
+
|