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 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-04-26 13:33:26 -04:00
committed by GitHub
parent 2b95ccbddd
commit b8bbc74192
2 changed files with 91 additions and 0 deletions

View File

@@ -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', () => {

View File

@@ -406,22 +406,27 @@ export const configNewProject: QueryHandler = async (args, projectDir, workstrea
...userChoices,
git: {
...(defaults.git as Record<string, unknown>),
...((globalDefaults.git as Record<string, unknown>) || {}),
...((userChoices.git as Record<string, unknown>) || {}),
},
workflow: {
...(defaults.workflow as Record<string, unknown>),
...((globalDefaults.workflow as Record<string, unknown>) || {}),
...((userChoices.workflow as Record<string, unknown>) || {}),
},
hooks: {
...(defaults.hooks as Record<string, unknown>),
...((globalDefaults.hooks as Record<string, unknown>) || {}),
...((userChoices.hooks as Record<string, unknown>) || {}),
},
agent_skills: {
...((defaults.agent_skills as Record<string, unknown>) || {}),
...((globalDefaults.agent_skills as Record<string, unknown>) || {}),
...((userChoices.agent_skills as Record<string, unknown>) || {}),
},
features: {
...((defaults.features as Record<string, unknown>) || {}),
...((globalDefaults.features as Record<string, unknown>) || {}),
...((userChoices.features as Record<string, unknown>) || {}),
},
};