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:
parent
5ddbcd3de6
commit
bf4736a254
4 changed files with 190 additions and 15 deletions
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue