* 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:
5
.changeset/curious-seals-wander.md
Normal file
5
.changeset/curious-seals-wander.md
Normal 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)
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user