fix(#4734): degrade worktree isolation when the root has no git repository (#4843)

* test(#4734): non-git root must degrade worktree isolation (failing first)

* fix(#4734): degrade worktree isolation when the root has no git repository

* fix(#4734): review fold-ins — 3972 ladder fixture, parity fixture, docs row, message wording

* chore(#4734): backfill changeset PR number (4843)

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-09-18 03:16:25 -04:00
committed by GitHub
parent 8d0b6868ae
commit c5629bbe74
8 changed files with 290 additions and 83 deletions

View File

@@ -29,6 +29,7 @@ const {
applyWorktreeBaseRef,
resolveEffectiveBaseRef,
evaluateWorktreeBaseDegrade,
classifyGitHead,
cmdWorktreeBaseCheck,
cmdWorktreeSetBaseRef,
} = require(MODULE_PATH);
@@ -254,19 +255,20 @@ describe('resolveEffectiveBaseRef', () => {
// ─── evaluateWorktreeBaseDegrade ──────────────────────────────────────────────
describe('evaluateWorktreeBaseDegrade', () => {
// Stub helper: matches on args.join(' ') and returns canned results
function makeExecGit(responses) {
return function stubExecGit(args, _opts) {
const key = args.join(' ');
if (Object.prototype.hasOwnProperty.call(responses, key)) {
return responses[key];
}
// Default: fail with a helpful error to surface unexpected calls
throw new Error(`Unexpected execGit call: ${JSON.stringify(args)}`);
};
}
// Stub helper: matches on args.join(' ') and returns canned results.
// Module-scoped so the #4734 classifyGitHead describe shares one copy (review finding).
function makeExecGit(responses) {
return function stubExecGit(args, _opts) {
const key = args.join(' ');
if (Object.prototype.hasOwnProperty.call(responses, key)) {
return responses[key];
}
// Default: fail with a helpful error to surface unexpected calls
throw new Error(`Unexpected execGit call: ${JSON.stringify(args)}`);
};
}
describe('evaluateWorktreeBaseDegrade', () => {
// #3659 rows share the diverged-HEAD stub shape — one builder keeps the
// four fixtures from drifting apart.
function makeDivergedExecGit(headSha, forkSha) {
@@ -350,13 +352,13 @@ describe('evaluateWorktreeBaseDegrade', () => {
assert.ok(result.message.includes('sequentially'), 'message must state the sequential fallback');
});
test('git rev-parse HEAD fails → no degrade, reason no-head', () => {
test('git rev-parse HEAD exits 128 (definitive no-repository) → degrades, reason no-head (#4734)', () => {
const result = evaluateWorktreeBaseDegrade({
execGit: makeExecGit({
'rev-parse HEAD': { exitCode: 128, stdout: '', stderr: 'fatal: not a git repo', signal: null, error: null },
}),
});
assert.strictEqual(result.shouldDegrade, false);
assert.strictEqual(result.shouldDegrade, true);
assert.strictEqual(result.reason, 'no-head');
assert.strictEqual(result.headSha, null);
});
@@ -374,7 +376,10 @@ describe('evaluateWorktreeBaseDegrade', () => {
// ─── #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)
// (#4734 revised the exit-128 row: git's definitive no-repository answer
// now DEGRADES — a worktree can never be created there. The ambiguous
// exit-0-empty row is unchanged, still deliberately non-degrading.)
// - exitCode === 128 → degrade, reason 'no-head' (#4734)
// - exit 0 with non-empty sha → proceed (unchanged)
// - anything else (127, other → degrade, reason 'head-unresolvable'
// non-zero, exit 0 empty stdout
@@ -441,32 +446,33 @@ describe('evaluateWorktreeBaseDegrade', () => {
assert.strictEqual(result.reason, 'head-unresolvable');
});
test('exitCode 128 ("not a git repository") still does NOT degrade (#3050 regression guard)', () => {
test('exitCode 128 ("not a git repository") degrades with a user-visible message (#4734; was a #3050 non-degrade pin)', () => {
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.shouldDegrade, true);
assert.strictEqual(result.reason, 'no-head');
assert.strictEqual(result.headAbsenceVerified, true);
// Message presence is a typed fact (non-null, non-empty) — its prose is
// operator-facing text, not a test oracle (no raw-text matching).
assert.ok(typeof result.message === 'string' && result.message.length > 0, 'the degrade carries the message the workflow prints');
});
// ─── #3057 B8: headAbsenceVerified distinguishes the two "no-head" causes ──
//
// Both outcomes below keep `shouldDegrade:false, reason:'no-head'` — that
// product decision is deliberately UNCHANGED (pinned by the regression
// guards above and flagged in the #3050 review as still an open question).
// What changes is that a caller can now tell git's DEFINITIVE "not a git
// repository" answer (exit 128) apart from git completing but returning
// nothing useful (exit 0, empty stdout) — the module's own #380-383 comment
// named this gap; these two paired tests prove it is closed.
// #4734 revised the exit-128 outcome (degrade, with headAbsenceVerified:true
// preserved) and left the exit-0-empty outcome deliberately unchanged — the
// paired tests below prove both, and that a caller can still tell the two
// 'no-head' causes apart.
test('exit 128 — git\'s definitive "not a git repository" answer → headAbsenceVerified:true', () => {
test('exit 128 — git\'s definitive "not a git repository" answer → degrades, headAbsenceVerified:true (#4734)', () => {
const faultyGit = makeFaultyGit({
faults: [{ kind: 'exit', exitCode: 128, stderr: 'fatal: not a git repository' }],
});
const result = evaluateWorktreeBaseDegrade({ execGit: faultyGit });
assert.strictEqual(result.shouldDegrade, false);
assert.strictEqual(result.shouldDegrade, true);
assert.strictEqual(result.reason, 'no-head');
assert.strictEqual(result.headAbsenceVerified, true);
});
@@ -1401,3 +1407,69 @@ describe('execute-plan Pattern A: pre-dispatch worktree base-check (#2649)', ()
});
}
describe('#4734: classifyGitHead — single owner of the HEAD-resolution classes', () => {
test('exit 0 with a sha → present, headSha carries the trimmed sha', () => {
const SHA = 'aabbccdd11223344aabbccdd11223344aabbccdd';
const status = classifyGitHead({
execGit: makeExecGit({
'rev-parse HEAD': { exitCode: 0, stdout: `${SHA}\n`, stderr: '', signal: null, error: null },
}),
});
assert.strictEqual(status.status, 'present');
assert.strictEqual(status.headSha, SHA);
});
test('exit 128 → definitive-absence (not a repository, or a repository with no commits — neither can host a worktree)', () => {
const status = classifyGitHead({
execGit: makeExecGit({
'rev-parse HEAD': { exitCode: 128, stdout: '', stderr: 'fatal: not a git repository', signal: null, error: null },
}),
});
assert.strictEqual(status.status, 'definitive-absence');
assert.strictEqual(status.headSha, null);
});
test('a REAL non-git working directory degrades end-to-end (no injected seam — the #4734 fixture wording)', (t) => {
const { createTempDir, cleanup } = require('./helpers.cjs');
const dir = createTempDir('gsd-4734-nogit-real-');
t.after(() => cleanup(dir));
const result = evaluateWorktreeBaseDegrade({ cwd: dir });
assert.strictEqual(result.shouldDegrade, true);
assert.strictEqual(result.reason, 'no-head');
assert.strictEqual(result.headAbsenceVerified, true);
assert.ok(typeof result.message === 'string' && result.message.length > 0);
});
test('exit 0 with empty stdout → ambiguous-absence (git completed without a definitive answer)', () => {
const status = classifyGitHead({
execGit: makeExecGit({
'rev-parse HEAD': { exitCode: 0, stdout: '', stderr: '', signal: null, error: null },
}),
});
assert.strictEqual(status.status, 'ambiguous-absence');
assert.strictEqual(status.headSha, null);
});
test('timeout → indeterminate (fail closed)', () => {
const timedOutErr = new Error('spawnSync git ETIMEDOUT');
timedOutErr.code = 'ETIMEDOUT';
const status = classifyGitHead({
execGit: makeExecGit({
'rev-parse HEAD': { exitCode: null, stdout: '', stderr: '', signal: 'SIGTERM', error: timedOutErr },
}),
});
assert.strictEqual(status.status, 'indeterminate');
assert.strictEqual(status.headSha, null);
});
test('other non-zero exit (git missing, exit 127) → indeterminate (fail closed)', () => {
const status = classifyGitHead({
execGit: makeExecGit({
'rev-parse HEAD': { exitCode: 127, stdout: '', stderr: 'command not found', signal: null, error: null },
}),
});
assert.strictEqual(status.status, 'indeterminate');
assert.strictEqual(status.headSha, null);
});
});