// 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, symlinkSync } 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, 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', dir], { encoding: 'utf8' }); } // Parked outside the work tree, in the git dir, so a later `git ls-files --others` never lists them. const parkedPath = (dir, path) => join(dir, '.git', 'voyage', 'parked', 'step-1-attempt-1', path); test('park: the step\'s untracked new files move into the git dir 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(parkedPath(dir, 'tests/batch.sh'), 'utf8'), 'stale | cat\n', 'and kept, never deleted'); assert.equal(git(dir, 'ls-files', '--others', '--exclude-standard').trim(), '', 'nothing parked shows up as a new file'); 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); 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 }); } }); test('park: a path that leaves the repository is refused, never moved', () => { const dir = repo(); try { const r = run(dir, DIFF.replaceAll('tests/batch.sh', '../outside.sh')); 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: a path with a space is read and parked', () => { const dir = repo(); try { mkdirSync(join(dir, 'tests')); writeFileSync(join(dir, 'tests/my batch.sh'), 'stale\n'); const r = run(dir, DIFF.replaceAll('tests/batch.sh', 'tests/my batch.sh')); assert.equal(r.status, 0, r.stderr); assert.ok(JSON.parse(r.stdout).parked.includes('tests/my batch.sh'), r.stdout); } finally { rmSync(dir, { recursive: true, force: true }); } }); test('park: a new file under a header it cannot read is named, and exit 1 stops the run (review of 3238327)', () => { const dir = repo(); try { mkdirSync(join(dir, 'tests')); writeFileSync(join(dir, 'tests/batch.sh'), 'stale\n'); const r = run(dir, DIFF.replace('diff --git a/tests/batch.sh b/tests/batch.sh', 'diff --git c/tests/batch.sh w/tests/batch.sh')); assert.equal(r.status, 1, r.stdout); const out = JSON.parse(r.stdout); assert.ok(out.kept.some((k) => /header not read/.test(k.reason)), r.stdout); } finally { rmSync(dir, { recursive: true, force: true }); } }); test('park: an attempt file still on disk because its target exists is exit 1, and the parked copy is never overwritten', () => { const dir = repo(); try { mkdirSync(join(dir, 'tests')); writeFileSync(join(dir, 'tests/batch.sh'), 'second\n'); mkdirSync(dirname(parkedPath(dir, 'tests/batch.sh')), { recursive: true }); writeFileSync(parkedPath(dir, 'tests/batch.sh'), 'first\n'); const r = run(dir); assert.equal(r.status, 1, r.stdout); assert.equal(readFileSync(parkedPath(dir, 'tests/batch.sh'), 'utf8'), 'first\n'); assert.equal(existsSync(join(dir, 'tests/batch.sh')), true); } finally { rmSync(dir, { recursive: true, force: true }); } }); test('park: a symlink the attempt made is parked, not called absent', () => { const dir = repo(); try { mkdirSync(join(dir, 'tests')); symlinkSync('nowhere', join(dir, 'tests/batch.sh')); const r = run(dir, DIFF.replace('new file mode 100755', 'new file mode 120000')); assert.equal(r.status, 0, r.stdout); assert.ok(JSON.parse(r.stdout).parked.includes('tests/batch.sh'), 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'), '--step', '1', '--attempt', '1', '--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 + 1600); assert.match(para, /lib\/util\/park-attempt-files\.mjs/); assert.match(para, /step-\{N\}-upward\.diff/); 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 ` 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 /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 }); } });