452 lines
18 KiB
Bash
Executable File
452 lines
18 KiB
Bash
Executable File
#!/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 --print-target-dir
|
|
# scripts/gate --init
|
|
# scripts/gate --prune [--force]
|
|
#
|
|
# Framing: docs/gate-script-framing.md (revision 4, approved).
|
|
#
|
|
# 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 --print-target-dir
|
|
scripts/gate --init
|
|
scripts/gate --prune [--force]
|
|
|
|
--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.
|
|
--print-plan print the exact gate commands and exit.
|
|
--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.
|
|
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 `name<TAB>command` 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.
|
|
printf 'sweep-crdt\tcargo test --workspace --features crdt --no-fail-fast -- --skip basedpyright\n'
|
|
fi
|
|
printf 'diff-check\tgit diff --check\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 <reason>` 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-target-dir) MODE=printdir; shift ;;
|
|
--init) MODE=init; shift ;;
|
|
--prune) MODE=prune; shift ;;
|
|
--force) FORCE=1; shift ;;
|
|
-h|--help) usage ;;
|
|
*) echo "gate: unknown argument: $1" >&2; usage ;;
|
|
esac
|
|
done
|
|
|
|
case $MODE in
|
|
plan)
|
|
emit_plan | cut -f2-
|
|
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 workspace sweep added)"
|
|
echo
|
|
|
|
PLAN_FILE="$LOGDIR/plan.txt"
|
|
emit_plan > "$PLAN_FILE"
|
|
|
|
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"
|