fix(park): read HEAD membership once and move nothing when it cannot be read

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-10-26 14:36:03 +01:00
commit bf4736a254
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
4 changed files with 190 additions and 15 deletions

View file

@ -15,7 +15,14 @@
// 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
// 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.
//
@ -25,9 +32,10 @@
// 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, an
// index entry that could not be removed; nothing is moved). Neither 1 nor 2
// is ever "the tree is clean".
// (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';
@ -76,11 +84,47 @@ function git(root, args, opts = {}) {
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;
/**
* 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;
if (lexists(resolve(root, git(root, ['rev-parse', '--git-path', ref]).trim()))) return false;
const packed = resolve(root, git(root, ['rev-parse', '--git-path', 'packed-refs']).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) {
@ -101,11 +145,13 @@ export function parkAttemptFiles({ diffText, step, attempt, cwd = process.cwd()
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);
@ -118,8 +164,13 @@ export function parkAttemptFiles({ diffText, step, attempt, cwd = process.cwd()
continue;
}
const posix = rel.split('\\').join('/');
if (trackedPaths.has(posix) && inHead(root, posix)) {
kept.push({ path, reason: 'tracked since the attempt: not the executor\'s to move', blocking: false });
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);
@ -131,6 +182,10 @@ export function parkAttemptFiles({ diffText, step, attempt, cwd = process.cwd()
// 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.