#!/bin/sh # scripts/gate --- run the fixed gate suite in a per-worktree build # directory, with isolated ambient roots and durable logs. # # scripts/gate [--acceptance SUITE]... [--protocol] [--print-plan] # scripts/gate [--acceptance SUITE]... [--protocol] --print-plan-named # scripts/gate --print-target-dir # scripts/gate --init # scripts/gate --prune [--force] # scripts/gate --self-test # # Framing: docs/gate-script-framing.md (revision 4, approved), and # docs/gate-protocol-build-framing.md (revision 5, approved) for the # crdt build step, --self-test and --print-plan-named. # # WHY A PER-WORKTREE TARGET DIRECTORY. This machine exports one # CARGO_TARGET_DIR for every checkout, and cargo takes an EXCLUSIVE LOCK # on it. Two worktrees building at once do not run in parallel --- the # second blocks --- and they invalidate each other's artifacts, so # alternating between lanes recompiles from scratch each time. Parallel # worktree development under that arrangement is slower than serial. # Measured cost of the fix: a cold `cargo test --workspace --no-run` is # 80s and 19G, so per-lane directories are cheap and there is no shared # cache worth preserving. # # NOTE ON PRECEDENCE, because the obvious alternative silently fails. # CARGO_TARGET_DIR (the environment) OVERRIDES `build.target-dir` in any # config.toml. A per-worktree `.cargo/config.toml` therefore does # nothing at all while that variable is exported --- which is worse than # doing nothing visibly, because it looks configured. Only a # per-invocation value beats the environment, which is why this exists # as a script rather than a config file. # # THIS IS A CONVENTION, NOT AN ENFORCEMENT. A bare `cargo test` still # uses the shared directory and still takes the lock. Nothing in-repo # can prevent that while the variable is exported globally; real # enforcement would mean dropping the export or adopting direnv, both # machine-config changes outside this repository. set -eu usage() { cat >&2 <<'EOF' usage: scripts/gate [--acceptance SUITE]... [--protocol] [--print-plan] scripts/gate [--acceptance SUITE]... [--protocol] --print-plan-named scripts/gate --print-target-dir scripts/gate --init scripts/gate --prune [--force] scripts/gate --self-test --acceptance SUITE a touched acceptance suite to run (repeatable). docs/agent-handoff.md section 3 stays authoritative for CHOOSING these; this script only runs what it is handed. A script cannot infer them from a working tree, and one that guessed would report coverage it does not have. --protocol the change touches PROTOCOL_VERSION; adds the CRDT workspace sweep on top of the default one, plus the build that sweep needs (see build-crdt below). --print-plan print the exact gate commands and exit. Names are stripped, so every line is runnable as printed. --print-plan-named print the plan as `namecommand` lines and exit --- the same text the runner reads. Exists because a step's NAME is half its contract (a build failure must be attributed to build-crdt, not sweep-crdt) and --print-plan cannot show it. --print-target-dir print this worktree's build directory and exit. Creates nothing. --init create the build directory and ownership marker, print it, exit. Runs no gates. --prune list managed directories whose worktree is gone. Deletes NOTHING without --force. --force with --prune, actually delete. --self-test drive the real runner with a HARDCODED synthetic plan --- true, false, true --- to witness that a failing gate is named as ITSELF and that the suite CONTINUES past it. Runs no real gates. EXITS NON-ZERO BY DESIGN: the middle step fails on purpose, so a non-zero status is this mode working, not this mode broken. EOF exit 2 } # --------------------------------------------------------------------- # One canonical path representation. # # `pwd -P` semantics: physical, symlinks resolved. Used at ALL THREE # points that compare or derive from a worktree path --- directory # derivation, marker creation, and prune comparison. Using it at only # some of them is a real bug and not a tidiness point: a symlinked # invocation would then derive a DIFFERENT directory whose marker # records the CANONICAL path, producing a second build directory for a # live worktree that is indistinguishable from an orphan. # # A path that cannot be entered yields the empty string, which never # matches a marker --- correct, since a directory that is gone is not a # live worktree. # # HONEST NOTE ON HOW MUCH THIS CURRENTLY DOES. Measured on the git in # use here, BOTH `git rev-parse --show-toplevel` and # `git worktree list --porcelain` already report resolved physical # paths, including when a worktree was registered through a symlinked # parent. So on this git, canon() is defence in depth rather than # load-bearing, and the acceptance test for symlinked spellings passes # with or without it --- recorded so nobody reads a mutation-test # "vacuous" result as a hole in the suite. # # It stays because the property it guarantees is one this script's # correctness rests on, that guarantee is not contractual in git, and # a marker can also be written by hand. The cost is one subshell. # --------------------------------------------------------------------- canon() { ( cd "$1" 2>/dev/null && pwd -P ) || true } # Short digest of a string. sha256sum on Linux, shasum on macOS, and # cksum as the POSIX floor so this degrades rather than failing. digest8() { if command -v sha256sum >/dev/null 2>&1; then printf '%s' "$1" | sha256sum | cut -c1-8 elif command -v shasum >/dev/null 2>&1; then printf '%s' "$1" | shasum -a 256 | cut -c1-8 else printf '%s' "$1" | cksum | awk '{printf "%08x", $1}' fi } MARKER_NAME='.pmacs-gate-target' # --------------------------------------------------------------------- # Acceptance suite names are INTERPOLATED INTO A COMMAND that the runner # evaluates, so they are an injection surface and are validated as one. # `--acceptance 'x; rm -rf ~'` must be refused, not executed. # # The allowlist is what a cargo test target can actually be named: # letters, digits, underscore, hyphen. That excludes every shell # metacharacter, whitespace, and path separators, so nothing reaching # the plan can be anything but a bare target name. A leading hyphen is # refused separately --- it is not dangerous, it would just become a # stray flag to cargo and fail confusingly. # --------------------------------------------------------------------- validate_suite() { case $1 in '') echo "gate: --acceptance needs a suite name" >&2 exit 2 ;; -*) echo "gate: acceptance suite may not start with '-': $1" >&2 exit 2 ;; *[!A-Za-z0-9_-]*) echo "gate: refusing acceptance suite name: $1" >&2 echo "gate: names are cargo test targets --- letters, digits," >&2 echo "gate: underscore and hyphen only. The name is interpolated" >&2 echo "gate: into a command, so anything else is rejected rather" >&2 echo "gate: than escaped." >&2 exit 2 ;; esac } # The managed root. PMACS_GATE_TARGET_ROOT exists for the behaviour # tests and is documented test-only: it is what keeps them from being # able to reach the real root. gate_root() { if [ -n "${PMACS_GATE_TARGET_ROOT:-}" ]; then printf '%s' "$PMACS_GATE_TARGET_ROOT" else printf '%s' "${HOME}/build/pmacs-gate-targets" fi } worktree_root() { git rev-parse --show-toplevel 2>/dev/null || { echo "gate: not inside a git worktree" >&2 exit 2 } } # The derived build directory for this worktree. Pure: creates nothing. target_dir_for() { _wt=$1 _base=$(basename "$_wt") printf '%s/%s-%s' "$(gate_root)" "$_base" "$(digest8 "$_wt")" } # Create the directory and its ownership marker. Idempotent. The gate # path calls this too, so --init is not a second implementation. ensure_target_dir() { _wt=$1 _dir=$(target_dir_for "$_wt") mkdir -p "$_dir" printf '%s\n' "$_wt" > "$_dir/$MARKER_NAME" printf '%s' "$_dir" } # --------------------------------------------------------------------- # The plan. # # Emits one `namecommand` line per gate. docs/agent-handoff.md # section 3 owns the REASONING for each of these --- why --workspace and # never --tests, why --no-fail-fast, why --skip basedpyright, when # --protocol applies. What lives here is the executable form, so it # cannot be retyped differently each time. # # --print-plan renders this without running anything, which is what # makes drift from section 3 testable. # --------------------------------------------------------------------- emit_plan() { printf 'fmt\tcargo fmt --check\n' printf 'clippy\tcargo clippy --workspace --all-targets -- -D warnings\n' printf 'lib\tcargo test --lib\n' printf 'lib-crdt\tcargo test --lib --features crdt\n' for _s in $ACCEPTANCE; do printf 'acceptance-%s\tcargo test --test %s\n' "$_s" "$_s" done printf 'm4\tcargo test --test m4_acceptance -- --skip basedpyright\n' printf 'gpu\tPMACS_REQUIRE_GPU=1 cargo test -p pmacs-gpu\n' printf 'sweep\tcargo test --workspace --no-fail-fast -- --skip basedpyright\n' if [ "$PROTOCOL" = 1 ]; then # Section 3: touching PROTOCOL_VERSION STRENGTHENS the sweep # line, it does not replace it. Both sweeps run. # # THE BUILD IS A PRECONDITION OF THE SWEEP, not a courtesy. The # crdt sweep spawns `pmacs-gpu` as a PROCESS, and no `cargo # test` run produces that binary: pmacs-gpu has no tests/ # directory, so cargo never uplifts its bin to debug/pmacs-gpu. # On a cold target directory the sweep therefore fails twelve # gpu_invocation_acceptance::crdt::* tests on "build pmacs-gpu # before this acceptance suite" --- and, worse, other crdt tests # that render through the real binary SKIP THEMSELVES and report # ok, so the missing build also voids coverage silently. # # WHY ONLY UNDER --protocol, measured rather than reasoned. On # 2026-08-09, on a disposable target directory with # debug/pmacs-gpu asserted ABSENT before each run and each sweep # run alone from that cold state: the DEFAULT sweep exited 0 and # left debug/pmacs-gpu still absent --- it never builds the # binary and never needs it --- while the crdt sweep exited 101 # with exactly those twelve failures. So the default gate does # not pay for this build. # # A SEPARATE NAMED STEP, never folded into the sweep command. # `cargo build ... && cargo test ...` would report a BUILD # failure under the name `sweep-crdt`, which is a wrong # attribution in the one place this script exists to be # trustworthy about. --self-test is what witnesses that the # runner names the failing gate as itself. printf 'build-crdt\tcargo build --workspace --no-default-features --features luajit,crdt\n' printf 'sweep-crdt\tcargo test --workspace --features crdt --no-fail-fast -- --skip basedpyright\n' fi printf 'diff-check\tgit diff --check\n' } # --------------------------------------------------------------------- # The synthetic plan behind --self-test: the runner held to its own # contract. # # WHY A MODE EXISTS AT ALL. tests/gate_script_acceptance.rs drives only # NO-GATES paths --- a test that ran the real suite would run the gate # suite inside the gate suite --- so plan assertions can prove a step's # name and its order and NOTHING about what the runner does when a step # fails. That left two stated properties with no way to observe them: # a failing gate is attributed to ITSELF (which is the whole reason # build-crdt is a separate step rather than `cargo build && cargo # test`), and the suite CONTINUES past it rather than aborting. Same # shape of argument as --init: verification needs a path it can drive # safely, and this one is not a second implementation --- it hands the # REAL runner loop a different plan file. # # THE PLAN IS A LITERAL, and that is the design, not a shortcut. The # obvious seam --- letting a caller supply PLAN_FILE --- would work, # and it would turn the runner's `eval` into a general command # executor. That is the same class of defect this script's own review # caught in --acceptance and fixed with a refusal at parse time; # reintroducing it in the tool whose purpose is to be trustworthy is # not a trade worth making. Nothing external supplies a command here. # # THREE LINES, AND THE THIRD IS LOAD-BEARING. With the failure LAST, a # runner that aborts and a runner that continues produce IDENTICAL # output, so the witness would pass on a runner doing the opposite of # the stated policy. The sentinel after the failure, asserted to have # written its own log, is the only thing that separates them. # # `true` and `false` are the entire workload, so this stays on the # cheap side of the suite. Whether `cargo build` really fails is # cargo's business; whether THIS SCRIPT names the right gate when a # command fails is the criterion, and that is orthogonal to which # command failed. # --------------------------------------------------------------------- emit_self_test_plan() { printf 'self-pass\ttrue\n' printf 'build-crdt\tfalse\n' printf 'self-sentinel\ttrue\n' } # --------------------------------------------------------------------- # Pruning. # # Dry run by default; --force to delete. Never automatic, never on the # gate path. # # "LIVE" IS NARROWER THAN "LISTED", and that difference is the whole # correctness of this. `git worktree list --porcelain` keeps reporting a # worktree that was administratively registered but whose directory was # manually deleted --- it adds a `prunable ` line to that # record. Treating every listed path as live would make exactly the # directories most worth reclaiming permanently ineligible. # --------------------------------------------------------------------- # # FAILS LOUDLY RATHER THAN RETURNING NOTHING. An empty live set means # "every managed directory is an orphan", so a silent failure here is a # command to delete all of them. The first version piped git straight # into awk and the caller masked the result with `|| true`; run from # outside any repository that produced an empty set and # `--prune --force` would have deleted every marked directory. The # capture-then-check shape below is what makes that unrepresentable. live_worktrees() { _porc=$(git worktree list --porcelain 2>/dev/null) || return 1 # A repository always has at least its own worktree, so empty output # is a failure, not an answer. [ -n "$_porc" ] || return 1 # Records are blank-line separated. Emit the canonical path of every # record that carries NO `prunable` line. printf '%s\n' "$_porc" | awk ' /^worktree / { path = substr($0, 10); prunable = 0; next } /^prunable/ { prunable = 1; next } /^$/ { if (path != "" && !prunable) print path; path = ""; next } END { if (path != "" && !prunable) print path } ' | while IFS= read -r p; do c=$(canon "$p") [ -n "$c" ] && printf '%s\n' "$c" done } do_prune() { # SAFETY GATE, and it is the most important thing in this file. # Pruning decides what to DELETE by subtracting the live worktree # set from the managed root. If that set cannot be established, the # correct answer is not "nothing is live" --- it is "refuse". if ! git rev-parse --show-toplevel >/dev/null 2>&1; then echo "gate: refusing to prune --- not inside a git worktree." >&2 echo "gate: the live-worktree set cannot be established from here," >&2 echo "gate: and an empty one would mark every managed directory an" >&2 echo "gate: orphan. Run --prune from inside a checkout." >&2 exit 2 fi if ! _live=$(live_worktrees); then echo "gate: refusing to prune --- could not enumerate git worktrees." >&2 echo "gate: nothing was examined and nothing was deleted." >&2 exit 2 fi _root=$(gate_root) if [ ! -d "$_root" ]; then echo "gate: no managed root at $_root; nothing to prune" return 0 fi _found=0 for _d in "$_root"/*; do [ -d "$_d" ] || continue _found=1 if [ ! -r "$_d/$MARKER_NAME" ]; then # The protection that matters: a directory that merely # RESEMBLES a managed one is never touched. echo "gate: skip $_d --- no readable $MARKER_NAME" continue fi # The marker is DOCUMENTED as one line, so enforce that rather # than reading the first line of whatever is there. Reading only # line 1 would accept a multi-line file and act on its head --- # which is exactly the shape a corrupted or hand-edited marker # takes, and acting on it means deleting a directory on the # strength of a file we did not understand. _lines=$(wc -l < "$_d/$MARKER_NAME" 2>/dev/null || echo 0) if [ "$_lines" -ne 1 ]; then echo "gate: skip $_d --- marker is not exactly one line ($_lines)" continue fi _owner=$(cat "$_d/$MARKER_NAME" 2>/dev/null || true) case $_owner in /*) ;; *) echo "gate: skip $_d --- marker is not an absolute path" continue ;; esac if printf '%s\n' "$_live" | grep -qxF "$_owner"; then echo "gate: skip $_d --- worktree is live ($_owner)" continue fi if [ "$FORCE" = 1 ]; then rm -rf "$_d" echo "gate: deleted $_d (worktree gone: $_owner)" else echo "gate: WOULD delete $_d (worktree gone: $_owner)" fi done [ "$_found" = 1 ] || echo "gate: managed root is empty" if [ "$FORCE" != 1 ]; then echo "gate: dry run --- nothing was deleted. Re-run with --force." fi } # --------------------------------------------------------------------- # Argument parsing # --------------------------------------------------------------------- ACCEPTANCE='' PROTOCOL=0 FORCE=0 MODE=run while [ $# -gt 0 ]; do case $1 in --acceptance) [ $# -ge 2 ] || usage validate_suite "$2" ACCEPTANCE="$ACCEPTANCE $2" shift 2 ;; --protocol) PROTOCOL=1; shift ;; --print-plan) MODE=plan; shift ;; --print-plan-named) MODE=plannamed; shift ;; --print-target-dir) MODE=printdir; shift ;; --init) MODE=init; shift ;; --prune) MODE=prune; shift ;; --self-test) MODE=selftest; shift ;; --force) FORCE=1; shift ;; -h|--help) usage ;; *) echo "gate: unknown argument: $1" >&2; usage ;; esac done # --------------------------------------------------------------------- # Mode dispatch. # # TWO RENDERINGS OF ONE PLAN, and the second exists because the first # hid a defect. `--print-plan` pipes through `cut -f2-` so every printed # line is a command a reader can copy and run --- and that same cut is # why the emitted NAMES never reached a test. Not cosmetic: the entire # reason build-crdt is a separate step is that a build failure must be # attributed to `build-crdt` rather than to `sweep-crdt`, and with the # names stripped, RENAMING THE REAL BUILD STEP TO `sweep-crdt` left the # ordering assertion green. The witness could not reach the step it # named. --self-test could not either --- it hardcodes the string # `build-crdt` in its own synthetic plan, which proves things about the # RUNNER and nothing about this emitter. # # --print-plan-named prints emit_plan VERBATIM: the same # `namecommand` text the runner reads back from PLAN_FILE, so a # test can assert both halves of a real step together and a rename # cannot pass. # # WHY THIS RATHER THAN THE TWO ALTERNATIVES. # # Injecting PLAN_FILE would let a test hand the runner a plan and read # the names back, and it would turn the runner's `eval` into a general # command executor --- the same class of defect this script's own # review caught in --acceptance and fixed with a parse-time refusal. # Declined there; declined here for the same reason. # # Re-deriving the plan test-side (sourcing this file, or parsing # emit_plan out of it) would be a SECOND implementation of the thing # under test, which is the exact failure being repaired one level up. # # A DISTINCT MODE, not a modifier on --print-plan: there is then no # `--with-names` without `--print-plan` whose behaviour has to be # defined, and --print-plan's contract --- runnable lines --- is left # exactly as it was. Both modes call emit_plan, and so does the runner, # so neither rendering can drift from what actually executes; # tests/gate_script_acceptance.rs pins that the stripped rendering is # the named one minus its names, so this stays true by test and not # only by reading. # --------------------------------------------------------------------- case $MODE in plan) emit_plan | cut -f2- exit 0 ;; plannamed) emit_plan exit 0 ;; printdir) target_dir_for "$(canon "$(worktree_root)")" echo exit 0 ;; init) ensure_target_dir "$(canon "$(worktree_root)")" echo exit 0 ;; prune) do_prune exit 0 ;; esac # --------------------------------------------------------------------- # Run # --------------------------------------------------------------------- WT=$(canon "$(worktree_root)") cd "$WT" TARGET=$(ensure_target_dir "$WT") STAMP=$(date -u +%Y%m%dT%H%M%SZ) # $$ as well as the timestamp: two invocations in the same worktree # within one second would otherwise share a log directory and overwrite # each other's evidence --- which is precisely the U2/U3 failure this # logging exists to prevent, reintroduced by a naming choice. LOGDIR="$TARGET/gate-logs/$STAMP-$$" mkdir -p "$LOGDIR" # Ambient roots: all five, one fresh directory, reaped on exit. # # THE FIFTH IS NOT REDUNDANT. PMACS_STATE_HOME outranks XDG_STATE_HOME # (src/state.rs), so redirecting only the four XDG variables leaves the # real state root live on a machine that exports it. # # Belt and braces rather than a workaround: ambient-root isolation # merged as #206 and the in-crate paths resolve roots explicitly. What # this covers is everything OUTSIDE that guarantee --- integration # suites that spawn the real binary, PTY and daemon fixtures, anything # reaching a production resolution path --- where the process under test # reads the environment it was handed. HOME is deliberately left alone. AMBIENT="$TARGET/gate-ambient/$STAMP-$$" mkdir -p "$AMBIENT" cleanup() { rm -rf "$AMBIENT"; } trap cleanup EXIT INT TERM export CARGO_TARGET_DIR="$TARGET" export XDG_CONFIG_HOME="$AMBIENT" XDG_DATA_HOME="$AMBIENT" \ XDG_STATE_HOME="$AMBIENT" XDG_CACHE_HOME="$AMBIENT" \ PMACS_STATE_HOME="$AMBIENT" echo "gate: worktree $WT" echo "gate: target dir $TARGET" echo "gate: logs $LOGDIR" # Printed so the isolation is OBSERVABLE rather than merely asserted: # this is the directory all five ambient roots point at, and the exit # trap removes it, so it should be gone once the run finishes. echo "gate: ambient $AMBIENT" [ -n "$ACCEPTANCE" ] && echo "gate: acceptance $ACCEPTANCE" [ "$PROTOCOL" = 1 ] && echo "gate: protocol yes (CRDT build + workspace sweep added)" if [ "$MODE" = selftest ]; then echo "gate: SELF-TEST hardcoded synthetic plan --- NO real gate runs." echo "gate: the middle step fails ON PURPOSE, so a non-zero" echo "gate: exit is this mode working, not this mode broken." fi echo # The self-test hands the REAL runner loop below a different plan file. # Everything after this point is shared, which is the point: a witness # that exercised its own copy of the runner would witness nothing. PLAN_FILE="$LOGDIR/plan.txt" if [ "$MODE" = selftest ]; then emit_self_test_plan > "$PLAN_FILE" else emit_plan > "$PLAN_FILE" fi N=0 FAILED='' while IFS="$(printf '\t')" read -r name cmd; do N=$((N + 1)) log=$(printf '%s/%02d-%s.log' "$LOGDIR" "$N" "$name") printf 'gate: [%02d] %-14s ' "$N" "$name" # THE RUNNER MUST SURVIVE `set -e`, and the obvious forms do not. # # cmd | tee log --- reports TEE's status, so a failing gate # exits 0 and the suite reads green. # `set -o pipefail` is not POSIX; dash # lacks it and this script targets sh. # cmd > log; rc=$? --- under `set -e` the shell exits AT the # failing command, so `rc=$?` never runs # and nothing prints which gate failed or # where its log is --- destroying the # entire point of capturing it. # # A failing command is exempt from `set -e` only as an `if` # condition. Hence this shape: no pipeline, so no status is lost to # tee; no bare failing command, so no status is lost to set -e. if eval "$cmd" > "$log" 2>&1; then echo "ok" else rc=$? echo "FAILED (exit $rc)" echo "gate: log: $log" >&2 FAILED="$FAILED $name" fi done < "$PLAN_FILE" echo echo "gate: sweep logs (the U2/U3 remedy --- read these, do not re-run and grep):" for f in "$LOGDIR"/*-sweep.log "$LOGDIR"/*-sweep-crdt.log; do [ -f "$f" ] && echo "gate: $f" done if [ -n "$FAILED" ]; then echo >&2 echo "gate: FAILED:$FAILED" >&2 echo "gate: logs in $LOGDIR" >&2 exit 1 fi echo echo "gate: all gates passed"