* 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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user