fix(trekexecute): park a re-planned step's earlier new files before the fresh build

The revert of a step sent back to the plan leaves its new files on disk, and --resume built the revised step on top of them: a stale tests/batch.sh holding the rejected line would have passed Verify, the manifest and the success criterion. lib/util/park-attempt-files.mjs moves every still-untracked file the attempt created (from step-N-upward.diff) into the report dir, never deleting it; /trekexecute runs it before the fresh build helper (voyage-lekerepo trial 5, finding 4).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-10-18 19:54:54 +02:00
commit 3238327483
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
4 changed files with 267 additions and 4 deletions

View file

@ -0,0 +1,137 @@
// tests/lib/park-attempt-files.test.mjs
// A step sent back to the plan leaves its new files on disk; before the fresh
// build of the re-planned step they are moved into the report dir, never
// deleted (voyage-lekerepo trial 5, finding 4).
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { mkdtempSync, writeFileSync, readFileSync, existsSync, mkdirSync, rmSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { tmpdir } from 'node:os';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
const CLI = join(ROOT, 'lib/util/park-attempt-files.mjs');
function git(cwd, ...args) {
const r = spawnSync('git', args, { cwd, encoding: 'utf8' });
assert.equal(r.status, 0, r.stderr);
return r.stdout;
}
function repo() {
const dir = mkdtempSync(join(tmpdir(), 'voyage-park-'));
git(dir, 'init', '-q');
git(dir, 'config', 'user.email', 't@example.invalid');
git(dir, 'config', 'user.name', 't');
mkdirSync(join(dir, 'bin'));
writeFileSync(join(dir, 'bin/slug.mjs'), 'old\n');
git(dir, 'add', 'bin/slug.mjs');
git(dir, 'commit', '-q', '-m', 'base');
return dir;
}
// The shape `git diff {start_sha}` gives with the new files registered --intent-to-add.
const DIFF = [
'diff --git a/bin/slug.mjs b/bin/slug.mjs',
'index 3367afd..e69de29 100644',
'--- a/bin/slug.mjs',
'+++ b/bin/slug.mjs',
'@@ -1 +1 @@',
'-old',
'+new',
'diff --git a/tests/batch.sh b/tests/batch.sh',
'new file mode 100755',
'index 0000000..1111111',
'--- /dev/null',
'+++ b/tests/batch.sh',
'@@ -0,0 +1 @@',
'+cat',
'diff --git a/tests/cli-stdin.test.mjs b/tests/cli-stdin.test.mjs',
'new file mode 100644',
'--- /dev/null',
'+++ b/tests/cli-stdin.test.mjs',
'@@ -0,0 +1 @@',
'+t',
'',
].join('\n');
function run(dir, extra = []) {
writeFileSync(join(dir, 'upward.diff'), DIFF);
return spawnSync(process.execPath, [CLI, '--diff', join(dir, 'upward.diff'), '--to', join(dir, 'parked'), '--cwd', dir, ...extra], { encoding: 'utf8' });
}
test('park: the step\'s untracked new files move into --to with their paths; tracked files stay', () => {
const dir = repo();
try {
mkdirSync(join(dir, 'tests'));
writeFileSync(join(dir, 'tests/batch.sh'), 'stale | cat\n');
writeFileSync(join(dir, 'tests/cli-stdin.test.mjs'), 'stale test\n');
writeFileSync(join(dir, 'bin/slug.mjs'), 'new\n');
const r = run(dir);
assert.equal(r.status, 0, r.stderr);
const out = JSON.parse(r.stdout);
assert.deepEqual(out.parked, ['tests/batch.sh', 'tests/cli-stdin.test.mjs']);
assert.equal(existsSync(join(dir, 'tests/batch.sh')), false, 'the stale file is out of the tree');
assert.equal(readFileSync(join(dir, 'parked/tests/batch.sh'), 'utf8'), 'stale | cat\n', 'and kept, never deleted');
assert.equal(readFileSync(join(dir, 'bin/slug.mjs'), 'utf8'), 'new\n', 'a tracked file is not the helper\'s to move');
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('park: a new file that is tracked now, or already gone, is kept and named, never moved', () => {
const dir = repo();
try {
mkdirSync(join(dir, 'tests'));
writeFileSync(join(dir, 'tests/batch.sh'), 'committed by the operator\n');
git(dir, 'add', 'tests/batch.sh');
git(dir, 'commit', '-q', '-m', 'operator');
const r = run(dir);
assert.equal(r.status, 0, r.stderr);
const out = JSON.parse(r.stdout);
assert.deepEqual(out.parked, []);
assert.deepEqual(out.kept.map((k) => k.path), ['tests/batch.sh', 'tests/cli-stdin.test.mjs']);
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);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('park: a path that leaves the repository is refused, never moved', () => {
const dir = repo();
try {
const evil = DIFF.replaceAll('tests/batch.sh', '../outside.sh');
writeFileSync(join(dir, 'upward.diff'), evil);
const r = spawnSync(process.execPath, [CLI, '--diff', join(dir, 'upward.diff'), '--to', join(dir, 'parked'), '--cwd', dir], { encoding: 'utf8' });
assert.equal(r.status, 0, r.stderr);
const out = JSON.parse(r.stdout);
assert.ok(out.kept.some((k) => k.path === '../outside.sh' && /outside the repository/.test(k.reason)), r.stdout);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('park: an unreadable diff or missing arguments exit 2 and move nothing', () => {
const dir = repo();
try {
const r = spawnSync(process.execPath, [CLI, '--diff', join(dir, 'nope.diff'), '--to', join(dir, 'parked'), '--cwd', dir], { encoding: 'utf8' });
assert.equal(r.status, 2);
assert.equal(spawnSync(process.execPath, [CLI], { encoding: 'utf8' }).status, 2);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('trekexecute: a re-planned step parks the earlier attempt\'s new files before the fresh build', () => {
const md = readFileSync(join(ROOT, 'commands/trekexecute.md'), 'utf8');
const at = md.indexOf('A step at `fix_rounds` 4 on `--resume` was re-planned');
assert.ok(at > 0);
const para = md.slice(at, at + 1200);
assert.match(para, /lib\/util\/park-attempt-files\.mjs/);
assert.match(para, /step-\{N\}-upward\.diff/);
assert.match(para, /never deleted/);
});