Files
stack/scripts/land-work.sh
T
jason.woltjeandClaude Opus 5.5 1f2ac5b848 scripts: land-work turns a resolved merge decision into a gated local commit (row 116, #1610)
Candidate manifest 8b4ab31014a124ec0e3abba44238976d6f4c9af48cbdb36c2c77d18f0137f888
from Darkwing's round 2 packet (0a4076e8e), on BASE 1223a0f37. No
manifest path changed between BASE and the landing target. Filbert
approved round 2 in 28468, closing C1 and D1 from round 1 (28464).
Sage reran the full gate at the landing target with the candidate
applied. The replay acceptance follows ruling sage-0204.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 14:24:57 -05:00

281 lines
14 KiB
Bash
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
# Land approved work: turn a resolved merge decision into one gated local
# commit on the base branch (row 116, #1610).
#
# Usage: scripts/land-work.sh <decision-id> [--base refactor] [--dry-run]
# [--subject TEXT] [--attribution LINE] [--business ID]
# [--project ID]
#
# Checks, in order; each refusal names its reason on stderr and exits 2:
# bus-schema the bus database's schema isn't the Store's
# decision-not-found no decision with that id and one raise in the business
# not-a-merge the decision's action isn't git.merge.protected
# not-resolved the decision is open, withdrawn or expired
# not-human not resolved by the business's human through the CLI
# not-merge-option the choice isn't the raise's approval option
# work-ref the question doesn't name exactly one
# work/vikunja-<p>-<t>, or a task_ref disagrees with it
# target-branch the raise's target or --base isn't refactor (next and
# main always refuse)
# superseded a later merge decision on the same work ref or task,
# or one that supersedes this one
# no-verdict no review.verdict for the task
# not-approved the latest verdict isn't approve on a full commit id
# ref-moved the work ref in the working repository isn't at C
# root-file the reviewed diff adds a file at the repository root
# checkout-dirty the base is checked out with changes on a reviewed path
# base-conflict the reviewed diff doesn't apply cleanly to the base
# patch-drift the applied diff's patch-id isn't the reviewed one
# gate-failed a gate step failed; the log is named
# base-moved the base branch moved while the gate ran
#
# Reads: <dataRoot>/bus/bus.sqlite, opened readOnly by
# scripts/land-work/read.mjs, which holds every query and runs SELECTs only.
# The live bus can stay up: it runs in WAL mode and a reader doesn't block it.
#
# Writes, on a pass and without --dry-run: one commit on refs/heads/<base>,
# made by `git commit` in a detached worktree, then moved onto the base with
# a compare-and-swap update-ref. If the base is checked out, the reviewed
# paths there are brought up to the new commit. Nothing is pushed.
#
# The worktree goes under $TMPDIR and is removed on every exit. The gate logs
# stay in $TMPDIR/land-work-logs.* and the tool names the directory.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO="$(cd "$SCRIPT_DIR/.." && pwd)"
usage() { echo "usage: land-work.sh <decision-id> [--base refactor] [--dry-run] [--subject TEXT] [--attribution LINE] [--business ID] [--project ID]" >&2; exit 4; }
fail() { echo "land-work: $1" >&2; exit "${2:-1}"; }
refuse() { echo "land-work: refused: $1${2:+: $2}" >&2; exit 2; }
DECISION=""; BASE="refactor"; DRY_RUN="no"; SUBJECT=""; ATTRIBUTION=""
BUSINESS=""; PROJECT=""
while [ $# -gt 0 ]; do
case "$1" in
--base) [ $# -ge 2 ] || usage; BASE="$2"; shift 2 ;;
--dry-run) DRY_RUN="yes"; shift ;;
--subject) [ $# -ge 2 ] || usage; SUBJECT="$2"; shift 2 ;;
--attribution) [ $# -ge 2 ] || usage; ATTRIBUTION="$2"; shift 2 ;;
--business) [ $# -ge 2 ] || usage; BUSINESS="$2"; shift 2 ;;
--project) [ $# -ge 2 ] || usage; PROJECT="$2"; shift 2 ;;
-*) usage ;;
*) [ -z "$DECISION" ] || usage; DECISION="$1"; shift ;;
esac
done
[ -n "$DECISION" ] || usage
[[ "$DECISION" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$ ]] || fail "not a decision id: $DECISION" 4
case "$SUBJECT$ATTRIBUTION" in *$'\n'*) fail "--subject and --attribution take one line" 4 ;; esac
# Exactly refactor (#1610): next and main always refuse, as does anything else.
[ "$BASE" = "refactor" ] || refuse target-branch "--base must be refactor, not $BASE"
# ---- temporary space: the worktree goes, the gate logs stay ----
TMP_ROOT="${TMPDIR:-/tmp}"
LOGS="$(mktemp -d "$TMP_ROOT/land-work-logs.XXXXXX")"
SCRATCH="$(mktemp -d "$TMP_ROOT/land-work.XXXXXX")"
# Lowercase digits and a dash: Compose names a network <dir>_default as is.
WT_NAME="lw-$$-$(date -u +%s)"
WT=""
TARGET=""
cleanup() {
local rc=$?
if [ -n "$WT" ]; then
[ -L "$WT/node_modules" ] && rm -f "$WT/node_modules"
git -C "$TARGET" worktree remove --force "$WT" >/dev/null 2>&1 || rm -rf "$WT"
git -C "$TARGET" worktree prune >/dev/null 2>&1 || true
fi
if command -v docker >/dev/null 2>&1 && docker network inspect "${WT_NAME}_default" >/dev/null 2>&1; then
docker network rm "${WT_NAME}_default" >/dev/null 2>&1 || echo "land-work: could not remove Docker network ${WT_NAME}_default" >&2
fi
rm -rf "$SCRATCH"
rmdir "$LOGS" 2>/dev/null || true
exit "$rc"
}
trap cleanup EXIT
# ---- configuration: data root, business, project ----
DATA_ROOT="$(node "$REPO/scripts/mosaic-config.mjs" validate | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>console.log(JSON.parse(d).dataRoot))')" \
|| fail "system config is missing or invalid" 3
CONFIG_DIR="$(dirname "${MOSAIC_CONFIG:-$HOME/.config/mosaic-dev/config.json}")"
if [ -z "$BUSINESS" ]; then
shopt -s nullglob
files=("$CONFIG_DIR"/businesses/*.json)
shopt -u nullglob
[ "${#files[@]}" -eq 1 ] || fail "name the business with --business (${#files[@]} business files)" 4
BUSINESS="$(basename "${files[0]}" .json)"
fi
BUSINESS_FILE="$CONFIG_DIR/businesses/$BUSINESS.json"
[ -f "$BUSINESS_FILE" ] || fail "no business file: $BUSINESS_FILE" 3
# ---- the decision and the verdict, read-only from the bus ----
READ_RC=0
node "$SCRIPT_DIR/land-work/read.mjs" "$DATA_ROOT" "$BUSINESS_FILE" "$DECISION" "$BASE" > "$SCRATCH/read.txt" || READ_RC=$?
value() { awk -F'\t' -v k="$1" '$1 == k { sub(/^[^\t]*\t/, ""); print; exit }' "$SCRATCH/read.txt"; }
if [ "$READ_RC" -eq 2 ] && grep -q '^refuse' "$SCRATCH/read.txt"; then
line="$(value refuse)"
refuse "${line%% *}" "${line#* }"
fi
[ "$READ_RC" -eq 0 ] || fail "could not read the decision from the bus (exit $READ_RC)" "$READ_RC"
TASK_REF="$(value task_ref)"
WORK_REF="$(value work_ref)"
C="$(value commit)"
VERDICT="$(value verdict)"
CHOICE="$(value choice)"
RESOLVED_BY="$(value by)"
RESOLVED_AT="$(value resolved_at)"
[ -n "$SUBJECT" ] || SUBJECT="$(value subject)"
[ -n "$SUBJECT" ] || fail "the decision gives no subject; pass --subject" 4
[ -n "$PROJECT" ] || PROJECT="$(value project)"
# ---- the project's repository and the working repository ----
PROJECT_ROOT="$(node -e '
const b = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"));
const ids = Object.keys(b.projects ?? {});
const p = process.argv[2] || (ids.length === 1 ? ids[0] : "");
if (!p || !b.projects?.[p]?.root) process.exit(3);
console.log(`${p}\t${b.projects[p].root}`);
' "$BUSINESS_FILE" "$PROJECT")" || fail "name the project with --project; the decision names none that $BUSINESS has" 4
PROJECT="${PROJECT_ROOT%% *}"
TARGET="${PROJECT_ROOT#* }"
[ -d "$TARGET/.git" ] || [ -f "$TARGET/.git" ] || fail "project root is not a git checkout: $TARGET" 4
WORKING="$DATA_ROOT/working/$BUSINESS/$PROJECT.git"
[ -d "$WORKING" ] || fail "no working repository: $WORKING" 4
# The working repository holds what sessions sent: no hooks, no fsmonitor,
# file transport only, as packages/seat/src/working.mjs runs it.
wgit() { git -c core.hooksPath=/dev/null -c core.fsmonitor=false -c protocol.allow=never -c protocol.file.allow=always -C "$WORKING" "$@"; }
# A diff in one fixed format, whatever the user's diff config says.
pdiff() { git -c diff.noprefix=false -c diff.mnemonicPrefix=false -C "$1" diff --no-ext-diff --no-textconv --no-color --binary --no-renames "${@:2}"; }
AT="$(wgit rev-parse --verify -q "refs/heads/$WORK_REF^{commit}" || true)"
[ "$AT" = "$C" ] || refuse ref-moved "$WORK_REF is at ${AT:-nothing}, the verdict approved $C"
OLD="$(git -C "$TARGET" rev-parse --verify -q "refs/heads/$BASE^{commit}")" || fail "no branch $BASE in $TARGET" 4
# Objects only: no refspec destination and no FETCH_HEAD, so no ref moves.
git -c protocol.allow=never -c protocol.file.allow=always -c transfer.fsckObjects=true \
-c maintenance.auto=false -c gc.auto=0 -C "$TARGET" \
fetch -q --no-tags --no-write-fetch-head "$WORKING" "refs/heads/$WORK_REF" \
|| fail "could not fetch $WORK_REF from $WORKING"
git -C "$TARGET" cat-file -e "$C^{commit}" 2>/dev/null || fail "the fetch did not bring $C"
# ---- the reviewed diff ----
MB="$(git -C "$TARGET" merge-base "$OLD" "$C")" || refuse base-conflict "$C shares no history with $BASE"
pdiff "$TARGET" "$MB" "$C" > "$SCRATCH/reviewed.patch"
[ -s "$SCRATCH/reviewed.patch" ] || refuse base-conflict "the reviewed diff $MB..$C is empty"
REVIEWED_ID="$(git -C "$TARGET" patch-id --stable < "$SCRATCH/reviewed.patch" | cut -d' ' -f1)"
# Invariant 1: nothing new at the root. A top-level file, a top-level
# directory the base doesn't have, and the legacy src/ and archived v1/
# trees all count as the root.
mapfile -d '' -t ADDED < <(git -C "$TARGET" diff -z --no-renames --name-only --diff-filter=A "$MB" "$C")
mapfile -d '' -t TOP < <(git -C "$TARGET" ls-tree -z --name-only "$OLD")
ROOT_HITS=""
for f in "${ADDED[@]}"; do
case "$f" in
*/*) top="${f%%/*}" ;;
*) ROOT_HITS="$ROOT_HITS $f"; continue ;;
esac
known="no"
for t in "${TOP[@]}"; do [ "$t" = "$top" ] && known="yes" && break; done
if [ "$known" = "no" ] || [ "$top" = "src" ] || [ "$top" = "v1" ]; then ROOT_HITS="$ROOT_HITS $f"; fi
done
[ -z "$ROOT_HITS" ] || refuse root-file "the reviewed diff adds$ROOT_HITS"
mapfile -d '' -t PATHS < <(git -C "$TARGET" diff -z --no-renames --name-only "$MB" "$C")
# Paths go to git as they are, never as globs or pathspec magic.
lgit() { git --literal-pathspecs -C "$@"; }
# Where the base is checked out, the reviewed paths there must be clean: the
# landing rewrites them to the new commit.
CHECKOUT="$(git -C "$TARGET" worktree list --porcelain | awk -v b="branch refs/heads/$BASE" '/^worktree /{w=substr($0,10)} $0==b{print w; exit}')"
checkout_clean() {
[ -n "$CHECKOUT" ] || return 0
local dirty=""
lgit "$CHECKOUT" diff --quiet HEAD -- "${PATHS[@]}" || dirty="changed"
lgit "$CHECKOUT" diff --cached --quiet HEAD -- "${PATHS[@]}" || dirty="staged"
for f in "${PATHS[@]}"; do
if ! git -C "$CHECKOUT" cat-file -e "HEAD:$f" 2>/dev/null && [ -e "$CHECKOUT/$f" ]; then dirty="untracked $f"; fi
done
[ -z "$dirty" ] || refuse checkout-dirty "$BASE is checked out at $CHECKOUT with a $dirty reviewed path"
}
checkout_clean
# ---- the squash, in a detached worktree on the base ----
WT="$SCRATCH/$WT_NAME"
git -C "$TARGET" worktree add -q --detach "$WT" "$OLD" || fail "could not add a worktree at $WT"
git -C "$WT" apply --index "$SCRATCH/reviewed.patch" 2> "$SCRATCH/apply.err" \
|| refuse base-conflict "$(tail -n1 "$SCRATCH/apply.err")"
APPLIED_ID="$(pdiff "$WT" --cached "$OLD" | git -C "$WT" patch-id --stable | cut -d' ' -f1)"
[ "$APPLIED_ID" = "$REVIEWED_ID" ] || refuse patch-drift "applied ${APPLIED_ID:-none}, reviewed $REVIEWED_ID"
# ---- the gate ----
echo "land-work: gate logs in $LOGS" >&2
# The full gate, as Sage's landing gate template runs it. There is no switch
# to shorten or skip it.
[ -d "$TARGET/node_modules" ] || fail "no node_modules in $TARGET for the gate" 4
ln -s "$TARGET/node_modules" "$WT/node_modules"
gate_step() { # name dir command
local rc=0
(cd "$WT/$2" && bash -c "set -o pipefail; $3") > "$LOGS/$1.log" 2>&1 || rc=$?
echo "$1 rc=$rc $(grep -E '^ℹ (pass|fail) [0-9]+' "$LOGS/$1.log" | tr '\n' ' ')" | tee -a "$LOGS/gate.log" >&2
[ "$rc" -eq 0 ] || refuse gate-failed "$1, see $LOGS/$1.log"
}
# No pkg-queue step: scripts/test-queue.sh runs packages/queue/tests/ itself.
for d in "$WT"/packages/*/; do
p="$(basename "$d")"
[ -d "$d/tests" ] || continue
[ "$p" = queue ] && continue
gate_step "pkg-$p" "packages/$p" 'node --test tests/*.test.mjs'
done
for s in "$WT"/scripts/test-*.sh; do
[ -f "$s" ] || continue
n="$(basename "$s" .sh)"
gate_step "script-${n#test-}" . "bash scripts/$n.sh"
done
echo "GATE_PASSED" | tee -a "$LOGS/gate.log" >&2
# ---- the commit ----
SHORT_ID="${REVIEWED_ID:0:12}"
cat > "$SCRATCH/message" <<EOF
$SUBJECT
Squash of $WORK_REF ($TASK_REF) at $C onto $BASE $OLD,
the reviewed diff $MB..$C. Resolved $CHOICE by $RESOLVED_BY via the CLI at $RESOLVED_AT.
Decision: $DECISION
Verdict: $VERDICT
Work-ref: $WORK_REF
Reviewed-commit: $C
Base: $BASE $OLD
Patch-id: $REVIEWED_ID
Gate: full, every package suite and every scripts/test-*.sh
${ATTRIBUTION:-Applied-under: scripts/land-work.sh (conductor)}
EOF
if [ "$DRY_RUN" = "yes" ]; then
TREE="$(git -C "$WT" write-tree)"
echo "land-work (dry-run): would commit tree $TREE on $BASE at $OLD, patch-id $SHORT_ID, with this message:"
sed 's/^/ /' "$SCRATCH/message"
echo "land-work (dry-run): no ref was written."
exit 0
fi
checkout_clean
git -C "$WT" commit -q -F "$SCRATCH/message" || fail "git commit failed in the worktree"
NEW="$(git -C "$WT" rev-parse HEAD)"
git -C "$TARGET" update-ref -m "land-work: decision $DECISION" "refs/heads/$BASE" "$NEW" "$OLD" 2>/dev/null \
|| refuse base-moved "$BASE is no longer at $OLD; nothing was written"
if [ -n "$CHECKOUT" ]; then
# HEAD is now NEW there: take the reviewed paths' index entries and files from it.
lgit "$CHECKOUT" reset -q -- "${PATHS[@]}"
for f in "${PATHS[@]}"; do
if git -C "$CHECKOUT" cat-file -e "HEAD:$f" 2>/dev/null; then
lgit "$CHECKOUT" checkout -q -- "$f"
else
rm -f "$CHECKOUT/$f"
fi
done
fi
echo "land-work: committed $NEW on $BASE (parent $OLD), patch-id $SHORT_ID. NOT pushed - push remains an explicit act."
echo "$WORK_REF ($TASK_REF) is merged to $BASE as ${NEW:0:9}; push pending."