packages/queue, scripts/queue-commit.sh, scripts/git-hooks and scripts/test-queue.sh, plus docs/plans/BRIEF-TEMPLATE.md. There is no queue.json yet, so verify skips until the genesis commit after A2. Darkwing built it, and Filbert reviewed R0 (6933b885, changes requested) and r1 (e464be6c, approved). The 20 files match manifest 85a8a453. The nine suites passed on an index export, including the new queue suite. test-queue.sh joins the suite list in AGENTS.md. Lead decisions 20, 23 and 26. Co-Authored-By: Claude Opus 5.5 <[email protected]>
229 lines
12 KiB
Bash
Executable File
229 lines
12 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 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.
|
|
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
|
|
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, so a branch that moves
|
|
# at any later point makes update-ref in step 7 fail. ---
|
|
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
|