Merge pull request #3106 from open-gsd/test/3057-wave3-negative-space

fix(#3057): reach the branches nothing could reach, and stop reaping on a PID we never probed — Wave 3
This commit is contained in:
Tom Boucher
2026-08-06 03:10:37 -04:00
committed by GitHub
6 changed files with 1664 additions and 114 deletions

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 3106
---
**A worktree whose owner could not be probed is no longer deleted** — an orphan lock holding a process id above 2147483647 made the liveness check throw a type error rather than an errno error, which read as "owner is dead" and removed the worktree. Only "no such process" now means dead; every unrecognized outcome leaves the worktree alone. An unreadable lock timestamp also reported "too fresh", advising a wait that could never help, and now reports its own reason. (#3103)

View File

@@ -136,7 +136,8 @@ export function tryRemoteShow(
const branch = m[1];
// git emits "(unknown)" when the remote is offline but the local cache
// resolved it; treat that as non-authoritative and fall through.
if (!branch || branch === '(unknown)') return null;
// No `!branch ||` guard: m[1] comes from the `(\S+)` capture group above, so it is never empty.
if (branch === '(unknown)') return null;
return branch;
} catch {
return null;
@@ -271,9 +272,13 @@ export interface GitWorktreeInfo {
* Detect whether `cwd` sits inside a git worktree, and if so, return the
* absolute path of the worktree root.
*/
export function gitWorktreeInfoInternal(cwd: string): GitWorktreeInfo {
export function gitWorktreeInfoInternal(
cwd: string,
deps?: Pick<BaseBranchDeps, 'execGit'>
): GitWorktreeInfo {
const execGit: ExecGitFn = deps?.execGit ?? execGitSeam;
try {
const insideResult = execGitSeam(['rev-parse', '--is-inside-work-tree'], { cwd, timeout: 5000 });
const insideResult = execGit(['rev-parse', '--is-inside-work-tree'], { cwd, timeout: 5000 });
if (insideResult.exitCode !== 0) {
return { inside: false, worktreeRoot: null };
}
@@ -281,7 +286,7 @@ export function gitWorktreeInfoInternal(cwd: string): GitWorktreeInfo {
if (insideStdout !== 'true') {
return { inside: false, worktreeRoot: null };
}
const rootResult = execGitSeam(['rev-parse', '--show-toplevel'], { cwd, timeout: 5000 });
const rootResult = execGit(['rev-parse', '--show-toplevel'], { cwd, timeout: 5000 });
if (rootResult.exitCode !== 0) {
return { inside: true, worktreeRoot: null };
}

View File

@@ -1724,8 +1724,21 @@ function reapOrphanWorktrees(repoRoot: string, deps: WorktreeDeps = {}): ReapRes
}
// 4a. Stale-lock guard: skip if lock is too fresh (PID recycling / race).
//
// The two causes are reported SEPARATELY (#3057). A lock whose mtime could
// not be read is not "fresh" in any sense: `lock_too_fresh` tells an
// operator that waiting will resolve the skip, and waiting never resolves a
// stat failure — the lock could be seconds or months old and the sweep has
// no way to tell. Conflating them is the same defect this module already
// fixed for `parse_failed` vs `no_worktrees` in planWorktreePrune: a
// decision made on unread data must not be indistinguishable from one made
// on real data.
const lockMtime = mtimeSafe(lockedFile);
if (!lockMtime || nowMs - lockMtime.getTime() < reapMtimeGuardMs) {
if (!lockMtime) {
results.push({ path: worktreePath, status: 'skipped', reason: 'lock_age_unknown' });
continue;
}
if (nowMs - lockMtime.getTime() < reapMtimeGuardMs) {
results.push({ path: worktreePath, status: 'skipped', reason: 'lock_too_fresh' });
continue;
}
@@ -1737,9 +1750,26 @@ function reapOrphanWorktrees(repoRoot: string, deps: WorktreeDeps = {}): ReapRes
continue;
}
const pid = parseInt(pidStr, 10);
// Number.isFinite, not Number.isNaN: pidStr is captured by /^\d+/ above, so
// pid can never be NaN. A 309-or-more-digit string parses to Infinity.
//
// NOT LOAD-BEARING FOR SAFETY — do not delete it as redundant. Fail-closed
// liveness now lives in defaultIsPidAlive, which treats every non-ESRCH
// outcome (including the TypeError process.kill throws for Infinity) as
// ALIVE. This guard survives because it produces a more ACCURATE verdict
// for garbage input: `lock_owner_unknown` says "the lock names a PID this
// parse could not represent", whereas falling through would report
// `pid_alive` — an assertion about an owner that was never probed.
// Note this is an EARLIER, DIFFERENT gate than the process.kill range
// limit: process.kill accepts up to 2147483647 and rejects 2147483648
// (measured), far below the parse cliff this guard catches.
if (!Number.isFinite(pid)) {
results.push({ path: worktreePath, status: 'skipped', reason: 'lock_owner_unknown' });
continue;
}
let pidIsAlive: boolean;
try {
pidIsAlive = Number.isNaN(pid) || isPidAliveCheck(pid);
pidIsAlive = isPidAliveCheck(pid);
} catch {
pidIsAlive = true; // Cannot determine liveness — treat as alive, do not reap.
}
@@ -1803,13 +1833,28 @@ function reapOrphanWorktrees(repoRoot: string, deps: WorktreeDeps = {}): ReapRes
// ─── reapOrphanWorktrees deps helpers ─────────────────────────────────────────
/**
* Liveness probe for a lock-owner PID — FAILS CLOSED (#3057).
*
* `ESRCH` ("no such process") is the ONLY outcome that proves the owner is
* gone. Every other failure means the probe could not determine liveness:
* - `EPERM` — the process exists, we just may not signal it;
* - `TypeError` / `ERR_INVALID_ARG_TYPE` — `process.kill` accepts a pid up
* to 2147483647 and REJECTS 2147483648 and above (measured), so a finite
* but out-of-range pid never reaches the OS at all;
* - anything else — an outcome this helper does not recognise.
*
* The return value feeds a DESTRUCTIVE decision (`git worktree remove
* --force`), so an unrecognised failure must never read as "dead". Hence the
* inversion: only ESRCH returns false; everything else returns true (alive,
* do not reap).
*/
function defaultIsPidAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (err) {
if (err && (err as NodeJS.ErrnoException).code === 'EPERM') return true;
return false;
return (err as NodeJS.ErrnoException | null | undefined)?.code !== 'ESRCH';
}
}
@@ -1825,21 +1870,23 @@ function defaultMtimeSafe(file: string): Date | null {
try { return fs.statSync(file).mtime; } catch { return null; }
}
function cmdWorktreeReapOrphans(cwd: string): void {
function cmdWorktreeReapOrphans(cwd: string, deps: RecordAgentCmdDeps & WorktreeDeps = {}): void {
const write = deps.write || ((s: string) => process.stdout.write(s));
const writeErr = deps.writeErr || ((s: string) => process.stderr.write(s));
let result: ReapResult[];
try {
result = reapOrphanWorktrees(cwd);
result = reapOrphanWorktrees(cwd, deps);
} catch (err) {
// Surface failure as a one-line warning; keep exit-zero so workflows don't break.
process.stderr.write(`[gsd] worktree.reap-orphans failed: ${err && (err as Error).message ? (err as Error).message : String(err)}\n`);
writeErr(`[gsd] worktree.reap-orphans failed: ${err && (err as Error).message ? (err as Error).message : String(err)}\n`);
result = [];
}
const skippedCount = result.filter((r) => r.status === 'skipped').length;
if (skippedCount > 0) {
// Surface skipped entries so operators are aware of unresolved orphans.
process.stderr.write(`[gsd] worktree.reap-orphans: ${skippedCount} orphan(s) skipped (run with DEBUG=1 for details)\n`);
writeErr(`[gsd] worktree.reap-orphans: ${skippedCount} orphan(s) skipped (run with DEBUG=1 for details)\n`);
}
process.stdout.write(`${JSON.stringify({ ok: true, reaped: result.filter((r) => r.status === 'reaped').length, entries: result }, null, 2)}\n`);
write(`${JSON.stringify({ ok: true, reaped: result.filter((r) => r.status === 'reaped').length, entries: result }, null, 2)}\n`);
}
// Unused exports kept for API compatibility
@@ -1875,16 +1922,23 @@ function resolveWorktreeRoot(cwd: string, deps: WorktreeDeps = {}): { root: stri
* the repository; used as `cwd` for git commands.
* @returns list of worktree paths that were removed (always empty)
*/
function pruneOrphanedWorktrees(repoRoot: string): string[] {
function pruneOrphanedWorktrees(repoRoot: string, deps: WorktreeDeps & { writeErr?: (s: string) => void } = {}): string[] {
const writeErr = deps.writeErr || ((s: string) => process.stderr.write(s));
try {
// `...deps` comes LAST deliberately: `parseWorktreePorcelain` is a declared
// member of WorktreeDeps and planWorktreePrune already reads
// `deps.parseWorktreePorcelain` before falling back to the module function,
// so a caller-supplied parser is an intended override, not an accident.
// The hard-coded key is only a restatement of that same default. Do not
// reorder the two — `tests/worktree-safety-reap.test.cjs` pins the override.
const plan = planWorktreePrune(
repoRoot,
{ allowDestructive: false },
{ parseWorktreePorcelain }
{ parseWorktreePorcelain, ...deps }
);
const pruneResult = executeWorktreePrunePlan(plan) as { timedOut?: boolean } | null;
const pruneResult = executeWorktreePrunePlan(plan, deps) as { timedOut?: boolean } | null;
if (pruneResult && pruneResult.timedOut) {
process.stderr.write(
writeErr(
'[gsd-tools] WARNING: worktree health check degraded' +
' — git worktree prune timed out after 10s.' +
' Orphaned worktree metadata may remain until the next successful run.\n'

View File

@@ -283,9 +283,24 @@ describe('#1268 gitWorktreeInfoInternal: relocation to git-base-branch', () => {
const dir = createTempGitProject('gsd-wt-info-');
t.after(() => cleanup(dir));
const result = gitBaseBranch.gitWorktreeInfoInternal(dir);
// `git rev-parse --show-toplevel` reports the resolved (symlink-free) path,
// which on macOS differs from the mkdtemp path (/var → /private/var). Pin the
// exact value rather than "a non-empty string": a resolver that returned the
// .git dir, the cwd, or any other plausible-looking path would pass the weaker
// shape check while being wrong.
//
// git always reports POSIX forward slashes, on every platform including
// Windows, while `fs.realpathSync.native` returns the platform's native
// form (backslashes on Windows). The expected side must therefore be
// normalized to git's convention rather than compared to the raw native
// realpath, or the assertion just encodes the separator convention of
// whatever platform it was written on. This is separators only — POSIX's
// `replace` is a no-op there, so the assertion keeps its full strength on
// POSIX. The remote gsd-test matrix is Linux-only and cannot exercise this
// path; it only surfaced on the Windows GitHub Actions shard.
assert.strictEqual(result.inside, true, 'inside must be true for a git project dir');
assert.ok(typeof result.worktreeRoot === 'string' && result.worktreeRoot.length > 0,
`worktreeRoot must be a non-empty string, got: ${JSON.stringify(result.worktreeRoot)}`);
assert.strictEqual(result.worktreeRoot, fs.realpathSync.native(dir).replace(/\\/g, '/'),
'worktreeRoot must be the resolved worktree root path');
});
test('gitWorktreeInfoInternal(createTempDir()) returns {inside:false, worktreeRoot:null} for a non-git dir', (t) => {
@@ -296,11 +311,12 @@ describe('#1268 gitWorktreeInfoInternal: relocation to git-base-branch', () => {
assert.strictEqual(result.worktreeRoot, null, 'worktreeRoot must be null for a non-git dir');
});
test('gitWorktreeInfoInternal never throws (non-git dir)', (t) => {
const dir = createTempDir('gsd-wt-info-nothrow-');
t.after(() => cleanup(dir));
assert.doesNotThrow(() => gitBaseBranch.gitWorktreeInfoInternal(dir));
});
// NOTE: the former "never throws" liveness test that sat here was replaced
// (#3057 W3). "It did not throw" is satisfied by a function that returns
// undefined, the wrong branch, or nothing useful at all. The
// `execGit throws → {inside:false, worktreeRoot:null}` test below asserts the
// exact value the catch arm is contracted to produce, which is what the old
// test was gesturing at.
});
// ─── #3057 B4: last-resort "main" — verified vs unverified ───────────────────
@@ -365,7 +381,11 @@ describe('#3057 B4: resolveBaseBranchDiagnostics — verified vs unverified last
writeDiagnostic: (s) => { stderrText += s; },
});
assert.strictEqual(stdoutText, 'main\n');
assert.ok(stderrText.length > 0, 'the unverified fallback must write a stderr diagnostic');
assert.strictEqual(
stderrText,
`⚠ git-base-branch: defaulted to 'main' WITHOUT verifying against this repository — ` +
`a git query timed out or could not run. See #3057.\n`,
);
stdoutText = '';
stderrText = '';
@@ -379,6 +399,482 @@ describe('#3057 B4: resolveBaseBranchDiagnostics — verified vs unverified last
});
});
// ─── #3057 W3: negative-space coverage for the resolver's failure arms ───────
//
// Everything below drives the *unhappy* halves of git-base-branch: malformed
// config, git output that parses but says nothing useful, git that cannot run
// at all, and a repository with no work tree. Each test asserts the exact value
// the arm is contracted to produce — never "it did not throw", never a shape
// check — because an arm that silently returns `undefined` instead of `null`
// changes the precedence ladder's behaviour while passing any weaker assertion.
/**
* Build a result object shaped exactly like `execGit`'s (see `_spawnResult` in
* shell-command-projection). Defaults are a benign, completed, zero-exit call.
*/
function gitResult(overrides) {
return {
exitCode: 0,
stdout: '',
stderr: '',
signal: null,
error: null,
timedOut: false,
...overrides,
};
}
/** An `execGit` stand-in that always returns the same shaped result. */
function constGit(overrides) {
return () => gitResult(overrides);
}
/**
* An `execGit` stand-in that throws. `makeFaultyGit` deliberately never throws
* (it returns a shaped failure result), so the resolver's `catch` arms need
* this instead.
*/
function throwingGit(message) {
return () => { throw new Error(message); };
}
describe('#3057 W3: readConfigBaseBranch — config present but unusable', () => {
const PLANNING_DIR = path.join(path.sep, 'gsd-3057-w3', '.planning');
/** Read a config whose raw text is `raw`, recording the paths requested. */
function readWith(raw, seenPaths) {
return gitBaseBranch.readConfigBaseBranch(PLANNING_DIR, {
readFile: (p) => { if (seenPaths) seenPaths.push(p); return raw; },
});
}
test('config.json exists but is not valid JSON → null (parse failure swallowed)', () => {
const seen = [];
assert.strictEqual(readWith('{ not json', seen), null);
assert.deepStrictEqual(seen, [path.join(PLANNING_DIR, 'config.json')],
'the resolver must look for config.json inside the planning dir it was given');
});
test('config.json parses to a non-object → null for null / string / number / array', () => {
// NOTE on the `[]` case: this documents observed behaviour only. It does
// NOT pin the `Array.isArray(cfg)` guard in readConfigBaseBranch — that
// guard is unreachable (and therefore unkillable) through this readFile
// entry point. `cfg` is always the result of `JSON.parse(raw)` on a
// string, and a JSON array can never carry a `.git` or `.base_branch`
// own-property the way a hand-built JS array could; with the guard
// deleted entirely, `top.git`/`top.base_branch` on an array are still
// `undefined`, so the result is `null` either way. Verified by mutation:
// deleting `|| Array.isArray(cfg)` from the built lib does not change any
// output for any JSON-string input. The guard is real defense-in-depth
// for a future non-JSON-string caller, not something this suite can pin.
assert.strictEqual(readWith('null'), null, 'JSON null must not be treated as a config');
assert.strictEqual(readWith('"master"'), null, 'a bare JSON string must not be treated as a config');
assert.strictEqual(readWith('42'), null, 'a bare JSON number must not be treated as a config');
assert.strictEqual(readWith('[]'), null, 'a JSON array must not be treated as a config');
});
test('"git" section present but base_branch missing / non-string / blank → null', () => {
assert.strictEqual(readWith('{"git":{}}'), null);
assert.strictEqual(readWith('{"git":{"base_branch":42}}'), null);
assert.strictEqual(readWith('{"git":{"base_branch":null}}'), null);
assert.strictEqual(readWith('{"git":{"base_branch":""}}'), null);
assert.strictEqual(readWith('{"git":{"base_branch":" "}}'), null,
'a whitespace-only override must not win the precedence ladder');
});
test('"git" key present but not a usable object (string/array/null) → nested lookup finds nothing, flat legacy key still consulted', () => {
// NOTE on the `"git":[]` case: like the sibling note above, this does NOT
// pin `!Array.isArray(gitSection)`. `gitSection` here is a JSON-parsed
// array with no `.base_branch` own-property, so `gitSection.base_branch`
// is `undefined` whether or not the guard runs — the flat key is
// consulted either way. Verified by mutation: deleting
// `&& !Array.isArray(gitSection)` from the built lib does not change this
// output for any JSON-string input.
assert.strictEqual(readWith('{"git":"main","base_branch":"release"}'), 'release');
assert.strictEqual(readWith('{"git":[],"base_branch":"release"}'), 'release');
assert.strictEqual(readWith('{"git":null,"base_branch":"release"}'), 'release');
});
test('flat base_branch present but non-string / blank → null', () => {
assert.strictEqual(readWith('{"base_branch":true}'), null);
assert.strictEqual(readWith('{"base_branch":["main"]}'), null);
assert.strictEqual(readWith('{"base_branch":""}'), null);
assert.strictEqual(readWith('{"base_branch":" "}'), null);
});
test('config parses cleanly but carries neither key → null (distinct from an absent file)', () => {
// The absent-file path returns null after reading an empty string and never
// reaches JSON.parse. This one parses a real object and falls all the way
// through both key lookups to the final return.
assert.strictEqual(readWith('{"other":1}'), null);
assert.strictEqual(readWith('{}'), null);
assert.strictEqual(readWith(''), null, 'absent file (empty read) also yields null');
});
test('positive controls: values are trimmed, and the nested key outranks the flat one', () => {
assert.strictEqual(readWith('{"git":{"base_branch":" develop "}}'), 'develop');
assert.strictEqual(readWith('{"base_branch":" release\\n"}'), 'release');
assert.strictEqual(readWith('{"git":{"base_branch":"nested"},"base_branch":"flat"}'), 'nested');
});
});
describe('#3057 W3: trySymbolicRef — tier-2 output that resolves to nothing', () => {
test('stdout is exactly "origin/" → null (prefix strip leaves an empty name)', () => {
assert.strictEqual(gitBaseBranch.trySymbolicRef('/x', constGit({ stdout: 'origin/' })), null);
assert.strictEqual(gitBaseBranch.trySymbolicRef('/x', constGit({ stdout: 'origin/\n' })), null);
});
test('only ONE leading "origin/" is stripped — slashes inside the name survive', () => {
assert.strictEqual(
gitBaseBranch.trySymbolicRef('/x', constGit({ stdout: 'origin/feature/long-name\n' })),
'feature/long-name');
assert.strictEqual(
gitBaseBranch.trySymbolicRef('/x', constGit({ stdout: 'origin/origin/main\n' })),
'origin/main');
});
test('execGit THROWS → null (catch arm; makeFaultyGit cannot reach this)', () => {
assert.strictEqual(gitBaseBranch.trySymbolicRef('/x', throwingGit('symbolic-ref exploded')), null);
});
test('the tier-2 subprocess is bounded (argv + timeout are pinned)', () => {
const seen = [];
gitBaseBranch.trySymbolicRef('/some/cwd', (args, opts) => {
seen.push({ args, opts });
return gitResult({ stdout: 'origin/main\n' });
});
assert.deepStrictEqual(seen, [{
args: ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'],
opts: { cwd: '/some/cwd', timeout: 5_000 },
}]);
});
});
describe('#3057 W3: tryRemoteShow — tier-3 output that is present but not authoritative', () => {
const REMOTE_SHOW_NO_HEAD = [
'* remote origin',
' Fetch URL: /tmp/origin.git',
' Push URL: /tmp/origin.git',
' Remote branch:',
' main tracked',
'',
].join('\n');
test('stdout has no "HEAD branch:" line → null', () => {
assert.strictEqual(
gitBaseBranch.tryRemoteShow('/x', constGit({ stdout: REMOTE_SHOW_NO_HEAD })), null);
});
test('"HEAD branch:" with no value on the line → null (no capture, no guess)', () => {
assert.strictEqual(
gitBaseBranch.tryRemoteShow('/x', constGit({ stdout: ' HEAD branch: \n' })), null);
});
test('"HEAD branch: (unknown)" → null — the documented offline-remote case', () => {
// git prints "(unknown)" when it could not reach the remote. Returning it
// verbatim would hand a literal branch named "(unknown)" to five workflows;
// returning null lets tier 4 answer instead.
assert.strictEqual(
gitBaseBranch.tryRemoteShow('/x', constGit({ stdout: ' HEAD branch: (unknown)\n' })), null);
});
test('execGit THROWS → null (catch arm)', () => {
assert.strictEqual(gitBaseBranch.tryRemoteShow('/x', throwingGit('remote show exploded')), null);
});
test('positive control: the HEAD branch line is found mid-output and returned verbatim', () => {
const stdout = [
'* remote origin',
' Fetch URL: /tmp/origin.git',
' HEAD branch: master',
' Remote branch:',
' master tracked',
'',
].join('\n');
assert.strictEqual(gitBaseBranch.tryRemoteShow('/x', constGit({ stdout })), 'master');
});
test('the tier-3 subprocess is bounded (argv + timeout are pinned)', () => {
const seen = [];
gitBaseBranch.tryRemoteShow('/some/cwd', (args, opts) => {
seen.push({ args, opts });
return gitResult({ stdout: ' HEAD branch: main\n' });
});
assert.deepStrictEqual(seen, [{
args: ['remote', 'show', 'origin'],
opts: { cwd: '/some/cwd', timeout: 15_000 },
}]);
});
});
describe('#3057 W3: tryLocalBranch — non-empty stdout that names neither main nor master', () => {
test('stdout is exactly "\\n" → null, reached PAST the empty-stdout guard', () => {
// This is the branch that was once deleted as "unreachable". The guard is
// `if (r.exitCode !== 0 || !r.stdout) return null` — `"\n"` is a truthy
// string, so the guard does NOT fire; `split('\n')` yields ["", ""], both
// main/master checks are false, and the FINAL `return null` executes.
// Deleting that line makes this function return `undefined`, which
// strictEqual(null) catches.
assert.strictEqual(gitBaseBranch.tryLocalBranch('/x', constGit({ stdout: '\n' })), null);
});
test('stdout is exactly "" → null via the EARLY guard (a different arm)', () => {
assert.strictEqual(gitBaseBranch.tryLocalBranch('/x', constGit({ stdout: '' })), null);
});
// Boundary trio over the number of branch lines `git branch --list main master`
// can emit: 0 (below the smallest useful listing), 1, and 2 (the maximum this
// argv can produce).
test('0 branch lines → null', () => {
assert.strictEqual(gitBaseBranch.tryLocalBranch('/x', constGit({ stdout: '\n' })), null);
});
test('1 branch line → that branch', () => {
assert.strictEqual(gitBaseBranch.tryLocalBranch('/x', constGit({ stdout: ' main\n' })), 'main');
assert.strictEqual(gitBaseBranch.tryLocalBranch('/x', constGit({ stdout: ' master\n' })), 'master');
assert.strictEqual(gitBaseBranch.tryLocalBranch('/x', constGit({ stdout: '* master\n' })), 'master',
'the checked-out marker "* " must be stripped before matching');
});
test('2 branch lines → "main" wins the tie-break', () => {
assert.strictEqual(
gitBaseBranch.tryLocalBranch('/x', constGit({ stdout: ' main\n master\n' })), 'main');
assert.strictEqual(
gitBaseBranch.tryLocalBranch('/x', constGit({ stdout: '* master\n main\n' })), 'main');
});
test('execGit THROWS → null (catch arm)', () => {
assert.strictEqual(gitBaseBranch.tryLocalBranch('/x', throwingGit('branch --list exploded')), null);
});
test('the tier-4 subprocess is bounded (argv + timeout are pinned)', () => {
const seen = [];
gitBaseBranch.tryLocalBranch('/some/cwd', (args, opts) => {
seen.push({ args, opts });
return gitResult({ stdout: ' main\n' });
});
assert.deepStrictEqual(seen, [{
args: ['branch', '--list', 'main', 'master'],
opts: { cwd: '/some/cwd', timeout: 5_000 },
}]);
});
});
describe('#3057 W3: resolveBaseBranchDiagnostics — which tier actually answered', () => {
// Every tier can produce a plausible-looking branch name, so asserting the
// returned string alone cannot tell a tier-2 answer from a tier-3 or tier-4
// one. Each test below rigs the LOWER tiers to answer with a DIFFERENT branch
// than the tier under test, so a resolver that consulted them in the wrong
// order returns the wrong string, and additionally pins the recorded argv so
// an early return is provably an early return.
/** A passthrough answering each tier with a distinct, recognisable branch. */
function tieredPassthrough({ symref, remote, local }) {
return (args) => {
if (args[0] === 'symbolic-ref') {
return symref === null ? gitResult({ exitCode: 1 }) : gitResult({ stdout: `origin/${symref}\n` });
}
if (args[0] === 'remote') {
return remote === null ? gitResult({ exitCode: 128 }) : gitResult({ stdout: ` HEAD branch: ${remote}\n` });
}
if (args[0] === 'branch') {
return local === null ? gitResult({ stdout: '\n' }) : gitResult({ stdout: ` ${local}\n` });
}
return gitResult({});
};
}
const NO_CONFIG = { readFile: () => null };
test('tier 2 answers → tiers 3 and 4 are never consulted', () => {
const git = makeFaultyGit({
passthrough: tieredPassthrough({ symref: 'from-symref', remote: 'from-remote', local: 'master' }),
});
const result = gitBaseBranch.resolveBaseBranchDiagnostics('/x', { ...NO_CONFIG, execGit: git });
assert.deepStrictEqual(result, { branch: 'from-symref', verified: true });
assert.deepStrictEqual(git.calls.map((c) => c.args[0]), ['symbolic-ref'],
'a tier-2 hit must stop the ladder before `remote show` and `branch --list`');
});
test('tier 3 answers → tier 4 is never consulted, even though it WOULD answer "master"', () => {
const git = makeFaultyGit({
passthrough: tieredPassthrough({ symref: null, remote: 'from-remote', local: 'master' }),
});
const result = gitBaseBranch.resolveBaseBranchDiagnostics('/x', { ...NO_CONFIG, execGit: git });
assert.deepStrictEqual(result, { branch: 'from-remote', verified: true });
assert.deepStrictEqual(git.calls.map((c) => c.args[0]), ['symbolic-ref', 'remote'],
'a tier-3 hit must stop the ladder before `branch --list`');
});
test('tier 4 answers only after tiers 2 and 3 both decline', () => {
const git = makeFaultyGit({
passthrough: tieredPassthrough({ symref: null, remote: null, local: 'master' }),
});
const result = gitBaseBranch.resolveBaseBranchDiagnostics('/x', { ...NO_CONFIG, execGit: git });
assert.deepStrictEqual(result, { branch: 'master', verified: true });
assert.deepStrictEqual(git.calls.map((c) => c.args[0]), ['symbolic-ref', 'remote', 'branch']);
});
test('a config override answers before ANY git subprocess runs', () => {
const git = makeFaultyGit({
passthrough: tieredPassthrough({ symref: 'from-symref', remote: 'from-remote', local: 'master' }),
});
const result = gitBaseBranch.resolveBaseBranchDiagnostics('/x', {
readFile: () => '{"git":{"base_branch":"from-config"}}',
execGit: git,
});
assert.deepStrictEqual(result, { branch: 'from-config', verified: true });
assert.deepStrictEqual(git.calls, [], 'tier 1 must not spawn git at all');
});
test('git cannot be SPAWNED at all (exit 127 + error) → "main", verified:false', () => {
// Distinct from the timeout case already covered by #3057 B4: here every
// call returns exitCode 127 with `error` set and `timedOut:false`, which is
// the `r.error` disjunct of the failure detector rather than `r.timedOut`.
const git = makeFaultyGit({ faults: [{ kind: 'spawnFail' }] });
const result = gitBaseBranch.resolveBaseBranchDiagnostics('/x', { ...NO_CONFIG, execGit: git });
assert.deepStrictEqual(result, { branch: 'main', verified: false });
assert.deepStrictEqual(git.calls.map((c) => c.args[0]), ['symbolic-ref', 'remote', 'branch'],
'all three tiers must still be attempted before the unverified default');
});
test('a spawn failure on ONE tier alone is enough to mark the default unverified', () => {
// Tiers 2 and 3 complete cleanly with "no answer"; only tier 4 fails to run.
const git = makeFaultyGit({
faults: [{ kind: 'spawnFail', when: ['branch', '--list'] }],
passthrough: tieredPassthrough({ symref: null, remote: null, local: null }),
});
const result = gitBaseBranch.resolveBaseBranchDiagnostics('/x', { ...NO_CONFIG, execGit: git });
assert.deepStrictEqual(result, { branch: 'main', verified: false });
});
test('tier-4 stdout of "\\n" (no branches) is a VERIFIED "no candidate", not a failure', () => {
// The counterpart to the tryLocalBranch "\n" test, one level up: git ran,
// answered, and the answer was "neither branch exists". That must still be
// verified:true — collapsing it into verified:false would re-fail-open the
// exact distinction #3057 B4 introduced.
const git = makeFaultyGit({
passthrough: tieredPassthrough({ symref: null, remote: null, local: null }),
});
const result = gitBaseBranch.resolveBaseBranchDiagnostics('/x', { ...NO_CONFIG, execGit: git });
assert.deepStrictEqual(result, { branch: 'main', verified: true });
});
});
describe('#3057 W3: gitWorktreeInfoInternal — no work tree, and git failing mid-sequence', () => {
test('a REAL bare repository reports {inside:false, worktreeRoot:null}', (t) => {
// `git rev-parse --is-inside-work-tree` exits 0 in a bare repo and prints
// "false" — the exitCode guard does NOT fire, so this is the stdout check,
// and it is reachable without any injection.
const dir = createTempDir('gsd-3057-w3-bare-');
t.after(() => cleanup(dir));
execSync('git init --bare', { cwd: dir, stdio: 'pipe' });
assert.deepStrictEqual(
gitBaseBranch.gitWorktreeInfoInternal(dir),
{ inside: false, worktreeRoot: null });
});
test('is-inside-work-tree prints "false" with exit 0 → no second git call is made', () => {
const git = makeFaultyGit({ passthrough: () => gitResult({ stdout: 'false\n' }) });
assert.deepStrictEqual(
gitBaseBranch.gitWorktreeInfoInternal('/x', { execGit: git }),
{ inside: false, worktreeRoot: null });
assert.deepStrictEqual(git.calls.map((c) => c.args), [['rev-parse', '--is-inside-work-tree']],
'--show-toplevel must not be queried once we know there is no work tree');
});
test('inside a work tree but --show-toplevel FAILS → {inside:true, worktreeRoot:null}', () => {
// inside is still reported truthfully; only the root is unknown. Reporting
// inside:false here would be a lie about a repository we just confirmed.
const git = makeFaultyGit({
faults: [{
kind: 'exit',
exitCode: 128,
stderr: 'fatal: no work tree',
when: ['rev-parse', '--show-toplevel'],
}],
passthrough: () => gitResult({ stdout: 'true\n' }),
});
assert.deepStrictEqual(
gitBaseBranch.gitWorktreeInfoInternal('/x', { execGit: git }),
{ inside: true, worktreeRoot: null });
assert.deepStrictEqual(git.calls.map((c) => c.args[1]),
['--is-inside-work-tree', '--show-toplevel']);
});
test('--show-toplevel succeeds with blank stdout → {inside:true, worktreeRoot:null}', () => {
const git = makeFaultyGit({
passthrough: (args) => gitResult({ stdout: args[1] === '--show-toplevel' ? ' \n' : 'true\n' }),
});
assert.deepStrictEqual(
gitBaseBranch.gitWorktreeInfoInternal('/x', { execGit: git }),
{ inside: true, worktreeRoot: null });
});
test('--show-toplevel succeeds → the trimmed path is returned', () => {
const git = makeFaultyGit({
passthrough: (args) => gitResult({ stdout: args[1] === '--show-toplevel' ? ' /repo/root \n' : 'true\n' }),
});
assert.deepStrictEqual(
gitBaseBranch.gitWorktreeInfoInternal('/x', { execGit: git }),
{ inside: true, worktreeRoot: '/repo/root' });
});
test('execGit THROWS → {inside:false, worktreeRoot:null} (catch arm)', () => {
assert.deepStrictEqual(
gitBaseBranch.gitWorktreeInfoInternal('/x', { execGit: throwingGit('git is gone') }),
{ inside: false, worktreeRoot: null });
});
test('both worktree probes are bounded and receive the caller cwd', () => {
const git = makeFaultyGit({
passthrough: (args) => gitResult({ stdout: args[1] === '--show-toplevel' ? '/repo/root\n' : 'true\n' }),
});
gitBaseBranch.gitWorktreeInfoInternal('/some/cwd', { execGit: git });
assert.deepStrictEqual(git.calls, [
{ args: ['rev-parse', '--is-inside-work-tree'], opts: { cwd: '/some/cwd', timeout: 5000 } },
{ args: ['rev-parse', '--show-toplevel'], opts: { cwd: '/some/cwd', timeout: 5000 } },
]);
});
});
describe('#3057 W3: cmdGitBaseBranch — the DEFAULT diagnostic sink', () => {
test('with no writeDiagnostic injected, the unverified warning goes to process.stderr', (t) => {
const written = [];
t.mock.method(process.stderr, 'write', (chunk) => { written.push(String(chunk)); return true; });
const stdout = [];
const branch = gitBaseBranch.cmdGitBaseBranch('/x', [], {
readFile: () => null,
execGit: makeFaultyGit({ faults: [{ kind: 'timeout' }] }),
write: (s) => { stdout.push(s); },
// writeDiagnostic deliberately omitted → the process.stderr default arm.
});
assert.strictEqual(branch, 'main');
assert.deepStrictEqual(stdout, ['main\n'], 'the stdout contract five workflows parse is unchanged');
assert.strictEqual(written.length, 1, 'exactly one diagnostic must reach the default stderr sink');
assert.match(written[0], /WITHOUT verifying/);
});
test('with no writeDiagnostic injected and a VERIFIED answer, process.stderr is untouched', (t) => {
const written = [];
t.mock.method(process.stderr, 'write', (chunk) => { written.push(String(chunk)); return true; });
const stdout = [];
const branch = gitBaseBranch.cmdGitBaseBranch('/x', [], {
readFile: () => '{"git":{"base_branch":"develop"}}',
execGit: makeFaultyGit(),
write: (s) => { stdout.push(s); },
});
assert.strictEqual(branch, 'develop');
assert.deepStrictEqual(stdout, ['develop\n']);
assert.deepStrictEqual(written, [], 'a verified answer must write nothing to the default stderr sink');
});
});
// ─── setGsdConfig prototype-pollution guard (#1406) ───────────────────────────
describe('#1406: setGsdConfig prototype-pollution guard', () => {
@@ -697,76 +1193,72 @@ describe('handle_branching branches off origin/HEAD, not current HEAD (#2916)',
// exercising the symbolic-ref code path) so a regression that hard-codes
// `main` instead of consulting origin/HEAD will fail the trunk variant.
for (const defaultBranch of ['main', 'trunk']) {
test(`new phase branch branches off origin/${defaultBranch} with 0 inherited commits`, () => {
test(`new phase branch branches off origin/${defaultBranch} with 0 inherited commits`, (t) => {
const bash = extractHandleBranchingBash();
const { root, clonePath } = setupFixture(defaultBranch);
// Teardown via t.after, not try/finally — CONTRIBUTING.md "Setup and
// Cleanup" reserves try/finally for context-free helper functions.
t.after(() => cleanup(root));
try {
const upstream = `origin/${defaultBranch}`;
const upstream = `origin/${defaultBranch}`;
assert.equal(
git(clonePath, 'rev-parse', '--abbrev-ref', 'HEAD'),
'feature/phase-01-foundation'
);
assert.equal(
git(clonePath, 'rev-list', '--count', `${upstream}..HEAD`),
'1',
`fixture should be 1 commit ahead of ${upstream}`
);
runHandleBranchingStep(bash, clonePath, 'feature/phase-02-content-sync');
assert.equal(
git(clonePath, 'rev-parse', '--abbrev-ref', 'HEAD'),
'feature/phase-02-content-sync',
'handle_branching should switch to the new phase branch'
);
const inherited = git(clonePath, 'rev-list', '--count', `${upstream}..HEAD`);
assert.equal(
inherited,
'0',
`new phase branch must branch off ${upstream}, but inherited ${inherited} commit(s) from previous-phase HEAD`
);
assert.equal(
git(clonePath, 'rev-parse', 'HEAD'),
git(clonePath, 'rev-parse', upstream),
`new phase branch tip must equal ${upstream} tip`
);
} finally {
cleanup(root);
}
});
}
test('handle_branching reuses an existing branch instead of forking again', () => {
const bash = extractHandleBranchingBash();
const { root, clonePath } = setupFixture();
try {
// Pre-create the target branch off origin/main with its own commit, then
// walk away to a different branch — the step must switch back to it.
git(clonePath, 'checkout', '-B', 'feature/phase-02-content-sync', 'origin/main');
fs.writeFileSync(path.join(clonePath, 'phase02.txt'), 'phase 2 work\n');
git(clonePath, 'add', 'phase02.txt');
git(clonePath, 'commit', '-m', 'phase 02 wip');
const phase02Sha = git(clonePath, 'rev-parse', 'HEAD');
git(clonePath, 'checkout', 'feature/phase-01-foundation');
assert.equal(
git(clonePath, 'rev-parse', '--abbrev-ref', 'HEAD'),
'feature/phase-01-foundation'
);
assert.equal(
git(clonePath, 'rev-list', '--count', `${upstream}..HEAD`),
'1',
`fixture should be 1 commit ahead of ${upstream}`
);
runHandleBranchingStep(bash, clonePath, 'feature/phase-02-content-sync');
assert.equal(
git(clonePath, 'rev-parse', '--abbrev-ref', 'HEAD'),
'feature/phase-02-content-sync'
'feature/phase-02-content-sync',
'handle_branching should switch to the new phase branch'
);
const inherited = git(clonePath, 'rev-list', '--count', `${upstream}..HEAD`);
assert.equal(
inherited,
'0',
`new phase branch must branch off ${upstream}, but inherited ${inherited} commit(s) from previous-phase HEAD`
);
assert.equal(
git(clonePath, 'rev-parse', 'HEAD'),
phase02Sha,
'existing-branch tip must be preserved (no rebase/reset)'
git(clonePath, 'rev-parse', upstream),
`new phase branch tip must equal ${upstream} tip`
);
} finally {
cleanup(root);
}
});
}
test('handle_branching reuses an existing branch instead of forking again', (t) => {
const bash = extractHandleBranchingBash();
const { root, clonePath } = setupFixture();
t.after(() => cleanup(root));
// Pre-create the target branch off origin/main with its own commit, then
// walk away to a different branch — the step must switch back to it.
git(clonePath, 'checkout', '-B', 'feature/phase-02-content-sync', 'origin/main');
fs.writeFileSync(path.join(clonePath, 'phase02.txt'), 'phase 2 work\n');
git(clonePath, 'add', 'phase02.txt');
git(clonePath, 'commit', '-m', 'phase 02 wip');
const phase02Sha = git(clonePath, 'rev-parse', 'HEAD');
git(clonePath, 'checkout', 'feature/phase-01-foundation');
runHandleBranchingStep(bash, clonePath, 'feature/phase-02-content-sync');
assert.equal(
git(clonePath, 'rev-parse', '--abbrev-ref', 'HEAD'),
'feature/phase-02-content-sync'
);
assert.equal(
git(clonePath, 'rev-parse', 'HEAD'),
phase02Sha,
'existing-branch tip must be preserved (no rebase/reset)'
);
});
});
});

View File

@@ -0,0 +1,992 @@
'use strict';
/**
* `reapOrphanWorktrees` — fault-injected verdict coverage (#3057, wave 3).
*
* Seam: gsd-core/bin/lib/worktree-safety.cjs
* Interface: reapOrphanWorktrees, cmdWorktreeReapOrphans, pruneOrphanedWorktrees
*
* WHY A SECOND FILE FOR THIS MODULE
* `tests/worktree-safety.test.cjs` is ~6.4k lines and its `reapOrphanWorktrees`
* suites live inside a folded block with their own local fixture helpers. The
* negative-space work below needs a different fixture shape (an injected
* `execGit` that delegates to real git, plus per-test mutation of the
* `.git/worktrees/<name>/` admin directory), so it gets its own module-bucketed
* file rather than a third set of helpers wedged into the folded block.
*
* WHAT THIS FILE PINS THAT NOTHING ELSE DID
* Every pre-existing test drove the DEFAULT `execGit` against real git and
* injected only `mtimeSafe` / `nowMs` / `isPidAlive`. `reapOrphanWorktrees`
* accepts `execGit`, `readDirSafe` and `readFileSafe` in the same `deps` bag,
* and nothing used them — so every fail-closed `return` inside the function was
* unreachable from the suite. Each test here injects exactly the one fault that
* selects one branch and asserts the SPECIFIC `{status, reason}` verdict that
* branch produces, never merely that the call returned an array.
*
* Determinism: no wall clock is read (`mtimeSafe`/`nowMs` are injected), and no
* live PID is probed (`isPidAlive` is injected), so the only real-world
* dependency is git itself.
*/
const { describe, test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const { cleanup } = require('./helpers.cjs');
const { runGit } = require('./helpers/process-seam.cjs');
const { makeFaultyGit, withFaultyFs } = require('./helpers/faulty-deps.cjs');
const {
reapOrphanWorktrees,
cmdWorktreeReapOrphans,
pruneOrphanedWorktrees,
} = require('../gsd-core/bin/lib/worktree-safety.cjs');
// ─── Fixed clock values (ADR-456 clock seam) ─────────────────────────────────
/** Older than any staleness threshold, at any real point in time. */
const STALE_MTIME = new Date(0);
/** The lock-owner PID written into every fixture; liveness is always injected. */
const LOCK_OWNER_PID = '4242';
const GIT_TIMEOUT_MS = 30000;
// ─── Path + git helpers ──────────────────────────────────────────────────────
function canonicalPath(p) {
try { return fs.realpathSync.native(path.resolve(p)); } catch { return path.resolve(p); }
}
/**
* Long-form os.tmpdir(). Windows CI reports 8.3 short names that git does not
* echo back, so every fixture path is built from the resolved form.
*/
function resolvedTmpDir() {
try { return fs.realpathSync.native(os.tmpdir()); } catch { return os.tmpdir(); }
}
/** Run git for FIXTURE SETUP; throws on anything but a clean exit. */
function git(args, cwd) {
const r = runGit(args, { cwd, timeoutMs: GIT_TIMEOUT_MS });
if (r.exitCode !== 0) {
throw new Error(`git ${args.join(' ')} failed (${r.outcome}/${r.exitCode}): ${r.stderr}`);
}
return r.stdout;
}
/**
* An `execGit`-shaped delegate that runs REAL git. Used as `makeFaultyGit`'s
* `passthrough` so a test can fault one argv and leave every other call intact.
*/
function realExecGit(args, opts = {}) {
const r = runGit(args, { cwd: opts.cwd, timeoutMs: GIT_TIMEOUT_MS });
return {
exitCode: r.exitCode,
stdout: r.stdout,
stderr: r.stderr,
signal: r.signal,
error: r.code === null ? null : Object.assign(new Error(r.code), { code: r.code }),
timedOut: r.timedOut,
};
}
/** A benign zero-exit result carrying `stdout`. */
function okResult(stdout) {
return { exitCode: 0, stdout, stderr: '', signal: null, error: null, timedOut: false };
}
function argvOf(faultyGit) {
return faultyGit.calls.map((c) => c.args.join(' '));
}
function calledWith(faultyGit, prefix) {
return faultyGit.calls.some((c) => prefix.every((token, i) => c.args[i] === token));
}
// ─── Fixture construction ────────────────────────────────────────────────────
function initRepo(dir) {
fs.mkdirSync(dir, { recursive: true });
git(['init'], dir);
git(['config', 'user.email', 'test@test.com'], dir);
git(['config', 'user.name', 'Test'], dir);
git(['config', 'commit.gpgsign', 'false'], dir);
fs.writeFileSync(path.join(dir, 'README.md'), '# Test\n');
git(['add', '-A'], dir);
git(['commit', '-m', 'initial commit'], dir);
// Exit code deliberately unchecked: the rename fails harmlessly when the
// repo was already initialised on `main`.
runGit(['branch', '-m', 'master', 'main'], { cwd: dir, timeoutMs: GIT_TIMEOUT_MS });
}
/** Locate `.git/worktrees/<name>/` for a linked worktree. */
function adminDirFor(repoDir, wtDir) {
const commonDir = path.resolve(repoDir, git(['rev-parse', '--git-common-dir'], repoDir).trim());
const worktreesDir = path.join(commonDir, 'worktrees');
const wanted = canonicalPath(wtDir);
for (const entry of fs.readdirSync(worktreesDir)) {
const gitdirFile = path.join(worktreesDir, entry, 'gitdir');
if (!fs.existsSync(gitdirFile)) continue;
const pointer = fs.readFileSync(gitdirFile, 'utf8').trim();
const root = path.resolve(worktreesDir, entry, pointer).replace(/[/\\]\.git$/, '');
if (canonicalPath(root) === wanted) return path.join(worktreesDir, entry);
}
throw new Error(`no .git/worktrees/<name> admin dir for ${wtDir}`);
}
/**
* Build a repo with one linked, locked worktree whose branch is merged into
* `main` unless `merge:false`. The lock owner is a fixed PID string; liveness is
* always supplied through `deps.isPidAlive`, never probed against the OS.
*/
function makeFixture(tmpBase, name, options = {}) {
const repoDir = path.join(tmpBase, `repo-${name}`);
const wtDir = path.join(tmpBase, `wt-${name}`);
const branch = `worktree-agent-${name}`;
initRepo(repoDir);
git(['worktree', 'add', wtDir, '-b', branch], repoDir);
fs.writeFileSync(path.join(wtDir, 'work.txt'), 'content\n');
git(['add', '-A'], wtDir);
git(['commit', '-m', `work in ${name}`], wtDir);
if (options.merge !== false) {
git(['merge', branch, '--no-ff', '-m', `merge ${branch}`], repoDir);
}
const adminDir = adminDirFor(repoDir, wtDir);
if (options.lock !== false) {
fs.writeFileSync(path.join(adminDir, 'locked'), LOCK_OWNER_PID);
}
return { repoDir, wtDir, branch, adminDir };
}
/** Deps every "owner is dead, lock is stale" test shares. */
function deadOwnerDeps(extra = {}) {
return { isPidAlive: () => false, mtimeSafe: () => STALE_MTIME, ...extra };
}
/** Assert exactly one result row, and return it. */
function onlyRow(result) {
assert.strictEqual(result.length, 1, `expected exactly one result row, got ${JSON.stringify(result)}`);
return result[0];
}
// ─── Suite: default-branch discovery — fail-closed verdicts ──────────────────
describe('#3057 reapOrphanWorktrees: default-branch discovery verdicts', () => {
let tmpBase;
beforeEach(() => {
tmpBase = fs.mkdtempSync(path.join(resolvedTmpDir(), 'gsd-3057-reap-disc-'));
});
afterEach(() => {
cleanup(tmpBase);
});
test('returns no rows and never reads the admin directory when git cannot resolve --git-dir', () => {
const f = makeFixture(tmpBase, 'nogitdir');
const probed = [];
const faultyGit = makeFaultyGit({
faults: [{ kind: 'exit', exitCode: 128, when: ['rev-parse', '--git-dir'] }],
passthrough: realExecGit,
});
const result = reapOrphanWorktrees(f.repoDir, deadOwnerDeps({
execGit: faultyGit,
readDirSafe: (dir) => { probed.push(dir); return fs.readdirSync(dir); },
}));
assert.deepStrictEqual(result, []);
assert.deepStrictEqual(probed, [], 'admin directory must not be read once --git-dir failed');
assert.deepStrictEqual(argvOf(faultyGit), ['rev-parse --git-dir']);
assert.ok(fs.existsSync(f.wtDir), 'the worktree must survive a fail-closed bail-out');
});
test('returns no rows when the worktrees admin directory cannot be listed', () => {
const f = makeFixture(tmpBase, 'nodir');
const probed = [];
const faultyGit = makeFaultyGit({ passthrough: realExecGit });
const result = reapOrphanWorktrees(f.repoDir, deadOwnerDeps({
execGit: faultyGit,
readDirSafe: (dir) => { probed.push(dir); return null; },
}));
assert.deepStrictEqual(result, []);
assert.strictEqual(probed.length, 1, 'the admin directory must be probed exactly once');
assert.strictEqual(path.basename(probed[0]), 'worktrees');
// Distinguishes this bail-out from the --git-dir one above: --git-dir DID
// run and succeed, and nothing after the admin listing was attempted.
assert.deepStrictEqual(argvOf(faultyGit), ['rev-parse --git-dir']);
});
test('returns no rows for a repo that has no linked worktrees at all', () => {
// Exercises the real `defaultReadDirSafe` catch: `.git/worktrees/` does not
// exist, so readdirSync throws and the helper returns null.
const repoDir = path.join(tmpBase, 'repo-bare-of-worktrees');
initRepo(repoDir);
assert.deepStrictEqual(reapOrphanWorktrees(repoDir), []);
});
test('reaps from origin/HEAD alone and never consults local branch candidates', () => {
const f = makeFixture(tmpBase, 'remotehead');
const mainTip = git(['rev-parse', 'main'], f.repoDir).trim();
const faultyGit = makeFaultyGit({
passthrough: (args, opts) => {
if (args[0] === 'symbolic-ref' && args[args.length - 1] === 'refs/remotes/origin/HEAD') {
return okResult('origin/main\n');
}
if (args[0] === 'rev-parse' && args[1] === 'refs/remotes/origin/main') {
return okResult(`${mainTip}\n`);
}
return realExecGit(args, opts);
},
});
const row = onlyRow(reapOrphanWorktrees(f.repoDir, deadOwnerDeps({ execGit: faultyGit })));
assert.strictEqual(row.status, 'reaped');
assert.strictEqual(row.reason, 'pid_dead_and_merged');
// The remote-exclusive arm is what makes this distinguishable from the
// local-candidate arm that every other fixture in the tree takes.
assert.strictEqual(calledWith(faultyGit, ['remote']), false, 'must not fall back to remote enumeration');
assert.strictEqual(
calledWith(faultyGit, ['config', '--get', 'init.defaultBranch']),
false,
'must not build a local candidate list when origin/HEAD resolved'
);
});
test('returns no rows when origin/HEAD names a remote ref that will not resolve', () => {
const f = makeFixture(tmpBase, 'badremoteref');
const faultyGit = makeFaultyGit({
faults: [{ kind: 'exit', exitCode: 128, when: ['rev-parse', 'refs/remotes/origin/main'] }],
passthrough: (args, opts) => (
args[0] === 'symbolic-ref' && args[args.length - 1] === 'refs/remotes/origin/HEAD'
? okResult('origin/main\n')
: realExecGit(args, opts)
),
});
const result = reapOrphanWorktrees(f.repoDir, deadOwnerDeps({ execGit: faultyGit }));
assert.deepStrictEqual(result, []);
assert.strictEqual(
calledWith(faultyGit, ['worktree', 'list']),
false,
'must fail closed before building the canonical index'
);
assert.ok(fs.existsSync(f.wtDir));
});
test('returns no rows when a remote exists but origin/HEAD is unset', () => {
const f = makeFixture(tmpBase, 'ambiguousremote');
const originSrc = path.join(tmpBase, 'origin-src');
initRepo(originSrc);
git(['remote', 'add', 'origin', originSrc], f.repoDir);
const faultyGit = makeFaultyGit({ passthrough: realExecGit });
const result = reapOrphanWorktrees(f.repoDir, deadOwnerDeps({ execGit: faultyGit }));
assert.deepStrictEqual(result, [], 'an ambiguous default branch must not be guessed');
assert.strictEqual(calledWith(faultyGit, ['remote']), true);
assert.strictEqual(
calledWith(faultyGit, ['config', '--get', 'init.defaultBranch']),
false,
'the candidate list must not be built once a remote is known to exist'
);
assert.ok(fs.existsSync(f.wtDir));
});
test('returns no rows when not one default-branch candidate resolves', () => {
const f = makeFixture(tmpBase, 'nocandidate');
const faultyGit = makeFaultyGit({
faults: [{
kind: 'exit',
exitCode: 128,
when: (args) => args[0] === 'rev-parse' && args[1] !== '--git-dir',
}],
passthrough: realExecGit,
});
const result = reapOrphanWorktrees(f.repoDir, deadOwnerDeps({ execGit: faultyGit }));
assert.deepStrictEqual(result, []);
assert.strictEqual(calledWith(faultyGit, ['rev-parse', 'main']), true);
assert.strictEqual(calledWith(faultyGit, ['rev-parse', 'master']), true);
assert.strictEqual(
calledWith(faultyGit, ['worktree', 'list']),
false,
'must fail closed before building the canonical index'
);
});
});
// ─── Suite: canonical-index construction ─────────────────────────────────────
describe('#3057 reapOrphanWorktrees: canonical-index degradation verdicts', () => {
let tmpBase;
beforeEach(() => {
tmpBase = fs.mkdtempSync(path.join(resolvedTmpDir(), 'gsd-3057-reap-idx-'));
});
afterEach(() => {
cleanup(tmpBase);
});
test('still reaps when git worktree list fails and the canonical index stays empty', () => {
const f = makeFixture(tmpBase, 'listfails');
const wtCanonical = canonicalPath(f.wtDir);
const faultyGit = makeFaultyGit({
faults: [{ kind: 'timeout', when: ['worktree', 'list'] }],
passthrough: realExecGit,
});
const row = onlyRow(reapOrphanWorktrees(f.repoDir, deadOwnerDeps({ execGit: faultyGit })));
// A failed listing must degrade to the gitdir-derived path, NOT abort the
// sweep — an empty index is not "there is nothing to reap".
assert.strictEqual(row.status, 'reaped');
assert.strictEqual(row.reason, 'pid_dead_and_merged');
assert.strictEqual(canonicalPath(row.path), wtCanonical);
assert.strictEqual(calledWith(faultyGit, ['worktree', 'list']), true);
assert.strictEqual(fs.existsSync(f.wtDir), false);
});
test('still reaps when a porcelain block carries no worktree line', () => {
const f = makeFixture(tmpBase, 'headlessblock');
const realPorcelain = git(['worktree', 'list', '--porcelain'], f.repoDir);
const faultyGit = makeFaultyGit({
passthrough: (args, opts) => (
args[0] === 'worktree' && args[1] === 'list'
? okResult(`bare\n\n${realPorcelain}`)
: realExecGit(args, opts)
),
});
// Without the `continue`, `wtLine.slice(...)` would throw on the leading
// block and the whole sweep would die.
const row = onlyRow(reapOrphanWorktrees(f.repoDir, deadOwnerDeps({ execGit: faultyGit })));
assert.strictEqual(row.status, 'reaped');
assert.strictEqual(row.reason, 'pid_dead_and_merged');
});
test('still reaps when the porcelain lists a path that no longer exists on disk', () => {
const f = makeFixture(tmpBase, 'ghostpath');
const realPorcelain = git(['worktree', 'list', '--porcelain'], f.repoDir);
const ghost = path.join(tmpBase, 'ghost-worktree');
const faultyGit = makeFaultyGit({
passthrough: (args, opts) => (
args[0] === 'worktree' && args[1] === 'list'
? okResult(`worktree ${ghost}\nHEAD 0000000000000000000000000000000000000000\n\n${realPorcelain}`)
: realExecGit(args, opts)
),
});
// realpathSync.native throws for the ghost block; the catch must skip that
// one entry and keep indexing the rest.
const row = onlyRow(reapOrphanWorktrees(f.repoDir, deadOwnerDeps({ execGit: faultyGit })));
assert.strictEqual(row.status, 'reaped');
assert.strictEqual(row.reason, 'pid_dead_and_merged');
});
});
// ─── Suite: admin-directory shape ────────────────────────────────────────────
describe('#3057 reapOrphanWorktrees: admin-entry verdicts', () => {
let tmpBase;
beforeEach(() => {
tmpBase = fs.mkdtempSync(path.join(resolvedTmpDir(), 'gsd-3057-reap-admin-'));
});
afterEach(() => {
cleanup(tmpBase);
});
test('reports no row at all for a linked worktree that carries no lock file', () => {
const f = makeFixture(tmpBase, 'locked');
const unlockedDir = path.join(tmpBase, 'wt-unlocked');
git(['worktree', 'add', unlockedDir, '-b', 'worktree-agent-unlocked'], f.repoDir);
const result = reapOrphanWorktrees(f.repoDir, deadOwnerDeps());
const row = onlyRow(result);
assert.strictEqual(canonicalPath(row.path), canonicalPath(f.wtDir));
assert.strictEqual(row.status, 'reaped');
assert.ok(fs.existsSync(unlockedDir), 'an unlocked worktree is not the reaper concern');
});
test('reports no row for a locked admin entry whose gitdir pointer is missing', () => {
const f = makeFixture(tmpBase, 'nopointer');
fs.unlinkSync(path.join(f.adminDir, 'gitdir'));
// The lock file is present and stale and the owner is dead, so a row WOULD
// be emitted if the missing pointer were not a hard skip.
assert.deepStrictEqual(reapOrphanWorktrees(f.repoDir, deadOwnerDeps()), []);
assert.ok(fs.existsSync(f.wtDir));
});
test('reports no row when an injected readFileSafe reports the gitdir pointer as empty', () => {
const f = makeFixture(tmpBase, 'blankpointer');
const gitdirFile = path.join(f.adminDir, 'gitdir');
// Covers the `deps.readFileSafe` seam arm AND the empty-string half of the
// falsy-pointer guard (the missing-file half returns null, not '').
const result = reapOrphanWorktrees(f.repoDir, deadOwnerDeps({
readFileSafe: (file) => {
if (path.resolve(file) === path.resolve(gitdirFile)) return '';
try { return fs.readFileSync(file, 'utf8'); } catch { return null; }
},
}));
assert.deepStrictEqual(result, []);
assert.ok(fs.existsSync(f.wtDir));
});
test('reports lock_age_unknown when the real mtime helper cannot stat the lock file', () => {
const f = makeFixture(tmpBase, 'statfails');
// No `mtimeSafe` injection: this drives the module's own default helper and
// pins its catch arm. nowMs is the far future, so a readable mtime would
// read as stale and reap.
const result = withFaultyFs(
{ statSync: () => { throw Object.assign(new Error('EIO'), { code: 'EIO' }); } },
() => reapOrphanWorktrees(f.repoDir, { isPidAlive: () => false, nowMs: 8640000000000000 })
);
const row = onlyRow(result);
// NOT `lock_too_fresh` (#3057): an unreadable mtime is not an age at all.
// Freshness tells an operator to wait; waiting never clears an EIO.
assert.deepStrictEqual(
{ status: row.status, reason: row.reason },
{ status: 'skipped', reason: 'lock_age_unknown' }
);
assert.ok(fs.existsSync(f.wtDir));
});
test('reports remove_failed against the raw gitdir pointer when its basename is not .git', () => {
const f = makeFixture(tmpBase, 'oddpointer');
const pointerTarget = path.join(f.wtDir, 'notgit');
fs.writeFileSync(path.join(f.adminDir, 'gitdir'), `${pointerTarget}\n`);
const faultyGit = makeFaultyGit({
faults: [{ kind: 'exit', exitCode: 1, when: ['worktree', 'remove'] }],
passthrough: realExecGit,
});
const row = onlyRow(reapOrphanWorktrees(f.repoDir, deadOwnerDeps({ execGit: faultyGit })));
// `path` is the load-bearing assertion: a pointer that does not end in
// `/.git` is used verbatim (no dirname()), and because it does not exist,
// the canonical lookup throws and the raw path is what reaches git.
assert.strictEqual(row.path, pointerTarget);
assert.strictEqual(row.status, 'skipped');
assert.strictEqual(row.reason, 'remove_failed');
assert.ok(fs.existsSync(f.wtDir));
});
test('reports lock_age_unknown, not lock_too_fresh, when mtimeSafe returns null', () => {
const f = makeFixture(tmpBase, 'nomtime');
// nowMs is the far future, so a REAL mtime would read as stale and the
// entry would be reaped. Only the null-mtime arm can produce this verdict.
const row = onlyRow(reapOrphanWorktrees(f.repoDir, {
isPidAlive: () => false,
mtimeSafe: () => null,
nowMs: 8640000000000000,
}));
assert.deepStrictEqual(
{ status: row.status, reason: row.reason },
{ status: 'skipped', reason: 'lock_age_unknown' }
);
assert.ok(fs.existsSync(f.wtDir));
});
test('reports lock_too_fresh, not lock_age_unknown, for a readable zero-age lock under the default guard', () => {
const f = makeFixture(tmpBase, 'defaultguard');
const now = 1000000;
// The other half of the split: the mtime IS readable, the lock genuinely is
// recent, and waiting out the guard genuinely would change the outcome.
const row = onlyRow(reapOrphanWorktrees(f.repoDir, {
isPidAlive: () => false,
mtimeSafe: () => new Date(now),
nowMs: now,
}));
assert.deepStrictEqual(
{ status: row.status, reason: row.reason },
{ status: 'skipped', reason: 'lock_too_fresh' }
);
assert.ok(fs.existsSync(f.wtDir));
});
test('reaps the same zero-age lock when an injected reapMtimeGuardMs of 0 retires the guard', () => {
const f = makeFixture(tmpBase, 'zeroguard');
const now = 1000000;
const row = onlyRow(reapOrphanWorktrees(f.repoDir, {
isPidAlive: () => false,
mtimeSafe: () => new Date(now),
nowMs: now,
reapMtimeGuardMs: 0,
}));
assert.strictEqual(row.status, 'reaped');
assert.strictEqual(row.reason, 'pid_dead_and_merged');
});
});
// ─── Suite: liveness and ancestry verdicts ───────────────────────────────────
describe('#3057 reapOrphanWorktrees: liveness and ancestry verdicts', () => {
let tmpBase;
beforeEach(() => {
tmpBase = fs.mkdtempSync(path.join(resolvedTmpDir(), 'gsd-3057-reap-live-'));
});
afterEach(() => {
cleanup(tmpBase);
});
test('reports pid_alive when the lock owner is alive', () => {
const f = makeFixture(tmpBase, 'alive');
const row = onlyRow(reapOrphanWorktrees(f.repoDir, {
isPidAlive: () => true,
mtimeSafe: () => STALE_MTIME,
}));
assert.strictEqual(row.status, 'skipped');
assert.strictEqual(row.reason, 'pid_alive');
assert.ok(fs.existsSync(f.wtDir));
});
test('reports pid_alive when the liveness probe throws', () => {
const f = makeFixture(tmpBase, 'probethrows');
const row = onlyRow(reapOrphanWorktrees(f.repoDir, {
isPidAlive: () => { throw Object.assign(new Error('EPERM'), { code: 'EPERM' }); },
mtimeSafe: () => STALE_MTIME,
}));
// An undeterminable owner is treated as alive — same verdict as a genuinely
// live owner, which is the intended fail-closed conflation.
assert.strictEqual(row.status, 'skipped');
assert.strictEqual(row.reason, 'pid_alive');
assert.ok(fs.existsSync(f.wtDir));
});
test('reports pid_alive from the default isPidAlive helper when process.kill throws EPERM', (t) => {
// No `isPidAlive` injection: this drives the module's OWN default helper
// (`defaultIsPidAlive`), whose EPERM arm every other test in this tree
// bypasses by injecting `isPidAlive` directly. `process.kill` is
// monkeypatched per CONTRIBUTING's cross-platform IO-fault-injection rule
// rather than run against a real cross-user PID.
const f = makeFixture(tmpBase, 'defaultkill-eperm');
const originalKill = process.kill;
t.after(() => { process.kill = originalKill; });
process.kill = () => { throw Object.assign(new Error('EPERM'), { code: 'EPERM' }); };
const row = onlyRow(reapOrphanWorktrees(f.repoDir, { mtimeSafe: () => STALE_MTIME }));
assert.deepStrictEqual({ status: row.status, reason: row.reason }, { status: 'skipped', reason: 'pid_alive' });
assert.ok(fs.existsSync(f.wtDir));
});
test('reports pid_dead_and_merged from the default isPidAlive helper when process.kill throws ESRCH', (t) => {
// Same default helper as above, but its dead-owner arm: ESRCH means "no
// such process", so `defaultIsPidAlive` returns false and the sweep falls
// through to the (merged, by fixture default) ancestry check and reaps.
const f = makeFixture(tmpBase, 'defaultkill-esrch');
const originalKill = process.kill;
t.after(() => { process.kill = originalKill; });
process.kill = () => { throw Object.assign(new Error('ESRCH'), { code: 'ESRCH' }); };
const row = onlyRow(reapOrphanWorktrees(f.repoDir, { mtimeSafe: () => STALE_MTIME }));
assert.deepStrictEqual(
{ status: row.status, reason: row.reason },
{ status: 'reaped', reason: 'pid_dead_and_merged' }
);
assert.strictEqual(fs.existsSync(f.wtDir), false);
});
test('reports pid_alive from the default isPidAlive helper when process.kill returns without throwing', (t) => {
// The non-throwing arm of `defaultIsPidAlive`: a live owner's `kill(pid,
// 0)` returns normally, so the helper returns true directly, with no
// catch block involved at all.
const f = makeFixture(tmpBase, 'defaultkill-alive');
const originalKill = process.kill;
t.after(() => { process.kill = originalKill; });
process.kill = () => true;
const row = onlyRow(reapOrphanWorktrees(f.repoDir, { mtimeSafe: () => STALE_MTIME }));
assert.deepStrictEqual({ status: row.status, reason: row.reason }, { status: 'skipped', reason: 'pid_alive' });
assert.ok(fs.existsSync(f.wtDir));
});
test('reports cannot_resolve_branch_tip when the admin HEAD file is absent', () => {
const f = makeFixture(tmpBase, 'noheadfile');
fs.unlinkSync(path.join(f.adminDir, 'HEAD'));
const faultyGit = makeFaultyGit({ passthrough: realExecGit });
const row = onlyRow(reapOrphanWorktrees(f.repoDir, deadOwnerDeps({ execGit: faultyGit })));
assert.strictEqual(row.status, 'skipped');
assert.strictEqual(row.reason, 'cannot_resolve_branch_tip');
assert.strictEqual(
calledWith(faultyGit, ['merge-base']),
false,
'ancestry must not be probed once the tip is unknown'
);
assert.ok(fs.existsSync(f.wtDir));
});
test('reports cannot_resolve_branch_tip when the admin HEAD names an unresolvable branch', () => {
const f = makeFixture(tmpBase, 'deadsymref');
fs.writeFileSync(path.join(f.adminDir, 'HEAD'), 'ref: refs/heads/does-not-exist\n');
const faultyGit = makeFaultyGit({ passthrough: realExecGit });
const row = onlyRow(reapOrphanWorktrees(f.repoDir, deadOwnerDeps({ execGit: faultyGit })));
assert.strictEqual(row.status, 'skipped');
assert.strictEqual(row.reason, 'cannot_resolve_branch_tip');
// Distinguishes the symbolic-ref arm from the missing-file and
// unrecognised-content arms, which all share this one reason string.
assert.strictEqual(calledWith(faultyGit, ['rev-parse', 'refs/heads/does-not-exist']), true);
});
test('reports cannot_resolve_branch_tip for an admin HEAD that is neither a symref nor a sha', () => {
const f = makeFixture(tmpBase, 'garbagehead');
const headFile = path.join(f.adminDir, 'HEAD');
fs.writeFileSync(headFile, 'not-a-ref\n');
const faultyGit = makeFaultyGit({ passthrough: realExecGit });
const row = onlyRow(reapOrphanWorktrees(f.repoDir, deadOwnerDeps({ execGit: faultyGit })));
assert.strictEqual(row.status, 'skipped');
assert.strictEqual(row.reason, 'cannot_resolve_branch_tip');
assert.ok(fs.existsSync(headFile), 'the HEAD file is present — this is not the missing-file arm');
assert.strictEqual(
faultyGit.calls.some((c) => c.args[0] === 'rev-parse' && String(c.args[1]).startsWith('refs/heads/')),
false,
'unrecognised HEAD content must not be handed to rev-parse'
);
});
test('reaps a detached admin HEAD without resolving any branch ref', () => {
const f = makeFixture(tmpBase, 'detached');
const branchTip = git(['rev-parse', f.branch], f.repoDir).trim();
fs.writeFileSync(path.join(f.adminDir, 'HEAD'), `${branchTip}\n`);
const faultyGit = makeFaultyGit({ passthrough: realExecGit });
const row = onlyRow(reapOrphanWorktrees(f.repoDir, deadOwnerDeps({ execGit: faultyGit })));
assert.strictEqual(row.status, 'reaped');
assert.strictEqual(row.reason, 'pid_dead_and_merged');
assert.strictEqual(
faultyGit.calls.some((c) => c.args[0] === 'rev-parse' && String(c.args[1]).startsWith('refs/heads/')),
false,
'a bare 40-hex HEAD is the tip; no ref resolution is needed'
);
assert.strictEqual(fs.existsSync(f.wtDir), false);
});
test('reports branch_not_merged for an unmerged branch whose lock owner is dead', () => {
const f = makeFixture(tmpBase, 'unmerged', { merge: false });
const row = onlyRow(reapOrphanWorktrees(f.repoDir, deadOwnerDeps()));
assert.strictEqual(row.status, 'skipped');
assert.strictEqual(row.reason, 'branch_not_merged');
assert.ok(fs.existsSync(f.wtDir), 'unmerged work must survive the sweep');
});
// ── The Number.isFinite PARSE gate ────────────────────────────────────────
// This gate is NOT the process.kill range limit (pinned in the next block).
// It fires far later, where `parseInt('9'.repeat(N), 10)` stops being
// representable: finite through N=308, Infinity from N=309 (measured).
// Reaching it means the reaper never learned a usable PID at all, so the
// verdict is `lock_owner_unknown`, not a liveness claim.
test('reports lock_owner_unknown for a 400-digit lock PID (parse overflows past the Number.isFinite gate)', () => {
const f = makeFixture(tmpBase, 'giantpid', { lock: false });
fs.writeFileSync(path.join(f.adminDir, 'locked'), '9'.repeat(400));
const row = onlyRow(reapOrphanWorktrees(f.repoDir, deadOwnerDeps()));
assert.deepStrictEqual({ status: row.status, reason: row.reason }, { status: 'skipped', reason: 'lock_owner_unknown' });
assert.ok(fs.existsSync(f.wtDir), 'a lock PID that overflows to Infinity must never be reaped');
});
test('passes a 308-digit lock PID through the Number.isFinite gate (last representable length)', () => {
const f = makeFixture(tmpBase, 'cliffminus1', { lock: false });
fs.writeFileSync(path.join(f.adminDir, 'locked'), '9'.repeat(308));
let seenPid;
const row = onlyRow(reapOrphanWorktrees(f.repoDir, deadOwnerDeps({
isPidAlive: (pid) => { seenPid = pid; return false; },
})));
assert.strictEqual(seenPid, Number('9'.repeat(308)), 'a finite 308-digit PID must reach isPidAlive unchanged');
assert.deepStrictEqual({ status: row.status, reason: row.reason }, { status: 'reaped', reason: 'pid_dead_and_merged' });
assert.strictEqual(fs.existsSync(f.wtDir), false);
});
test('stops a 309-digit lock PID at the Number.isFinite gate (first unrepresentable length)', () => {
const f = makeFixture(tmpBase, 'cliffexact', { lock: false });
fs.writeFileSync(path.join(f.adminDir, 'locked'), '9'.repeat(309));
const row = onlyRow(reapOrphanWorktrees(f.repoDir, deadOwnerDeps()));
assert.deepStrictEqual({ status: row.status, reason: row.reason }, { status: 'skipped', reason: 'lock_owner_unknown' });
assert.ok(fs.existsSync(f.wtDir));
});
// ── The process.kill RANGE cliff — the one that actually decides a reap ───
// Measured with the real `process.kill(pid, 0)` on this platform:
// 2147483647 → Error, code ESRCH (accepted; asks the OS)
// 2147483648 → TypeError ERR_INVALID_ARG_TYPE (rejected before the OS)
// Both tests drive the module's OWN `defaultIsPidAlive` (no `isPidAlive`
// injection) so the verdict is produced by the real errno classification.
// Each asserts the throw shape first: if a future Node moved the cliff, the
// probe fails loudly instead of the verdict flipping silently.
const PID_KILL_MAX = 2147483647;
test('treats the largest PID process.kill accepts as dead when the OS answers ESRCH', () => {
const f = makeFixture(tmpBase, 'killmax', { lock: false });
fs.writeFileSync(path.join(f.adminDir, 'locked'), String(PID_KILL_MAX));
// Measured cliff, lower side: this value reaches the OS, which has no such
// process (every platform's max PID is orders of magnitude below it).
assert.throws(
() => process.kill(PID_KILL_MAX, 0),
(err) => err.code === 'ESRCH',
`process.kill(${PID_KILL_MAX}, 0) must reach the OS and report ESRCH`
);
const row = onlyRow(reapOrphanWorktrees(f.repoDir, { mtimeSafe: () => STALE_MTIME }));
assert.deepStrictEqual(
{ status: row.status, reason: row.reason },
{ status: 'reaped', reason: 'pid_dead_and_merged' }
);
assert.strictEqual(fs.existsSync(f.wtDir), false);
});
test('treats the first PID process.kill rejects as ALIVE and leaves the worktree on disk', () => {
const f = makeFixture(tmpBase, 'killmaxplus1', { lock: false });
const overRange = PID_KILL_MAX + 1;
fs.writeFileSync(path.join(f.adminDir, 'locked'), String(overRange));
// Measured cliff, upper side: one past the accepted range, `process.kill`
// throws a TypeError with NO errno. That is "could not determine", not
// "dead" — the old errno-only catch read it as dead and REAPED here.
assert.throws(
() => process.kill(overRange, 0),
(err) => err instanceof TypeError && err.code === 'ERR_INVALID_ARG_TYPE',
`process.kill(${overRange}, 0) must throw TypeError ERR_INVALID_ARG_TYPE`
);
const row = onlyRow(reapOrphanWorktrees(f.repoDir, { mtimeSafe: () => STALE_MTIME }));
assert.deepStrictEqual({ status: row.status, reason: row.reason }, { status: 'skipped', reason: 'pid_alive' });
assert.ok(fs.existsSync(f.wtDir), 'an unclassifiable liveness probe must never reap');
});
test('treats an unrecognised errno from process.kill as ALIVE (only ESRCH means dead)', (t) => {
// EPERM has its own test above; this pins the GENERAL rule for a code the
// helper has never heard of, which an `=== EPERM ? true : false` catch
// would classify as dead.
const f = makeFixture(tmpBase, 'defaultkill-einval');
const originalKill = process.kill;
t.after(() => { process.kill = originalKill; });
process.kill = () => { throw Object.assign(new Error('EINVAL'), { code: 'EINVAL' }); };
const row = onlyRow(reapOrphanWorktrees(f.repoDir, { mtimeSafe: () => STALE_MTIME }));
assert.deepStrictEqual({ status: row.status, reason: row.reason }, { status: 'skipped', reason: 'pid_alive' });
assert.ok(fs.existsSync(f.wtDir));
});
test('treats a codeless throw from process.kill as ALIVE', (t) => {
// A thrown value with no `.code` at all (the TypeError case in the
// abstract): `undefined !== 'ESRCH'`, so it must still read as alive.
const f = makeFixture(tmpBase, 'defaultkill-bare');
const originalKill = process.kill;
t.after(() => { process.kill = originalKill; });
process.kill = () => { throw new Error('no errno on this one'); };
const row = onlyRow(reapOrphanWorktrees(f.repoDir, { mtimeSafe: () => STALE_MTIME }));
assert.deepStrictEqual({ status: row.status, reason: row.reason }, { status: 'skipped', reason: 'pid_alive' });
assert.ok(fs.existsSync(f.wtDir));
});
test('reaches the liveness check for an ordinary small lock PID', () => {
const f = makeFixture(tmpBase, 'ordinarypid', { lock: false });
fs.writeFileSync(path.join(f.adminDir, 'locked'), '4242');
let seenPid;
const row = onlyRow(reapOrphanWorktrees(f.repoDir, deadOwnerDeps({
isPidAlive: (pid) => { seenPid = pid; return false; },
})));
assert.strictEqual(seenPid, 4242, 'an ordinary PID must reach isPidAlive unchanged');
assert.deepStrictEqual({ status: row.status, reason: row.reason }, { status: 'reaped', reason: 'pid_dead_and_merged' });
assert.strictEqual(fs.existsSync(f.wtDir), false);
});
test('reports remove_failed and leaves the worktree on disk when git worktree remove fails', () => {
const f = makeFixture(tmpBase, 'removefails');
const faultyGit = makeFaultyGit({
faults: [{ kind: 'exit', exitCode: 1, when: ['worktree', 'remove'] }],
passthrough: realExecGit,
});
const row = onlyRow(reapOrphanWorktrees(f.repoDir, deadOwnerDeps({ execGit: faultyGit })));
assert.strictEqual(canonicalPath(row.path), canonicalPath(f.wtDir));
assert.strictEqual(row.status, 'skipped');
assert.strictEqual(row.reason, 'remove_failed');
assert.ok(fs.existsSync(f.wtDir));
assert.strictEqual(calledWith(faultyGit, ['worktree', 'unlock']), true, 'unlock precedes remove');
});
});
// ─── Suite: CLI wrappers ─────────────────────────────────────────────────────
describe('#3057 cmdWorktreeReapOrphans / pruneOrphanedWorktrees output verdicts', () => {
let tmpBase;
beforeEach(() => {
tmpBase = fs.mkdtempSync(path.join(resolvedTmpDir(), 'gsd-3057-reap-cli-'));
});
afterEach(() => {
cleanup(tmpBase);
});
test('cmdWorktreeReapOrphans reports ok with zero entries after the reaper throws', () => {
const out = [];
const err = [];
cmdWorktreeReapOrphans(tmpBase, {
write: (s) => out.push(s),
writeErr: (s) => err.push(s),
execGit: () => { throw new Error('boom'); },
});
assert.deepStrictEqual(err, ['[gsd] worktree.reap-orphans failed: boom\n']);
assert.deepStrictEqual(JSON.parse(out.join('')), { ok: true, reaped: 0, entries: [] });
});
test('cmdWorktreeReapOrphans warns with the skipped count and emits the skipped row as JSON', () => {
const f = makeFixture(tmpBase, 'cliskip', { merge: false });
const out = [];
const err = [];
cmdWorktreeReapOrphans(f.repoDir, {
write: (s) => out.push(s),
writeErr: (s) => err.push(s),
...deadOwnerDeps(),
});
assert.deepStrictEqual(err, [
'[gsd] worktree.reap-orphans: 1 orphan(s) skipped (run with DEBUG=1 for details)\n',
]);
const payload = JSON.parse(out.join(''));
assert.strictEqual(payload.ok, true);
assert.strictEqual(payload.reaped, 0);
assert.strictEqual(payload.entries.length, 1);
assert.strictEqual(payload.entries[0].status, 'skipped');
assert.strictEqual(payload.entries[0].reason, 'branch_not_merged');
});
test('cmdWorktreeReapOrphans stays silent on stderr when nothing is skipped', () => {
const f = makeFixture(tmpBase, 'cliclean');
const out = [];
const err = [];
cmdWorktreeReapOrphans(f.repoDir, {
write: (s) => out.push(s),
writeErr: (s) => err.push(s),
...deadOwnerDeps(),
});
assert.deepStrictEqual(err, []);
const payload = JSON.parse(out.join(''));
assert.strictEqual(payload.reaped, 1);
assert.strictEqual(payload.entries[0].reason, 'pid_dead_and_merged');
});
test('pruneOrphanedWorktrees warns that the health check degraded when git worktree prune times out', () => {
const f = makeFixture(tmpBase, 'prunetimeout');
const err = [];
const faultyGit = makeFaultyGit({
faults: [{ kind: 'timeout', when: ['worktree', 'prune'] }],
passthrough: realExecGit,
});
const removed = pruneOrphanedWorktrees(f.repoDir, {
execGit: faultyGit,
writeErr: (s) => err.push(s),
});
assert.deepStrictEqual(removed, []);
assert.deepStrictEqual(err, [
'[gsd-tools] WARNING: worktree health check degraded' +
' — git worktree prune timed out after 10s.' +
' Orphaned worktree metadata may remain until the next successful run.\n',
]);
});
test('pruneOrphanedWorktrees hands the porcelain to a caller-supplied parseWorktreePorcelain', () => {
const f = makeFixture(tmpBase, 'pruneparser');
const realPorcelain = git(['worktree', 'list', '--porcelain'], f.repoDir);
const seen = [];
const faultyGit = makeFaultyGit({ passthrough: realExecGit });
// `parseWorktreePorcelain` is a declared member of the deps bag and
// `planWorktreePrune` reads `deps.parseWorktreePorcelain` first, defaulting
// to the module function only when absent. pruneOrphanedWorktrees therefore
// spreads `...deps` AFTER its own hard-coded default so the caller's parser
// wins. Ordering the two the other way round is invisible to every other
// test in the tree; this one fails if the spread moves.
const removed = pruneOrphanedWorktrees(f.repoDir, {
execGit: faultyGit,
parseWorktreePorcelain: (porcelain) => { seen.push(porcelain); return []; },
writeErr: () => { throw new Error('no degradation warning expected'); },
});
assert.deepStrictEqual(removed, []);
assert.strictEqual(seen.length, 1, 'the injected parser must be the one that ran, exactly once');
assert.strictEqual(seen[0], realPorcelain, 'it must receive the porcelain readWorktreeList obtained');
assert.strictEqual(calledWith(faultyGit, ['worktree', 'prune']), true, 'the metadata prune still runs');
});
test('pruneOrphanedWorktrees returns an empty list and warns nothing when git throws', () => {
const f = makeFixture(tmpBase, 'prunethrows');
const err = [];
const removed = pruneOrphanedWorktrees(f.repoDir, {
execGit: () => { throw new Error('boom'); },
writeErr: (s) => err.push(s),
});
assert.deepStrictEqual(removed, [], 'a throwing git must never crash the caller');
assert.deepStrictEqual(err, [], 'the degraded-health warning belongs to the timeout arm only');
});
});

View File

@@ -3582,10 +3582,7 @@ describe('worktree-safety: pruneOrphanedWorktrees behaviour', () => {
test('pruneOrphanedWorktrees(temp dir) returns [] and does not throw', (t) => {
const dir = createTempDir('gsd-prune-');
t.after(() => cleanup(dir));
let result;
assert.doesNotThrow(() => {
result = worktreeSafety.pruneOrphanedWorktrees(dir);
});
const result = worktreeSafety.pruneOrphanedWorktrees(dir);
assert.deepStrictEqual(result, []);
});
});
@@ -3912,10 +3909,14 @@ describe('bug-3707: reapOrphanWorktrees', () => {
// ensuring the live-PID check is the only reason the entry is skipped.
const result = reapOrphanWorktrees(repoDir, { mtimeSafe: () => STALE_MTIME });
// #3057: assert the SPECIFIC verdict unconditionally. The previous form
// guarded on `if (skipped)`, so it passed vacuously whenever the entry was
// absent from the results entirely — the exact failure this test exists to
// catch.
const skipped = result.find((r) => canonicalPath(r.path) === canonicalPath(wtDir));
if (skipped) {
assert.notEqual(skipped.status, 'reaped', 'live-pid worktree must not be reaped');
}
assert.ok(skipped, 'live-pid worktree must appear in the results');
assert.equal(skipped.status, 'skipped', 'live-pid worktree must not be reaped');
assert.equal(skipped.reason, 'pid_alive', 'reason must be pid_alive');
assert.ok(fs.existsSync(wtDir), 'worktree directory must still exist for live-pid worktree');
});
@@ -3938,10 +3939,12 @@ describe('bug-3707: reapOrphanWorktrees', () => {
// ensuring the unmerged-branch check is the only reason the entry is skipped.
const result = reapOrphanWorktrees(repoDir, { mtimeSafe: () => STALE_MTIME });
// #3057: unconditional verdict assertion — the old `if (entry)` form passed
// vacuously when no row was produced at all.
const entry = result.find((r) => canonicalPath(r.path) === canonicalPath(wtDir));
if (entry) {
assert.notEqual(entry.status, 'reaped', 'unmerged worktree must not be reaped (data loss guard)');
}
assert.ok(entry, 'unmerged worktree must appear in the results');
assert.equal(entry.status, 'skipped', 'unmerged worktree must not be reaped (data loss guard)');
assert.equal(entry.reason, 'branch_not_merged', 'reason must be branch_not_merged');
assert.ok(fs.existsSync(wtDir), 'unmerged worktree directory must still exist');
});
@@ -3967,10 +3970,12 @@ describe('bug-3707: reapOrphanWorktrees', () => {
// within 5 minutes) which is fragile on heavily-loaded CI hosts.
const result = reapOrphanWorktrees(repoDir, { mtimeSafe: () => FRESH_MTIME });
// #3057: unconditional verdict assertion — the old `if (entry)` form passed
// vacuously when no row was produced at all.
const entry = result.find((r) => canonicalPath(r.path) === canonicalPath(wtDir));
if (entry) {
assert.notEqual(entry.status, 'reaped', 'fresh-mtime worktree must not be reaped (race guard)');
}
assert.ok(entry, 'fresh-mtime worktree must appear in the results');
assert.equal(entry.status, 'skipped', 'fresh-mtime worktree must not be reaped (race guard)');
assert.equal(entry.reason, 'lock_too_fresh', 'reason must be lock_too_fresh');
assert.ok(fs.existsSync(wtDir), 'fresh-lock worktree directory must still exist');
});
@@ -4189,16 +4194,12 @@ describe('bug-3707: reapOrphanWorktrees — adversarial edge cases', () => {
// ensuring the non-numeric content check is the only reason the entry is skipped.
const result = reapOrphanWorktrees(repoDir, { mtimeSafe: () => STALE_MTIME });
// #3057: unconditional verdict assertion — the old `if (entry)` form passed
// vacuously when no row was produced at all.
const entry = result.find((r) => canonicalPath(r.path) === canonicalPath(wtDir));
if (entry) {
assert.notEqual(
entry.status,
'reaped',
'non-numeric Claude Code lock must NOT be reaped (fail-closed: owner unknown)'
);
assert.equal(entry.status, 'skipped', 'non-numeric lock entry should have status=skipped');
assert.equal(entry.reason, 'lock_owner_unknown', 'reason must be lock_owner_unknown');
}
assert.ok(entry, 'Claude-Code-locked worktree must appear in the results');
assert.equal(entry.status, 'skipped', 'non-numeric lock entry should have status=skipped');
assert.equal(entry.reason, 'lock_owner_unknown', 'reason must be lock_owner_unknown');
assert.ok(fs.existsSync(wtDir), 'worktree with Claude Code lock must NOT be removed');
});
@@ -4232,10 +4233,13 @@ describe('bug-3707: reapOrphanWorktrees — adversarial edge cases', () => {
mtimeSafe: () => STALE_MTIME,
});
// #3057: unconditional verdict assertion. An undeterminable owner takes the
// same fail-closed exit as a genuinely live one, so the reason string is
// pid_alive in both cases — production deliberately conflates them.
const entry = result.find((r) => canonicalPath(r.path) === canonicalPath(wtDir));
if (entry) {
assert.notEqual(entry.status, 'reaped', 'EPERM from isPidAlive must be treated as ALIVE — must not reap');
}
assert.ok(entry, 'EPERM worktree must appear in the results');
assert.equal(entry.status, 'skipped', 'EPERM from isPidAlive must be treated as ALIVE — must not reap');
assert.equal(entry.reason, 'pid_alive', 'reason must be pid_alive');
assert.ok(fs.existsSync(wtDir), 'worktree must still exist when isPidAlive throws EPERM');
});
@@ -4282,11 +4286,12 @@ describe('bug-3707: reapOrphanWorktrees — adversarial edge cases', () => {
// OR skip it for a safe reason — it must NOT return an empty result (which
// would mean it bailed out entirely, silently skipping all orphan detection).
assert.ok(Array.isArray(result), 'reapOrphanWorktrees must return an array');
assert.ok(result.length > 0, 'reaper must not bail out entirely for trunk-default repos — must inspect the worktree');
assert.equal(result.length, 1, 'reaper must inspect exactly the one worktree in this trunk-default repo — not bail out entirely, and not report extras');
const entry = result.find((r) => canonicalPath(r.path) === wtDirCanonical);
assert.ok(entry, 'worktree must appear in results (reaped or skipped with reason)');
// The branch IS merged into trunk, and the PID is dead, so it should be reaped.
assert.equal(entry.status, 'reaped', 'worktree with dead pid merged into trunk must be reaped');
assert.equal(entry.reason, 'pid_dead_and_merged', 'reason must be pid_dead_and_merged (using trunk as the default branch)');
});
});
});
@@ -4755,8 +4760,7 @@ describe('bug #260: gsd-worktree-path-guard.js', () => {
};
const result = runHook(worktreeDir, payload);
assert.strictEqual(result.status, 2, `Expected exit 2 (block), got ${result.status}. stderr: ${result.stderr}`);
let parsed;
assert.doesNotThrow(() => { parsed = JSON.parse(result.stdout); }, 'stdout must be valid JSON');
const parsed = JSON.parse(result.stdout);
assert.strictEqual(parsed.decision, 'block', 'Expected decision:"block" in output');
});
@@ -5341,8 +5345,7 @@ describe('#1342 — GSD-activity gate + fail-open for no-repo targets', () => {
`GSD-managed worktree targeting main repo root must be blocked (exit 2). ` +
`Got exit ${result.status}. stderr: ${result.stderr}`
);
let parsed;
assert.doesNotThrow(() => { parsed = JSON.parse(result.stdout); }, 'stdout must be valid JSON');
const parsed = JSON.parse(result.stdout);
assert.strictEqual(parsed.decision, 'block', 'Expected decision:"block" in output');
});
@@ -5407,8 +5410,7 @@ describe('#1342 — GSD-activity gate + fail-open for no-repo targets', () => {
`GSD-managed worktree targeting .git/config of another repo must be blocked (exit 2). ` +
`Got exit ${result.status}. stderr: ${result.stderr}`
);
let parsed;
assert.doesNotThrow(() => { parsed = JSON.parse(result.stdout); }, 'stdout must be valid JSON');
const parsed = JSON.parse(result.stdout);
assert.strictEqual(parsed.decision, 'block', 'Expected decision:"block" in output');
assert.ok(
parsed.reason && parsed.reason.includes('.git'),