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.
- gitgrip-1.5.0.dist-info/METADATA +13 -0
- gitgrip-1.5.0.dist-info/RECORD +80 -0
- gitgrip-1.5.0.dist-info/WHEEL +5 -0
- gitgrip-1.5.0.dist-info/entry_points.txt +2 -0
- gitgrip-1.5.0.dist-info/top_level.txt +2 -0
- gr2/__init__.py +0 -0
- gr2/overlay/__init__.py +6 -0
- gr2/overlay/activate.py +196 -0
- gr2/overlay/agent_manifest.py +138 -0
- gr2/overlay/cli.py +181 -0
- gr2/overlay/cross_repo.py +124 -0
- gr2/overlay/drivers.py +113 -0
- gr2/overlay/introspection.py +155 -0
- gr2/overlay/language_drivers.py +115 -0
- gr2/overlay/objects.py +412 -0
- gr2/overlay/perf.py +251 -0
- gr2/overlay/refs.py +36 -0
- gr2/overlay/trust.py +150 -0
- gr2/overlay/types.py +69 -0
- gr2/overlay/units.py +313 -0
- gr2/overlay/workspace_spec.py +59 -0
- gr2/prototypes/__init__.py +0 -0
- gr2/prototypes/cache_materialization_probe.py +190 -0
- gr2/prototypes/concurrent_event_stress.py +199 -0
- gr2/prototypes/concurrent_lease_stress.py +240 -0
- gr2/prototypes/concurrent_workspace_cap_stress.py +231 -0
- gr2/prototypes/contribution_protocol.py +665 -0
- gr2/prototypes/cross_mode_lane_stress.py +986 -0
- gr2/prototypes/jsonl_store.py +158 -0
- gr2/prototypes/lane_workspace_prototype.py +2088 -0
- gr2/prototypes/layout_model_probe.py +139 -0
- gr2/prototypes/propagation_daemon.py +546 -0
- gr2/prototypes/propagation_state_machine.py +1478 -0
- gr2/prototypes/python_exec_playground.py +194 -0
- gr2/prototypes/python_hook_runtime_playground.py +240 -0
- gr2/prototypes/python_migration_playground.py +144 -0
- gr2/prototypes/python_review_checkout_playground.py +242 -0
- gr2/prototypes/python_spec_apply_playground.py +282 -0
- gr2/prototypes/real_git_lane_materialization.py +248 -0
- gr2/prototypes/real_git_playground.py +334 -0
- gr2/prototypes/recall_lane_history.py +274 -0
- gr2/prototypes/repo_maintenance_prototype.py +659 -0
- gr2/prototypes/repo_transport_probe.py +147 -0
- gr2/python_cli/__init__.py +2 -0
- gr2/python_cli/__main__.py +6 -0
- gr2/python_cli/add.py +51 -0
- gr2/python_cli/app.py +2516 -0
- gr2/python_cli/branch.py +67 -0
- gr2/python_cli/channel_bridge.py +131 -0
- gr2/python_cli/clone_exec.py +1019 -0
- gr2/python_cli/commit.py +199 -0
- gr2/python_cli/config.py +291 -0
- gr2/python_cli/env_exec.py +419 -0
- gr2/python_cli/events.py +529 -0
- gr2/python_cli/execops.py +372 -0
- gr2/python_cli/failures.py +98 -0
- gr2/python_cli/file_exec.py +256 -0
- gr2/python_cli/gitops.py +226 -0
- gr2/python_cli/grip.py +1337 -0
- gr2/python_cli/grip_cli.py +493 -0
- gr2/python_cli/hooks.py +450 -0
- gr2/python_cli/launch_exec.py +786 -0
- gr2/python_cli/merge_verification.py +274 -0
- gr2/python_cli/migration.py +985 -0
- gr2/python_cli/open_gr_review.py +699 -0
- gr2/python_cli/platform.py +441 -0
- gr2/python_cli/pr.py +487 -0
- gr2/python_cli/project_review.py +314 -0
- gr2/python_cli/prune.py +365 -0
- gr2/python_cli/push.py +172 -0
- gr2/python_cli/review.py +462 -0
- gr2/python_cli/review_ephemeral.py +143 -0
- gr2/python_cli/review_run.py +621 -0
- gr2/python_cli/spec_apply.py +1285 -0
- gr2/python_cli/staging_cleanup.py +205 -0
- gr2/python_cli/syncops.py +920 -0
- gr2/python_cli/target.py +100 -0
- gr2/python_cli/workspace_snapshot.py +105 -0
- gr2/schemas/gr2-materialization-plan-v1.schema.json +191 -0
- 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}
|
gr2/python_cli/prune.py
ADDED
|
@@ -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)
|