fix(#4881): trust worktree.baseRef:"head" in harness mode; a WorktreeCreate hook withholds it and only a measured fork base restores it (#4921)
* fix(#4881): trust worktree.baseRef:"head" in harness mode and keep the #4868 observation as what restores it under a WorktreeCreate hook The pre-dispatch base check still derived its harness-mode verdict from the retired #48 premise that the harness never reads worktree.baseRef: with "head" set and HEAD diverged from origin/HEAD it degraded every wave with baseref-head-ignored-by-harness. #4868 inserted an observation of a clean prior harness worktree at HEAD ahead of that comparison, but an execute-phase run never has one at the moment it checks — the base-check runs before any dispatch, a degraded wave creates no worktrees, and a wave that did run in worktrees has them removed and HEAD moved before the next check — so the common case was unchanged (#4881 repro states 1 and 4). Re-scope of the closed #4752 onto current next, with #4868 kept: - branch a trusts "head" in both isolation modes, the way the harness is measured to behave (#4588: three settings layers, three OSes), and the spawn-time exit-42 guard stays the observation-based backstop; - a Claude Code WorktreeCreate hook in any settings file the check reads, or a file that does not parse, withholds that trust — the hook creates the worktree without applying the setting — and the inferred comparison runs, degrading with baseref-head-bypassed-by-hook; - the #4868 observation (b2) now sits behind branch a: it is reached only when "head" was not trusted outright, and on a hook host it is what restores the trust — a hook that forks from HEAD leaves exactly that evidence, one that forks elsewhere never does. Its per-HEAD cache is unchanged. It is skipped under an explicit --observed-fork-base, which outranks an inference from a prior worktree; - --observed-fork-base <sha> threads a measured fork base through the evaluation (strict full-hex, TypeError otherwise). Tests: the #4868 rows are unchanged and still reachable (they run with the setting unset); the #4752 rows re-land, with the one exit-128 row updated to the degrade #4734 pinned since; a new #4881 block pins the start-of-run state (baseref-head, git never consulted), the hook + observation composition in both directions, and that an explicit observation skips the probe. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RPXQQPzHGintWtbhBoQCRS * docs(#4881): rewrite the eight surfaces that still state the harness ignores worktree.baseRef:"head" Every prose surface #4868 left untouched still asserted the retired #48 premise as verified fact, starting with the step file the orchestrator reads. Each now describes the measured behaviour, the WorktreeCreate-hook exception, the --observed-fork-base input, and the #4868 observation as what lifts the hook degrade; docs/CLI-TOOLS.md gains the fork-from-head-observed reason row #4868 did not document. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RPXQQPzHGintWtbhBoQCRS * chore(#4881): set changeset fragment pr to 4921 * fix(#4881): withhold the #4868 observation under the hook interlock The hook interlock this PR added withheld the worktree.baseRef:"head" trust but still let b2's prior-worktree observation restore it, and that observation cannot be attributed to the hook. The evidence is a clean agent worktree sitting at the orchestrator HEAD; nothing on disk records which creator left it there, so one the plain harness created BEFORE a WorktreeCreate hook was configured — with HEAD unmoved since — reads as evidence for the hook. It was the one fail-open branch in a mechanism documented as fail-closed. Keying the observation cache by hook configuration does not close it. observeHarnessForkFromHead has two legs: a HEAD-keyed cache and a live probe over .claude/worktrees/agent-*. A hook-keyed cache simply misses, and the miss falls through to the probe, which re-finds the same stale worktree and re-confirms. The probe takes no hook input at all. So the observation is not consulted under the interlock rather than re-keyed: on a hook host the only admissible positive signal is an explicit --observed-fork-base measurement of the dispatch in hand, and absent one the inferred comparison runs and a mismatch degrades with baseref-head-bypassed-by-hook, leaving the spawn-time exit-42 guard as the backstop. Scoped to the case branch a. declined to trust: "head" set AND a hook (or an unparseable layer) in the harness's path. With no "head" setting b2 is #4868's own arm and is unchanged, hook or not — re-scoping that trust is a separate question this PR does not open, and a test pins the boundary. Cost, stated: a hook host with "head" set, on a branch diverged from origin/HEAD and passing no observation, now runs sequentially. It still runs parallel when HEAD matches origin/HEAD. No workflow threads an observation today. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NuT9wTyrMaxjAPevqeH4YZ * refactor(#4881): drop the now-unused forkRef message-builder parameter buildMsgBaserefHeadIgnored stopped reading forkRef when the message was made mode-neutral, and the parameter was retained with `void forkRef;` for symmetry with its two sibling builders, which do read it. Symmetry is not reason enough to keep a dead parameter on a module-private function with one caller, so drop it (#4921 review). Behaviour is unchanged; the message text is pinned by an existing full-string assertion, which is what covers the only real risk here — transposing the two remaining arguments at the call site. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NuT9wTyrMaxjAPevqeH4YZ * test(#4881): exercise both FULL_SHA_RE alternatives at their boundaries The invalid-observation list pinned 39 and 41 hex around the 40-hex SHA-1 arm but left the 64-hex SHA-256 arm's own +/-1 boundary unexercised, which the repo's boundary-coverage convention asks for (#4921 review). Adds 63, 65 and a 64-length non-hex string. The regex already rejected all three -- this is coverage of correct behaviour, not a fix -- so it carries no negative control against a pre-fix base. That it is not vacuous was shown instead by widening the arm to {63,65}, under which the row fails by name. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NuT9wTyrMaxjAPevqeH4YZ * docs(#4881): finish the surface sweep the hook interlock owes Self-found by this round's own pre-push adversarial review, over six passes. Eight sites, four classes. FIVE were prose still asserting the stance the interlock overturned, which reads as live canon to anyone arriving cold: docs/CLI-TOOLS.md, docs/CONFIGURATION.md and gsd-core/references/planning-config.md each still said the hook degrade is lifted "unless/until a clean prior harness worktree is observed"; observeHarnessForkFromHead's own header still said a qualifying worktree "can only exist if the harness forked from HEAD"; and a test name still called the observation "required on a hook host" when it is now inadmissible there. My own sweep had grepped for "restores"/"lifts" and missed every one -- the ordinary failure of a grep, which returns what you thought to search for. The SIXTH is the same class one step worse: gsd-core/workflows/execute-plan.md still said flatly that Claude Code's isolation="worktree" "forks from origin/HEAD, not live local HEAD" -- in a paragraph THIS PR already edits, a few sentences after the clause it corrected. A tombstone makes only its own line clean; adjoining text asserting the dead stance is the other half of the same defect. Now qualified on the setting, with the hook exception named. The SEVENTH is a proof-strength overstatement that predates this PR, with a driven counterexample: a worktree created from an older base and since `git checkout --detach`ed onto HEAD is clean, sits at HEAD, and satisfies the probe identically, so "can only exist" was false. The worktree's own reflog does retain that original checkout -- the information is not lost, the probe simply does not consult it. The EIGHTH is that the header described only one of the function's two legs. A cache hit returns the prior conclusion without reading any worktree, so "the probe reads a worktree's present state" was true of the live probe and false of the cache. The header now separates them, and names the cache's blindness as a third reason the observation is inadmissible under a hook. Gaps seven and eight are inherited from #4868 and accepted there for the no-hook case. Nothing about the mechanism changes here; only what the header claims for it. No behavioural change -- comments, prose, and one test's registered name. Emitted-Drift-Ack-Growth: execute-plan.md — the Pattern A paragraph gained a qualifying clause: it stated flatly that Claude Code forks from origin/HEAD, which is the premise this PR retires, a few sentences after the clause the PR had already corrected. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NuT9wTyrMaxjAPevqeH4YZ --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
This commit is contained in:
@@ -5,7 +5,8 @@
|
||||
*
|
||||
* Seam: gsd-core/bin/lib/worktree-base-ref.cjs
|
||||
* Interface: shortSha, readBaseRefFromSettings, applyWorktreeBaseRef,
|
||||
* resolveEffectiveBaseRef, evaluateWorktreeBaseDegrade
|
||||
* resolveEffectiveBaseRef, findWorktreeCreateHook,
|
||||
* evaluateWorktreeBaseDegrade
|
||||
*
|
||||
* Issue #683: worktree base-mismatch detection and degradation logic.
|
||||
* All tests use dependency injection (inline stubs) — no real filesystem
|
||||
@@ -30,6 +31,7 @@ const {
|
||||
readBaseRefFromSettings,
|
||||
applyWorktreeBaseRef,
|
||||
resolveEffectiveBaseRef,
|
||||
findWorktreeCreateHook,
|
||||
evaluateWorktreeBaseDegrade,
|
||||
classifyGitHead,
|
||||
cmdWorktreeBaseCheck,
|
||||
@@ -297,61 +299,242 @@ describe('evaluateWorktreeBaseDegrade', () => {
|
||||
assert.strictEqual(called, false, 'orchestrator mode: GSD controls the fork start-point, head is honored by construction');
|
||||
});
|
||||
|
||||
test('effectiveBaseRef="head" + harness mode (default) + diverged HEAD → degrade, reason baseref-head-ignored-by-harness (#3659)', () => {
|
||||
// #48 verified 5/5 that the harness dispatch path never routes through
|
||||
// project-settings baseRef — with head set on a diverged branch the check
|
||||
// must compare and degrade, not trust the setting.
|
||||
const HEAD_SHA = '11111111223344aa11111111223344aa11111111';
|
||||
const FORK_SHA = '99999999223344bb99999999223344bb99999999';
|
||||
test('effectiveBaseRef="head" + harness mode (default) + diverged HEAD → no degrade, reason baseref-head, execGit never called (#4588)', () => {
|
||||
// #3659 made harness mode fall through to the origin/HEAD comparison on
|
||||
// #48's finding that the harness did not read the setting. It does now —
|
||||
// measured on current Claude Code from all three settings layers on macOS,
|
||||
// Windows and Linux (#4588) — so `head` means the fork base IS the
|
||||
// orchestrator HEAD in harness mode too, and there is nothing to compare.
|
||||
let called = false;
|
||||
const result = evaluateWorktreeBaseDegrade({
|
||||
execGit: makeDivergedExecGit(HEAD_SHA, FORK_SHA),
|
||||
execGit: () => { called = true; return { exitCode: 0, stdout: '', stderr: '', signal: null, error: null }; },
|
||||
effectiveBaseRef: 'head',
|
||||
});
|
||||
assert.strictEqual(result.shouldDegrade, true,
|
||||
'head must not suppress the comparison in harness mode (#3659)');
|
||||
assert.strictEqual(result.reason, 'baseref-head-ignored-by-harness');
|
||||
assert.strictEqual(result.headSha, HEAD_SHA);
|
||||
assert.strictEqual(result.forkRef, 'origin/HEAD');
|
||||
assert.strictEqual(result.forkSha, FORK_SHA);
|
||||
assert.strictEqual(result.shouldDegrade, false,
|
||||
'head must suppress the comparison in harness mode: the Claude Code harness honors worktree.baseRef, as measured (#4588)');
|
||||
assert.strictEqual(result.reason, 'baseref-head');
|
||||
assert.strictEqual(result.message, null);
|
||||
assert.strictEqual(result.headSha, null);
|
||||
assert.strictEqual(result.forkRef, null);
|
||||
assert.strictEqual(result.forkSha, null);
|
||||
assert.strictEqual(called, false, 'no observation and head set: the fork base is known without asking git');
|
||||
});
|
||||
|
||||
test('effectiveBaseRef="head" + explicit harness-worktree mode + diverged → degrade (#3659)', () => {
|
||||
const HEAD_SHA = 'aaaa1111223344ccaaaa1111223344ccaaaa1111';
|
||||
const FORK_SHA = 'bbbb1111223344ddbbbb1111223344ddbbbb1111';
|
||||
test('effectiveBaseRef="head" + explicit harness-worktree mode + diverged → no degrade (#4588)', () => {
|
||||
const result = evaluateWorktreeBaseDegrade({
|
||||
execGit: makeDivergedExecGit(HEAD_SHA, FORK_SHA),
|
||||
execGit: () => { throw new Error('execGit must not be called'); },
|
||||
effectiveBaseRef: 'head',
|
||||
isolationMode: 'harness-worktree',
|
||||
});
|
||||
assert.strictEqual(result.shouldDegrade, false);
|
||||
assert.strictEqual(result.reason, 'baseref-head');
|
||||
});
|
||||
|
||||
// ── observedForkBase (#4588): the measurement replaces the inference ─────
|
||||
|
||||
test('observedForkBase === HEAD + harness + head → no degrade, reason observed-fork-matches-head; origin/HEAD never resolved (#4588)', () => {
|
||||
// The regression the triage brief named: an observed fork base equal to
|
||||
// local HEAD in harness mode must be able to yield shouldDegrade:false.
|
||||
const SAME_SHA = 'cccc1111223344eecccc1111223344eecccc1111';
|
||||
const calls = [];
|
||||
const result = evaluateWorktreeBaseDegrade({
|
||||
execGit: (args) => {
|
||||
calls.push(args.join(' '));
|
||||
if (args.join(' ') === 'rev-parse HEAD') return { exitCode: 0, stdout: SAME_SHA, stderr: '', signal: null, error: null };
|
||||
throw new Error(`Unexpected execGit call: ${JSON.stringify(args)}`);
|
||||
},
|
||||
effectiveBaseRef: 'head',
|
||||
isolationMode: 'harness-worktree',
|
||||
observedForkBase: SAME_SHA,
|
||||
});
|
||||
assert.strictEqual(result.shouldDegrade, false);
|
||||
assert.strictEqual(result.reason, 'observed-fork-matches-head');
|
||||
assert.strictEqual(result.headSha, SAME_SHA);
|
||||
assert.strictEqual(result.forkRef, 'observed');
|
||||
assert.strictEqual(result.forkSha, SAME_SHA);
|
||||
assert.deepStrictEqual(calls, ['rev-parse HEAD'],
|
||||
'an observation is the fork base — origin/HEAD must not be consulted');
|
||||
});
|
||||
|
||||
test('observedForkBase !== HEAD + harness + head → degrade, reason baseref-head-ignored-by-harness, message names the observation (#4588)', () => {
|
||||
// Acceptance criterion (2): a genuine mismatch keeps the existing
|
||||
// degrade-and-warn — now reporting a measurement, not a belief.
|
||||
const HEAD_SHA = '11111111223344aa11111111223344aa11111111';
|
||||
const OBSERVED = '99999999223344bb99999999223344bb99999999';
|
||||
const result = evaluateWorktreeBaseDegrade({
|
||||
execGit: makeExecGit({
|
||||
'rev-parse HEAD': { exitCode: 0, stdout: HEAD_SHA, stderr: '', signal: null, error: null },
|
||||
}),
|
||||
effectiveBaseRef: 'head',
|
||||
observedForkBase: OBSERVED,
|
||||
});
|
||||
assert.strictEqual(result.shouldDegrade, true,
|
||||
'head set but the host forked from elsewhere: the harness did not honor it — degrade (#4588)');
|
||||
assert.strictEqual(result.reason, 'baseref-head-ignored-by-harness');
|
||||
assert.strictEqual(result.headSha, HEAD_SHA);
|
||||
assert.strictEqual(result.forkRef, 'observed');
|
||||
assert.strictEqual(result.forkSha, OBSERVED);
|
||||
assert.ok(result.message !== null, 'divergence under head must carry the explanatory message');
|
||||
// Pinned VERBATIM — the source declares these messages downstream dependencies, and a
|
||||
// substring check lets the untested portions drift (P4.6 review, round 3).
|
||||
const expectedMsg = `⚠ Worktree base mismatch: worktree.baseRef:"head" is set, but a worktree created for this dispatch was observed to fork from ${OBSERVED.slice(0, 8)} while HEAD is ${HEAD_SHA.slice(0, 8)} — the worktree was not forked from HEAD despite the setting. Running this phase sequentially on the main working tree. Parallel worktrees return once a fresh dispatch is observed to fork from HEAD, or once HEAD is merged/pushed so the default fork base matches it. See #3659, #4588.`;
|
||||
assert.strictEqual(result.message, expectedMsg);
|
||||
assert.ok(result.message.includes('observed'), 'message must say the fork base was observed, not inferred');
|
||||
assert.ok(!result.message.includes('runtime harness'),
|
||||
'message must be mode-neutral — the observation may come from a GSD-created worktree (P4.6 review)');
|
||||
assert.ok(result.message.includes('fresh dispatch'),
|
||||
'remedy must ask for a fresh observation — pushing cannot change a fixed measurement');
|
||||
assert.ok(result.message.includes(OBSERVED.slice(0, 8)) && result.message.includes(HEAD_SHA.slice(0, 8)),
|
||||
'message must carry both short SHAs');
|
||||
assert.ok(result.message.includes('sequentially'), 'message must state the sequential fallback');
|
||||
assert.ok(result.message.includes('#4588'), 'message must cite the measurement issue');
|
||||
});
|
||||
|
||||
test('observedForkBase !== HEAD + no baseRef → degrade, reason head-diverged-from-fork, forkRef "observed" (#4588)', () => {
|
||||
const HEAD_SHA = 'aaaa1111223344ccaaaa1111223344ccaaaa1111';
|
||||
const OBSERVED = 'bbbb1111223344ddbbbb1111223344ddbbbb1111';
|
||||
const result = evaluateWorktreeBaseDegrade({
|
||||
execGit: makeExecGit({
|
||||
'rev-parse HEAD': { exitCode: 0, stdout: HEAD_SHA, stderr: '', signal: null, error: null },
|
||||
}),
|
||||
observedForkBase: OBSERVED,
|
||||
});
|
||||
assert.strictEqual(result.shouldDegrade, true);
|
||||
assert.strictEqual(result.reason, 'head-diverged-from-fork');
|
||||
assert.strictEqual(result.forkRef, 'observed');
|
||||
assert.strictEqual(result.forkSha, OBSERVED);
|
||||
// Pinned verbatim (P4.6 review, round 4) — the observed branch of buildMsgDiverged.
|
||||
const expectedMsg = `⚠ Worktree base mismatch: HEAD (${HEAD_SHA.slice(0, 8)}) differs from the observed fork base (${OBSERVED.slice(0, 8)}). Running this phase sequentially on the main working tree. Parallel worktrees return once a fresh dispatch is observed to fork from HEAD, or once HEAD is merged/pushed so the default fork base matches it, or set worktree.baseRef:"head" to fork worktrees from HEAD instead (honored by GSD-created worktrees and by the Claude Code harness; #683, #4588).`;
|
||||
assert.strictEqual(result.message, expectedMsg);
|
||||
assert.ok(result.message.includes('the observed fork base'),
|
||||
'message must phrase the fork side as an observation, not as a ref name');
|
||||
assert.ok(result.message.includes('fresh dispatch') && !result.message.includes('so the observed fork base matches it'),
|
||||
'remedy must not tell the user to push until a fixed observation matches');
|
||||
});
|
||||
|
||||
test('observedForkBase is case-folded: an uppercase 40-hex observation equal to HEAD matches (#4588 review)', () => {
|
||||
const SAME_SHA = '0123456789abcdef0123456789abcdef01234567';
|
||||
const result = evaluateWorktreeBaseDegrade({
|
||||
execGit: makeExecGit({
|
||||
'rev-parse HEAD': { exitCode: 0, stdout: SAME_SHA, stderr: '', signal: null, error: null },
|
||||
}),
|
||||
observedForkBase: SAME_SHA.toUpperCase(),
|
||||
});
|
||||
assert.strictEqual(result.shouldDegrade, false);
|
||||
assert.strictEqual(result.reason, 'observed-fork-matches-head');
|
||||
assert.strictEqual(result.forkSha, SAME_SHA, 'forkSha is the canonical lowercase form');
|
||||
});
|
||||
|
||||
test('observedForkBase accepts a 64-hex (SHA-256 repository) sha (#4588 review)', () => {
|
||||
const SAME_SHA = 'a'.repeat(64);
|
||||
const result = evaluateWorktreeBaseDegrade({
|
||||
execGit: makeExecGit({
|
||||
'rev-parse HEAD': { exitCode: 0, stdout: SAME_SHA, stderr: '', signal: null, error: null },
|
||||
}),
|
||||
observedForkBase: SAME_SHA,
|
||||
});
|
||||
assert.strictEqual(result.reason, 'observed-fork-matches-head');
|
||||
});
|
||||
|
||||
test('observedForkBase that is not a string (number, object, boolean) THROWS rather than reading as absent (#4588 review r2)', () => {
|
||||
for (const bad of [123, {}, true, []]) {
|
||||
assert.throws(
|
||||
() => evaluateWorktreeBaseDegrade({
|
||||
execGit: () => { throw new Error('execGit must not be called'); },
|
||||
effectiveBaseRef: 'head',
|
||||
observedForkBase: bad,
|
||||
}),
|
||||
/observedForkBase must be a full 40- or 64-hex commit sha/,
|
||||
`observedForkBase=${JSON.stringify(bad)} must fail closed, not short-circuit as baseref-head`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('observedForkBase that is non-blank but not a full sha THROWS — an abbreviation could never match and would always degrade (#4588 review)', () => {
|
||||
// One full-string pin so the validation message itself cannot drift (P4.6 review, round 4).
|
||||
assert.throws(
|
||||
() => evaluateWorktreeBaseDegrade({ execGit: () => { throw new Error('unreachable'); }, observedForkBase: 'HEAD' }),
|
||||
{ name: 'TypeError', message: 'evaluateWorktreeBaseDegrade: observedForkBase must be a full 40- or 64-hex commit sha (git rev-parse HEAD inside the worktree, before any commit), got "HEAD"' }
|
||||
);
|
||||
// Both alternatives of FULL_SHA_RE get their own ±1 boundary: 39/41 around the
|
||||
// 40-hex (SHA-1) arm, 63/65 around the 64-hex (SHA-256) arm (#4921 review).
|
||||
for (const bad of ['0123456', '0123456789abcdef0123456789abcdef0123456', 'HEAD', 'not-a-sha', 'g'.repeat(40), 'b'.repeat(41), 'c'.repeat(63), 'd'.repeat(65), 'g'.repeat(64)]) {
|
||||
assert.throws(
|
||||
() => evaluateWorktreeBaseDegrade({
|
||||
execGit: () => { throw new Error('execGit must not be called before the observation is validated'); },
|
||||
effectiveBaseRef: 'head',
|
||||
observedForkBase: bad,
|
||||
}),
|
||||
/observedForkBase must be a full 40- or 64-hex commit sha/,
|
||||
`observedForkBase=${JSON.stringify(bad)} must fail closed`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('observedForkBase === HEAD + no baseRef → no degrade, reason observed-fork-matches-head (#4588)', () => {
|
||||
const SAME_SHA = 'dddd1111223344ffdddd1111223344ffdddd1111';
|
||||
const result = evaluateWorktreeBaseDegrade({
|
||||
execGit: makeExecGit({
|
||||
'rev-parse HEAD': { exitCode: 0, stdout: SAME_SHA, stderr: '', signal: null, error: null },
|
||||
}),
|
||||
observedForkBase: SAME_SHA,
|
||||
});
|
||||
assert.strictEqual(result.shouldDegrade, false);
|
||||
assert.strictEqual(result.reason, 'observed-fork-matches-head');
|
||||
});
|
||||
|
||||
test('observedForkBase + head + orchestrator mode → the observation still decides (no short-circuit) (#4588)', () => {
|
||||
// An observation is stronger than either mode's belief about the fork
|
||||
// base: if GSD's own `git worktree add` somehow forked from elsewhere, the
|
||||
// measurement — not the construction argument — is what the verdict reads.
|
||||
const HEAD_SHA = 'eeee1111223344abeeee1111223344abeeee1111';
|
||||
const OBSERVED = 'ffff1111223344acffff1111223344acffff1111';
|
||||
const result = evaluateWorktreeBaseDegrade({
|
||||
execGit: makeExecGit({
|
||||
'rev-parse HEAD': { exitCode: 0, stdout: HEAD_SHA, stderr: '', signal: null, error: null },
|
||||
}),
|
||||
effectiveBaseRef: 'head',
|
||||
isolationMode: 'orchestrator-worktree',
|
||||
observedForkBase: OBSERVED,
|
||||
});
|
||||
assert.strictEqual(result.shouldDegrade, true);
|
||||
assert.strictEqual(result.reason, 'baseref-head-ignored-by-harness');
|
||||
});
|
||||
|
||||
test('effectiveBaseRef="head" + harness + HEAD == origin/HEAD → no degrade, reason head-matches-fork (#3659)', () => {
|
||||
const SAME_SHA = 'cccc1111223344eecccc1111223344eecccc1111';
|
||||
const result = evaluateWorktreeBaseDegrade({
|
||||
execGit: makeExecGit({
|
||||
'rev-parse HEAD': { exitCode: 0, stdout: SAME_SHA, stderr: '', signal: null, error: null },
|
||||
'rev-parse --verify --quiet origin/HEAD': { exitCode: 0, stdout: SAME_SHA, stderr: '', signal: null, error: null },
|
||||
}),
|
||||
effectiveBaseRef: 'head',
|
||||
isolationMode: 'harness-worktree',
|
||||
});
|
||||
assert.strictEqual(result.shouldDegrade, false,
|
||||
'when the harness fork base happens to equal HEAD there is no mismatch to degrade for');
|
||||
assert.strictEqual(result.reason, 'head-matches-fork');
|
||||
test('observedForkBase empty or whitespace → treated as absent: head short-circuits, else origin/HEAD is inferred (#4588)', () => {
|
||||
// Negative control for the observation path: an empty observation must
|
||||
// not be mistaken for a measured fork base of "".
|
||||
for (const empty of ['', ' ', null, undefined]) {
|
||||
const viaHead = evaluateWorktreeBaseDegrade({
|
||||
execGit: () => { throw new Error('execGit must not be called'); },
|
||||
effectiveBaseRef: 'head',
|
||||
observedForkBase: empty,
|
||||
});
|
||||
assert.strictEqual(viaHead.reason, 'baseref-head', `head + observedForkBase=${JSON.stringify(empty)} short-circuits`);
|
||||
|
||||
const HEAD_SHA = '12341234123412341234123412341234deadbeef';
|
||||
const FORK_SHA = '43214321432143214321432143214321cafebabe';
|
||||
const inferred = evaluateWorktreeBaseDegrade({
|
||||
execGit: makeDivergedExecGit(HEAD_SHA, FORK_SHA),
|
||||
observedForkBase: empty,
|
||||
});
|
||||
assert.strictEqual(inferred.reason, 'head-diverged-from-fork', `no head + observedForkBase=${JSON.stringify(empty)} infers origin/HEAD`);
|
||||
assert.strictEqual(inferred.forkRef, 'origin/HEAD');
|
||||
}
|
||||
});
|
||||
|
||||
test('harness head-diverge message cites the verified harness limitation (#3659)', () => {
|
||||
const HEAD_SHA = 'dddd1111223344ffdddd1111223344ffdddd1111';
|
||||
const FORK_SHA = 'eeee1111223344abeeceeee1111223344abeeceee';
|
||||
test('observedForkBase does not bypass HEAD resolution: exit 128 → no-head, degrades as #4734 pinned (#4588)', () => {
|
||||
const result = evaluateWorktreeBaseDegrade({
|
||||
execGit: makeDivergedExecGit(HEAD_SHA, FORK_SHA),
|
||||
effectiveBaseRef: 'head',
|
||||
execGit: makeExecGit({
|
||||
'rev-parse HEAD': { exitCode: 128, stdout: '', stderr: 'fatal: not a git repo', signal: null, error: null },
|
||||
}),
|
||||
observedForkBase: 'abcdef1234567890abcdef1234567890abcdef12',
|
||||
});
|
||||
assert.ok(result.message !== null, 'divergence under head must carry the explanatory message');
|
||||
assert.ok(result.message.includes('#48'), 'message must cite the verified harness limitation');
|
||||
assert.ok(result.message.includes('sequentially'), 'message must state the sequential fallback');
|
||||
// #4734: exit 128 is git's definitive "no repository" answer and degrades —
|
||||
// an observation cannot make a worktree creatable where none can exist.
|
||||
assert.strictEqual(result.shouldDegrade, true);
|
||||
assert.strictEqual(result.reason, 'no-head');
|
||||
assert.strictEqual(result.headAbsenceVerified, true);
|
||||
});
|
||||
|
||||
test('git rev-parse HEAD exits 128 (definitive no-repository) → degrades, reason no-head (#4734)', () => {
|
||||
@@ -398,6 +581,11 @@ describe('evaluateWorktreeBaseDegrade', () => {
|
||||
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');
|
||||
// Pinned verbatim (P4.6 review, round 4). #4588 dropped the sentence claiming
|
||||
// baseRef:"head" "never applied" in harness mode — with head set this path is
|
||||
// no longer reached at all, so the note would have been false.
|
||||
assert.strictEqual(result.message,
|
||||
'⚠ 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. Retry; if it persists, check for a stalled filesystem mount or a stale git index lock (.git/index.lock). See #683, #3050.');
|
||||
assert.strictEqual(result.headSha, null);
|
||||
});
|
||||
|
||||
@@ -525,10 +713,12 @@ describe('evaluateWorktreeBaseDegrade', () => {
|
||||
assert.strictEqual(result.headSha, HEAD_SHA);
|
||||
assert.strictEqual(result.forkRef, 'origin/HEAD');
|
||||
assert.strictEqual(result.forkSha, FORK_SHA);
|
||||
// Verify message contains the short SHAs and the corrected remediation
|
||||
// (#3659: the old text advised setting baseRef:"head", which the harness
|
||||
// does not read).
|
||||
const expectedMsg = `⚠ Worktree base mismatch: HEAD (${HEAD_SHA.slice(0, 8)}) differs from origin/HEAD (${FORK_SHA.slice(0, 8)}). Running this phase sequentially on the main working tree. Parallel worktrees return once HEAD is merged/pushed so origin/HEAD matches it. (worktree.baseRef:"head" applies only where GSD itself creates the worktree — the runtime harness does not read it; #48, #3659.)`;
|
||||
// Verify message contains the short SHAs and the remediation. #3659 had
|
||||
// removed the baseRef:"head" advice on #48's finding that the harness did
|
||||
// not read the setting; it does now (#4588), so the advice is back — with
|
||||
// the setting absent the harness really does fork from origin/HEAD, and
|
||||
// `head` is the fix for that.
|
||||
const expectedMsg = `⚠ Worktree base mismatch: HEAD (${HEAD_SHA.slice(0, 8)}) differs from origin/HEAD (${FORK_SHA.slice(0, 8)}). Running this phase sequentially on the main working tree. Parallel worktrees return once HEAD is merged/pushed so origin/HEAD matches it, or set worktree.baseRef:"head" to fork worktrees from HEAD instead (honored by GSD-created worktrees and by the Claude Code harness; #683, #4588).`;
|
||||
assert.strictEqual(result.message, expectedMsg);
|
||||
});
|
||||
|
||||
@@ -640,7 +830,11 @@ describe('cmdWorktreeBaseCheck', () => {
|
||||
assert.deepStrictEqual(parsed, result);
|
||||
});
|
||||
|
||||
test('baseRef=head in settings + default (harness) mode + diverged HEAD → shouldDegrade true (#3659)', () => {
|
||||
test('baseRef=head in settings + default (harness) mode + diverged HEAD → shouldDegrade false, reason baseref-head (#4588)', () => {
|
||||
// The #4588 symptom end to end: project-local head, harness mode, HEAD
|
||||
// ahead of origin/HEAD — this returned baseref-head-ignored-by-harness and
|
||||
// degraded every wave on an unmerged branch. The Claude Code harness honors the
|
||||
// setting, so the check no longer compares.
|
||||
const cwd = '/repo';
|
||||
const claudeDir = '/repo/.claude';
|
||||
const HEAD_SHA = 'fade1111223344cafade1111223344cafade1111';
|
||||
@@ -658,9 +852,87 @@ describe('cmdWorktreeBaseCheck', () => {
|
||||
userClaudeDir: '/nonexistent-hermetic-user-dir',
|
||||
};
|
||||
const result = cmdWorktreeBaseCheck(cwd, [], deps);
|
||||
assert.strictEqual(result.shouldDegrade, true,
|
||||
'settings head must not suppress the harness-mode comparison (#3659)');
|
||||
assert.strictEqual(result.shouldDegrade, false,
|
||||
'settings head suppresses the harness-mode comparison: the Claude Code harness honors worktree.baseRef, as measured (#4588)');
|
||||
assert.strictEqual(result.reason, 'baseref-head');
|
||||
});
|
||||
|
||||
test('--observed-fork-base <sha> equal to HEAD + settings head → observed-fork-matches-head (#4588)', () => {
|
||||
const cwd = '/repo';
|
||||
const claudeDir = '/repo/.claude';
|
||||
const SAME_SHA = 'feed1111223344dafeed1111223344dafeed1111';
|
||||
const deps = {
|
||||
readFile: (p) => {
|
||||
if (p === path.join(claudeDir, 'settings.local.json')) return JSON.stringify({ worktree: { baseRef: 'head' } });
|
||||
return null;
|
||||
},
|
||||
execGit: makeExecGitCheck({
|
||||
'rev-parse HEAD': { exitCode: 0, stdout: SAME_SHA, stderr: '', signal: null, error: null },
|
||||
}),
|
||||
write: () => {},
|
||||
userClaudeDir: '/nonexistent-hermetic-user-dir',
|
||||
};
|
||||
const result = cmdWorktreeBaseCheck(cwd, ['--observed-fork-base', SAME_SHA], deps);
|
||||
assert.strictEqual(result.shouldDegrade, false);
|
||||
assert.strictEqual(result.reason, 'observed-fork-matches-head');
|
||||
assert.strictEqual(result.forkRef, 'observed');
|
||||
});
|
||||
|
||||
test('--observed-fork-base <sha> differing from HEAD + settings head → baseref-head-ignored-by-harness (#4588)', () => {
|
||||
const cwd = '/repo';
|
||||
const claudeDir = '/repo/.claude';
|
||||
const HEAD_SHA = 'fade1111223344cafade1111223344cafade1111';
|
||||
const OBSERVED = 'bead1111223344dbbead1111223344dbbead1111';
|
||||
const deps = {
|
||||
readFile: (p) => {
|
||||
if (p === path.join(claudeDir, 'settings.local.json')) return JSON.stringify({ worktree: { baseRef: 'head' } });
|
||||
return null;
|
||||
},
|
||||
execGit: makeExecGitCheck({
|
||||
'rev-parse HEAD': { exitCode: 0, stdout: HEAD_SHA, stderr: '', signal: null, error: null },
|
||||
}),
|
||||
write: () => {},
|
||||
userClaudeDir: '/nonexistent-hermetic-user-dir',
|
||||
};
|
||||
const result = cmdWorktreeBaseCheck(cwd, ['--mode', 'harness-worktree', '--observed-fork-base', OBSERVED], deps);
|
||||
assert.strictEqual(result.shouldDegrade, true);
|
||||
assert.strictEqual(result.reason, 'baseref-head-ignored-by-harness');
|
||||
assert.strictEqual(result.forkSha, OBSERVED);
|
||||
});
|
||||
|
||||
test('--observed-fork-base rejects a missing or non-sha value — no silent fallback to inference (#4588)', () => {
|
||||
const cwd = '/repo';
|
||||
const deps = {
|
||||
readFile: () => null,
|
||||
execGit: makeExecGitCheck({}),
|
||||
write: () => {},
|
||||
userClaudeDir: '/nonexistent-hermetic-user-dir',
|
||||
};
|
||||
// Includes the abbreviated 7-char form: accepted by an earlier draft, it could never
|
||||
// equal the full `rev-parse HEAD` output and so always degraded (P4.6 review).
|
||||
for (const bad of [['--observed-fork-base'], ['--observed-fork-base', 'HEAD'], ['--observed-fork-base', 'abc'], ['--observed-fork-base', 'abcdef1'], ['--observed-fork-base', 'g'.repeat(40)], ['--observed-fork-base', 'a'.repeat(39)]]) {
|
||||
assert.throws(
|
||||
() => cmdWorktreeBaseCheck(cwd, bad, deps),
|
||||
/--observed-fork-base: observedForkBase must be a full 40- or 64-hex commit sha/,
|
||||
`args ${JSON.stringify(bad)} must fail closed`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('--observed-fork-base folds case before comparing (#4588 review)', () => {
|
||||
const cwd = '/repo';
|
||||
const SAME_SHA = 'feed1111223344dafeed1111223344dafeed1111';
|
||||
const deps = {
|
||||
readFile: () => null,
|
||||
execGit: makeExecGitCheck({
|
||||
'rev-parse HEAD': { exitCode: 0, stdout: SAME_SHA, stderr: '', signal: null, error: null },
|
||||
}),
|
||||
write: () => {},
|
||||
userClaudeDir: '/nonexistent-hermetic-user-dir',
|
||||
};
|
||||
const result = cmdWorktreeBaseCheck(cwd, ['--observed-fork-base', SAME_SHA.toUpperCase()], deps);
|
||||
assert.strictEqual(result.reason, 'observed-fork-matches-head');
|
||||
assert.strictEqual(result.forkSha, SAME_SHA);
|
||||
});
|
||||
|
||||
test('--mode rejects invalid values — no silent default that would re-open the #3659 hole', () => {
|
||||
@@ -1177,9 +1449,12 @@ describe('cmdWorktreeBaseCheck — user/global cascade (#1013)', () => {
|
||||
assert.strictEqual(result.reason, 'baseref-head');
|
||||
});
|
||||
|
||||
test('user/global head + phase lane + default (harness) mode → shouldDegrade:true (#3659)', () => {
|
||||
// The mirror of the KEY REGRESSION row: in harness mode the setting cannot
|
||||
// suppress anything (#48), so the same lane degrades.
|
||||
test('user/global head + phase lane + default (harness) mode → shouldDegrade:false (#4588)', () => {
|
||||
// The mirror of the KEY REGRESSION row. #3659 had this lane degrade in
|
||||
// harness mode on #48's finding that the harness did not read the setting;
|
||||
// the user/global layer in particular is honored by the harness (measured
|
||||
// on current Claude Code, #4588 — the arm #4090's triage left to the
|
||||
// originating investigation), so the same lane no longer degrades.
|
||||
const deps = {
|
||||
execGit: makePhaseLaneExecGit(HEAD_SHA),
|
||||
readFile: (p) => {
|
||||
@@ -1194,9 +1469,9 @@ describe('cmdWorktreeBaseCheck — user/global cascade (#1013)', () => {
|
||||
userClaudeDir: USER_CLAUDE_DIR,
|
||||
};
|
||||
const result = cmdWorktreeBaseCheck(cwd, [], deps);
|
||||
assert.strictEqual(result.shouldDegrade, true,
|
||||
'harness mode: head must not suppress the lane degrade (#3659)');
|
||||
assert.strictEqual(result.reason, 'fork-ref-unknown');
|
||||
assert.strictEqual(result.shouldDegrade, false,
|
||||
'harness mode: a user/global head suppresses the lane degrade — the Claude Code harness honors it, as measured (#4588)');
|
||||
assert.strictEqual(result.reason, 'baseref-head');
|
||||
});
|
||||
|
||||
test('(e negative) NO user/global head + same phase lane → shouldDegrade:true (proves lane degrades)', () => {
|
||||
@@ -1241,6 +1516,262 @@ describe('cmdWorktreeBaseCheck — user/global cascade (#1013)', () => {
|
||||
|
||||
const QUICK_WORKFLOW_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'quick.md');
|
||||
|
||||
// ─── WorktreeCreate-hook interlock (#4588) ───────────────────────────────────
|
||||
//
|
||||
// A Claude Code WorktreeCreate hook creates the agent worktree from the directory it
|
||||
// emits, and Claude Code does not apply worktree.baseRef on that path. So the #4588
|
||||
// trust in "head" under harness-worktree must not hold on a host that configures one.
|
||||
|
||||
const HOOK_SETTINGS = JSON.stringify({
|
||||
hooks: { WorktreeCreate: [{ hooks: [{ type: 'command', command: 'make-worktree.sh' }] }] },
|
||||
});
|
||||
|
||||
function settingsReader(files) {
|
||||
return (p) => (Object.prototype.hasOwnProperty.call(files, p) ? files[p] : null);
|
||||
}
|
||||
|
||||
describe('findWorktreeCreateHook (#4588)', () => {
|
||||
const claudeDir = '/repo/.claude';
|
||||
const USER_CLAUDE_DIR = '/home/user/.claude';
|
||||
const LAYERS = [
|
||||
path.join(claudeDir, 'settings.local.json'),
|
||||
path.join(claudeDir, 'settings.json'),
|
||||
path.join(USER_CLAUDE_DIR, 'settings.json'),
|
||||
];
|
||||
|
||||
test('a WorktreeCreate hook in each of the three layers is found and names that layer (#4588)', () => {
|
||||
for (const file of LAYERS) {
|
||||
const found = findWorktreeCreateHook(claudeDir, { readFile: settingsReader({ [file]: HOOK_SETTINGS }) }, USER_CLAUDE_DIR);
|
||||
assert.deepStrictEqual(found, { file, kind: 'hook' }, `hook in ${file}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('every layer is checked, not only the one that supplies baseRef (#4588)', () => {
|
||||
const found = findWorktreeCreateHook(claudeDir, {
|
||||
readFile: settingsReader({
|
||||
[LAYERS[0]]: JSON.stringify({ worktree: { baseRef: 'head' } }),
|
||||
[LAYERS[2]]: HOOK_SETTINGS,
|
||||
}),
|
||||
}, USER_CLAUDE_DIR);
|
||||
assert.deepStrictEqual(found, { file: LAYERS[2], kind: 'hook' });
|
||||
});
|
||||
|
||||
test('no settings, other hook events, an empty WorktreeCreate list or a blank file → null (#4588)', () => {
|
||||
const cases = {
|
||||
'no files': {},
|
||||
'other hook events only': { [LAYERS[1]]: JSON.stringify({ hooks: { PreToolUse: [{ hooks: [] }], WorktreeRemove: [{ hooks: [] }] } }) },
|
||||
'empty WorktreeCreate list': { [LAYERS[0]]: JSON.stringify({ hooks: { WorktreeCreate: [] } }) },
|
||||
'baseRef only': { [LAYERS[0]]: JSON.stringify({ worktree: { baseRef: 'head' } }) },
|
||||
'whitespace-only file': { [LAYERS[0]]: ' \n' },
|
||||
'non-object top level': { [LAYERS[1]]: '[]' },
|
||||
};
|
||||
for (const [label, files] of Object.entries(cases)) {
|
||||
assert.strictEqual(findWorktreeCreateHook(claudeDir, { readFile: settingsReader(files) }, USER_CLAUDE_DIR), null, label);
|
||||
}
|
||||
});
|
||||
|
||||
test('a layer that does not parse fails closed as kind "unparseable" (#4588)', () => {
|
||||
const found = findWorktreeCreateHook(claudeDir, { readFile: settingsReader({ [LAYERS[1]]: '{ "hooks": ' }) }, USER_CLAUDE_DIR);
|
||||
assert.deepStrictEqual(found, { file: LAYERS[1], kind: 'unparseable' });
|
||||
});
|
||||
|
||||
test('JSONC comments and trailing commas still parse, so a commented hook is found (#4588)', () => {
|
||||
const jsonc = '{\n // local hook\n "hooks": { "WorktreeCreate": [ { "hooks": [ { "type": "command", "command": "x" } ] }, ] },\n}\n';
|
||||
const found = findWorktreeCreateHook(claudeDir, { readFile: settingsReader({ [LAYERS[0]]: jsonc }) }, USER_CLAUDE_DIR);
|
||||
assert.deepStrictEqual(found, { file: LAYERS[0], kind: 'hook' });
|
||||
});
|
||||
|
||||
test('user/global layer is read only when userClaudeDir is given and differs from claudeDir (#4588)', () => {
|
||||
const readPaths = [];
|
||||
const readFile = (p) => { readPaths.push(p); return null; };
|
||||
findWorktreeCreateHook(claudeDir, { readFile }, claudeDir);
|
||||
findWorktreeCreateHook(claudeDir, { readFile }, null);
|
||||
findWorktreeCreateHook(claudeDir, { readFile });
|
||||
assert.ok(!readPaths.includes(LAYERS[2]), 'user layer never read without a distinct userClaudeDir');
|
||||
assert.strictEqual(readPaths.filter((p) => p === LAYERS[1]).length, 3, 'shared settings.json read once per call');
|
||||
});
|
||||
});
|
||||
|
||||
describe('evaluateWorktreeBaseDegrade — WorktreeCreate hook interlock (#4588)', () => {
|
||||
const HEAD_SHA = 'a1a1a1a1b2b2b2b2c3c3c3c3d4d4d4d4e5e5e5e5';
|
||||
const FORK_SHA = 'f6f6f6f6a7a7a7a7b8b8b8b8c9c9c9c9d0d0d0d0';
|
||||
const HOOK = { file: '/repo/.claude/settings.json', kind: 'hook' };
|
||||
const ok = (stdout) => ({ exitCode: 0, stdout, stderr: '', signal: null, error: null });
|
||||
function stubGit(responses) {
|
||||
return (args) => {
|
||||
const key = args.join(' ');
|
||||
if (Object.prototype.hasOwnProperty.call(responses, key)) return responses[key];
|
||||
throw new Error(`Unexpected execGit call: ${JSON.stringify(args)}`);
|
||||
};
|
||||
}
|
||||
|
||||
test('head + harness mode + hook + HEAD diverged from origin/HEAD → degrade, reason baseref-head-bypassed-by-hook naming the file (#4588)', () => {
|
||||
for (const isolationMode of [undefined, 'harness-worktree']) {
|
||||
const result = evaluateWorktreeBaseDegrade({
|
||||
execGit: stubGit({ 'rev-parse HEAD': ok(HEAD_SHA), 'rev-parse --verify --quiet origin/HEAD': ok(FORK_SHA) }),
|
||||
effectiveBaseRef: 'head',
|
||||
isolationMode,
|
||||
worktreeCreateHook: HOOK,
|
||||
});
|
||||
assert.strictEqual(result.shouldDegrade, true, `isolationMode=${isolationMode}`);
|
||||
assert.strictEqual(result.reason, 'baseref-head-bypassed-by-hook');
|
||||
assert.strictEqual(result.headSha, HEAD_SHA);
|
||||
assert.strictEqual(result.forkRef, 'origin/HEAD');
|
||||
assert.strictEqual(result.forkSha, FORK_SHA);
|
||||
assert.ok(result.message.includes('WorktreeCreate hook is configured in /repo/.claude/settings.json'), result.message);
|
||||
assert.ok(!/harness does not honor/.test(result.message), 'the hook, not the harness, is named');
|
||||
// origin/HEAD says nothing about where a hook forks, so the remedy must not promise that
|
||||
// pushing restores parallelism; it points at a measurement instead (#4588 round review).
|
||||
assert.ok(!/merged\/pushed/.test(result.message), result.message);
|
||||
assert.ok(result.message.includes('--observed-fork-base'), result.message);
|
||||
assert.ok(result.message.includes('remove the hook'), result.message);
|
||||
}
|
||||
});
|
||||
|
||||
test('head + harness mode + hook + HEAD equal to origin/HEAD → no degrade, reason head-matches-fork (#4588)', () => {
|
||||
const result = evaluateWorktreeBaseDegrade({
|
||||
execGit: stubGit({ 'rev-parse HEAD': ok(HEAD_SHA), 'rev-parse --verify --quiet origin/HEAD': ok(HEAD_SHA) }),
|
||||
effectiveBaseRef: 'head',
|
||||
worktreeCreateHook: HOOK,
|
||||
});
|
||||
assert.strictEqual(result.shouldDegrade, false);
|
||||
assert.strictEqual(result.reason, 'head-matches-fork');
|
||||
});
|
||||
|
||||
test('an unparseable settings layer degrades with the same reason and says the hook cannot be ruled out (#4588)', () => {
|
||||
const result = evaluateWorktreeBaseDegrade({
|
||||
execGit: stubGit({ 'rev-parse HEAD': ok(HEAD_SHA), 'rev-parse --verify --quiet origin/HEAD': ok(FORK_SHA) }),
|
||||
effectiveBaseRef: 'head',
|
||||
worktreeCreateHook: { file: '/repo/.claude/settings.local.json', kind: 'unparseable' },
|
||||
});
|
||||
assert.strictEqual(result.shouldDegrade, true);
|
||||
assert.strictEqual(result.reason, 'baseref-head-bypassed-by-hook');
|
||||
assert.ok(result.message.includes('/repo/.claude/settings.local.json could not be parsed'), result.message);
|
||||
assert.ok(result.message.includes('fix /repo/.claude/settings.local.json so it parses'), result.message);
|
||||
assert.ok(!/merged\/pushed/.test(result.message), result.message);
|
||||
});
|
||||
|
||||
test('hook + head + orchestrator-worktree mode → baseref-head unchanged, execGit never called (#4588)', () => {
|
||||
const result = evaluateWorktreeBaseDegrade({
|
||||
execGit: () => { throw new Error('execGit must not be called'); },
|
||||
effectiveBaseRef: 'head',
|
||||
isolationMode: 'orchestrator-worktree',
|
||||
worktreeCreateHook: HOOK,
|
||||
});
|
||||
assert.strictEqual(result.shouldDegrade, false);
|
||||
assert.strictEqual(result.reason, 'baseref-head');
|
||||
});
|
||||
|
||||
test('hook + head + an observed fork base → the observation decides, not the hook (#4588)', () => {
|
||||
const match = evaluateWorktreeBaseDegrade({
|
||||
execGit: stubGit({ 'rev-parse HEAD': ok(HEAD_SHA) }),
|
||||
effectiveBaseRef: 'head',
|
||||
observedForkBase: HEAD_SHA,
|
||||
worktreeCreateHook: HOOK,
|
||||
});
|
||||
assert.strictEqual(match.reason, 'observed-fork-matches-head');
|
||||
const mismatch = evaluateWorktreeBaseDegrade({
|
||||
execGit: stubGit({ 'rev-parse HEAD': ok(HEAD_SHA) }),
|
||||
effectiveBaseRef: 'head',
|
||||
observedForkBase: FORK_SHA,
|
||||
worktreeCreateHook: HOOK,
|
||||
});
|
||||
assert.strictEqual(mismatch.reason, 'baseref-head-ignored-by-harness');
|
||||
});
|
||||
|
||||
test('hook without "head" set → the ordinary divergence verdict, not the hook reason (#4588)', () => {
|
||||
const result = evaluateWorktreeBaseDegrade({
|
||||
execGit: stubGit({ 'rev-parse HEAD': ok(HEAD_SHA), 'rev-parse --verify --quiet origin/HEAD': ok(FORK_SHA) }),
|
||||
effectiveBaseRef: 'fresh',
|
||||
worktreeCreateHook: HOOK,
|
||||
});
|
||||
assert.strictEqual(result.reason, 'head-diverged-from-fork');
|
||||
});
|
||||
|
||||
test('no hook finding (null or omitted) → head still short-circuits to baseref-head (#4588)', () => {
|
||||
for (const worktreeCreateHook of [null, undefined]) {
|
||||
const result = evaluateWorktreeBaseDegrade({
|
||||
execGit: () => { throw new Error('execGit must not be called'); },
|
||||
effectiveBaseRef: 'head',
|
||||
worktreeCreateHook,
|
||||
});
|
||||
assert.strictEqual(result.reason, 'baseref-head', `worktreeCreateHook=${worktreeCreateHook}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('cmdWorktreeBaseCheck — WorktreeCreate hook interlock (#4588)', () => {
|
||||
const cwd = '/repo';
|
||||
const claudeDir = '/repo/.claude';
|
||||
const USER_CLAUDE_DIR = '/home/user/.claude';
|
||||
const LOCAL = path.join(claudeDir, 'settings.local.json');
|
||||
const SHARED = path.join(claudeDir, 'settings.json');
|
||||
const USER = path.join(USER_CLAUDE_DIR, 'settings.json');
|
||||
const HEAD_SHA = '0a0a0a0a1b1b1b1b2c2c2c2c3d3d3d3d4e4e4e4e';
|
||||
const FORK_SHA = '5f5f5f5f6a6a6a6a7b7b7b7b8c8c8c8c9d9d9d9d';
|
||||
const ok = (stdout) => ({ exitCode: 0, stdout, stderr: '', signal: null, error: null });
|
||||
function divergedGit() {
|
||||
return (args) => {
|
||||
const key = args.join(' ');
|
||||
if (key === 'rev-parse HEAD') return ok(HEAD_SHA);
|
||||
if (key === 'rev-parse --verify --quiet origin/HEAD') return ok(FORK_SHA);
|
||||
throw new Error(`Unexpected execGit call: ${JSON.stringify(args)}`);
|
||||
};
|
||||
}
|
||||
function run(files, args = []) {
|
||||
return cmdWorktreeBaseCheck(cwd, args, {
|
||||
readFile: settingsReader(files),
|
||||
execGit: divergedGit(),
|
||||
write: () => {},
|
||||
userClaudeDir: USER_CLAUDE_DIR,
|
||||
});
|
||||
}
|
||||
|
||||
test('head set + a WorktreeCreate hook in each layer read + default harness mode → degrade, baseref-head-bypassed-by-hook (#4588)', () => {
|
||||
for (const hookFile of [LOCAL, SHARED, USER]) {
|
||||
const files = { [hookFile]: HOOK_SETTINGS };
|
||||
// head comes from a layer that does not carry the hook, except when the hook shares project-local
|
||||
files[hookFile === LOCAL ? USER : LOCAL] = JSON.stringify({ worktree: { baseRef: 'head' } });
|
||||
const result = run(files);
|
||||
assert.strictEqual(result.shouldDegrade, true, `hook in ${hookFile}`);
|
||||
assert.strictEqual(result.reason, 'baseref-head-bypassed-by-hook', `hook in ${hookFile}`);
|
||||
assert.ok(result.message.includes(hookFile), result.message);
|
||||
}
|
||||
});
|
||||
|
||||
test('head and the hook in the same file also degrades (#4588)', () => {
|
||||
const both = JSON.stringify({ worktree: { baseRef: 'head' }, hooks: JSON.parse(HOOK_SETTINGS).hooks });
|
||||
const result = run({ [SHARED]: both });
|
||||
assert.strictEqual(result.reason, 'baseref-head-bypassed-by-hook');
|
||||
});
|
||||
|
||||
test('the same hook changes nothing under --mode orchestrator-worktree or with --observed-fork-base (#4588)', () => {
|
||||
const files = { [LOCAL]: JSON.stringify({ worktree: { baseRef: 'head' } }), [SHARED]: HOOK_SETTINGS };
|
||||
assert.strictEqual(run(files, ['--mode', 'orchestrator-worktree']).reason, 'baseref-head');
|
||||
assert.strictEqual(run(files, ['--observed-fork-base', HEAD_SHA]).reason, 'observed-fork-matches-head');
|
||||
assert.strictEqual(run(files, ['--observed-fork-base', FORK_SHA]).reason, 'baseref-head-ignored-by-harness');
|
||||
});
|
||||
|
||||
test('head set + no WorktreeCreate hook in any layer → baseref-head, the #4588 trust stays (#4588)', () => {
|
||||
const result = run({
|
||||
[LOCAL]: JSON.stringify({ worktree: { baseRef: 'head' }, hooks: { PreToolUse: [{ hooks: [] }] } }),
|
||||
[USER]: JSON.stringify({ hooks: { WorktreeCreate: [] } }),
|
||||
});
|
||||
assert.strictEqual(result.shouldDegrade, false);
|
||||
assert.strictEqual(result.reason, 'baseref-head');
|
||||
});
|
||||
|
||||
test('head from the user layer + a project-local layer that does not parse → fails closed and degrades (#4588)', () => {
|
||||
// resolveEffectiveBaseRef skips the unparseable layer and still resolves "head" from the
|
||||
// user layer; the interlock treats that same layer as a possible hook, so the two stay
|
||||
// consistent in direction — neither ever trusts "head" on the strength of a file it could not read.
|
||||
const result = run({ [LOCAL]: '{ "worktree": ', [USER]: JSON.stringify({ worktree: { baseRef: 'head' } }) });
|
||||
assert.strictEqual(result.shouldDegrade, true);
|
||||
assert.strictEqual(result.reason, 'baseref-head-bypassed-by-hook');
|
||||
assert.ok(result.message.includes(`${LOCAL} could not be parsed`), result.message);
|
||||
});
|
||||
});
|
||||
|
||||
describe('quick: pre-dispatch worktree base re-check (#1941)', () => {
|
||||
test('workflow file exists', () => {
|
||||
assert.ok(fs.existsSync(QUICK_WORKFLOW_PATH), 'workflows/quick.md should exist');
|
||||
@@ -1407,6 +1938,31 @@ describe('execute-plan Pattern A: pre-dispatch worktree base-check (#2649)', ()
|
||||
});
|
||||
});
|
||||
|
||||
// The base-check prose moved out of execute-phase.md into its own step file
|
||||
// (#4683/#4828) while this fix was in review, and the move carried the retired
|
||||
// claim that harness-isolated runtimes ignore worktree.baseRef. Pin the step
|
||||
// file itself, so a later extraction cannot quietly bring the claim back.
|
||||
const WORKTREE_BASE_CHECK_STEP_PATH = path.join(
|
||||
__dirname, '..', 'gsd-core', 'workflows', 'execute-phase', 'steps', 'worktree-base-check.md',
|
||||
);
|
||||
|
||||
describe('execute-phase worktree_base_check step prose (#4588)', () => {
|
||||
test('cites #4588 and names both exceptions to the baseRef:"head" trust', () => {
|
||||
const content = fs.readFileSync(WORKTREE_BASE_CHECK_STEP_PATH, 'utf-8');
|
||||
assert.ok(content.includes('#4588'), 'the step must cite #4588');
|
||||
assert.ok(content.includes('WorktreeCreate'),
|
||||
'the step must name the WorktreeCreate-hook exception that withholds the "head" trust');
|
||||
assert.ok(content.includes('--observed-fork-base'),
|
||||
'the step must say a supplied observation is compared against HEAD, not trusted');
|
||||
});
|
||||
|
||||
test('does not claim harness-isolated runtimes ignore the setting', () => {
|
||||
const content = fs.readFileSync(WORKTREE_BASE_CHECK_STEP_PATH, 'utf-8');
|
||||
assert.ok(!/harness-isolated runtimes[^.]{0,200}do\s+not\s+read\s+the\s+setting/i.test(content.replace(/\s+/g, ' ')),
|
||||
'the step must not carry the retired #48 claim that harness-isolated runtimes do not read baseRef');
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1771,3 +2327,199 @@ describe('#4588 A2: a clean prior harness worktree at the orchestrator HEAD conf
|
||||
assert.strictEqual(result.reason, 'fork-from-head-observed');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── #4881: the baseRef:"head" trust and the #4868 observation compose ──────────
|
||||
|
||||
describe('#4881: the baseRef:"head" trust and the prior-worktree observation compose', () => {
|
||||
const HEAD_SHA = '1234123412341234123412341234123412341234';
|
||||
const ORIGIN_SHA = 'abcdabcdabcdabcdabcdabcdabcdabcdabcdabcd';
|
||||
const WT_PATH = '/repo/.claude/worktrees/agent-hook';
|
||||
const HOOK = { file: '/repo/.claude/settings.json', kind: 'hook' };
|
||||
const ok = (stdout) => ({ exitCode: 0, stdout, stderr: '', signal: null, error: null });
|
||||
|
||||
// A harness host: HEAD diverged from origin/HEAD, and either one clean
|
||||
// agent worktree at HEAD or none at all.
|
||||
function makeHostGit({ worktreeAtHead }) {
|
||||
const list = worktreeAtHead
|
||||
? `worktree /repo\nbranch refs/heads/feature\n\nworktree ${WT_PATH}\nbranch refs/heads/agent-hook\n`
|
||||
: 'worktree /repo\nbranch refs/heads/feature\n';
|
||||
return (args) => {
|
||||
const key = args.join(' ');
|
||||
if (key === 'rev-parse HEAD') return ok(`${HEAD_SHA}\n`);
|
||||
if (key === 'worktree list --porcelain') return ok(list);
|
||||
if (key === `-C ${WT_PATH} status --porcelain`) return ok('');
|
||||
if (key === `-C ${WT_PATH} rev-parse HEAD`) return ok(`${HEAD_SHA}\n`);
|
||||
if (key === 'rev-parse --verify --quiet origin/HEAD') return ok(`${ORIGIN_SHA}\n`);
|
||||
if (key === 'symbolic-ref --quiet refs/remotes/origin/HEAD') return { exitCode: 1, stdout: '', stderr: '', signal: null, error: null };
|
||||
throw new Error(`Unexpected execGit call: ${JSON.stringify(args)}`);
|
||||
};
|
||||
}
|
||||
|
||||
test('the start of every run: head + harness + no prior worktree + HEAD diverged → baseref-head, and git is never consulted (#4881)', () => {
|
||||
// The state an execute-phase run is in at its first base-check and after
|
||||
// every wave commit (#4881 repro state 1/4): no evidence can exist yet.
|
||||
// The setting is trusted, so no worktree probe and no origin/HEAD
|
||||
// comparison runs — the degrade the issue reports is unreachable.
|
||||
let calls = 0;
|
||||
const result = evaluateWorktreeBaseDegrade({
|
||||
execGit: () => { calls += 1; throw new Error('the trust must not consult git'); },
|
||||
effectiveBaseRef: 'head',
|
||||
cwd: '/repo',
|
||||
probeStateRead: () => { throw new Error('the trust must not read the probe cache'); },
|
||||
});
|
||||
assert.strictEqual(result.shouldDegrade, false);
|
||||
assert.strictEqual(result.reason, 'baseref-head');
|
||||
assert.strictEqual(calls, 0, 'branch a returns before HEAD is resolved');
|
||||
});
|
||||
|
||||
test('a wave commit cannot erase the verdict on the common path: the same call at a new HEAD returns baseref-head again (#4881)', () => {
|
||||
// There is no per-HEAD verdict to lose — the trust is a property of the
|
||||
// configuration, not of the commit — so advancing HEAD between waves
|
||||
// (#4881 repro state 4) changes nothing.
|
||||
for (const _head of ['1111111111111111111111111111111111111111', '2222222222222222222222222222222222222222']) {
|
||||
const result = evaluateWorktreeBaseDegrade({
|
||||
execGit: () => { throw new Error('the trust must not consult git'); },
|
||||
effectiveBaseRef: 'head',
|
||||
cwd: '/repo',
|
||||
});
|
||||
assert.strictEqual(result.reason, 'baseref-head');
|
||||
}
|
||||
});
|
||||
|
||||
test('head + harness + WorktreeCreate hook + a clean prior harness worktree at HEAD → baseref-head-bypassed-by-hook: the observation is withheld, not consulted (#4921)', () => {
|
||||
// The stale-evidence case. A clean agent worktree sits at HEAD, so the
|
||||
// #4868 probe WOULD confirm — but nothing on disk records which creator
|
||||
// left it there, and a worktree the plain harness created before this hook
|
||||
// was configured is indistinguishable from one the hook created. So the
|
||||
// probe is not consulted at all: neither leg runs, which is why keying the
|
||||
// cache by hook configuration would not have closed this (a cache miss
|
||||
// falls through to the live probe and re-finds the same worktree).
|
||||
// The stubs RECORD rather than throw: observeHarnessForkFromHead treats a throwing
|
||||
// execGit or stateRead as an inconclusive observation and swallows it, so a throwing
|
||||
// stub would fall through to the same verdict with or without the interlock and pin
|
||||
// nothing. Recording keeps both assertions live under reversion.
|
||||
const host = makeHostGit({ worktreeAtHead: true });
|
||||
let consulted = false;
|
||||
const result = evaluateWorktreeBaseDegrade({
|
||||
execGit: (args) => {
|
||||
if (args.join(' ') === 'worktree list --porcelain') consulted = true;
|
||||
return host(args);
|
||||
},
|
||||
effectiveBaseRef: 'head',
|
||||
cwd: '/repo',
|
||||
worktreeCreateHook: HOOK,
|
||||
probeStateRead: () => { consulted = true; return null; },
|
||||
probeStateWrite: () => { consulted = true; },
|
||||
});
|
||||
assert.strictEqual(consulted, false, 'the observation must not be consulted once a hook withheld the trust');
|
||||
assert.strictEqual(result.shouldDegrade, true, `reason=${result.reason}`);
|
||||
assert.strictEqual(result.reason, 'baseref-head-bypassed-by-hook');
|
||||
assert.strictEqual(result.headSha, HEAD_SHA);
|
||||
assert.strictEqual(result.forkSha, ORIGIN_SHA, 'the inferred comparison still governs');
|
||||
});
|
||||
|
||||
test('head + harness + WorktreeCreate hook + no prior harness worktree → baseref-head-bypassed-by-hook: with no observation the inferred comparison governs (#4881)', () => {
|
||||
const result = evaluateWorktreeBaseDegrade({
|
||||
execGit: makeHostGit({ worktreeAtHead: false }),
|
||||
effectiveBaseRef: 'head',
|
||||
cwd: '/repo',
|
||||
worktreeCreateHook: HOOK,
|
||||
probeStateRead: () => null,
|
||||
probeStateWrite: () => {},
|
||||
});
|
||||
assert.strictEqual(result.shouldDegrade, true);
|
||||
assert.strictEqual(result.reason, 'baseref-head-bypassed-by-hook');
|
||||
assert.strictEqual(result.forkSha, ORIGIN_SHA, 'the inferred comparison still governs');
|
||||
});
|
||||
|
||||
test('an unparseable settings layer withholds the observation the same way — a hook in it cannot be ruled out (#4921)', () => {
|
||||
const host = makeHostGit({ worktreeAtHead: true });
|
||||
let consulted = false;
|
||||
const result = evaluateWorktreeBaseDegrade({
|
||||
execGit: (args) => {
|
||||
if (args.join(' ') === 'worktree list --porcelain') consulted = true;
|
||||
return host(args);
|
||||
},
|
||||
effectiveBaseRef: 'head',
|
||||
cwd: '/repo',
|
||||
worktreeCreateHook: { file: '/repo/.claude/settings.local.json', kind: 'unparseable' },
|
||||
probeStateRead: () => { consulted = true; return null; },
|
||||
probeStateWrite: () => { consulted = true; },
|
||||
});
|
||||
assert.strictEqual(consulted, false, 'the observation must not be consulted once an unparseable layer withheld the trust');
|
||||
assert.strictEqual(result.shouldDegrade, true, `reason=${result.reason}`);
|
||||
assert.strictEqual(result.reason, 'baseref-head-bypassed-by-hook');
|
||||
});
|
||||
|
||||
test('a cached fork-from-head verdict at this HEAD does NOT survive the hook interlock (#4921)', () => {
|
||||
// The cache leg stated explicitly, because the review's proposed remedy
|
||||
// aimed at it: a cache entry keyed to this exact HEAD and carrying the
|
||||
// confirmed verdict is still never read once a hook withheld the trust.
|
||||
const host = makeHostGit({ worktreeAtHead: false });
|
||||
const cached = JSON.stringify({
|
||||
headSha: HEAD_SHA,
|
||||
worktreePath: WT_PATH,
|
||||
worktreeHead: HEAD_SHA,
|
||||
verdict: 'fork-from-head-confirmed',
|
||||
probedAt: '2026-09-01T00:00:00.000Z',
|
||||
});
|
||||
const result = evaluateWorktreeBaseDegrade({
|
||||
execGit: host,
|
||||
effectiveBaseRef: 'head',
|
||||
cwd: '/repo',
|
||||
worktreeCreateHook: HOOK,
|
||||
probeStateRead: () => cached,
|
||||
probeStateWrite: () => { assert.fail('the probe cache must not be written once a hook withheld the trust'); },
|
||||
});
|
||||
assert.strictEqual(result.shouldDegrade, true, `reason=${result.reason}`);
|
||||
assert.strictEqual(result.reason, 'baseref-head-bypassed-by-hook');
|
||||
});
|
||||
|
||||
test('the withholding is scoped to head + hook: with no setting, a hook does NOT withhold #4868 arm (#4921)', () => {
|
||||
// The boundary this PR deliberately did not cross. b2 is #4868's own arm
|
||||
// when `"head"` is absent, hook or not; re-scoping that trust is a separate
|
||||
// question from the one #4881 opened, and is not taken here.
|
||||
const result = evaluateWorktreeBaseDegrade({
|
||||
execGit: makeHostGit({ worktreeAtHead: true }),
|
||||
effectiveBaseRef: null,
|
||||
cwd: '/repo',
|
||||
worktreeCreateHook: HOOK,
|
||||
probeStateRead: () => null,
|
||||
probeStateWrite: () => {},
|
||||
});
|
||||
assert.strictEqual(result.shouldDegrade, false, `reason=${result.reason}`);
|
||||
assert.strictEqual(result.reason, 'fork-from-head-observed');
|
||||
});
|
||||
|
||||
test('an explicit observedForkBase outranks the prior-worktree probe: the probe never runs (#4881)', () => {
|
||||
// A clean worktree at HEAD is present AND the caller measured a different
|
||||
// fork base for this dispatch: the measurement wins and degrades. Were the
|
||||
// probe consulted first, the stale worktree would suppress it.
|
||||
const host = makeHostGit({ worktreeAtHead: true });
|
||||
const result = evaluateWorktreeBaseDegrade({
|
||||
execGit: (args) => {
|
||||
if (args.join(' ') === 'worktree list --porcelain') throw new Error('the probe must not run under an explicit observation');
|
||||
return host(args);
|
||||
},
|
||||
effectiveBaseRef: 'head',
|
||||
cwd: '/repo',
|
||||
observedForkBase: ORIGIN_SHA,
|
||||
probeStateRead: () => { throw new Error('the probe cache must not be read under an explicit observation'); },
|
||||
});
|
||||
assert.strictEqual(result.shouldDegrade, true);
|
||||
assert.strictEqual(result.reason, 'baseref-head-ignored-by-harness');
|
||||
assert.strictEqual(result.forkRef, 'observed');
|
||||
});
|
||||
|
||||
test('no setting + a clean prior harness worktree at HEAD → fork-from-head-observed, exactly as #4868 shipped it (#4881)', () => {
|
||||
// The #4868 rows above run with the setting unset; this pins that #4881
|
||||
// left that arm reachable and unchanged.
|
||||
const result = evaluateWorktreeBaseDegrade({
|
||||
execGit: makeHostGit({ worktreeAtHead: true }),
|
||||
cwd: '/repo',
|
||||
probeStateRead: () => null,
|
||||
probeStateWrite: () => {},
|
||||
});
|
||||
assert.strictEqual(result.reason, 'fork-from-head-observed');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user