* 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:
5
.changeset/witty-foxes-sprint.md
Normal file
5
.changeset/witty-foxes-sprint.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
type: Fixed
|
||||||
|
pr: 4843
|
||||||
|
---
|
||||||
|
**Agent dispatch no longer blocks on a project root that is not a git repository** — `worktree.base-check` now degrades to sequential when git definitively reports no repository, and the isolation guard's stale-sentinel fallback no longer demands `isolation="worktree"` where no worktree can be created (multi-repo workspace roots). Repos with a real HEAD behave exactly as before. (#4734)
|
||||||
@@ -1301,7 +1301,7 @@ node gsd-tools.cjs worktree set-baseref
|
|||||||
| `head-matches-fork` | `false` | HEAD and `origin/HEAD` are the same commit |
|
| `head-matches-fork` | `false` | HEAD and `origin/HEAD` are the same commit |
|
||||||
| `head-diverged-from-fork` | `true` | Branch is ahead of or diverged from `origin/HEAD` |
|
| `head-diverged-from-fork` | `true` | Branch is ahead of or diverged from `origin/HEAD` |
|
||||||
| `fork-ref-unknown` | `true` | `origin/HEAD` could not be resolved |
|
| `fork-ref-unknown` | `true` | `origin/HEAD` could not be resolved |
|
||||||
| `no-head` | `false` | Not in a git repo (no `HEAD`) — `git rev-parse HEAD` exited 128 (definitive), or exited 0 with empty stdout |
|
| `no-head` | `true` for exit 128, `false` for exit 0 with empty stdout | Exit 128 is git's definitive "no resolvable HEAD here" answer — not a git repository, or a repository with no commits; no harness worktree can be created, so the check degrades to sequential (#4734), with a `message` explaining why. Exit 0 with empty stdout is ambiguous (git completed without a definitive answer) and stays non-degrading (`headAbsenceVerified` distinguishes the two: `true` / `false`) |
|
||||||
| `head-unresolvable` | `true` | `git rev-parse HEAD` did not return a definitive answer (timed out, `git` missing, or any other non-128 failure) — fails closed rather than being treated as `no-head` |
|
| `head-unresolvable` | `true` | `git rev-parse HEAD` did not return a definitive answer (timed out, `git` missing, or any other non-128 failure) — fails closed rather than being treated as `no-head` |
|
||||||
|
|
||||||
**`worktree set-baseref`** applies a no-clobber write of `worktree.baseRef:"head"` to `.claude/settings.local.json`. If the file already contains an explicit `baseRef` value other than `"head"`, the existing value is preserved and `skipped:"explicit-other"` is returned. Malformed JSON causes an error rather than a silent overwrite. Both fresh installs and upgrades of GSD Core run this automatically when `workflow.use_worktrees` is enabled (the default); the command is also available for manual use — for example, to apply the setting when worktrees were toggled on after installation, or to re-apply it after a settings change.
|
**`worktree set-baseref`** applies a no-clobber write of `worktree.baseRef:"head"` to `.claude/settings.local.json`. If the file already contains an explicit `baseRef` value other than `"head"`, the existing value is preserved and `skipped:"explicit-other"` is returned. Malformed JSON causes an error rather than a silent overwrite. Both fresh installs and upgrades of GSD Core run this automatically when `workflow.use_worktrees` is enabled (the default); the command is also available for manual use — for example, to apply the setting when worktrees were toggled on after installation, or to re-apply it after a settings change.
|
||||||
|
|||||||
@@ -337,6 +337,30 @@ function resolveRegistryIsolation(cwd, configPath) {
|
|||||||
if (!useWorktrees) isolation = 'none';
|
if (!useWorktrees) isolation = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #4734: a harness worktree can never be created in a directory that is not
|
||||||
|
// a git repository (or a repository with no commits) — git's definitive
|
||||||
|
// exit-128 answer on `rev-parse HEAD`. Demanding the isolation flag there
|
||||||
|
// blocked every flag-less dispatch the moment the sentinel went stale, on a
|
||||||
|
// root where no worktree can exist. Degrade to 'none' exactly like the
|
||||||
|
// use_worktrees opt-out above; the classification is owned by
|
||||||
|
// `classifyGitHead` (worktree-base-ref.cjs) — the same single owner the
|
||||||
|
// base-check's degrade decision uses — and ambiguous or failed resolutions
|
||||||
|
// keep the conservative (enforce) default, matching that check's own
|
||||||
|
// fail-closed treatment of the same classes.
|
||||||
|
if (isolation === 'harness-worktree') {
|
||||||
|
try {
|
||||||
|
ensureRuntimeBuild();
|
||||||
|
const { classifyGitHead } = require('../gsd-core/bin/lib/worktree-base-ref.cjs');
|
||||||
|
if (classifyGitHead({ cwd }).status === 'definitive-absence') {
|
||||||
|
isolation = 'none';
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Unbuilt or unreadable runtime library — keep the conservative
|
||||||
|
// (enforce) default rather than silently disabling the guard, same
|
||||||
|
// posture as the ladder fallback above.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return { isolation, harnessFlag };
|
return { isolation, harnessFlag };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -106,6 +106,8 @@ function buildMsgBaserefHeadIgnored(headSha: string | null, forkRef: string | nu
|
|||||||
|
|
||||||
const MSG_HEAD_UNRESOLVABLE = `⚠ Cannot determine the worktree base (git rev-parse HEAD did not return a definitive answer). Running this phase sequentially on the main working tree to avoid an unverified base mismatch. Note: worktree.baseRef:"head" silences this check only where GSD itself creates the worktree (orchestrator-managed runtimes) — in harness mode it never applied (#48, #3659). Retry; if it persists, check for a stalled filesystem mount or a stale git index lock (.git/index.lock). See #683, #3050.`;
|
const MSG_HEAD_UNRESOLVABLE = `⚠ Cannot determine the worktree base (git rev-parse HEAD did not return a definitive answer). Running this phase sequentially on the main working tree to avoid an unverified base mismatch. Note: worktree.baseRef:"head" silences this check only where GSD itself creates the worktree (orchestrator-managed runtimes) — in harness mode it never applied (#48, #3659). Retry; if it persists, check for a stalled filesystem mount or a stale git index lock (.git/index.lock). See #683, #3050.`;
|
||||||
|
|
||||||
|
const MSG_NO_GIT_REPOSITORY = `⚠ No worktree base exists here (git resolved no HEAD — the root is not a git repository, or the repository has no commits), so a harness worktree cannot be created. Running this dispatch sequentially on the main working tree instead — no isolation flag is required. See #4734.`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns true when an execGit result indicates the subprocess was killed by
|
* Returns true when an execGit result indicates the subprocess was killed by
|
||||||
* a timeout. A timeout means the command genuinely could not complete — it
|
* a timeout. A timeout means the command genuinely could not complete — it
|
||||||
@@ -377,6 +379,46 @@ export function cmdWorktreeSetBaseRef(
|
|||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One classification of `git rev-parse HEAD`, shared by every surface that
|
||||||
|
* must tell git's definitive "no repository here" answer apart from ambiguous
|
||||||
|
* or failed resolutions (#4734 — before this, evaluateWorktreeBaseDegrade was
|
||||||
|
* the only owner and the isolation guard's fallback simply had no check).
|
||||||
|
* Exit 128 covers BOTH "not a git repository" and "a repository with no
|
||||||
|
* commits" (`ambiguous argument 'HEAD'`); stderr text is localized, so the
|
||||||
|
* exit code is the stable contract — and in neither case can a harness
|
||||||
|
* worktree be created, which is the only question every consumer asks.
|
||||||
|
*
|
||||||
|
* - `present`: exit 0 with a non-empty sha (`headSha` carries it, trimmed).
|
||||||
|
* - `definitive-absence`: exit 128 — git completed and definitively answered.
|
||||||
|
* - `ambiguous-absence`: exit 0 with empty stdout — git completed without a
|
||||||
|
* definitive answer (#3057 B8).
|
||||||
|
* - `indeterminate`: timeout or any other non-success — never evidence of
|
||||||
|
* anything; consumers must fail closed (#3050).
|
||||||
|
*/
|
||||||
|
export function classifyGitHead(deps?: {
|
||||||
|
execGit?: ExecGitFn;
|
||||||
|
cwd?: string;
|
||||||
|
}): { status: 'present'; headSha: string } | { status: 'definitive-absence' | 'ambiguous-absence' | 'indeterminate'; headSha: null } {
|
||||||
|
const execGit: ExecGitFn = deps?.execGit ?? execGitSeam;
|
||||||
|
const cwdOpts = deps?.cwd ? { cwd: deps.cwd } : {};
|
||||||
|
const headResult = execGit(['rev-parse', 'HEAD'], cwdOpts);
|
||||||
|
if (isExecGitTimeout(headResult)) {
|
||||||
|
return { status: 'indeterminate', headSha: null };
|
||||||
|
}
|
||||||
|
const headStdout = headResult.stdout ? headResult.stdout.trim() : '';
|
||||||
|
if (headResult.exitCode === 128) {
|
||||||
|
return { status: 'definitive-absence', headSha: null };
|
||||||
|
}
|
||||||
|
if (headResult.exitCode === 0 && !headStdout) {
|
||||||
|
return { status: 'ambiguous-absence', headSha: null };
|
||||||
|
}
|
||||||
|
if (headResult.exitCode !== 0) {
|
||||||
|
return { status: 'indeterminate', headSha: null };
|
||||||
|
}
|
||||||
|
return { status: 'present', headSha: headStdout };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Evaluates whether the current worktree HEAD has diverged from the fork base
|
* Evaluates whether the current worktree HEAD has diverged from the fork base
|
||||||
* (origin/HEAD) that the Claude Code harness would use when creating a 'fresh'
|
* (origin/HEAD) that the Claude Code harness would use when creating a 'fresh'
|
||||||
@@ -406,15 +448,14 @@ export function evaluateWorktreeBaseDegrade(deps?: {
|
|||||||
forkRef: string | null;
|
forkRef: string | null;
|
||||||
forkSha: string | null;
|
forkSha: string | null;
|
||||||
/**
|
/**
|
||||||
* Only meaningful when `reason === 'no-head'` (both non-degrade outcomes);
|
* Only meaningful when `reason === 'no-head'`; `null` for every other
|
||||||
* `null` for every other reason. `true` for exit 128 — git's definitive
|
* reason. Distinguishes the two no-head causes: `true` for exit 128 —
|
||||||
* "not a git repository" answer. `false` for exit 0 with empty stdout: git
|
* git's definitive "no resolvable HEAD here" answer, which DEGRADES as of
|
||||||
* completed but did NOT give a confirmed "no HEAD" answer, unlike exit 128
|
* #4734 (a worktree can never be created; the maintainer brief on #4734
|
||||||
* — this outcome is left `shouldDegrade:false` unchanged (pinned by an
|
* answered the previously-open product question). `false` for exit 0 with
|
||||||
* existing regression guard; the underlying product question of whether it
|
* empty stdout — git completed but did NOT give a confirmed answer; that
|
||||||
* SHOULD degrade is still open, see #3050 review), but a caller can now
|
* outcome stays `shouldDegrade:false` (pinned by an existing regression
|
||||||
* tell the two `'no-head'` causes apart instead of treating them as the
|
* guard; the product question remains open, #3050/#3057 B8).
|
||||||
* same verified answer. (#3057 B8)
|
|
||||||
*/
|
*/
|
||||||
headAbsenceVerified: boolean | null;
|
headAbsenceVerified: boolean | null;
|
||||||
} {
|
} {
|
||||||
@@ -438,44 +479,33 @@ export function evaluateWorktreeBaseDegrade(deps?: {
|
|||||||
return { shouldDegrade: false, reason: 'baseref-head', message: null, headSha: null, forkRef: null, forkSha: null, headAbsenceVerified: null };
|
return { shouldDegrade: false, reason: 'baseref-head', message: null, headSha: null, forkRef: null, forkSha: null, headAbsenceVerified: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
// b. Resolve HEAD sha.
|
// b. Resolve HEAD sha — through the single classification owner (#4734).
|
||||||
const headResult = execGit(['rev-parse', 'HEAD'], cwdOpts);
|
const head = classifyGitHead({ execGit, cwd });
|
||||||
// A TIMEOUT means the command never completed — it is not evidence of "not a
|
if (head.status === 'indeterminate') {
|
||||||
// git repository" and must fail closed (distinct from the clean-exit-128
|
// A timeout, git missing (exit 127), or any other non-128 non-success is
|
||||||
// "no-head" case below, which genuinely completed and reported no HEAD).
|
// NOT a definitive answer from git — fail closed (#3050).
|
||||||
if (isExecGitTimeout(headResult)) {
|
|
||||||
return { shouldDegrade: true, reason: 'head-unresolvable', message: MSG_HEAD_UNRESOLVABLE, headSha: null, forkRef: null, forkSha: null, headAbsenceVerified: null };
|
return { shouldDegrade: true, reason: 'head-unresolvable', message: MSG_HEAD_UNRESOLVABLE, headSha: null, forkRef: null, forkSha: null, headAbsenceVerified: null };
|
||||||
}
|
}
|
||||||
const headStdout = headResult.stdout ? headResult.stdout.trim() : '';
|
if (head.status === 'definitive-absence') {
|
||||||
// exit 128 is git's definitive "not a git repository" answer — it completed
|
// Exit 128 is git's definitive "no resolvable HEAD here" answer — not a
|
||||||
// and genuinely reported no HEAD. Only this specific, confirmed outcome
|
// git repository, or a repository with no commits. In either case a
|
||||||
// stays a benign non-degrade; every other non-success outcome below is
|
// harness worktree can NEVER be created, so demanding worktree isolation
|
||||||
// NOT a definitive answer from git and must fail closed (#3050).
|
// blocked every dispatch from e.g. a multi-repo workspace root (#4734).
|
||||||
if (headResult.exitCode === 128) {
|
// This wires the verdict `headAbsenceVerified` (#3057 B8) was added to
|
||||||
return { shouldDegrade: false, reason: 'no-head', message: null, headSha: null, forkRef: null, forkSha: null, headAbsenceVerified: true };
|
// enable; the maintainer brief on #4734 answers the previously-open
|
||||||
|
// product question: the definitive case degrades.
|
||||||
|
return { shouldDegrade: true, reason: 'no-head', message: MSG_NO_GIT_REPOSITORY, headSha: null, forkRef: null, forkSha: null, headAbsenceVerified: true };
|
||||||
}
|
}
|
||||||
// Exit 0 with empty stdout is pinned as benign no-degrade by an existing
|
if (head.status === 'ambiguous-absence') {
|
||||||
// regression guard (tests/worktree-base-ref.test.cjs — "git rev-parse HEAD
|
// Exit 0 with empty stdout is pinned as benign no-degrade by an existing
|
||||||
// returns empty stdout"). Left unchanged deliberately; flagged in the
|
// regression guard (tests/worktree-base-ref.test.cjs — "git rev-parse HEAD
|
||||||
// #3050 review for a product-intent call rather than silently flipped.
|
// returns empty stdout"). Unlike the exit-128 case above, git did NOT
|
||||||
// Unlike the exit-128 case above, git did NOT give a definitive "no HEAD"
|
// give a definitive "no HEAD" answer here — `headAbsenceVerified:false`
|
||||||
// answer here — `headAbsenceVerified:false` names that gap explicitly
|
// names that gap explicitly (#3057 B8; the product question of whether
|
||||||
// instead of leaving it folded into an identical-looking 'no-head' reason
|
// this SHOULD degrade is unchanged and still open).
|
||||||
// (#3057 B8; the product question of whether this SHOULD degrade is
|
|
||||||
// unchanged and still open).
|
|
||||||
if (headResult.exitCode === 0 && !headStdout) {
|
|
||||||
return { shouldDegrade: false, reason: 'no-head', message: null, headSha: null, forkRef: null, forkSha: null, headAbsenceVerified: false };
|
return { shouldDegrade: false, reason: 'no-head', message: null, headSha: null, forkRef: null, forkSha: null, headAbsenceVerified: false };
|
||||||
}
|
}
|
||||||
if (headResult.exitCode !== 0) {
|
const headSha = head.headSha;
|
||||||
// Any other non-success outcome (e.g. exit 127 — git missing — or any
|
|
||||||
// other non-zero, non-128 exit) is not a definitive "not a repo" answer.
|
|
||||||
// Fail closed instead of silently treating it as benign.
|
|
||||||
// (`!headStdout` was previously OR'd in here but is unreachable: the
|
|
||||||
// exitCode===0 && !headStdout case is already handled above, and every
|
|
||||||
// other branch here has exitCode!==0 already true — #3050 review.)
|
|
||||||
return { shouldDegrade: true, reason: 'head-unresolvable', message: MSG_HEAD_UNRESOLVABLE, headSha: null, forkRef: null, forkSha: null, headAbsenceVerified: null };
|
|
||||||
}
|
|
||||||
const headSha = headStdout;
|
|
||||||
|
|
||||||
// c. Resolve fork base (what the harness forks 'fresh' worktrees from = origin/HEAD).
|
// c. Resolve fork base (what the harness forks 'fresh' worktrees from = origin/HEAD).
|
||||||
let forkRef: string | null = null;
|
let forkRef: string | null = null;
|
||||||
|
|||||||
@@ -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
|
let cursorProject; // harness-worktree GSD project, not an isolated worktree
|
||||||
|
|
||||||
before(() => {
|
before(() => {
|
||||||
claudeProject = createTempDir('gsd-cs-parity-claude-');
|
// Must be a REAL git repo (not a bare mkdir'd directory) — same reason as
|
||||||
fs.mkdirSync(path.join(claudeProject, '.planning'), { recursive: true });
|
// cursorProject below, and load-bearing since #4734: the Claude guard's
|
||||||
fs.writeFileSync(path.join(claudeProject, '.planning', 'config.json'), JSON.stringify({ runtime: 'claude' }));
|
// 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
|
// 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
|
// #3045 MAJOR 3 "not a git repo -> INERT" branch would otherwise short-circuit
|
||||||
|
|||||||
@@ -52,8 +52,9 @@ const os = require('node:os');
|
|||||||
const fc = require('./helpers/fast-check-setup.cjs');
|
const fc = require('./helpers/fast-check-setup.cjs');
|
||||||
const { runHook: runHookSeam } = require('./helpers/process-seam.cjs');
|
const { runHook: runHookSeam } = require('./helpers/process-seam.cjs');
|
||||||
const { toLegacyResult, gitOrThrow } = require('./helpers/git-fixture.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 { 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 { 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 { 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');
|
const { runtimes } = require('../gsd-core/bin/lib/capability-registry.cjs');
|
||||||
@@ -121,9 +122,12 @@ function agentPayload(overrides = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function mkProject(prefix) {
|
function mkProject(prefix) {
|
||||||
const dir = createTempDir(prefix);
|
// #4734: git-inited with a real HEAD. Production GSD project roots are git
|
||||||
fs.mkdirSync(path.join(dir, '.planning'), { recursive: true });
|
// repositories, and the guard's fallback now degrades to 'none' when the
|
||||||
return dir;
|
// 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) {
|
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.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', 'config.json'), JSON.stringify(rootCfg));
|
||||||
fs.writeFileSync(path.join(dir, '.planning', 'workstreams', 'alpha', 'config.json'), JSON.stringify(wsCfg));
|
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;
|
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"/);
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ const {
|
|||||||
applyWorktreeBaseRef,
|
applyWorktreeBaseRef,
|
||||||
resolveEffectiveBaseRef,
|
resolveEffectiveBaseRef,
|
||||||
evaluateWorktreeBaseDegrade,
|
evaluateWorktreeBaseDegrade,
|
||||||
|
classifyGitHead,
|
||||||
cmdWorktreeBaseCheck,
|
cmdWorktreeBaseCheck,
|
||||||
cmdWorktreeSetBaseRef,
|
cmdWorktreeSetBaseRef,
|
||||||
} = require(MODULE_PATH);
|
} = require(MODULE_PATH);
|
||||||
@@ -254,19 +255,20 @@ describe('resolveEffectiveBaseRef', () => {
|
|||||||
|
|
||||||
// ─── evaluateWorktreeBaseDegrade ──────────────────────────────────────────────
|
// ─── evaluateWorktreeBaseDegrade ──────────────────────────────────────────────
|
||||||
|
|
||||||
describe('evaluateWorktreeBaseDegrade', () => {
|
// Stub helper: matches on args.join(' ') and returns canned results.
|
||||||
// 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) {
|
function makeExecGit(responses) {
|
||||||
return function stubExecGit(args, _opts) {
|
return function stubExecGit(args, _opts) {
|
||||||
const key = args.join(' ');
|
const key = args.join(' ');
|
||||||
if (Object.prototype.hasOwnProperty.call(responses, key)) {
|
if (Object.prototype.hasOwnProperty.call(responses, key)) {
|
||||||
return responses[key];
|
return responses[key];
|
||||||
}
|
}
|
||||||
// Default: fail with a helpful error to surface unexpected calls
|
// Default: fail with a helpful error to surface unexpected calls
|
||||||
throw new Error(`Unexpected execGit call: ${JSON.stringify(args)}`);
|
throw new Error(`Unexpected execGit call: ${JSON.stringify(args)}`);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
describe('evaluateWorktreeBaseDegrade', () => {
|
||||||
// #3659 rows share the diverged-HEAD stub shape — one builder keeps the
|
// #3659 rows share the diverged-HEAD stub shape — one builder keeps the
|
||||||
// four fixtures from drifting apart.
|
// four fixtures from drifting apart.
|
||||||
function makeDivergedExecGit(headSha, forkSha) {
|
function makeDivergedExecGit(headSha, forkSha) {
|
||||||
@@ -350,13 +352,13 @@ describe('evaluateWorktreeBaseDegrade', () => {
|
|||||||
assert.ok(result.message.includes('sequentially'), 'message must state the sequential fallback');
|
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({
|
const result = evaluateWorktreeBaseDegrade({
|
||||||
execGit: makeExecGit({
|
execGit: makeExecGit({
|
||||||
'rev-parse HEAD': { exitCode: 128, stdout: '', stderr: 'fatal: not a git repo', signal: null, error: null },
|
'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.reason, 'no-head');
|
||||||
assert.strictEqual(result.headSha, null);
|
assert.strictEqual(result.headSha, null);
|
||||||
});
|
});
|
||||||
@@ -374,7 +376,10 @@ describe('evaluateWorktreeBaseDegrade', () => {
|
|||||||
// ─── #3050: fail-closed matrix for git rev-parse HEAD outcomes ─────────────
|
// ─── #3050: fail-closed matrix for git rev-parse HEAD outcomes ─────────────
|
||||||
// DECIDED RULE: degrade UNLESS git completed and gave a definitive answer.
|
// DECIDED RULE: degrade UNLESS git completed and gave a definitive answer.
|
||||||
// - timeout → degrade, reason 'head-unresolvable'
|
// - 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)
|
// - exit 0 with non-empty sha → proceed (unchanged)
|
||||||
// - anything else (127, other → degrade, reason 'head-unresolvable'
|
// - anything else (127, other → degrade, reason 'head-unresolvable'
|
||||||
// non-zero, exit 0 empty stdout
|
// non-zero, exit 0 empty stdout
|
||||||
@@ -441,32 +446,33 @@ describe('evaluateWorktreeBaseDegrade', () => {
|
|||||||
assert.strictEqual(result.reason, 'head-unresolvable');
|
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({
|
const result = evaluateWorktreeBaseDegrade({
|
||||||
execGit: makeExecGit({
|
execGit: makeExecGit({
|
||||||
'rev-parse HEAD': { exitCode: 128, stdout: '', stderr: 'fatal: not a git repository', signal: null, error: null },
|
'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.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 ──
|
// ─── #3057 B8: headAbsenceVerified distinguishes the two "no-head" causes ──
|
||||||
//
|
//
|
||||||
// Both outcomes below keep `shouldDegrade:false, reason:'no-head'` — that
|
// #4734 revised the exit-128 outcome (degrade, with headAbsenceVerified:true
|
||||||
// product decision is deliberately UNCHANGED (pinned by the regression
|
// preserved) and left the exit-0-empty outcome deliberately unchanged — the
|
||||||
// guards above and flagged in the #3050 review as still an open question).
|
// paired tests below prove both, and that a caller can still tell the two
|
||||||
// What changes is that a caller can now tell git's DEFINITIVE "not a git
|
// 'no-head' causes apart.
|
||||||
// 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.
|
|
||||||
|
|
||||||
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({
|
const faultyGit = makeFaultyGit({
|
||||||
faults: [{ kind: 'exit', exitCode: 128, stderr: 'fatal: not a git repository' }],
|
faults: [{ kind: 'exit', exitCode: 128, stderr: 'fatal: not a git repository' }],
|
||||||
});
|
});
|
||||||
const result = evaluateWorktreeBaseDegrade({ execGit: faultyGit });
|
const result = evaluateWorktreeBaseDegrade({ execGit: faultyGit });
|
||||||
assert.strictEqual(result.shouldDegrade, false);
|
assert.strictEqual(result.shouldDegrade, true);
|
||||||
assert.strictEqual(result.reason, 'no-head');
|
assert.strictEqual(result.reason, 'no-head');
|
||||||
assert.strictEqual(result.headAbsenceVerified, true);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -326,12 +326,12 @@ describe('shared isSpawnTimeout predicate — parity for worktree-base-ref evalu
|
|||||||
assert.strictEqual(isSpawnTimeout(result), expectTimeout);
|
assert.strictEqual(isSpawnTimeout(result), expectTimeout);
|
||||||
|
|
||||||
// exitCode 128 ("not a git repository") is git's own definitive,
|
// exitCode 128 ("not a git repository") is git's own definitive,
|
||||||
// completed answer — the ONLY non-timeout, non-success outcome that
|
// completed answer. Since #4734 it degrades (no worktree can exist
|
||||||
// does not degrade. Pairing it with each non-timeout signal/error
|
// without a resolvable HEAD) but keeps its OWN reason — so pairing it
|
||||||
// combination means: if isExecGitTimeout ever mis-classifies one of
|
// with each non-timeout signal/error combination still yields a real
|
||||||
// these as a timeout, this assertion flips from 'no-head' (no
|
// divergence signal: if isExecGitTimeout ever mis-classifies one of
|
||||||
// degrade) to 'head-unresolvable' (degrade) and the test fails —
|
// these as a timeout, the reason flips from 'no-head' (#4734 degrade)
|
||||||
// a real behavioral divergence signal, not a same-reason coincidence.
|
// to 'head-unresolvable' and the test fails.
|
||||||
const execGit = () => ({
|
const execGit = () => ({
|
||||||
exitCode: expectTimeout ? null : 128,
|
exitCode: expectTimeout ? null : 128,
|
||||||
stdout: '',
|
stdout: '',
|
||||||
@@ -344,7 +344,7 @@ describe('shared isSpawnTimeout predicate — parity for worktree-base-ref evalu
|
|||||||
assert.strictEqual(degradeResult.shouldDegrade, true);
|
assert.strictEqual(degradeResult.shouldDegrade, true);
|
||||||
assert.strictEqual(degradeResult.reason, 'head-unresolvable');
|
assert.strictEqual(degradeResult.reason, 'head-unresolvable');
|
||||||
} else {
|
} else {
|
||||||
assert.strictEqual(degradeResult.shouldDegrade, false);
|
assert.strictEqual(degradeResult.shouldDegrade, true);
|
||||||
assert.strictEqual(degradeResult.reason, 'no-head');
|
assert.strictEqual(degradeResult.reason, 'no-head');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user