fix(execute): the park step parks intent-to-add and coloured-diff files from any directory and exits 2 on a git failure

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-10-23 21:00:24 +02:00
commit 7869c4cbae
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
5 changed files with 486 additions and 15 deletions

View file

@ -1886,7 +1886,7 @@ session's effort, Sub-step E).
| 4 | the plan | **After three failed fix rounds the question is the architecture, not a fourth fix.** The step goes back to planning (below). Sub-step B sends three answers here directly, with no fix round: a `"not-run"` caused by the `Verify:` command, a second `plan-unreadable` (the executor's own call still wrong after one correction), and a `not-red-at-base` with `tests_only: true` (a step of only tests). Stop class 4 |
| 5 | the operator | A step already sent to the plan (`fix_rounds` 4) that fails its control again after re-planning goes to the operator. The executor never builds this step again: on a later `--resume` it only runs the checks (B, D1, D2, D3, D4, E) on whatever the operator changed. If they fail again the step stays at 5 — there is no sixth round, `fix_rounds` never exceeds 5 — and the run stops again. Stop class 4 |
**Back to planning** (`fix_rounds = 4`): save `git diff --no-ext-diff --no-renames --src-prefix=a/ --dst-prefix=b/ {start_sha}` (one form whatever the user's git config) to
**Back to planning** (`fix_rounds = 4`): save `git -c core.quotepath=off diff --no-ext-diff --no-color --no-renames --src-prefix=a/ --dst-prefix=b/ {start_sha}` (one form whatever the user's git config) to
`{report dir}/step-{N}-upward.diff` and write `{report dir}/step-{N}-upward.md`:
the step verbatim, every round's findings and verdicts, and the question for
the planner — which assumption in the step (its Changes, its Interface, its

View file

@ -13,31 +13,45 @@
// under its own path. The git dir, like the test lock's, is outside the work
// tree, so a parked file never comes back as a new file of the next build.
// Moved, never deleted, and never over a file already parked there.
// A new file the helper registered --intent-to-add is in the index, not in
// HEAD: it is taken out of the index (`git rm --cached`) and parked like any
// other; a file committed since the attempt (in HEAD) is never touched. The
// paths are read from the repository root (`git rev-parse --show-toplevel`),
// whatever --cwd inside it is given, and colour codes in the diff are ignored.
//
// Usage:
// node lib/util/park-attempt-files.mjs --diff <step-N-upward.diff> --step <N> --attempt <k> [--cwd <repo>]
// Prints {"parked": [paths], "kept": [{"path", "reason", "blocking"}], "to": dir}.
// Exit: 0 = no file the attempt made is left in the tree; 1 = one still is
// (a header it could not read, a move that failed, a target already taken):
// the run stops; 2 = could not run (bad usage, unreadable diff, not a
// repository). Neither 1 nor 2 is ever "the tree is clean".
// (a header it could not read, a non-empty diff with no readable header, a
// move that failed, a target already taken): the run stops; 2 = could not run
// (bad usage, unreadable diff, not a repository, a git call that failed, an
// index entry that could not be removed; nothing is moved). Neither 1 nor 2
// is ever "the tree is clean".
import { readFileSync, mkdirSync, renameSync, lstatSync } from 'node:fs';
import { join, dirname, resolve, relative, isAbsolute } from 'node:path';
import { spawnSync } from 'node:child_process';
import { isMain } from './is-main.mjs';
const ANSI_RE = /\x1B\[[0-9;]*m/g;
/**
* What a unified git diff creates: `paths` read from a `diff --git a/X b/X`
* header followed by `new file mode`, and `unread`, the raw headers of new
* files whose header is not that form (another prefix, a quoted path).
* files whose header is not that form (another prefix, a quoted path). Colour
* codes are stripped first; a non-empty diff with no `diff --git` header at
* all is one `unread` entry, so it can never read as "nothing to park".
*/
export function newFilesInDiff(diffText) {
const paths = [];
const unread = [];
let header = null;
for (const line of String(diffText).split('\n')) {
let headers = 0;
const lines = String(diffText).split('\n').map((l) => l.replace(ANSI_RE, ''));
for (const line of lines) {
if (line.startsWith('diff --git ')) {
headers++;
header = line;
continue;
}
@ -49,9 +63,26 @@ export function newFilesInDiff(diffText) {
header = null;
}
}
const first = lines.find((l) => l.trim() !== '');
if (headers === 0 && first !== undefined) unread.push(`no diff --git header in a non-empty diff (first line: ${first.slice(0, 200)})`);
return { paths, unread };
}
/** A git call run in `root`; anything but exit 0 throws (the CLI turns it into exit 2). */
function git(root, args, opts = {}) {
const r = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, ...opts });
if (r.error) throw new Error(`git ${args.join(' ')}: ${r.error.message}`);
if (r.status !== 0) throw new Error(`git ${args.join(' ')} exited ${r.status}: ${String(r.stderr ?? '').trim().split('\n')[0]}`);
return r.stdout;
}
/** Is `path` (root-relative) in HEAD? A repository with no commit has no HEAD: not in it. */
function inHead(root, path) {
const r = spawnSync('git', ['-C', root, 'cat-file', '-e', `HEAD:${path}`], { encoding: 'utf8' });
if (r.error) throw new Error(`git cat-file -e HEAD:${path}: ${r.error.message}`);
return r.status === 0;
}
function lexists(abs) {
try {
lstatSync(abs);
@ -66,15 +97,15 @@ function lexists(abs) {
* @returns {{ parked: string[], kept: Array<{path: string, reason: string, blocking: boolean}>, to: string }}
*/
export function parkAttemptFiles({ diffText, step, attempt, cwd = process.cwd() }) {
const root = resolve(cwd);
const gitDir = spawnSync('git', ['rev-parse', '--absolute-git-dir'], { cwd: root, encoding: 'utf8' }).stdout.trim();
const root = git(resolve(cwd), ['rev-parse', '--show-toplevel']).trim();
const gitDir = git(root, ['rev-parse', '--absolute-git-dir']).trim();
const to = join(gitDir, 'voyage', 'parked', `step-${step}-attempt-${attempt}`);
const trackedPaths = new Set(
spawnSync('git', ['ls-files', '-z'], { cwd: root, encoding: 'utf8' }).stdout.split('\0').filter(Boolean),
);
const trackedPaths = new Set(git(root, ['ls-files', '-z']).split('\0').filter(Boolean));
const { paths, unread } = newFilesInDiff(diffText);
const parked = [];
const kept = unread.map((h) => ({ path: h, reason: 'diff header not read: move this file out by hand', blocking: true }));
const moves = [];
const unindex = [];
for (const path of paths) {
const abs = resolve(root, path);
const rel = relative(root, abs);
@ -86,15 +117,34 @@ export function parkAttemptFiles({ diffText, step, attempt, cwd = process.cwd()
kept.push({ path, reason: 'not on disk: nothing to park', blocking: false });
continue;
}
if (trackedPaths.has(rel.split('\\').join('/'))) {
kept.push({ path, reason: 'tracked since the attempt: not the executor\'s to move', blocking: false });
continue;
const posix = rel.split('\\').join('/');
let intentToAdd = false;
if (trackedPaths.has(posix)) {
if (inHead(root, posix)) {
kept.push({ path, reason: 'tracked since the attempt: not the executor\'s to move', blocking: false });
continue;
}
// In the index but not in HEAD: registered --intent-to-add by the attempt.
intentToAdd = true;
}
const target = join(to, rel);
if (lexists(target)) {
kept.push({ path, reason: `already parked at ${target}: never overwritten, move it by hand`, blocking: true });
continue;
}
moves.push({ path, abs, rel, target });
if (intentToAdd) unindex.push(posix);
}
// Out of the index first, all in ONE call before anything moves: git
// refuses the whole call when one path cannot be removed, so a failure
// throws (exit 2) with every entry still in the index and nothing moved.
// Literal pathspecs, read NUL-separated from stdin: a new file named
// `tests/*.sh` never matches a committed `tests/real.sh`.
if (unindex.length > 0) {
git(root, ['--literal-pathspecs', 'rm', '--cached', '-q', '--pathspec-from-file=-', '--pathspec-file-nul'],
{ input: unindex.map((p) => `${p}\0`).join('') });
}
for (const { path, abs, rel, target } of moves) {
try {
mkdirSync(dirname(target), { recursive: true });
renameSync(abs, target);
@ -131,7 +181,13 @@ if (isMain(import.meta.url)) {
process.stderr.write(`park-attempt-files: ${cwd} is not a git repository\n`);
process.exit(2);
}
const result = parkAttemptFiles({ diffText, step, attempt, cwd });
let result;
try {
result = parkAttemptFiles({ diffText, step, attempt, cwd });
} catch (e) {
process.stderr.write(`park-attempt-files: could not run: ${e.message}\n`);
process.exit(2);
}
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
process.exit(result.kept.some((k) => k.blocking) ? 1 : 0);
}

198
tests/e2e-fix-round-4-5a.sh Normal file
View file

@ -0,0 +1,198 @@
#!/usr/bin/env bash
# tests/e2e-fix-round-4-5a.sh
# End to end for fix round 4-5a (plan
# .claude/projects/2026-10-20-fix-round-serious-findings, End-to-end:): the
# shipped CLIs run the way the commands run them, in temp git repos.
# 1. The park tool (lib/util/park-attempt-files.mjs), on the diff the
# "Back to planning" line saves:
# a. a new file registered --intent-to-add is parked (exit 0, gone from
# the tree and the index, kept under .git/voyage/parked/);
# b. a coloured diff (color.diff=always) still parks its new file;
# c. --cwd a subdirectory of the repo parks the root-relative path.
# 2. The verdict gate (lib/review/verdict-gate.mjs --json <review> --repo .):
# a review of HEAD (0), a later lessons-only commit (0), a later source
# commit (2, reason review-stale).
# 3. The arg parser (lib/parsers/arg-parser.mjs --command trekendsession --
# fix-header): no help flag, the slug is the first positional.
# 4. The gates shim (lib/util/autonomy-gate.mjs): `--gates closed` and a bare
# `--gates` both give next_state gates_on.
# Prints "e2e: ok" and exits 0, or names the failed check and exits 1.
# bash 3.2 compatible, ASCII only.
set -u
cd "$(dirname "$0")/.." || { echo "e2e: FAIL - cannot cd to the repo root" >&2; exit 1; }
ROOT="$(pwd)"
PARK="$ROOT/lib/util/park-attempt-files.mjs"
VERDICT="$ROOT/lib/review/verdict-gate.mjs"
ARGS="$ROOT/lib/parsers/arg-parser.mjs"
SHIM="$ROOT/lib/util/autonomy-gate.mjs"
unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE
TMP="$(mktemp -d "${TMPDIR:-/tmp}/e2e-fix-round-4-5a.XXXXXX")" || { echo "e2e: FAIL - mktemp" >&2; exit 1; }
trap 'rm -rf "$TMP"' EXIT
fail() {
echo "e2e: FAIL - $1" >&2
exit 1
}
mkdir -p "$TMP/hooks-none" || fail "mkdir hooks-none"
# newrepo <dir>: git init with no hooks, no signing, one identity.
newrepo() {
mkdir -p "$1" || fail "mkdir $1"
git -C "$1" init -q || fail "git init $1"
git -C "$1" config user.name "Test" || fail "git config user.name"
git -C "$1" config user.email "test@example.invalid" || fail "git config user.email"
git -C "$1" config commit.gpgsign false || fail "git config commit.gpgsign"
git -C "$1" config core.hooksPath "$TMP/hooks-none" || fail "git config core.hooksPath"
}
# jsonok <check> <js expression on j>: stdin is JSON; exit 0 when the expression is true.
jsonok() {
node -e 'let s="";process.stdin.on("data",(d)=>{s+=d;}).on("end",()=>{let j;try{j=JSON.parse(s);}catch(e){process.exit(3);}process.exit(Function("j","return ("+process.argv[1]+");")(j)?0:1);});' "$1"
}
# ---- 1. the park tool ----
P="$TMP/park"
newrepo "$P"
mkdir -p "$P/bin" "$P/tests" || fail "mkdir park dirs"
printf 'old\n' > "$P/bin/slug.mjs"
git -C "$P" add bin/slug.mjs || fail "git add bin/slug.mjs"
git -C "$P" commit -q -m "base" || fail "git commit base"
START="$(git -C "$P" rev-parse HEAD)" || fail "rev-parse start"
PARKED="$P/.git/voyage/parked"
# savediff <file>: the "Back to planning" save line of commands/trekexecute.md.
savediff() {
git -C "$P" -c core.quotepath=off diff --no-ext-diff --no-color --no-renames --src-prefix=a/ --dst-prefix=b/ "$START" > "$1" \
|| fail "git diff for $1"
}
# parked_ok <name> <attempt>: tests/<name> left the tree and the index and sits in the parked dir.
parked_ok() {
[ ! -e "$P/tests/$1" ] || fail "park $2: tests/$1 is still in the work tree"
if git -C "$P" ls-files --error-unmatch -- "tests/$1" >/dev/null 2>&1; then
fail "park $2: tests/$1 is still in the index"
fi
[ -f "$PARKED/step-1-attempt-$2/tests/$1" ] || fail "park $2: tests/$1 is not under the parked directory"
}
# 1a. an intent-to-add file
printf 'stale | cat\n' > "$P/tests/a.sh"
git -C "$P" add --intent-to-add -- tests/a.sh || fail "git add --intent-to-add tests/a.sh"
savediff "$TMP/park-1.diff"
grep -q '^new file mode' "$TMP/park-1.diff" || fail "park 1: the saved diff names no new file"
out="$(node "$PARK" --diff "$TMP/park-1.diff" --step 1 --attempt 1 --cwd "$P")" || fail "park 1 (intent-to-add) exited non-zero: $out"
parked_ok a.sh 1
# 1b. a coloured diff
printf 'stale\n' > "$P/tests/b.sh"
git -C "$P" add --intent-to-add -- tests/b.sh || fail "git add --intent-to-add tests/b.sh"
git -C "$P" -c color.diff=always diff --no-ext-diff --no-renames --src-prefix=a/ --dst-prefix=b/ "$START" > "$TMP/park-2.diff" \
|| fail "git diff (coloured)"
ESC="$(printf '\033')"
grep -q "$ESC" "$TMP/park-2.diff" || fail "park 2: control - the diff holds no colour code"
out="$(node "$PARK" --diff "$TMP/park-2.diff" --step 1 --attempt 2 --cwd "$P")" || fail "park 2 (coloured diff) exited non-zero: $out"
parked_ok b.sh 2
# 1c. from a subdirectory
printf 'stale\n' > "$P/tests/c.sh"
git -C "$P" add --intent-to-add -- tests/c.sh || fail "git add --intent-to-add tests/c.sh"
savediff "$TMP/park-3.diff"
out="$(node "$PARK" --diff "$TMP/park-3.diff" --step 1 --attempt 3 --cwd "$P/bin")" || fail "park 3 (--cwd bin) exited non-zero: $out"
parked_ok c.sh 3
[ "$(cat "$P/bin/slug.mjs")" = "old" ] || fail "park: a committed file was touched"
# ---- 2. the verdict gate with --repo ----
V="$TMP/verdict"
newrepo "$V"
mkdir -p "$TMP/project" || fail "mkdir project"
printf 'export const a = 1;\n' > "$V/a.mjs"
git -C "$V" add a.mjs || fail "git add a.mjs"
git -C "$V" commit -q -m "feat: the reviewed work" || fail "git commit reviewed"
REVIEWED="$(git -C "$V" rev-parse HEAD)" || fail "rev-parse reviewed"
REVIEW="$TMP/project/review.md"
cat > "$REVIEW" <<EOF
---
type: trekreview
review_version: "1.1"
task: "t"
slug: s
project_dir: .claude/projects/2026-10-23-s/
brief_path: .claude/projects/2026-10-23-s/brief.md
scope_sha_end: $REVIEWED
reviewed_files_count: 1
verdict: ALLOW
findings: []
---
# Review: t
## Executive Summary
x
## Coverage
| File | Treatment | Reason |
|---|---|---|
| a.mjs | summary-only | default |
## Remediation Summary
x
\`\`\`json
{
"verdict": "ALLOW",
"counts": { "BLOCKER": 0, "MAJOR": 0, "MINOR": 0, "SUGGESTION": 0 },
"allow_blocked_by": [],
"findings": []
}
\`\`\`
EOF
# gate: the merge lock line of commands/trekexecute.md, run in the repo being merged.
gate() {
(cd "$V" && node "$VERDICT" --json "$REVIEW" --repo .)
}
out="$(gate)" || fail "verdict 1: a review of HEAD did not exit 0: $out"
mkdir -p "$V/.claude/rules" || fail "mkdir .claude/rules"
printf '<!-- voyage:review-lessons:start -->\nEach line: a finding class.\n<!-- voyage:review-lessons:end -->\n' \
> "$V/.claude/rules/voyage-review-lessons.md"
git -C "$V" add .claude/rules/voyage-review-lessons.md || fail "git add lessons"
git -C "$V" commit -q -m "docs(voyage): review lessons (round 1)" || fail "git commit lessons"
out="$(gate)" || fail "verdict 2: a later lessons-only commit did not exit 0: $out"
printf 'export const a = 2;\n' > "$V/a.mjs"
git -C "$V" add a.mjs || fail "git add a.mjs (later)"
git -C "$V" commit -q -m "feat: a later source change" || fail "git commit later"
out="$(gate)"
code=$?
[ "$code" -eq 2 ] || fail "verdict 3: a review of an older commit exited $code, not 2: $out"
printf '%s' "$out" | jsonok 'typeof j.reason === "string" && j.reason.indexOf("review-stale") === 0' \
|| fail "verdict 3: the reason does not start review-stale: $out"
# ---- 3. the arg parser for /trekendsession ----
out="$(node "$ARGS" --command trekendsession -- fix-header)" || fail "arg-parser on fix-header exited non-zero: $out"
printf '%s' "$out" | jsonok 'j.flags["--help"] === undefined && j.positional[0] === "fix-header"' \
|| fail "arg-parser: fix-header asked for help or is not the first positional: $out"
# ---- 4. the gates shim ----
out="$(node "$SHIM" --state idle --event start --gates closed)" || fail "shim --gates closed exited non-zero: $out"
printf '%s' "$out" | jsonok 'j.ok === true && j.next_state === "gates_on"' || fail "shim: --gates closed is not gates_on: $out"
out="$(node "$SHIM" --state idle --event start --gates)" || fail "shim bare --gates exited non-zero: $out"
printf '%s' "$out" | jsonok 'j.ok === true && j.next_state === "gates_on"' || fail "shim: a bare --gates is not gates_on: $out"
echo "e2e: ok"
exit 0

View file

@ -1390,3 +1390,50 @@ test('[ORD] sibling: agents/review-coordinator.md — Pass 1 groups in two passe
assert.match(pass1, /Group in two passes, so the order the findings arrive in never changes a row: first every finding joins the row of its title/,
'Pass 1 does not name the two passes, title first');
});
// --- F4b sibling: the writer of step-{N}-upward.diff ------------------------
// The park tool reads the diff "Back to planning" saves; with color.diff=always
// or core.quotepath on in the user's config, its headers were coloured or
// quoted. The save line pins both off, on the same line (no line_ref move).
const PARK_WRITER = 'git -c core.quotepath=off diff --no-ext-diff --no-color --no-renames --src-prefix=a/ --dst-prefix=b/ {start_sha}';
test('[F4b] sibling: commands/trekexecute.md "Back to planning" saves the diff with core.quotepath=off and --no-color', () => {
const lines = read(EXEC).split('\n').filter((l) => l.startsWith('**Back to planning** (`fix_rounds = 4`): save `'));
assert.equal(lines.length, 1, `expected one "Back to planning" save line; found ${lines.length}`);
assert.ok(lines[0].includes(`save \`${PARK_WRITER}\` (one form whatever the user's git config) to`),
`the upward diff is not saved as ${PARK_WRITER}: ${lines[0]}`);
});
// --- e2e script: tests/e2e-fix-round-4-5a.sh --------------------------------
// The round run end to end (the plan's End-to-end:). Static pins only: the
// script itself is the End-to-end: command.
const E2E_ROUND = 'tests/e2e-fix-round-4-5a.sh';
test('e2e script: tests/e2e-fix-round-4-5a.sh exists, is ASCII only and passes /bin/bash -n', () => {
assert.ok(existsSync(join(ROOT, E2E_ROUND)), `missing file ${E2E_ROUND}`);
const buf = readFileSync(join(ROOT, E2E_ROUND));
assert.equal(buf.findIndex((b) => b > 127), -1, 'the e2e script is not ASCII only');
const r = spawnSync('/bin/bash', ['-n', join(ROOT, E2E_ROUND)], { encoding: 'utf8', env: childEnv() });
assert.equal(r.status, 0, r.stderr);
const text = buf.toString('utf8');
assert.match(text, /e2e: ok/, 'the script does not print e2e: ok');
assert.match(text, /^set -u$/m, 'the script does not set -u');
for (const bash4 of [/\bdeclare -A\b/, /\bmapfile\b/, /\breadarray\b/, /\|&/]) {
assert.doesNotMatch(text, bash4, `the script uses ${bash4}, which bash 3.2 lacks`);
}
});
test('e2e script: it makes its temp directory with mktemp -d and an EXIT trap removes it', () => {
const text = read(E2E_ROUND);
assert.match(text, /^TMP="\$\(mktemp -d [^\n]*\)"/m, 'the script does not make $TMP with mktemp -d');
assert.match(text, /^trap 'rm -rf "\$TMP"' EXIT$/m, 'no EXIT trap removes $TMP');
});
test('e2e script: it names each of the four CLIs it runs', () => {
const text = read(E2E_ROUND);
for (const cli of ['lib/util/park-attempt-files.mjs', 'lib/review/verdict-gate.mjs', 'lib/parsers/arg-parser.mjs', 'lib/util/autonomy-gate.mjs']) {
assert.ok(text.includes(cli), `the e2e script does not run ${cli}`);
}
});

View file

@ -100,6 +100,9 @@ test('park: a new file that is tracked now, or already gone, is kept and named,
assert.match(out.kept[0].reason, /tracked/);
assert.match(out.kept[1].reason, /not on disk/);
assert.equal(existsSync(join(dir, 'tests/batch.sh')), true);
const inIndex = spawnSync('git', ['ls-files', '--error-unmatch', '--', 'tests/batch.sh'], { cwd: dir, encoding: 'utf8' });
assert.equal(inIndex.status, 0, `the committed file left the index: ${inIndex.stderr}`);
assert.equal(git(dir, 'status', '--porcelain', '--', 'tests/batch.sh'), '', 'the committed file is no longer clean in the index');
} finally {
rmSync(dir, { recursive: true, force: true });
}
@ -194,3 +197,170 @@ test('trekexecute: a re-planned step parks the earlier attempt\'s new files befo
assert.match(para, /never deleted/);
assert.match(para, /Exit 1 means a file the attempt made is still in the tree/);
});
// --- fix round 4-5a: F4a, F4b, F4c ---------------------------------------------
// [F4a] `git diff {start_sha}` names the attempt's new files because the helper
// registered them --intent-to-add, so they are in the index: the tool called
// them "tracked since the attempt" and left them in the tree with exit 0.
// [F4b] A coloured diff (color.diff=always) hid every header; a diff with no
// readable header at all parked nothing with exit 0.
// [F4c] The root was the --cwd as given, so a subdirectory read every
// root-relative path as absent; a git call that failed read as "nothing tracked".
/** The CLI run with `--cwd <cwd>` on `diff` written into the repo's git dir. */
function runFrom(dir, cwd, diff = DIFF) {
writeFileSync(join(dir, '.git', 'upward.diff'), diff);
return spawnSync(process.execPath, [CLI, '--diff', join(dir, '.git', 'upward.diff'), '--step', '1', '--attempt', '1', '--cwd', cwd], { encoding: 'utf8' });
}
/** The attempt's two new files, written on disk. */
function attemptFiles(dir) {
mkdirSync(join(dir, 'tests'));
writeFileSync(join(dir, 'tests/batch.sh'), 'stale | cat\n');
writeFileSync(join(dir, 'tests/cli-stdin.test.mjs'), 'stale test\n');
}
test('[F4a] a new file still registered --intent-to-add is parked: gone from the tree and the index, kept under the parked directory', () => {
const dir = repo();
try {
attemptFiles(dir);
git(dir, 'add', '--intent-to-add', '--', 'tests/batch.sh', 'tests/cli-stdin.test.mjs');
assert.match(git(dir, 'ls-files'), /tests\/batch\.sh/, 'control: the file is in the index before the park');
const r = run(dir);
assert.equal(r.status, 0, r.stdout + r.stderr);
assert.equal(existsSync(join(dir, 'tests/batch.sh')), false, 'the intent-to-add file is still in the work tree');
assert.equal(existsSync(join(dir, 'tests/cli-stdin.test.mjs')), false, 'the intent-to-add test file is still in the work tree');
assert.doesNotMatch(git(dir, 'ls-files'), /tests\//, 'the parked files are still in the index');
assert.equal(readFileSync(parkedPath(dir, 'tests/batch.sh'), 'utf8'), 'stale | cat\n', 'the file is not under the parked directory');
assert.equal(readFileSync(parkedPath(dir, 'tests/cli-stdin.test.mjs'), 'utf8'), 'stale test\n');
assert.deepEqual(JSON.parse(r.stdout).parked, ['tests/batch.sh', 'tests/cli-stdin.test.mjs']);
assert.equal(readFileSync(join(dir, 'bin/slug.mjs'), 'utf8'), 'old\n', 'a committed file is never touched');
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('[F4b] a diff with \\x1b[1m...\\x1b[m around its headers parks the file', () => {
const dir = repo();
try {
attemptFiles(dir);
const coloured = DIFF.split('\n')
.map((l) => (/^(diff --git |new file mode |index |--- |\+\+\+ )/.test(l) ? `\x1b[1m${l}\x1b[m` : l))
.join('\n');
assert.ok(coloured.includes('\x1b[1mdiff --git a/tests/batch.sh b/tests/batch.sh\x1b[m'), 'control: the headers are coloured');
const r = run(dir, coloured);
assert.equal(r.status, 0, r.stdout + r.stderr);
assert.equal(existsSync(join(dir, 'tests/batch.sh')), false, 'a coloured header left the file in the tree');
assert.equal(readFileSync(parkedPath(dir, 'tests/batch.sh'), 'utf8'), 'stale | cat\n', 'the file is not under the parked directory');
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('[F4b] a non-empty diff with no readable diff --git header exits 1 with a blocking entry', () => {
const dir = repo();
try {
attemptFiles(dir);
const headerless = ['--- /dev/null', '+++ b/tests/batch.sh', '@@ -0,0 +1 @@', '+cat', ''].join('\n');
const r = run(dir, headerless);
assert.equal(r.status, 1, `a diff it could not read must stop the run: ${r.stdout}${r.stderr}`);
const out = JSON.parse(r.stdout);
assert.equal(out.kept.filter((k) => k.blocking).length, 1, `expected one blocking entry: ${r.stdout}`);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('[F4c] --cwd <repo>/bin parks the root-relative path', () => {
const dir = repo();
try {
attemptFiles(dir);
const r = runFrom(dir, join(dir, 'bin'));
assert.equal(r.status, 0, r.stdout + r.stderr);
assert.ok(JSON.parse(r.stdout).parked.includes('tests/batch.sh'), `tests/batch.sh was not parked from a subdirectory: ${r.stdout}`);
assert.equal(existsSync(join(dir, 'tests/batch.sh')), false, 'the file is still in the tree');
assert.equal(readFileSync(parkedPath(dir, 'tests/batch.sh'), 'utf8'), 'stale | cat\n');
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('[F4c] a corrupt .git/index (ls-files exits 128) exits 2 and moves nothing', () => {
const dir = repo();
try {
attemptFiles(dir);
writeFileSync(join(dir, '.git', 'index'), 'not an index\n');
const ls = spawnSync('git', ['ls-files', '-z'], { cwd: dir, encoding: 'utf8' });
assert.equal(ls.status, 128, `control: ls-files must fail on the corrupt index: ${ls.stderr}`);
const r = run(dir);
assert.equal(r.status, 2, `a failed git call must be exit 2, never a park: ${r.stdout}${r.stderr}`);
assert.equal(existsSync(join(dir, 'tests/batch.sh')), true, 'a file was moved although git failed');
assert.equal(existsSync(parkedPath(dir, 'tests/batch.sh')), false);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('park: an empty diff exits 0 and parks nothing', () => {
const dir = repo();
try {
const r = run(dir, '');
assert.equal(r.status, 0, r.stdout + r.stderr);
const out = JSON.parse(r.stdout);
assert.deepEqual(out.parked, []);
assert.deepEqual(out.kept, []);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('[F4a] an intent-to-add file named like a glob is parked, and the committed file it would match stays in the index', () => {
const dir = repo();
try {
mkdirSync(join(dir, 'tests'));
mkdirSync(join(dir, 'app/s'), { recursive: true });
writeFileSync(join(dir, 'tests/real.sh'), 'committed\n');
writeFileSync(join(dir, 'app/s/page.tsx'), 'committed\n');
git(dir, 'add', '--', 'tests/real.sh', 'app/s/page.tsx');
git(dir, 'commit', '-q', '-m', 'operator');
writeFileSync(join(dir, 'tests/*.sh'), 'stale glob\n');
mkdirSync(join(dir, 'app/[s]'));
writeFileSync(join(dir, 'app/[s]/page.tsx'), 'stale bracket\n');
git(dir, '--literal-pathspecs', 'add', '--intent-to-add', '--', 'tests/*.sh', 'app/[s]/page.tsx');
const diff = DIFF.replaceAll('tests/batch.sh', 'tests/*.sh').replaceAll('tests/cli-stdin.test.mjs', 'app/[s]/page.tsx');
const r = run(dir, diff);
assert.equal(r.status, 0, r.stdout + r.stderr);
for (const committed of ['tests/real.sh', 'app/s/page.tsx']) {
const inIndex = spawnSync('git', ['ls-files', '--error-unmatch', '--', committed], { cwd: dir, encoding: 'utf8' });
assert.equal(inIndex.status, 0, `${committed} left the index: ${inIndex.stderr}`);
assert.equal(git(dir, 'status', '--porcelain', '--', committed), '', `${committed} is no longer clean in the index`);
}
assert.equal(existsSync(join(dir, 'tests/*.sh')), false);
assert.equal(existsSync(parkedPath(dir, 'tests/*.sh')), true, 'the glob-named file is not under the parked directory');
assert.equal(existsSync(parkedPath(dir, 'app/[s]/page.tsx')), true, 'the bracket-named file is not under the parked directory');
assert.equal(readFileSync(parkedPath(dir, 'tests/*.sh'), 'utf8'), 'stale glob\n', 'the glob-named file is not parked');
assert.equal(readFileSync(parkedPath(dir, 'app/[s]/page.tsx'), 'utf8'), 'stale bracket\n', 'the bracket-named file is not parked');
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('[F4a] one index entry that cannot be removed exits 2 with every entry still in the index and nothing parked', () => {
const dir = repo();
try {
attemptFiles(dir);
git(dir, 'add', '--intent-to-add', '--', 'tests/batch.sh');
git(dir, 'add', '--', 'tests/cli-stdin.test.mjs');
writeFileSync(join(dir, 'tests/cli-stdin.test.mjs'), 'edited after staging\n');
const r = run(dir);
assert.equal(r.status, 2, `a removal git refuses must be exit 2: ${r.stdout}${r.stderr}`);
const listed = git(dir, 'ls-files').split('\n');
assert.ok(listed.includes('tests/batch.sh'), 'the intent-to-add entry was taken out of the index although the run could not finish');
assert.ok(listed.includes('tests/cli-stdin.test.mjs'), 'the staged entry was taken out of the index');
assert.equal(existsSync(parkedPath(dir, 'tests/batch.sh')), false, 'a file was parked although the run could not finish');
assert.equal(existsSync(parkedPath(dir, 'tests/cli-stdin.test.mjs')), false);
assert.equal(existsSync(join(dir, 'tests/batch.sh')), true);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});