canopy-cli 3.1.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.
Files changed (71) hide show
  1. canopy/__init__.py +2 -0
  2. canopy/actions/__init__.py +32 -0
  3. canopy/actions/aliases.py +421 -0
  4. canopy/actions/augments.py +55 -0
  5. canopy/actions/bootstrap.py +249 -0
  6. canopy/actions/bot_resolutions.py +123 -0
  7. canopy/actions/bot_status.py +133 -0
  8. canopy/actions/commit.py +511 -0
  9. canopy/actions/conflicts.py +314 -0
  10. canopy/actions/doctor.py +1459 -0
  11. canopy/actions/draft_replies.py +185 -0
  12. canopy/actions/drift.py +241 -0
  13. canopy/actions/errors.py +115 -0
  14. canopy/actions/evacuate.py +192 -0
  15. canopy/actions/feature_state.py +607 -0
  16. canopy/actions/historian.py +612 -0
  17. canopy/actions/ide_workspace.py +49 -0
  18. canopy/actions/last_visit.py +83 -0
  19. canopy/actions/migrate_slots.py +313 -0
  20. canopy/actions/preflight_state.py +97 -0
  21. canopy/actions/push.py +199 -0
  22. canopy/actions/reads.py +304 -0
  23. canopy/actions/resume.py +582 -0
  24. canopy/actions/review_filter.py +135 -0
  25. canopy/actions/ship.py +399 -0
  26. canopy/actions/slot_details.py +208 -0
  27. canopy/actions/slot_load.py +383 -0
  28. canopy/actions/slots.py +221 -0
  29. canopy/actions/stash.py +230 -0
  30. canopy/actions/switch.py +775 -0
  31. canopy/actions/switch_preflight.py +192 -0
  32. canopy/actions/thread_actions.py +88 -0
  33. canopy/actions/thread_resolutions.py +101 -0
  34. canopy/actions/triage.py +286 -0
  35. canopy/agent/__init__.py +5 -0
  36. canopy/agent/runner.py +129 -0
  37. canopy/agent_setup/__init__.py +264 -0
  38. canopy/agent_setup/skills/augment-canopy/SKILL.md +116 -0
  39. canopy/agent_setup/skills/using-canopy/SKILL.md +191 -0
  40. canopy/cli/__init__.py +0 -0
  41. canopy/cli/main.py +4152 -0
  42. canopy/cli/render.py +98 -0
  43. canopy/cli/ui.py +150 -0
  44. canopy/features/__init__.py +2 -0
  45. canopy/features/coordinator.py +1256 -0
  46. canopy/git/__init__.py +0 -0
  47. canopy/git/hooks.py +173 -0
  48. canopy/git/multi.py +435 -0
  49. canopy/git/repo.py +859 -0
  50. canopy/git/templates/post-checkout.py +67 -0
  51. canopy/graph/__init__.py +0 -0
  52. canopy/integrations/__init__.py +0 -0
  53. canopy/integrations/github.py +983 -0
  54. canopy/integrations/linear.py +307 -0
  55. canopy/integrations/precommit.py +239 -0
  56. canopy/mcp/__init__.py +0 -0
  57. canopy/mcp/client.py +329 -0
  58. canopy/mcp/server.py +1797 -0
  59. canopy/providers/__init__.py +105 -0
  60. canopy/providers/github_issues.py +289 -0
  61. canopy/providers/linear.py +341 -0
  62. canopy/providers/types.py +149 -0
  63. canopy/workspace/__init__.py +4 -0
  64. canopy/workspace/config.py +378 -0
  65. canopy/workspace/context.py +224 -0
  66. canopy/workspace/discovery.py +197 -0
  67. canopy/workspace/workspace.py +173 -0
  68. canopy_cli-3.1.0.dist-info/METADATA +282 -0
  69. canopy_cli-3.1.0.dist-info/RECORD +71 -0
  70. canopy_cli-3.1.0.dist-info/WHEEL +4 -0
  71. canopy_cli-3.1.0.dist-info/entry_points.txt +3 -0
canopy/git/repo.py ADDED
@@ -0,0 +1,859 @@
1
+ """
2
+ Single-repo Git operations.
3
+
4
+ Every Git interaction goes through this module — nothing else shells out
5
+ to git directly. This is the only module that calls subprocess.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import subprocess
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+
14
+ class GitError(Exception):
15
+ """A git command failed."""
16
+ def __init__(self, message: str, returncode: int = 1):
17
+ super().__init__(message)
18
+ self.returncode = returncode
19
+
20
+
21
+ def _run(args: list[str], cwd: Path, check: bool = True) -> str:
22
+ """Run a git command and return stdout.
23
+
24
+ Args:
25
+ args: git subcommand + arguments (without 'git' prefix).
26
+ cwd: repository path.
27
+ check: if True, raise GitError on non-zero exit.
28
+
29
+ Returns:
30
+ Stripped stdout string.
31
+ """
32
+ result = subprocess.run(
33
+ ["git"] + args,
34
+ capture_output=True,
35
+ text=True,
36
+ cwd=cwd,
37
+ )
38
+ if check and result.returncode != 0:
39
+ stderr = result.stderr.strip()
40
+ raise GitError(
41
+ f"git {' '.join(args)} failed: {stderr}",
42
+ returncode=result.returncode,
43
+ )
44
+ return result.stdout.strip()
45
+
46
+
47
+ def _run_ok(args: list[str], cwd: Path) -> str:
48
+ """Run a git command, returning stdout or empty string on failure."""
49
+ result = subprocess.run(
50
+ ["git"] + args,
51
+ capture_output=True,
52
+ text=True,
53
+ cwd=cwd,
54
+ )
55
+ return result.stdout.strip() if result.returncode == 0 else ""
56
+
57
+
58
+ # ── Query operations ──────────────────────────────────────────────────────
59
+
60
+ def current_branch(repo_path: Path) -> str:
61
+ """Get the current branch name, or '(detached)' if HEAD is detached."""
62
+ branch = _run_ok(["rev-parse", "--abbrev-ref", "HEAD"], cwd=repo_path)
63
+ return "(detached)" if branch == "HEAD" else branch
64
+
65
+
66
+ def head_sha(repo_path: Path) -> str:
67
+ """Get the full HEAD commit sha."""
68
+ return _run(["rev-parse", "HEAD"], cwd=repo_path)
69
+
70
+
71
+ def sha_of(repo_path: Path, ref: str) -> str:
72
+ """Resolve any ref (branch / sha / tag) to its full commit sha.
73
+
74
+ Returns empty string if the ref doesn't resolve.
75
+ """
76
+ try:
77
+ return _run(["rev-parse", "--verify", f"{ref}^{{commit}}"], cwd=repo_path)
78
+ except GitError:
79
+ return ""
80
+
81
+
82
+ def short_sha(repo_path: Path) -> str:
83
+ """Get the short HEAD commit sha."""
84
+ return _run(["rev-parse", "--short", "HEAD"], cwd=repo_path)
85
+
86
+
87
+ def is_dirty(repo_path: Path) -> bool:
88
+ """Check if the working tree has any changes."""
89
+ result = subprocess.run(
90
+ ["git", "status", "--porcelain"],
91
+ capture_output=True, text=True, cwd=repo_path,
92
+ )
93
+ return bool(result.stdout.strip())
94
+
95
+
96
+ def dirty_file_count(repo_path: Path) -> int:
97
+ """Count files with uncommitted changes."""
98
+ output = _run_ok(["status", "--porcelain"], cwd=repo_path)
99
+ if not output:
100
+ return 0
101
+ return len([line for line in output.split("\n") if line.strip()])
102
+
103
+
104
+ def remote_url(repo_path: Path) -> str:
105
+ """Get the URL of the 'origin' remote, or empty string."""
106
+ return _run_ok(["remote", "get-url", "origin"], cwd=repo_path)
107
+
108
+
109
+ def default_branch(repo_path: Path) -> str:
110
+ """Detect the default branch (main or master)."""
111
+ for candidate in ("main", "master"):
112
+ result = subprocess.run(
113
+ ["git", "rev-parse", "--verify", candidate],
114
+ capture_output=True, text=True, cwd=repo_path,
115
+ )
116
+ if result.returncode == 0:
117
+ return candidate
118
+ return "main"
119
+
120
+
121
+ def divergence(repo_path: Path, branch: str, base: str) -> tuple[int, int]:
122
+ """Count commits ahead and behind base.
123
+
124
+ Returns:
125
+ (ahead, behind) tuple.
126
+ """
127
+ ahead_out = _run_ok(["log", f"{base}..{branch}", "--oneline"], cwd=repo_path)
128
+ behind_out = _run_ok(["log", f"{branch}..{base}", "--oneline"], cwd=repo_path)
129
+
130
+ ahead = len(ahead_out.strip().split("\n")) if ahead_out else 0
131
+ behind = len(behind_out.strip().split("\n")) if behind_out else 0
132
+
133
+ return (ahead, behind)
134
+
135
+
136
+ def changed_files(repo_path: Path, branch: str, base: str) -> list[str]:
137
+ """Get files changed between base and branch (three-dot diff)."""
138
+ output = _run_ok(["diff", "--name-only", f"{base}...{branch}"], cwd=repo_path)
139
+ if not output:
140
+ return []
141
+ return [f for f in output.split("\n") if f.strip()]
142
+
143
+
144
+ def changed_files_with_status(repo_path: Path, branch: str, base: str) -> list[dict]:
145
+ """Get files changed between base and branch, with M/A/D status.
146
+
147
+ Includes uncommitted changes (working tree + index) so the listing
148
+ matches what the user sees when editing files in a worktree.
149
+
150
+ Returns:
151
+ List of {path, status} where status is one of:
152
+ "M" modified, "A" added, "D" deleted, "R" renamed,
153
+ "C" copied, "T" type-changed, "?" untracked.
154
+ """
155
+ entries: dict[str, str] = {}
156
+
157
+ committed = _run_ok(
158
+ ["diff", "--name-status", f"{base}...{branch}"], cwd=repo_path,
159
+ )
160
+ for line in committed.splitlines():
161
+ if not line.strip():
162
+ continue
163
+ parts = line.split("\t")
164
+ if len(parts) < 2:
165
+ continue
166
+ status = parts[0][0].upper()
167
+ path = parts[-1]
168
+ entries[path] = status
169
+
170
+ # Porcelain output preserves leading spaces; don't use _run_ok (which strips).
171
+ raw = subprocess.run(
172
+ ["git", "status", "--porcelain"],
173
+ capture_output=True, text=True, cwd=repo_path,
174
+ ).stdout
175
+ for line in raw.splitlines():
176
+ if len(line) < 4:
177
+ continue
178
+ index_status = line[0]
179
+ worktree_status = line[1]
180
+ path = line[3:]
181
+ if index_status == "?" and worktree_status == "?":
182
+ entries[path] = "?"
183
+ continue
184
+ # Prefer index status when staged, otherwise worktree status.
185
+ status = index_status.strip() or worktree_status.strip()
186
+ if status:
187
+ entries[path] = status.upper()
188
+
189
+ return [{"path": p, "status": s} for p, s in sorted(entries.items())]
190
+
191
+
192
+ def branches(repo_path: Path) -> list[str]:
193
+ """List all local branch names."""
194
+ output = _run_ok(["branch", "--format=%(refname:short)"], cwd=repo_path)
195
+ if not output:
196
+ return []
197
+ return [b.strip() for b in output.split("\n") if b.strip()]
198
+
199
+
200
+ def branch_exists(repo_path: Path, branch: str) -> bool:
201
+ """Check if a local branch exists."""
202
+ result = subprocess.run(
203
+ ["git", "rev-parse", "--verify", branch],
204
+ capture_output=True, text=True, cwd=repo_path,
205
+ )
206
+ return result.returncode == 0
207
+
208
+
209
+ # ── Write operations ─────────────────────────────────────────────────────
210
+
211
+ def create_branch(repo_path: Path, name: str, start_point: str = "HEAD") -> None:
212
+ """Create a new branch.
213
+
214
+ Uses --no-track so the new branch does not inherit the start_point's
215
+ upstream. Without this, a user gitconfig of branch.autoSetupMerge=inherit
216
+ (or =simple matching a remote-tracking start_point) would silently make
217
+ the new branch track origin/<start_point> — so a later `git push` would
218
+ push to the start_point's branch on the remote. Upstream gets set
219
+ explicitly on first push instead.
220
+ """
221
+ _run(["branch", "--no-track", name, start_point], cwd=repo_path)
222
+
223
+
224
+ def checkout(repo_path: Path, branch: str) -> None:
225
+ """Checkout a branch."""
226
+ _run(["checkout", branch], cwd=repo_path)
227
+
228
+
229
+ def checkout_detach(repo_path: Path) -> None:
230
+ """Detach HEAD so the current branch lock is released (used before slot swap)."""
231
+ _run(["checkout", "--detach"], cwd=repo_path)
232
+
233
+
234
+ def stage_files(repo_path: Path, files: list[str]) -> None:
235
+ """Stage specific files."""
236
+ if files:
237
+ _run(["add"] + files, cwd=repo_path)
238
+
239
+
240
+ def unstage_files(repo_path: Path, files: list[str]) -> None:
241
+ """Unstage specific files."""
242
+ if files:
243
+ _run(["restore", "--staged"] + files, cwd=repo_path)
244
+
245
+
246
+ def stage_all_tracked(repo_path: Path) -> None:
247
+ """Stage all tracked, modified files (mirror of `git add -u`)."""
248
+ _run(["add", "-u"], cwd=repo_path)
249
+
250
+
251
+ def staged_file_count(repo_path: Path) -> int:
252
+ """Count files currently in the index awaiting commit."""
253
+ output = _run_ok(["diff", "--cached", "--name-only"], cwd=repo_path)
254
+ if not output:
255
+ return 0
256
+ return len([line for line in output.split("\n") if line.strip()])
257
+
258
+
259
+ def commit(
260
+ repo_path: Path,
261
+ message: str,
262
+ *,
263
+ amend: bool = False,
264
+ no_hooks: bool = False,
265
+ allow_empty: bool = False,
266
+ ) -> dict[str, Any]:
267
+ """Create a commit. Returns ``{sha, files_changed}``.
268
+
269
+ ``files_changed`` is the count of files touched by the new commit
270
+ (uses ``git show --name-only`` against the resulting HEAD, so it
271
+ works for the first commit and for ``--amend``).
272
+
273
+ Args:
274
+ amend: pass ``--amend``. Reuses the existing message via ``-m``
275
+ anyway (caller controls the new message).
276
+ no_hooks: pass ``--no-verify`` to skip pre-commit / commit-msg hooks.
277
+ allow_empty: pass ``--allow-empty``.
278
+ """
279
+ args = ["commit", "-m", message]
280
+ if amend:
281
+ args.append("--amend")
282
+ if no_hooks:
283
+ args.append("--no-verify")
284
+ if allow_empty:
285
+ args.append("--allow-empty")
286
+ _run(args, cwd=repo_path)
287
+ sha = head_sha(repo_path)
288
+ show_out = _run_ok(
289
+ ["show", "--name-only", "--pretty=format:", "HEAD"], cwd=repo_path,
290
+ )
291
+ files_changed = len([line for line in show_out.split("\n") if line.strip()])
292
+ return {"sha": sha, "files_changed": files_changed}
293
+
294
+
295
+ # ── Push / upstream queries ──────────────────────────────────────────────
296
+
297
+ def has_upstream(repo_path: Path, branch: str | None = None) -> bool:
298
+ """Check whether ``branch`` (or current branch) has a configured upstream."""
299
+ target = f"{branch}@{{upstream}}" if branch else "@{upstream}"
300
+ result = subprocess.run(
301
+ ["git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", target],
302
+ capture_output=True, text=True, cwd=repo_path,
303
+ )
304
+ return result.returncode == 0
305
+
306
+
307
+ def upstream_ref(repo_path: Path, branch: str | None = None) -> str:
308
+ """Return the upstream ref (e.g. ``origin/main``), or empty string if unset."""
309
+ target = f"{branch}@{{upstream}}" if branch else "@{upstream}"
310
+ return _run_ok(
311
+ ["rev-parse", "--abbrev-ref", "--symbolic-full-name", target],
312
+ cwd=repo_path,
313
+ )
314
+
315
+
316
+ def unpushed_count(repo_path: Path, branch: str | None = None) -> int:
317
+ """Count commits HEAD (or branch) is ahead of its upstream.
318
+
319
+ Returns 0 when the branch is up-to-date OR has no upstream — caller
320
+ should check ``has_upstream`` to disambiguate.
321
+ """
322
+ target = branch or "HEAD"
323
+ upstream = f"{branch}@{{upstream}}" if branch else "@{upstream}"
324
+ result = subprocess.run(
325
+ ["git", "rev-list", "--count", f"{upstream}..{target}"],
326
+ capture_output=True, text=True, cwd=repo_path,
327
+ )
328
+ if result.returncode != 0:
329
+ return 0
330
+ try:
331
+ return int(result.stdout.strip() or "0")
332
+ except ValueError:
333
+ return 0
334
+
335
+
336
+ def push(
337
+ repo_path: Path,
338
+ *,
339
+ branch: str | None = None,
340
+ remote: str = "origin",
341
+ set_upstream: bool = False,
342
+ force_with_lease: bool = False,
343
+ dry_run: bool = False,
344
+ ) -> dict[str, Any]:
345
+ """Run ``git push`` and return a structured result.
346
+
347
+ Returns one of:
348
+ - ``{status: "ok", pushed_count, ref, set_upstream?, dry_run?}``
349
+ - ``{status: "rejected", reason}`` — non-fast-forward without ``force_with_lease``
350
+ - ``{status: "failed", reason}`` — any other git failure (network, auth, etc.)
351
+
352
+ The caller is responsible for the "up-to-date / nothing to push"
353
+ short-circuit (use ``unpushed_count`` first); this primitive always
354
+ invokes ``git push``.
355
+ """
356
+ pushed_count = (
357
+ unpushed_count(repo_path, branch) if not dry_run else 0
358
+ )
359
+
360
+ args = ["push"]
361
+ if set_upstream:
362
+ args.append("--set-upstream")
363
+ if force_with_lease:
364
+ args.append("--force-with-lease")
365
+ if dry_run:
366
+ args.append("--dry-run")
367
+ args.append(remote)
368
+ if branch:
369
+ args.append(branch)
370
+
371
+ result = subprocess.run(
372
+ ["git"] + args,
373
+ capture_output=True, text=True, cwd=repo_path,
374
+ )
375
+ if result.returncode == 0:
376
+ out: dict[str, Any] = {
377
+ "status": "ok",
378
+ "pushed_count": pushed_count,
379
+ "ref": f"{remote}/{branch}" if branch else upstream_ref(repo_path),
380
+ }
381
+ if set_upstream:
382
+ out["set_upstream"] = True
383
+ if dry_run:
384
+ out["dry_run"] = True
385
+ return out
386
+
387
+ stderr = (result.stderr or "").strip()
388
+ tail = stderr.splitlines()[-3:] if stderr else []
389
+ reason = "\n".join(tail) or stderr or "push failed"
390
+
391
+ # Non-fast-forward / hook rejection — git uses "rejected" or "non-fast-forward"
392
+ if "rejected" in stderr or "non-fast-forward" in stderr:
393
+ return {"status": "rejected", "reason": reason}
394
+ return {"status": "failed", "reason": reason}
395
+
396
+
397
+ # ── Diff / log ────────────────────────────────────────────────────────────
398
+
399
+ def diff_stat(repo_path: Path, ref_a: str, ref_b: str) -> dict:
400
+ """Get diff stats between two refs.
401
+
402
+ Returns:
403
+ {files_changed: int, insertions: int, deletions: int}
404
+ """
405
+ output = _run_ok(
406
+ ["diff", "--shortstat", f"{ref_a}...{ref_b}"],
407
+ cwd=repo_path,
408
+ )
409
+ result = {"files_changed": 0, "insertions": 0, "deletions": 0}
410
+ if not output:
411
+ return result
412
+
413
+ # "3 files changed, 45 insertions(+), 12 deletions(-)"
414
+ import re
415
+ m = re.search(r"(\d+) files? changed", output)
416
+ if m:
417
+ result["files_changed"] = int(m.group(1))
418
+ m = re.search(r"(\d+) insertions?", output)
419
+ if m:
420
+ result["insertions"] = int(m.group(1))
421
+ m = re.search(r"(\d+) deletions?", output)
422
+ if m:
423
+ result["deletions"] = int(m.group(1))
424
+
425
+ return result
426
+
427
+
428
+ def log_for_path(
429
+ repo_path: Path, since_sha: str, path: str, *, follow: bool = True,
430
+ ) -> list[dict]:
431
+ """Commits that touched ``path`` since ``since_sha`` (exclusive).
432
+
433
+ Drives M9 ``draft_replies`` — given a PR review comment anchored at
434
+ ``since_sha`` for a file, this returns every later commit on the
435
+ current branch that touched the file. An empty list means the
436
+ comment is unaddressed (file untouched since the comment).
437
+
438
+ ``follow=True`` (the default) tracks renames so a reply to a comment
439
+ on a renamed file still surfaces the renaming commit.
440
+
441
+ Returns a list of ``{sha, subject, date}`` (ISO-8601 author date).
442
+ """
443
+ args = ["log", f"{since_sha}..HEAD", "--pretty=format:%H|%s|%aI"]
444
+ if follow:
445
+ args.append("--follow")
446
+ args += ["--", path]
447
+ try:
448
+ output = _run_ok(args, cwd=repo_path)
449
+ except GitError:
450
+ return []
451
+ out: list[dict] = []
452
+ for line in output.split("\n"):
453
+ line = line.strip()
454
+ if not line:
455
+ continue
456
+ parts = line.split("|", 2)
457
+ if len(parts) != 3:
458
+ continue
459
+ sha, subject, date = parts
460
+ out.append({"sha": sha, "subject": subject, "date": date})
461
+ return out
462
+
463
+
464
+ def log_oneline(repo_path: Path, ref_range: str, max_count: int = 20) -> list[str]:
465
+ """Get one-line log entries for a ref range."""
466
+ output = _run_ok(
467
+ ["log", ref_range, "--oneline", f"--max-count={max_count}"],
468
+ cwd=repo_path,
469
+ )
470
+ if not output:
471
+ return []
472
+ return [line for line in output.split("\n") if line.strip()]
473
+
474
+
475
+ def status_porcelain(repo_path: Path) -> list[dict]:
476
+ """Get porcelain status output as structured data.
477
+
478
+ Returns:
479
+ List of {path, index_status, worktree_status}
480
+ """
481
+ # Use raw subprocess to preserve leading spaces (porcelain format uses them)
482
+ result = subprocess.run(
483
+ ["git", "status", "--porcelain"],
484
+ capture_output=True, text=True, cwd=repo_path,
485
+ )
486
+ raw = result.stdout
487
+ if not raw or not raw.strip():
488
+ return []
489
+
490
+ entries = []
491
+ for line in raw.splitlines():
492
+ if len(line) < 4:
493
+ continue
494
+ index_status = line[0]
495
+ worktree_status = line[1]
496
+ path = line[3:]
497
+ entries.append({
498
+ "path": path,
499
+ "index_status": index_status.strip(),
500
+ "worktree_status": worktree_status.strip(),
501
+ })
502
+
503
+ return entries
504
+
505
+
506
+ def pull_rebase(repo_path: Path, remote: str = "origin", branch: str | None = None) -> str:
507
+ """Pull with rebase from remote. Returns output message."""
508
+ args = ["pull", "--rebase", remote]
509
+ if branch:
510
+ args.append(branch)
511
+ return _run(args, cwd=repo_path)
512
+
513
+
514
+ def merge_base(repo_path: Path, ref_a: str, ref_b: str) -> str:
515
+ """Find the merge base of two refs."""
516
+ return _run(["merge-base", ref_a, ref_b], cwd=repo_path)
517
+
518
+
519
+ # ── Stash ─────────────────────────────────────────────────────────────────
520
+
521
+ def stash_save(
522
+ repo_path: Path, message: str = "", include_untracked: bool = False,
523
+ ) -> bool:
524
+ """Stash uncommitted changes. Returns True if anything was stashed.
525
+
526
+ ``include_untracked=True`` adds ``-u`` so untracked files are also
527
+ stashed (used by feature-scoped stashes where the user expects
528
+ "everything for this feature" to disappear cleanly).
529
+ """
530
+ args = ["stash", "push"]
531
+ if include_untracked:
532
+ args.append("-u")
533
+ if message:
534
+ args.extend(["-m", message])
535
+ output = _run(args, cwd=repo_path)
536
+ # "No local changes to save" means nothing was stashed
537
+ return "No local changes" not in output
538
+
539
+
540
+ def stash_pop(repo_path: Path, index: int = 0) -> str:
541
+ """Pop a stash entry. Returns output message."""
542
+ return _run(["stash", "pop", f"stash@{{{index}}}"], cwd=repo_path)
543
+
544
+
545
+ def stash_list(repo_path: Path) -> list[dict]:
546
+ """List stash entries.
547
+
548
+ Returns:
549
+ List of {index, branch, message}
550
+ """
551
+ output = _run_ok(["stash", "list", "--format=%gd|%gs"], cwd=repo_path)
552
+ if not output:
553
+ return []
554
+
555
+ entries = []
556
+ for line in output.splitlines():
557
+ if not line.strip():
558
+ continue
559
+ parts = line.split("|", 1)
560
+ ref = parts[0].strip() # stash@{0}
561
+ desc = parts[1].strip() if len(parts) > 1 else ""
562
+ # Extract index from stash@{N}
563
+ try:
564
+ idx = int(ref.split("{")[1].rstrip("}"))
565
+ except (IndexError, ValueError):
566
+ idx = 0
567
+ entries.append({
568
+ "index": idx,
569
+ "ref": ref,
570
+ "message": desc,
571
+ })
572
+ return entries
573
+
574
+
575
+ def stash_drop(repo_path: Path, index: int = 0) -> str:
576
+ """Drop a stash entry."""
577
+ return _run(["stash", "drop", f"stash@{{{index}}}"], cwd=repo_path)
578
+
579
+
580
+ # ── Branch management ─────────────────────────────────────────────────────
581
+
582
+ def delete_branch(repo_path: Path, name: str, force: bool = False) -> str:
583
+ """Delete a local branch."""
584
+ flag = "-D" if force else "-d"
585
+ return _run(["branch", flag, name], cwd=repo_path)
586
+
587
+
588
+ def rename_branch(repo_path: Path, old_name: str, new_name: str) -> str:
589
+ """Rename a local branch."""
590
+ return _run(["branch", "-m", old_name, new_name], cwd=repo_path)
591
+
592
+
593
+ def all_branches(repo_path: Path) -> list[dict]:
594
+ """List all local branches with metadata.
595
+
596
+ Returns:
597
+ List of {name, is_current, sha, subject}
598
+ """
599
+ output = _run_ok(
600
+ ["branch", "--format=%(HEAD)|%(refname:short)|%(objectname:short)|%(subject)"],
601
+ cwd=repo_path,
602
+ )
603
+ if not output:
604
+ return []
605
+
606
+ entries = []
607
+ for line in output.splitlines():
608
+ parts = line.split("|", 3)
609
+ if len(parts) < 4:
610
+ continue
611
+ entries.append({
612
+ "name": parts[1].strip(),
613
+ "is_current": parts[0].strip() == "*",
614
+ "sha": parts[2].strip(),
615
+ "subject": parts[3].strip(),
616
+ })
617
+ return entries
618
+
619
+
620
+ # ── Worktree ──────────────────────────────────────────────────────────────
621
+
622
+ def is_worktree(repo_path: Path) -> bool:
623
+ """Check if repo_path is a linked worktree (not the main working tree).
624
+
625
+ Linked worktrees have a `.git` *file* (not directory) that points to
626
+ the main repo's `.git/worktrees/<name>/` directory.
627
+ """
628
+ git_path = repo_path / ".git"
629
+ return git_path.is_file()
630
+
631
+
632
+ def worktree_main_path(repo_path: Path) -> Path | None:
633
+ """If repo_path is a linked worktree, return the main working tree path.
634
+
635
+ Returns None if this is the main working tree (not a linked worktree).
636
+ """
637
+ common = _run_ok(["rev-parse", "--git-common-dir"], cwd=repo_path)
638
+ local = _run_ok(["rev-parse", "--git-dir"], cwd=repo_path)
639
+
640
+ if not common or not local:
641
+ return None
642
+
643
+ common_resolved = (repo_path / common).resolve()
644
+ local_resolved = (repo_path / local).resolve()
645
+
646
+ if common_resolved == local_resolved:
647
+ return None # main working tree
648
+
649
+ # common-dir is the main repo's .git — its parent is the main working tree
650
+ return common_resolved.parent
651
+
652
+
653
+ def worktree_list(repo_path: Path) -> list[dict]:
654
+ """List all worktrees for the repo at repo_path.
655
+
656
+ Returns:
657
+ List of {path, head, branch, is_bare}
658
+ """
659
+ output = _run_ok(["worktree", "list", "--porcelain"], cwd=repo_path)
660
+ if not output:
661
+ return []
662
+
663
+ worktrees = []
664
+ current: dict = {}
665
+ for line in output.splitlines():
666
+ if line.startswith("worktree "):
667
+ if current:
668
+ worktrees.append(current)
669
+ current = {"path": line[9:], "head": "", "branch": "", "is_bare": False}
670
+ elif line.startswith("HEAD "):
671
+ current["head"] = line[5:]
672
+ elif line.startswith("branch "):
673
+ # "branch refs/heads/main" -> "main"
674
+ ref = line[7:]
675
+ current["branch"] = ref.replace("refs/heads/", "")
676
+ elif line == "bare":
677
+ current["is_bare"] = True
678
+ elif line == "detached":
679
+ current["branch"] = "(detached)"
680
+
681
+ if current:
682
+ worktrees.append(current)
683
+
684
+ return worktrees
685
+
686
+
687
+ def worktree_for_branch(repo_path: Path, branch: str) -> str | None:
688
+ """Find the worktree path where a branch is checked out.
689
+
690
+ Returns the worktree path string, or None if the branch isn't
691
+ checked out in any worktree.
692
+ """
693
+ for wt in worktree_list(repo_path):
694
+ if wt.get("branch") == branch:
695
+ return wt["path"]
696
+ return None
697
+
698
+
699
+ def worktree_add(
700
+ repo_path: Path,
701
+ dest_path: Path,
702
+ branch: str,
703
+ create_branch: bool = True,
704
+ ) -> str:
705
+ """Create a new linked worktree.
706
+
707
+ Args:
708
+ repo_path: The main repo (or any existing worktree of it).
709
+ dest_path: Where to create the new worktree directory.
710
+ branch: Branch name to checkout in the worktree.
711
+ create_branch: If True and branch doesn't exist, create it (-b).
712
+
713
+ Returns:
714
+ Output message from git.
715
+ """
716
+ args = ["worktree", "add"]
717
+ if create_branch and not branch_exists(repo_path, branch):
718
+ # --no-track: see create_branch() for rationale.
719
+ args.extend(["-b", branch, "--no-track"])
720
+ args.append(str(dest_path))
721
+ if not create_branch or branch_exists(repo_path, branch):
722
+ args.append(branch)
723
+ return _run(args, cwd=repo_path)
724
+
725
+
726
+ def worktree_remove(repo_path: Path, worktree_path: Path, force: bool = False) -> str:
727
+ """Remove a linked worktree."""
728
+ args = ["worktree", "remove"]
729
+ if force:
730
+ args.append("--force")
731
+ args.append(str(worktree_path))
732
+ return _run(args, cwd=repo_path)
733
+
734
+
735
+ def worktree_move(main_repo: Path, old_path: Path, new_path: Path) -> None:
736
+ """Run `git worktree move <old_path> <new_path>` from main_repo.
737
+
738
+ Updates .git/worktrees/<name>/gitdir so the worktree's back-reference
739
+ to the main repo stays correct after the directory is relocated.
740
+ """
741
+ _run(["worktree", "move", str(old_path), str(new_path)], cwd=main_repo)
742
+
743
+
744
+ # ── Log ───────────────────────────────────────────────────────────────────
745
+
746
+ def commit_iso_date(repo_path: Path, ref: str = "HEAD") -> str:
747
+ """Return the committer date of a ref as ISO 8601 (e.g. ``2026-04-25T12:34:56Z``).
748
+
749
+ Used by the review-comment temporal filter to know how old the latest
750
+ commit on the branch is. Returns empty string if the ref doesn't resolve.
751
+ """
752
+ try:
753
+ return _run_ok(
754
+ ["log", "-1", "--format=%cI", ref], cwd=repo_path,
755
+ ).strip()
756
+ except GitError:
757
+ return ""
758
+
759
+
760
+ def commits_touching_path(
761
+ repo_path: Path,
762
+ ref: str,
763
+ path: str,
764
+ since: str | None = None,
765
+ ) -> list[dict]:
766
+ """Return commits on ``ref`` that touched ``path``, optionally since a date.
767
+
768
+ Each entry: ``{sha, short_sha, committed_at, subject}``. Newest first.
769
+ ``since`` should be ISO 8601; commits with committer date ``> since``
770
+ are returned (used to ask: did anything happen after the comment?).
771
+ """
772
+ sep = "\x1f"
773
+ fmt = f"%H{sep}%h{sep}%cI{sep}%s"
774
+ args = ["log", ref, f"--format={fmt}"]
775
+ if since:
776
+ args.append(f"--since={since}")
777
+ args.extend(["--", path])
778
+ try:
779
+ output = _run_ok(args, cwd=repo_path)
780
+ except GitError:
781
+ return []
782
+ if not output:
783
+ return []
784
+ entries = []
785
+ for line in output.splitlines():
786
+ parts = line.split(sep)
787
+ if len(parts) < 4:
788
+ continue
789
+ entries.append({
790
+ "sha": parts[0],
791
+ "short_sha": parts[1],
792
+ "committed_at": parts[2],
793
+ "subject": parts[3],
794
+ })
795
+ return entries
796
+
797
+
798
+ def log_structured(
799
+ repo_path: Path,
800
+ ref: str = "HEAD",
801
+ max_count: int = 20,
802
+ ) -> list[dict]:
803
+ """Get structured log entries.
804
+
805
+ Returns:
806
+ List of {sha, short_sha, author, date, subject}
807
+ """
808
+ sep = "\x1f" # unit separator
809
+ fmt = f"%H{sep}%h{sep}%an{sep}%ai{sep}%s"
810
+ output = _run_ok(
811
+ ["log", ref, f"--format={fmt}", f"--max-count={max_count}"],
812
+ cwd=repo_path,
813
+ )
814
+ if not output:
815
+ return []
816
+
817
+ entries = []
818
+ for line in output.splitlines():
819
+ parts = line.split(sep)
820
+ if len(parts) < 5:
821
+ continue
822
+ entries.append({
823
+ "sha": parts[0],
824
+ "short_sha": parts[1],
825
+ "author": parts[2],
826
+ "date": parts[3],
827
+ "subject": parts[4],
828
+ })
829
+ return entries
830
+
831
+
832
+ def log_since(repo_path: Path, branch: str, since_iso: str) -> list[dict]:
833
+ """Return commits on ``branch`` authored after ``since_iso`` (ISO 8601).
834
+
835
+ Used by feature_resume to populate the commits-since-last-visit section.
836
+ Returns a list of {sha, short_sha, at, author, subject} or [] on error.
837
+ """
838
+ sep = "\x1f" # unit separator
839
+ fmt = f"%H{sep}%h{sep}%aI{sep}%an{sep}%s"
840
+ output = _run_ok(
841
+ ["log", branch, f"--since={since_iso}", f"--format={fmt}"],
842
+ cwd=repo_path,
843
+ )
844
+ if not output:
845
+ return []
846
+
847
+ entries = []
848
+ for line in output.splitlines():
849
+ parts = line.split(sep)
850
+ if len(parts) < 5:
851
+ continue
852
+ entries.append({
853
+ "sha": parts[0],
854
+ "short_sha": parts[1],
855
+ "at": parts[2],
856
+ "author": parts[3],
857
+ "subject": parts[4],
858
+ })
859
+ return entries