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
@@ -0,0 +1,621 @@
1
+ """`review run <lane-dir>`: the review-owned in-lane test run — the last raw-shell
2
+ exit point (venv + install + pytest by hand) folded into one verb.
3
+
4
+ It runs ONLY inside an `open-gr --enter` reconstruction lane (it reads the
5
+ `.grip-open-gr-reconstruct.json` marker), so a green is always about a bound tree.
6
+ Two structural bindings make the green mean something:
7
+
8
+ * THE TREE COMPARISON — the lane's current working tree must equal the marker's
9
+ bound head-tree. One comparison catches both a wrong reconstruction and a lane
10
+ drifted after open (a touched tracked file changes the tree). Asserted BEFORE
11
+ the venv is created, so the venv never pollutes the hash.
12
+ * IMPORT UNDER THE LANE — after install, the package's resolved `__file__` must
13
+ live under the lane, or a second checkout shadowing it on `PYTHONPATH` would let
14
+ a pass be about someone else's tree (the stale editable-install trap).
15
+
16
+ Counts come from pytest's SUMMARY LINE, never the exit code (a run that collected
17
+ zero tests exits 0). A run that collects/selects zero tests, or whose summary line
18
+ cannot be parsed, is a REFUSAL, not a green.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import os
24
+ import re
25
+ import shlex
26
+ import subprocess
27
+ import sys
28
+ import tempfile
29
+ from datetime import datetime, timezone
30
+ from pathlib import Path
31
+
32
+ # An in-repo hint read when `--install` is omitted. It lives at the REPO ROOT
33
+ # (the lane), NOT in a pyproject table, on purpose: a repo whose importable
34
+ # package is a SUBDIR (grip's is `gr2/`) has no top-level pyproject, which is the
35
+ # exact case the default `pip install -e <lane>` cannot handle — so a pyproject
36
+ # hint would be unreadable precisely where it is needed. A root sentinel file
37
+ # works regardless of where the package lives. Format: `key = value` lines, `#`
38
+ # comments; keys `install` (a command with {venv} and {lane} placeholders,
39
+ # shell-split FIRST, then {venv}/{lane} substituted per token — so a lane path with
40
+ # a space stays one token) and optional `package` (the import name whose __file__
41
+ # must resolve under the lane).
42
+ _HINT_NAME = ".review-install"
43
+
44
+
45
+ _HINT_KEYS = frozenset({"install", "package"})
46
+
47
+
48
+ def _apply_install_placeholders(tokens: list[str], venv_python: Path, repo_dir: Path) -> list[str]:
49
+ """Substitute `{venv}` and `{lane}` per token, so a lane path containing a space
50
+ stays one token (substituting before shell-splitting would let the space break the
51
+ token apart). The single substitution point shared by the --install flag and the
52
+ .review-install hint, so both accept the identical template (review-run door 2)."""
53
+ return [
54
+ tok.replace("{venv}", str(venv_python)).replace("{lane}", str(repo_dir))
55
+ for tok in tokens
56
+ ]
57
+
58
+
59
+ def read_install_hint(repo_dir: Path) -> dict | None:
60
+ """Parse `<repo_dir>/.review-install`; return {'install': str, 'package': str}
61
+ (both optional keys) or None when the file is absent. An unrecognised key is a
62
+ REFUSAL (`bad_hint`), not a silent skip: a typo like `instal = ...` would
63
+ otherwise fall through to the default install and refuse under a cause the repo
64
+ never declared."""
65
+ p = repo_dir / _HINT_NAME
66
+ if not p.is_file():
67
+ return None
68
+ out: dict[str, str] = {}
69
+ for raw in p.read_text().splitlines():
70
+ line = raw.strip()
71
+ if not line or line.startswith("#") or "=" not in line:
72
+ continue
73
+ key, _, val = line.partition("=")
74
+ key = key.strip()
75
+ if key not in _HINT_KEYS:
76
+ raise ReviewRunRefused(
77
+ "bad_hint",
78
+ f"unrecognised key {key!r} in {p}; allowed keys are "
79
+ f"{sorted(_HINT_KEYS)}",
80
+ )
81
+ out[key] = val.strip()
82
+ return out
83
+
84
+ _MARKER_NAME = ".grip-open-gr-reconstruct.json"
85
+ _RECEIPT_NAME = ".grip-review-run.json"
86
+ # The full pytest output, persisted beside the receipt. The receipt's counts and
87
+ # `failed_ids` say WHAT failed; this file is the raw text a reviewer reads to see
88
+ # WHY. Named so `close-gr` can carry it (and the receipt) out before it reclaims
89
+ # the lane (review-run door 1).
90
+ _OUTPUT_LOG_NAME = ".grip-review-run.log"
91
+ _VENV_DIRNAME = ".venv"
92
+
93
+
94
+ class ReviewRunRefused(Exception):
95
+ """A structural refusal: the run cannot yield a trustworthy green."""
96
+
97
+ def __init__(self, code: str, detail: str) -> None:
98
+ self.code = code
99
+ self.detail = detail
100
+ super().__init__(f"{code}: {detail}")
101
+
102
+
103
+ def _git(repo_dir: Path, *args: str, env: dict | None = None) -> str:
104
+ proc = subprocess.run(
105
+ ["git", "-C", str(repo_dir), *args],
106
+ text=True,
107
+ capture_output=True,
108
+ env=env,
109
+ )
110
+ if proc.returncode != 0:
111
+ raise ReviewRunRefused(
112
+ "git_failed",
113
+ f"git {' '.join(args)} in {repo_dir} exited {proc.returncode}: "
114
+ f"{proc.stderr.strip()}",
115
+ )
116
+ return proc.stdout.strip()
117
+
118
+
119
+ # ---- the tree comparison (drift + reconstruction, ONE check) ----------------
120
+
121
+ def compute_working_tree(repo_dir: Path) -> str:
122
+ """The tree hash of the current TRACKED content of repo_dir, computed in a
123
+ throwaway index so the real index is untouched. `add -u` stages modifications
124
+ and deletions of tracked files but NOT untracked additions, so the open-gr
125
+ marker, the lane `.venv`, and the run receipt do not read as drift — only a
126
+ change to a reconstructed (tracked) file does."""
127
+ with tempfile.TemporaryDirectory() as td:
128
+ idx = str(Path(td) / "index")
129
+ env = {**os.environ, "GIT_INDEX_FILE": idx}
130
+ _git(repo_dir, "read-tree", "HEAD", env=env)
131
+ _git(repo_dir, "add", "-u", env=env)
132
+ return _git(repo_dir, "write-tree", env=env)
133
+
134
+
135
+ def assert_lane_tree_bound(repo_dir: Path, bound_head_tree: str) -> str:
136
+ """THE tracked-tree comparison. Refuse unless the lane's current tracked tree
137
+ equals the bound head-tree recorded at open. Returns the computed tree. Dropping
138
+ this comparison lets a MODIFIED tracked file pass as a green."""
139
+ if not bound_head_tree:
140
+ raise ReviewRunRefused(
141
+ "no_bound_tree",
142
+ f"the open-gr marker for {repo_dir} records no bound_head_tree; "
143
+ "reopen the lane with a build that records it",
144
+ )
145
+ current = compute_working_tree(repo_dir)
146
+ if current != bound_head_tree:
147
+ raise ReviewRunRefused(
148
+ "tree_drift",
149
+ f"lane tree {current} != bound head-tree {bound_head_tree} "
150
+ f"({repo_dir}); the run would not be about the pinned head",
151
+ )
152
+ return current
153
+
154
+
155
+ # Untracked paths the run itself is expected to create; everything else untracked in
156
+ # the lane is drift, because an injected conftest.py or module can change what the
157
+ # tests do WITHOUT touching the tracked tree (which `assert_lane_tree_bound` sees).
158
+ _UNTRACKED_ALLOW_NAMES = frozenset({_MARKER_NAME, _RECEIPT_NAME})
159
+ _UNTRACKED_ALLOW_TOP = (_VENV_DIRNAME + "/",)
160
+ _UNTRACKED_ALLOW_SEGMENTS = frozenset({"__pycache__", ".pytest_cache", ".mypy_cache"})
161
+
162
+
163
+ def _is_allowlisted_untracked(rel_path: str) -> bool:
164
+ if rel_path in _UNTRACKED_ALLOW_NAMES:
165
+ return True
166
+ if any(rel_path == pre.rstrip("/") or rel_path.startswith(pre) for pre in _UNTRACKED_ALLOW_TOP):
167
+ return True
168
+ segments = rel_path.strip("/").split("/")
169
+ if any(seg in _UNTRACKED_ALLOW_SEGMENTS or seg.endswith(".egg-info") for seg in segments):
170
+ return True
171
+ return False
172
+
173
+
174
+ def assert_no_untracked_drift(repo_dir: Path) -> None:
175
+ """Refuse if the lane holds any untracked path the run did not create. Without
176
+ this an untracked `conftest.py` (or shadow module) that patches the package turns
177
+ a failing tree green while the tracked-tree comparison passes. The complement of
178
+ `assert_lane_tree_bound`: dropping either reds only its own drift witness."""
179
+ out = _git(repo_dir, "status", "--porcelain")
180
+ offending = []
181
+ for line in out.splitlines():
182
+ if line.startswith("?? "):
183
+ rel = line[3:].strip().strip('"')
184
+ if not _is_allowlisted_untracked(rel):
185
+ offending.append(rel)
186
+ if offending:
187
+ raise ReviewRunRefused(
188
+ "untracked_drift",
189
+ f"untracked path(s) in the lane the run did not create: "
190
+ f"{', '.join(offending[:5])}; an injected conftest/module can change test "
191
+ "behavior without touching the tracked tree",
192
+ )
193
+
194
+
195
+ # ---- import resolves under the lane -----------------------------------------
196
+
197
+ def resolve_import_file(venv_python: Path, package: str, env: dict) -> str:
198
+ """Import `package` in the venv python (under `env`) and return its __file__."""
199
+ proc = subprocess.run(
200
+ [str(venv_python), "-c", f"import {package} as _m; print(_m.__file__ or '')"],
201
+ text=True,
202
+ capture_output=True,
203
+ env=env,
204
+ )
205
+ if proc.returncode != 0:
206
+ raise ReviewRunRefused(
207
+ "import_failed",
208
+ f"could not import {package!r} in the lane venv: {proc.stderr.strip()}",
209
+ )
210
+ path = proc.stdout.strip()
211
+ if not path:
212
+ raise ReviewRunRefused(
213
+ "import_no_file",
214
+ f"{package!r} has no __file__ (namespace package?); cannot bind the "
215
+ "install to the lane",
216
+ )
217
+ return path
218
+
219
+
220
+ def assert_import_under_lane(resolved_file: str, lane_dir: Path) -> None:
221
+ """Refuse unless the resolved import path lives under the lane. A second checkout
222
+ on PYTHONPATH would otherwise let a pass be about a different tree."""
223
+ p = Path(resolved_file).resolve()
224
+ root = lane_dir.resolve()
225
+ if root != p and root not in p.parents:
226
+ raise ReviewRunRefused(
227
+ "import_escapes_lane",
228
+ f"{p} does not resolve under the lane {root}; a checkout outside the "
229
+ "lane is shadowing the reconstruction (stale editable install)",
230
+ )
231
+
232
+
233
+ # ---- undeclared-extra detection (pip exits 0 but warns) ----
234
+
235
+ # pip exits 0 when an install requests an extra the package does not declare,
236
+ # emitting `WARNING: <name> <version> does not provide the extra 'X'` on stderr
237
+ # (older pip omits the version). The invariant is the phrase, so anchor on it and
238
+ # ignore the version. Match either quote style pip might use.
239
+ _UNDECLARED_EXTRA_RE = re.compile(r"""does not provide the extra ['"]([^'"]+)['"]""")
240
+
241
+
242
+ def detect_undeclared_extras(output: str) -> list[str]:
243
+ """Return the sorted unique extra names pip reported as undeclared in `output`.
244
+
245
+ A typo'd or undeclared extra in a repo's own `.review-install` (say `gr2[devv]`
246
+ for `gr2[dev]`) makes pip install NOTHING of what that extra promised — pytest
247
+ and the rest of the test deps — while exiting 0. The run then fails later under
248
+ `pytest_not_installed`, which points at the symptom, not the bad extra name. This
249
+ lets the run name the root cause first."""
250
+ return sorted({m.group(1) for m in _UNDECLARED_EXTRA_RE.finditer(output)})
251
+
252
+
253
+ # ---- pytest summary parsing (counts from the summary line, not exit code) ----
254
+
255
+ _COLLECTED_RE = re.compile(r"collected (\d+) item")
256
+ _SELECTED_RE = re.compile(r"(\d+) selected")
257
+ # A summary line ends with "in <time>s" (barred in normal mode, bare in -q), or is
258
+ # the "no tests ran in <time>s" line; leading/trailing "=" bars are optional.
259
+ _SUMMARY_LINE_RE = re.compile(r"(?:in \d+\.\d+s|no tests ran)")
260
+ _TIME_TAIL_RE = re.compile(r"\bin \d+\.\d+s\b|\bno tests ran\b")
261
+ _COUNT_RE = re.compile(
262
+ r"(\d+) (passed|failed|error|errors|skipped|xfailed|xpassed|deselected|warning|warnings)"
263
+ )
264
+
265
+
266
+ def parse_pytest_summary(stdout: str) -> dict | None:
267
+ """Counts from pytest's SUMMARY LINE, never the exit code. Handles both the
268
+ barred normal-mode line (`===== 3 passed in 0.01s =====`) and the bare `-q` line
269
+ (`1 passed in 0.00s`, `1 deselected in 0.00s`). Returns a dict with
270
+ collected/selected/deselected/passed/failed/skipped/xfailed/errors, or None if
271
+ no summary line exists (unparseable output -> the caller refuses). A run where no
272
+ test ran yields selected==0 (the caller refuses)."""
273
+ lines = stdout.splitlines()
274
+ summary_body: str | None = None
275
+ for line in reversed(lines):
276
+ if _SUMMARY_LINE_RE.search(line):
277
+ summary_body = line.strip().strip("=").strip()
278
+ break
279
+ if summary_body is None:
280
+ return None
281
+
282
+ counts = {k: 0 for k in ("passed", "failed", "errors", "skipped", "xfailed", "xpassed")}
283
+ deselected = 0
284
+ for n, word in _COUNT_RE.findall(summary_body):
285
+ if word in ("error", "errors"):
286
+ counts["errors"] = int(n)
287
+ elif word in ("warning", "warnings"):
288
+ continue
289
+ elif word == "deselected":
290
+ deselected = int(n)
291
+ else:
292
+ counts[word] = int(n)
293
+
294
+ collected = None
295
+ selected = None
296
+ for line in lines:
297
+ cm = _COLLECTED_RE.search(line)
298
+ if cm:
299
+ collected = int(cm.group(1))
300
+ sm = _SELECTED_RE.search(line)
301
+ if sm:
302
+ selected = int(sm.group(1))
303
+ dm = re.search(r"(\d+) deselected", line)
304
+ if dm:
305
+ deselected = max(deselected, int(dm.group(1)))
306
+
307
+ ran = (
308
+ counts["passed"] + counts["failed"] + counts["errors"]
309
+ + counts["skipped"] + counts["xfailed"] + counts["xpassed"]
310
+ )
311
+ if "no tests ran" in summary_body:
312
+ selected = 0
313
+ elif selected is None:
314
+ selected = ran
315
+ return {
316
+ "collected": collected,
317
+ "deselected": deselected,
318
+ "selected": selected,
319
+ **counts,
320
+ }
321
+
322
+
323
+ # ---- failed-test node ids (which tests failed, not just how many) -----------
324
+
325
+ # pytest's short test summary info section (emitted under `-rfE`, which the run
326
+ # always passes) lists one line per non-passing outcome:
327
+ # FAILED tests/test_x.py::test_bad - AssertionError: ...
328
+ # FAILED tests/test_x.py::test_p[case 2 with spaces] - AssertionError
329
+ # ERROR tests/test_x.py::test_y - fixture 'conn' not found (setup/collection)
330
+ # The node id is everything between the status word and pytest's ` - <message>`
331
+ # separator (space-dash-space), or the rest of the line when there is no message.
332
+ # A `\S+` token would truncate a PARAMETRIZED id at the first space inside its
333
+ # brackets, losing the exact case a reviewer must re-run — so match to the ` - `
334
+ # instead. Anchored at line start (MULTILINE) so the `ERRORS` banner and the
335
+ # `___ ERROR at setup of ___` divider lines, which do not start with the word, are
336
+ # not mistaken for summary rows. review-run door 1: the 35 env failures in the
337
+ # real review were unrecoverable from the receipt because this was never captured.
338
+ # Known edge: a param whose brackets literally contain " - " (space-dash-space)
339
+ # truncates there, since that is also the id/message separator; pytest usually
340
+ # sanitizes such ids and the truncation still keeps the file and test stem, so it
341
+ # is accepted rather than guarded.
342
+ _FAILED_ID_RE = re.compile(r"^(?:FAILED|ERROR)\s+(.+?)(?: - .*)?$", re.MULTILINE)
343
+
344
+
345
+ def parse_failed_ids(output: str) -> list[str]:
346
+ """Return the sorted unique node ids pytest reported as FAILED or ERROR in
347
+ `output`'s short test summary. A count of failures with no ids is a dead end
348
+ for a reviewer; this is the path back to the exact tests to re-run."""
349
+ return sorted({m.group(1) for m in _FAILED_ID_RE.finditer(output)})
350
+
351
+
352
+ def merge_report_flags(pytest_args: list[str]) -> list[str]:
353
+ """Return `pytest_args` with a single `-r` spec GUARANTEED to make the short test
354
+ summary list every FAILED and ERROR node id for parse_failed_ids.
355
+
356
+ Two pytest facts drive this and neither is `-r`-is-additive:
357
+ * `-r` is LAST-WINS across tokens, so a prepended `-rfE` is silently overridden
358
+ by any later caller `-r` — failed_ids then comes back EMPTY on a real red run.
359
+ * within one `-r` spec, the chars are processed IN ORDER and `N` (none) CLEARS
360
+ everything before it. So a sorted union like `-rENf` loses ERROR: E is added,
361
+ N clears it, f is added — a red run with an ERROR-at-setup keeps FAILED and
362
+ drops the ERROR ids. (Measured: `-rfE` prints both, `-rENf` FAILED only.)
363
+
364
+ So: collect the caller's `-r` chars in ORDER (deduped), DROP `N` (the run requires
365
+ output, so "none" cannot stand), drop any caller f/E, then append `f` and `E` LAST
366
+ so nothing that follows can clear them. `a`/`A` (all / all-but-passed) stay, ahead
367
+ of f/E, and are harmless supersets."""
368
+ required = ("f", "E")
369
+ caller_seq: list[str] = []
370
+ seen: set[str] = set()
371
+ rest: list[str] = []
372
+ i = 0
373
+ while i < len(pytest_args):
374
+ a = pytest_args[i]
375
+ chars: str | None = None
376
+ if a == "-r" and i + 1 < len(pytest_args): # `-r fE` (separate arg)
377
+ chars = pytest_args[i + 1]
378
+ i += 2
379
+ elif a.startswith("-r") and len(a) > 2: # `-rfE` (attached)
380
+ chars = a[2:]
381
+ i += 1
382
+ else:
383
+ rest.append(a)
384
+ i += 1
385
+ continue
386
+ for c in chars:
387
+ if c not in seen:
388
+ seen.add(c)
389
+ caller_seq.append(c)
390
+ ordered = [c for c in caller_seq if c not in ("N", *required)]
391
+ ordered.extend(required)
392
+ return ["-r" + "".join(ordered), *rest]
393
+
394
+
395
+ # ---- the verb ---------------------------------------------------------------
396
+
397
+ def _read_marker(lane_dir: Path) -> dict:
398
+ marker_path = lane_dir / _MARKER_NAME
399
+ if not marker_path.exists():
400
+ raise ReviewRunRefused(
401
+ "no_marker",
402
+ f"no open-gr marker at {marker_path}; `review run` only runs inside a "
403
+ "lane opened by `review open-gr --enter`",
404
+ )
405
+ marker = json.loads(marker_path.read_text())
406
+ if marker.get("kind") != "open-gr-reconstruct":
407
+ raise ReviewRunRefused(
408
+ "not_open_gr", f"{marker_path} is not an open-gr reconstruction marker"
409
+ )
410
+ return marker
411
+
412
+
413
+ def run_review_lane(
414
+ lane_dir: Path,
415
+ *,
416
+ package: str | None = None,
417
+ pytest_args: list[str],
418
+ python: str | None = None,
419
+ install: list[str] | None = None,
420
+ system_site_packages: bool = False,
421
+ ) -> dict:
422
+ """Create `<lane>/.venv`, install the reconstructed tree, and run pytest — but
423
+ only after the lane's tree is proven to equal the bound head-tree and the import
424
+ is proven to resolve under the lane. Returns a receipt. Raises ReviewRunRefused
425
+ for any structural problem (no marker, tree drift, import escape, zero collected,
426
+ unparseable summary)."""
427
+ lane_dir = Path(lane_dir).resolve()
428
+ marker = _read_marker(lane_dir)
429
+ repos = marker.get("repos", [])
430
+ if len(repos) != 1:
431
+ raise ReviewRunRefused(
432
+ "multi_repo_lane",
433
+ f"v1 review run handles a single-repo lane; marker binds {len(repos)} "
434
+ "repos (multi-repo is a follow-on)",
435
+ )
436
+ repo = repos[0]
437
+ bound_tree = repo.get("bound_head_tree", "")
438
+ repo_dir = lane_dir # single-repo lane: the clone IS the lane
439
+
440
+ # (1) THE TREE COMPARISON — before the venv exists, so it never pollutes the hash.
441
+ # Two halves: tracked content equals the bound tree, AND no untracked path the
442
+ # run did not create (an injected conftest changes behavior invisibly to the
443
+ # tracked-tree hash).
444
+ assert_lane_tree_bound(repo_dir, bound_tree)
445
+ assert_no_untracked_drift(repo_dir)
446
+
447
+ # (2) venv in the lane, so close-gr reclaims it.
448
+ interpreter = python or sys.executable
449
+ venv_dir = lane_dir / _VENV_DIRNAME
450
+ venv_cmd = [interpreter, "-m", "venv"]
451
+ if system_site_packages:
452
+ venv_cmd.append("--system-site-packages")
453
+ venv_cmd.append(str(venv_dir))
454
+ proc = subprocess.run(venv_cmd, text=True, capture_output=True)
455
+ if proc.returncode != 0:
456
+ raise ReviewRunRefused("venv_failed", f"venv create failed: {proc.stderr.strip()}")
457
+ venv_python = venv_dir / "bin" / "python"
458
+
459
+ # (3) resolve install + package, tracking WHERE each came from. An explicit
460
+ # --install/--package always wins; otherwise the repo's own .review-install
461
+ # hint supplies them, so a repo that declares itself (like grip, whose package
462
+ # is the gr2/ subdir) needs no hand-written flags. The hint's install template
463
+ # is SHELL-SPLIT FIRST, then {venv}/{lane} substituted per token, so a lane
464
+ # path containing a space stays one token even with an unquoted hint line
465
+ # (substituting before splitting would let the space break the token apart).
466
+ install_source = "flag" if install is not None else None
467
+ package_source = "flag" if package is not None else None
468
+ hint = read_install_hint(repo_dir)
469
+ if install is not None:
470
+ # The --install FLAG supports the SAME {venv}/{lane} placeholders as the hint,
471
+ # so the documented template works identically whether typed on the CLI or
472
+ # declared in .review-install. review-run door 2: only the hint substituted, so
473
+ # a reviewer who passed the documented `{venv} -m pip install -e {lane}` on the
474
+ # flag got literal braces and a failed install.
475
+ install = _apply_install_placeholders(install, venv_python, repo_dir)
476
+ elif hint and hint.get("install"):
477
+ install = _apply_install_placeholders(shlex.split(hint["install"]), venv_python, repo_dir)
478
+ install_source = "hint"
479
+ if package is None and hint and hint.get("package"):
480
+ package = hint["package"]
481
+ package_source = "hint"
482
+ if package is None:
483
+ raise ReviewRunRefused(
484
+ "no_package",
485
+ "no --package given and the lane's .review-install declares none; a "
486
+ "package name is required so the install can be proven to resolve under "
487
+ "the lane",
488
+ )
489
+
490
+ # (4) install the reconstructed tree editable. A hint (or flag) can name a binary
491
+ # that does not exist; subprocess.run then raises OSError, which must become a
492
+ # refusal, never an uncaught traceback (tree content chooses the command, so a
493
+ # typo in a repo's own hint must not produce the one shape review run promises
494
+ # never to give).
495
+ if install is not None:
496
+ install_cmd = install
497
+ else:
498
+ install_cmd = [str(venv_python), "-m", "pip", "install", "-e", str(repo_dir)]
499
+ install_source = "default"
500
+ try:
501
+ proc = subprocess.run(install_cmd, text=True, capture_output=True, cwd=str(repo_dir))
502
+ except OSError as exc:
503
+ raise ReviewRunRefused(
504
+ "install_failed",
505
+ f"install `{' '.join(install_cmd)}` could not run: {exc}",
506
+ )
507
+ if proc.returncode != 0:
508
+ raise ReviewRunRefused(
509
+ "install_failed",
510
+ f"install `{' '.join(install_cmd)}` failed: {proc.stderr.strip()[-800:]}",
511
+ )
512
+
513
+ # (4a) An undeclared extra does NOT fail the install — pip warns and exits 0,
514
+ # installing none of that extra's dependencies. Named here, BEFORE the import
515
+ # and pytest checks, so a typo'd extra surfaces as its own root cause instead
516
+ # of the misleading `pytest_not_installed` symptom it would otherwise produce.
517
+ undeclared = detect_undeclared_extras(proc.stdout + "\n" + proc.stderr)
518
+ if undeclared:
519
+ raise ReviewRunRefused(
520
+ "undeclared_extra",
521
+ "the install requested extra(s) the package does not declare: "
522
+ f"{', '.join(undeclared)}. pip exits 0 on an undeclared extra and installs "
523
+ "nothing for it, so the test dependencies it was meant to bring (pytest and "
524
+ "the rest) are silently absent. Fix the extra name in --install or the "
525
+ f"repo's .review-install. install: `{' '.join(install_cmd)}`",
526
+ )
527
+
528
+ # (5) IMPORT UNDER THE LANE — in the same env pytest will use.
529
+ run_env = {**os.environ}
530
+ resolved_file = resolve_import_file(venv_python, package, run_env)
531
+ assert_import_under_lane(resolved_file, lane_dir)
532
+
533
+ # (6) pytest must be importable in the lane venv. A plain editable install does
534
+ # not bring it (pytest is a test-time extra), and running pytest anyway
535
+ # yields exit 1 with no summary — which the summary check would mislabel
536
+ # `unparseable_summary`. Name the real cause instead, and keep it a refusal.
537
+ proc = subprocess.run(
538
+ [str(venv_python), "-c", "import pytest"], text=True, capture_output=True, env=run_env
539
+ )
540
+ if proc.returncode != 0:
541
+ raise ReviewRunRefused(
542
+ "pytest_not_installed",
543
+ "pytest is not importable in the lane venv; a plain editable install does "
544
+ "not bring it. Add pytest to --install or the repo's .review-install "
545
+ f"(it is a test-time dependency). stderr: {proc.stderr.strip()[-300:]}",
546
+ )
547
+
548
+ # (7) run pytest; counts from the summary line, never the exit code. The full
549
+ # output is persisted to a log in the lane and the failed node ids are parsed
550
+ # from it, so a red receipt says WHICH tests failed and the raw text survives
551
+ # for close-gr to carry out (review-run door 1).
552
+ # Guarantee the short test summary lists every FAILED/ERROR node id for
553
+ # parse_failed_ids. pytest emits those summary lines only under `-r`, and `-r` is
554
+ # LAST-WINS: a prepended `-rfE` would be silently overridden by a caller `-rN`/`-rs`,
555
+ # leaving failed_ids empty on a real red run. merge_report_flags folds f/E INTO the
556
+ # caller's own -r chars, so f and E survive whatever the caller passed.
557
+ test_cmd = [str(venv_python), "-m", "pytest", *merge_report_flags(pytest_args)]
558
+ proc = subprocess.run(
559
+ test_cmd, text=True, capture_output=True, cwd=str(repo_dir), env=run_env
560
+ )
561
+ pytest_output = proc.stdout + "\n" + proc.stderr
562
+ (lane_dir / _OUTPUT_LOG_NAME).write_text(pytest_output)
563
+ failed_ids = parse_failed_ids(pytest_output)
564
+ summary = parse_pytest_summary(pytest_output)
565
+ if summary is None:
566
+ raise ReviewRunRefused(
567
+ "unparseable_summary",
568
+ "no pytest summary line found; refusing to call this a green "
569
+ f"(pytest exit was {proc.returncode})",
570
+ )
571
+ if not summary.get("selected"):
572
+ raise ReviewRunRefused(
573
+ "zero_collected",
574
+ f"pytest selected 0 tests (collected={summary.get('collected')}, "
575
+ f"deselected={summary.get('deselected')}); a zero-test run is not a green",
576
+ )
577
+
578
+ version = subprocess.run(
579
+ [str(venv_python), "--version"], text=True, capture_output=True
580
+ ).stdout.strip() or subprocess.run(
581
+ [str(venv_python), "-V"], text=True, capture_output=True
582
+ ).stderr.strip()
583
+
584
+ # A green requires at least one PASS: an all-skipped or all-deselected run has no
585
+ # failure but proves nothing, so it is not a green.
586
+ result = (
587
+ "green"
588
+ if (summary["passed"] >= 1 and summary["failed"] == 0 and summary["errors"] == 0)
589
+ else "red"
590
+ )
591
+ receipt = {
592
+ "kind": "review-run",
593
+ # When this run happened, so close-gr can key the preserved evidence by
594
+ # (gr commit, run time) and two closes of the same lane name do not overwrite
595
+ # each other's receipt/log.
596
+ "created": datetime.now(timezone.utc).isoformat(),
597
+ "gr_commit": marker.get("gr_commit", ""),
598
+ "bound_head": repo.get("bound_head", ""),
599
+ "bound_head_tree": bound_tree,
600
+ "interpreter": {"path": str(venv_python), "version": version},
601
+ "resolved_install_path": resolved_file,
602
+ "install_command": install_cmd,
603
+ "install_source": install_source,
604
+ "package_source": package_source,
605
+ "test_command": test_cmd,
606
+ "collected": summary["collected"],
607
+ "deselected": summary["deselected"],
608
+ "selected": summary["selected"],
609
+ "passed": summary["passed"],
610
+ "failed": summary["failed"],
611
+ "skipped": summary["skipped"],
612
+ "xfailed": summary["xfailed"],
613
+ "errors": summary["errors"],
614
+ # WHICH tests failed (node ids parsed from the summary), so a red receipt is
615
+ # actionable and not just a count, and the raw output log this run wrote.
616
+ "failed_ids": failed_ids,
617
+ "output_log": _OUTPUT_LOG_NAME,
618
+ "result": result,
619
+ }
620
+ (lane_dir / _RECEIPT_NAME).write_text(json.dumps(receipt, indent=2) + "\n")
621
+ return receipt