fix(#3050): consolidate the spawn-timeout predicate and propagate the unresolved-root reason (#3060)

* chore(#3050): changeset and review artifacts for the follow-up

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(#3050): backfill changeset pr number

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-08-04 17:21:24 -04:00
committed by GitHub
parent 24066e536e
commit 4eb8e3648c
15 changed files with 530 additions and 253 deletions

View File

@@ -3838,3 +3838,56 @@ describe('gsd-tools.cjs dispatch/help/skip-list parity (DEFECT.GENERATIVE-FIX, #
assert.equal(typeof gsdTools.skipsRootResolution, 'function');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// resolveMainWorktreeCwd — #3050: gsd-tools must surface (not silently
// swallow) a git_timed_out worktree-root resolution, since writing planning
// artifacts to the wrong tree is the exact fail-open the issue names.
// ─────────────────────────────────────────────────────────────────────────────
describe('gsd-tools.cjs resolveMainWorktreeCwd (#3050)', () => {
const { resolveMainWorktreeCwd } = require('../gsd-core/bin/gsd-tools.cjs');
test('emits a WARNING to stderr and still resolves the fallback root on git_timed_out', () => {
const warnings = [];
const resolved = resolveMainWorktreeCwd('/repo/wt', {
existsSync: () => false,
resolveWorktreeRoot: () => ({ root: '/repo/wt', reason: 'git_timed_out' }),
writeWarning: (msg) => warnings.push(msg),
});
assert.equal(resolved, '/repo/wt');
assert.equal(warnings.length, 1, 'must emit exactly one warning on git_timed_out');
assert.match(warnings[0], /git timed out/i);
assert.match(warnings[0], /wrong tree/i);
});
test('does NOT warn on a benign reason (linked_worktree)', () => {
const warnings = [];
const resolved = resolveMainWorktreeCwd('/repo/wt', {
existsSync: () => false,
resolveWorktreeRoot: () => ({ root: '/repo', reason: 'linked_worktree' }),
writeWarning: (msg) => warnings.push(msg),
});
assert.equal(resolved, '/repo');
assert.deepEqual(warnings, [], 'must not warn for a benign, definitive resolution');
});
test('does NOT warn on a benign reason (not_git_repo)', () => {
const warnings = [];
const resolved = resolveMainWorktreeCwd('/repo/wt', {
existsSync: () => false,
resolveWorktreeRoot: () => ({ root: '/repo/wt', reason: 'not_git_repo' }),
writeWarning: (msg) => warnings.push(msg),
});
assert.equal(resolved, '/repo/wt');
assert.deepEqual(warnings, []);
});
test('short-circuits (never calls resolveWorktreeRoot) when .planning already exists in cwd', () => {
const resolved = resolveMainWorktreeCwd('/repo/wt', {
existsSync: () => true,
resolveWorktreeRoot: () => { throw new Error('must not be called when .planning exists locally'); },
writeWarning: () => { throw new Error('must not warn when short-circuited'); },
});
assert.equal(resolved, '/repo/wt');
});
});

View File

@@ -54,8 +54,13 @@ const LIB = path.join(__dirname, '..', 'gsd-core', 'bin', 'lib');
* returning the parsed JSON result and the git argv list that was actually
* executed (so "git commit never ran" is asserted directly, not inferred).
*/
function commitWithFailingAdd({ cwd, files, failFor = [], stderr = 'fatal: injected staging failure', timeout = false, amend = false }) {
function commitWithFailingAdd({ cwd, files, failFor = [], stderr = 'fatal: injected staging failure', timeout = false, amend = false, gitVerb = 'add' }) {
const callsOut = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2608-')), 'calls.json');
// `timeout` is `false` | `true` (alias for `'posix'`) | `'posix'` | `'windows'` —
// #3050: the shared isSpawnTimeout predicate only requires `error.code ===
// 'ETIMEDOUT'`, NOT `signal === 'SIGTERM'` (Windows does not reliably report
// SIGTERM), so both shapes must be proven to still read as a timeout.
const timeoutShape = timeout === true ? 'posix' : timeout;
const script = `
const path = require('path');
const LIB = ${JSON.stringify(LIB)};
@@ -63,19 +68,27 @@ const projection = require(path.join(LIB, 'shell-command-projection.cjs'));
const { cmdCommit } = require(path.join(LIB, 'commands.cjs'));
const failFor = ${JSON.stringify(failFor)};
const stderrText = ${JSON.stringify(stderr)};
const timedOut = ${JSON.stringify(timeout)};
const timeoutShape = ${JSON.stringify(timeoutShape)};
const gitVerb = ${JSON.stringify(gitVerb)};
const real = projection.execGit;
const calls = [];
projection.execGit = (args, opts) => {
calls.push(args);
if (args[0] === 'add' && failFor.includes(args[args.length - 1])) {
if (timedOut) {
// The exact shape spawnSync produces on a timeout, which
if (args[0] === gitVerb && failFor.includes(args[args.length - 1])) {
if (timeoutShape === 'posix') {
// The exact shape spawnSync produces on a POSIX timeout, which
// shell-command-projection surfaces as signal + error.code.
const e = new Error('spawnSync git ETIMEDOUT');
e.code = 'ETIMEDOUT';
return { exitCode: 1, stdout: '', stderr: stderrText, signal: 'SIGTERM', error: e };
}
if (timeoutShape === 'windows') {
// Windows shape: spawnSync's timeout kill does not reliably report
// signal:'SIGTERM' — only error.code:'ETIMEDOUT' is guaranteed (#3050).
const e = new Error('spawnSync git ETIMEDOUT');
e.code = 'ETIMEDOUT';
return { exitCode: 1, stdout: '', stderr: stderrText, signal: null, error: e };
}
return { exitCode: 128, stdout: '', stderr: stderrText, signal: null, error: null };
}
return real(args, opts);
@@ -117,16 +130,29 @@ function committedFiles(cwd) {
* which carried the identical defect (failed `git add` dropped, commit proceeds
* with the subset that staged).
*/
function subrepoCommitWithFailingAdd({ cwd, files, failFor = [] }) {
function subrepoCommitWithFailingAdd({ cwd, files, failFor = [], timeout = false }) {
// See commitWithFailingAdd above for the timeoutShape rationale (#3050).
const timeoutShape = timeout === true ? 'posix' : timeout;
const script = `
const path = require('path');
const LIB = ${JSON.stringify(LIB)};
const projection = require(path.join(LIB, 'shell-command-projection.cjs'));
const { cmdCommitToSubrepo } = require(path.join(LIB, 'commands.cjs'));
const failFor = ${JSON.stringify(failFor)};
const timeoutShape = ${JSON.stringify(timeoutShape)};
const real = projection.execGit;
projection.execGit = (args, opts) => {
if (args[0] === 'add' && failFor.includes(args[args.length - 1])) {
if (timeoutShape === 'posix') {
const e = new Error('spawnSync git ETIMEDOUT');
e.code = 'ETIMEDOUT';
return { exitCode: 1, stdout: '', stderr: 'fatal: injected subrepo staging failure', signal: 'SIGTERM', error: e };
}
if (timeoutShape === 'windows') {
const e = new Error('spawnSync git ETIMEDOUT');
e.code = 'ETIMEDOUT';
return { exitCode: 1, stdout: '', stderr: 'fatal: injected subrepo staging failure', signal: null, error: e };
}
return { exitCode: 128, stdout: '', stderr: 'fatal: injected subrepo staging failure', signal: null, error: null };
}
return real(args, opts);
@@ -309,6 +335,51 @@ describe('#2608: commit --files fails closed when git add fails', () => {
assert.equal(result.failures[0].timed_out, true);
});
// #3050 item 4: this site (commands.cts's `git add` staging loop) now routes
// through the shared isSpawnTimeout predicate, which drops the `signal ===
// 'SIGTERM'` requirement — a Windows-shaped timeout (no signal, only
// error.code === 'ETIMEDOUT') must still be detected.
test('a staging timeout is reported as staging_timeout even without SIGTERM (Windows shape, #3050)', () => {
const { result } = commitWithFailingAdd({
cwd: tmpDir,
files: ['.planning/ARCHITECTURE.md'],
failFor: ['.planning/ARCHITECTURE.md'],
stderr: '',
timeout: 'windows',
});
assert.equal(result.reason, 'staging_timeout');
assert.equal(result.failures[0].timed_out, true);
});
// #3050 item 4: the `git rm --cached` branch of the same staging loop (the
// default-mode "stage the deletion" path, distinct from `git add` above)
// carries its own inline copy of the timeout check pre-fix. Drive it
// directly: default mode (no explicit --files) stages '.planning/', and
// when that path is absent on disk the loop takes the `git rm --cached`
// branch instead of `git add`.
test('a `git rm --cached` timeout in default mode is reported as staging_timeout, POSIX and Windows shapes (#3050)', () => {
// Mid-test fixture mutation (simulating an absent '.planning/' on disk),
// not teardown; the outer afterEach still runs helpers.cleanup(tmpDir) on
// the whole tmpDir.
// eslint-disable-next-line local/no-raw-rmsync-in-tests -- see comment above
fs.rmSync(path.join(tmpDir, '.planning'), { recursive: true, force: true });
for (const shape of ['posix', 'windows']) {
const { result } = commitWithFailingAdd({
cwd: tmpDir,
files: undefined,
failFor: ['.planning/'],
gitVerb: 'rm',
stderr: '',
timeout: shape,
});
assert.equal(result.reason, 'staging_timeout', `shape=${shape}`);
assert.equal(result.failures[0].timed_out, true, `shape=${shape}`);
}
});
test('an ordinary non-zero git add is NOT reported as a timeout', () => {
// Boundary: the timeout carve-out must not swallow the ordinary case.
const { result } = commitWithFailingAdd({

View File

@@ -1,9 +1,10 @@
'use strict';
const { describe, test, beforeEach, afterEach } = require('node:test');
const { describe, test, beforeEach, afterEach, mock } = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const fs = require('node:fs');
const childProcess = require('node:child_process');
const {
execGit,
@@ -113,7 +114,7 @@ describe('dispatchGsdCommand', () => {
let tmpDir;
beforeEach(() => { tmpDir = createTempDir(); });
afterEach(() => { cleanup(tmpDir); });
afterEach(() => { cleanup(tmpDir); mock.restoreAll(); });
test('resolveGsdToolsPath resolves to the real gsd-tools.cjs on disk', () => {
const toolsPath = resolveGsdToolsPath();
@@ -171,6 +172,26 @@ describe('dispatchGsdCommand', () => {
assert.equal(result.timedOut, true);
});
});
// #3050 item 4: this site (dispatchGsdCommand's `timedOut` derivation) now
// routes through the shared isSpawnTimeout predicate, which drops the
// `signal === 'SIGTERM'` requirement — a Windows-shaped timeout (no signal,
// only error.code === 'ETIMEDOUT') must still be detected. The real-timeout
// test above only exercises whatever shape THIS OS's spawnSync happens to
// produce (SIGTERM on POSIX); mocking spawnSync proves the Windows shape too.
test('Windows-shaped timeout (no signal, error.code ETIMEDOUT) is still reported as timedOut:true (#3050)', () => {
mock.method(childProcess, 'spawnSync', () => ({
status: null,
stdout: '',
stderr: '',
signal: null,
error: Object.assign(new Error('spawnSync ETIMEDOUT'), { code: 'ETIMEDOUT' }),
}));
const result = dispatchGsdCommand({ family: 'progress', subcommand: 'json', cwd: tmpDir });
assert.equal(result.ok, false);
assert.equal(result.timedOut, true);
});
});
// ─── probeTty ────────────────────────────────────────────────────────────────

View File

@@ -300,6 +300,86 @@ describe('evaluateWorktreeBaseDegrade', () => {
assert.strictEqual(result.reason, 'no-head');
});
// ─── #3050: fail-closed matrix for git rev-parse HEAD outcomes ─────────────
// DECIDED RULE: degrade UNLESS git completed and gave a definitive answer.
// - timeout → degrade, reason 'head-unresolvable'
// - exitCode === 128 → NO degrade, reason 'no-head' (unchanged)
// - exit 0 with non-empty sha → proceed (unchanged)
// - anything else (127, other → degrade, reason 'head-unresolvable'
// non-zero, exit 0 empty stdout
// is pinned separately above)
test('git rev-parse HEAD TIMES OUT → shouldDegrade:true, reason "head-unresolvable" (#3050)', () => {
const timedOutErr = new Error('spawnSync git ETIMEDOUT');
timedOutErr.code = 'ETIMEDOUT';
const result = evaluateWorktreeBaseDegrade({
execGit: makeExecGit({
'rev-parse HEAD': { exitCode: null, stdout: '', stderr: '', signal: 'SIGTERM', error: timedOutErr },
}),
});
assert.strictEqual(result.shouldDegrade, true);
assert.strictEqual(result.reason, 'head-unresolvable');
assert.ok(result.message, 'a fail-closed degrade must carry a non-null explanatory message');
assert.strictEqual(result.headSha, null);
});
test('cross-platform: timeout WITHOUT signal set (Windows shape) still degrades (#3050)', () => {
// Node.js guarantees error.code === 'ETIMEDOUT' cross-platform when the
// spawnSync `timeout` option fires; `signal` reporting is the
// platform-fragile half and must not be required to detect a timeout.
const timedOutErr = new Error('spawnSync git ETIMEDOUT');
timedOutErr.code = 'ETIMEDOUT';
const result = evaluateWorktreeBaseDegrade({
execGit: makeExecGit({
'rev-parse HEAD': { exitCode: null, stdout: '', stderr: '', signal: null, error: timedOutErr },
}),
});
assert.strictEqual(result.shouldDegrade, true);
assert.strictEqual(result.reason, 'head-unresolvable');
});
test('git missing (exitCode 127) → degrade, reason "head-unresolvable" (#3050)', () => {
const result = evaluateWorktreeBaseDegrade({
execGit: makeExecGit({
'rev-parse HEAD': { exitCode: 127, stdout: '', stderr: 'git: not found', signal: null, error: null },
}),
});
assert.strictEqual(result.shouldDegrade, true);
assert.strictEqual(result.reason, 'head-unresolvable');
});
// Boundary coverage: 128 is the ONLY benign non-zero exit (definitive "not a
// git repository"). 129 (limit+1) must NOT be swept into that carve-out.
test('exitCode 129 (limit+1 boundary, just past the 128 carve-out) → degrade, reason "head-unresolvable" (#3050)', () => {
const result = evaluateWorktreeBaseDegrade({
execGit: makeExecGit({
'rev-parse HEAD': { exitCode: 129, stdout: '', stderr: 'fatal: something else', signal: null, error: null },
}),
});
assert.strictEqual(result.shouldDegrade, true);
assert.strictEqual(result.reason, 'head-unresolvable');
});
test('other non-zero, non-128 exit → degrade, reason "head-unresolvable" (#3050)', () => {
const result = evaluateWorktreeBaseDegrade({
execGit: makeExecGit({
'rev-parse HEAD': { exitCode: 1, stdout: '', stderr: 'fatal: something else', signal: null, error: null },
}),
});
assert.strictEqual(result.shouldDegrade, true);
assert.strictEqual(result.reason, 'head-unresolvable');
});
test('exitCode 128 ("not a git repository") still does NOT degrade (#3050 regression guard)', () => {
const result = evaluateWorktreeBaseDegrade({
execGit: makeExecGit({
'rev-parse HEAD': { exitCode: 128, stdout: '', stderr: 'fatal: not a git repository', signal: null, error: null },
}),
});
assert.strictEqual(result.shouldDegrade, false);
assert.strictEqual(result.reason, 'no-head');
});
test('HEAD == origin/HEAD → no degrade, reason head-matches-fork', () => {
const HEAD_SHA = 'aabbccdd11223344aabbccdd11223344aabbccdd';
const result = evaluateWorktreeBaseDegrade({

View File

@@ -1,186 +0,0 @@
'use strict';
/**
* Worktree fail-open-guards regression tests (#3050).
*
* Covers three defects where a git TIMEOUT was silently collapsed into a
* benign "not a git repo" / "current directory" outcome instead of failing
* closed:
*
* 1. evaluateWorktreeBaseDegrade (worktree-base-ref.cjs) — a `rev-parse
* HEAD` timeout must degrade (shouldDegrade:true), not be treated the
* same as "not a git repository".
* 2. resolveWorktreeContext (worktree-safety.cjs) — a `rev-parse
* --git-dir`/`--git-common-dir` timeout must be distinguishable from
* the genuine not-a-git-repo case, not silently fall back to
* current_directory with an identical reason.
* 3. cmdWorktreeCreate (worktree-safety.cjs) — root confinement must not
* be skippable by omitting `--root`; it must fail closed.
*
* All tests drive the real production functions through the injected
* execGit/deps seam — no real filesystem or git subprocess is touched.
*/
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const BASE_REF_MODULE_PATH = path.join(
__dirname, '..', 'gsd-core', 'bin', 'lib', 'worktree-base-ref.cjs'
);
const WORKTREE_SAFETY_MODULE_PATH = path.join(
__dirname, '..', 'gsd-core', 'bin', 'lib', 'worktree-safety.cjs'
);
const { evaluateWorktreeBaseDegrade } = require(BASE_REF_MODULE_PATH);
const {
resolveWorktreeContext,
cmdWorktreeCreate,
} = require(WORKTREE_SAFETY_MODULE_PATH);
// ─── helpers ────────────────────────────────────────────────────────────────
/**
* Build a mock execGit result mirroring the real shape spawnSync-derived
* results carry: {exitCode, stdout, stderr, signal, error}.
*/
function gitResult({ exitCode = 0, stdout = '', stderr = '', signal = null, error = null } = {}) {
return { exitCode, stdout, stderr, signal, error };
}
/** A timed-out git result: SIGTERM + ETIMEDOUT, per the established idiom. */
function timedOutResult() {
const err = new Error('spawnSync git ETIMEDOUT');
err.code = 'ETIMEDOUT';
return gitResult({ exitCode: null, signal: 'SIGTERM', error: err });
}
/** A genuine "not a git repository" result (git exits 128). */
function notAGitRepoResult() {
return gitResult({ exitCode: 128, stderr: 'fatal: not a git repository (or any of the parent directories): .git' });
}
// ─── DEFECT 1: evaluateWorktreeBaseDegrade must fail closed on HEAD timeout ──
describe('evaluateWorktreeBaseDegrade — HEAD resolution timeout (#3050 DEFECT 1)', () => {
test('rev-parse HEAD TIMES OUT → shouldDegrade:true (fail closed, distinct reason)', () => {
const execGit = (args) => {
assert.deepStrictEqual(args, ['rev-parse', 'HEAD']);
return timedOutResult();
};
const result = evaluateWorktreeBaseDegrade({ execGit, effectiveBaseRef: null, cwd: '/repo' });
assert.strictEqual(result.shouldDegrade, true);
assert.notStrictEqual(result.reason, 'no-head');
assert.ok(result.message, 'a fail-closed degrade must carry a non-null explanatory message');
assert.strictEqual(result.headSha, null);
});
test('genuine not-a-git-repo (exit 128) → shouldDegrade:false, reason "no-head" (regression guard — unchanged)', () => {
const execGit = () => notAGitRepoResult();
const result = evaluateWorktreeBaseDegrade({ execGit, effectiveBaseRef: null, cwd: '/repo' });
assert.strictEqual(result.shouldDegrade, false);
assert.strictEqual(result.reason, 'no-head');
assert.strictEqual(result.message, null);
});
test('HEAD resolves but fork ref is unresolvable → reason "fork-ref-unknown" (regression guard — unchanged)', () => {
const execGit = (args) => {
if (args[0] === 'rev-parse' && args[1] === 'HEAD') return gitResult({ stdout: 'aaaa111\n' });
// origin/HEAD direct rev-parse and symbolic-ref fallback both fail.
return gitResult({ exitCode: 1 });
};
const result = evaluateWorktreeBaseDegrade({ execGit, effectiveBaseRef: null, cwd: '/repo' });
assert.strictEqual(result.shouldDegrade, true);
assert.strictEqual(result.reason, 'fork-ref-unknown');
assert.strictEqual(result.headSha, 'aaaa111');
});
test('HEAD == fork sha → shouldDegrade:false, reason "head-matches-fork" (regression guard — unchanged)', () => {
const sha = 'deadbeef00000000000000000000000000000000';
const execGit = (args) => {
if (args[0] === 'rev-parse' && args[1] === 'HEAD') return gitResult({ stdout: `${sha}\n` });
if (args.includes('origin/HEAD')) return gitResult({ stdout: `${sha}\n` });
return gitResult({ exitCode: 1 });
};
const result = evaluateWorktreeBaseDegrade({ execGit, effectiveBaseRef: null, cwd: '/repo' });
assert.strictEqual(result.shouldDegrade, false);
assert.strictEqual(result.reason, 'head-matches-fork');
});
test('HEAD diverged from fork → shouldDegrade:true, reason "head-diverged-from-fork" (regression guard — unchanged)', () => {
const headSha = 'aaaaaaaa00000000000000000000000000000000';
const forkSha = 'bbbbbbbb00000000000000000000000000000000';
const execGit = (args) => {
if (args[0] === 'rev-parse' && args[1] === 'HEAD') return gitResult({ stdout: `${headSha}\n` });
if (args.includes('origin/HEAD')) return gitResult({ stdout: `${forkSha}\n` });
return gitResult({ exitCode: 1 });
};
const result = evaluateWorktreeBaseDegrade({ execGit, effectiveBaseRef: null, cwd: '/repo' });
assert.strictEqual(result.shouldDegrade, true);
assert.strictEqual(result.reason, 'head-diverged-from-fork');
});
test('baseRef:"head" short-circuits before any git call → shouldDegrade:false (regression guard — unchanged)', () => {
const execGit = () => { throw new Error('execGit must not be called when baseRef is "head"'); };
const result = evaluateWorktreeBaseDegrade({ execGit, effectiveBaseRef: 'head', cwd: '/repo' });
assert.strictEqual(result.shouldDegrade, false);
assert.strictEqual(result.reason, 'baseref-head');
});
});
// ─── DEFECT 2: resolveWorktreeContext must distinguish timeout from not-a-repo ──
describe('resolveWorktreeContext — git-dir resolution timeout (#3050 DEFECT 2)', () => {
test('git rev-parse --git-dir TIMES OUT → reason is distinguishable from not_git_repo', () => {
const execGit = (args) => {
if (args.includes('--git-dir')) return { ...timedOutResult(), timedOut: true };
return { ...notAGitRepoResult(), timedOut: false };
};
const context = resolveWorktreeContext('/repo', { execGit, existsSync: () => false });
assert.notStrictEqual(context.reason, 'not_git_repo');
});
test('genuine not-a-git-repo (both calls exit 128, no timeout) → reason "not_git_repo" (regression guard — unchanged)', () => {
const execGit = () => ({ ...notAGitRepoResult(), timedOut: false });
const context = resolveWorktreeContext('/repo', { execGit, existsSync: () => false });
assert.strictEqual(context.effectiveRoot, '/repo');
assert.strictEqual(context.mode, 'current_directory');
assert.strictEqual(context.reason, 'not_git_repo');
});
});
// ─── DEFECT 3: worktree create root confinement must not be skippable ───────
describe('cmdWorktreeCreate — root confinement is mandatory (#3050 DEFECT 3)', () => {
// process.exitCode is global; cmdWorktreeCreate sets it as a side effect on
// its failure paths (it doubles as the CLI entry point). Calling it directly
// in-process — rather than through a spawned subprocess — means that side
// effect leaks into THIS test file's own process.exitCode, which makes the
// whole file exit non-zero even though every assertion passes (mirrors the
// documented hazard + `withExitCode` guard in tests/worktree-safety.test.cjs
// around cmdWorktreeRecordAgent). Save/restore it so this test can assert
// on the failure path without poisoning the file's own exit status.
function withExitCode(fn) {
const saved = process.exitCode;
try { return fn(); } finally { process.exitCode = saved; }
}
test('omitting --root fails closed instead of silently skipping confinement', () => {
const deps = {
readFile: () => JSON.stringify({ orchestrator_root: '/repo', worktrees: [] }),
writeFile: () => { throw new Error('writeFile must not be called — confinement must fail before any write'); },
execGit: () => { throw new Error('execGit must not be called — confinement must fail before any git side effect'); },
write: () => {},
writeErr: () => {},
};
const result = withExitCode(() => cmdWorktreeCreate('/repo', [
'--manifest', 'manifest.json',
'--agent-id', 'agent-1',
'--path', '/repo/.claude/worktrees/agent-1',
'--branch', 'worktree-agent-agent-1',
'--base', 'abc1234',
], deps));
assert.strictEqual(result.ok, false);
assert.notStrictEqual(result.reason, 'created');
});
});

View File

@@ -15,9 +15,10 @@
* - tests/bug-3384-worktree-cleanup-manifest.test.cjs (manifest-scoped cleanup module)
*/
const { describe, test } = require('node:test');
const { describe, test, afterEach, mock } = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const childProcess = require('node:child_process');
const fc = require('fast-check');
const { createTempGitProject, createTempDir, cleanup } = require('./helpers.cjs');
@@ -140,6 +141,149 @@ describe('resolveWorktreeContext', () => {
'must return effectiveRoot string even on timeout'
);
});
// ─── #3050 DEFECT 2: timeout must be distinguishable from not_git_repo ─────
test('git rev-parse --git-dir/--git-common-dir TIMES OUT → reason "git_timed_out" (#3050)', () => {
const execGit = (args) => {
if (args.includes('--git-dir')) return { ...makeTimeoutStub()(args), timedOut: true };
return { exitCode: 128, stdout: '', stderr: 'fatal: not a git repository', timedOut: false };
};
const context = resolveWorktreeContext('/repo', { execGit, existsSync: () => false });
assert.strictEqual(context.reason, 'git_timed_out');
assert.notStrictEqual(context.reason, 'not_git_repo');
assert.strictEqual(context.effectiveRoot, '/repo');
});
// ─── #3050 item 7: drive the REAL spawn seam, not a hand-set execGit stub ──
// Every test above injects deps.execGit with a hand-set `timedOut`, so the
// production execGitDefault → shell-command-projection's execGit →
// isSpawnTimeout chain is never actually exercised, and a Windows-shaped
// timeout (spawnSync reports no `signal`, only `error.code === 'ETIMEDOUT'`)
// is unexercised on this half. This test omits deps.execGit entirely so
// resolveWorktreeContext falls through to the real execGitDefault, and
// mocks node:child_process.spawnSync — the actual primitive
// shell-command-projection.cjs's execGit wraps — to return that exact
// Windows shape.
describe('execGitDefault (real spawn seam)', () => {
afterEach(() => {
mock.restoreAll();
});
test('Windows-shaped timeout (no signal, error.code ETIMEDOUT) is detected as a real timeout (#3050)', () => {
mock.method(childProcess, 'spawnSync', () => ({
status: null,
stdout: '',
stderr: '',
signal: null,
error: Object.assign(new Error('spawnSync git ETIMEDOUT'), { code: 'ETIMEDOUT' }),
}));
const context = resolveWorktreeContext('/repo', { existsSync: () => false });
assert.strictEqual(context.reason, 'git_timed_out');
assert.strictEqual(context.effectiveRoot, '/repo');
});
test('POSIX-shaped timeout (SIGTERM + error.code ETIMEDOUT) is also detected as a real timeout', () => {
mock.method(childProcess, 'spawnSync', () => ({
status: null,
stdout: '',
stderr: '',
signal: 'SIGTERM',
error: Object.assign(new Error('spawnSync git ETIMEDOUT'), { code: 'ETIMEDOUT' }),
}));
const context = resolveWorktreeContext('/repo', { existsSync: () => false });
assert.strictEqual(context.reason, 'git_timed_out');
});
test('externally-delivered SIGTERM with no ETIMEDOUT error is NOT reported as a timeout', () => {
// Boundary: the timeout carve-out must not swallow a plain non-zero exit
// that merely happens to carry a signal, absent an ETIMEDOUT error.
mock.method(childProcess, 'spawnSync', () => ({
status: null,
stdout: '',
stderr: 'fatal: not a git repository',
signal: 'SIGTERM',
error: null,
}));
const context = resolveWorktreeContext('/repo', { existsSync: () => false });
assert.notStrictEqual(context.reason, 'git_timed_out');
});
});
});
// ─── #3050 item 4: shared timeout predicate — single source, no divergence ──
// worktree-safety.cjs's execGitDefault and worktree-base-ref.cjs's
// isExecGitTimeout both now delegate to shell-command-projection.cjs's
// isSpawnTimeout. This describe block asserts the parity half of that claim
// for worktree-base-ref's PUBLIC evaluateWorktreeBaseDegrade — driving it
// against the same synthetic spawn results the shared predicate is tested
// with directly, so a future edit that reintroduces a local, diverging copy
// of the timeout check in worktree-base-ref.cts will make this test fail
// rather than silently drift. (worktree-safety.cjs's own half of this parity
// — execGitDefault via resolveWorktreeContext — is covered separately above,
// by "execGitDefault (real spawn seam)", which drives the real
// node:child_process.spawnSync primitive rather than a synthetic result.)
describe('shared isSpawnTimeout predicate — parity for worktree-base-ref evaluateWorktreeBaseDegrade (#3050)', () => {
const { isSpawnTimeout } = require(path.join(
__dirname, '..', 'gsd-core', 'bin', 'lib', 'shell-command-projection.cjs'
));
const { evaluateWorktreeBaseDegrade } = require(path.join(
__dirname, '..', 'gsd-core', 'bin', 'lib', 'worktree-base-ref.cjs'
));
const cases = [
{
name: 'SIGTERM + ETIMEDOUT (POSIX shape)',
result: { signal: 'SIGTERM', error: Object.assign(new Error('x'), { code: 'ETIMEDOUT' }) },
expectTimeout: true,
},
{
name: 'no signal + ETIMEDOUT (Windows shape)',
result: { signal: null, error: Object.assign(new Error('x'), { code: 'ETIMEDOUT' }) },
expectTimeout: true,
},
{
name: 'externally-delivered SIGTERM, no error (not a timeout)',
result: { signal: 'SIGTERM', error: null },
expectTimeout: false,
},
{
name: 'clean non-zero exit, no signal, no error',
result: { signal: null, error: null },
expectTimeout: false,
},
];
for (const { name, result, expectTimeout } of cases) {
test(`isSpawnTimeout(${name}) === ${expectTimeout}, and evaluateWorktreeBaseDegrade agrees`, () => {
assert.strictEqual(isSpawnTimeout(result), expectTimeout);
// exitCode 128 ("not a git repository") is git's own definitive,
// completed answer — the ONLY non-timeout, non-success outcome that
// does not degrade. Pairing it with each non-timeout signal/error
// combination means: if isExecGitTimeout ever mis-classifies one of
// these as a timeout, this assertion flips from 'no-head' (no
// degrade) to 'head-unresolvable' (degrade) and the test fails —
// a real behavioral divergence signal, not a same-reason coincidence.
const execGit = () => ({
exitCode: expectTimeout ? null : 128,
stdout: '',
stderr: '',
signal: result.signal,
error: result.error,
});
const degradeResult = evaluateWorktreeBaseDegrade({ execGit, effectiveBaseRef: null, cwd: '/repo' });
if (expectTimeout) {
assert.strictEqual(degradeResult.shouldDegrade, true);
assert.strictEqual(degradeResult.reason, 'head-unresolvable');
} else {
assert.strictEqual(degradeResult.shouldDegrade, false);
assert.strictEqual(degradeResult.reason, 'no-head');
}
});
}
});
// ─── parseWorktreePorcelain ───────────────────────────────────────────────────
@@ -3122,12 +3266,24 @@ describe('worktree-safety: resolveWorktreeRoot and pruneOrphanedWorktrees reloca
describe('worktree-safety: resolveWorktreeRoot behaviour', () => {
const worktreeSafety = require(WORKTREE_SAFETY_PATH);
test('resolveWorktreeRoot(createTempGitProject()) returns a non-empty string', (t) => {
test('resolveWorktreeRoot(createTempGitProject()) returns {root, reason} with a non-empty root', (t) => {
const dir = createTempGitProject('gsd-wt-root-');
t.after(() => cleanup(dir));
const result = worktreeSafety.resolveWorktreeRoot(dir);
assert.ok(typeof result === 'string' && result.length > 0,
`Expected non-empty string, got: ${JSON.stringify(result)}`);
assert.ok(result && typeof result === 'object', 'must return an object, not a bare string');
assert.ok(typeof result.root === 'string' && result.root.length > 0,
`Expected non-empty root string, got: ${JSON.stringify(result)}`);
assert.ok(typeof result.reason === 'string' && result.reason.length > 0,
`Expected non-empty reason string, got: ${JSON.stringify(result)}`);
});
test('resolveWorktreeRoot propagates git_timed_out via the injected execGit seam (#3050)', () => {
const result = worktreeSafety.resolveWorktreeRoot('/repo/wt', {
existsSync: () => false,
execGit: makeTimeoutStub(),
});
assert.strictEqual(result.reason, 'git_timed_out');
assert.strictEqual(result.root, '/repo/wt');
});
});