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.
- canopy/__init__.py +2 -0
- canopy/actions/__init__.py +32 -0
- canopy/actions/aliases.py +421 -0
- canopy/actions/augments.py +55 -0
- canopy/actions/bootstrap.py +249 -0
- canopy/actions/bot_resolutions.py +123 -0
- canopy/actions/bot_status.py +133 -0
- canopy/actions/commit.py +511 -0
- canopy/actions/conflicts.py +314 -0
- canopy/actions/doctor.py +1459 -0
- canopy/actions/draft_replies.py +185 -0
- canopy/actions/drift.py +241 -0
- canopy/actions/errors.py +115 -0
- canopy/actions/evacuate.py +192 -0
- canopy/actions/feature_state.py +607 -0
- canopy/actions/historian.py +612 -0
- canopy/actions/ide_workspace.py +49 -0
- canopy/actions/last_visit.py +83 -0
- canopy/actions/migrate_slots.py +313 -0
- canopy/actions/preflight_state.py +97 -0
- canopy/actions/push.py +199 -0
- canopy/actions/reads.py +304 -0
- canopy/actions/resume.py +582 -0
- canopy/actions/review_filter.py +135 -0
- canopy/actions/ship.py +399 -0
- canopy/actions/slot_details.py +208 -0
- canopy/actions/slot_load.py +383 -0
- canopy/actions/slots.py +221 -0
- canopy/actions/stash.py +230 -0
- canopy/actions/switch.py +775 -0
- canopy/actions/switch_preflight.py +192 -0
- canopy/actions/thread_actions.py +88 -0
- canopy/actions/thread_resolutions.py +101 -0
- canopy/actions/triage.py +286 -0
- canopy/agent/__init__.py +5 -0
- canopy/agent/runner.py +129 -0
- canopy/agent_setup/__init__.py +264 -0
- canopy/agent_setup/skills/augment-canopy/SKILL.md +116 -0
- canopy/agent_setup/skills/using-canopy/SKILL.md +191 -0
- canopy/cli/__init__.py +0 -0
- canopy/cli/main.py +4152 -0
- canopy/cli/render.py +98 -0
- canopy/cli/ui.py +150 -0
- canopy/features/__init__.py +2 -0
- canopy/features/coordinator.py +1256 -0
- canopy/git/__init__.py +0 -0
- canopy/git/hooks.py +173 -0
- canopy/git/multi.py +435 -0
- canopy/git/repo.py +859 -0
- canopy/git/templates/post-checkout.py +67 -0
- canopy/graph/__init__.py +0 -0
- canopy/integrations/__init__.py +0 -0
- canopy/integrations/github.py +983 -0
- canopy/integrations/linear.py +307 -0
- canopy/integrations/precommit.py +239 -0
- canopy/mcp/__init__.py +0 -0
- canopy/mcp/client.py +329 -0
- canopy/mcp/server.py +1797 -0
- canopy/providers/__init__.py +105 -0
- canopy/providers/github_issues.py +289 -0
- canopy/providers/linear.py +341 -0
- canopy/providers/types.py +149 -0
- canopy/workspace/__init__.py +4 -0
- canopy/workspace/config.py +378 -0
- canopy/workspace/context.py +224 -0
- canopy/workspace/discovery.py +197 -0
- canopy/workspace/workspace.py +173 -0
- canopy_cli-3.1.0.dist-info/METADATA +282 -0
- canopy_cli-3.1.0.dist-info/RECORD +71 -0
- canopy_cli-3.1.0.dist-info/WHEEL +4 -0
- 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
|