fix(#3972): one worktreesOptedOut ladder in planning-workspace, shared by resolver and guard (#3979)

* test(#3972): the guard fallback must share the opt-out ladder

* fix(#3972): one worktreesOptedOut ladder in planning-workspace, shared by resolver and guard

* test(#3972): without a workstream the root config is the effective config

* fix(#3972): guard the whole ladder body — malformed env values degrade, never throw

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

* chore(#3972): backfill changeset PR number (3979)

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-28 01:53:23 -04:00
committed by GitHub
parent 45f18b444d
commit 5ad708aa4e
5 changed files with 175 additions and 43 deletions

View File

@@ -1751,40 +1751,14 @@ 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".
// #3972: single owner — planning-workspace's worktreesOptedOut ladder
// (scoped own-key, root inheritance under the ws gate, strict === false).
// Kept as a local name so routeDispatchIsolation's call sites read the
// same as they did in #3938/#3963; the logic itself now lives beside
// planningDir/planningRoot where every isolation surface can share it.
try {
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'
&& 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;
const { worktreesOptedOut } = require('./lib/planning-workspace.cjs');
return worktreesOptedOut(cwd);
} catch {
return false;
}