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/push.py ADDED
@@ -0,0 +1,172 @@
1
+ """Native single-repository push with explicit remote and arrival evidence."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+ from .gitops import git
9
+
10
+
11
+ class PushError(Exception):
12
+ pass
13
+
14
+
15
+ class PushEvidenceError(PushError):
16
+ """The push was acknowledged, but its remote result cannot be verified."""
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class PushReceipt:
21
+ remote: str
22
+ branch: str
23
+ local_sha: str
24
+ remote_sha: str
25
+ set_upstream: bool
26
+ force_with_lease: bool
27
+
28
+
29
+ def _current_branch(repo: Path) -> str:
30
+ try:
31
+ result = git(repo, "branch", "--show-current")
32
+ except OSError as exc:
33
+ raise PushError(f"failed to determine the current branch in {repo}: {exc}") from exc
34
+ if result.returncode != 0:
35
+ detail = (result.stderr or result.stdout).strip()
36
+ raise PushError(detail or "failed to determine the current branch")
37
+ branch = result.stdout.strip()
38
+ if not branch:
39
+ raise PushError("cannot push from detached HEAD")
40
+ return branch
41
+
42
+
43
+ def _remote_names(repo: Path) -> tuple[str, ...]:
44
+ try:
45
+ result = git(repo, "remote")
46
+ except OSError as exc:
47
+ raise PushError(f"failed to enumerate remotes in {repo}: {exc}") from exc
48
+ if result.returncode != 0:
49
+ detail = (result.stderr or result.stdout).strip()
50
+ raise PushError(detail or "failed to enumerate git remotes")
51
+ return tuple(line.strip() for line in result.stdout.splitlines() if line.strip())
52
+
53
+
54
+ def _select_remote(repo: Path, branch: str, explicit: str | None) -> str:
55
+ remotes = _remote_names(repo)
56
+ if explicit is not None:
57
+ if explicit not in remotes:
58
+ raise PushError(f"remote '{explicit}' is not configured in {repo}")
59
+ return explicit
60
+
61
+ for key in (f"branch.{branch}.pushRemote", "remote.pushDefault", f"branch.{branch}.remote"):
62
+ try:
63
+ configured = git(repo, "config", "--get", key)
64
+ except OSError as exc:
65
+ raise PushError(f"failed to inspect configured push destination {key}: {exc}") from exc
66
+ if configured.returncode == 0 and configured.stdout.strip():
67
+ remote = configured.stdout.strip()
68
+ if remote not in remotes:
69
+ raise PushError(f"{key} names unavailable remote '{remote}'")
70
+ return remote
71
+ if configured.returncode not in {0, 1}:
72
+ detail = (configured.stderr or configured.stdout).strip()
73
+ raise PushError(detail or f"failed to inspect configured push destination {key}")
74
+
75
+ if len(remotes) == 1:
76
+ return remotes[0]
77
+ if not remotes:
78
+ raise PushError("no git remote is configured; pass --remote after adding one")
79
+ raise PushError(
80
+ f"multiple remotes are configured ({', '.join(remotes)}); pass --remote or configure the branch upstream"
81
+ )
82
+
83
+
84
+ def _head_sha(repo: Path) -> str:
85
+ try:
86
+ result = git(repo, "rev-parse", "--verify", "HEAD")
87
+ except OSError as exc:
88
+ raise PushError(f"failed to resolve HEAD in {repo}: {exc}") from exc
89
+ sha = result.stdout.strip() if result.returncode == 0 else ""
90
+ if not sha:
91
+ raise PushError("cannot push because HEAD is not a commit")
92
+ return sha
93
+
94
+
95
+ def _remote_branch_sha(repo: Path, remote: str, branch: str) -> str:
96
+ ref = f"refs/heads/{branch}"
97
+ try:
98
+ result = git(repo, "ls-remote", "--heads", remote, ref)
99
+ except OSError as exc:
100
+ raise PushEvidenceError(
101
+ f"push was acknowledged but remote receipt evidence was unavailable: {exc}"
102
+ ) from exc
103
+ if result.returncode != 0:
104
+ detail = (result.stderr or result.stdout).strip()
105
+ raise PushEvidenceError(
106
+ detail or "push was acknowledged but the remote branch could not be queried"
107
+ )
108
+ rows = [line.split() for line in result.stdout.splitlines() if line.strip()]
109
+ matches = [parts[0] for parts in rows if len(parts) == 2 and parts[1] == ref]
110
+ if len(matches) != 1:
111
+ raise PushEvidenceError(
112
+ f"push was acknowledged but remote '{remote}' did not provide exactly one receipt for {ref}"
113
+ )
114
+ return matches[0]
115
+
116
+
117
+ def _refuse_review_ephemeral_repo(repo: Path) -> None:
118
+ """A review-ephemeral lane is read-only and disposable: refuse a push from it,
119
+ naming the kind, so a review lane never becomes a work lane."""
120
+ import json as _json
121
+ record = Path(repo) / ".git" / "grip-review.json"
122
+ try:
123
+ kind = _json.loads(record.read_text()).get("lane_kind")
124
+ except (OSError, ValueError):
125
+ return
126
+ if kind == "review-ephemeral":
127
+ raise PushError(
128
+ f"{repo} is a review-ephemeral review lane (read-only, disposable): it "
129
+ "cannot be pushed. A review lane never becomes a work lane."
130
+ )
131
+
132
+
133
+ def push_current_branch(
134
+ repo: Path,
135
+ *,
136
+ remote: str | None = None,
137
+ set_upstream: bool = False,
138
+ force_with_lease: bool = False,
139
+ ) -> PushReceipt:
140
+ """Push the current branch and verify that the remote ref equals HEAD."""
141
+ _refuse_review_ephemeral_repo(repo)
142
+ branch = _current_branch(repo)
143
+ selected_remote = _select_remote(repo, branch, remote)
144
+ local_sha = _head_sha(repo)
145
+
146
+ args = ["push"]
147
+ if set_upstream:
148
+ args.append("--set-upstream")
149
+ if force_with_lease:
150
+ args.append("--force-with-lease")
151
+ args.extend([selected_remote, branch])
152
+ try:
153
+ pushed = git(repo, *args)
154
+ except OSError as exc:
155
+ raise PushError(f"failed to launch git push in {repo}: {exc}") from exc
156
+ if pushed.returncode != 0:
157
+ detail = (pushed.stderr or pushed.stdout).strip()
158
+ raise PushError(detail or f"git push failed with exit {pushed.returncode}")
159
+
160
+ remote_sha = _remote_branch_sha(repo, selected_remote, branch)
161
+ if remote_sha != local_sha:
162
+ raise PushEvidenceError(
163
+ f"push was acknowledged but remote {selected_remote}/{branch} is {remote_sha}, expected {local_sha}"
164
+ )
165
+ return PushReceipt(
166
+ remote=selected_remote,
167
+ branch=branch,
168
+ local_sha=local_sha,
169
+ remote_sha=remote_sha,
170
+ set_upstream=set_upstream,
171
+ force_with_lease=force_with_lease,
172
+ )
@@ -0,0 +1,462 @@
1
+ """Repo-tier review primitive.
2
+
3
+ `gr review open` materializes a PR head into an isolated review lane over the
4
+ grip#807 reference-clone seam (``ensure_lane_checkout``), and adds the review
5
+ behavior the raw lane materialization does not carry:
6
+
7
+ * the lane must land at the EXPECTED head sha, and the expected sha is bound
8
+ from the host's own advertisement of the PR head, independently of the fetch
9
+ that brings the bytes down — a mismatch REFUSES, never warns (verdict
10
+ binding);
11
+ * the import resolution is PRINTED, so the reviewer sees which tree their run
12
+ imports and cannot mistake a machine-wide install for the reviewed code;
13
+ * every dispatched execution first asserts its cwd is CONTAINED by the lane
14
+ (a child-routing defect: a dispatched child must not run against the base tree);
15
+ * the review is recorded as the (repo, base, head) triple plus a required
16
+ ``lane_kind`` stamp — where ``repo`` is the canonical, transport-independent
17
+ GitHub source identity — a one-entry pin-delta so the project tier can adopt
18
+ it without a second pin spelling, and ``lane_kind`` tells a reader whether the
19
+ head reconstructs independently (``materialized``) or from the author's
20
+ worktree under the bind-time guard (``bound``).
21
+
22
+ Two destructive operations are fenced: a REUSED lane is never deleted (cleanup
23
+ may only remove what this call created), and ``close`` refuses any path that is
24
+ not a review lane this tool opened.
25
+
26
+ This module is the pure, testable core. The Typer verbs are thin shells that
27
+ resolve the source repo from the workspace spec and the PR pins from the host,
28
+ then call in here.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import json
34
+ import re
35
+ import shutil
36
+ import subprocess
37
+ from collections.abc import Callable, Sequence
38
+ from dataclasses import dataclass
39
+ from pathlib import Path
40
+
41
+ from . import gitops
42
+ from .clone_exec import CloneExecutionError, IncompleteRemoval, rmtree_or_refuse
43
+ from .gitops import ensure_lane_checkout
44
+
45
+ _SHA40 = re.compile(r"\A[0-9a-f]{40}\Z")
46
+
47
+ # A review receipt is stamped with the KIND of lane it came from, so a reader
48
+ # never has to infer the reconstruction guarantee. ``materialized``: an isolated
49
+ # clone pinned at the recorded head, reconstructible independently of anything
50
+ # else on disk. ``bound``: derived from an author's own worktree at bind time,
51
+ # honest only under the clean-tree/HEAD-matches guard the bind imposes. The
52
+ # field is REQUIRED on every receipt (gr2-lane-author-shape ruling 2026-09-03).
53
+ LANE_KINDS = ("materialized", "bound", "review-ephemeral")
54
+
55
+ Echo = Callable[[str], None]
56
+
57
+
58
+ class ReviewError(Exception):
59
+ """A review-lane operation refused: a wrong head, a broken isolation
60
+ invariant, a dispatched execution whose cwd escapes the lane, or a
61
+ destructive operation whose target could not be proven safe. A refusal is
62
+ terminal — the caller must not proceed as if it were a warning."""
63
+
64
+
65
+ # --------------------------------------------------------------------------- #
66
+ # repository identity (transport-independent)
67
+ # --------------------------------------------------------------------------- #
68
+ _GITHUB_SSH_SCP = re.compile(r"\Agit@github\.com:(?P<owner>[^/]+)/(?P<repo>.+?)(?:\.git)?\Z")
69
+ _GITHUB_SSH_URL = re.compile(
70
+ r"\Assh://git@github\.com/(?P<owner>[^/]+)/(?P<repo>[^/?#]+?)(?:\.git)?(?:[/?#].*)?\Z"
71
+ )
72
+ _GITHUB_HTTPS = re.compile(
73
+ r"\Ahttps?://(?:[^@/]+@)?github\.com/(?P<owner>[^/]+)/(?P<repo>[^/?#]+?)(?:\.git)?(?:[/?#].*)?\Z"
74
+ )
75
+
76
+
77
+ def canonical_source_identity(remote_url: str, *, allow_local: bool = False) -> str:
78
+ """Reduce a clone URL to the repository's portable identity.
79
+
80
+ Clone transport (SSH vs HTTPS, credentials, a machine path) answers *how the
81
+ bytes arrive*; repository identity answers *which repository they are*. A
82
+ cross-workspace lock needs one key per repository, so SSH and HTTPS clones of
83
+ the same GitHub repo MUST canonicalize to the same bytes:
84
+ ``https://github.com/<owner>/<repo>`` (lowercase, no ``.git``, no
85
+ credentials, no query or fragment).
86
+
87
+ A filesystem origin supports local test materialization but is not a
88
+ portable committed-lock identity; it is refused unless ``allow_local`` is
89
+ set, in which case it is marked ``local:<path>`` so it can never masquerade
90
+ as a projectable identity."""
91
+ url = (remote_url or "").strip()
92
+ for pattern in (_GITHUB_SSH_SCP, _GITHUB_SSH_URL, _GITHUB_HTTPS):
93
+ match = pattern.match(url)
94
+ if match:
95
+ return f"https://github.com/{match.group('owner').lower()}/{match.group('repo').lower()}"
96
+ if allow_local:
97
+ return f"local:{Path(url).resolve()}"
98
+ raise ReviewError(
99
+ f"origin {url!r} is not a canonical GitHub source; a review record's repo "
100
+ "identity must be a portable https://github.com/<owner>/<repo> (no .git). "
101
+ "A filesystem origin is clone transport, not a lock-projectable identity; "
102
+ "pass allow_local only for a non-portable local test lane."
103
+ )
104
+
105
+
106
+ @dataclass(frozen=True)
107
+ class ReviewRecord:
108
+ """The repo-tier review IS this triple: a one-entry pin-delta.
109
+
110
+ ``repo`` is the canonical, transport-independent source identity (never a
111
+ clone URL or a local working path), ``base`` and ``head`` are lowercase full
112
+ 40-hex commit object IDs. Kept minimal on purpose so a project-tier lock can
113
+ project this record without a second pin spelling.
114
+
115
+ ``lane_kind`` (``materialized`` | ``bound``) is REQUIRED so a reader never
116
+ infers the reconstruction guarantee: a materialized lane reconstructs from a
117
+ carried range independently of the author's disk; a bound lane's bytes live
118
+ in the author's worktree and are honest only under the bind-time guard."""
119
+
120
+ repo: str
121
+ base: str
122
+ head: str
123
+ lane_kind: str
124
+
125
+ def __post_init__(self) -> None:
126
+ if self.lane_kind not in LANE_KINDS:
127
+ raise ReviewError(
128
+ f"review record lane_kind must be one of {LANE_KINDS}, got "
129
+ f"{self.lane_kind!r}"
130
+ )
131
+
132
+ def to_dict(self) -> dict[str, str]:
133
+ return {"repo": self.repo, "base": self.base, "head": self.head, "lane_kind": self.lane_kind}
134
+
135
+
136
+ def review_record_path(lane_repo_root: Path | str) -> Path:
137
+ """Where the triple is stored: inside the lane clone's own ``.git`` (a real
138
+ directory per the #807 isolation contract), so it is untracked by the lane's
139
+ working tree, removed when the lane is dropped, and serves as the identity
140
+ marker that a directory is a review lane this tool opened."""
141
+ return Path(lane_repo_root) / ".git" / "grip-review.json"
142
+
143
+
144
+ def assert_cwd_contained(cwd: Path | str, lane_root: Path | str) -> Path:
145
+ """Refuse any execution whose working directory is not the lane or a
146
+ descendant of it (a dispatched child must not route its execution out of the
147
+ isolated lane). Returns the resolved cwd on success."""
148
+ cwd_r = Path(cwd).resolve()
149
+ lane_r = Path(lane_root).resolve()
150
+ if cwd_r != lane_r and lane_r not in cwd_r.parents:
151
+ raise ReviewError(
152
+ f"dispatched execution cwd {cwd_r} escapes the review lane {lane_r}; "
153
+ "refusing to run — a review command must execute inside its lane"
154
+ )
155
+ return cwd_r
156
+
157
+
158
+ def _require_sha(label: str, value: str) -> str:
159
+ if not isinstance(value, str) or not _SHA40.match(value):
160
+ raise ReviewError(f"{label} must be a lowercase full 40-hex commit sha, got {value!r}")
161
+ return value
162
+
163
+
164
+ def host_pr_head_oid(source_repo_root: Path, pr_number: int, *, remote: str = "origin") -> str:
165
+ """The host's own OID for a PR head, read straight from the remote's ref
166
+ advertisement (``ls-remote``) — independent of any local fetch. This is the
167
+ authority ``expected_head_sha`` must be bound from, so that a wrong or
168
+ tampered fetch (whose local ref would otherwise be compared against itself)
169
+ is detectable."""
170
+ ref = f"refs/pull/{pr_number}/head"
171
+ result = gitops.git(Path(source_repo_root), "ls-remote", remote, ref)
172
+ line = result.stdout.strip()
173
+ if result.returncode != 0 or not line:
174
+ raise ReviewError(
175
+ f"cannot read the host OID for PR {pr_number} ({ref}) on {remote!r}: "
176
+ f"{result.stderr.strip() or 'ref not advertised'}"
177
+ )
178
+ oid = line.split()[0].strip().lower()
179
+ if not _SHA40.match(oid):
180
+ raise ReviewError(f"host advertised a non-sha OID for PR {pr_number}: {oid!r}")
181
+ return oid
182
+
183
+
184
+ def open_review_lane(
185
+ *,
186
+ source_repo_root: Path,
187
+ review_branch: str,
188
+ expected_head_sha: str,
189
+ base_sha: str,
190
+ lane_repo_root: Path,
191
+ workspace_root: Path,
192
+ allow_local: bool = False,
193
+ ephemeral: bool = False,
194
+ repo_name: str | None = None,
195
+ echo: Echo = print,
196
+ ) -> ReviewRecord:
197
+ """Materialize the review lane at the expected head and record the triple.
198
+
199
+ ``expected_head_sha`` is the host-observed PR head (see ``host_pr_head_oid``);
200
+ ``review_branch`` must exist in the source and hold the fetched head. A
201
+ source ref that does not match ``expected_head_sha`` is a raced or tampered
202
+ head and REFUSES before anything is materialized. A REUSED lane is never
203
+ deleted: cleanup removes only a lane this call created."""
204
+ source_repo_root = Path(source_repo_root)
205
+ lane_repo_root = Path(lane_repo_root)
206
+ _require_sha("expected head", expected_head_sha)
207
+ _require_sha("base pin", base_sha)
208
+
209
+ # 1. Verdict binding, BEFORE materializing: the fetched source ref must equal
210
+ # the host-observed head. A mismatch refuses; it never warps the lane into
211
+ # a different commit and calls it the PR, and it never reaches the seam.
212
+ actual = gitops.git(source_repo_root, "rev-parse", "--verify", f"{review_branch}^{{commit}}")
213
+ if actual.returncode != 0:
214
+ raise ReviewError(
215
+ f"review branch {review_branch!r} does not resolve in {source_repo_root}: "
216
+ f"{actual.stderr.strip() or actual.stdout.strip()}"
217
+ )
218
+ if actual.stdout.strip() != expected_head_sha:
219
+ raise ReviewError(
220
+ f"review head mismatch: {review_branch} fetched {actual.stdout.strip()} but the "
221
+ f"host advertises {expected_head_sha} for this PR; refusing to open a review on a "
222
+ "head that is not the one the host is serving"
223
+ )
224
+
225
+ # 2. Canonical, transport-independent repo identity — never the clone URL or
226
+ # a local working path.
227
+ repo_identity = canonical_source_identity(
228
+ gitops.remote_origin_url(source_repo_root) or "", allow_local=allow_local
229
+ )
230
+
231
+ # 3. Materialize over the #807 seam. Capture created-vs-reused: it decides
232
+ # whether cleanup below is permitted to delete anything.
233
+ # ``review_branch`` selects the source object. The local lane branch is a
234
+ # separate safe name, so a caller that supplies an immutable SHA never turns
235
+ # that SHA into a mutable branch selector at the clone seam.
236
+ # The destination is one review lane, so its local branch is intentionally
237
+ # stable across a later reopen. The immutable seed, not this branch spelling,
238
+ # decides which commit is reviewed.
239
+ lane_branch = "grip-review/open"
240
+ if ephemeral:
241
+ # A review-ephemeral lane is a blobless+sparse clone from the persistent
242
+ # mirror -- NEVER the work-lane clone seam (materialize_lane_clone), whose
243
+ # 8.1/8.2 invariants guard MUTABLE lanes. It is read-only and rm -rf'd on
244
+ # close, so those invariants exceed its needs; blobless keeps the whole
245
+ # commit+tree graph the review uses.
246
+ from . import review_ephemeral
247
+ if lane_repo_root.exists():
248
+ existing = gitops.current_head_sha(lane_repo_root)
249
+ if existing != expected_head_sha:
250
+ raise ReviewError(
251
+ f"an existing review lane at {lane_repo_root} is at {existing!r}, not the "
252
+ f"requested head {expected_head_sha}; run `review close` to drop it, then reopen."
253
+ )
254
+ first_materialize = False
255
+ else:
256
+ review_ephemeral.materialize_review_ephemeral(
257
+ mirror=source_repo_root, dest=lane_repo_root,
258
+ head=expected_head_sha, base=base_sha,
259
+ repo_name=(repo_name or source_repo_root.name.removesuffix(".git")),
260
+ )
261
+ first_materialize = True
262
+ lane_head = gitops.git(lane_repo_root, "rev-parse", "HEAD")
263
+ if lane_head.returncode != 0 or lane_head.stdout.strip() != expected_head_sha:
264
+ if first_materialize:
265
+ try:
266
+ rmtree_or_refuse(lane_repo_root)
267
+ except IncompleteRemoval as cleanup_exc:
268
+ raise ReviewError(
269
+ f"review-ephemeral lane is at {lane_head.stdout.strip()!r}, not the "
270
+ f"expected head {expected_head_sha}, AND it could not be fully "
271
+ f"discarded: {cleanup_exc}"
272
+ ) from cleanup_exc
273
+ raise ReviewError(
274
+ f"review-ephemeral lane is at {lane_head.stdout.strip()!r}, not the expected "
275
+ f"head {expected_head_sha}; lane discarded"
276
+ )
277
+ echo(f"review lane (ephemeral): {lane_repo_root.resolve()}")
278
+ record = ReviewRecord(repo=repo_identity, base=base_sha, head=expected_head_sha,
279
+ lane_kind=review_ephemeral.REVIEW_EPHEMERAL_KIND)
280
+ path = review_record_path(lane_repo_root)
281
+ path.parent.mkdir(parents=True, exist_ok=True)
282
+ path.write_text(json.dumps(record.to_dict(), indent=2) + "\n")
283
+ return record
284
+ try:
285
+ first_materialize = ensure_lane_checkout(
286
+ source_repo_root=source_repo_root,
287
+ target_repo_root=lane_repo_root,
288
+ branch=lane_branch,
289
+ seed_commit=expected_head_sha,
290
+ workspace_root=workspace_root,
291
+ )
292
+ except CloneExecutionError as exc:
293
+ # The clone seam checks the requested seed during reuse, before it can
294
+ # mutate a reviewer-owned lane. Translate that refusal into the review
295
+ # contract's existing preservation wording.
296
+ if lane_repo_root.exists() and gitops.current_head_sha(lane_repo_root) != expected_head_sha:
297
+ raise ReviewError(
298
+ f"an existing review lane at {lane_repo_root} is not the requested head "
299
+ f"{expected_head_sha}. It is preserved unchanged (it may hold uncommitted "
300
+ "review notes); run `review close` to drop it, then reopen."
301
+ ) from exc
302
+ raise ReviewError(str(exc)) from exc
303
+
304
+ # 4. The lane must be at the expected head. On a FRESH lane, materialize
305
+ # binds HEAD to the seed, so a mismatch is a genuine fault and the lane we
306
+ # just made is discarded. On a REUSED lane, materialize leaves it
307
+ # byte-for-byte (grip#807 never resets/fetches/switches), so a mismatch
308
+ # means the reviewer already has an open lane at another head — REFUSE and
309
+ # PRESERVE it (their uncommitted notes are exactly what this primitive
310
+ # exists to protect); only an explicit `review close` may remove it.
311
+ lane_head = gitops.git(lane_repo_root, "rev-parse", "HEAD")
312
+ if lane_head.returncode != 0 or lane_head.stdout.strip() != expected_head_sha:
313
+ if first_materialize:
314
+ try:
315
+ rmtree_or_refuse(lane_repo_root)
316
+ except IncompleteRemoval as cleanup_exc:
317
+ raise ReviewError(
318
+ f"freshly materialized lane is at {lane_head.stdout.strip()!r}, not "
319
+ f"the expected head {expected_head_sha}, AND it could not be fully "
320
+ f"discarded: {cleanup_exc}"
321
+ ) from cleanup_exc
322
+ raise ReviewError(
323
+ f"freshly materialized lane is at {lane_head.stdout.strip()!r}, not the "
324
+ f"expected head {expected_head_sha}; lane discarded"
325
+ )
326
+ raise ReviewError(
327
+ f"an existing review lane at {lane_repo_root} is at "
328
+ f"{lane_head.stdout.strip()!r}, not the requested head {expected_head_sha}. "
329
+ "It is preserved unchanged (it may hold uncommitted review notes); run "
330
+ "`review close` to drop it, then reopen."
331
+ )
332
+
333
+ # 5. Print import resolution: name the lane so a reviewer cannot mistake a
334
+ # machine-wide install for the reviewed tree (the PYTHONPATH trap).
335
+ src_dir = lane_repo_root / "src"
336
+ pythonpath = src_dir if src_dir.is_dir() else lane_repo_root
337
+ echo(f"review lane: {lane_repo_root.resolve()}")
338
+ echo(
339
+ f"import resolution: PYTHONPATH={pythonpath.resolve()} — run the review "
340
+ "from the lane so imports resolve to the reviewed tree, not a "
341
+ "machine-wide install"
342
+ )
343
+
344
+ # 6. Record the triple plus the lane kind. This path always materializes an
345
+ # isolated clone pinned at the expected head, so the receipt is stamped
346
+ # ``materialized`` — reconstructible independently of any author worktree.
347
+ record = ReviewRecord(repo=repo_identity, base=base_sha, head=expected_head_sha, lane_kind="materialized")
348
+ path = review_record_path(lane_repo_root)
349
+ path.parent.mkdir(parents=True, exist_ok=True)
350
+ path.write_text(json.dumps(record.to_dict(), indent=2) + "\n")
351
+ return record
352
+
353
+
354
+ def run_in_review_lane(
355
+ lane_repo_root: Path,
356
+ command: Sequence[str],
357
+ *,
358
+ cwd: Path | str | None = None,
359
+ echo: Echo = print,
360
+ ) -> subprocess.CompletedProcess:
361
+ """Dispatch a review command inside the lane, refusing BEFORE spawning if
362
+ its cwd escapes the lane.
363
+
364
+ This is the one place the review primitive spawns a child, so it is where
365
+ the cwd-containment gate must fire (a dispatched child routing its execution
366
+ out of the isolated lane is the defect). The assertion runs before
367
+ ``subprocess.run``, never after — a child that has already started outside
368
+ the lane has already read the wrong tree."""
369
+ lane_repo_root = Path(lane_repo_root)
370
+ run_cwd = assert_cwd_contained(cwd if cwd is not None else lane_repo_root, lane_repo_root)
371
+ echo(f"review dispatch (cwd={run_cwd}): {' '.join(command)}")
372
+ return subprocess.run(list(command), cwd=run_cwd)
373
+
374
+
375
+ def close_review_lane(
376
+ *, lane_repo_root: Path, review_lane_root: Path, echo: Echo = print
377
+ ) -> None:
378
+ """Drop a review lane, refusing anything the tool does not own.
379
+
380
+ ``close`` is a recursive delete, and **provenance cannot come from the
381
+ record**: every field of the record is attacker-suppliable, so a same-origin
382
+ clone with a fully self-consistent planted record (matching repo, its own
383
+ HEAD) could otherwise route a delete at an arbitrary directory. Repo identity
384
+ is not lane provenance.
385
+
386
+ So the PRIMARY gate is a property the tool owns and a foreign clone cannot
387
+ forge: the target must resolve STRICTLY BENEATH ``review_lane_root`` — the
388
+ lane tree this tool manages under the workspace. A path outside that tree, or
389
+ the root ITSELF, is refused before anything it contains is read, whatever
390
+ record it carries. The root itself is refused deliberately: a recursive delete
391
+ of the managed root would take every sibling lane and the lane index with it,
392
+ so a self-consistent record planted AT the root must not license it — only a
393
+ strict descendant (an individual lane's own clone) is a legitimate target.
394
+
395
+ The record checks — owned ``.git`` directory, a well-formed
396
+ ``(repo, base, head, lane_kind)`` receipt, repo matching the lane's own
397
+ origin, and HEAD matching the recorded head — are SECONDARY consistency checks INSIDE that
398
+ boundary: they catch a corrupted or MOVED lane (a reviewer commit or reset
399
+ that means the lane may hold work), not a foreign one. The base workspace is
400
+ never touched — only the lane clone is removed."""
401
+ lane = Path(lane_repo_root).resolve()
402
+ root = Path(review_lane_root).resolve()
403
+
404
+ # PRIMARY, forgery-proof: STRICTLY beneath the tool-managed review-lane root.
405
+ # `root in lane.parents` is true only when root is a proper ancestor of lane,
406
+ # so this refuses both a path outside the tree AND the root itself (aiming the
407
+ # delete at the root would take every sibling lane and the index with it).
408
+ if root not in lane.parents:
409
+ raise ReviewError(
410
+ f"{lane} is not strictly beneath the review-lane root {root} this tool manages "
411
+ "(a path outside the tree, or the root itself, is refused); refusing to delete, "
412
+ "whatever record it carries."
413
+ )
414
+
415
+ if not lane.exists():
416
+ echo(f"no review lane at {lane}")
417
+ return
418
+
419
+ # SECONDARY consistency, inside the owned boundary.
420
+ git_dir = lane / ".git"
421
+ record_path = review_record_path(lane)
422
+ if not (git_dir.is_dir() and not git_dir.is_symlink()) or not record_path.is_file():
423
+ raise ReviewError(
424
+ f"{lane} lacks an owned .git directory or the review record; refusing to "
425
+ "delete even inside the lane tree."
426
+ )
427
+ try:
428
+ record = json.loads(record_path.read_text())
429
+ except (OSError, json.JSONDecodeError) as exc:
430
+ raise ReviewError(
431
+ f"{lane} review record is unreadable or malformed ({exc}); refusing to delete."
432
+ ) from exc
433
+ if set(record) != {"repo", "base", "head", "lane_kind"} or not (
434
+ isinstance(record.get("repo"), str)
435
+ and record["repo"]
436
+ and _SHA40.match(str(record.get("base", "")))
437
+ and _SHA40.match(str(record.get("head", "")))
438
+ and record.get("lane_kind") in LANE_KINDS
439
+ ):
440
+ raise ReviewError(
441
+ f"{lane} review record is not a well-formed (repo, base, head, lane_kind) receipt; "
442
+ "refusing to delete."
443
+ )
444
+ lane_identity = canonical_source_identity(
445
+ gitops.remote_origin_url(lane) or "", allow_local=True
446
+ )
447
+ if record["repo"] != lane_identity:
448
+ raise ReviewError(
449
+ f"{lane} review record repo {record['repo']!r} does not match the lane's own "
450
+ f"origin identity {lane_identity!r}; refusing to delete."
451
+ )
452
+ lane_head = gitops.git(lane, "rev-parse", "HEAD")
453
+ if lane_head.returncode != 0 or lane_head.stdout.strip() != record["head"]:
454
+ raise ReviewError(
455
+ f"{lane} is at HEAD {lane_head.stdout.strip()!r}, not the recorded review head "
456
+ f"{record['head']}; the lane has moved (a commit, reset, or repointed clone) and "
457
+ "may hold work — refusing to delete. Reset it to the recorded head, or remove it "
458
+ "by hand once you have saved anything you need."
459
+ )
460
+
461
+ shutil.rmtree(lane)
462
+ echo(f"review lane dropped: {lane}")