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]>
This commit is contained in:
2026-10-14 14:24:57 -05:00
co-authored by Claude Opus 5.5
parent 922cb8170b
commit 1f2ac5b848
6 changed files with 1021 additions and 0 deletions
+29
View File
@@ -118,6 +118,34 @@ succeeded run → clean target tree → path allowlist → syntax gates →
apply → policy suites → attribution commit. Any failure reverts.
Push is never automatic.
## Conductor (landing approved work)
```bash
scripts/land-work.sh <decision-id> [--base refactor] [--dry-run] [--subject TEXT]
[--attribution LINE] [--business ID] [--project ID]
```
Turns a merge decision the human resolved into one gated local commit on
`refactor` (#1610). It reads `<dataRoot>/bus/bus.sqlite` read-only through
`scripts/land-work/read.mjs` and checks that the human picked the raise's
approval option, that the question names one `work/vikunja-<p>-<t>`, and
that the task's latest `review.verdict` approves a commit C that the work
ref in the working repository still points at. It then applies
`git diff $(merge-base refactor C) C` in a detached worktree, checks the
patch-id, runs every package suite except `queue` and every
`scripts/test-*.sh`, and commits. The subject is the approval option's
text unless `--subject` overrides it, and `--attribution` replaces the last
line, `Applied-under: scripts/land-work.sh (conductor)`. `--business` and
`--project` are needed only when the config has more than one business file
or the business more than one project. The tool prints the line for the PM
and doesn't send it. `--dry-run` runs every check and the gate and writes
no ref. A refusal names its reason and exits 2; the reasons are listed at
the top of the script. The worktree, its `node_modules` link and its Docker
network are removed on every exit. The gate log directory stays and the
tool names it. Keep `TMPDIR` short: the gate inherits it, the cli suite
puts unix sockets under it, and a 46-byte `TMPDIR` failed 8 of those tests.
Push is a separate act.
## Maintenance
| Command | Purpose | Notes |
@@ -128,6 +156,7 @@ Push is never automatic.
| `scripts/test-release.sh` | Release selftests | 14 cases |
| `scripts/test-conductor.sh` | Auto-apply selftests (sandboxed) | 17 cases |
| `scripts/test-auth.sh` | Auth checkpoint selftests (no Docker) | 13 cases |
| `scripts/test-land-work.sh` | Land-work selftests (sandboxed, fake Docker) | 84 cases |
| `scripts/gitea-api.sh <METHOD> <path> [body]` | Gitea API helper; reads the JSON credential file or a raw per-seat token file | Token never on argv/stdout |
## Seat launch with registration (`scripts/mosaic`)
+27
View File
@@ -43,6 +43,33 @@ tracked, versioned files whose changes arrive as reviewed commits.
- Verification is mechanical: suites + `node --check` / `bash -n` gates.
- Recursive decomposition = "fail → smaller task", never "hope."
## Landing approved work
A stack coder pushes `work/vikunja-<p>-<t>` to the working repository, the
reviewer approves a commit on it, and the PM raises a `git.merge.protected`
decision for the human. After the human resolves it with the merge option,
the conductor runs:
```bash
scripts/land-work.sh <decision-id> --dry-run
scripts/land-work.sh <decision-id>
```
The tool binds the commit to what was reviewed and decided. It reads the
decision, its raise and the latest verdict from the bus database
read-only, requires the work ref to be at the approved commit, squashes
the reviewed diff onto `refactor` in a detached worktree, checks the
patch-id, runs the full gate and commits once, locally. It refuses work
that adds a file at the repository root, in a new top-level directory, or
under `src/` or `v1/`. Each
refusal names its reason (`docs/TOOLS.md`). Don't work around one; fix the
cause or go back to the PM.
The conductor then reviews the commit, pushes it as a separate act and
sends the PM the line the tool printed. Only `refactor` lands this way.
Merges to `next` or `main`, re-pins and restarts keep their own authority,
and no stack role runs this tool.
## Worker task template
```json
+280
View File
@@ -0,0 +1,280 @@
#!/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."
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env node
// Test fixture for land-work (row 116, #1610): writes rows into a bus
// database through the real Store, so the schema and its digest are the
// Store's own and land-work's read check passes.
//
// node scripts/land-work/fixture.mjs <dataRoot> <rows.json>
//
// rows.json is an array of { "table": "decisions" | "decision_events" |
// "events", ...columns }. Rows go in in array order, in one transaction.
// Object values (body, options) are stored as JSON text. A missing `at` is
// now, a missing events or decisions `id` is a fresh UUID, and decisions
// default to the shape a PM's merge raise has. The data root is made if
// missing. Tests only: the Store's writer lock refuses a database that
// already has a writer.
import { readFileSync, mkdirSync } from 'node:fs';
import { randomUUID } from 'node:crypto';
import { pathToFileURL } from 'node:url';
import { Store } from '../../packages/bus/src/store.mjs';
const TABLES = {
decisions: ['seq', 'id', 'at', 'business', 'project', 'raised_by_role', 'raised_by_run', 'class', 'action',
'route_to', 'question', 'options', 'recommendation', 'task_ref', 'requirement_ref', 'blocking', 'supersedes'],
decision_events: ['seq', 'decision', 'at', 'op', 'by', 'choice', 'note', 'via'],
events: ['seq', 'id', 'at', 'business', 'kind', 'actor_role', 'actor_run', 'subject', 'corrects', 'body'],
};
const DEFAULTS = {
decisions: { raised_by_role: 'pm', raised_by_run: 'rfixture', class: 'gated', route_to: 'human',
recommendation: 'yes', blocking: 0 },
decision_events: {},
events: {},
};
export function writeRows(dataRoot, rows) {
mkdirSync(dataRoot, { recursive: true, mode: 0o700 });
const store = new Store(dataRoot);
try {
store.transaction(() => {
for (const row of rows) {
const columns = TABLES[row.table];
if (!columns) throw Error(`unknown table ${row.table}`);
const r = { ...DEFAULTS[row.table], ...row };
r.at ??= new Date().toISOString();
if (row.table !== 'decision_events') r.id ??= randomUUID();
const used = columns.filter((c) => r[c] !== undefined);
const values = used.map((c) => (r[c] !== null && typeof r[c] === 'object' ? JSON.stringify(r[c]) : r[c]));
store.run(`INSERT INTO ${row.table} (${used.join(',')}) VALUES (${used.map(() => '?').join(',')})`, ...values);
}
});
} finally {
store.close();
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const [dataRoot, file] = process.argv.slice(2);
writeRows(dataRoot, JSON.parse(readFileSync(file, 'utf8')));
}
+224
View File
@@ -0,0 +1,224 @@
#!/usr/bin/env node
// land-work's read of the bus (row 116, #1610). Every query land-work runs
// against bus.sqlite is in this file, and every one is a SELECT.
//
// node scripts/land-work/read.mjs <dataRoot> <business-file> <decision-id> <base>
//
// It opens <dataRoot>/bus/bus.sqlite with readOnly: true and checks the
// schema the way the Store does before reading anything. Output is one
// "key<TAB>value" line per fact land-work needs, or one
// "refuse<TAB>reason<TAB>detail" line and exit 2. A missing or unsafe
// database or business file exits 3.
//
// Kept in step with packages/bus/src/store.mjs, which this imports from:
// the schema_version literal '3b' below is a copy of the Store's. A schema
// bump changes it there, and until it changes here every read refuses
// bus-schema.
import { DatabaseSync } from 'node:sqlite';
import { readFileSync, lstatSync } from 'node:fs';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
import { createHash } from 'node:crypto';
import { schemaDigest, SCHEMA_SHA256 } from '../../packages/bus/src/store.mjs';
const SCHEMA_VERSION = '3b';
const MERGE = 'git.merge.protected';
const WORK_REF = /^work\/vikunja-([1-9][0-9]*)-([1-9][0-9]*)$/;
const ANY_WORK_REF = /\bwork\/[A-Za-z0-9._/-]*[A-Za-z0-9_]/g;
const OID = /^[0-9a-f]{40}([0-9a-f]{24})?$/;
class Refusal extends Error {
constructor(reason, detail) {
super(detail);
this.reason = reason;
}
}
const refuse = (reason, detail) => {
throw new Refusal(reason, detail);
};
const json = (text) => {
try {
return JSON.parse(text);
} catch {
return null;
}
};
export function openBus(dataRoot) {
const path = join(dataRoot, 'bus', 'bus.sqlite');
let s;
try {
s = lstatSync(path);
} catch {
throw Object.assign(new Error(`no bus database at ${path}`), { exit: 3 });
}
if (s.isSymbolicLink() || !s.isFile() || s.uid !== process.getuid())
throw Object.assign(new Error(`unsafe bus database ${path}`), { exit: 3 });
const db = new DatabaseSync(path, { readOnly: true, timeout: 5000 });
try {
checkSchema(db);
} catch (e) {
db.close();
throw e;
}
return db;
}
// The Store's own open check (store.mjs), against a reference built from the
// same schema.sql the import has already verified against SCHEMA_SHA256.
export function checkSchema(db) {
const ddl = readFileSync(new URL('../../packages/bus/schema.sql', import.meta.url), 'utf8');
if (createHash('sha256').update(ddl).digest('hex') !== SCHEMA_SHA256)
refuse('bus-schema', 'packages/bus/schema.sql does not match SCHEMA_SHA256');
const reference = new DatabaseSync(':memory:');
reference.exec(ddl);
const expected = schemaDigest(reference);
reference.close();
const meta = (key) => db.prepare('SELECT value FROM meta WHERE key = ?').get(key)?.value;
let digest;
try {
digest = schemaDigest(db);
} catch (e) {
refuse('bus-schema', `cannot read the schema: ${e.message}`);
}
if (digest !== expected) refuse('bus-schema', `schema digest ${digest.slice(0, 12)}, the store expects ${expected.slice(0, 12)}`);
if (meta('schema_version') !== SCHEMA_VERSION || meta('schema_digest') !== expected)
refuse('bus-schema', `meta says version ${meta('schema_version')}, digest ${String(meta('schema_digest')).slice(0, 12)}`);
}
// The queries. Each takes the business so one bus can't answer for another.
const queries = {
decision: (db, b, id) => db.prepare('SELECT * FROM decisions WHERE business = ? AND id = ?').get(b, id),
raise: (db, b, id) =>
db
.prepare(
`SELECT * FROM events WHERE business = ? AND kind = 'action.allowed' AND subject = ?
AND json_extract(body, '$.operation') = 'decision.raise' AND json_extract(body, '$.decision') = ?
ORDER BY seq`,
)
.all(b, id, id),
closes: (db, id) =>
db
.prepare(
`SELECT * FROM decision_events WHERE decision = ? AND op IN ('resolved','withdrawn','expired') ORDER BY seq`,
)
.all(id),
answer: (db, b, id) =>
db
.prepare(
`SELECT * FROM events WHERE business = ? AND kind = 'human.input' AND subject = ?
AND json_extract(body, '$.kind') = 'answer' AND json_extract(body, '$.decision') = ?
AND json_extract(body, '$.op') = 'resolved'
ORDER BY seq`,
)
.all(b, id, id),
later: (db, b, d) =>
db
.prepare(
`SELECT * FROM decisions WHERE business = ? AND id <> ?
AND (supersedes = ? OR (seq > ? AND action = ?)) ORDER BY seq`,
)
.all(b, d.id, d.id, d.seq, MERGE),
verdict: (db, b, task) =>
db
.prepare(`SELECT * FROM events WHERE business = ? AND kind = 'review.verdict' AND subject = ? ORDER BY seq DESC LIMIT 1`)
.get(b, task),
};
export function readLanding(db, business, id, base) {
const b = business.id;
const d = queries.decision(db, b, id);
const raises = queries.raise(db, b, id);
if (!d || raises.length !== 1) refuse('decision-not-found', `no decision ${id} with one raise in business ${b}`);
const raise = json(raises[0].body) ?? {};
if (d.action !== MERGE || raise.action !== MERGE) refuse('not-a-merge', `action ${d.action}`);
const closes = queries.closes(db, id);
if (closes.length !== 1 || closes[0].op !== 'resolved')
refuse('not-resolved', closes.length ? closes.map((r) => r.op).join(',') : 'open');
const res = closes[0];
const answers = queries.answer(db, b, id);
const answer = answers.length === 1 ? answers[0] : null;
if (
res.by !== business.human ||
res.via !== 'cli' ||
!answer ||
answer.actor_role !== null ||
answer.actor_run !== null ||
json(answer.body)?.choice !== res.choice
)
refuse('not-human', `resolved by ${res.by} via ${res.via}, ${answers.length} human answer(s)`);
const options = json(d.options) ?? [];
const merge = options.find((o) => o?.key === raise.approvalChoice);
if (!raise.approvalChoice || !merge) refuse('not-merge-option', 'the raise names no approval option');
if (res.choice !== raise.approvalChoice)
refuse('not-merge-option', `chose ${res.choice}, the merge option is ${raise.approvalChoice}`);
// The work ref comes from the question: decisions don't carry it.
const named = [...new Set(d.question.match(ANY_WORK_REF) ?? [])];
const m = named.length === 1 ? WORK_REF.exec(named[0]) : null;
if (!m) refuse('work-ref', `the question names ${named.join(', ') || 'no work ref'}`);
const workRef = named[0];
const task = `vikunja:${m[1]}/${m[2]}`;
for (const ref of [d.task_ref, raise.task_ref])
if (ref != null && ref !== task) refuse('work-ref', `the decision cites ${ref}, the question ${workRef}`);
// The target is the raise's, and only refactor ever lands.
if (raise.target !== 'refactor' || raise.target !== base)
refuse('target-branch', `the raise targets ${raise.target}, the base is ${base}`);
const later = queries.later(db, b, d).filter(
(r) => r.supersedes === id || r.task_ref === task || (r.question.match(ANY_WORK_REF) ?? []).includes(workRef),
);
if (later.length) refuse('superseded', `later decision ${later.map((r) => r.id).join(', ')} on ${workRef}`);
const v = queries.verdict(db, b, task);
if (!v) refuse('no-verdict', `no review.verdict for ${task}`);
const vb = json(v.body) ?? {};
if (vb.verdict !== 'approve' || vb.task_ref !== task || !OID.test(vb.commit ?? ''))
refuse('not-approved', `latest verdict ${v.id} is ${vb.verdict} on ${vb.commit}`);
return {
task_ref: task,
work_ref: workRef,
commit: vb.commit,
verdict: v.id,
project: d.project ?? '',
choice: res.choice,
by: res.by,
resolved_at: res.at,
subject: String(merge.text ?? '').split('\n')[0].trim(),
};
}
function main([dataRoot, businessFile, id, base]) {
const out = (k, v) => process.stdout.write(`${k}\t${String(v ?? '').replace(/[\t\n\r]/g, ' ')}\n`);
let business;
try {
business = JSON.parse(readFileSync(businessFile, 'utf8'));
} catch (e) {
process.stderr.write(`land-work: cannot read the business file ${businessFile}: ${e.message}\n`);
return 3;
}
if (typeof business?.id !== 'string' || typeof business?.human !== 'string' || !business.human) {
process.stderr.write(`land-work: ${businessFile} names no business id and human\n`);
return 3;
}
let db;
try {
db = openBus(dataRoot);
for (const [k, v] of Object.entries(readLanding(db, business, id, base))) out(k, v);
return 0;
} catch (e) {
if (e instanceof Refusal) {
out('refuse', `${e.reason}\t${e.message}`);
return 2;
}
process.stderr.write(`land-work: ${e.message}\n`);
return e.exit ?? 1;
} finally {
db?.close();
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) process.exitCode = main(process.argv.slice(2));
+404
View File
@@ -0,0 +1,404 @@
#!/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;
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' } : {}),
},
];
// 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: 'pm', 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' : 'jason', choice,
via: variant === 'via-webui' ? 'webui' : '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') 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"
# ---- 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-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 ]