ramets 0.3.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.
ramets/__init__.py ADDED
@@ -0,0 +1,13 @@
1
+ """ramets — Per-branch AI coding context.
2
+
3
+ Your codebase is one organism. Each branch is a ramet.
4
+
5
+ Named after Pando (Populus tremuloides), the world's largest organism —
6
+ a single aspen clone where individual trunks (ramets) share one root system (genet).
7
+
8
+ - Genet: the shared base knowledge graph (common ancestor of all branches)
9
+ - Ramets: per-branch delta-encoded graphs (individual trunks from the shared root)
10
+ - Decisions: structured tracking of AI suggestions and user choices
11
+ """
12
+
13
+ __version__ = "0.3.0"
ramets/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Allow running as `python -m ramets`."""
2
+
3
+ from ramets.cli import main
4
+
5
+ main()
ramets/cli.py ADDED
@@ -0,0 +1,501 @@
1
+ """CLI entry point — `ramets` command.
2
+
3
+ Your codebase is one organism. Each branch is a ramet.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ import click
12
+
13
+ from ramets.core.branch import (
14
+ current_branch,
15
+ get_db_path,
16
+ get_main_repo_root,
17
+ get_ramets_dir,
18
+ )
19
+ from ramets.core.db import GenetDB
20
+
21
+
22
+ def _get_db() -> GenetDB:
23
+ """Get a database connection, or exit with helpful message."""
24
+ db_path = get_db_path()
25
+ if not db_path.exists():
26
+ click.echo("No ramets database found. Run `ramets init` first.", err=True)
27
+ sys.exit(1)
28
+ return GenetDB(db_path)
29
+
30
+
31
+ @click.group()
32
+ @click.version_option()
33
+ def main():
34
+ """ramets — Per-branch AI coding context.
35
+
36
+ Your codebase is one organism. Each branch is a ramet.
37
+
38
+ Named after Pando (Populus tremuloides), the world's largest organism —
39
+ individual trunks (ramets) sharing one root system (genet).
40
+ """
41
+ pass
42
+
43
+
44
+ @main.command()
45
+ @click.option("--no-hooks", is_flag=True, help="Skip git/Claude hook installation")
46
+ @click.option("--no-claude-hooks", is_flag=True, help="Skip Claude Code hook installation")
47
+ def init(no_hooks: bool, no_claude_hooks: bool):
48
+ """Initialize ramets in the current repository.
49
+
50
+ Creates .ramets/, builds the genet (base graph), and installs hooks.
51
+ """
52
+ try:
53
+ repo_root = get_main_repo_root()
54
+ except RuntimeError:
55
+ click.echo("Error: Not inside a git repository.", err=True)
56
+ sys.exit(1)
57
+
58
+ ramets_dir = get_ramets_dir()
59
+ ramets_dir.mkdir(parents=True, exist_ok=True)
60
+
61
+ # Initialize database
62
+ db_path = ramets_dir / "store.db"
63
+ db = GenetDB(db_path)
64
+ db.init_schema()
65
+
66
+ click.echo(f"Initialized .ramets/ at {ramets_dir}")
67
+
68
+ # Build genet
69
+ click.echo("Building genet (base graph)...")
70
+ from ramets.core.base import build_genet
71
+
72
+ graph_data = build_genet(db, repo_root)
73
+ node_count = len(graph_data.get("nodes", []))
74
+ edge_count = len(graph_data.get("links", []))
75
+ click.echo(f"Genet built: {node_count} nodes, {edge_count} edges")
76
+
77
+ # Register current branch
78
+ branch = current_branch(cwd=repo_root)
79
+ db.register_branch(branch)
80
+ click.echo(f"Registered branch: {branch}")
81
+
82
+ # Install git hooks
83
+ if not no_hooks:
84
+ from ramets.hooks.git import install_git_hooks
85
+
86
+ installed = install_git_hooks(repo_root)
87
+ click.echo(f"Installed git hooks: {', '.join(installed)}")
88
+
89
+ # Install Claude Code hooks
90
+ if not no_hooks and not no_claude_hooks:
91
+ from ramets.hooks.claude import install_claude_hooks
92
+
93
+ install_claude_hooks(repo_root)
94
+ click.echo("Installed Claude Code hooks in .claude/settings.json")
95
+
96
+ # Add .ramets/ to .gitignore
97
+ gitignore = repo_root / ".gitignore"
98
+ if gitignore.exists():
99
+ content = gitignore.read_text()
100
+ if ".ramets/" not in content:
101
+ with open(gitignore, "a") as f:
102
+ f.write("\n# ramets knowledge graph (local)\n.ramets/\n")
103
+ click.echo("Added .ramets/ to .gitignore")
104
+ else:
105
+ gitignore.write_text("# ramets knowledge graph (local)\n.ramets/\n")
106
+ click.echo("Created .gitignore with .ramets/")
107
+
108
+ db.close()
109
+
110
+ click.echo('\nReady! Try: ramets query "how does the main module work?"')
111
+ click.echo(f"Storage: {db_path.stat().st_size / 1024:.0f} KB")
112
+
113
+
114
+ @main.command()
115
+ def build():
116
+ """Full genet rebuild from current HEAD."""
117
+ db = _get_db()
118
+ repo_root = get_main_repo_root()
119
+
120
+ click.echo("Rebuilding genet...")
121
+ from ramets.core.base import build_genet
122
+
123
+ graph_data = build_genet(db, repo_root)
124
+ node_count = len(graph_data.get("nodes", []))
125
+ edge_count = len(graph_data.get("links", []))
126
+ click.echo(f"Genet rebuilt: {node_count} nodes, {edge_count} edges")
127
+
128
+ db.close()
129
+
130
+
131
+ @main.command()
132
+ def update():
133
+ """Incremental delta update for current branch.
134
+
135
+ Called by post-commit hook. Computes what changed vs genet.
136
+ """
137
+ db = _get_db()
138
+ repo_root = get_main_repo_root()
139
+ branch = current_branch(cwd=repo_root)
140
+
141
+ from ramets.core.base import load_genet_or_build
142
+ from ramets.core.delta import compute_deltas
143
+ from ramets.extract.ast_bridge import extract_graph
144
+
145
+ genet = load_genet_or_build(db, repo_root)
146
+ branch_graph = extract_graph(repo_root)
147
+ deltas = compute_deltas(genet, branch_graph)
148
+
149
+ from ramets.core.branch import current_commit
150
+
151
+ commit = current_commit(cwd=repo_root)
152
+
153
+ # Replace deltas (compute_deltas returns full diff vs genet, not incremental)
154
+ db.conn.execute("DELETE FROM ramet_deltas WHERE branch = ?", (branch,))
155
+ db.conn.execute("DELETE FROM ramets WHERE branch = ?", (branch,))
156
+ db.conn.commit()
157
+
158
+ if deltas:
159
+ db.append_deltas(branch, commit, deltas)
160
+ click.echo(f"{len(deltas)} deltas for {branch}")
161
+ else:
162
+ click.echo(f"No changes for {branch}")
163
+
164
+ db.close()
165
+
166
+
167
+ @main.command()
168
+ def status():
169
+ """Show genet freshness, ramet count, delta sizes, storage."""
170
+ db = _get_db()
171
+
172
+ genet_meta = db.genet_meta()
173
+ if genet_meta:
174
+ click.echo(
175
+ f"Genet: {genet_meta['node_count']} nodes, "
176
+ f"{genet_meta['edge_count']} edges "
177
+ f"(commit {genet_meta['commit_sha']}, built {genet_meta['built_at']})"
178
+ )
179
+ else:
180
+ click.echo("Genet: not built")
181
+
182
+ branches = db.list_branches()
183
+ click.echo(f"\nBranches: {len(branches)}")
184
+ for b in branches:
185
+ delta_count = db.delta_count(b["name"])
186
+ ramet = db.ramet_meta(b["name"])
187
+ cached = f" [cached: {ramet['node_count']}n/{ramet['edge_count']}e]" if ramet else ""
188
+ click.echo(f" {b['name']}: {delta_count} deltas{cached}")
189
+
190
+ ramets_dir = get_ramets_dir()
191
+ db_size = (ramets_dir / "store.db").stat().st_size
192
+ click.echo(f"\nStorage: {db_size / 1024:.0f} KB ({ramets_dir})")
193
+
194
+ db.close()
195
+
196
+
197
+ @main.command("query")
198
+ @click.argument("question")
199
+ @click.option("--branch", "-b", default=None, help="Branch to query (default: current)")
200
+ @click.option("--depth", "-d", default=3, help="BFS traversal depth")
201
+ @click.option("--budget", default=2000, help="Approximate token budget")
202
+ def query_cmd(question: str, branch: str | None, depth: int, budget: int):
203
+ """Query the knowledge graph."""
204
+ db = _get_db()
205
+
206
+ from ramets.core.materialize import query_graph
207
+
208
+ result = query_graph(db, question, branch=branch, depth=depth, token_budget=budget)
209
+ click.echo(result)
210
+ db.close()
211
+
212
+
213
+ @main.command("diff")
214
+ @click.argument("branch_a")
215
+ @click.argument("branch_b", default="main")
216
+ def diff_cmd(branch_a: str, branch_b: str):
217
+ """Show graph differences between two branches."""
218
+ db = _get_db()
219
+
220
+ from ramets.core.materialize import diff_branches
221
+
222
+ result = diff_branches(db, branch_a, branch_b)
223
+ click.echo(result)
224
+ db.close()
225
+
226
+
227
+ @main.command()
228
+ def summary():
229
+ """Print the session summary (~400 tokens)."""
230
+ db = _get_db()
231
+
232
+ from ramets.core.materialize import summary as gen_summary
233
+
234
+ result = gen_summary(db)
235
+ click.echo(result)
236
+ db.close()
237
+
238
+
239
+ @main.command()
240
+ @click.option("--open", "open_obsidian", is_flag=True, help="Open vault in Obsidian")
241
+ @click.option("--dir", "vault_dir", default=None, help="Custom output directory")
242
+ @click.option("--all-branches", is_flag=True, help="Generate all branch layers")
243
+ def obsidian(open_obsidian: bool, vault_dir: str | None, all_branches: bool):
244
+ """Generate Obsidian vault with branch layers."""
245
+ db = _get_db()
246
+
247
+ from ramets.obsidian.vault import generate_vault
248
+
249
+ output = Path(vault_dir) if vault_dir else get_ramets_dir() / "obsidian"
250
+
251
+ branches_to_render = None
252
+ if all_branches:
253
+ branches_to_render = [b["name"] for b in db.list_branches()]
254
+
255
+ generate_vault(db, output, branches=branches_to_render)
256
+ click.echo(f"Obsidian vault generated at {output}")
257
+
258
+ if open_obsidian:
259
+ import subprocess
260
+
261
+ subprocess.run(
262
+ ["open", f"obsidian://open?path={output}"],
263
+ check=False,
264
+ )
265
+ click.echo("Opening in Obsidian...")
266
+
267
+ db.close()
268
+
269
+
270
+ @main.command()
271
+ @click.option("--question", "-q", required=True, help="The decision question")
272
+ @click.option(
273
+ "--options",
274
+ "-o",
275
+ multiple=True,
276
+ required=True,
277
+ help="Options (repeat for each). Format: 'label' or 'label:description'",
278
+ )
279
+ @click.option("--chosen", "-c", type=int, required=True, help="Index of chosen option (0-based)")
280
+ @click.option(
281
+ "--category",
282
+ type=click.Choice(
283
+ [
284
+ "architecture",
285
+ "implementation",
286
+ "dependency",
287
+ "naming",
288
+ "algorithm",
289
+ "refactor",
290
+ "config",
291
+ "test_strategy",
292
+ "api_design",
293
+ "data_model",
294
+ "performance",
295
+ "security",
296
+ ]
297
+ ),
298
+ default="implementation",
299
+ help="Decision category",
300
+ )
301
+ @click.option("--rationale", "-r", default="", help="Why this option was chosen")
302
+ @click.option("--confidence", type=float, default=0.8, help="Confidence level (0.0-1.0)")
303
+ @click.option("--file", "context_files", multiple=True, help="Related files")
304
+ def decide(
305
+ question: str,
306
+ options: tuple[str, ...],
307
+ chosen: int,
308
+ category: str,
309
+ rationale: str,
310
+ confidence: float,
311
+ context_files: tuple[str, ...],
312
+ ):
313
+ """Record a design/implementation decision.
314
+
315
+ Examples:
316
+ ramets decide -q "Auth strategy?" -o "JWT" -o "Session cookies" -c 0 -r "Stateless"
317
+ ramets decide -q "ORM choice?" -o "SQLAlchemy:Full ORM" -o "Raw SQL" -c 1 --category dependency
318
+ """
319
+ db = _get_db()
320
+
321
+ from ramets.core.branch import current_commit
322
+ from ramets.decision.schema import Decision, Option
323
+ from ramets.decision.tracker import DecisionTracker
324
+
325
+ if chosen < 0 or chosen >= len(options):
326
+ click.echo(f"Error: --chosen must be 0-{len(options) - 1}", err=True)
327
+ sys.exit(1)
328
+
329
+ parsed_options = []
330
+ for opt_str in options:
331
+ if ":" in opt_str:
332
+ label, desc = opt_str.split(":", 1)
333
+ parsed_options.append(Option(label=label.strip(), description=desc.strip()))
334
+ else:
335
+ parsed_options.append(Option(label=opt_str.strip()))
336
+
337
+ branch = current_branch()
338
+ commit = current_commit()
339
+
340
+ decision = Decision(
341
+ branch=branch,
342
+ category=category,
343
+ question=question,
344
+ options=parsed_options,
345
+ chosen=chosen,
346
+ rationale=rationale,
347
+ confidence=confidence,
348
+ context_files=list(context_files),
349
+ commit_sha=commit,
350
+ )
351
+
352
+ tracker = DecisionTracker(db)
353
+ did = tracker.record(decision)
354
+ chosen_label = parsed_options[chosen].label
355
+
356
+ click.echo(f"Decision recorded: {did}")
357
+ click.echo(f" Q: {question}")
358
+ click.echo(f" Chose: {chosen_label}")
359
+ if rationale:
360
+ click.echo(f" Why: {rationale}")
361
+
362
+ db.close()
363
+
364
+
365
+ @main.command()
366
+ @click.option("--branch", "-b", default=None, help="Filter by branch")
367
+ @click.option("--category", default=None, help="Filter by category")
368
+ @click.option("--status", default="active", help="Filter by status")
369
+ @click.option("--search", "-s", default=None, help="Full-text search")
370
+ @click.option("--id", "decision_id", default=None, help="Show specific decision by ID")
371
+ def decisions(
372
+ branch: str | None,
373
+ category: str | None,
374
+ status: str,
375
+ search: str | None,
376
+ decision_id: str | None,
377
+ ):
378
+ """List or search decisions for the current branch.
379
+
380
+ Examples:
381
+ ramets decisions # All active decisions
382
+ ramets decisions -s "auth" # Search for auth-related
383
+ ramets decisions --id abc123 # Show specific decision
384
+ ramets decisions --category architecture
385
+ """
386
+ db = _get_db()
387
+
388
+ from ramets.decision.tracker import DecisionTracker
389
+
390
+ tracker = DecisionTracker(db)
391
+
392
+ if decision_id:
393
+ d = tracker.get(decision_id)
394
+ if d is None:
395
+ click.echo(f"Decision not found: {decision_id}", err=True)
396
+ sys.exit(1)
397
+ _print_decision_detail(d)
398
+ db.close()
399
+ return
400
+
401
+ if search:
402
+ results = tracker.search(search)
403
+ else:
404
+ if branch is None:
405
+ branch = current_branch()
406
+ results = tracker.query(branch=branch, category=category, status=status)
407
+
408
+ if not results:
409
+ click.echo("No decisions found.")
410
+ else:
411
+ click.echo(f"Decisions: {len(results)}")
412
+ for d in results:
413
+ chosen_label = d.options[d.chosen].label if d.options else "?"
414
+ status_icon = {"active": "●", "superseded": "○", "reversed": "✗", "deferred": "◌"}.get(
415
+ d.status.value, "?"
416
+ )
417
+ click.echo(f" {status_icon} [{d.id}] {d.question}")
418
+ click.echo(f" → {chosen_label} ({d.category.value}, {d.confidence or '?'})")
419
+
420
+ db.close()
421
+
422
+
423
+ def _print_decision_detail(d) -> None:
424
+ """Print full decision details."""
425
+ click.echo(f"Decision: {d.id}")
426
+ click.echo(f" Question: {d.question}")
427
+ click.echo(f" Category: {d.category.value}")
428
+ click.echo(f" Branch: {d.branch}")
429
+ click.echo(f" Status: {d.status.value}")
430
+ click.echo(f" Timestamp: {d.timestamp}")
431
+ click.echo(f" Confidence: {d.confidence}")
432
+ if d.commit_sha:
433
+ click.echo(f" Commit: {d.commit_sha}")
434
+ click.echo(" Options:")
435
+ for i, opt in enumerate(d.options):
436
+ marker = " ✓" if i == d.chosen else " "
437
+ desc = f" — {opt.description}" if opt.description else ""
438
+ click.echo(f" {marker} [{i}] {opt.label}{desc}")
439
+ if opt.pros:
440
+ for pro in opt.pros:
441
+ click.echo(f" + {pro}")
442
+ if opt.cons:
443
+ for con in opt.cons:
444
+ click.echo(f" - {con}")
445
+ if d.rationale:
446
+ click.echo(f" Rationale: {d.rationale}")
447
+ if d.context_files:
448
+ click.echo(f" Files: {', '.join(d.context_files)}")
449
+
450
+
451
+ @main.command()
452
+ def gc():
453
+ """Prune dead ramets, compact the genet database."""
454
+ db = _get_db()
455
+
456
+ from ramets.core.branch import list_worktrees
457
+
458
+ # Get active branches from worktrees + current
459
+ active = {current_branch()}
460
+ for wt in list_worktrees():
461
+ if "branch" in wt:
462
+ active.add(wt["branch"])
463
+
464
+ # Find stale branches
465
+ all_branches = db.list_branches()
466
+ stale = [b for b in all_branches if b["name"] not in active]
467
+
468
+ if stale:
469
+ for b in stale:
470
+ db.delete_ramet(b["name"])
471
+ db.conn.execute("DELETE FROM branches WHERE name = ?", (b["name"],))
472
+ click.echo(f" Pruned: {b['name']}")
473
+ db.conn.commit()
474
+ click.echo(f"Pruned {len(stale)} stale ramets")
475
+ else:
476
+ click.echo("No stale ramets to prune")
477
+
478
+ # VACUUM to reclaim space
479
+ db.conn.execute("VACUUM")
480
+ click.echo("Database compacted")
481
+
482
+ db.close()
483
+
484
+
485
+ @main.command()
486
+ def serve():
487
+ """Start MCP server (stdio for Claude Code)."""
488
+ try:
489
+ from ramets.mcp.server import run_server
490
+
491
+ run_server()
492
+ except ImportError:
493
+ click.echo(
494
+ "MCP dependencies not installed. Run: pip install ramets[mcp]",
495
+ err=True,
496
+ )
497
+ sys.exit(1)
498
+
499
+
500
+ if __name__ == "__main__":
501
+ main()
@@ -0,0 +1 @@
1
+ """Core storage and graph operations."""
ramets/core/base.py ADDED
@@ -0,0 +1,45 @@
1
+ """Genet builder — constructs the base (shared root) graph.
2
+
3
+ Wraps graphify's AST extraction to build the initial knowledge graph,
4
+ then stores it as a compressed blob in the genet table.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+
11
+ from ramets.core.branch import current_commit, get_repo_root
12
+ from ramets.core.db import GenetDB
13
+ from ramets.extract.ast_bridge import extract_graph
14
+
15
+
16
+ def build_genet(db: GenetDB, repo_path: Path | None = None) -> dict:
17
+ """Build the base graph from the current HEAD and store it.
18
+
19
+ Args:
20
+ db: The genet database connection.
21
+ repo_path: Repository root path. Defaults to current repo root.
22
+
23
+ Returns:
24
+ The graph data dict {nodes: [...], links: [...]}.
25
+ """
26
+ if repo_path is None:
27
+ repo_path = get_repo_root()
28
+
29
+ commit_sha = current_commit(cwd=repo_path)
30
+
31
+ # Extract AST graph via graphify
32
+ graph_data = extract_graph(repo_path)
33
+
34
+ # Store as genet
35
+ db.store_genet(graph_data, commit_sha)
36
+
37
+ return graph_data
38
+
39
+
40
+ def load_genet_or_build(db: GenetDB, repo_path: Path | None = None) -> dict:
41
+ """Load genet from DB, or build if it doesn't exist."""
42
+ genet = db.load_genet()
43
+ if genet is not None:
44
+ return genet
45
+ return build_genet(db, repo_path)
ramets/core/branch.py ADDED
@@ -0,0 +1,116 @@
1
+ """Branch and worktree detection.
2
+
3
+ Resolves the current branch, the main repo root (even from worktrees),
4
+ and the .ramets/ data directory.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import subprocess
10
+ from pathlib import Path
11
+
12
+
13
+ def git_run(*args: str, cwd: Path | None = None) -> str:
14
+ """Run a git command and return stdout, stripped."""
15
+ result = subprocess.run(
16
+ ["git", *args],
17
+ capture_output=True,
18
+ text=True,
19
+ cwd=str(cwd) if cwd else None,
20
+ )
21
+ return result.stdout.strip()
22
+
23
+
24
+ def get_repo_root(cwd: Path | None = None) -> Path:
25
+ """Get the working tree root (could be a worktree)."""
26
+ root = git_run("rev-parse", "--show-toplevel", cwd=cwd)
27
+ if not root:
28
+ raise RuntimeError("Not inside a git repository")
29
+ return Path(root)
30
+
31
+
32
+ def get_main_repo_root(cwd: Path | None = None) -> Path:
33
+ """Get the main repo root, resolving through worktrees.
34
+
35
+ In a worktree, .git is a file pointing to the main repo's .git/worktrees/<name>.
36
+ We resolve back to the main repo root.
37
+ """
38
+ root = get_repo_root(cwd)
39
+ git_common = git_run("rev-parse", "--git-common-dir", cwd=root)
40
+ if git_common and not git_common.startswith("--"):
41
+ common_path = Path(git_common)
42
+ if not common_path.is_absolute():
43
+ common_path = (root / common_path).resolve()
44
+ # .git/worktrees/xxx → .git → repo root
45
+ if common_path.name == ".git":
46
+ return common_path.parent
47
+ # Already at .git
48
+ return common_path.parent
49
+ return root
50
+
51
+
52
+ def get_ramets_dir(cwd: Path | None = None) -> Path:
53
+ """Get the .ramets/ directory path (at main repo root)."""
54
+ return get_main_repo_root(cwd) / ".ramets"
55
+
56
+
57
+ def get_db_path(cwd: Path | None = None) -> Path:
58
+ """Get the path to store.db."""
59
+ return get_ramets_dir(cwd) / "store.db"
60
+
61
+
62
+ def current_branch(cwd: Path | None = None) -> str:
63
+ """Get the current branch name."""
64
+ branch = git_run("rev-parse", "--abbrev-ref", "HEAD", cwd=cwd)
65
+ return branch or "HEAD"
66
+
67
+
68
+ def current_commit(cwd: Path | None = None) -> str:
69
+ """Get the current commit SHA (short)."""
70
+ return git_run("rev-parse", "--short", "HEAD", cwd=cwd)
71
+
72
+
73
+ def merge_base(
74
+ branch_a: str = "HEAD", branch_b: str = "main", cwd: Path | None = None
75
+ ) -> str | None:
76
+ """Find the merge-base commit between two branches."""
77
+ result = git_run("merge-base", branch_a, branch_b, cwd=cwd)
78
+ return result or None
79
+
80
+
81
+ def changed_files(since: str = "HEAD~1", until: str = "HEAD", cwd: Path | None = None) -> list[str]:
82
+ """Get files changed between two commits."""
83
+ output = git_run("diff", "--name-only", since, until, cwd=cwd)
84
+ if not output:
85
+ return []
86
+ return [f.strip() for f in output.splitlines() if f.strip()]
87
+
88
+
89
+ def is_worktree(cwd: Path | None = None) -> bool:
90
+ """Check if the current directory is a git worktree (not the main repo)."""
91
+ root = get_repo_root(cwd)
92
+ main_root = get_main_repo_root(cwd)
93
+ return root != main_root
94
+
95
+
96
+ def list_worktrees(cwd: Path | None = None) -> list[dict]:
97
+ """List all worktrees for this repo."""
98
+ output = git_run("worktree", "list", "--porcelain", cwd=cwd)
99
+ if not output:
100
+ return []
101
+ worktrees = []
102
+ current: dict = {}
103
+ for line in output.splitlines():
104
+ if line.startswith("worktree "):
105
+ if current:
106
+ worktrees.append(current)
107
+ current = {"path": line.split(" ", 1)[1]}
108
+ elif line.startswith("HEAD "):
109
+ current["head"] = line.split(" ", 1)[1]
110
+ elif line.startswith("branch "):
111
+ current["branch"] = line.split(" ", 1)[1].replace("refs/heads/", "")
112
+ elif line == "bare":
113
+ current["bare"] = True
114
+ if current:
115
+ worktrees.append(current)
116
+ return worktrees