fix(#3963): inherit the root worktree opt-out under GSD_WORKSTREAM (#3967)

* test(#3963): root use_worktrees opt-out must be inherited under a workstream

* fix(#3963): inherit the root worktree opt-out under GSD_WORKSTREAM

* chore(#3963): changeset fragment (pr number backfilled after PR creation)

* chore(#3963): backfill changeset PR number (3967)

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-27 23:38:19 -04:00
committed by GitHub
parent e9868a92ba
commit 45f18b444d
3 changed files with 157 additions and 5 deletions

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 3967
---
**`workflow.use_worktrees=false` at the root now applies inside workstreams too** — the dispatch-isolation resolver inherits the root opt-out under `GSD_WORKSTREAM` exactly as `config-get` does, so a root-level opt-out no longer leaves workstream runs recording `harness-worktree` over the mandated `none`. (#3963)

View File

@@ -1751,13 +1751,40 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
* comment above). Never throws.
*/
function projectWorktreesOptedOut(cwd) {
// #3963: the read must see the value `config-get` sees. Under
// GSD_WORKSTREAM, config-get inherits the ROOT config for keys the
// workstream config omits (config.cts resolveFromRootConfig, the #2714
// inheritance; loadConfig's deep-merge implements the same ladder for
// its consumers), so a root-level `use_worktrees: false` is part of the
// effective config even when the workstream file omits the key. Mirror
// that ladder for this one key with direct reads — never loadConfig,
// which normalizes and rewrites config on a path that backs sentinel
// writes (same discipline as the resolveRuntime comment in
// resolveDispatchIsolationDecision).
// Semantics: the workstream's OWN key wins; otherwise the root's (only
// under the ws-env gate — loadConfigResolved does NOT inherit root under
// GSD_PROJECT alone, and this read must not diverge from that); strict
// `=== false`; any read failure degrades to "not opted out".
try {
const { planningDir } = require('./lib/planning-workspace.cjs');
const cfgPath = require('path').join(planningDir(cwd), 'config.json');
const cfg = JSON.parse(require('fs').readFileSync(cfgPath, 'utf8'));
return cfg != null && typeof cfg === 'object'
const { planningDir, planningRoot } = require('./lib/planning-workspace.cjs');
const readCfg = (p) => {
try {
return JSON.parse(require('fs').readFileSync(p, 'utf8'));
} catch {
return null;
}
};
const ownKey = (cfg) =>
cfg != null && typeof cfg === 'object'
&& cfg.workflow != null && typeof cfg.workflow === 'object'
&& cfg.workflow.use_worktrees === false;
&& Object.prototype.hasOwnProperty.call(cfg.workflow, 'use_worktrees');
const wsCfg = readCfg(require('path').join(planningDir(cwd), 'config.json'));
if (ownKey(wsCfg)) return wsCfg.workflow.use_worktrees === false;
if (process.env['GSD_WORKSTREAM']) {
const rootCfg = readCfg(require('path').join(planningRoot(cwd), 'config.json'));
if (ownKey(rootCfg)) return rootCfg.workflow.use_worktrees === false;
}
return false;
} catch {
return false;
}

View File

@@ -1027,6 +1027,126 @@ describe('#3045 CORE REDESIGN — dispatch-isolation records as an unconditional
assert.equal(readSentinelRaw(dir).isolation, 'harness-worktree');
});
// #3963 — the opt-out read must see the value config-get sees. Under
// GSD_WORKSTREAM, config-get merges the ROOT config into the workstream
// config (#2714 inheritance); the resolver's raw single-file read saw only
// the workstream file, so a root-level use_worktrees=false was invisible
// and the #3737 clobber resurfaced on every workstream-scoped run.
test('#3963: root use_worktrees=false is inherited under GSD_WORKSTREAM', (t) => {
const dir = createTempProject('gsd-3963-ws-');
t.after(() => cleanup(dir));
fs.writeFileSync(
path.join(dir, '.planning', 'config.json'),
JSON.stringify({ runtime: 'claude', workflow: { use_worktrees: false } }),
);
fs.mkdirSync(path.join(dir, '.planning', 'workstreams', 'alpha'), { recursive: true });
fs.writeFileSync(
path.join(dir, '.planning', 'workstreams', 'alpha', 'config.json'),
JSON.stringify({ model_profile: 'balanced' }),
);
const result = runGsdTools(
['query', 'dispatch-isolation', '--raw'],
dir,
{ GSD_RUNTIME: 'claude', HOME: dir, GSD_WORKSTREAM: 'alpha' },
);
assert.equal(result.success, true, result.error);
assert.equal(result.output.trim(), 'none',
'#3963: the root opt-out must be inherited under a workstream, matching config-get');
const sentinel = readSentinelRaw(dir);
assert.equal(sentinel.isolation, 'none');
assert.equal(sentinel.harness_flag, null);
});
test('#3963: the workstream\'s own key wins over the root\'s', (t) => {
const dir = createTempProject('gsd-3963-ws2-');
t.after(() => cleanup(dir));
fs.writeFileSync(
path.join(dir, '.planning', 'config.json'),
JSON.stringify({ runtime: 'claude', workflow: { use_worktrees: false } }),
);
fs.mkdirSync(path.join(dir, '.planning', 'workstreams', 'alpha'), { recursive: true });
fs.writeFileSync(
path.join(dir, '.planning', 'workstreams', 'alpha', 'config.json'),
JSON.stringify({ workflow: { use_worktrees: true } }),
);
const result = runGsdTools(
['query', 'dispatch-isolation', '--raw'],
dir,
{ GSD_RUNTIME: 'claude', HOME: dir, GSD_WORKSTREAM: 'alpha' },
);
assert.equal(result.success, true, result.error);
assert.equal(result.output.trim(), 'harness-worktree',
'#3963: inheritance, not root-override — the workstream\'s own true must win');
});
test('#3963 parity: dispatch-isolation agrees with config-get on the same fixtures', () => {
// Generative-fix-divergence guard (#3963 review): the resolver's raw read
// and config-get's merged read must answer identically on shared shapes.
const cases = [
{ root: { workflow: { use_worktrees: false } }, ws: { model_profile: 'balanced' } },
{ root: { workflow: { use_worktrees: false } }, ws: { workflow: { use_worktrees: true } } },
{ root: { runtime: 'claude' }, ws: { workflow: { use_worktrees: false } } },
{ root: { runtime: 'claude' }, ws: { runtime: 'claude' } },
];
for (const { root, ws } of cases) {
const dir = createTempProject('gsd-3963-parity-');
try {
fs.writeFileSync(path.join(dir, '.planning', 'config.json'), JSON.stringify(root));
fs.mkdirSync(path.join(dir, '.planning', 'workstreams', 'alpha'), { recursive: true });
fs.writeFileSync(path.join(dir, '.planning', 'workstreams', 'alpha', 'config.json'), JSON.stringify(ws));
const env = { GSD_RUNTIME: 'claude', HOME: dir, GSD_WORKSTREAM: 'alpha' };
// --default true mirrors the schema default so the key-absent shape
// answers "true" (not opted out) instead of erroring — the same
// effective value the resolver's degrade-to-false produces.
const cfg = runGsdTools(['query', 'config-get', 'workflow.use_worktrees', '--raw', '--default', 'true'], dir, env);
const iso = runGsdTools(['query', 'dispatch-isolation', '--raw'], dir, env);
assert.equal(cfg.success, true, cfg.error);
assert.equal(iso.success, true, iso.error);
const optedOut = cfg.output.trim() === 'false';
assert.equal(iso.output.trim(), optedOut ? 'none' : 'harness-worktree',
`#3963 parity: config-get says use_worktrees=${cfg.output.trim()} but dispatch-isolation says ${iso.output.trim()}`);
} finally {
cleanup(dir);
}
}
});
test('#3963: no root inheritance under GSD_PROJECT alone (documented contract)', (t) => {
const dir = createTempProject('gsd-3963-proj-');
t.after(() => cleanup(dir));
fs.mkdirSync(path.join(dir, '.planning', 'second-product'), { recursive: true });
// Root opted out; the scoped project config omits the key. Under
// GSD_PROJECT alone, config-get does NOT inherit root — and neither may
// this resolver (the ws-env gate is the boundary).
fs.writeFileSync(path.join(dir, '.planning', 'config.json'), JSON.stringify({ workflow: { use_worktrees: false } }));
fs.writeFileSync(path.join(dir, '.planning', 'second-product', 'config.json'), JSON.stringify({ runtime: 'claude' }));
const cfg = runGsdTools(['query', 'config-get', 'workflow.use_worktrees', '--raw', '--default', 'true'], dir,
{ GSD_RUNTIME: 'claude', HOME: dir, GSD_PROJECT: 'second-product' });
const iso = runGsdTools(['query', 'dispatch-isolation', '--raw'], dir,
{ GSD_RUNTIME: 'claude', HOME: dir, GSD_PROJECT: 'second-product' });
assert.equal(cfg.output.trim() === 'false', false,
'fixture self-check: config-get must NOT see the root opt-out under GSD_PROJECT alone');
assert.equal(iso.output.trim(), 'harness-worktree',
'#3963: no root inheritance without the workstream env — parity with config-get');
});
test('#3963: malformed workstream config falls back to the root under the gate', (t) => {
const dir = createTempProject('gsd-3963-malformed-');
t.after(() => cleanup(dir));
fs.writeFileSync(path.join(dir, '.planning', 'config.json'), JSON.stringify({ workflow: { use_worktrees: false } }));
fs.mkdirSync(path.join(dir, '.planning', 'workstreams', 'alpha'), { recursive: true });
fs.writeFileSync(path.join(dir, '.planning', 'workstreams', 'alpha', 'config.json'), '{ not valid json');
const iso = runGsdTools(['query', 'dispatch-isolation', '--raw'], dir,
{ GSD_RUNTIME: 'claude', HOME: dir, GSD_WORKSTREAM: 'alpha' });
assert.equal(iso.success, true, iso.error);
assert.equal(iso.output.trim(), 'none',
'#3963: an unreadable workstream config must degrade to the root view, matching loadConfigResolved branch B');
});
test('#3737: end-to-end — an opted-out project records none and the guard ALLOWS the sequential dispatch', (t) => {
const dir = createTempProject('gsd-3737-optout-');
t.after(() => cleanup(dir));