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
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
|