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
gr2/python_cli/grip.py ADDED
@@ -0,0 +1,1337 @@
1
+ """Grip object model: git-native multi-repo workspace snapshots.
2
+
3
+ Uses git plumbing (hash-object, mktree, commit-tree, update-ref) to store
4
+ workspace state as content-addressable objects in a dedicated .grip/ repo.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import subprocess
9
+ from dataclasses import dataclass, field
10
+ from pathlib import Path
11
+ import re
12
+
13
+ from .gitops import git
14
+
15
+
16
+ # ---------------------------------------------------------------------------
17
+ # Exceptions
18
+ # ---------------------------------------------------------------------------
19
+
20
+
21
+ class GripInitError(Exception):
22
+ """Raised when .grip/ repo is missing or not properly initialized."""
23
+
24
+
25
+ class GripCorruptError(Exception):
26
+ """Raised when .grip/ repo state is corrupt (bad HEAD, missing objects)."""
27
+
28
+
29
+ # ---------------------------------------------------------------------------
30
+ # Data types
31
+ # ---------------------------------------------------------------------------
32
+
33
+
34
+ @dataclass
35
+ class GripCommitInfo:
36
+ sha: str
37
+ message: str
38
+ repos: list[str]
39
+ timestamp: str = ""
40
+
41
+
42
+ @dataclass
43
+ class GripDiff:
44
+ changed: dict[str, dict[str, str]] = field(default_factory=dict)
45
+ added: list[str] = field(default_factory=list)
46
+ removed: list[str] = field(default_factory=list)
47
+
48
+
49
+ _PROJECT_REVIEW_SCHEMA = "gr2-project-review/v1"
50
+ _SHA40 = re.compile(r"\A[0-9a-f]{40}\Z")
51
+
52
+
53
+ class _RangeApplyError(Exception):
54
+ """A carried range failed to apply, or its committer metadata did not match the
55
+ commits it describes. Callers translate this into their own refusal type."""
56
+
57
+ def __init__(self, op: str, detail: str):
58
+ self.op = op
59
+ self.detail = detail
60
+ super().__init__(f"{op}: {detail}")
61
+
62
+
63
+ def _apply_range_in_lane(lane: Path, range_patch: str, committers: str | None) -> None:
64
+ """Apply a carried range in ``lane`` (already detached on the recorded base).
65
+
66
+ ``committers`` None -> a plain ``git am``: the reconstructed commits are
67
+ TREE-faithful but the committer identity and date are re-stamped, so the head
68
+ SHA differs from the pre-push head (row 1's contract). Otherwise ``committers``
69
+ is a TSV, one line per commit in apply order (oldest first, the order
70
+ ``format-patch`` and ``mailsplit`` both use): ``name<TAB>email<TAB>ISO-date``.
71
+ The range is mailsplit and each commit applied under its recorded committer
72
+ identity AND date (author identity/date already ride in the patch), so the
73
+ reconstructed head SHA equals the original pre-push head, not merely its tree
74
+ (row 2's contract). A row count that does not match the commit count is a
75
+ refusal -- the committer metadata must describe exactly the commits in the range.
76
+ """
77
+ import os
78
+ import tempfile
79
+
80
+ def _run(*args: str, env: dict | None = None) -> subprocess.CompletedProcess[str]:
81
+ return subprocess.run(
82
+ ["git", "-c", "user.name=grip-review", "-c", "user.email=review@grip",
83
+ "-C", str(lane), *args],
84
+ capture_output=True, text=True, check=False, env=env,
85
+ )
86
+
87
+ with tempfile.TemporaryDirectory() as td:
88
+ mbox = Path(td) / "range.patch"
89
+ mbox.write_text(range_patch)
90
+ if committers is None:
91
+ p = _run("am", str(mbox))
92
+ if p.returncode != 0:
93
+ raise _RangeApplyError("am", p.stderr.strip()[:160])
94
+ return
95
+ rows = [ln for ln in committers.splitlines() if ln.strip()]
96
+ split_dir = Path(td) / "split"
97
+ split_dir.mkdir()
98
+ s = _run("mailsplit", f"-o{split_dir}", str(mbox))
99
+ if s.returncode != 0:
100
+ raise _RangeApplyError("mailsplit", s.stderr.strip()[:160])
101
+ patches = sorted(split_dir.glob("[0-9]*"))
102
+ if len(patches) != len(rows):
103
+ raise _RangeApplyError(
104
+ "committer_count_mismatch",
105
+ f"{len(rows)} committer row(s) for {len(patches)} commit(s)")
106
+ for patch, row in zip(patches, rows):
107
+ parts = row.split("\t")
108
+ if len(parts) != 3 or not all(parts):
109
+ raise _RangeApplyError("committer_row_malformed", row[:80])
110
+ cn, ce, cd = parts
111
+ env = {**os.environ, "GIT_COMMITTER_NAME": cn,
112
+ "GIT_COMMITTER_EMAIL": ce, "GIT_COMMITTER_DATE": cd}
113
+ p = _run("am", str(patch), env=env)
114
+ if p.returncode != 0:
115
+ raise _RangeApplyError("am", p.stderr.strip()[:160])
116
+
117
+
118
+ def _carry_objects_from_range(workspace: Path, remote: str, base: str, range_patch: str,
119
+ committers: str | None = None, expected_head: str | None = None) -> dict[str, str]:
120
+ """Derive the carried objects for a project-review pin from a frozen RANGE.
121
+
122
+ The producer of a gate review holds the range.patch (the frozen artifact), not a
123
+ clone of the pre-push head. To record the head-tree the reconstruction will be
124
+ asserted against, apply the range over the base in a throwaway clone: clone the
125
+ recorded remote, check out the base, apply the range, and read the resulting tree
126
+ and fuller metadata. The range is the source of truth (it is what was frozen,
127
+ gated, and leak-scanned).
128
+
129
+ Without ``committers`` the applied commits are TREE-faithful only (the committer is
130
+ re-stamped, so the derived sha differs from the pinned head). With ``committers``
131
+ (a TSV, one committer row per commit) the reconstruction is SHA-faithful and the
132
+ derived head is asserted equal to ``expected_head`` at create time -- so committer
133
+ metadata that does not describe these commits is refused HERE, not baked into the
134
+ commit to surface only when a reviewer opens it."""
135
+ import tempfile
136
+ if not range_patch.strip():
137
+ raise GripCorruptError("project review range is empty")
138
+ with tempfile.TemporaryDirectory() as td:
139
+ lane = Path(td) / "recon"
140
+ clone = subprocess.run(["git", "clone", "--quiet", remote, str(lane)],
141
+ capture_output=True, text=True, check=False)
142
+ if clone.returncode != 0:
143
+ raise GripCorruptError(f"cannot clone {remote} to derive review head-tree: {clone.stderr.strip()[:160]}")
144
+
145
+ def _lg(*a: str, allow_fail: bool = False) -> subprocess.CompletedProcess[str]:
146
+ p = subprocess.run(["git", "-c", "user.name=grip-review", "-c",
147
+ "user.email=review@grip", "-C", str(lane), *a],
148
+ capture_output=True, text=True, check=False)
149
+ if p.returncode != 0 and not allow_fail:
150
+ raise GripCorruptError(f"range reconstruction failed ({' '.join(a)}): {p.stderr.strip()[:160]}")
151
+ return p
152
+
153
+ if _lg("rev-parse", "--verify", f"{base}^{{commit}}", allow_fail=True).returncode != 0:
154
+ raise GripCorruptError(f"base {base} not reachable on {remote} to derive review head-tree")
155
+ _lg("checkout", "--detach", base)
156
+ try:
157
+ _apply_range_in_lane(lane, range_patch, committers)
158
+ except _RangeApplyError as exc:
159
+ raise GripCorruptError(f"range reconstruction failed ({exc.op}): {exc.detail}") from exc
160
+ head_sha = _lg("rev-parse", "HEAD").stdout.strip()
161
+ head_tree = _lg("rev-parse", "HEAD^{tree}").stdout.strip()
162
+ if committers is not None and expected_head is not None and head_sha != expected_head:
163
+ raise GripCorruptError(
164
+ f"committer-faithful reconstruction derived {head_sha}, not the pinned head "
165
+ f"{expected_head}: the carried committer metadata does not describe these commits")
166
+ metadata = _lg("log", "--format=fuller", f"{base}..HEAD").stdout
167
+ obj = {"range.patch": range_patch, "metadata": metadata, "head-tree": head_tree}
168
+ if committers is not None:
169
+ obj["committers"] = committers
170
+ return obj
171
+
172
+
173
+ def create_project_review_commit(
174
+ workspace: Path, pins: list[dict[str, str]], ranges: dict[str, str] | None = None,
175
+ committers: dict[str, str] | None = None,
176
+ ) -> str:
177
+ """Encode the minimal project-review gr tree through the sole object seam.
178
+
179
+ ``ranges`` (key -> range.patch text) makes the commit SELF-DESCRIBING for a
180
+ pre-push head: each keyed pin carries ``objects/<key>/{range.patch, metadata,
181
+ head-tree}`` (derived by applying the range over the base, see
182
+ ``_carry_objects_from_range``), so ``reconstruct_project_review_lane`` rebuilds
183
+ the head from the commit alone -- no hand ``git am``, no clone that holds the
184
+ head, no head on any remote. This ports the review-BIND carry-the-range model to
185
+ the project path.
186
+
187
+ ``committers`` (key -> TSV, one ``name<TAB>email<TAB>ISO-date`` row per commit in
188
+ apply order) upgrades reconstruction from TREE-faithful to SHA-faithful: with it,
189
+ each carried key ALSO stores ``objects/<key>/committers`` and the reconstruction
190
+ re-stamps each commit's committer identity+date so the rebuilt head SHA equals the
191
+ pinned pre-push head, not merely its tree (the committer-date-match contract). A
192
+ key present in ``committers`` must also be in ``ranges``; the derived head is
193
+ asserted equal to the pinned head at create time."""
194
+ _validate_grip_repo(workspace)
195
+ if not pins:
196
+ raise GripCorruptError("project review requires at least one repository pin")
197
+ ranges = ranges or {}
198
+ committers = committers or {}
199
+ entries: list[str] = []
200
+ objects_entries: list[str] = []
201
+ seen: set[str] = set()
202
+ for pin in sorted(pins, key=lambda item: item["key"]):
203
+ key = pin.get("key", "")
204
+ if not key or key in seen or any(ch in key for ch in "/\\"):
205
+ raise GripCorruptError(f"invalid or duplicate project review key: {key!r}")
206
+ seen.add(key)
207
+ fields: list[str] = []
208
+ for name, value in (("remote", pin.get("repo", "")), ("path", pin.get("path", "")), ("commit", pin.get("head", "")), ("base", pin.get("base", ""))):
209
+ if not value or (name in {"commit", "base"} and not _SHA40.match(value)):
210
+ raise GripCorruptError(f"invalid project review {name} for {key}")
211
+ fields.append(f"100644 blob {_hash_blob(workspace, value)}\t{name}")
212
+ entries.append(f"040000 tree {_mktree(workspace, fields)}\t{key}")
213
+ if key in ranges:
214
+ obj = _carry_objects_from_range(
215
+ workspace, pin.get("repo", ""), pin.get("base", ""), ranges[key],
216
+ committers=committers.get(key), expected_head=pin.get("head", ""))
217
+ names = ("range.patch", "metadata", "head-tree") + (("committers",) if "committers" in obj else ())
218
+ obj_fields = [f"100644 blob {_hash_blob(workspace, obj[n])}\t{n}" for n in names]
219
+ objects_entries.append(f"040000 tree {_mktree(workspace, obj_fields)}\t{key}")
220
+ unknown = set(ranges) - seen
221
+ if unknown:
222
+ raise GripCorruptError(f"ranges reference keys not in the pins: {sorted(unknown)}")
223
+ committer_only = set(committers) - set(ranges)
224
+ if committer_only:
225
+ raise GripCorruptError(
226
+ f"committer metadata for keys without a carried range: {sorted(committer_only)}")
227
+ repos_tree = _mktree(workspace, entries)
228
+ meta_tree = _mktree(workspace, [f"100644 blob {_hash_blob(workspace, _PROJECT_REVIEW_SCHEMA)}\tschema", f"100644 blob {_hash_blob(workspace, 'review')}\tkind"])
229
+ root_fields = [f"040000 tree {meta_tree}\t.grip", f"040000 tree {repos_tree}\trepos"]
230
+ if objects_entries:
231
+ root_fields.append(f"040000 tree {_mktree(workspace, objects_entries)}\tobjects")
232
+ root_tree = _mktree(workspace, root_fields)
233
+ commit = _commit_tree(workspace, root_tree, parent=_current_head(workspace), message="grip project review")
234
+ _grip_git(workspace, "update-ref", "HEAD", commit)
235
+ return commit
236
+
237
+
238
+ def project_review_carried_keys(workspace: Path, commit: str) -> set[str]:
239
+ """The project-review keys that carry a reconstruction range (an ``objects/<key>``
240
+ subtree). Empty when the commit carries no ranges (the remote-resolved case).
241
+ Guards the absent-objects case so a plain project-review commit is not an error."""
242
+ root = {
243
+ line.strip()
244
+ for line in _grip_git(workspace, "ls-tree", "--name-only", commit).stdout.splitlines()
245
+ if line.strip()
246
+ }
247
+ return _tree_keys(workspace, commit, "objects") if "objects" in root else set()
248
+
249
+
250
+ def reconstruct_project_review_lane(
251
+ workspace: Path, commit: str, key: str, lane_dir: Path
252
+ ) -> dict[str, str]:
253
+ """Reconstruct a project-review pin's head from its carried range (shape (b)).
254
+
255
+ Refuses anything but a project-review-KIND commit, then reuses the review-BIND
256
+ reconstruction (clone remote, check out base, ``git am`` the carried range, assert
257
+ the resulting TREE equals the recorded head-tree). The assertion is on the TREE,
258
+ never the sha: ``git am`` re-stamps the committer, so the reconstructed sha differs
259
+ from the pinned head until the committer-date-match lane; both are returned so that
260
+ lane has its before/after."""
261
+ actual = _grip_git(workspace, "show", f"{commit}:.grip/schema").stdout.strip()
262
+ if actual != _PROJECT_REVIEW_SCHEMA:
263
+ raise GripCorruptError(
264
+ f"not a gr2 project review commit: found {actual or '<none>'!r}, expected {_PROJECT_REVIEW_SCHEMA!r}"
265
+ )
266
+ return reconstruct_review_lane(workspace, commit, key, lane_dir)
267
+
268
+
269
+ def read_project_review_commit(workspace: Path, commit: str) -> list[dict[str, str]]:
270
+ """Strictly decode the minimal reviewed repository fields."""
271
+ actual_schema = _grip_git(workspace, "show", f"{commit}:.grip/schema").stdout.strip()
272
+ if actual_schema != _PROJECT_REVIEW_SCHEMA:
273
+ raise GripCorruptError(
274
+ f"not a gr2 project review commit: found kind {actual_schema or '<none>'!r}, "
275
+ f"expected {_PROJECT_REVIEW_SCHEMA!r} (a project-review-KIND commit; use `review open-gr` "
276
+ f"for a review-BIND commit)"
277
+ )
278
+ rows = _read_repo_state(workspace, commit)
279
+ decoded: list[dict[str, str]] = []
280
+ for key, fields in sorted(rows.items()):
281
+ if set(fields) != {"remote", "path", "commit", "base"} or not _SHA40.match(fields["commit"]) or not _SHA40.match(fields["base"]):
282
+ raise GripCorruptError(f"invalid project review repository tree: {key}")
283
+ decoded.append({"key": key, "repo": fields["remote"], "path": fields["path"], "head": fields["commit"], "base": fields["base"]})
284
+ return decoded
285
+
286
+
287
+ # ---------------------------------------------------------------------------
288
+ # kind=workspace: a lane's resolved repository state captured as one gr commit.
289
+ # Same section-5 shape as the review kind, different .grip/kind. Records the
290
+ # resolved head each repo checks out plus the base it builds on; the commit is
291
+ # the reproduction coordinate for the workspace at snapshot time.
292
+ # ---------------------------------------------------------------------------
293
+
294
+ _WORKSPACE_SCHEMA = "gr2-workspace/v1"
295
+
296
+
297
+ def create_workspace_commit(workspace: Path, repos: list[dict[str, str]]) -> str:
298
+ """Encode a kind=workspace gr commit through the sole object seam."""
299
+ _validate_grip_repo(workspace)
300
+ if not repos:
301
+ raise GripCorruptError("workspace commit requires at least one repository")
302
+ entries: list[str] = []
303
+ seen: set[str] = set()
304
+ for repo in sorted(repos, key=lambda item: item["key"]):
305
+ key = repo.get("key", "")
306
+ if not key or key in seen or any(ch in key for ch in "/\\"):
307
+ raise GripCorruptError(f"invalid or duplicate workspace repo key: {key!r}")
308
+ seen.add(key)
309
+ fields: list[str] = []
310
+ for name, value in (("remote", repo.get("remote", "")), ("path", repo.get("path", "")), ("commit", repo.get("commit", "")), ("base", repo.get("base", ""))):
311
+ if not value or (name in {"commit", "base"} and not _SHA40.match(value)):
312
+ raise GripCorruptError(f"invalid workspace {name} for {key}")
313
+ fields.append(f"100644 blob {_hash_blob(workspace, value)}\t{name}")
314
+ entries.append(f"040000 tree {_mktree(workspace, fields)}\t{key}")
315
+ repos_tree = _mktree(workspace, entries)
316
+ meta_tree = _mktree(workspace, [f"100644 blob {_hash_blob(workspace, _WORKSPACE_SCHEMA)}\tschema", f"100644 blob {_hash_blob(workspace, 'workspace')}\tkind"])
317
+ root_tree = _mktree(workspace, [f"040000 tree {meta_tree}\t.grip", f"040000 tree {repos_tree}\trepos"])
318
+ commit = _commit_tree(workspace, root_tree, parent=_current_head(workspace), message="grip workspace snapshot")
319
+ _grip_git(workspace, "update-ref", "HEAD", commit)
320
+ return commit
321
+
322
+
323
+ def read_workspace_commit(workspace: Path, commit: str) -> list[dict[str, str]]:
324
+ """Strictly decode a kind=workspace gr commit's resolved repository fields."""
325
+ if _grip_git(workspace, "show", f"{commit}:.grip/schema").stdout.strip() != _WORKSPACE_SCHEMA:
326
+ raise GripCorruptError("not a gr2 workspace commit")
327
+ if _grip_git(workspace, "show", f"{commit}:.grip/kind").stdout.strip() != "workspace":
328
+ raise GripCorruptError("gr2 workspace commit has the wrong kind")
329
+ rows = _read_repo_state(workspace, commit)
330
+ decoded: list[dict[str, str]] = []
331
+ for key, fields in sorted(rows.items()):
332
+ if set(fields) != {"remote", "path", "commit", "base"} or not _SHA40.match(fields["commit"]) or not _SHA40.match(fields["base"]):
333
+ raise GripCorruptError(f"invalid workspace repository tree: {key}")
334
+ decoded.append({"key": key, "remote": fields["remote"], "path": fields["path"], "commit": fields["commit"], "base": fields["base"]})
335
+ return decoded
336
+
337
+
338
+ # ---------------------------------------------------------------------------
339
+ # Milestone 1.2 — the gate on gr2: review bind + verify
340
+ #
341
+ # A review gr commit is a project-review commit plus two subtrees: observed/
342
+ # (the live remote head of each row's target ref at bind time) and texts/ (the
343
+ # platform title and body, NORM: trailing newlines stripped). The gr commit id
344
+ # is the freeze; the frozen directory and its five SHA-256s are deleted, not
345
+ # wrapped: the object is its own frozen record.
346
+ # ---------------------------------------------------------------------------
347
+
348
+ _REVIEW_BIND_SCHEMA = "gr2-review-bind/v2"
349
+
350
+
351
+ class GripReviewRefused(Exception):
352
+ """A bind refusal. Carries the refusal name and the two values that disagreed."""
353
+
354
+ def __init__(self, refusal: str, expected: str = "", observed: str = "") -> None:
355
+ self.refusal = refusal
356
+ self.expected = expected
357
+ self.observed = observed
358
+ detail = f": expected {expected!r}, observed {observed!r}" if expected or observed else ""
359
+ super().__init__(f"{refusal}{detail}")
360
+
361
+
362
+ def _norm_text(value: str) -> str:
363
+ """NORM a platform text: strip trailing newlines (the freeze's own rule)."""
364
+ return value.rstrip("\n")
365
+
366
+
367
+ def _remote_head(workspace: Path, remote: str, ref: str) -> str:
368
+ """The live head of one ref on a remote, or '' if absent. A ref name like
369
+ refs/heads/<branch>; ls-remote is read-only and needs no local ref."""
370
+ proc = _grip_git(workspace, "ls-remote", remote, ref)
371
+ if proc.returncode != 0:
372
+ raise GripReviewRefused("remote_unreadable", ref, proc.stderr.strip()[:120])
373
+ line = proc.stdout.strip().splitlines()
374
+ return line[0].split("\t", 1)[0] if line else ""
375
+
376
+
377
+ def _head_present_on_remote(workspace: Path, remote: str, head: str) -> bool:
378
+ """True if the head SHA is already an object any ref on the remote points at.
379
+ A pre-push branch's head is present at no ref; a re-freeze of an already
380
+ pushed head is refused unless a prior ratify receipt is named."""
381
+ proc = _grip_git(workspace, "ls-remote", remote)
382
+ if proc.returncode != 0:
383
+ raise GripReviewRefused("remote_unreadable", remote, proc.stderr.strip()[:120])
384
+ return any(row.split("\t", 1)[0] == head for row in proc.stdout.splitlines())
385
+
386
+
387
+ def _source_capture(source: str, *args: str) -> str:
388
+ """Run a read-only git command in the author's source repo and return stdout.
389
+
390
+ Bind carries the frozen set INSIDE the gr commit (decision (a), 2026-09-02):
391
+ the object must reconstruct a pre-push head with nothing but itself and the
392
+ live base, so bind derives the range, the fuller metadata, and the head tree
393
+ from the source that actually holds the head. Read-only; a failure refuses
394
+ the bind rather than writing a partial object."""
395
+ proc = subprocess.run(
396
+ ["git", "-C", source, *args], capture_output=True, text=True, check=False
397
+ )
398
+ if proc.returncode != 0:
399
+ raise GripReviewRefused(
400
+ "source_unreadable", f"{source} git {' '.join(args)}", proc.stderr.strip()[:160]
401
+ )
402
+ return proc.stdout
403
+
404
+
405
+ def _carry_objects(workspace: Path, source: str, base: str, head: str) -> dict[str, str]:
406
+ """From the author's source repo, capture the range (format-patch base..head),
407
+ the fuller metadata (author+committer per commit), and the head tree. These
408
+ are the bytes the hand freeze produced by hand; here they live in the object.
409
+ The head tree is recorded so ``run`` can assert the reconstruction matches it
410
+ WITHOUT needing the pre-push head object anywhere but the range."""
411
+ if _source_capture(source, "rev-parse", "--verify", f"{head}^{{commit}}").strip() != head:
412
+ raise GripReviewRefused("source_missing_head", head, source)
413
+ if _source_capture(source, "rev-parse", "--verify", f"{base}^{{commit}}").strip() != base:
414
+ raise GripReviewRefused("source_missing_base", base, source)
415
+ range_patch = _source_capture(source, "format-patch", f"{base}..{head}", "--stdout")
416
+ if not range_patch.strip():
417
+ raise GripReviewRefused("empty_range", f"{base}..{head}", source)
418
+ metadata = _source_capture(source, "log", "--format=fuller", f"{base}..{head}")
419
+ head_tree = _source_capture(source, "rev-parse", f"{head}^{{tree}}").strip()
420
+ return {"range.patch": range_patch, "metadata": metadata, "head-tree": head_tree}
421
+
422
+
423
+ def _run_policy_hook(policy_hook: list[str] | None, scan_items: list[tuple[str, str]]) -> str:
424
+ """Run the configured policy hook over the carried readable bytes.
425
+
426
+ None = OSS default, no hook, recorded as ``no-policy``. Otherwise the carried
427
+ range and texts are written to a temp directory and the hook is invoked with
428
+ that directory as its last argument; a nonzero exit REFUSES the bind (a bind
429
+ that carries a leak refuses like a freeze), and the clean verdict is recorded
430
+ in the object so a second reader can see which policy cleared it."""
431
+ if policy_hook is None:
432
+ return "no-policy"
433
+ import tempfile
434
+ with tempfile.TemporaryDirectory() as td:
435
+ for name, content in scan_items:
436
+ (Path(td) / name).write_text(content)
437
+ proc = subprocess.run(
438
+ [*policy_hook, td], capture_output=True, text=True, check=False
439
+ )
440
+ if proc.returncode != 0:
441
+ raise GripReviewRefused(
442
+ "policy_hook_refused", " ".join(policy_hook),
443
+ (proc.stdout + proc.stderr).strip()[:200],
444
+ )
445
+ return f"clean: {' '.join(policy_hook)} exit 0"
446
+
447
+
448
+ def _range_terminal_head(range_patch: str) -> str | None:
449
+ """The sha the range's LAST commit was formatted from. `git format-patch` writes
450
+ one ``From <40-hex> `` header per commit, in apply order, so the terminal one is
451
+ the range's head. Returns None if the patch carries no From header. Used to defend
452
+ the bind: the head a range describes must equal the declared --head (the same
453
+ guarantee the --source path gets from its rev-parse of the head in the clone)."""
454
+ found: str | None = None
455
+ for line in range_patch.splitlines():
456
+ if line.startswith("From ") and len(line) >= 45 and line[45] == " ":
457
+ cand = line[5:45]
458
+ if _SHA40.match(cand):
459
+ found = cand
460
+ return found
461
+
462
+
463
+ def create_review_bind_commit(
464
+ workspace: Path, rows: list[dict[str, str]], *, ratified: str | None = None,
465
+ policy_hook: list[str] | None = None,
466
+ ) -> str:
467
+ """Bind a review gr commit. Each row: key, remote, path, head, base, ref, title, body.
468
+
469
+ Reads the live remote head of every row's target ref, records it under
470
+ observed/, and refuses BEFORE writing anything if base is not that head
471
+ (behind-must-be-0) or if head is already on the remote without a named
472
+ ratify receipt (the 2026-09-02 pre-gate-push lesson).
473
+
474
+ ``policy_hook`` is the OSS-neutral seam: a configured command
475
+ that receives a directory of the carried readable bytes (the range and the
476
+ platform texts, which is what a leak scanner must see — commit messages
477
+ leak, packs hide them) and refuses the bind on a nonzero exit, the way a
478
+ freeze refuses today. OSS ships no hook (records ``no-policy``); our config
479
+ points it at the leak scanner. The verdict is recorded in the object."""
480
+ _validate_grip_repo(workspace)
481
+ if not rows:
482
+ raise GripCorruptError("review bind requires at least one repository row")
483
+ entries: list[str] = []
484
+ observed_entries: list[str] = []
485
+ texts_entries: list[str] = []
486
+ objects_entries: list[str] = []
487
+ evidence_entries: list[str] = []
488
+ scan_items: list[tuple[str, str]] = []
489
+ seen: set[str] = set()
490
+ for row in sorted(rows, key=lambda item: item["key"]):
491
+ key = row.get("key", "")
492
+ if not key or key in seen or any(ch in key for ch in "/\\"):
493
+ raise GripCorruptError(f"invalid or duplicate review key: {key!r}")
494
+ seen.add(key)
495
+ remote, path, head, base, ref = (
496
+ row.get("remote", ""), row.get("path", ""), row.get("head", ""),
497
+ row.get("base", ""), row.get("ref", ""),
498
+ )
499
+ for name, value, sha in (("remote", remote, False), ("path", path, False),
500
+ ("commit", head, True), ("base", base, True), ("ref", ref, False)):
501
+ if not value or (sha and not _SHA40.match(value)):
502
+ raise GripReviewRefused("invalid_field", f"{key}/{name}", value)
503
+
504
+ # Refusal 1: base must be the live remote head of the target ref.
505
+ observed = _remote_head(workspace, remote, ref)
506
+ if base != observed:
507
+ raise GripReviewRefused("base_not_live_head", base, observed)
508
+ # Refusal 2: head must not already be on the remote (unless ratified).
509
+ if _head_present_on_remote(workspace, remote, head) and not ratified:
510
+ raise GripReviewRefused("head_already_on_remote", head, "present")
511
+
512
+ fields = [f"100644 blob {_hash_blob(workspace, v)}\t{n}"
513
+ for n, v in (("remote", remote), ("path", path), ("commit", head), ("base", base))]
514
+ entries.append(f"040000 tree {_mktree(workspace, fields)}\t{key}")
515
+ # remote-head field hoisted out of the outer f-string's expression: a
516
+ # nested f-string carrying \t inside {…} is a SyntaxError on Python 3.11
517
+ # (PEP 701 only lifted this in 3.12+), and gr2 supports >=3.11. Output is
518
+ # byte-identical, so every content-hash is unchanged.
519
+ remote_head_field = f"100644 blob {_hash_blob(workspace, observed)}\tremote-head"
520
+ observed_entries.append(
521
+ f"040000 tree {_mktree(workspace, [remote_head_field])}\t{key}"
522
+ )
523
+ title = _norm_text(row.get("title", ""))
524
+ body = _norm_text(row.get("body", ""))
525
+ text_fields = [f"100644 blob {_hash_blob(workspace, title)}\ttitle",
526
+ f"100644 blob {_hash_blob(workspace, body)}\tbody"]
527
+ texts_entries.append(f"040000 tree {_mktree(workspace, text_fields)}\t{key}")
528
+
529
+ # (a): carry the frozen set inside the object. A row with a source repo
530
+ # carries range.patch + fuller metadata + head-tree so a pre-push head
531
+ # reconstructs from the object alone. Rows without a source are the
532
+ # legacy shape (SHAs only) and carry no objects subtree entry.
533
+ scan_items.append((f"{key}.title", title))
534
+ scan_items.append((f"{key}.body", body))
535
+ source = row.get("source")
536
+ range_patch = row.get("range_patch")
537
+ if source and range_patch:
538
+ raise GripCorruptError(
539
+ f"row {key}: source and range_patch are mutually exclusive "
540
+ "(a clone that holds the head vs the frozen range itself)")
541
+ obj: dict[str, str] | None = None
542
+ if source:
543
+ obj = _carry_objects(workspace, source, base, head)
544
+ elif range_patch:
545
+ # A classic freeze-public-range.sh range.patch, head local-only: derive
546
+ # the head-tree by applying the range over base in a throwaway clone, so
547
+ # the producer owns the git am end to end (no author clone required).
548
+ # Head defense (parity with the --source path's source_missing_head
549
+ # check): the range's terminal `From <sha>` header is the head the patch
550
+ # was formatted from; it must equal the declared head, or the row would
551
+ # bind a range describing a DIFFERENT head than the one recorded here and
552
+ # refused-against on the remote.
553
+ from_head = _range_terminal_head(range_patch)
554
+ if from_head is None:
555
+ raise GripReviewRefused("range_no_from_header", key, "range.patch carries no From <sha> header")
556
+ if from_head != head:
557
+ raise GripReviewRefused("range_head_mismatch", head, from_head)
558
+ obj = _carry_objects_from_range(workspace, remote, base, range_patch)
559
+ if obj is not None:
560
+ obj_fields = [f"100644 blob {_hash_blob(workspace, obj[n])}\t{n}"
561
+ for n in ("range.patch", "metadata", "head-tree")]
562
+ objects_entries.append(f"040000 tree {_mktree(workspace, obj_fields)}\t{key}")
563
+ scan_items.append((f"{key}.range.patch", obj["range.patch"]))
564
+ evidence = row.get("evidence")
565
+ if evidence:
566
+ ev_fields = [f"100644 blob {_hash_blob(workspace, evidence)}\tcommands"]
567
+ resolution = row.get("resolution")
568
+ if resolution:
569
+ ev_fields.append(f"100644 blob {_hash_blob(workspace, resolution)}\tresolution")
570
+ evidence_entries.append(f"040000 tree {_mktree(workspace, ev_fields)}\t{key}")
571
+
572
+ # Policy hook: scan the carried readable bytes; refuse on a hit
573
+ # the way a freeze does, and record the verdict in the object.
574
+ policy_verdict = _run_policy_hook(policy_hook, scan_items)
575
+
576
+ repos_tree = _mktree(workspace, entries)
577
+ observed_tree = _mktree(workspace, observed_entries)
578
+ texts_tree = _mktree(workspace, texts_entries)
579
+ meta_tree = _mktree(workspace, [
580
+ f"100644 blob {_hash_blob(workspace, _REVIEW_BIND_SCHEMA)}\tschema",
581
+ f"100644 blob {_hash_blob(workspace, 'review')}\tkind",
582
+ f"100644 blob {_hash_blob(workspace, policy_verdict)}\tpolicy",
583
+ ])
584
+ root_fields = [
585
+ f"040000 tree {meta_tree}\t.grip",
586
+ f"040000 tree {observed_tree}\tobserved",
587
+ f"040000 tree {repos_tree}\trepos",
588
+ f"040000 tree {texts_tree}\ttexts",
589
+ ]
590
+ if objects_entries:
591
+ root_fields.append(f"040000 tree {_mktree(workspace, objects_entries)}\tobjects")
592
+ if evidence_entries:
593
+ root_fields.append(f"040000 tree {_mktree(workspace, evidence_entries)}\tevidence")
594
+ root_tree = _mktree(workspace, root_fields)
595
+ commit = _commit_tree(workspace, root_tree, parent=_current_head(workspace), message="grip review bind")
596
+ _grip_git(workspace, "update-ref", "HEAD", commit)
597
+ return commit
598
+
599
+
600
+ def verify_review_commit(workspace: Path, commit: str) -> dict[str, object]:
601
+ """Re-derive the review gr commit from its own objects and report what was
602
+ measured: the recomputed root tree (must equal the commit's tree, else
603
+ corruption), and per row the remote/path/head/base, the observed remote
604
+ head, and the SHA-256 of each NORM'd text (the bridge to the frozen title/
605
+ body NORM the hand gate produced)."""
606
+ import hashlib
607
+
608
+ if _grip_git(workspace, "show", f"{commit}:.grip/schema").stdout.strip() != _REVIEW_BIND_SCHEMA:
609
+ raise GripCorruptError("not a gr2 review bind commit")
610
+
611
+ stored_tree = _grip_git(workspace, "rev-parse", f"{commit}^{{tree}}").stdout.strip()
612
+ rows = _read_repo_state(workspace, commit)
613
+ root_paths = {
614
+ line.strip()
615
+ for line in _grip_git(workspace, "ls-tree", "--name-only", commit).stdout.splitlines()
616
+ if line.strip()
617
+ }
618
+ has_objects = "objects" in root_paths
619
+ has_evidence = "evidence" in root_paths
620
+ objects_keys = _tree_keys(workspace, commit, "objects") if has_objects else set()
621
+ evidence_keys = _tree_keys(workspace, commit, "evidence") if has_evidence else set()
622
+ measured: list[dict[str, str]] = []
623
+ recomputed_entries: list[str] = []
624
+ observed_recomputed: list[str] = []
625
+ texts_recomputed: list[str] = []
626
+ objects_recomputed: list[str] = []
627
+ evidence_recomputed: list[str] = []
628
+ for key, fields in sorted(rows.items()):
629
+ if set(fields) != {"remote", "path", "commit", "base"}:
630
+ raise GripCorruptError(f"invalid review repository tree: {key}")
631
+ observed = _grip_git(workspace, "show", f"{commit}:observed/{key}/remote-head").stdout.strip()
632
+ title = _grip_git(workspace, "show", f"{commit}:texts/{key}/title").stdout
633
+ body = _grip_git(workspace, "show", f"{commit}:texts/{key}/body").stdout
634
+ title_norm = _norm_text(title)
635
+ body_norm = _norm_text(body)
636
+ row_measured = {
637
+ "key": key, "remote": fields["remote"], "path": fields["path"],
638
+ "head": fields["commit"], "base": fields["base"], "observed_remote_head": observed,
639
+ "base_equals_observed": str(fields["base"] == observed),
640
+ "title_sha256": hashlib.sha256(title_norm.encode()).hexdigest(),
641
+ "body_sha256": hashlib.sha256(body_norm.encode()).hexdigest(),
642
+ }
643
+ # Recompute the three base subtrees from the decoded content, exactly as bind built them.
644
+ f = [f"100644 blob {_hash_blob(workspace, v)}\t{n}"
645
+ for n, v in (("remote", fields["remote"]), ("path", fields["path"]),
646
+ ("commit", fields["commit"]), ("base", fields["base"]))]
647
+ recomputed_entries.append(f"040000 tree {_mktree(workspace, f)}\t{key}")
648
+ # remote-head field hoisted (see create_review_bind_commit): nested
649
+ # f-string with \t in the expression is a 3.11 SyntaxError; byte-identical.
650
+ remote_head_field = f"100644 blob {_hash_blob(workspace, observed)}\tremote-head"
651
+ observed_recomputed.append(
652
+ f"040000 tree {_mktree(workspace, [remote_head_field])}\t{key}"
653
+ )
654
+ tf = [f"100644 blob {_hash_blob(workspace, title_norm)}\ttitle",
655
+ f"100644 blob {_hash_blob(workspace, body_norm)}\tbody"]
656
+ texts_recomputed.append(f"040000 tree {_mktree(workspace, tf)}\t{key}")
657
+
658
+ # (a): the carried frozen set. head-tree is what run asserts the
659
+ # reconstruction against; range/metadata are the readable bytes the
660
+ # leak scanner and reviewer see.
661
+ if key in objects_keys:
662
+ rng = _grip_git(workspace, "show", f"{commit}:objects/{key}/range.patch").stdout
663
+ meta = _grip_git(workspace, "show", f"{commit}:objects/{key}/metadata").stdout
664
+ head_tree = _grip_git(workspace, "show", f"{commit}:objects/{key}/head-tree").stdout.strip()
665
+ of = [f"100644 blob {_hash_blob(workspace, v)}\t{n}"
666
+ for n, v in (("range.patch", rng), ("metadata", meta), ("head-tree", head_tree))]
667
+ objects_recomputed.append(f"040000 tree {_mktree(workspace, of)}\t{key}")
668
+ row_measured["head_tree"] = head_tree
669
+ row_measured["range_sha256"] = hashlib.sha256(rng.encode()).hexdigest()
670
+ if key in evidence_keys:
671
+ ev_paths = _tree_keys(workspace, commit, f"evidence/{key}")
672
+ ef = []
673
+ for name in ("commands", "resolution"):
674
+ if name in ev_paths:
675
+ content = _grip_git(workspace, "show", f"{commit}:evidence/{key}/{name}").stdout
676
+ ef.append(f"100644 blob {_hash_blob(workspace, content)}\t{name}")
677
+ evidence_recomputed.append(f"040000 tree {_mktree(workspace, ef)}\t{key}")
678
+
679
+ measured.append(row_measured)
680
+
681
+ policy = _grip_git(workspace, "show", f"{commit}:.grip/policy").stdout
682
+ meta_tree = _mktree(workspace, [
683
+ f"100644 blob {_hash_blob(workspace, _REVIEW_BIND_SCHEMA)}\tschema",
684
+ f"100644 blob {_hash_blob(workspace, 'review')}\tkind",
685
+ f"100644 blob {_hash_blob(workspace, policy)}\tpolicy",
686
+ ])
687
+ root_fields = [
688
+ f"040000 tree {meta_tree}\t.grip",
689
+ f"040000 tree {_mktree(workspace, observed_recomputed)}\tobserved",
690
+ f"040000 tree {_mktree(workspace, recomputed_entries)}\trepos",
691
+ f"040000 tree {_mktree(workspace, texts_recomputed)}\ttexts",
692
+ ]
693
+ if objects_recomputed:
694
+ root_fields.append(f"040000 tree {_mktree(workspace, objects_recomputed)}\tobjects")
695
+ if evidence_recomputed:
696
+ root_fields.append(f"040000 tree {_mktree(workspace, evidence_recomputed)}\tevidence")
697
+ recomputed_tree = _mktree(workspace, root_fields)
698
+ return {
699
+ "commit": commit,
700
+ "stored_tree": stored_tree,
701
+ "recomputed_tree": recomputed_tree,
702
+ "tree_matches": stored_tree == recomputed_tree,
703
+ "rows": measured,
704
+ }
705
+
706
+
707
+ def _tree_keys(workspace: Path, commit: str, path: str) -> set[str]:
708
+ """The immediate child names of a subtree in a gr commit (empty if absent)."""
709
+ proc = _grip_git(workspace, "ls-tree", "--name-only", f"{commit}:{path}")
710
+ if proc.returncode != 0:
711
+ return set()
712
+ return {line.strip() for line in proc.stdout.splitlines() if line.strip()}
713
+
714
+
715
+ def parse_evidence(text: str) -> list[dict[str, str]]:
716
+ """Parse the carried evidence blob into declared checks.
717
+
718
+ Format (the hand freeze's own evidence.txt): blocks separated by a line of
719
+ ``---``, each with ``label:``, ``command:``, and ``exit:`` fields. The
720
+ author's own runs; inputs to ``run``, not proof (a receipt that only re-runs
721
+ them ratifies the disclosure axis and says so)."""
722
+ checks: list[dict[str, str]] = []
723
+ for block in re.split(r"(?m)^---\s*$", text):
724
+ fields: dict[str, str] = {}
725
+ for line in block.splitlines():
726
+ m = re.match(r"\s*(label|command|exit):\s*(.*)$", line)
727
+ if m:
728
+ fields[m.group(1)] = m.group(2).strip()
729
+ if fields.get("command"):
730
+ checks.append({
731
+ "label": fields.get("label", ""),
732
+ "command": fields["command"],
733
+ "expected_exit": fields.get("exit", ""),
734
+ })
735
+ return checks
736
+
737
+
738
+ def run_review_checks(
739
+ workspace: Path, commit: str, key: str, lane_dir: Path, *, env: dict[str, str] | None = None
740
+ ) -> dict[str, object]:
741
+ """Reconstruct the lane, then execute the carried declared checks INSIDE it.
742
+
743
+ Reconstruction (with its tree assertion) runs first; a tree mismatch refuses
744
+ before a single check executes. Each check runs with cwd = the lane (a check
745
+ that escapes the lane is running against the wrong tree), and records
746
+ command, cwd, exit, the declared expected exit, whether they matched, and a
747
+ digest of the output tail. RAN is a field, not a label."""
748
+ import hashlib
749
+
750
+ import os
751
+
752
+ materialized = reconstruct_review_lane(workspace, commit, key, lane_dir)
753
+ lane = Path(materialized["lane"])
754
+ checks = []
755
+ if key in _tree_keys(workspace, commit, "evidence"):
756
+ if "commands" in _tree_keys(workspace, commit, f"evidence/{key}"):
757
+ checks = parse_evidence(
758
+ _grip_git(workspace, "show", f"{commit}:evidence/{key}/commands").stdout
759
+ )
760
+
761
+ # Resolution: pin PYTHONPATH to the reconstructed lane so a declared check
762
+ # (e.g. `python -m pytest ...`) imports the REVIEWED tree, never a machine-wide
763
+ # install (the 2026-08-11 lesson: a green suite about someone else's checkout).
764
+ # The caller's env wins if it sets PYTHONPATH explicitly.
765
+ src = lane / "src"
766
+ lane_pp = str(src if src.is_dir() else lane)
767
+ base_env = {**os.environ}
768
+ existing_pp = base_env.get("PYTHONPATH", "")
769
+ base_env["PYTHONPATH"] = f"{lane_pp}{os.pathsep}{existing_pp}" if existing_pp else lane_pp
770
+ run_env = {**base_env, **(env or {})}
771
+ import_resolution = lane_pp
772
+ runs: list[dict[str, object]] = []
773
+ import shlex
774
+ for check in checks:
775
+ argv = shlex.split(check["command"])
776
+ proc = subprocess.run(
777
+ argv, cwd=str(lane), env=run_env, capture_output=True, text=True, check=False
778
+ )
779
+ tail = (proc.stdout + proc.stderr)[-4096:]
780
+ runs.append({
781
+ "label": check["label"],
782
+ "command": check["command"],
783
+ "cwd": str(lane),
784
+ "exit": proc.returncode,
785
+ "expected_exit": check["expected_exit"],
786
+ "exit_matched": (check["expected_exit"] == "" or str(proc.returncode) == check["expected_exit"]),
787
+ "output_digest": hashlib.sha256(tail.encode()).hexdigest(),
788
+ "kind": "declared",
789
+ })
790
+ return {"materialized": materialized, "runs": runs, "import_resolution": import_resolution}
791
+
792
+
793
+ _FINDING_REQUIRED_WHEN_BLOCKING = ("seam", "smallest_fix", "witness", "risk")
794
+
795
+
796
+ def validate_finding(finding: dict[str, object]) -> dict[str, object]:
797
+ """A blocking finding is refused unless every required field is present (the
798
+ reviewer-who-blocks-must-propose rule, as a constraint, not a habit)."""
799
+ if finding.get("blocking"):
800
+ missing = [f for f in _FINDING_REQUIRED_WHEN_BLOCKING if not finding.get(f)]
801
+ if missing:
802
+ raise GripReviewRefused("incomplete_blocking_finding", ",".join(missing), "")
803
+ return finding
804
+
805
+
806
+ def build_review_receipt(
807
+ workspace: Path,
808
+ commit: str,
809
+ *,
810
+ actor: str,
811
+ verdict: str,
812
+ axes: dict[str, str],
813
+ run_results: dict[str, object],
814
+ read: list[str] | None = None,
815
+ probes: list[dict[str, object]] | None = None,
816
+ mutations: list[dict[str, object]] | None = None,
817
+ findings: list[dict[str, object]] | None = None,
818
+ ) -> dict[str, object]:
819
+ """Assemble the receipt object: one object per actor per gr commit.
820
+
821
+ Records verify (recomputed tree), liveness (every observed head re-read now),
822
+ the materialized reconstruction (reconstructed head + tree beside the bound
823
+ ones), the runs, and the findings. A block verdict needs at least one blocking
824
+ finding, and each blocking finding is refused unless complete."""
825
+ if verdict not in ("ratify", "block"):
826
+ raise GripReviewRefused("invalid_verdict", verdict, "ratify|block")
827
+ findings = [validate_finding(f) for f in (findings or [])]
828
+ if verdict == "block" and not any(f.get("blocking") for f in findings):
829
+ raise GripReviewRefused("block_without_blocking_finding", verdict, "")
830
+
831
+ v = verify_review_commit(workspace, commit)
832
+ # Liveness: re-read every observed remote head NOW and compare to the bound base.
833
+ liveness: list[dict[str, str]] = []
834
+ for key, fields in sorted(_read_repo_state(workspace, commit).items()):
835
+ live = _remote_head(workspace, fields["remote"], _row_ref(workspace, commit, key))
836
+ liveness.append({
837
+ "key": key, "bound_base": fields["base"], "live_remote_head": live,
838
+ "state": "equal" if live == fields["base"] else "moved",
839
+ })
840
+ return {
841
+ "gr_commit": commit,
842
+ "actor": actor,
843
+ "verdict": verdict,
844
+ "axes": axes,
845
+ "liveness": liveness,
846
+ "verify": {"tree_matches": v["tree_matches"], "recomputed_tree": v["recomputed_tree"]},
847
+ "materialized": run_results.get("materialized"),
848
+ "import_resolution": run_results.get("import_resolution"),
849
+ "runs": run_results.get("runs", []),
850
+ "read": read or [],
851
+ "probes": probes or [],
852
+ "mutations": mutations or [],
853
+ "findings": findings,
854
+ "expires_on": {"any_base_moved": any(l["state"] == "moved" for l in liveness)},
855
+ }
856
+
857
+
858
+ def _row_ref(workspace: Path, commit: str, key: str) -> str:
859
+ """The target ref a bound row's base was observed against. Stored implicitly:
860
+ bind recorded observed/<key>/remote-head against the row's ref. The ref name
861
+ itself is not separately stored in v2, so liveness re-reads the base's ref by
862
+ convention from the remote's default integration branch when unknown; here we
863
+ fall back to refs/heads/dev, the team's only integration branch."""
864
+ return "refs/heads/dev"
865
+
866
+
867
+ def review_row_keys(workspace: Path, commit: str) -> list[str]:
868
+ """The repository keys bound in a review gr commit, sorted. Cheap: reads the
869
+ repos/ subtree only (no tree recomputation)."""
870
+ return sorted(_read_repo_state(workspace, commit).keys())
871
+
872
+
873
+ def reconstruct_review_lane(
874
+ workspace: Path, commit: str, key: str, lane_dir: Path
875
+ ) -> dict[str, str]:
876
+ """Materialize a bound row's head by reconstruction, decision (a).
877
+
878
+ Clone the recorded remote, check out the recorded BASE (the live remote head at
879
+ bind), apply the carried range, and assert the resulting tree equals the bound
880
+ head-tree. The reconstruction never needs the pre-push head object anywhere but
881
+ the carried range; a tree mismatch is a REFUSAL, raised before ``run`` executes a
882
+ single check (a check over a tree that is not the reviewed tree is a finding
883
+ about the wrong bytes).
884
+
885
+ When the commit ALSO carries ``objects/<key>/committers`` (the committer-date-
886
+ match contract), the range is applied commit-by-commit under each commit's
887
+ recorded committer identity+date, so the reconstructed head SHA equals the pinned
888
+ pre-push head, not merely its tree -- and the SHA is asserted equal to the bound
889
+ head. Without that object (a range-1-era commit) it falls back to a plain
890
+ ``git am``: tree-faithful, committer re-stamped, so the returned reconstructed_head
891
+ differs from the bound head by design."""
892
+ if key not in _tree_keys(workspace, commit, "objects"):
893
+ raise GripReviewRefused("row_carries_no_objects", key, "reconstruction needs a carried range")
894
+ repo = _read_repo_state(workspace, commit)[key]
895
+ remote, base, bound_head = repo["remote"], repo["base"], repo["commit"]
896
+ head_tree_expected = _grip_git(workspace, "show", f"{commit}:objects/{key}/head-tree").stdout.strip()
897
+ committers_proc = _grip_git(workspace, "show", f"{commit}:objects/{key}/committers")
898
+ committers = committers_proc.stdout if committers_proc.returncode == 0 else None
899
+ range_text = _grip_git(workspace, "show", f"{commit}:objects/{key}/range.patch").stdout
900
+
901
+ lane_dir = Path(lane_dir)
902
+ lane_dir.parent.mkdir(parents=True, exist_ok=True)
903
+
904
+ def _lg(*args: str, allow_fail: bool = False) -> subprocess.CompletedProcess[str]:
905
+ proc = subprocess.run(
906
+ ["git", "-c", "user.name=grip-review", "-c", "user.email=review@grip", "-C",
907
+ str(lane_dir), *args],
908
+ capture_output=True, text=True, check=False,
909
+ )
910
+ if proc.returncode != 0 and not allow_fail:
911
+ raise GripReviewRefused("reconstruct_failed", " ".join(args), proc.stderr.strip()[:160])
912
+ return proc
913
+
914
+ clone = subprocess.run(
915
+ ["git", "clone", "--quiet", remote, str(lane_dir)],
916
+ capture_output=True, text=True, check=False,
917
+ )
918
+ if clone.returncode != 0:
919
+ raise GripReviewRefused("clone_failed", remote, clone.stderr.strip()[:160])
920
+ if _lg("rev-parse", "--verify", f"{base}^{{commit}}", allow_fail=True).returncode != 0:
921
+ raise GripReviewRefused("base_unreachable_on_remote", base, remote)
922
+ _lg("checkout", "--detach", base)
923
+ try:
924
+ _apply_range_in_lane(lane_dir, range_text, committers)
925
+ except _RangeApplyError as exc:
926
+ raise GripReviewRefused("reconstruct_failed", exc.op, exc.detail) from exc
927
+
928
+ reconstructed_head = _lg("rev-parse", "HEAD").stdout.strip()
929
+ reconstructed_tree = _lg("rev-parse", "HEAD^{tree}").stdout.strip()
930
+ if reconstructed_tree != head_tree_expected:
931
+ raise GripReviewRefused("tree_mismatch", head_tree_expected, reconstructed_tree)
932
+ if committers is not None and reconstructed_head != bound_head:
933
+ raise GripReviewRefused("sha_mismatch", bound_head, reconstructed_head)
934
+ return {
935
+ "lane": str(lane_dir),
936
+ "bound_head": bound_head,
937
+ "reconstructed_head": reconstructed_head,
938
+ "bound_head_tree": head_tree_expected,
939
+ "reconstructed_tree": reconstructed_tree,
940
+ }
941
+
942
+
943
+ # ---------------------------------------------------------------------------
944
+ # Helpers
945
+ # ---------------------------------------------------------------------------
946
+
947
+
948
+ def _grip_git(workspace: Path, *args: str) -> subprocess.CompletedProcess[str]:
949
+ return git(workspace / ".grip", *args)
950
+
951
+
952
+ def _validate_grip_repo(workspace: Path) -> None:
953
+ """Verify .grip/ is a valid git repo. Raises GripInitError if not."""
954
+ grip_dir = workspace / ".grip"
955
+ if not grip_dir.exists():
956
+ raise GripInitError(
957
+ f"No .grip/ directory at {workspace}. Run grip_init first."
958
+ )
959
+ git_dir = grip_dir / ".git"
960
+ if not git_dir.exists():
961
+ raise GripInitError(
962
+ f".grip/ exists but has no .git/ at {workspace}. Run grip_init to repair."
963
+ )
964
+ if git_dir.is_file():
965
+ raise GripInitError(
966
+ f".grip/.git is a file, not a directory (corrupt). "
967
+ f"Remove {git_dir} and run grip_init to repair."
968
+ )
969
+
970
+
971
+ def _hash_blob(workspace: Path, content: str) -> str:
972
+ proc = subprocess.run(
973
+ ["git", "hash-object", "-w", "--stdin"],
974
+ cwd=workspace / ".grip",
975
+ input=content,
976
+ capture_output=True,
977
+ text=True,
978
+ check=False,
979
+ )
980
+ if proc.returncode != 0:
981
+ raise RuntimeError(f"hash-object failed: {proc.stderr}")
982
+ return proc.stdout.strip()
983
+
984
+
985
+ def _mktree(workspace: Path, entries: list[str]) -> str:
986
+ tree_input = "\n".join(entries) + "\n" if entries else ""
987
+ proc = subprocess.run(
988
+ ["git", "mktree"],
989
+ cwd=workspace / ".grip",
990
+ input=tree_input,
991
+ capture_output=True,
992
+ text=True,
993
+ check=False,
994
+ )
995
+ if proc.returncode != 0:
996
+ raise RuntimeError(f"mktree failed: {proc.stderr}")
997
+ return proc.stdout.strip()
998
+
999
+
1000
+ def _commit_tree(
1001
+ workspace: Path, tree_sha: str, *, parent: str | None = None, message: str = ""
1002
+ ) -> str:
1003
+ args = ["git", "commit-tree", tree_sha]
1004
+ if parent:
1005
+ args.extend(["-p", parent])
1006
+ args.extend(["-m", message or "grip snapshot"])
1007
+ proc = subprocess.run(
1008
+ args,
1009
+ cwd=workspace / ".grip",
1010
+ capture_output=True,
1011
+ text=True,
1012
+ check=False,
1013
+ env=_git_env(),
1014
+ )
1015
+ if proc.returncode != 0:
1016
+ raise RuntimeError(f"commit-tree failed: {proc.stderr}")
1017
+ return proc.stdout.strip()
1018
+
1019
+
1020
+ def _git_env() -> dict[str, str]:
1021
+ import os
1022
+
1023
+ env = os.environ.copy()
1024
+ env.setdefault("GIT_AUTHOR_NAME", "grip")
1025
+ env.setdefault("GIT_AUTHOR_EMAIL", "grip@synapt.dev")
1026
+ env.setdefault("GIT_COMMITTER_NAME", "grip")
1027
+ env.setdefault("GIT_COMMITTER_EMAIL", "grip@synapt.dev")
1028
+ return env
1029
+
1030
+
1031
+ def _current_head(workspace: Path, *, strict: bool = False) -> str | None:
1032
+ """Get current HEAD of .grip/ repo.
1033
+
1034
+ Returns None if no commits yet. Raises GripCorruptError if HEAD exists
1035
+ but points to invalid state (when strict=True or when HEAD file is missing/corrupt).
1036
+ """
1037
+ head_path = workspace / ".grip" / ".git" / "HEAD"
1038
+ if not head_path.exists():
1039
+ raise GripCorruptError(
1040
+ f".grip/.git/HEAD is missing at {workspace}. "
1041
+ "The grip repo may be corrupt."
1042
+ )
1043
+
1044
+ proc = _grip_git(workspace, "rev-parse", "HEAD")
1045
+ if proc.returncode != 0:
1046
+ head_content = head_path.read_text().strip()
1047
+ if head_content.startswith("ref: "):
1048
+ return None
1049
+ raise GripCorruptError(
1050
+ f".grip/HEAD points to invalid ref: {head_content!r}. "
1051
+ "The grip repo may be corrupt."
1052
+ )
1053
+ return proc.stdout.strip() or None
1054
+
1055
+
1056
+ def _repo_tree_entries(workspace: Path, name: str, repo_path: Path) -> str:
1057
+ """Build a tree for one repo and return an mktree entry line."""
1058
+ from gr2.python_cli.gitops import repo_dirty
1059
+
1060
+ blobs: list[str] = []
1061
+
1062
+ head = git(repo_path, "rev-parse", "HEAD")
1063
+ if head.returncode == 0 and head.stdout.strip():
1064
+ sha = _hash_blob(workspace, head.stdout.strip())
1065
+ blobs.append(f"100644 blob {sha}\tcommit")
1066
+
1067
+ branch = git(repo_path, "branch", "--show-current")
1068
+ if branch.returncode == 0 and branch.stdout.strip():
1069
+ sha = _hash_blob(workspace, branch.stdout.strip())
1070
+ blobs.append(f"100644 blob {sha}\tbranch")
1071
+
1072
+ remote = git(repo_path, "config", "--get", "remote.origin.url")
1073
+ if remote.returncode == 0 and remote.stdout.strip():
1074
+ sha = _hash_blob(workspace, remote.stdout.strip())
1075
+ blobs.append(f"100644 blob {sha}\tremote")
1076
+
1077
+ is_dirty = repo_dirty(repo_path)
1078
+ dirty_sha = _hash_blob(workspace, "true" if is_dirty else "false")
1079
+ blobs.append(f"100644 blob {dirty_sha}\tdirty")
1080
+
1081
+ tree_sha = _mktree(workspace, blobs)
1082
+ return f"040000 tree {tree_sha}\t{name}"
1083
+
1084
+
1085
+ def _changeset_tree(
1086
+ workspace: Path,
1087
+ *,
1088
+ changeset_type: str = "",
1089
+ sprint: str = "",
1090
+ ) -> str | None:
1091
+ """Build the .grip/ changeset metadata subtree. Returns tree SHA or None."""
1092
+ blobs: list[str] = []
1093
+
1094
+ if changeset_type:
1095
+ sha = _hash_blob(workspace, changeset_type)
1096
+ blobs.append(f"100644 blob {sha}\ttype")
1097
+
1098
+ if sprint:
1099
+ sha = _hash_blob(workspace, sprint)
1100
+ blobs.append(f"100644 blob {sha}\tsprint")
1101
+
1102
+ if not blobs:
1103
+ return None
1104
+ return _mktree(workspace, blobs)
1105
+
1106
+
1107
+ def _config_overlay_tree(workspace: Path, overlay_dir: Path) -> str | None:
1108
+ """Build a config/ tree from overlay JSON files for inclusion in grip commit."""
1109
+ entries: list[str] = []
1110
+
1111
+ for f in sorted(overlay_dir.glob("*.json")):
1112
+ content = f.read_text()
1113
+ sha = _hash_blob(workspace, content)
1114
+ entries.append(f"100644 blob {sha}\t{f.name}")
1115
+
1116
+ prompts_dir = overlay_dir / "prompts"
1117
+ if prompts_dir.is_dir():
1118
+ prompt_entries: list[str] = []
1119
+ for pf in sorted(prompts_dir.glob("*.json")):
1120
+ content = pf.read_text()
1121
+ sha = _hash_blob(workspace, content)
1122
+ prompt_entries.append(f"100644 blob {sha}\t{pf.name}")
1123
+ if prompt_entries:
1124
+ prompts_tree = _mktree(workspace, prompt_entries)
1125
+ entries.append(f"040000 tree {prompts_tree}\tprompts")
1126
+
1127
+ if not entries:
1128
+ return None
1129
+ return _mktree(workspace, entries)
1130
+
1131
+
1132
+ # ---------------------------------------------------------------------------
1133
+ # Public API
1134
+ # ---------------------------------------------------------------------------
1135
+
1136
+
1137
+ def grip_init(workspace: Path) -> Path:
1138
+ """Initialize the .grip/ git repo. Idempotent."""
1139
+ grip_dir = workspace / ".grip"
1140
+ if not grip_dir.exists():
1141
+ grip_dir.mkdir(parents=True)
1142
+ git_dir = grip_dir / ".git"
1143
+ if git_dir.is_file():
1144
+ raise GripInitError(
1145
+ f".grip/.git is a file, not a directory (corrupt). "
1146
+ f"Remove {git_dir} and run grip_init again."
1147
+ )
1148
+ if not git_dir.exists():
1149
+ _require_git_success(git(grip_dir, "init"), "git init")
1150
+ _require_git_success(
1151
+ git(grip_dir, "config", "user.email", "grip@synapt.dev"),
1152
+ "git config user.email",
1153
+ )
1154
+ _require_git_success(
1155
+ git(grip_dir, "config", "user.name", "grip"),
1156
+ "git config user.name",
1157
+ )
1158
+ probe = git(grip_dir, "rev-parse", "--is-inside-work-tree")
1159
+ _require_git_success(probe, "git rev-parse --is-inside-work-tree")
1160
+ if probe.stdout.strip() != "true":
1161
+ raise GripInitError(".grip is not a valid git object store after initialization")
1162
+ return grip_dir
1163
+
1164
+
1165
+ def _require_git_success(proc: subprocess.CompletedProcess[str], action: str) -> None:
1166
+ if proc.returncode == 0:
1167
+ return
1168
+ diagnostic = (proc.stderr or proc.stdout).strip() or "no diagnostic"
1169
+ raise GripInitError(f"{action} failed: {diagnostic}")
1170
+
1171
+
1172
+ def grip_snapshot(
1173
+ workspace: Path,
1174
+ repos: dict[str, Path],
1175
+ *,
1176
+ changeset_type: str = "",
1177
+ sprint: str = "",
1178
+ message: str = "",
1179
+ overlay_dir: Path | None = None,
1180
+ ) -> str:
1181
+ """Create a grip commit from current repo states. Returns commit SHA."""
1182
+ _validate_grip_repo(workspace)
1183
+ repo_entries: list[str] = []
1184
+ for name in sorted(repos):
1185
+ entry = _repo_tree_entries(workspace, name, repos[name])
1186
+ repo_entries.append(entry)
1187
+
1188
+ repos_tree = _mktree(workspace, repo_entries)
1189
+ root_entries = [f"040000 tree {repos_tree}\trepos"]
1190
+
1191
+ cs_tree = _changeset_tree(workspace, changeset_type=changeset_type, sprint=sprint)
1192
+ if cs_tree:
1193
+ root_entries.append(f"040000 tree {cs_tree}\t.grip")
1194
+
1195
+ if overlay_dir and overlay_dir.is_dir():
1196
+ config_tree = _config_overlay_tree(workspace, overlay_dir)
1197
+ if config_tree:
1198
+ root_entries.append(f"040000 tree {config_tree}\tconfig")
1199
+
1200
+ root_tree = _mktree(workspace, root_entries)
1201
+
1202
+ parent = _current_head(workspace)
1203
+ commit_msg = message or f"grip snapshot ({changeset_type})" if changeset_type else message or "grip snapshot"
1204
+ commit_sha = _commit_tree(workspace, root_tree, parent=parent, message=commit_msg)
1205
+
1206
+ _grip_git(workspace, "update-ref", "HEAD", commit_sha)
1207
+
1208
+ return commit_sha
1209
+
1210
+
1211
+ def grip_log(workspace: Path, *, max_count: int = 10) -> list[GripCommitInfo]:
1212
+ """List grip commit history, most recent first."""
1213
+ _validate_grip_repo(workspace)
1214
+ head = _current_head(workspace)
1215
+ if not head:
1216
+ return []
1217
+
1218
+ proc = _grip_git(
1219
+ workspace,
1220
+ "log",
1221
+ f"--max-count={max_count}",
1222
+ "--format=%H%n%s%n%aI%n---",
1223
+ "HEAD",
1224
+ )
1225
+ if proc.returncode != 0:
1226
+ return []
1227
+
1228
+ entries: list[GripCommitInfo] = []
1229
+ chunks = proc.stdout.strip().split("---\n")
1230
+ for chunk in chunks:
1231
+ chunk = chunk.strip().rstrip("---").strip()
1232
+ if not chunk:
1233
+ continue
1234
+ lines = chunk.splitlines()
1235
+ if len(lines) < 2:
1236
+ continue
1237
+ sha = lines[0].strip()
1238
+ msg = lines[1].strip()
1239
+ ts = lines[2].strip() if len(lines) > 2 else ""
1240
+
1241
+ repo_names = _read_repo_names(workspace, sha)
1242
+ entries.append(GripCommitInfo(sha=sha, message=msg, repos=repo_names, timestamp=ts))
1243
+
1244
+ return entries
1245
+
1246
+
1247
+ def _read_repo_names(workspace: Path, commit_sha: str) -> list[str]:
1248
+ proc = _grip_git(workspace, "ls-tree", f"{commit_sha}:repos")
1249
+ if proc.returncode != 0:
1250
+ return []
1251
+ return [
1252
+ line.split("\t")[-1]
1253
+ for line in proc.stdout.strip().splitlines()
1254
+ if line.strip()
1255
+ ]
1256
+
1257
+
1258
+ def grip_diff(workspace: Path, ref_a: str, ref_b: str) -> GripDiff:
1259
+ """Compare two grip commits and return changed/added/removed repos."""
1260
+ _validate_grip_repo(workspace)
1261
+ repos_a = _read_repo_state(workspace, ref_a)
1262
+ repos_b = _read_repo_state(workspace, ref_b)
1263
+
1264
+ result = GripDiff()
1265
+
1266
+ all_names = set(repos_a.keys()) | set(repos_b.keys())
1267
+ for name in sorted(all_names):
1268
+ if name in repos_a and name not in repos_b:
1269
+ result.removed.append(name)
1270
+ elif name not in repos_a and name in repos_b:
1271
+ result.added.append(name)
1272
+ else:
1273
+ old_commit = repos_a[name].get("commit", "")
1274
+ new_commit = repos_b[name].get("commit", "")
1275
+ if old_commit != new_commit:
1276
+ result.changed[name] = {
1277
+ "old_commit": old_commit,
1278
+ "new_commit": new_commit,
1279
+ }
1280
+
1281
+ return result
1282
+
1283
+
1284
+ def _read_repo_state(workspace: Path, ref: str) -> dict[str, dict[str, str]]:
1285
+ """Read all repo states from a grip commit."""
1286
+ proc = _grip_git(workspace, "ls-tree", f"{ref}:repos")
1287
+ if proc.returncode != 0:
1288
+ return {}
1289
+
1290
+ repos: dict[str, dict[str, str]] = {}
1291
+ for line in proc.stdout.strip().splitlines():
1292
+ if not line.strip():
1293
+ continue
1294
+ name = line.split("\t")[-1]
1295
+ state: dict[str, str] = {}
1296
+ fields = _grip_git(workspace, "ls-tree", f"{ref}:repos/{name}")
1297
+ if fields.returncode == 0:
1298
+ for fline in fields.stdout.strip().splitlines():
1299
+ if not fline.strip():
1300
+ continue
1301
+ fname = fline.split("\t")[-1]
1302
+ blob = _grip_git(workspace, "show", f"{ref}:repos/{name}/{fname}")
1303
+ if blob.returncode == 0:
1304
+ state[fname] = blob.stdout.strip()
1305
+ repos[name] = state
1306
+
1307
+ return repos
1308
+
1309
+
1310
+ def grip_checkout(workspace: Path, ref: str) -> dict[str, str]:
1311
+ """Read a grip commit and checkout matching commits in workspace repos.
1312
+
1313
+ Returns dict mapping repo name to commit SHA.
1314
+ """
1315
+ _validate_grip_repo(workspace)
1316
+
1317
+ # Verify the ref resolves to a valid object
1318
+ verify = _grip_git(workspace, "cat-file", "-t", ref)
1319
+ if verify.returncode != 0:
1320
+ raise GripCorruptError(
1321
+ f"Ref '{ref}' does not resolve to a valid object in .grip/ repo."
1322
+ )
1323
+
1324
+ repo_states = _read_repo_state(workspace, ref)
1325
+ result: dict[str, str] = {}
1326
+
1327
+ for name, state in sorted(repo_states.items()):
1328
+ commit_sha = state.get("commit", "")
1329
+ if not commit_sha:
1330
+ continue
1331
+ result[name] = commit_sha
1332
+
1333
+ repo_path = workspace / name
1334
+ if repo_path.is_dir():
1335
+ git(repo_path, "checkout", commit_sha)
1336
+
1337
+ return result