246 lines
12 KiB
JavaScript
246 lines
12 KiB
JavaScript
#!/usr/bin/env node
|
|
// lib/util/park-attempt-files.mjs
|
|
// A step sent back to the plan (`fix_rounds` 4) is reverted with its new files
|
|
// left on disk, untracked: nothing the step made is deleted. On --resume the
|
|
// re-planned step is built fresh, but the stale files were still there, and in
|
|
// voyage-lekerepo trial 5 (finding 4) an old tests/batch.sh holding the
|
|
// rejected line would have passed Verify, the manifest and the success
|
|
// criterion. Only the plan critic caught it.
|
|
//
|
|
// Before the fresh build the executor parks them: it parks every file new in
|
|
// the attempt's diff (a `new file mode` entry in step-{N}-upward.diff) that is
|
|
// untracked, or staged but not in HEAD; it moves to
|
|
// <git dir>/voyage/parked/step-<N>-attempt-<k>/ 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. HEAD
|
|
// is read once, before anything changes (`git rev-parse --verify --quiet HEAD`,
|
|
// then `git ls-tree --full-tree -r -z --name-only HEAD`; an unborn HEAD holds
|
|
// nothing). HEAD counts as unborn only when it names a branch that is stored
|
|
// nowhere (no loose ref file, no packed-refs line): a corrupt branch ref looks
|
|
// the same to `rev-parse` but exists, and is a failed HEAD read (exit 2, nothing
|
|
// unindexed or moved). `ls-tree` reads tree entries, not blobs: a HEAD path whose blob is
|
|
// missing from the object store is still in HEAD, and kept. 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 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, the
|
|
// HEAD read that failed (a corrupt branch ref too), a path in HEAD that is missing from the index, an
|
|
// index entry that could not be removed; nothing is unindexed or 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). 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;
|
|
let sawHeader = false;
|
|
const lines = String(diffText).split(/\r?\n/).map((l) => l.replace(ANSI_RE, ''));
|
|
for (const line of lines) {
|
|
if (line.startsWith('diff --git ')) {
|
|
sawHeader = true;
|
|
header = line;
|
|
continue;
|
|
}
|
|
if (header !== null && /^new file mode \d+$/.test(line)) {
|
|
// Both names are the same for a new file, so a space in it is read too.
|
|
const m = header.match(/^diff --git a\/(.+) b\/\1$/);
|
|
if (m) paths.push(m[1]);
|
|
else unread.push(header);
|
|
header = null;
|
|
}
|
|
}
|
|
const first = lines.find((l) => l.trim() !== '');
|
|
if (!sawHeader && 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 HEAD unborn? Only when HEAD points at a branch ref R (`symbolic-ref -q
|
|
* HEAD` exit 0) and R is stored nowhere: no loose ref file
|
|
* (`rev-parse --git-path R`) and no line for R in packed-refs. A corrupt branch
|
|
* ref (garbage in the loose file, or a bad packed line) makes
|
|
* `rev-parse --verify --quiet HEAD` exit 1 with no output just like an unborn
|
|
* HEAD, and so does `show-ref --verify --quiet R` (measured on git 2.50.1), so
|
|
* the exit codes alone cannot tell them apart; where R is stored can.
|
|
*/
|
|
function headIsUnborn(root) {
|
|
const sym = spawnSync('git', ['-C', root, 'symbolic-ref', '-q', 'HEAD'], { encoding: 'utf8' });
|
|
if (sym.error || sym.status !== 0) return false;
|
|
const ref = String(sym.stdout ?? '').trim();
|
|
if (!/^refs\/heads\/./.test(ref)) return false;
|
|
// One call, one output line per --git-path, in argument order.
|
|
const [loosePath = '', packedPath = ''] = git(root, ['rev-parse', '--git-path', ref, '--git-path', 'packed-refs']).split('\n');
|
|
if (lexists(resolve(root, loosePath.trim()))) return false;
|
|
const packed = resolve(root, packedPath.trim());
|
|
if (lexists(packed)) {
|
|
const lines = readFileSync(packed, 'utf8').split('\n');
|
|
if (lines.some((l) => l.endsWith(` ${ref}`))) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Every path in HEAD (root-relative), read once before anything changes. An
|
|
* unborn HEAD (`rev-parse --verify --quiet HEAD` exit 1 with no output, and
|
|
* headIsUnborn: the branch HEAD names is stored nowhere) is an empty set; a
|
|
* branch ref that exists but cannot be read, and any other failure of either
|
|
* call, throws (exit 2). `ls-tree` reads tree entries, so a path whose blob is
|
|
* missing from the object store is still "in HEAD".
|
|
*/
|
|
function headPaths(root) {
|
|
const args = ['rev-parse', '--verify', '--quiet', 'HEAD'];
|
|
const r = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8' });
|
|
if (r.error) throw new Error(`git ${args.join(' ')}: ${r.error.message}`);
|
|
if (r.status === 1 && String(r.stdout ?? '').trim() === '') {
|
|
if (headIsUnborn(root)) return new Set();
|
|
throw new Error(`git ${args.join(' ')} exited 1, and the branch HEAD names exists: a corrupt or unreadable HEAD ref`);
|
|
}
|
|
if (r.status !== 0) throw new Error(`git ${args.join(' ')} exited ${r.status}: ${String(r.stderr ?? '').trim().split('\n')[0]}`);
|
|
return new Set(git(root, ['ls-tree', '--full-tree', '-r', '-z', '--name-only', 'HEAD']).split('\0').filter(Boolean));
|
|
}
|
|
|
|
function lexists(abs) {
|
|
try {
|
|
lstatSync(abs);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {{ diffText: string, step: string|number, attempt: string|number, cwd?: string }} args
|
|
* @returns {{ parked: string[], kept: Array<{path: string, reason: string, blocking: boolean}>, to: string }}
|
|
*/
|
|
export function parkAttemptFiles({ diffText, step, attempt, cwd = process.cwd() }) {
|
|
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(git(root, ['ls-files', '-z']).split('\0').filter(Boolean));
|
|
const inHead = headPaths(root);
|
|
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 = [];
|
|
const headNotIndexed = [];
|
|
for (const path of paths) {
|
|
const abs = resolve(root, path);
|
|
const rel = relative(root, abs);
|
|
if (isAbsolute(path) || rel === '' || rel.startsWith('..') || isAbsolute(rel) || rel.split(/[\\/]/)[0] === '.git') {
|
|
kept.push({ path, reason: 'outside the repository: refused', blocking: false });
|
|
continue;
|
|
}
|
|
if (!lexists(abs)) {
|
|
kept.push({ path, reason: 'not on disk: nothing to park', blocking: false });
|
|
continue;
|
|
}
|
|
const posix = rel.split('\\').join('/');
|
|
if (inHead.has(posix)) {
|
|
if (trackedPaths.has(posix)) {
|
|
kept.push({ path, reason: 'tracked since the attempt: not the executor\'s to move', blocking: false });
|
|
} else {
|
|
// Committed, then taken out of the index: not the attempt's to move.
|
|
headNotIndexed.push(posix);
|
|
}
|
|
continue;
|
|
}
|
|
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 });
|
|
// In the index but not in HEAD: registered --intent-to-add by the attempt.
|
|
if (trackedPaths.has(posix)) unindex.push(posix);
|
|
}
|
|
// A path in HEAD that is missing from the index: nothing is unindexed or moved.
|
|
if (headNotIndexed.length > 0) {
|
|
throw new Error(`in HEAD but missing from the index, nothing moved: ${headNotIndexed.join(', ')}`);
|
|
}
|
|
// 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);
|
|
parked.push(rel);
|
|
} catch (e) {
|
|
kept.push({ path, reason: `could not move: ${e.message}`, blocking: true });
|
|
}
|
|
}
|
|
return { parked, kept, to };
|
|
}
|
|
|
|
if (isMain(import.meta.url)) {
|
|
const argv = process.argv.slice(2);
|
|
const opt = (name) => {
|
|
const i = argv.indexOf(name);
|
|
return i === -1 ? undefined : argv[i + 1];
|
|
};
|
|
const diff = opt('--diff');
|
|
const step = opt('--step');
|
|
const attempt = opt('--attempt');
|
|
const cwd = opt('--cwd') ?? process.cwd();
|
|
if (!diff || !/^[0-9]+$/.test(step ?? '') || !/^[0-9]+$/.test(attempt ?? '')) {
|
|
process.stderr.write('Usage: park-attempt-files.mjs --diff <step-N-upward.diff> --step <N> --attempt <k> [--cwd <repo>]\n');
|
|
process.exit(2);
|
|
}
|
|
let diffText;
|
|
try {
|
|
diffText = readFileSync(diff, 'utf8');
|
|
} catch (e) {
|
|
process.stderr.write(`park-attempt-files: cannot read ${diff}: ${e.message}\n`);
|
|
process.exit(2);
|
|
}
|
|
if (spawnSync('git', ['rev-parse', '--git-dir'], { cwd, encoding: 'utf8' }).status !== 0) {
|
|
process.stderr.write(`park-attempt-files: ${cwd} is not a git repository\n`);
|
|
process.exit(2);
|
|
}
|
|
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);
|
|
}
|