562 lines
29 KiB
JavaScript
562 lines
29 KiB
JavaScript
// 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 { after, test } from 'node:test';
|
|
import { strict as assert } from 'node:assert';
|
|
import { mkdtempSync, writeFileSync, readFileSync, existsSync, mkdirSync, rmSync, symlinkSync, cpSync } from 'node:fs';
|
|
import { join, dirname, delimiter } from 'node:path';
|
|
import { tmpdir } from 'node:os';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { spawnSync } from 'node:child_process';
|
|
import { childEnv } from '../helpers/child-env.mjs';
|
|
|
|
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;
|
|
}
|
|
|
|
// The base repo (bin/slug.mjs committed), built once; repo() gives each test its own copy.
|
|
let seed = null;
|
|
after(() => {
|
|
if (seed) rmSync(seed, { recursive: true, force: true });
|
|
});
|
|
|
|
function repo() {
|
|
if (!seed) {
|
|
seed = mkdtempSync(join(tmpdir(), 'voyage-park-seed-'));
|
|
git(seed, 'init', '-q');
|
|
git(seed, 'config', 'user.email', 't@example.invalid');
|
|
git(seed, 'config', 'user.name', 't');
|
|
mkdirSync(join(seed, 'bin'));
|
|
writeFileSync(join(seed, 'bin/slug.mjs'), 'old\n');
|
|
git(seed, 'add', 'bin/slug.mjs');
|
|
git(seed, 'commit', '-q', '-m', 'base');
|
|
}
|
|
const dir = mkdtempSync(join(tmpdir(), 'voyage-park-'));
|
|
cpSync(seed, dir, { recursive: true });
|
|
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');
|
|
|
|
/** The CLI run with `--cwd <cwd>` (the repo by default) on `diff` written into the repo's git dir. */
|
|
function run(dir, diff = DIFF, cwd = dir) {
|
|
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' });
|
|
}
|
|
|
|
// 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 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 = run(dir, DIFF, 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 });
|
|
}
|
|
});
|
|
|
|
// --- fix round 4-5b: M7 ----------------------------------------------------------
|
|
// [M7] HEAD membership was one `git cat-file -e HEAD:<path>` per path, and any
|
|
// failure of it read as "not in HEAD": a git that could not read HEAD unindexed
|
|
// and moved the attempt's files. A path in HEAD but gone from the index was
|
|
// moved like an untracked file. Both now exit 2 before anything changes.
|
|
|
|
test('[M7] a failing HEAD read exits 2 and moves nothing', async () => {
|
|
const { pathWith } = await import('../helpers/bin-path.mjs');
|
|
const real = pathWith(['git']);
|
|
const fake = mkdtempSync(join(tmpdir(), 'voyage-fake-git-'));
|
|
const dir = repo();
|
|
try {
|
|
const realGit = join(real.path, 'git');
|
|
assert.ok(!realGit.includes("'"), 'control: the real git path carries no quote');
|
|
writeFileSync(join(fake, 'git'), [
|
|
'#!/bin/sh',
|
|
'for a in "$@"; do',
|
|
' if [ "$a" = ls-tree ]; then echo "fatal: ls-tree failed (fake git)" >&2; exit 128; fi',
|
|
'done',
|
|
`exec '${realGit}' "$@"`,
|
|
'',
|
|
].join('\n'), { mode: 0o755 });
|
|
attemptFiles(dir);
|
|
git(dir, 'add', '--intent-to-add', '--', 'tests/batch.sh', 'tests/cli-stdin.test.mjs');
|
|
const env = childEnv({ PATH: `${fake}${delimiter}${process.env.PATH ?? ''}` });
|
|
const ctl = spawnSync('git', ['ls-tree', 'HEAD'], { cwd: dir, encoding: 'utf8', env });
|
|
assert.equal(ctl.status, 128, `control: the fake git must fail ls-tree: ${ctl.stderr}`);
|
|
assert.equal(spawnSync('git', ['rev-parse', 'HEAD'], { cwd: dir, encoding: 'utf8', env }).status, 0, 'control: the fake git runs every other call');
|
|
writeFileSync(join(dir, '.git', 'upward.diff'), DIFF);
|
|
const r = spawnSync(process.execPath, [CLI, '--diff', join(dir, '.git', 'upward.diff'), '--step', '1', '--attempt', '1', '--cwd', dir], { encoding: 'utf8', env });
|
|
assert.equal(r.status, 2, `a HEAD read that failed must be exit 2: ${r.stdout}${r.stderr}`);
|
|
assert.match(r.stderr, /ls-tree/, 'the message does not name the git call that failed');
|
|
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 HEAD could not be read');
|
|
assert.ok(listed.includes('tests/cli-stdin.test.mjs'), 'the second intent-to-add entry was taken out of the index');
|
|
assert.equal(readFileSync(join(dir, 'tests/batch.sh'), 'utf8'), 'stale | cat\n', 'the file was moved although HEAD could not be read');
|
|
assert.equal(existsSync(join(dir, 'tests/cli-stdin.test.mjs')), true, 'the second file was moved');
|
|
assert.equal(existsSync(parkedPath(dir, 'tests/batch.sh')), false, 'a file was parked although HEAD could not be read');
|
|
} finally {
|
|
real.cleanup();
|
|
rmSync(fake, { recursive: true, force: true });
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('[M7] a HEAD path missing from the index exits 2 and moves nothing', () => {
|
|
const dir = repo();
|
|
try {
|
|
attemptFiles(dir);
|
|
git(dir, 'add', '--', 'tests/batch.sh');
|
|
git(dir, 'commit', '-q', '-m', 'operator');
|
|
git(dir, 'rm', '--cached', '-q', '--', 'tests/batch.sh');
|
|
assert.doesNotMatch(git(dir, 'ls-files'), /tests\/batch\.sh/, 'control: the committed file is out of the index');
|
|
assert.match(git(dir, 'ls-tree', '-r', '--name-only', 'HEAD'), /tests\/batch\.sh/, 'control: the file is in HEAD');
|
|
const r = run(dir);
|
|
assert.equal(r.status, 2, `a HEAD path missing from the index must be exit 2: ${r.stdout}${r.stderr}`);
|
|
assert.match(r.stderr, /tests\/batch\.sh/, 'the message does not name the path');
|
|
assert.equal(readFileSync(join(dir, 'tests/batch.sh'), 'utf8'), 'stale | cat\n', 'the committed file was moved');
|
|
assert.equal(existsSync(join(dir, 'tests/cli-stdin.test.mjs')), true, 'the untracked file was moved although the run could not finish');
|
|
assert.equal(existsSync(parkedPath(dir, 'tests/batch.sh')), false, 'the committed file was parked');
|
|
assert.equal(existsSync(parkedPath(dir, 'tests/cli-stdin.test.mjs')), false, 'a file was parked although the run could not finish');
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
/** The CLI run on `diff` written into the repo's git dir, its child given childEnv(). */
|
|
function runIsolated(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', env: childEnv() });
|
|
}
|
|
|
|
test('[M7] a corrupt branch ref is a failed HEAD read: exit 2, nothing unindexed or moved', () => {
|
|
const dir = repo();
|
|
try {
|
|
attemptFiles(dir);
|
|
git(dir, 'add', '--', 'tests/batch.sh');
|
|
git(dir, 'commit', '-q', '-m', 'operator');
|
|
const ref = git(dir, 'symbolic-ref', '-q', 'HEAD').trim();
|
|
writeFileSync(join(dir, '.git', ref), 'garbage\n');
|
|
const ctl = spawnSync('git', ['rev-parse', '--verify', '--quiet', 'HEAD'], { cwd: dir, encoding: 'utf8', env: childEnv() });
|
|
assert.equal(ctl.status, 1, `control: the corrupt ref reads like an unborn HEAD (exit 1): ${ctl.stderr}`);
|
|
assert.equal(ctl.stdout.trim(), '', 'control: and prints nothing');
|
|
const r = runIsolated(dir);
|
|
assert.equal(r.status, 2, `a corrupt branch ref must be exit 2: ${r.stdout}${r.stderr}`);
|
|
const listed = git(dir, 'ls-files').split('\n');
|
|
assert.ok(listed.includes('tests/batch.sh'), 'the committed file was taken out of the index although HEAD could not be read');
|
|
assert.equal(readFileSync(join(dir, 'tests/batch.sh'), 'utf8'), 'stale | cat\n', 'the committed file was moved');
|
|
assert.equal(existsSync(join(dir, 'tests/cli-stdin.test.mjs')), true, 'the untracked file was moved although HEAD could not be read');
|
|
assert.equal(existsSync(parkedPath(dir, 'tests/batch.sh')), false, 'the committed file was parked');
|
|
assert.equal(existsSync(parkedPath(dir, 'tests/cli-stdin.test.mjs')), false, 'a file was parked although HEAD could not be read');
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('[M7] an unborn HEAD parks the attempt\'s files', () => {
|
|
const dir = mkdtempSync(join(tmpdir(), 'voyage-park-unborn-'));
|
|
try {
|
|
git(dir, 'init', '-q');
|
|
attemptFiles(dir);
|
|
git(dir, 'add', '--intent-to-add', '--', 'tests/batch.sh');
|
|
assert.match(git(dir, 'ls-files'), /tests\/batch\.sh/, 'control: the intent-to-add file is in the index');
|
|
const ctl = spawnSync('git', ['rev-parse', '--verify', '--quiet', 'HEAD'], { cwd: dir, encoding: 'utf8', env: childEnv() });
|
|
assert.equal(ctl.status, 1, 'control: HEAD is unborn');
|
|
const r = runIsolated(dir);
|
|
assert.equal(r.status, 0, `an unborn HEAD must park: ${r.stdout}${r.stderr}`);
|
|
assert.deepEqual(JSON.parse(r.stdout).parked, ['tests/batch.sh', 'tests/cli-stdin.test.mjs']);
|
|
assert.doesNotMatch(git(dir, 'ls-files'), /tests\/batch\.sh/, 'the intent-to-add entry is still in the index');
|
|
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 untracked file is still in the work tree');
|
|
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');
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
// --- fix round 4-5b: E22c --------------------------------------------------------
|
|
// [E22c] The diff was split on '\n' only: in a CRLF diff every line kept its '\r',
|
|
// so no `new file mode` line matched and nothing was parked, with exit 0.
|
|
|
|
test('[E22c] a CRLF diff parks its new files', async () => {
|
|
const mod = await import('../../lib/util/park-attempt-files.mjs');
|
|
assert.equal(typeof mod.newFilesInDiff, 'function', 'newFilesInDiff is not exported');
|
|
const crlf = DIFF.split('\n').join('\r\n');
|
|
assert.ok(crlf.includes('new file mode 100755\r\n'), 'control: the diff has CRLF line ends');
|
|
assert.deepEqual(mod.newFilesInDiff(crlf).paths, ['tests/batch.sh', 'tests/cli-stdin.test.mjs'],
|
|
'a CRLF diff did not yield its new files');
|
|
const dir = repo();
|
|
try {
|
|
attemptFiles(dir);
|
|
const r = run(dir, crlf);
|
|
assert.equal(r.status, 0, r.stdout + r.stderr);
|
|
assert.deepEqual(JSON.parse(r.stdout).parked, ['tests/batch.sh', 'tests/cli-stdin.test.mjs'], `a CRLF diff parked nothing: ${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', 'the file is not under the parked directory');
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
// --- fix round 4-5b: M3 ----------------------------------------------------------
|
|
// [M3] The "Back to planning" save line ran `git diff` as the user's config had
|
|
// it: with diff.relative on and the session in a subdirectory, the saved diff
|
|
// held only that subdirectory's files, with paths relative to it, so the park
|
|
// tool missed every new file outside it.
|
|
|
|
/** The command of the one "Back to planning" save line in commands/trekexecute.md, as written. */
|
|
function saveLine() {
|
|
const md = readFileSync(join(ROOT, 'commands/trekexecute.md'), 'utf8');
|
|
const lines = md.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}`);
|
|
const m = lines[0].match(/save `([^`]+)`/);
|
|
assert.ok(m, `no backticked command on the save line: ${lines[0]}`);
|
|
return m[1];
|
|
}
|
|
|
|
test('[M3] the save line, read from the execute command as written, parks every file from a subdirectory with diff.relative on', () => {
|
|
const dir = repo();
|
|
try {
|
|
git(dir, 'config', 'diff.relative', 'true');
|
|
mkdirSync(join(dir, 'sub'));
|
|
writeFileSync(join(dir, 'sub/keep.txt'), 'kept\n');
|
|
git(dir, 'add', 'sub/keep.txt');
|
|
git(dir, 'commit', '-q', '-m', 'sub');
|
|
const start = git(dir, 'rev-parse', 'HEAD').trim();
|
|
attemptFiles(dir);
|
|
writeFileSync(join(dir, 'sub/new-in-sub.txt'), 'stale sub\n');
|
|
git(dir, 'add', '--intent-to-add', '--', 'tests/batch.sh', 'tests/cli-stdin.test.mjs', 'sub/new-in-sub.txt');
|
|
const values = { start_sha: start };
|
|
const cmd = saveLine().replace(/\{([a-z_ ]+)\}/g, (all, key) => (key in values ? values[key] : all));
|
|
assert.doesNotMatch(cmd, /\{[^}]*\}/, `the save line holds a placeholder this test does not fill: ${cmd}`);
|
|
const out = join(dir, '.git', 'upward.diff');
|
|
const sub = join(dir, 'sub');
|
|
const saved = spawnSync('bash', ['-c', `${cmd} > "$1"`, 'save', out], { cwd: sub, encoding: 'utf8', env: childEnv() });
|
|
assert.equal(saved.status, 0, `the save line failed from sub/: ${saved.stderr}`);
|
|
const r = spawnSync(process.execPath, [CLI, '--diff', out, '--step', '1', '--attempt', '1', '--cwd', sub], { encoding: 'utf8', env: childEnv() });
|
|
assert.equal(r.status, 0, r.stdout + r.stderr);
|
|
assert.deepEqual(JSON.parse(r.stdout).parked, ['sub/new-in-sub.txt', 'tests/batch.sh', 'tests/cli-stdin.test.mjs'],
|
|
`the diff saved from sub/ with diff.relative on did not park every new file: ${r.stdout}`);
|
|
assert.equal(existsSync(join(dir, 'tests/batch.sh')), false, 'a new file outside sub/ is still in the tree');
|
|
assert.equal(existsSync(join(dir, 'sub/new-in-sub.txt')), false, 'a new file inside sub/ is still in the tree');
|
|
assert.equal(readFileSync(parkedPath(dir, 'sub/new-in-sub.txt'), 'utf8'), 'stale sub\n', 'the file is not under the parked directory');
|
|
assert.equal(readFileSync(join(dir, 'sub/keep.txt'), 'utf8'), 'kept\n', 'a committed file is never touched');
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|