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/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
|
+
)
|
gr2/python_cli/review.py
ADDED
|
@@ -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}")
|