Files
stack/scripts/test-land-work.sh
T
jason.woltje 4fa6995c13 selector: --org and --business in land-work
land-work takes --org as --business in one scope. A repeated, conflicting,
missing, empty or flag-shaped value exits 4 before the config is read. The
id keeps its old grammar (none): any other text names businesses/<ID>.json
and a missing file exits 3. A value of --subject or --attribution is never
read as a selector.
2026-11-08 22:35:24 -06:00

450 lines
24 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
# Sandboxed selftests for scripts/land-work.sh (row 116, #1610).
#
# Builds a fixture project repository, a working repository holding the
# coder's work refs, and per case a bus database written through the real
# Store (scripts/land-work/fixture.mjs). Every refusal is exercised, then a
# landing, a dry run, a gate failure and a base that moves during the gate.
# Docker is a fake on PATH that logs its calls. Nothing here opens the real
# config, data root or bus: MOSAIC_CONFIG always names a sandbox file.
set -uo pipefail
cd "$(dirname "$0")/.."
REPO="$(pwd)"
SANDBOX="$(cd "$(mktemp -d)" && pwd -P)"
trap 'rm -rf "$SANDBOX"' EXIT
PASS=0
FAIL=0
# Status colors: terminal-only, NO_COLOR-respecting; plain when piped.
if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then
C_OK=$'\033[0;32m'; C_FAIL=$'\033[0;31m'; C_RESET=$'\033[0m'
else
C_OK=""; C_FAIL=""; C_RESET=""
fi
check() {
if [ "$2" = "0" ]; then PASS=$((PASS+1)); echo "${C_OK}OK${C_RESET} $1"; else FAIL=$((FAIL+1)); echo "${C_FAIL}FAIL${C_RESET} $1"; fi
}
# Git sees no user or system config: no hooks path, no signing, a fixed identity.
: > "$SANDBOX/gitconfig"
export GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL="$SANDBOX/gitconfig"
export GIT_AUTHOR_NAME=suite GIT_AUTHOR_EMAIL=suite@local GIT_COMMITTER_NAME=suite GIT_COMMITTER_EMAIL=suite@local
REAL_GIT="$(command -v git)"
# ---- fakes: docker logs its calls; a git that tampers with the applied diff ----
mkdir -p "$SANDBOX/bin" "$SANDBOX/tamper"
cat > "$SANDBOX/bin/docker" <<EOF
#!/usr/bin/env bash
echo "docker \$*" >> "$SANDBOX/docker.log"
[ "\$1 \$2" != "network inspect" ] || [ "\${FAKE_NET_EXISTS:-}" = 1 ]
EOF
cat > "$SANDBOX/tamper/git" <<EOF
#!/usr/bin/env bash
"$REAL_GIT" "\$@"; rc=\$?
if [ "\${1:-}" = -C ] && [ "\${3:-}" = apply ] && [ "\$rc" -eq 0 ]; then
printf 'tampered\n' >> "\$2/README.md" && "$REAL_GIT" -C "\$2" add README.md
fi
exit "\$rc"
EOF
chmod +x "$SANDBOX/bin/docker" "$SANDBOX/tamper/git"
# ---- the project repository at the review's merge base ----
P="$SANDBOX/proto"
T="$P/target"
git init -q -b refactor "$T"
printf 'line1\nline2\nline3\n' > "$T/README.md"
mkdir -p "$T/packages/demo/tests" "$T/packages/queue/tests" "$T/scripts" "$T/src"
cat > "$T/packages/demo/tests/demo.test.mjs" <<'EOF'
import test from 'node:test';
import assert from 'node:assert/strict';
test('demo', () => assert.notEqual(process.env.LW_FAIL, 'pkg'));
EOF
cat > "$T/packages/queue/tests/never.test.mjs" <<'EOF'
import test from 'node:test';
test('the gate runs no pkg-queue step', () => { throw new Error('pkg-queue ran'); });
EOF
cat > "$T/scripts/test-demo.sh" <<'EOF'
#!/usr/bin/env bash
[ "${LW_FAIL:-}" != script ] || exit 1
[ -z "${LW_DIRTY:-}" ] || echo "during the gate" >> "$LW_DIRTY"
if [ -n "${LW_MOVE_BASE:-}" ]; then
moved="$(git -C "$LW_MOVE_BASE" commit-tree "refactor^{tree}" -p refactor -m moved)" \
&& git -C "$LW_MOVE_BASE" update-ref refs/heads/refactor "$moved"
fi
echo "ℹ pass 1"
EOF
echo 'echo legacy' > "$T/src/legacy.sh"
echo node_modules > "$T/.gitignore"
git -C "$T" add -A && git -C "$T" commit -q -m base
MB="$(git -C "$T" rev-parse HEAD)"
# ---- the coder's commits, pushed to the working repository ----
git clone -q "$T" "$P/coder"
CO="$P/coder"
commit_on() { # message: commit what's changed in the coder clone, print the id
git -C "$CO" add -A && git -C "$CO" commit -q -m "$1" && git -C "$CO" rev-parse HEAD
}
git -C "$CO" checkout -q -b w42 "$MB"
printf 'line1\nline2 changed\nline3\n' > "$CO/README.md"
echo feature > "$CO/packages/demo/feature.txt"
W_OK="$(commit_on w42)"
git -C "$CO" checkout -q -b w43 "$MB"; echo notes > "$CO/notes.txt"
W_ROOT="$(commit_on w43)"
git -C "$CO" checkout -q -b w44 "$MB"; mkdir "$CO/tools2"; echo x > "$CO/tools2/x.txt"
W_NEWDIR="$(commit_on w44)"
git -C "$CO" checkout -q -b w45 "$MB"; echo 'export {};' > "$CO/src/parse-requirement-id.mjs"
W_SRC="$(commit_on w45)"
git -C "$CO" checkout -q --orphan w46; git -C "$CO" rm -rfq .; echo o > "$CO/o.txt"
W_ORPHAN="$(commit_on w46)"
git init -q --bare "$P/working"
for pair in "w42:42" "w43:43" "w44:44" "w45:45" "w46:46"; do
git -C "$CO" push -q "$P/working" "${pair%%:*}:refs/heads/work/vikunja-7-${pair#*:}"
done
git -C "$CO" push -q "$P/working" "$MB:refs/heads/work/vikunja-7-47"
# The base moves on after the review, on a path the work doesn't touch.
echo docs > "$T/docs.txt"
git -C "$T" add docs.txt && git -C "$T" commit -q -m "base moves"
OLD="$(git -C "$T" rev-parse HEAD)"
mkdir "$T/node_modules"
echo canary > "$T/node_modules/canary" # cleanup must not empty the linked directory
# ---- bus rows: one decision and its verdicts, in the live rows' shape ----
D="d0000000-0000-4000-8000-000000000001"
V="e0000000-0000-4000-8000-000000000002"
cat > "$SANDBOX/gen.mjs" <<'EOF'
const [variant, p, t, commit] = process.argv.slice(2);
const D = 'd0000000-0000-4000-8000-000000000001';
const L = 'd0000000-0000-4000-8000-000000000009';
const MERGE = 'git.merge.protected';
const biz = variant === 'other-business' ? 'other' : 'fx';
const ref = `work/vikunja-${p}-${t}`;
const task = `vikunja:${p}/${t}`;
const action = variant === 'not-a-merge' ? 'git.push.protected' : MERGE;
// Row 139 (#1633, PRD 1.5 REQ-DEC-2): a merge decision the arbiter resolved, raised cross-role by
// merge-gate or gated with a spec citation by a coder. land-work still lands only a human's.
const arbiter = { 'arbiter-cross': ['merge-gate', 'cross-role'], 'arbiter-cited': ['coder', 'gated'] }[variant];
const raiser = arbiter?.[0] ?? 'pm';
const question =
{
'two-refs': `Squash-merge ${ref} and work/vikunja-7-99 into refactor?`,
'bad-ref': 'Squash-merge work/feature-x into refactor?',
}[variant] ?? `Squash-merge ${ref} into refactor? It carries task ${p}/${t}, approved by the reviewer.`;
const options = [
{ key: 'yes', text: `Squash-merge ${ref} into refactor` },
{ key: 'no', text: "Don't merge yet" },
];
const rows = [
{
table: 'decisions', id: D, business: biz, project: null, action, question, options,
...(variant === 'task-ref-disagrees' ? { task_ref: 'vikunja:7/99' } : {}),
...(arbiter ? { raised_by_role: raiser, class: arbiter[1], route_to: 'cto' } : {}),
},
];
// An earlier merge decision on the same ref, withdrawn before D was raised:
// only a later one supersedes D.
const E = 'd0000000-0000-4000-8000-000000000008';
if (variant === 'earlier-merge') {
rows.unshift({ table: 'decisions', id: E, business: biz, project: null, action: MERGE, task_ref: task, question, options });
rows.push({ table: 'decision_events', decision: E, op: 'withdrawn', by: 'pm', choice: null, via: null });
}
if (variant !== 'no-raise')
rows.push({
table: 'events', business: biz, kind: 'action.allowed', actor_role: raiser, actor_run: 'rfixture', subject: D,
body: { decision: D, action, operation: 'decision.raise',
task_ref: variant === 'raise-task-disagrees' ? 'vikunja:7/99' : null,
target: variant === 'target-main' ? 'main' : 'refactor', approvalChoice: 'yes', holderRun: null },
});
const choice = variant === 'chose-no' ? 'no' : 'yes';
if (variant === 'withdrawn') rows.push({ table: 'decision_events', decision: D, op: 'withdrawn', by: 'pm', choice: null, via: null });
else if (variant !== 'open') {
rows.push({ table: 'decision_events', decision: D, op: 'resolved', by: variant === 'by-agent' ? 'pm' : arbiter ? 'cto' : 'jason', choice,
via: variant === 'via-webui' ? 'webui' : arbiter ? 'broker' : 'cli' });
const answer = {
table: 'events', business: biz, kind: 'human.input', subject: D,
actor_role: variant === 'answer-role' ? 'pm' : null, actor_run: variant === 'answer-run' ? 'rfixture' : null,
body: { kind: 'answer', decision: D, op: 'resolved', choice: variant === 'answer-choice' ? 'no' : choice },
};
if (variant !== 'no-answer' && !arbiter) rows.push(answer);
if (variant === 'two-answers') rows.push({ ...answer });
}
const later = {
'superseded-question': { action: MERGE, question: `Hold ${ref} back after all?` },
'superseded-task': { action: MERGE, task_ref: task, question: `Hold task ${p}/${t} back after all?` },
'superseded-by': { action: 'scope.change', supersedes: D, question: 'Replace the earlier merge decision' },
}[variant];
if (later) rows.push({ table: 'decisions', id: L, business: biz, project: null, options, ...later });
const verdict = (id, v, c, body = {}) => ({
table: 'events', id, business: biz, kind: 'review.verdict', actor_role: 'reviewer', actor_run: 'rrev', subject: task,
body: { task_ref: task, request: 'f0000000-0000-4000-8000-000000000003', commit: c, verdict: v, message: 'fixture', ...body },
});
// An earlier changes verdict, as 19/265 had, so "latest" is what's read.
if (variant !== 'no-verdict') {
rows.push(verdict('e0000000-0000-4000-8000-000000000001', 'changes', '1'.repeat(40)));
rows.push(verdict('e0000000-0000-4000-8000-000000000002', 'approve', variant === 'short-commit' ? commit.slice(0, 12) : commit,
variant === 'wrong-task-body' ? { task_ref: 'vikunja:7/99' } : {}));
}
if (variant === 'changes-later') rows.push(verdict('e0000000-0000-4000-8000-000000000004', 'changes', commit));
process.stdout.write(JSON.stringify(rows));
EOF
# ---- per case: a copy of the target and the working repository, and a bus ----
setup() { # name variant task commit
local c="$SANDBOX/c/$1"
mkdir -p "$c/businesses" "$c/tmp" "$c/data/working/fx"
chmod 700 "$c/data"
cp -a "$T" "$c/target"
cp -a "$P/working" "$c/data/working/fx/stack.git"
printf '{"configVersion":1,"environment":"development","dataRoot":"%s","execution":{"backend":"docker","provider":"zai","model":"m","adapter":"mock"}}\n' \
"$c/data" > "$c/config.json"
printf '{"id":"fx","human":"jason","projects":{"stack":{"root":"%s"}}}\n' "$c/target" > "$c/businesses/fx.json"
node "$SANDBOX/gen.mjs" "$2" 7 "$3" "$4" > "$c/rows.json" \
&& node scripts/land-work/fixture.mjs "$c/data" "$c/rows.json" \
|| echo "setup failed for $1" >&2
}
lw() { # name args...
local c="$SANDBOX/c/$1"
shift
MOSAIC_CONFIG="$c/config.json" TMPDIR="$c/tmp" PATH="${LW_PATH:+$LW_PATH:}$SANDBOX/bin:$PATH" \
bash scripts/land-work.sh "$@" > "$c/out" 2> "$c/err"
}
expect() { # name rc reason args...: run land-work, check the exit and the refusal
local name="$1" want="$2" reason="$3" rc=0
shift 3
lw "$name" "$@" || rc=$?
if [ "$rc" -eq "$want" ] && { [ -z "$reason" ] || grep -q "refused: $reason\b" "$SANDBOX/c/$name/err"; }; then
PASS=$((PASS+1)); echo "${C_OK}OK${C_RESET} $name: exit $rc${reason:+, $reason}"
else
FAIL=$((FAIL+1)); echo "${C_FAIL}FAIL${C_RESET} $name: exit $rc, wanted $want${reason:+ $reason}: $(tail -n2 "$SANDBOX/c/$name/err" | tr '\n' ' ')"
fi
}
cleaned() { # name: one worktree left in the target, no scratch left in TMPDIR
local c="$SANDBOX/c/$1"
[ "$(git -C "$c/target" worktree list --porcelain | grep -c '^worktree ')" -eq 1 ] \
&& [ -z "$(find "$c/tmp" -maxdepth 1 -name 'land-work.*')" ]
}
# ---- arguments and configuration ----
setup args ok 42 "$W_OK"
expect args 2 target-branch "$D" --base next
expect args 2 target-branch "$D" --base main
expect args 4 "" "bad/../id"
expect args 4 "" "$D" --unknown
expect args 4 "" "$D" --subject $'two\nlines'
rc=0; MOSAIC_CONFIG="$SANDBOX/missing.json" TMPDIR="$SANDBOX/c/args/tmp" bash scripts/land-work.sh "$D" --base next >/dev/null 2>&1 || rc=$?
check "--base next refuses before the config is read (got $rc)" "$([ "$rc" -eq 2 ]; echo $?)"
rc=0; MOSAIC_CONFIG="$SANDBOX/missing.json" TMPDIR="$SANDBOX/c/args/tmp" bash scripts/land-work.sh "$D" >/dev/null 2>&1 || rc=$?
check "a missing system config exits 3 (got $rc)" "$([ "$rc" -eq 3 ]; echo $?)"
mkdir -p "$SANDBOX/c/nobus/data/working/fx" "$SANDBOX/c/nobus/businesses" "$SANDBOX/c/nobus/tmp"
chmod 700 "$SANDBOX/c/nobus/data"
cp "$SANDBOX/c/args/businesses/fx.json" "$SANDBOX/c/nobus/businesses/"
sed "s#$SANDBOX/c/args/data#$SANDBOX/c/nobus/data#" "$SANDBOX/c/args/config.json" > "$SANDBOX/c/nobus/config.json"
expect nobus 3 "" "$D"
# ---- rename r2: --org is --business, each spelling once ----
# Against a missing config: exit 4 and the message mean the parse refused before any read (3 otherwise).
sel() { # want-rc want-stderr args...
local want="$1" text="$2" rc=0 err
shift 2
err="$(MOSAIC_CONFIG="$SANDBOX/missing.json" TMPDIR="$SANDBOX/c/args/tmp" bash scripts/land-work.sh "$@" 2>&1 >/dev/null)" || rc=$?
check "selector: $* exits $want: $text (got $rc: $err)" "$([ "$rc" -eq "$want" ] && [ "$err" = "$text" ]; echo $?)"
}
sel 4 "land-work: --business fx and --org other conflict" "$D" --business fx --org other
sel 4 "land-work: --org other and --business fx conflict" "$D" --org other --business fx
sel 4 "land-work: --business given twice" "$D" --business fx --business fx
sel 4 "land-work: --org given twice" "$D" --org fx --org fx
sel 4 "land-work: --org given twice" "$D" --org fx --business fx --org fx
sel 4 "land-work: --org needs one value" "$D" --org
sel 4 "land-work: --business needs one value" "$D" --business ""
sel 4 "land-work: --org needs one value, not --dry-run" "$D" --org --dry-run
sel 4 "land-work: --business needs one value, not -fx" "$D" --business -fx
USAGE="$(bash scripts/land-work.sh 2>&1)"
sel 4 "$USAGE" --org fx
sel 4 "$USAGE" "$D" --org fx extra
# A value another option takes is text, not a selector: the config read is what refuses.
rc=0; err="$(MOSAIC_CONFIG="$SANDBOX/missing.json" TMPDIR="$SANDBOX/c/args/tmp" bash scripts/land-work.sh "$D" --subject --org 2>&1 >/dev/null)" || rc=$?
check "selector: --subject --org is a subject, and the config read refuses (got $rc)" \
"$([ "$rc" -eq 3 ] && [ "${err##*$'\n'}" = "land-work: system config is missing or invalid" ]; echo $?)"
# Each spelling alone and both together name the business file read.
# land-work never had an id grammar and keeps none, so text that is not an id is a missing file too.
for args in "--business other" "--org other" "--org other --business other" "--org Other" "--business ../other"; do
# shellcheck disable=SC2086
expect args 3 "" "$D" $args
check "selector: $args names businesses/${args##* }.json" \
"$([ "$(tail -n1 "$SANDBOX/c/args/err")" = "land-work: no business file: $SANDBOX/c/args/businesses/${args##* }.json" ]; echo $?)"
done
expect args 2 decision-not-found "d0000000-0000-4000-8000-00000000000f" --org fx
expect args 2 decision-not-found "d0000000-0000-4000-8000-00000000000f" --business fx --org fx
expect args 2 decision-not-found "d0000000-0000-4000-8000-00000000000f" --org fx --business fx
setup dry-org ok 42 "$W_OK"
expect dry-org 0 "" "$D" --dry-run --org fx
grep -q "no ref was written" "$SANDBOX/c/dry-org/out"
check "selector: --org fx runs the dry run" "$?"
# ---- the bus database ----
setup schema-table ok 42 "$W_OK"
node -e 'const {DatabaseSync}=require("node:sqlite"); const db=new DatabaseSync(process.argv[1]); db.exec("CREATE TABLE extra(x)"); db.close()' \
"$SANDBOX/c/schema-table/data/bus/bus.sqlite"
expect schema-table 2 bus-schema "$D"
setup schema-meta ok 42 "$W_OK"
# The same schema, so the digest matches, with meta saying another version.
node -e '
const {DatabaseSync}=require("node:sqlite"); const db=new DatabaseSync(process.argv[1]);
const sql=db.prepare("SELECT sql FROM sqlite_master WHERE name=?").get("meta_no_update").sql;
db.exec("DROP TRIGGER meta_no_update"); db.exec("UPDATE meta SET value=\x273a\x27 WHERE key=\x27schema_version\x27"); db.exec(sql); db.close()' \
"$SANDBOX/c/schema-meta/data/bus/bus.sqlite"
expect schema-meta 2 bus-schema "$D"
rc=0
node --input-type=module -e '
import { openBus } from "./scripts/land-work/read.mjs";
const db = openBus(process.argv[1]);
try { db.exec("CREATE TABLE x(y)"); process.exit(1); } catch (e) { process.exit(/readonly/.test(e.message) ? 0 : 1); }' \
"$SANDBOX/c/args/data" || rc=$?
check "openBus opens the database read-only" "$rc"
# ---- the decision ----
for v in "decision-not-found:other-business" "decision-not-found:no-raise" "not-a-merge:not-a-merge" \
"not-resolved:open" "not-resolved:withdrawn" "not-human:by-agent" "not-human:via-webui" \
"not-human:no-answer" "not-human:answer-role" "not-human:answer-run" "not-human:answer-choice" \
"not-human:two-answers" "not-human:arbiter-cross" "not-human:arbiter-cited" "not-merge-option:chose-no" "work-ref:two-refs" "work-ref:bad-ref" \
"work-ref:task-ref-disagrees" "work-ref:raise-task-disagrees" "target-branch:target-main" "superseded:superseded-question" \
"superseded:superseded-task" "superseded:superseded-by" "no-verdict:no-verdict" \
"not-approved:changes-later" "not-approved:short-commit" "not-approved:wrong-task-body"; do
setup "${v#*:}" "${v#*:}" 42 "$W_OK"
expect "${v#*:}" 2 "${v%%:*}" "$D"
done
expect args 2 decision-not-found "d0000000-0000-4000-8000-00000000000f"
# A withdrawn earlier merge decision on the same ref doesn't supersede D.
setup earlier-merge earlier-merge 42 "$W_OK"
expect earlier-merge 0 "" "$D" --dry-run
# ---- the work ref and the reviewed diff ----
setup ref-moved ok 42 "$W_OK"
git -C "$SANDBOX/c/ref-moved/data/working/fx/stack.git" update-ref refs/heads/work/vikunja-7-42 "$W_ROOT"
expect ref-moved 2 ref-moved "$D"
setup root-top ok 43 "$W_ROOT"
expect root-top 2 root-file "$D"
setup root-newdir ok 44 "$W_NEWDIR"
expect root-newdir 2 root-file "$D"
setup root-src ok 45 "$W_SRC"
expect root-src 2 root-file "$D"
setup unrelated ok 46 "$W_ORPHAN"
expect unrelated 2 base-conflict "$D"
setup empty ok 47 "$MB"
expect empty 2 base-conflict "$D"
grep -q "is empty" "$SANDBOX/c/empty/err"
check "an empty reviewed diff says so" "$?"
# The base is checked out in the target: a reviewed path there must be clean.
setup dirty-changed ok 42 "$W_OK"
echo local >> "$SANDBOX/c/dirty-changed/target/README.md"
expect dirty-changed 2 checkout-dirty "$D"
setup dirty-staged ok 42 "$W_OK"
echo local >> "$SANDBOX/c/dirty-staged/target/README.md"
git -C "$SANDBOX/c/dirty-staged/target" add README.md
printf 'line1\nline2\nline3\n' > "$SANDBOX/c/dirty-staged/target/README.md"
expect dirty-staged 2 checkout-dirty "$D"
setup dirty-untracked ok 42 "$W_OK"
echo local > "$SANDBOX/c/dirty-untracked/target/packages/demo/feature.txt"
expect dirty-untracked 2 checkout-dirty "$D"
setup conflict ok 42 "$W_OK"
printf 'line1\nline2 on the base\nline3\n' > "$SANDBOX/c/conflict/target/README.md"
git -C "$SANDBOX/c/conflict/target" commit -q -am "base edits the reviewed line"
expect conflict 2 base-conflict "$D"
check "a refusal after the worktree is added leaves no worktree or scratch" "$(cleaned conflict; echo $?)"
grep -q "network inspect lw-" "$SANDBOX/docker.log" 2>/dev/null
check "cleanup looks for the worktree's Docker network" "$?"
setup dirty-during ok 42 "$W_OK"
LW_DIRTY="$SANDBOX/c/dirty-during/target/README.md" expect dirty-during 2 checkout-dirty "$D"
check "a reviewed path dirtied during the gate moves no ref" \
"$([ "$(git -C "$SANDBOX/c/dirty-during/target" rev-parse refactor)" = "$OLD" ]; echo $?)"
setup drift ok 42 "$W_OK"
LW_PATH="$SANDBOX/tamper" expect drift 2 patch-drift "$D"
check "patch-drift leaves no worktree or scratch" "$(cleaned drift; echo $?)"
# ---- the gate ----
setup gate-pkg ok 42 "$W_OK"
LW_FAIL=pkg expect gate-pkg 2 gate-failed "$D"
log="$(sed -n 's/.*gate-failed: pkg-demo, see \(.*\)$/\1/p' "$SANDBOX/c/gate-pkg/err")"
check "gate-failed names the failing step's log, and it exists" "$([ -n "$log" ] && [ -s "$log" ]; echo $?)"
check "gate-failed leaves no worktree or scratch" "$(cleaned gate-pkg; echo $?)"
setup gate-script ok 42 "$W_OK"
LW_FAIL=script expect gate-script 2 gate-failed "$D"
grep -q "gate-failed: script-demo" "$SANDBOX/c/gate-script/err"
check "a failing scripts/test-*.sh fails the gate" "$?"
check "a failed gate moves no ref" "$([ "$(git -C "$SANDBOX/c/gate-script/target" rev-parse refactor)" = "$OLD" ]; echo $?)"
setup nomods ok 42 "$W_OK"
rm -rf "$SANDBOX/c/nomods/target/node_modules"
expect nomods 4 "" "$D"
check "a target without node_modules leaves no worktree or scratch" "$(cleaned nomods; echo $?)"
setup moved ok 42 "$W_OK"
git -C "$SANDBOX/c/moved/target" checkout -q -b side
LW_MOVE_BASE="$SANDBOX/c/moved/target" expect moved 2 base-moved "$D"
check "base-moved keeps the base where it moved to" \
"$([ "$(git -C "$SANDBOX/c/moved/target" log -1 --format=%s refactor)" = moved ]; echo $?)"
# ---- a dry run ----
setup dry ok 42 "$W_OK"
before="$(git -C "$SANDBOX/c/dry/target" for-each-ref; git -C "$SANDBOX/c/dry/target" reflog refactor; git -C "$SANDBOX/c/dry/target" status --porcelain)"
expect dry 0 "" "$D" --dry-run --subject "Custom subject" --attribution "Applied-under: suite (conductor)"
after="$(git -C "$SANDBOX/c/dry/target" for-each-ref; git -C "$SANDBOX/c/dry/target" reflog refactor; git -C "$SANDBOX/c/dry/target" status --porcelain)"
check "--dry-run writes no ref" "$([ "$before" = "$after" ]; echo $?)"
grep -q "no ref was written" "$SANDBOX/c/dry/out" && grep -qx " Custom subject" "$SANDBOX/c/dry/out" \
&& grep -qx " Applied-under: suite (conductor)" "$SANDBOX/c/dry/out"
check "--dry-run prints the commit with --subject and --attribution" "$?"
grep -q "^GATE_PASSED" "$SANDBOX/c/dry/err"
check "--dry-run runs the gate" "$?"
check "--dry-run leaves no worktree or scratch" "$(cleaned dry; echo $?)"
DRY_TREE="$(sed -n 's/.*would commit tree \([0-9a-f]*\) .*/\1/p' "$SANDBOX/c/dry/out")"
# ---- a landing ----
setup land ok 42 "$W_OK"
L="$SANDBOX/c/land"
echo "local edit" >> "$L/target/docs.txt"
BUS_BEFORE="$(sha256sum "$L/data/bus/bus.sqlite")"
: > "$SANDBOX/docker.log"
FAKE_NET_EXISTS=1 expect land 0 "" "$D"
NEW="$(git -C "$L/target" rev-parse refactor)"
check "the landing is one commit on the old base" "$([ "$(git -C "$L/target" rev-parse "$NEW^")" = "$OLD" ]; echo $?)"
pid() { git -C "$L/target" diff "$1" "$2" | git patch-id --stable | cut -d' ' -f1; }
check "its diff is the reviewed diff by patch-id" "$([ "$(pid "$OLD" "$NEW")" = "$(pid "$MB" "$W_OK")" ]; echo $?)"
check "its tree is the dry run's" "$([ -n "$DRY_TREE" ] && [ "$(git -C "$L/target" rev-parse "$NEW^{tree}")" = "$DRY_TREE" ]; echo $?)"
msg="$(git -C "$L/target" log -1 --format=%B "$NEW")"
PATCH_ID="$(pid "$MB" "$W_OK")"
ok=0
[ "$(head -n1 <<<"$msg")" = "Squash-merge work/vikunja-7-42 into refactor" ] || ok=1
for line in "Decision: $D" "Verdict: $V" "Work-ref: work/vikunja-7-42" "Reviewed-commit: $W_OK" \
"Base: refactor $OLD" "Patch-id: $PATCH_ID"; do
grep -qxF "$line" <<<"$msg" || ok=1
done
[ "$(sed '/^$/d' <<<"$msg" | tail -n1)" = "Applied-under: scripts/land-work.sh (conductor)" ] || ok=1
check "the message has the option's subject, the citations and the attribution last" "$ok"
check "it prints the line for the PM" \
"$([ "$(tail -n1 "$L/out")" = "work/vikunja-7-42 (vikunja:7/42) is merged to refactor as ${NEW:0:9}; push pending." ]; echo $?)"
check "the checked-out base has the reviewed paths and keeps the unrelated edit" \
"$([ "$(git -C "$L/target" status --porcelain)" = " M docs.txt" ] && [ -f "$L/target/packages/demo/feature.txt" ]; echo $?)"
check "only refs/heads/refactor exists in the target" \
"$([ "$(git -C "$L/target" for-each-ref --format='%(refname)')" = refs/heads/refactor ]; echo $?)"
check "the work ref is unchanged" \
"$([ "$(git -C "$L/data/working/fx/stack.git" rev-parse refs/heads/work/vikunja-7-42)" = "$W_OK" ]; echo $?)"
check "the bus database is unchanged" "$([ "$(sha256sum "$L/data/bus/bus.sqlite")" = "$BUS_BEFORE" ]; echo $?)"
logs="$(sed -n 's/^land-work: gate logs in //p' "$L/err")"
grep -q "^pkg-demo rc=0" "$logs/gate.log" && grep -q "^script-demo rc=0" "$logs/gate.log" \
&& grep -q "^GATE_PASSED" "$logs/gate.log" && ! grep -q "pkg-queue" "$logs/gate.log"
check "the gate ran each package suite but queue, and each scripts/test-*.sh" "$?"
check "the landing leaves no worktree or scratch" "$(cleaned land; echo $?)"
check "the target's node_modules is still there, with its contents" \
"$([ -d "$L/target/node_modules" ] && [ ! -L "$L/target/node_modules" ] && [ -f "$L/target/node_modules/canary" ]; echo $?)"
grep -q "^docker network rm lw-[0-9]*-[0-9]*_default$" "$SANDBOX/docker.log"
check "the worktree's Docker network is removed" "$?"
echo "---"
echo "land-work selftests: $PASS passed, $FAIL failed"
[ "$FAIL" -eq 0 ]