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

@@ -144,6 +144,58 @@ function planningRoot(cwd: string): string {
return path.join(cwd, '.planning');
}
/**
* #3972: the ONE owner of "is this planning scope opted out of worktrees?" —
* the effective `workflow.use_worktrees === false` read every
* isolation-deciding surface must share (config-get's merged view is the
* contract). Ladder: the scoped config's OWN key wins (planningDir is
* project- and workstream-aware); otherwise the flat root's key, but only
* under the GSD_WORKSTREAM env gate — config-get deliberately does NOT
* inherit root under GSD_PROJECT alone, and this read must not diverge
* (#3963). Strict `=== false` (never coerced); any read failure degrades to
* "not opted out" (worktrees on — the fail-safe direction: the guard keeps
* enforcing). Direct file reads only — never loadConfig, which normalizes
* and rewrites config on paths that back sentinel writes.
*/
function worktreesOptedOut(cwd: string): boolean {
// #3972 review: the WHOLE body is guarded — planningDir/planningRoot
// themselves throw on a GSD_PROJECT/GSD_WORKSTREAM value containing path
// separators or `..`, and this contract ("any failure degrades to not
// opted out — worktrees on, keep enforcing") must hold for that shape too.
try {
return worktreesOptedOutUnguarded(cwd);
} catch {
return false;
}
}
function worktreesOptedOutUnguarded(cwd: string): boolean {
type MaybeConfig = { workflow?: unknown } | null;
const readCfg = (p: string): MaybeConfig => {
try {
return JSON.parse(String(fs.readFileSync(p, 'utf8'))) as MaybeConfig;
} catch {
return null;
}
};
const ownKey = (cfg: MaybeConfig): { present: boolean; value: unknown } => {
if (cfg === null || typeof cfg !== 'object') return { present: false, value: undefined };
const wf = cfg.workflow;
if (wf === null || typeof wf !== 'object' || Array.isArray(wf)) return { present: false, value: undefined };
const wfRec = wf as Record<string, unknown>;
return Object.prototype.hasOwnProperty.call(wfRec, 'use_worktrees')
? { present: true, value: wfRec['use_worktrees'] }
: { present: false, value: undefined };
};
const scoped = ownKey(readCfg(path.join(planningDir(cwd), 'config.json')));
if (scoped.present) return scoped.value === false;
if (process.env['GSD_WORKSTREAM']) {
const root = ownKey(readCfg(path.join(planningRoot(cwd), 'config.json')));
if (root.present) return root.value === false;
}
return false;
}
// Sorted list of workstream directory names under `<root>/.planning/workstreams`,
// or `[]` when the project is flat (no workstreams dir). Single source of truth
// for the "workstream mode" detection shared by the #1912/#2028 fail-safe guards
@@ -469,6 +521,7 @@ function findContextMdIn(absDirOrFiles: string | string[]): string | null {
}
export = {
worktreesOptedOut,
createPlanningWorkspace,
createSharedPointerAdapter,
createSessionScopedPointerAdapter,