diff --git a/CLAUDE.md b/CLAUDE.md index 948d50d2af..4975671cd1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -390,16 +390,25 @@ workflow, not optional conveniences — use them at the point named, every time: the 2026-08-01 incident this check exists for was a silent worktree-escape where ~1700 lines of half-finished output had already landed directly in the coordinator's live tree by the time the lane reported back. -- **Before squash-merging any PR**: `devtools workspace merge-gate record - --command "devtools verify"` (or a narrower test selection) against the - PR's current head, then `devtools workspace merge-gate check ` — - BLOCKs unless a fresh receipt exists for the exact head sha and no review - comment (inline, issue-level, or review-summary) is newer than the head - commit's timestamp unless explicitly `ack`'d. This replaces "remember to - grace-period-poll and remember to run the broader suite CI skips" with one - command; it is not automatic (a coordinator still has to remember to run - it), so treat it as a required step in the merge checklist below, not an - optional nicety. +- **To squash-merge any PR**: use `devtools workspace merge ` instead of + a bare `gh pr merge --squash` — it wraps `merge-gate record`/`check` at the + actual merge boundary instead of leaving them a step a coordinator must + remember. It auto-records a receipt if none is fresh for the current head + sha (running `--command`, default `devtools verify`), BLOCKs the merge on + any `merge-gate check` failure (no fresh receipt, stale receipt, nonzero + exit, or an unacked review comment newer than the head commit), strips a + doubled `(#N) (#N)` squash-subject suffix, then runs the actual + `gh pr merge --squash`. `--dry-run` runs every check without merging; + `--with-verify` immediately runs and records the merge-train's terminal + full-suite verify after merging. `devtools workspace merge train-status` + reports (exit 1) any PRs merged since the last recorded full-suite verify — + this is the structural stand-in for "a merge-train records the full-suite + verify as its terminal ledger step"; `devtools workspace merge + record-full-verify --command "devtools verify --all"` records that step + directly once per merge-train session boundary. The lower-level + `devtools workspace merge-gate record/check` commands still exist for + ad hoc receipt inspection, but the merge action itself should go through + `workspace merge`. - Sizing/triage input: `devtools workspace backlog-calibration` for lead-time/discovery/staleness distributions before deciding batch size. diff --git a/devtools/command_catalog.py b/devtools/command_catalog.py index ac9b3c9dfe..430370d48f 100644 --- a/devtools/command_catalog.py +++ b/devtools/command_catalog.py @@ -728,6 +728,36 @@ def to_dict(self) -> dict[str, object]: 'devtools workspace merge-gate ack 3517 123456789 --reason "already fixed upstream, false positive"', ), ), + CommandSpec( + "workspace merge", + "workspace", + "Merge boundary wrapper: refuses `gh pr merge` without a fresh merge-gate receipt.", + "devtools.merge_boundary", + use_when=( + "Replace a bare `gh pr merge --squash` with this at the actual merge boundary " + "(polylogue-ct3r2 / polylogue-t6iga: duplicate filings of the same finding -- " + "`merge-gate record/check` and the one-full-verify-per-train rule both existed but " + "fired only if a coordinator remembered to invoke them). `merge ` auto-records a " + "merge-gate receipt if none is fresh for the current head sha (running `--command`, " + 'default "devtools verify"), runs `merge-gate check` and refuses to merge on any ' + "BLOCK, strips a doubled `(#N) (#N)` squash-subject suffix (the 2026-07-12/13 " + "incident), then runs the actual `gh pr merge --squash`. `--dry-run` runs every check " + "without merging. `--with-verify` immediately runs and records the merge-train's " + "terminal full-suite verify after merging; otherwise it prints a reminder. " + "`train-status` reports (exit 1) any PRs merged since the last recorded full-suite " + "verify -- the structural stand-in for 'a merge-train records the full-suite verify " + "as its terminal ledger step'. `record-full-verify` runs and records that step " + "directly, e.g. once per merge-train session boundary." + ), + examples=( + "devtools workspace merge 3517", + 'devtools workspace merge 3517 --command "devtools test tests/unit/foo.py"', + "devtools workspace merge 3517 --dry-run", + 'devtools workspace merge 3517 --with-verify --verify-command "devtools verify --all"', + "devtools workspace merge train-status", + 'devtools workspace merge record-full-verify --command "devtools verify --all"', + ), + ), CommandSpec( "workspace merge-conductor", "workspace", diff --git a/devtools/merge_boundary.py b/devtools/merge_boundary.py new file mode 100644 index 0000000000..9dbc51b9c6 --- /dev/null +++ b/devtools/merge_boundary.py @@ -0,0 +1,341 @@ +"""merge-boundary: the merge-gate/broad-verify safety net, wired into the +actual place PRs get merged, instead of a rule a coordinator has to remember. + +``devtools workspace merge-gate record/check`` (see ``merge_gate.py``) and the +one-full-suite-verify-per-merge-train rule from CLAUDE.md are both real, +already-built fixes for real 2026-08-01 incidents -- but both fire only if a +coordinator remembers to invoke them at the right moment. The fanout- +operations report's incident-ledger cross-check (polylogue-ct3r2 / +polylogue-t6iga, duplicate filings of the same finding) found the exact +pattern: "everything that became a command stuck; everything that stayed a +rule someone must remember has already recurred at least once." This module +is the command. + +There is no GitHub Actions merge-boundary hook available here (CI is +CircleCI-only, GHA is intentionally dark -- see project memory). The merge +boundary in practice is a human/agent coordinator invoking +``gh pr merge --squash``. So the enforcement point is a wrapper around that +exact call: + + devtools workspace merge + +which: + + 1. Refuses unless the PR is OPEN. + 2. If no fresh ``merge-gate`` receipt exists for the PR's *current* head + sha, records one automatically (running ``--command``, default + ``devtools verify``) instead of just failing and telling the caller to + go run a separate command first. + 3. Runs ``merge-gate check`` (late-review-comment grace-window poll + + receipt freshness/exit-code checks). Refuses to merge on any BLOCK. + 4. Applies title hygiene: strips a doubled ``(#N) (#N)`` suffix (the + 2026-07-12/13 incident where the squash-merge subject carried the PR + number twice because a manual ``gh pr edit --title`` step was skipped) + and ensures exactly one trailing ``(#N)``. + 5. Runs the actual ``gh pr merge --squash``. + 6. Appends a merge-train ledger entry (``.cache/verify/merge-gate/merge-train-ledger.json``) + and, unless ``--with-verify`` was given, prints a reminder that the + ledger's terminal step -- one full-suite ``devtools verify --all`` (or + narrower agreed selection) since the last one -- has not yet been + recorded for this train. + +``devtools workspace merge train-status`` inspects the ledger and reports +(exit 1 if so) any PRs merged since the last recorded full-suite verify -- +this is the structural stand-in for "a merge-train run records the full- +suite verify as its terminal ledger step": the train is not clean until this +reports OK. + +``devtools workspace merge record-full-verify --command "devtools verify --all"`` +runs that command now and records it as the train's terminal step, clearing +every pending PR in the ledger. + +Usage: + devtools workspace merge 3517 + devtools workspace merge 3517 --command "devtools test tests/unit/foo.py" + devtools workspace merge 3517 --dry-run + devtools workspace merge 3517 --with-verify --verify-command "devtools verify --all" + devtools workspace merge train-status + devtools workspace merge record-full-verify --command "devtools verify --all" +""" + +from __future__ import annotations + +import argparse +import json +import re +import subprocess +import sys +import time +from pathlib import Path +from typing import Any + +from devtools import merge_gate + +_LEDGER_PATH = Path(".cache/verify/merge-gate/merge-train-ledger.json") + +# Matches a squash-merge subject that already carries the PR number and picked +# up a second, duplicate one -- e.g. "fix: thing (#3517) (#3517)". Only the +# exact-duplicate case is collapsed; a title that already ends with exactly +# one "(#N)" for the right N is left untouched. +_DOUBLED_PR_SUFFIX_RE = re.compile(r"\s*\(#(\d+)\)\s*\(#\1\)\s*$") + + +def _gh_json(args: list[str]) -> Any: + result = subprocess.run(["gh", *args], capture_output=True, text=True, timeout=60) + if result.returncode != 0: + raise RuntimeError(result.stderr.strip()[:300] or f"gh {' '.join(args)} failed") + return json.loads(result.stdout) + + +def clean_merge_title(title: str, pr: int) -> str: + """Strip a doubled ``(#N) (#N)`` suffix and ensure exactly one trailing + ``(#N)`` for this PR number. Idempotent: a title already shaped correctly + passes through unchanged.""" + collapsed = _DOUBLED_PR_SUFFIX_RE.sub(f" (#{pr})", title).strip() + if not re.search(rf"\(#{pr}\)\s*$", collapsed): + collapsed = f"{collapsed} (#{pr})" + return collapsed + + +def _read_ledger() -> dict[str, Any]: + if not _LEDGER_PATH.exists(): + return {"merges": [], "last_full_verify": None} + try: + data = json.loads(_LEDGER_PATH.read_text()) + except (OSError, json.JSONDecodeError): + return {"merges": [], "last_full_verify": None} + if not isinstance(data, dict): + return {"merges": [], "last_full_verify": None} + data.setdefault("merges", []) + data.setdefault("last_full_verify", None) + return data + + +def _write_ledger(ledger: dict[str, Any]) -> None: + _LEDGER_PATH.parent.mkdir(parents=True, exist_ok=True) + _LEDGER_PATH.write_text(json.dumps(ledger, indent=2)) + + +def _append_merge_entry(pr: int, head_sha: str, title: str) -> None: + ledger = _read_ledger() + ledger["merges"].append( + { + "pr": pr, + "head_sha": head_sha, + "title": title, + "merged_at": time.time(), + } + ) + _write_ledger(ledger) + + +def _pending_prs_since_last_full_verify(ledger: dict[str, Any]) -> list[dict[str, Any]]: + last_verify_at = (ledger.get("last_full_verify") or {}).get("at", 0.0) + return [entry for entry in ledger.get("merges", []) if entry.get("merged_at", 0.0) > last_verify_at] + + +def _receipt_is_fresh_for_head(pr: int, head_sha: str, max_age_s: int) -> bool: + receipt = merge_gate._read_json_object(merge_gate._receipt_path(pr)) + if receipt is None: + return False + if receipt.get("head_sha") != head_sha: + return False + age_s = time.time() - float(receipt.get("recorded_at", 0)) + return bool(age_s <= max_age_s) + + +def cmd_merge( + pr: int, + *, + command: str, + max_age_s: int, + poll_rounds: int, + poll_interval_s: int, + dry_run: bool, + with_verify: bool, + verify_command: str, +) -> int: + try: + info = _gh_json(["pr", "view", str(pr), "--json", "headRefOid,title,state"]) + except (RuntimeError, json.JSONDecodeError, OSError, subprocess.SubprocessError) as exc: + print(f"REFUSING to merge PR #{pr}: gh pr view failed: {exc}", file=sys.stderr) + return 1 + + if info.get("state") != "OPEN": + print(f"REFUSING to merge PR #{pr}: state is {info.get('state')!r}, not OPEN", file=sys.stderr) + return 1 + + head_sha = info["headRefOid"] + + if not _receipt_is_fresh_for_head(pr, head_sha, max_age_s): + print( + f"no fresh merge-gate receipt for PR #{pr} @ {head_sha[:8]} -- recording one now via {command!r}", + file=sys.stderr, + ) + record_exit = merge_gate.cmd_record(pr, command) + if record_exit != 0: + print(f"REFUSING to merge PR #{pr}: recording verification failed (exit {record_exit})", file=sys.stderr) + return record_exit + + check_exit = merge_gate.cmd_check( + pr, + max_age_s=max_age_s, + poll_rounds=poll_rounds, + poll_interval_s=poll_interval_s, + as_json=False, + ) + if check_exit != 0: + print(f"REFUSING to merge PR #{pr}: merge-gate check BLOCKed (see reasons above)", file=sys.stderr) + return check_exit + + clean_title = clean_merge_title(info.get("title", ""), pr) + + if dry_run: + print(f"PR #{pr} @ {head_sha[:8]}: merge-gate OK -- dry-run, not merging (title would be {clean_title!r})") + return 0 + + merge_result = subprocess.run( + ["gh", "pr", "merge", str(pr), "--squash", "--subject", clean_title], + capture_output=True, + text=True, + timeout=120, + ) + if merge_result.returncode != 0: + print(f"gh pr merge failed: {merge_result.stderr.strip()[:500]}", file=sys.stderr) + return merge_result.returncode + + print(f"merged PR #{pr} @ {head_sha[:8]}: {clean_title!r}") + _append_merge_entry(pr, head_sha, clean_title) + + if with_verify: + print(f"running post-merge broad verify (merge-train terminal step): {verify_command!r}") + return cmd_record_full_verify(verify_command) + + print( + "REMINDER: this merge-train's terminal ledger step (one full-suite verify since the last " + "merge) is not yet recorded -- run `devtools workspace merge record-full-verify " + '--command "devtools verify --all"` before declaring the train done, or check ' + "`devtools workspace merge train-status`." + ) + return 0 + + +def cmd_train_status(as_json: bool) -> int: + ledger = _read_ledger() + pending = _pending_prs_since_last_full_verify(ledger) + ok = not pending + + if as_json: + print( + json.dumps( + { + "ok": ok, + "last_full_verify": ledger.get("last_full_verify"), + "pending_prs": pending, + }, + indent=2, + ) + ) + return 0 if ok else 1 + + if ok: + print("merge-train OK: no PRs merged since the last recorded full-suite verify") + return 0 + + print(f"merge-train INCOMPLETE: {len(pending)} PR(s) merged since the last full-suite verify:") + for entry in pending: + print(f" PR #{entry['pr']} @ {entry['head_sha'][:8]}: {entry['title']}") + print( + 'Run `devtools workspace merge record-full-verify --command "devtools verify --all"` ' + "(or the narrower agreed selection) before declaring this merge-train session done -- " + "per-PR CI skips the heavy suite, so nothing else will catch a master-red class only " + "visible on the merged whole." + ) + return 1 + + +def cmd_record_full_verify(command: str) -> int: + argv = command.split() + if not argv: + print("REFUSING: --command is empty after splitting", file=sys.stderr) + return 2 + started = time.time() + try: + result = subprocess.run(argv, capture_output=True, text=True) + except OSError as exc: + print(f"REFUSING: could not run {command!r}: {exc}", file=sys.stderr) + return 2 + duration_s = round(time.time() - started, 2) + + ledger = _read_ledger() + ledger["last_full_verify"] = { + "command": command, + "exit_code": result.returncode, + "duration_s": duration_s, + "at": time.time(), + } + _write_ledger(ledger) + + print(f"recorded merge-train terminal verify: {command!r} exit={result.returncode} ({duration_s}s)") + if result.returncode != 0: + print(result.stdout[-4000:]) + print(result.stderr[-4000:], file=sys.stderr) + print( + "POST-MERGE BROAD VERIFY FAILED -- this is the master-red drift-latch class (an " + "unrelated change breaking something only visible on the merged whole); investigate " + "before merging further PRs in this train.", + file=sys.stderr, + ) + return result.returncode + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + sub = parser.add_subparsers(dest="action", required=True) + + merge_p = sub.add_parser("merge", help="Merge-gate check (auto-recording if needed) then squash-merge a PR") + merge_p.add_argument("pr", type=int) + merge_p.add_argument( + "--command", default="devtools verify", help="Verification command to auto-record if no fresh receipt exists" + ) + merge_p.add_argument("--max-age-s", type=int, default=merge_gate._DEFAULT_MAX_AGE_S) + merge_p.add_argument("--poll-rounds", type=int, default=merge_gate._DEFAULT_POLL_ROUNDS) + merge_p.add_argument("--poll-interval-s", type=int, default=merge_gate._DEFAULT_POLL_INTERVAL_S) + merge_p.add_argument("--dry-run", action="store_true", help="Run every check but do not actually merge") + merge_p.add_argument( + "--with-verify", + action="store_true", + help="Immediately run and record the merge-train's terminal full-suite verify after merging", + ) + merge_p.add_argument("--verify-command", default="devtools verify --all", help="Command for --with-verify") + + status_p = sub.add_parser( + "train-status", help="Report whether the merge-train's terminal full-suite verify is recorded" + ) + status_p.add_argument("--json", action="store_true", dest="as_json") + + record_p = sub.add_parser( + "record-full-verify", help="Run and record the merge-train's terminal full-suite verify now" + ) + record_p.add_argument("--command", default="devtools verify --all") + + args = parser.parse_args(argv) + + if args.action == "merge": + return cmd_merge( + args.pr, + command=args.command, + max_age_s=args.max_age_s, + poll_rounds=args.poll_rounds, + poll_interval_s=args.poll_interval_s, + dry_run=args.dry_run, + with_verify=args.with_verify, + verify_command=args.verify_command, + ) + if args.action == "train-status": + return cmd_train_status(args.as_json) + return cmd_record_full_verify(args.command) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/docs/devtools.md b/docs/devtools.md index cacaa25e11..cc38a961aa 100644 --- a/docs/devtools.md +++ b/docs/devtools.md @@ -247,6 +247,7 @@ These are the commands worth remembering during normal repo work: | `devtools workspace lane-init` | Provision a fanout lane worktree: branch, isolated venv, guard check, ledger record. | | `devtools workspace lineage-validation` | Validate lineage-count evidence before citing archive counts externally. | | `devtools workspace mandate-continuity-replay` | Wire t8t continuity scenarios + work-evidence effects + discovery into one mandate artifact. | +| `devtools workspace merge` | Merge boundary wrapper: refuses `gh pr merge` without a fresh merge-gate receipt. | | `devtools workspace merge-conductor` | Mechanical-conflict triage for the PR merge train (dry-run by default). | | `devtools workspace merge-gate` | Structural pre-merge safety check: fresh local-verification receipt + no late review comments. | | `devtools workspace raw-authority-daemon-health-proof` | Prove daemon status/health HTTP responsiveness during a real raw-authority drain. | diff --git a/tests/unit/devtools/test_merge_boundary.py b/tests/unit/devtools/test_merge_boundary.py new file mode 100644 index 0000000000..877c0c7792 --- /dev/null +++ b/tests/unit/devtools/test_merge_boundary.py @@ -0,0 +1,332 @@ +from __future__ import annotations + +import json +import subprocess +from collections.abc import Callable +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock + +import pytest + +from devtools import merge_boundary, merge_gate + + +def _base_pr_view(head_sha: str = "abc123", title: str = "fix: thing (#42)", state: str = "OPEN") -> dict[str, object]: + return { + "headRefOid": head_sha, + "headRefName": "feature/x", + "title": title, + "state": state, + "mergeStateStatus": "CLEAN", + "commits": [{"oid": head_sha, "committedDate": "2026-08-01T12:00:00Z"}], + } + + +def _fake_run( + pr_view: dict[str, object], + comments: list[dict[str, object]] | None = None, + *, + local_exit: int = 0, + local_head_sha: str | None = None, + merge_exit: int = 0, +) -> Callable[..., MagicMock]: + comments = comments or [] + local_head_sha = local_head_sha if local_head_sha is not None else str(pr_view["headRefOid"]) + + def _run(cmd: list[str], **kwargs: Any) -> MagicMock: + joined = " ".join(cmd) + if cmd[:3] == ["gh", "pr", "view"]: + return MagicMock(returncode=0, stdout=json.dumps(pr_view), stderr="") + if cmd[:3] == ["gh", "pr", "merge"]: + return MagicMock(returncode=merge_exit, stdout="merged\n", stderr="" if merge_exit == 0 else "merge failed") + if "/issues/" in joined and "/comments" in joined: + return MagicMock(returncode=0, stdout=json.dumps([[]]), stderr="") + if "/pulls/" in joined and "/reviews" in joined: + return MagicMock(returncode=0, stdout=json.dumps([[]]), stderr="") + if "/pulls/" in joined and "/comments" in joined: + return MagicMock(returncode=0, stdout=json.dumps([comments]), stderr="") + if cmd[:2] == ["git", "rev-parse"]: + return MagicMock(returncode=0, stdout=local_head_sha + "\n", stderr="") + if cmd[:2] == ["git", "status"]: + return MagicMock(returncode=0, stdout="", stderr="") + return MagicMock(returncode=local_exit, stdout="ok\n", stderr="") + + return _run + + +# --------------------------------------------------------------------------- +# clean_merge_title +# --------------------------------------------------------------------------- + + +def test_clean_merge_title_collapses_doubled_suffix() -> None: + assert merge_boundary.clean_merge_title("fix: thing (#42) (#42)", 42) == "fix: thing (#42)" + + +def test_clean_merge_title_leaves_correct_title_untouched() -> None: + assert merge_boundary.clean_merge_title("fix: thing (#42)", 42) == "fix: thing (#42)" + + +def test_clean_merge_title_appends_missing_suffix() -> None: + assert merge_boundary.clean_merge_title("fix: thing", 42) == "fix: thing (#42)" + + +# --------------------------------------------------------------------------- +# cmd_merge +# --------------------------------------------------------------------------- + + +def test_merge_auto_records_when_no_fresh_receipt_then_merges(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.chdir(tmp_path) + pr_view = _base_pr_view() + monkeypatch.setattr(subprocess, "run", _fake_run(pr_view)) + + exit_code = merge_boundary.cmd_merge( + 42, + command="devtools test x", + max_age_s=3600, + poll_rounds=1, + poll_interval_s=0, + dry_run=False, + with_verify=False, + verify_command="devtools verify --all", + ) + + assert exit_code == 0 + assert merge_gate._receipt_path(42).exists() + ledger = json.loads(merge_boundary._LEDGER_PATH.read_text()) + assert ledger["merges"][0]["pr"] == 42 + assert ledger["merges"][0]["title"] == "fix: thing (#42)" + + +def test_merge_strips_doubled_pr_suffix_before_merging(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.chdir(tmp_path) + pr_view = _base_pr_view(title="fix: thing (#42) (#42)") + captured: dict[str, list[str]] = {} + + base_fake = _fake_run(pr_view) + + def _run(cmd: list[str], **kwargs: Any) -> MagicMock: + if cmd[:3] == ["gh", "pr", "merge"]: + captured["cmd"] = cmd + return base_fake(cmd, **kwargs) + + monkeypatch.setattr(subprocess, "run", _run) + + exit_code = merge_boundary.cmd_merge( + 42, + command="devtools test x", + max_age_s=3600, + poll_rounds=1, + poll_interval_s=0, + dry_run=False, + with_verify=False, + verify_command="devtools verify --all", + ) + + assert exit_code == 0 + subject_index = captured["cmd"].index("--subject") + 1 + assert captured["cmd"][subject_index] == "fix: thing (#42)" + + +def test_merge_refuses_when_pr_not_open(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.chdir(tmp_path) + pr_view = _base_pr_view(state="MERGED") + monkeypatch.setattr(subprocess, "run", _fake_run(pr_view)) + + exit_code = merge_boundary.cmd_merge( + 42, + command="devtools test x", + max_age_s=3600, + poll_rounds=1, + poll_interval_s=0, + dry_run=False, + with_verify=False, + verify_command="devtools verify --all", + ) + + assert exit_code == 1 + + +def test_merge_refuses_when_late_unacked_review_comment_exists(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.chdir(tmp_path) + pr_view = _base_pr_view() + late_comment = { + "id": 999, + "path": "polylogue/foo.py", + "line": 1, + "created_at": "2026-08-01T12:05:00Z", + "body": "real finding", + } + monkeypatch.setattr(subprocess, "run", _fake_run(pr_view, [late_comment])) + + exit_code = merge_boundary.cmd_merge( + 42, + command="devtools test x", + max_age_s=3600, + poll_rounds=1, + poll_interval_s=0, + dry_run=False, + with_verify=False, + verify_command="devtools verify --all", + ) + + assert exit_code != 0 + ledger = merge_boundary._read_ledger() + assert ledger["merges"] == [] + + +def test_merge_refuses_when_local_verify_command_fails(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.chdir(tmp_path) + pr_view = _base_pr_view() + monkeypatch.setattr(subprocess, "run", _fake_run(pr_view, local_exit=1)) + + exit_code = merge_boundary.cmd_merge( + 42, + command="devtools test x", + max_age_s=3600, + poll_rounds=1, + poll_interval_s=0, + dry_run=False, + with_verify=False, + verify_command="devtools verify --all", + ) + + assert exit_code == 1 + assert not (Path("cache") / "does-not-exist").exists() # sanity: no merge attempted + ledger = merge_boundary._read_ledger() + assert ledger["merges"] == [] + + +def test_merge_dry_run_never_calls_gh_pr_merge(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.chdir(tmp_path) + pr_view = _base_pr_view() + merge_calls: list[list[str]] = [] + + base_fake = _fake_run(pr_view) + + def _run(cmd: list[str], **kwargs: Any) -> MagicMock: + if cmd[:3] == ["gh", "pr", "merge"]: + merge_calls.append(cmd) + return base_fake(cmd, **kwargs) + + monkeypatch.setattr(subprocess, "run", _run) + + exit_code = merge_boundary.cmd_merge( + 42, + command="devtools test x", + max_age_s=3600, + poll_rounds=1, + poll_interval_s=0, + dry_run=True, + with_verify=False, + verify_command="devtools verify --all", + ) + + assert exit_code == 0 + assert merge_calls == [] + ledger = merge_boundary._read_ledger() + assert ledger["merges"] == [] + + +def test_merge_propagates_gh_pr_merge_failure(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.chdir(tmp_path) + pr_view = _base_pr_view() + monkeypatch.setattr(subprocess, "run", _fake_run(pr_view, merge_exit=3)) + + exit_code = merge_boundary.cmd_merge( + 42, + command="devtools test x", + max_age_s=3600, + poll_rounds=1, + poll_interval_s=0, + dry_run=False, + with_verify=False, + verify_command="devtools verify --all", + ) + + assert exit_code == 3 + ledger = merge_boundary._read_ledger() + assert ledger["merges"] == [] + + +def test_merge_with_verify_records_terminal_full_verify(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.chdir(tmp_path) + pr_view = _base_pr_view() + monkeypatch.setattr(subprocess, "run", _fake_run(pr_view)) + + exit_code = merge_boundary.cmd_merge( + 42, + command="devtools test x", + max_age_s=3600, + poll_rounds=1, + poll_interval_s=0, + dry_run=False, + with_verify=True, + verify_command="devtools verify --all", + ) + + assert exit_code == 0 + ledger = merge_boundary._read_ledger() + assert ledger["last_full_verify"]["command"] == "devtools verify --all" + assert ledger["last_full_verify"]["exit_code"] == 0 + # train-status should now report clean. + assert merge_boundary.cmd_train_status(as_json=False) == 0 + + +# --------------------------------------------------------------------------- +# train-status / record-full-verify +# --------------------------------------------------------------------------- + + +def test_train_status_ok_with_empty_ledger(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.chdir(tmp_path) + assert merge_boundary.cmd_train_status(as_json=False) == 0 + + +def test_train_status_blocks_when_pr_merged_after_last_full_verify( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.chdir(tmp_path) + merge_boundary._write_ledger( + {"merges": [], "last_full_verify": {"at": 1000.0, "command": "devtools verify --all", "exit_code": 0}} + ) + merge_boundary._append_merge_entry(1, "sha1", "some title") + + # Manually push merged_at ahead of the recorded verify. + ledger = merge_boundary._read_ledger() + ledger["merges"][0]["merged_at"] = 2000.0 + merge_boundary._write_ledger(ledger) + + assert merge_boundary.cmd_train_status(as_json=False) == 1 + + +def test_record_full_verify_clears_pending_prs(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.chdir(tmp_path) + merge_boundary._append_merge_entry(1, "sha1", "some title") + + def _run(cmd: list[str], **kwargs: Any) -> MagicMock: + return MagicMock(returncode=0, stdout="all good\n", stderr="") + + monkeypatch.setattr(subprocess, "run", _run) + + exit_code = merge_boundary.cmd_record_full_verify("devtools verify --all") + + assert exit_code == 0 + assert merge_boundary.cmd_train_status(as_json=False) == 0 + + +def test_record_full_verify_propagates_failure(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.chdir(tmp_path) + + def _run(cmd: list[str], **kwargs: Any) -> MagicMock: + return MagicMock(returncode=1, stdout="", stderr="broke") + + monkeypatch.setattr(subprocess, "run", _run) + + exit_code = merge_boundary.cmd_record_full_verify("devtools verify --all") + + assert exit_code == 1 + ledger = merge_boundary._read_ledger() + assert ledger["last_full_verify"]["exit_code"] == 1