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:
parent
773aa44dc9
commit
7869c4cbae
5 changed files with 486 additions and 15 deletions
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue