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

@@ -577,9 +577,13 @@ describe('executor-identity parity: hooks/gsd-agent-isolation-guard.js (Claude)
let cursorProject; // harness-worktree GSD project, not an isolated worktree
before(() => {
claudeProject = createTempDir('gsd-cs-parity-claude-');
fs.mkdirSync(path.join(claudeProject, '.planning'), { recursive: true });
fs.writeFileSync(path.join(claudeProject, '.planning', 'config.json'), JSON.stringify({ runtime: 'claude' }));
// Must be a REAL git repo (not a bare mkdir'd directory) — same reason as
// cursorProject below, and load-bearing since #4734: the Claude guard's
// fallback degrades to 'none' on a repository-less root, so a non-git
// fixture would flip the Claude side to allow and the parity probe would
// compare Claude-allowing against Cursor-denying instead of comparing two
// ACTUALLY enforcing configurations.
claudeProject = makeGitProject('gsd-cs-parity-claude-', JSON.stringify({ runtime: 'claude' }));
// Must be a REAL git repo (not a bare mkdir'd directory): the Cursor hook's
// #3045 MAJOR 3 "not a git repo -> INERT" branch would otherwise short-circuit

View File

@@ -52,8 +52,9 @@ const os = require('node:os');
const fc = require('./helpers/fast-check-setup.cjs');
const { runHook: runHookSeam } = require('./helpers/process-seam.cjs');
const { toLegacyResult, gitOrThrow } = require('./helpers/git-fixture.cjs');
const { PROBE_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
const { PROBE_TIMEOUT_MS, GIT_FIXTURE_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
const { createTempDir, createTempProject, runGsdTools, cleanup } = require('./helpers.cjs');
const { createFixture } = require('./fixtures/index.cjs');
const { SENTINEL_RELATIVE_PATH, SENTINEL_STALE_MS, readSentinel } = require('../hooks/lib/isolation-sentinel.js');
const { REASON_CODE, REASON_INTERPOLATION_MAX_LEN, sanitizeForReason, describeSentinelDiscard } = require('../hooks/lib/isolation-deny-reason.js');
const { runtimes } = require('../gsd-core/bin/lib/capability-registry.cjs');
@@ -121,9 +122,12 @@ function agentPayload(overrides = {}) {
}
function mkProject(prefix) {
const dir = createTempDir(prefix);
fs.mkdirSync(path.join(dir, '.planning'), { recursive: true });
return dir;
// #4734: git-inited with a real HEAD. Production GSD project roots are git
// repositories, and the guard's fallback now degrades to 'none' when the
// root has NO repository — a non-git fixture here would exercise that
// degrade instead of the fallback enforcement these suites pin. The
// dedicated non-git world lives in the #4734 describe below.
return createFixture({ prefix, planning: true, git: true, projectDoc: false });
}
function writeConfig(dir, content) {
@@ -1652,6 +1656,14 @@ describe('guard fallback — worktreesOptedOut ladder (#3972)', () => {
fs.mkdirSync(path.join(dir, '.planning', 'workstreams', 'alpha'), { recursive: true });
fs.writeFileSync(path.join(dir, '.planning', 'config.json'), JSON.stringify(rootCfg));
fs.writeFileSync(path.join(dir, '.planning', 'workstreams', 'alpha', 'config.json'), JSON.stringify(wsCfg));
// #4734: a real repository HEAD. Production GSD workspaces are git repos;
// without this the fallback's new definitive-no-repository degrade — not
// the ladder — would answer the "no opt-out" pin below.
const gitOpts = { cwd: dir, timeoutMs: GIT_FIXTURE_TIMEOUT_MS };
gitOrThrow(['init'], gitOpts);
gitOrThrow(['config', 'user.email', 'test@test.com'], gitOpts);
gitOrThrow(['config', 'user.name', 'Test'], gitOpts);
gitOrThrow(['commit', '--allow-empty', '-m', 'initial commit'], gitOpts);
return dir;
}
@@ -1765,3 +1777,63 @@ describe('hooks/lib/isolation-deny-reason.js — sanitizeForReason (#4594 F2/F5/
assert.match(message, /dispatch phase="03" plan="07-01-x"/);
});
});
// ─── #4734: a project root that is not a git repository ──────────────────────
describe('gsd-agent-isolation-guard.js: #4734 — a non-git project root is never demanded worktree isolation', () => {
// The bug's world: `.planning/` at the root of a directory that is NOT a
// git repository (a multi-repo workspace). `git rev-parse HEAD` exits 128 —
// git's definitive answer that no repository exists here — so a harness
// worktree can never be created and the fallback must degrade to 'none'
// instead of demanding the flag and blocking every dispatch.
function mkNonGitProject(prefix) {
const dir = createTempDir(prefix);
fs.mkdirSync(path.join(dir, '.planning'), { recursive: true });
writeConfig(dir, JSON.stringify({ runtime: 'claude' }));
return dir;
}
function mkGitProject(prefix) {
// Mirror of mkNonGitProject with a real repository HEAD — the positive
// control proving the git check, not something else, drives the degrade.
const dir = createFixture({ prefix, planning: true, git: true, projectDoc: false });
writeConfig(dir, JSON.stringify({ runtime: 'claude' }));
return dir;
}
test('no sentinel (fallback path) + registry harness-worktree + non-git root → ALLOW a flag-less dispatch', (t) => {
const project = mkNonGitProject('gsd-aig-4734-nogit-');
t.after(() => cleanup(project));
const r = runHook(agentPayload(), project);
assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`);
assert.equal(r.stdout, '', 'an allowed dispatch must not write a block decision');
});
test('stale sentinel lying "none" + non-git root → the fallback re-derives and still allows', (t) => {
const project = mkNonGitProject('gsd-aig-4734-nogit-stale-');
t.after(() => cleanup(project));
writeSentinel(project, { isolation: 'none', writtenAt: Date.now() - (SENTINEL_STALE_MS + 60000) });
const r = runHook(agentPayload(), project);
assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`);
});
test('positive control: the same shape in a git-inited root still DENIES (the repository is the differentiator)', (t) => {
const project = mkGitProject('gsd-aig-4734-git-');
t.after(() => cleanup(project));
const r = runHook(agentPayload(), project);
assert.equal(r.status, 2, `stdout: ${r.stdout} stderr: ${r.stderr}`);
assert.equal(JSON.parse(r.stdout).decision, 'block');
});
test('#4734 scope: a FRESH sentinel still governs a non-git root — the fix is fallback-only', (t) => {
// The sentinel-fresh path carries the workflow's own confirmed decision;
// with the base-check degrade (#4734a) that decision is made where git is
// actually consulted. The guard does not second-guess it here.
const project = mkNonGitProject('gsd-aig-4734-nogit-fresh-');
t.after(() => cleanup(project));
writeSentinel(project, { isolation: 'harness-worktree', harnessFlag: 'isolation="worktree"' });
const r = runHook(agentPayload(), project);
assert.equal(r.status, 2, `stdout: ${r.stdout} stderr: ${r.stderr}`);
assert.equal(JSON.parse(r.stdout).decision, 'block');
});
});

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);
});
});

View File

@@ -326,12 +326,12 @@ describe('shared isSpawnTimeout predicate — parity for worktree-base-ref evalu
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.
// completed answer. Since #4734 it degrades (no worktree can exist
// without a resolvable HEAD) but keeps its OWN reason — so pairing it
// with each non-timeout signal/error combination still yields a real
// divergence signal: if isExecGitTimeout ever mis-classifies one of
// these as a timeout, the reason flips from 'no-head' (#4734 degrade)
// to 'head-unresolvable' and the test fails.
const execGit = () => ({
exitCode: expectTimeout ? null : 128,
stdout: '',
@@ -344,7 +344,7 @@ describe('shared isSpawnTimeout predicate — parity for worktree-base-ref evalu
assert.strictEqual(degradeResult.shouldDegrade, true);
assert.strictEqual(degradeResult.reason, 'head-unresolvable');
} else {
assert.strictEqual(degradeResult.shouldDegrade, false);
assert.strictEqual(degradeResult.shouldDegrade, true);
assert.strictEqual(degradeResult.reason, 'no-head');
}
});