Candidate manifest 8b4ab31014a124ec0e3abba44238976d6f4c9af48cbdb36c2c77d18f0137f888 from Darkwing's round 2 packet (0a4076e8e), on BASE1223a0f37. 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]>
281 lines
14 KiB
Bash
281 lines
14 KiB
Bash
#!/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."
|