Files
stack/scripts/queue-commit.sh
T
jason.woltjeandClaude Opus 5.5 6fb50cc0a8 fix(queue): genesis owner-not-reviewer check, assign wording, queue-commit HEAD-moved message, calendar dates (row 33, #1508)
Built by filbert, approved by darkwing in round 1 (#1508 comment 26651).
Manifest fe7da3ef, 10 paths. Suites green on an index export.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-04 01:25:06 -05:00

242 lines
13 KiB
Bash
Executable File

#!/usr/bin/env bash
# queue-commit.sh: the lead's commit procedure for the queue (queue-as-data
# plan 8.12). It commits exactly the tested bytes of docs/plans/queue.json and
# docs/plans/QUEUE.md on top of HEAD through a temporary index and
# commit-tree, so nothing anyone else has staged is swept in.
#
# Usage:
# scripts/queue-commit.sh -m MSG commit the queue's next revisions
# scripts/queue-commit.sh --genesis -m MSG the first queue commit (HEAD has no queue.json)
# scripts/queue-commit.sh --install-hook [--by NAME]
# install the queue guard (jason or sage)
#
# Exit codes: 0 ok; 1 failed, nothing published; 2 refused, nothing
# published; 3 committed but the shared index was not reconciled (the
# printed command finishes it); 4 usage.
#
# Committing still needs its own authorization. This procedure doesn't give
# it, and it never pushes.
set -uo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)"
QJSON=docs/plans/queue.json
QMD=docs/plans/QUEUE.md
HOOK_REL=scripts/git-hooks/pre-commit
FIX="git reset -q -- $QJSON $QMD"
TMPD=""
say() { printf 'queue-commit: %s\n' "$*" >&2; }
die() { local code=$1; shift; say "$*"; exit "$code"; }
cleanup() { if [ -n "$TMPD" ]; then rm -rf -- "$TMPD"; fi; }
trap cleanup EXIT
usage() {
sed -n '8,12p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' >&2
exit 4
}
g() { git -C "$ROOT" "$@"; }
# --- arguments ---
MODE=commit
GENESIS=0
MSG=""
HAVE_MSG=0
BY=""
while [ $# -gt 0 ]; do
case "$1" in
-m) [ $# -ge 2 ] || usage; MSG=$2; HAVE_MSG=1; shift 2 ;;
--genesis) GENESIS=1; shift ;;
--install-hook) MODE=install; shift ;;
--by) [ $# -ge 2 ] || usage; BY=$2; shift 2 ;;
-h|--help) usage ;;
*) say "unknown argument: $1"; usage ;;
esac
done
if [ "$MODE" = install ]; then
{ [ "$HAVE_MSG" = 0 ] && [ "$GENESIS" = 0 ]; } || { say "--install-hook takes only --by"; usage; }
else
[ -z "$BY" ] || { say "--by applies only to --install-hook"; usage; }
[ "$HAVE_MSG" = 1 ] && [ -n "$MSG" ] || { say "a commit needs -m MSG"; usage; }
fi
# --- the repository (8.3): the canonical checkout, located only from this script ---
for v in GIT_DIR GIT_WORK_TREE GIT_COMMON_DIR GIT_INDEX_FILE GIT_OBJECT_DIRECTORY GIT_ALTERNATE_OBJECT_DIRECTORIES; do
if [ -n "${!v+x}" ]; then die 2 "refused: $v is set; run with the normal git environment"; fi
done
top=$(g rev-parse --show-toplevel 2>/dev/null) || die 2 "refused: $ROOT is not a git checkout"
[ "$(cd "$top" && pwd -P)" = "$ROOT" ] || die 2 "refused: $ROOT is not the top of its checkout"
gd=$(g rev-parse --path-format=absolute --git-dir) || die 1 "cannot read the git directory"
cd_=$(g rev-parse --path-format=absolute --git-common-dir) || die 1 "cannot read the git common directory"
[ "$(cd "$gd" && pwd -P)" = "$ROOT/.git" ] && [ "$(cd "$cd_" && pwd -P)" = "$ROOT/.git" ] \
|| die 2 "refused: $ROOT's git directory is not $ROOT/.git (a linked worktree or a separate git dir)"
HOOK="$ROOT/.git/hooks/pre-commit"
TMPD=$(mktemp -d "${TMPDIR:-/tmp}/queue-commit.XXXXXX") || die 1 "mktemp failed"
# The canary's index holds $1's tree, and the hook diffs it with the live
# HEAD. Once another commit lands, the clean run can fail with the guard
# working as installed, so a moved HEAD is reported as that, not as the guard.
head_moved() {
local now
now=$(g rev-parse --verify -q HEAD) || die 2 "refused ($2): HEAD has no commit"
[ "$now" = "$1" ] || die 2 "refused ($2): HEAD moved since $1 was recorded (another commit landed); run queue-commit.sh again"
}
# --- the queue guard, active and not just present (8.12, G2). $1 is the
# commit whose hook blob and tree the checks use. ---
guard_check() {
local h=$1 when=$2 idx out rc
[ -e "$HOOK" ] || [ -L "$HOOK" ] || die 2 "refused ($when): the queue guard is not installed at .git/hooks/pre-commit; run \`scripts/queue-commit.sh --install-hook\`"
[ ! -L "$HOOK" ] || die 2 "refused ($when): .git/hooks/pre-commit is a symlink"
[ -f "$HOOK" ] || die 2 "refused ($when): .git/hooks/pre-commit is not a regular file"
[ -O "$HOOK" ] || die 2 "refused ($when): .git/hooks/pre-commit is not owned by this user"
[ -x "$HOOK" ] || die 2 "refused ($when): .git/hooks/pre-commit is not executable, so git would skip it"
g cat-file -e "$h:$HOOK_REL" 2>/dev/null || die 2 "refused ($when): $h has no $HOOK_REL"
g cat-file blob "$h:$HOOK_REL" | cmp -s - "$HOOK" \
|| die 2 "refused ($when): .git/hooks/pre-commit differs from $HOOK_REL at $h"
out=$(g config --show-scope --get-all core.hooksPath 2>/dev/null)
[ -z "$out" ] || die 2 "refused ($when): core.hooksPath is set ($(printf '%s' "$out" | tr '\n\t' '; ')), so git would not run the queue guard"
# The canary: git itself runs the hook it would run for a commit. HEAD is
# checked before it and again if the clean run fails, since a commit can
# land between the two.
head_moved "$h" "$when"
idx="$TMPD/canary-$when/index"
mkdir -p "$(dirname "$idx")"
GIT_INDEX_FILE=$idx g read-tree "$h" || die 1 "canary ($when): read-tree failed"
if ! out=$(cd "$ROOT" && GIT_INDEX_FILE=$idx git hook run pre-commit 2>&1); then
head_moved "$h" "$when"
die 2 "refused ($when): the canary's clean run failed, so git is not running the queue guard as installed: $out"
fi
GIT_INDEX_FILE=$idx g update-index --add --cacheinfo "100644,$(g rev-parse "$h:$HOOK_REL"),$QMD" \
|| die 1 "canary ($when): update-index failed"
out=$(cd "$ROOT" && GIT_INDEX_FILE=$idx git hook run pre-commit 2>&1); rc=$?
[ "$rc" -ne 0 ] && [[ "$out" == *"mosaic queue guard: refused"* ]] \
|| die 2 "refused ($when): the canary's changed run was not refused by the queue guard (exit $rc)"
rm -rf -- "$(dirname "$idx")"
}
# --- --install-hook ---
if [ "$MODE" = install ]; then
[ -n "$BY" ] || BY=${MOSAIC_AGENT_NAME:-}
[ -n "$BY" ] || die 2 "refused: no actor; pass --by NAME or set MOSAIC_AGENT_NAME"
case "$BY" in jason|sage) ;; *) die 2 "refused: installing the queue guard is privileged (jason or sage), not $BY" ;; esac
H=$(g rev-parse --verify -q HEAD) || die 2 "refused: HEAD has no commit"
out=$(g config --show-scope --get-all core.hooksPath 2>/dev/null)
[ -z "$out" ] || die 2 "refused: core.hooksPath is set ($(printf '%s' "$out" | tr '\n\t' '; ')); unset it first"
g cat-file -e "$H:$HOOK_REL" 2>/dev/null || die 2 "refused: HEAD has no $HOOK_REL"
if [ -L "$HOOK" ]; then die 2 "refused: .git/hooks/pre-commit is a symlink; remove it by hand if it is not wanted"; fi
if [ -e "$HOOK" ]; then
[ -f "$HOOK" ] || die 2 "refused: .git/hooks/pre-commit is not a regular file"
g cat-file blob "$H:$HOOK_REL" | cmp -s - "$HOOK" || die 2 "refused: a different pre-commit hook exists at .git/hooks/pre-commit"
chmod 0755 "$HOOK" || die 1 "chmod failed"
note="the same bytes were already there"
else
mkdir -p "$ROOT/.git/hooks" || die 1 "cannot create .git/hooks"
tmp="$ROOT/.git/hooks/.pre-commit.queue-commit.$$"
g cat-file blob "$H:$HOOK_REL" > "$tmp" && chmod 0755 "$tmp" || { rm -f -- "$tmp"; die 1 "cannot write .git/hooks/pre-commit"; }
# link() never replaces, so a hook that appeared meanwhile is kept.
if ! ln -- "$tmp" "$HOOK" 2>/dev/null; then rm -f -- "$tmp"; die 2 "refused: a pre-commit hook appeared while installing"; fi
rm -f -- "$tmp"
note="copied from HEAD"
fi
guard_check "$H" install
printf 'ok installed the queue guard at .git/hooks/pre-commit (%s, blob %s, mode 0755); canary passed\n' \
"$note" "$(g rev-parse "$H:$HOOK_REL")"
exit 0
fi
# --- 1. guard. H is recorded first, before the canary. A branch that moves
# later is refused by a guard check or, after step 7's, by update-ref. ---
H=$(g rev-parse --verify -q HEAD) || die 2 "refused: HEAD has no commit"
BRANCH=$(g symbolic-ref -q --short HEAD) || die 2 "refused: HEAD is detached"
guard_check "$H" step1
g diff-index --cached --quiet "$H" -- "$QJSON" "$QMD" \
|| die 2 "refused: the shared index has staged changes to $QJSON or $QMD; seats never stage them; run: $FIX"
if g cat-file -e "$H:$QJSON" 2>/dev/null; then
[ "$GENESIS" = 0 ] || die 2 "refused: --genesis, but HEAD already has $QJSON"
else
[ "$GENESIS" = 1 ] || die 2 "refused: HEAD has no $QJSON; the first queue commit needs --genesis"
fi
# --- 2. snapshot, under the queue lock, with this checkout's code ---
SNAP="$TMPD/snapshot"
mkdir "$SNAP" || die 1 "mkdir failed"
snapline=$(cd "$ROOT" && node "$ROOT/packages/queue/src/cli.mjs" snapshot --out "$SNAP") || die 2 "refused: queue snapshot failed (see above)"
say "$snapline"
# shellcheck disable=SC2016
info=$(node -e '
const d = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"));
const g = d.log[0];
process.stdout.write([g.args.branch, g.args.root, g.args.map, g.result.mapBlob].join("\n"));
' "$SNAP/queue.json") || die 2 "refused: cannot read the snapshot's genesis entry"
{ read -r gbranch; read -r groot; read -r gmap; read -r gblob; } <<<"$info"
[ "$gbranch" = "$BRANCH" ] || die 2 "refused: HEAD is on $BRANCH, but the queue's genesis branch is $gbranch"
[ "$groot" = "$ROOT" ] || die 2 "refused: the queue's canonical root is $groot, not $ROOT"
if [ "$GENESIS" = 1 ]; then
hblob=$(g rev-parse -q --verify "$H:$gmap" 2>/dev/null) || die 2 "refused: the migration map $gmap is not in HEAD"
[ "$hblob" = "$gblob" ] || die 2 "refused: genesis read map blob $gblob, but HEAD's $gmap is $hblob"
fi
# --- 3. base, from the object database ---
TREE=$(g rev-parse "$H^{tree}") || die 1 "cannot read HEAD's tree"
printf '%s %s\n' "$H" "$TREE" > "$SNAP/base.id"
if [ "$GENESIS" = 1 ]; then
BASEARG=(--base-absent)
else
g cat-file blob "$H:$QJSON" > "$SNAP/base.json" || die 1 "cannot read the base $QJSON"
BASEARG=(--base-file "$SNAP/base.json")
fi
# --- 4. verify with HEAD's code, outside any repository ---
ARCH="$TMPD/archive/tree"
mkdir -p "$ARCH" || die 1 "mkdir failed"
g archive --format=tar "$H" | tar -x -C "$ARCH" || die 1 "cannot unpack HEAD's archive"
[ -f "$ARCH/packages/queue/src/cli.mjs" ] || die 2 "refused: HEAD has no packages/queue"
# NODE_TEST_CONTEXT is cleared: under a parent test runner, a nested
# `node --test` reports to that runner and exits 0 whatever its tests do.
if ! (cd "$ARCH" && GIT_CEILING_DIRECTORIES="$TMPD/archive" env -u NODE_TEST_CONTEXT node --test packages/queue/tests/ >"$TMPD/archive-tests.log" 2>&1); then
tail -n 40 "$TMPD/archive-tests.log" >&2
die 2 "refused: HEAD's queue tests failed in the archive"
fi
(cd "$ARCH" && GIT_CEILING_DIRECTORIES="$TMPD/archive" node packages/queue/src/cli.mjs verify --snapshot "$SNAP" "${BASEARG[@]}") >&2 \
|| die 2 "refused: HEAD's validator rejected the snapshot"
# --- 5. blobs ---
B1=$(g hash-object -w --no-filters "$SNAP/queue.json") || die 1 "hash-object failed"
B2=$(g hash-object -w --no-filters "$SNAP/QUEUE.md") || die 1 "hash-object failed"
# --- 6. tree, in a temporary index at a path that doesn't exist yet ---
TIDX="$TMPD/tree/index"
mkdir -p "$TMPD/tree"
GIT_INDEX_FILE=$TIDX g read-tree "$H" || die 1 "read-tree failed"
GIT_INDEX_FILE=$TIDX g update-index --add --cacheinfo "100644,$B1,$QJSON" --cacheinfo "100644,$B2,$QMD" \
|| die 1 "update-index failed"
T=$(GIT_INDEX_FILE=$TIDX g write-tree) || die 1 "write-tree failed"
changed=$(g diff-tree -r --name-only "$H" "$T") || die 1 "diff-tree failed"
[ -n "$changed" ] || die 2 "refused: the snapshot equals HEAD's queue files; nothing to commit"
while IFS= read -r p; do
[ "$p" = "$QJSON" ] || [ "$p" = "$QMD" ] || die 1 "the new tree changes $p, not only the queue files"
done <<<"$changed"
# --- 7. commit, recheck the guard, publish ---
printf '%s\n' "$MSG" > "$TMPD/msg"
C=$(g commit-tree "$T" -p "$H" -F "$TMPD/msg") || die 1 "commit-tree failed"
guard_check "$H" step7
if ! g update-ref -m queue-commit "refs/heads/$BRANCH" "$C" "$H"; then
die 1 "refs/heads/$BRANCH moved since $H; nothing published; start again"
fi
say "committed $C on $BRANCH (parent $H)"
# --- 8. reconcile the shared index ---
unreconciled() { say "committed $C, but the shared index was not reconciled: $*"; say "the queue guard refuses ordinary commits until this runs: $FIX"; exit 3; }
head_now=$(g rev-parse HEAD)
[ "$head_now" = "$C" ] || unreconciled "HEAD is $head_now, not $C; the index was not touched"
want=$(g ls-tree --format='%(objectmode) %(objectname) 0 %(path)' "$H" -- "$QJSON" "$QMD") || unreconciled "cannot read HEAD's entries"
have=$(g ls-files --format='%(objectmode) %(objectname) %(stage) %(path)' -- "$QJSON" "$QMD") || unreconciled "cannot read the index"
[ "$have" = "$want" ] || unreconciled "the index's queue entries differ from $H's, so someone staged a queue path; the index was not touched"
[ ! -e "$ROOT/.git/index.lock" ] || unreconciled "another git process holds .git/index.lock"
g reset -q -- "$QJSON" "$QMD" || unreconciled "git reset failed"
printf 'ok committed %s: %s\n' "$C" "$snapline"
exit 0