gitgrip 1.5.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 (80) hide show
  1. gitgrip-1.5.0.dist-info/METADATA +13 -0
  2. gitgrip-1.5.0.dist-info/RECORD +80 -0
  3. gitgrip-1.5.0.dist-info/WHEEL +5 -0
  4. gitgrip-1.5.0.dist-info/entry_points.txt +2 -0
  5. gitgrip-1.5.0.dist-info/top_level.txt +2 -0
  6. gr2/__init__.py +0 -0
  7. gr2/overlay/__init__.py +6 -0
  8. gr2/overlay/activate.py +196 -0
  9. gr2/overlay/agent_manifest.py +138 -0
  10. gr2/overlay/cli.py +181 -0
  11. gr2/overlay/cross_repo.py +124 -0
  12. gr2/overlay/drivers.py +113 -0
  13. gr2/overlay/introspection.py +155 -0
  14. gr2/overlay/language_drivers.py +115 -0
  15. gr2/overlay/objects.py +412 -0
  16. gr2/overlay/perf.py +251 -0
  17. gr2/overlay/refs.py +36 -0
  18. gr2/overlay/trust.py +150 -0
  19. gr2/overlay/types.py +69 -0
  20. gr2/overlay/units.py +313 -0
  21. gr2/overlay/workspace_spec.py +59 -0
  22. gr2/prototypes/__init__.py +0 -0
  23. gr2/prototypes/cache_materialization_probe.py +190 -0
  24. gr2/prototypes/concurrent_event_stress.py +199 -0
  25. gr2/prototypes/concurrent_lease_stress.py +240 -0
  26. gr2/prototypes/concurrent_workspace_cap_stress.py +231 -0
  27. gr2/prototypes/contribution_protocol.py +665 -0
  28. gr2/prototypes/cross_mode_lane_stress.py +986 -0
  29. gr2/prototypes/jsonl_store.py +158 -0
  30. gr2/prototypes/lane_workspace_prototype.py +2088 -0
  31. gr2/prototypes/layout_model_probe.py +139 -0
  32. gr2/prototypes/propagation_daemon.py +546 -0
  33. gr2/prototypes/propagation_state_machine.py +1478 -0
  34. gr2/prototypes/python_exec_playground.py +194 -0
  35. gr2/prototypes/python_hook_runtime_playground.py +240 -0
  36. gr2/prototypes/python_migration_playground.py +144 -0
  37. gr2/prototypes/python_review_checkout_playground.py +242 -0
  38. gr2/prototypes/python_spec_apply_playground.py +282 -0
  39. gr2/prototypes/real_git_lane_materialization.py +248 -0
  40. gr2/prototypes/real_git_playground.py +334 -0
  41. gr2/prototypes/recall_lane_history.py +274 -0
  42. gr2/prototypes/repo_maintenance_prototype.py +659 -0
  43. gr2/prototypes/repo_transport_probe.py +147 -0
  44. gr2/python_cli/__init__.py +2 -0
  45. gr2/python_cli/__main__.py +6 -0
  46. gr2/python_cli/add.py +51 -0
  47. gr2/python_cli/app.py +2516 -0
  48. gr2/python_cli/branch.py +67 -0
  49. gr2/python_cli/channel_bridge.py +131 -0
  50. gr2/python_cli/clone_exec.py +1019 -0
  51. gr2/python_cli/commit.py +199 -0
  52. gr2/python_cli/config.py +291 -0
  53. gr2/python_cli/env_exec.py +419 -0
  54. gr2/python_cli/events.py +529 -0
  55. gr2/python_cli/execops.py +372 -0
  56. gr2/python_cli/failures.py +98 -0
  57. gr2/python_cli/file_exec.py +256 -0
  58. gr2/python_cli/gitops.py +226 -0
  59. gr2/python_cli/grip.py +1337 -0
  60. gr2/python_cli/grip_cli.py +493 -0
  61. gr2/python_cli/hooks.py +450 -0
  62. gr2/python_cli/launch_exec.py +786 -0
  63. gr2/python_cli/merge_verification.py +274 -0
  64. gr2/python_cli/migration.py +985 -0
  65. gr2/python_cli/open_gr_review.py +699 -0
  66. gr2/python_cli/platform.py +441 -0
  67. gr2/python_cli/pr.py +487 -0
  68. gr2/python_cli/project_review.py +314 -0
  69. gr2/python_cli/prune.py +365 -0
  70. gr2/python_cli/push.py +172 -0
  71. gr2/python_cli/review.py +462 -0
  72. gr2/python_cli/review_ephemeral.py +143 -0
  73. gr2/python_cli/review_run.py +621 -0
  74. gr2/python_cli/spec_apply.py +1285 -0
  75. gr2/python_cli/staging_cleanup.py +205 -0
  76. gr2/python_cli/syncops.py +920 -0
  77. gr2/python_cli/target.py +100 -0
  78. gr2/python_cli/workspace_snapshot.py +105 -0
  79. gr2/schemas/gr2-materialization-plan-v1.schema.json +191 -0
  80. gr2_overlay/__init__.py +37 -0
@@ -0,0 +1,314 @@
1
+ """Minimal M1 project-tier composition over the existing review clone seam."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import dataclasses
6
+ import re
7
+ import tomllib
8
+ from pathlib import Path
9
+ from pathlib import PurePosixPath, PureWindowsPath
10
+ from typing import Literal
11
+
12
+ from gr2.prototypes import lane_workspace_prototype as lanes
13
+ from . import grip, review, spec_apply
14
+ from .gitops import git
15
+
16
+ _SHA40 = re.compile(r"\A[0-9a-f]{40}\Z")
17
+
18
+
19
+ @dataclasses.dataclass(frozen=True)
20
+ class ProjectReviewPin:
21
+ key: str
22
+ repo: str
23
+ path: str
24
+ base: str
25
+ head: str
26
+
27
+
28
+ @dataclasses.dataclass(frozen=True)
29
+ class ProjectReviewSpec:
30
+ schema: str
31
+ grip_commit: str
32
+ pins: tuple[ProjectReviewPin, ...]
33
+
34
+
35
+ @dataclasses.dataclass(frozen=True)
36
+ class ProjectReviewFailure:
37
+ key: str
38
+ reason: str
39
+
40
+
41
+ @dataclasses.dataclass(frozen=True)
42
+ class ProjectReviewOutcome:
43
+ status: Literal["opened", "refused", "partial"]
44
+ grip_commit: str
45
+ observed: tuple[review.ReviewRecord, ...]
46
+ failures: tuple[ProjectReviewFailure, ...]
47
+ review_root: Path | None
48
+ current_lane_changed: bool
49
+
50
+
51
+ def make_spec(
52
+ workspace: Path, pins: list[ProjectReviewPin], ranges: dict[str, str] | None = None,
53
+ committers: dict[str, str] | None = None,
54
+ ) -> ProjectReviewSpec:
55
+ ordered = tuple(sorted((_canonical_pin(pin) for pin in pins), key=lambda pin: pin.key))
56
+ if not ordered or len({pin.key for pin in ordered}) != len(ordered):
57
+ raise ValueError("project review pins must be non-empty with unique keys")
58
+ commit = grip.create_project_review_commit(
59
+ workspace, [{**dataclasses.asdict(pin), "repo": pin.repo} for pin in ordered],
60
+ ranges=ranges, committers=committers,
61
+ )
62
+ return ProjectReviewSpec("gr2-project-review/v1", commit, ordered)
63
+
64
+
65
+ def pins_from_lane(workspace_root: Path, owner_unit: str, lane_name: str) -> list[ProjectReviewPin]:
66
+ """Build project-review pins for a materialized lane, reading each repo's base
67
+ from the RECORDED fork base (the fork-base ruling), never from HEAD^.
68
+
69
+ A review is measured from the point the lane forked from its integration
70
+ branch; that coordinate is recorded at lane create and read here through the
71
+ same resolver the workspace snapshot uses. A lane with no recorded fork base is
72
+ refused by that resolver (unknown, never HEAD^), so a review can never be bound
73
+ against a base the lane did not actually fork from.
74
+ """
75
+ from . import workspace_snapshot as ws_snap
76
+
77
+ rows = ws_snap.resolve_lane_repos(Path(workspace_root).resolve(), owner_unit, lane_name)
78
+ return [
79
+ ProjectReviewPin(key=r["key"], repo=r["remote"], path=r["path"], base=r["base"], head=r["commit"])
80
+ for r in rows
81
+ ]
82
+
83
+
84
+ def _normalized_path(value: str) -> str:
85
+ path = PurePosixPath(value)
86
+ if not value or path.is_absolute() or ".." in path.parts or str(path) in {".", ""}:
87
+ raise ValueError(f"invalid project review path: {value!r}")
88
+ return path.as_posix()
89
+
90
+
91
+ def _canonical_pin(pin: ProjectReviewPin) -> ProjectReviewPin:
92
+ if not pin.key or any(ch in pin.key for ch in "/\\") or not pin.repo or not _SHA40.match(pin.base) or not _SHA40.match(pin.head):
93
+ raise ValueError(f"invalid project review pin: {pin.key}")
94
+ return dataclasses.replace(pin, path=_normalized_path(pin.path))
95
+
96
+
97
+ def _review_path_component(value: str, field: str) -> str:
98
+ """Validate names before they become a clone destination or lane path."""
99
+ if not value or value in {".", ".."}:
100
+ raise ValueError(f"invalid {field}: {value!r}")
101
+ windows = PureWindowsPath(value)
102
+ if (
103
+ "/" in value
104
+ or "\\" in value
105
+ or ":" in value
106
+ or windows.drive
107
+ or windows.root
108
+ or any(ord(char) < 0x20 or ord(char) == 0x7F for char in value)
109
+ ):
110
+ raise ValueError(f"invalid {field}: {value!r}")
111
+ return value
112
+
113
+
114
+ def _canonical_repo_identity(value: str, *, allow_local: bool) -> str:
115
+ """Canonicalize a pin/spec identity without treating a clone path as truth.
116
+
117
+ Local paths are test-only transport. When one names a checkout, its origin
118
+ supplies the repository identity just as production HTTPS/SSH transport
119
+ does. A bare local path remains marked local for isolated fixtures.
120
+
121
+ ``allow_local`` is threaded to ``canonical_source_identity`` on EVERY path,
122
+ including the two ``local:`` sub-cases, so the literal ``local:`` prefix
123
+ cannot bypass the FILESYSTEM-identity refusal: with ``allow_local=False`` a
124
+ filesystem identity is refused with the same ``ReviewError`` as a bare
125
+ non-GitHub origin, because the refusal is delegated to
126
+ ``canonical_source_identity`` rather than re-implemented here. A ``local:``
127
+ checkout whose origin is a portable ``https://github.com/<owner>/<repo>``
128
+ still canonicalizes to that GitHub identity regardless of the flag — the
129
+ refusal is of a non-portable filesystem identity, not of the prefix itself.
130
+ """
131
+ if value.startswith("local:"):
132
+ local = Path(value.removeprefix("local:"))
133
+ origin = review.gitops.remote_origin_url(local) if local.is_dir() else None
134
+ if origin:
135
+ return review.canonical_source_identity(origin, allow_local=allow_local)
136
+ return review.canonical_source_identity(str(local), allow_local=allow_local)
137
+ return review.canonical_source_identity(value, allow_local=allow_local)
138
+
139
+
140
+ def _validate_workspace_repository_boundary(
141
+ *, workspace: Path, pins: tuple[ProjectReviewPin, ...], sources: dict[str, tuple[Path, str]], allow_local: bool
142
+ ) -> ProjectReviewFailure | None:
143
+ """Bind every review pin and ephemeral source to the compiled workspace.
144
+
145
+ This is deliberately before pin-object checks, clone destinations, or lane
146
+ transition. A project review may only materialize repositories the compiled
147
+ workspace itself authorizes.
148
+ """
149
+ workspace_doc, failure = _load_workspace_boundary_doc(workspace)
150
+ if failure is not None:
151
+ return failure
152
+ entries: dict[str, str] = {}
153
+ for row in workspace_doc.get("repos", []):
154
+ if not isinstance(row, dict):
155
+ return ProjectReviewFailure("workspace_spec", "compiled workspace repo entry is not a mapping")
156
+ key = str(row.get("name", ""))
157
+ url = str(row.get("url", ""))
158
+ try:
159
+ entries[key] = _canonical_repo_identity(url, allow_local=allow_local)
160
+ except Exception as exc:
161
+ return ProjectReviewFailure("workspace_spec", f"invalid compiled repository {key!r}: {exc}")
162
+ for pin in pins:
163
+ expected = entries.get(pin.key)
164
+ if expected is None:
165
+ return ProjectReviewFailure(pin.key, f"unknown workspace repository key {pin.key!r}")
166
+ try:
167
+ pin_identity = _canonical_repo_identity(pin.repo, allow_local=allow_local)
168
+ except Exception as exc:
169
+ return ProjectReviewFailure(pin.key, f"invalid pin repository identity: {exc}")
170
+ if pin_identity != expected:
171
+ return ProjectReviewFailure(
172
+ pin.key,
173
+ f"workspace repository identity mismatch: pin {pin_identity!r}, compiled {expected!r}",
174
+ )
175
+ source_branch = sources.get(pin.key)
176
+ if source_branch is None:
177
+ continue
178
+ source, _branch = source_branch
179
+ origin = review.gitops.remote_origin_url(source)
180
+ if not origin:
181
+ return ProjectReviewFailure(pin.key, f"selected source {source} has no origin for identity validation")
182
+ try:
183
+ source_identity = _canonical_repo_identity(origin, allow_local=allow_local)
184
+ except Exception as exc:
185
+ return ProjectReviewFailure(pin.key, f"invalid selected source origin: {exc}")
186
+ if source_identity != pin_identity:
187
+ return ProjectReviewFailure(
188
+ pin.key,
189
+ f"selected source identity mismatch: source {source_identity!r}, pin {pin_identity!r}",
190
+ )
191
+ return None
192
+
193
+
194
+ def _load_workspace_boundary_doc(workspace: Path) -> tuple[dict[str, object] | None, ProjectReviewFailure | None]:
195
+ """Keep absent and malformed authority in the review outcome channel."""
196
+ try:
197
+ return spec_apply.load_workspace_spec_doc(workspace), None
198
+ except (SystemExit, tomllib.TOMLDecodeError) as exc:
199
+ return None, ProjectReviewFailure("workspace_spec", str(exc))
200
+
201
+
202
+ def _stamp_lane_kind(workspace: Path, owner_unit: str, lane_name: str, kind: str) -> None:
203
+ """Rewrite the lane's ``lane_kind`` in place. create_lane writes the default
204
+ ``materialized``; a review lane overrides it so require_current_lane reports the
205
+ review kind to every mutating verb."""
206
+ path = lanes.lane_file(workspace, owner_unit, lane_name)
207
+ text = path.read_text()
208
+ new = re.sub(r'(?m)^lane_kind\s*=\s*"[^"]*"\s*$', f'lane_kind = "{kind}"', text)
209
+ if 'lane_kind' not in new:
210
+ new = new.rstrip("\n") + f'\nlane_kind = "{kind}"\n'
211
+ path.write_text(new)
212
+
213
+
214
+ def open_project_review(*, workspace: Path, owner_unit: str, lane_name: str, spec: ProjectReviewSpec, sources: dict[str, tuple[Path, str]], allow_local: bool = False, ephemeral: bool = False, materialize_heads: dict[str, str] | None = None) -> ProjectReviewOutcome:
215
+ """Preflight every immutable pin, then materialize all members before enter.
216
+
217
+ ``ephemeral`` materializes each member as a blobless+sparse review-ephemeral
218
+ lane from the persistent mirror (``sources[key]`` is the mirror), never through
219
+ the work-lane clone seam.
220
+
221
+ ``materialize_heads`` (key -> sha) OVERRIDES which head each member is
222
+ materialized at, WITHOUT changing what the gr commit is validated against. It is
223
+ the carried-range reconstruction case: the commit pins the original (pre-push)
224
+ head, but ``git am`` re-stamps the committer so the reconstructed head is a
225
+ DIFFERENT sha with the SAME tree; the commit-binding check stays on the pinned
226
+ head while materialization uses the reconstructed head that actually exists in
227
+ the topped-up mirror. Absent a key, the pinned head is used (the ordinary case)."""
228
+ materialize_heads = materialize_heads or {}
229
+ if spec.schema != "gr2-project-review/v1":
230
+ return ProjectReviewOutcome("refused", spec.grip_commit, (), (ProjectReviewFailure("spec", "unsupported schema"),), None, False)
231
+ try:
232
+ owner_unit = _review_path_component(owner_unit, "owner unit")
233
+ lane_name = _review_path_component(lane_name, "lane name")
234
+ except ValueError as exc:
235
+ return ProjectReviewOutcome("refused", spec.grip_commit, (), (ProjectReviewFailure("review_root", str(exc)),), None, False)
236
+ # A cross-repo project review requires a MATERIALIZED lane whose rows
237
+ # reconstruct together from carried ranges. A bound lane is a single-repo
238
+ # label on the author's own worktree — its bytes live in a tree the author
239
+ # keeps editing, so it cannot give the exact-reconstruction guarantee across
240
+ # rows. Refuse it here with a specific message rather than letting the later
241
+ # create_lane emit a generic "refusing to replace" (gr2-lane-author-shape
242
+ # ruling: bound lanes are single-repo `pr` only).
243
+ lane_path = lanes.lane_file(workspace, owner_unit, lane_name)
244
+ if lane_path.exists():
245
+ try:
246
+ existing_kind = tomllib.loads(lane_path.read_text()).get("lane_kind")
247
+ except (OSError, tomllib.TOMLDecodeError):
248
+ existing_kind = None
249
+ if existing_kind == "bound":
250
+ return ProjectReviewOutcome(
251
+ "refused", spec.grip_commit, (),
252
+ (ProjectReviewFailure(
253
+ lane_name,
254
+ f"lane {owner_unit}/{lane_name} is a BOUND single-repo lane; a project review "
255
+ "requires a materialized lane whose rows reconstruct together. Use `gr2 pr` for "
256
+ "this bound lane's single-repo review, or open the project review on a "
257
+ "materialized lane.",
258
+ ),),
259
+ None, False,
260
+ )
261
+ try:
262
+ canonical_pins = tuple(_canonical_pin(pin) for pin in spec.pins)
263
+ except ValueError as exc:
264
+ return ProjectReviewOutcome("refused", spec.grip_commit, (), (ProjectReviewFailure("spec", str(exc)),), None, False)
265
+ decoded = grip.read_project_review_commit(workspace, spec.grip_commit)
266
+ if len(decoded) != len(canonical_pins):
267
+ return ProjectReviewOutcome("refused", spec.grip_commit, (), (ProjectReviewFailure("spec", "gr commit member count mismatch"),), None, False)
268
+ for row, pin in zip(decoded, canonical_pins):
269
+ for field, expected, observed in (("key", pin.key, row["key"]), ("repo", pin.repo, row["repo"]), ("path", pin.path, _normalized_path(row["path"])), ("base", pin.base, row["base"]), ("head", pin.head, row["head"])):
270
+ if expected != observed:
271
+ return ProjectReviewOutcome("refused", spec.grip_commit, (), (ProjectReviewFailure(pin.key, f"gr commit mismatch for {field}: expected {expected!r}, observed {observed!r}"),), None, False)
272
+ boundary_failure = _validate_workspace_repository_boundary(
273
+ workspace=workspace, pins=canonical_pins, sources=sources, allow_local=allow_local
274
+ )
275
+ if boundary_failure is not None:
276
+ return ProjectReviewOutcome("refused", spec.grip_commit, (), (boundary_failure,), None, False)
277
+ for pin in canonical_pins:
278
+ source_branch = sources.get(pin.key)
279
+ if source_branch is None:
280
+ return ProjectReviewOutcome("refused", spec.grip_commit, (), (ProjectReviewFailure(pin.key, "missing source transport"),), None, False)
281
+ source, _branch = source_branch
282
+ materialize_head = materialize_heads.get(pin.key, pin.head)
283
+ for label, sha in (("base", pin.base), ("head", materialize_head)):
284
+ if git(source, "cat-file", "-e", f"{sha}^{{commit}}").returncode != 0:
285
+ return ProjectReviewOutcome("refused", spec.grip_commit, (), (ProjectReviewFailure(pin.key, f"missing {label} pin {sha}"),), None, False)
286
+ review_root = workspace / "reviews" / owner_unit / lane_name
287
+ observed: list[review.ReviewRecord] = []
288
+ for pin in canonical_pins:
289
+ source, branch = sources[pin.key]
290
+ repo_name = Path(source).name.removesuffix(".git")
291
+ materialize_head = materialize_heads.get(pin.key, pin.head)
292
+ try:
293
+ record = review.open_review_lane(source_repo_root=source, review_branch=branch, expected_head_sha=materialize_head, base_sha=pin.base, lane_repo_root=review_root / "repos" / pin.key, workspace_root=workspace, allow_local=allow_local, ephemeral=ephemeral, repo_name=repo_name, echo=lambda _line: None)
294
+ except Exception as exc:
295
+ return ProjectReviewOutcome("partial", spec.grip_commit, tuple(observed), (ProjectReviewFailure(pin.key, str(exc)),), review_root, False)
296
+ observed.append(record)
297
+ try:
298
+ lanes.create_lane(argparse.Namespace(workspace_root=workspace, owner_unit=owner_unit, lane_name=lane_name, type="review", repos=",".join(pin.key for pin in canonical_pins), branch="main", source="project-review", default_commands=[]))
299
+ if ephemeral:
300
+ # Stamp the lane kind so every mutating verb (commit/push/bind) can
301
+ # refuse this lane naming the kind: a review lane never becomes a work lane.
302
+ _stamp_lane_kind(workspace, owner_unit, lane_name, "review-ephemeral")
303
+ lanes.enter_lane(argparse.Namespace(workspace_root=workspace, owner_unit=owner_unit, lane_name=lane_name, actor="project-review", notify_channel=False, recall=False))
304
+ except Exception as exc:
305
+ return ProjectReviewOutcome("partial", spec.grip_commit, tuple(observed), (ProjectReviewFailure("transition", str(exc)),), review_root, False)
306
+ return ProjectReviewOutcome("opened", spec.grip_commit, tuple(observed), (), review_root, True)
307
+
308
+
309
+ def outcome_payload(outcome: ProjectReviewOutcome) -> dict[str, object]:
310
+ return {"status": outcome.status, "grip_commit": outcome.grip_commit,
311
+ "observed": [record.to_dict() for record in outcome.observed],
312
+ "failures": [dataclasses.asdict(failure) for failure in outcome.failures],
313
+ "review_root": str(outcome.review_root) if outcome.review_root else None,
314
+ "current_lane_changed": outcome.current_lane_changed}
@@ -0,0 +1,365 @@
1
+ """Native `prune` command for gr2.
2
+
3
+ gr2 registers 44 verbs and `prune` was absent from all of them (measured
4
+ 2026-08-30): gr1 was the only surface with a branch-prune, so every lane gr2
5
+ opens left a branch that only gr1 or a hand could remove. This is that verb.
6
+
7
+ It is deliberately MORE correct than gr1's prune. gr1's `is_branch_merged`
8
+ (src/git/branch.rs) uses `git branch --merged <target>`, which is CONTAINMENT
9
+ alone -- it reports a branch merged only when its tip is an ancestor of the
10
+ target. In our squash- and rebase-heavy history that misses most landed work
11
+ (measured on Apollo's own desk 2026-09-06: feat/gr2-workspace-spec-regeneration
12
+ and feat/pr-create-base each read NOT contained but ARE present in origin/dev by
13
+ patch-id). Per claude.md's standing rule -- "containment answers 'is this commit
14
+ OBJECT present'; nobody ever wants that answer" -- merged here is decided by
15
+ PATCH-ID (`git cherry`, patch-id equivalence, catches plain/ff/rebase/cherry-pick
16
+ merges) OR by the SQUASH TREE check (the branch's aggregate diff matching a single
17
+ target commit's patch-id), never by containment alone.
18
+
19
+ Single-repo by convention, like gr2's other top-level verbs (cwd or
20
+ --repo-path). Dry-run by default; deletes only with --execute; never touches a
21
+ remote ref; never deletes the current branch, the target, or `main`/`dev`.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import subprocess
27
+
28
+ from dataclasses import dataclass
29
+
30
+ from pathlib import Path
31
+
32
+ from .gitops import git
33
+
34
+
35
+ class PruneError(Exception):
36
+ pass
37
+
38
+
39
+ # Never delete these, by name, in addition to the current branch and the target.
40
+ _NAMED_PROTECTED = ("main", "dev")
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class MergedBranch:
45
+ """A local branch whose work is already in the target, with why."""
46
+
47
+ name: str
48
+ reason: str # "patch-id" | "squash"
49
+ detail: str
50
+
51
+
52
+ @dataclass
53
+ class PruneReport:
54
+ target: str
55
+ target_source: str # how the target was resolved, for the printout
56
+ merged: list[MergedBranch]
57
+ protected_skipped: list[str]
58
+ deleted: list[str]
59
+ failed: list[tuple[str, str]] # (branch, error)
60
+ executed: bool
61
+
62
+
63
+ def _short(ref: str) -> str:
64
+ """The short branch name for a possibly-qualified ref (origin/dev -> dev)."""
65
+ return ref.rsplit("/", 1)[-1]
66
+
67
+
68
+ def _current_branch(repo: Path) -> str | None:
69
+ proc = git(repo, "branch", "--show-current")
70
+ if proc.returncode != 0:
71
+ raise PruneError(f"cannot read current branch in {repo}: {proc.stderr.strip()}")
72
+ name = proc.stdout.strip()
73
+ return name or None # empty == detached HEAD
74
+
75
+
76
+ def _local_branches(repo: Path) -> list[str]:
77
+ proc = git(repo, "for-each-ref", "--format=%(refname:short)", "refs/heads/")
78
+ if proc.returncode != 0:
79
+ raise PruneError(f"cannot list local branches in {repo}: {proc.stderr.strip()}")
80
+ return [line.strip() for line in proc.stdout.splitlines() if line.strip()]
81
+
82
+
83
+ def _ref_exists(repo: Path, ref: str) -> bool:
84
+ return git(repo, "rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}").returncode == 0
85
+
86
+
87
+ def resolve_target(
88
+ repo: Path,
89
+ target: str | None,
90
+ remote: str,
91
+ stored_target: str | None = None,
92
+ ) -> tuple[str, str]:
93
+ """Resolve the ref merged-ness is measured against.
94
+
95
+ Order: explicit ``--target``, then the gripspace's stored ``[settings].target``
96
+ (``stored_target``), then the integration branch ``<remote>/dev``, then the
97
+ remote's own default branch (``refs/remotes/<remote>/HEAD``), then
98
+ ``<remote>/main``. Returns ``(target_ref, how_it_was_resolved)``.
99
+
100
+ The stored target is the branch work in THIS gripspace actually merges into
101
+ (an epic branch, say, where ``dev`` is not the base). It is READ here, never
102
+ written -- ``gr target set`` is the only writer. A bare name (``epic/x``) is
103
+ mapped to ``<remote>/<name>`` because merged-ness is measured against a
104
+ remote-tracking ref. A stored target whose remote ref is ABSENT (a stale or
105
+ typo'd setting) is SKIPPED, not fatal: resolution falls through to ``dev`` and
106
+ the returned source names the skipped stored value, so a stranger reading the
107
+ report sees why ``dev`` was chosen. Skipping (rather than raising) keeps
108
+ deletion on the safe side -- a rotted setting never leaves branches
109
+ under-protected.
110
+
111
+ ``dev`` is tried BEFORE ``origin/HEAD`` on purpose: our integration branch is
112
+ ``dev``, but ``origin/HEAD`` points at ``main`` on a fresh clone, so resolving
113
+ HEAD first would silently measure prune against ``main`` and report dev-merged
114
+ branches as unmerged. An explicit ``--target`` that does not exist still
115
+ raises, because a wrong operator-named target makes every verdict wrong.
116
+ """
117
+ if target is not None:
118
+ if not _ref_exists(repo, target):
119
+ raise PruneError(f"target ref '{target}' does not exist in {repo}")
120
+ return target, "explicit --target"
121
+
122
+ stored_note: str | None = None
123
+ if stored_target:
124
+ stored_ref = (
125
+ stored_target
126
+ if stored_target.startswith(f"{remote}/")
127
+ else f"{remote}/{stored_target}"
128
+ )
129
+ if _ref_exists(repo, stored_ref):
130
+ return stored_ref, f"stored target ({stored_ref})"
131
+ stored_note = f"stored target '{stored_target}' skipped ({stored_ref} not found)"
132
+
133
+ def _with_note(source: str) -> str:
134
+ return f"{source}; {stored_note}" if stored_note else source
135
+
136
+ dev = f"{remote}/dev"
137
+ if _ref_exists(repo, dev):
138
+ return dev, _with_note(f"integration branch ({dev})")
139
+ head = git(repo, "symbolic-ref", "--quiet", f"refs/remotes/{remote}/HEAD")
140
+ if head.returncode == 0 and head.stdout.strip():
141
+ ref = _short_remote(head.stdout.strip(), remote)
142
+ if _ref_exists(repo, ref):
143
+ return ref, _with_note(f"{remote}/HEAD")
144
+ main = f"{remote}/main"
145
+ if _ref_exists(repo, main):
146
+ return main, _with_note(f"fallback ({main})")
147
+ msg = (
148
+ f"could not resolve a target in {repo}: no {remote}/dev, {remote}/HEAD, or "
149
+ f"{remote}/main. Pass --target explicitly."
150
+ )
151
+ if stored_note:
152
+ msg += f" ({stored_note})"
153
+ raise PruneError(msg)
154
+
155
+
156
+ def _short_remote(symref: str, remote: str) -> str:
157
+ # refs/remotes/origin/dev -> origin/dev
158
+ prefix = "refs/remotes/"
159
+ return symref[len(prefix):] if symref.startswith(prefix) else symref
160
+
161
+
162
+ def _patch_id(repo: Path, *diff_args: str) -> str | None:
163
+ """Stable patch-id of `git diff <diff_args>`; None if the diff is empty."""
164
+ diff = git(repo, "diff", *diff_args)
165
+ if diff.returncode != 0:
166
+ raise PruneError(f"git diff {' '.join(diff_args)} failed in {repo}: {diff.stderr.strip()}")
167
+ if not diff.stdout.strip():
168
+ return None
169
+ pid = subprocess.run(
170
+ ["git", "patch-id", "--stable"],
171
+ cwd=repo,
172
+ input=diff.stdout,
173
+ capture_output=True,
174
+ text=True,
175
+ check=False,
176
+ )
177
+ if pid.returncode != 0 or not pid.stdout.strip():
178
+ return None
179
+ return pid.stdout.split()[0]
180
+
181
+
182
+ def _cherry_merged(repo: Path, target: str, branch: str) -> tuple[bool, int]:
183
+ """Patch-id equivalence via `git cherry`: no '+' line means every commit
184
+ unique to `branch` is already present in `target` by patch-id (covers
185
+ plain/fast-forward/rebase/cherry-pick merges, and the empty-ahead case)."""
186
+ proc = git(repo, "cherry", target, branch)
187
+ if proc.returncode != 0:
188
+ raise PruneError(
189
+ f"git cherry {target} {branch} failed in {repo}: {proc.stderr.strip()}"
190
+ )
191
+ plus = [ln for ln in proc.stdout.splitlines() if ln.startswith("+")]
192
+ return (len(plus) == 0, len(plus))
193
+
194
+
195
+ # How many of target's first-parent commits (newest first) to scan for a squash
196
+ # match before giving up. A squash-merge lands as ONE commit on the target's
197
+ # first-parent mainline, so the scan is bounded to that line (not every commit
198
+ # ever merged in). The cap is a safety valve against a pathologically old
199
+ # merge-base: hitting it returns "not squash-merged" (the SAFE direction for a
200
+ # deletion -- we never delete on an inconclusive check), and the caller can pass
201
+ # --target closer if a branch diverged very long ago.
202
+ _SQUASH_SCAN_CAP = 400
203
+
204
+
205
+ def _target_squash_patch_ids(repo: Path, target: str) -> dict[str, str]:
206
+ """Map {commit patch-id -> sha} for target's newest first-parent commits.
207
+
208
+ Built ONCE per prune and shared across every branch, so squash detection is
209
+ O(cap) here plus O(1) per branch, not O(branches x cap). Squash-merge commits
210
+ land on the target's first-parent mainline, so that is the only line worth
211
+ scanning; the cap bounds a pathologically deep history and its miss is the
212
+ SAFE direction for a deletion (a branch squashed older than the window reads
213
+ as not-merged and is left alone)."""
214
+ rev = git(
215
+ repo,
216
+ "rev-list",
217
+ "--first-parent",
218
+ f"--max-count={_SQUASH_SCAN_CAP}",
219
+ target,
220
+ )
221
+ if rev.returncode != 0:
222
+ raise PruneError(
223
+ f"git rev-list --first-parent {target} failed in {repo}: {rev.stderr.strip()}"
224
+ )
225
+ ids: dict[str, str] = {}
226
+ for sha in (ln.strip() for ln in rev.stdout.splitlines() if ln.strip()):
227
+ # A root commit (no parent) or a shallow-clone boundary has no `<sha>^`;
228
+ # such a commit cannot be the squash of a feature branch, so skip it
229
+ # rather than error. This also keeps the verb usable inside a shallow
230
+ # review clone, where the oldest commits are grafted.
231
+ if not _ref_exists(repo, f"{sha}^"):
232
+ continue
233
+ pid = _patch_id(repo, f"{sha}^", sha)
234
+ if pid is not None:
235
+ ids.setdefault(pid, sha)
236
+ return ids
237
+
238
+
239
+ def _squash_merged(
240
+ repo: Path, target: str, branch: str, target_ids: dict[str, str]
241
+ ) -> tuple[bool, str | None]:
242
+ """The branch's aggregate diff (merge-base..branch) has the same stable
243
+ patch-id as a single first-parent commit on `target` -- the squash case
244
+ `git cherry` cannot see, because a squash gives the combined change a new
245
+ commit whose per-commit patch-ids never match the branch's originals."""
246
+ mb = git(repo, "merge-base", target, branch)
247
+ if mb.returncode != 0 or not mb.stdout.strip():
248
+ return (False, None)
249
+ base = mb.stdout.strip()
250
+ aggregate = _patch_id(repo, base, branch)
251
+ if aggregate is None:
252
+ # No net contribution over the merge-base (empty-ahead branch is already
253
+ # handled by _cherry_merged).
254
+ return (False, None)
255
+ sha = target_ids.get(aggregate)
256
+ return (sha is not None, sha)
257
+
258
+
259
+ def list_merged_branches(repo: Path, target: str) -> tuple[list[MergedBranch], list[str]]:
260
+ """Return (merged, protected_skipped) for `repo` measured against `target`.
261
+
262
+ Merged is decided by patch-id first, then the squash tree check -- never by
263
+ containment alone. Protected branches (current, the target's own short name,
264
+ `main`, `dev`) are never candidates and are returned so the caller can show
265
+ what was skipped and why.
266
+ """
267
+ repo = Path(repo).resolve()
268
+ protected = set(_NAMED_PROTECTED)
269
+ protected.add(_short(target))
270
+ current = _current_branch(repo)
271
+ if current is not None:
272
+ protected.add(current)
273
+
274
+ local = _local_branches(repo)
275
+ candidates = [b for b in local if b not in protected]
276
+ protected_skipped = [b for b in local if b in protected]
277
+ # Build target's first-parent squash-map ONCE, and only if a candidate that
278
+ # patch-id did not already resolve needs it -- most prunes never do.
279
+ target_ids: dict[str, str] | None = None
280
+
281
+ merged: list[MergedBranch] = []
282
+ for branch in candidates:
283
+ is_cherry, plus = _cherry_merged(repo, target, branch)
284
+ if is_cherry:
285
+ merged.append(
286
+ MergedBranch(branch, "patch-id", f"all commits present in {target} by patch-id (git cherry)")
287
+ )
288
+ continue
289
+ if target_ids is None:
290
+ target_ids = _target_squash_patch_ids(repo, target)
291
+ is_squash, sha = _squash_merged(repo, target, branch, target_ids)
292
+ if is_squash:
293
+ merged.append(
294
+ MergedBranch(
295
+ branch,
296
+ "squash",
297
+ f"aggregate diff matches {target} commit {sha[:12]} by patch-id (squash-merged)",
298
+ )
299
+ )
300
+ return merged, protected_skipped
301
+
302
+
303
+ def _delete_local_branch(repo: Path, branch: str) -> None:
304
+ # -D, not -d: we have already established the work is in the target by
305
+ # patch-id/tree, and git's own -d uses the same containment test we reject.
306
+ proc = git(repo, "branch", "-D", branch)
307
+ if proc.returncode != 0:
308
+ raise PruneError(proc.stderr.strip() or f"failed to delete {branch}")
309
+
310
+
311
+ def prune(
312
+ repo: Path,
313
+ *,
314
+ target: str | None = None,
315
+ remote: str = "origin",
316
+ stored_target: str | None = None,
317
+ execute: bool = False,
318
+ ) -> PruneReport:
319
+ """List (and, with execute=True, delete) merged local branches in one repo."""
320
+ repo = Path(repo).resolve()
321
+ target_ref, source = resolve_target(repo, target, remote, stored_target)
322
+ merged, protected_skipped = list_merged_branches(repo, target_ref)
323
+
324
+ deleted: list[str] = []
325
+ failed: list[tuple[str, str]] = []
326
+ if execute:
327
+ for mb in merged:
328
+ try:
329
+ _delete_local_branch(repo, mb.name)
330
+ deleted.append(mb.name)
331
+ except PruneError as exc:
332
+ failed.append((mb.name, str(exc)))
333
+
334
+ return PruneReport(
335
+ target=target_ref,
336
+ target_source=source,
337
+ merged=merged,
338
+ protected_skipped=protected_skipped,
339
+ deleted=deleted,
340
+ failed=failed,
341
+ executed=execute,
342
+ )
343
+
344
+
345
+ def render_report(report: PruneReport) -> str:
346
+ lines = [f"Target: {report.target} ({report.target_source})"]
347
+ if not report.merged:
348
+ lines.append("No merged branches to prune.")
349
+ return "\n".join(lines)
350
+ verb = "Deleted" if report.executed else "Would delete"
351
+ for mb in report.merged:
352
+ marker = " "
353
+ if report.executed:
354
+ if mb.name in report.deleted:
355
+ marker = " ✓ "
356
+ elif any(mb.name == f for f, _ in report.failed):
357
+ marker = " ✗ "
358
+ lines.append(f"{marker}{verb}: {mb.name} [{mb.reason}] {mb.detail}")
359
+ for name, err in report.failed:
360
+ lines.append(f" FAILED to delete {name}: {err}")
361
+ if not report.executed:
362
+ lines.append(f"\n{len(report.merged)} merged branch(es). Run with --execute to delete them.")
363
+ else:
364
+ lines.append(f"\nDeleted {len(report.deleted)} of {len(report.merged)} merged branch(es).")
365
+ return "\n".join(lines)