From b8bbc74192253041b72bcad29aef4e5c03a59b73 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 26 Apr 2026 13:33:26 -0400 Subject: [PATCH] fix(sdk): preserve nested keys from globalDefaults in configNewProject (#2745) When building nested config sections (workflow, git, hooks, agent_skills, features), the deep merge was missing globalDefaults for those sections, causing user values from ~/.gsd/defaults.json to be silently dropped. Added globalDefaults spread at the correct precedence level (hardcoded < globalDefaults < userChoices) for all five nested keys, and added three test cases verifying the merge works end-to-end via HOME env var override. Closes #2673 Co-authored-by: Claude Sonnet 4.6 --- sdk/src/query/config-mutation.test.ts | 86 +++++++++++++++++++++++++++ sdk/src/query/config-mutation.ts | 5 ++ 2 files changed, 91 insertions(+) diff --git a/sdk/src/query/config-mutation.test.ts b/sdk/src/query/config-mutation.test.ts index 5f25542e3..44f544b72 100644 --- a/sdk/src/query/config-mutation.test.ts +++ b/sdk/src/query/config-mutation.test.ts @@ -245,6 +245,92 @@ describe('configNewProject global defaults (D11)', () => { }); }); +// ─── configNewProject nested globalDefaults merging ─────────────────────── + +describe('configNewProject nested globalDefaults merging (fix #2673)', () => { + let fakeHome: string; + let originalHome: string | undefined; + + beforeEach(async () => { + fakeHome = await mkdtemp(join(tmpdir(), 'gsd-fakehome-')); + await mkdir(join(fakeHome, '.gsd'), { recursive: true }); + originalHome = process.env.HOME; + process.env.HOME = fakeHome; + }); + + afterEach(async () => { + if (originalHome !== undefined) { + process.env.HOME = originalHome; + } else { + delete process.env.HOME; + } + await rm(fakeHome, { recursive: true, force: true }); + }); + + it('preserves nested workflow keys from globalDefaults', async () => { + await writeFile( + join(fakeHome, '.gsd', 'defaults.json'), + JSON.stringify({ + workflow: { auto_advance: true, discuss_mode: 'skip' }, + git: { branching_strategy: 'milestone' }, + }), + ); + + const { configNewProject } = await import('./config-mutation.js'); + const result = await configNewProject([], tmpDir); + expect((result.data as { created: boolean }).created).toBe(true); + + const raw = JSON.parse(await readFile(join(tmpDir, '.planning', 'config.json'), 'utf-8')); + // Nested workflow keys from globalDefaults must survive + expect(raw.workflow.auto_advance).toBe(true); + expect(raw.workflow.discuss_mode).toBe('skip'); + // Hardcoded defaults not overridden by globalDefaults must still be present + expect(raw.workflow.research).toBe(true); + // Nested git key from globalDefaults must survive + expect(raw.git.branching_strategy).toBe('milestone'); + // Hardcoded git defaults not overridden must still be present + expect(raw.git.phase_branch_template).toBe('gsd/phase-{phase}-{slug}'); + }); + + it('lets userChoices override globalDefaults nested keys', async () => { + await writeFile( + join(fakeHome, '.gsd', 'defaults.json'), + JSON.stringify({ + workflow: { auto_advance: true }, + }), + ); + + const { configNewProject } = await import('./config-mutation.js'); + const choices = JSON.stringify({ workflow: { auto_advance: false } }); + const result = await configNewProject([choices], tmpDir); + expect((result.data as { created: boolean }).created).toBe(true); + + const raw = JSON.parse(await readFile(join(tmpDir, '.planning', 'config.json'), 'utf-8')); + // userChoices must win over globalDefaults + expect(raw.workflow.auto_advance).toBe(false); + }); + + it('preserves nested hooks, agent_skills, and features keys from globalDefaults', async () => { + await writeFile( + join(fakeHome, '.gsd', 'defaults.json'), + JSON.stringify({ + hooks: { context_warnings: false }, + agent_skills: { my_skill: true }, + features: { beta_feature: true }, + }), + ); + + const { configNewProject } = await import('./config-mutation.js'); + const result = await configNewProject([], tmpDir); + expect((result.data as { created: boolean }).created).toBe(true); + + const raw = JSON.parse(await readFile(join(tmpDir, '.planning', 'config.json'), 'utf-8')); + expect(raw.hooks.context_warnings).toBe(false); + expect(raw.agent_skills.my_skill).toBe(true); + expect(raw.features.beta_feature).toBe(true); + }); +}); + // ─── configSet ───────────────────────────────────────────────────────────── describe('configSet', () => { diff --git a/sdk/src/query/config-mutation.ts b/sdk/src/query/config-mutation.ts index 5dcbb3ab6..0c3aea73d 100644 --- a/sdk/src/query/config-mutation.ts +++ b/sdk/src/query/config-mutation.ts @@ -406,22 +406,27 @@ export const configNewProject: QueryHandler = async (args, projectDir, workstrea ...userChoices, git: { ...(defaults.git as Record), + ...((globalDefaults.git as Record) || {}), ...((userChoices.git as Record) || {}), }, workflow: { ...(defaults.workflow as Record), + ...((globalDefaults.workflow as Record) || {}), ...((userChoices.workflow as Record) || {}), }, hooks: { ...(defaults.hooks as Record), + ...((globalDefaults.hooks as Record) || {}), ...((userChoices.hooks as Record) || {}), }, agent_skills: { ...((defaults.agent_skills as Record) || {}), + ...((globalDefaults.agent_skills as Record) || {}), ...((userChoices.agent_skills as Record) || {}), }, features: { ...((defaults.features as Record) || {}), + ...((globalDefaults.features as Record) || {}), ...((userChoices.features as Record) || {}), }, };