feat(config): workstream config.json inherits from root .planning/config.json

- Add _deepMergeConfig() with correct null-override semantics
- loadConfig() reads root config.json when GSD_WORKSTREAM is set, then
  deep-merges with workstream config (workstream wins on conflict)
- Workstream without config.json falls back to root config entirely
- Migrations and disk writes operate on fileData (on-disk content) only,
  never on the merged result, to prevent workstream pollution
- Fixes null-override bug from PR #2717: explicit null in workstream now
  correctly overrides root value instead of falling back to root
- Tests: inherit root model_overrides, workstream override, nested
  workflow.* deep merge, explicit null override, missing workstream config

Closes #2714
This commit is contained in:
Tom Boucher
2026-04-26 11:15:16 -04:00
parent 0f17cfc71d
commit 1cb4bebcf5
2 changed files with 165 additions and 21 deletions

View File

@@ -200,6 +200,92 @@ describe('loadConfig', () => {
});
});
// ─── loadConfig workstream config inheritance (#2714) ────────────────────────
describe('loadConfig workstream config inheritance (#2714)', () => {
let tmpDir;
let originalEnv;
beforeEach(() => {
tmpDir = createTempProject();
originalEnv = process.env.GSD_WORKSTREAM;
delete process.env.GSD_WORKSTREAM;
});
afterEach(() => {
if (originalEnv !== undefined) {
process.env.GSD_WORKSTREAM = originalEnv;
} else {
delete process.env.GSD_WORKSTREAM;
}
cleanup(tmpDir);
});
function writeRootConfig(obj) {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify(obj, null, 2)
);
}
function writeWorkstreamConfig(wsName, obj) {
const wsDir = path.join(tmpDir, '.planning', 'workstreams', wsName);
fs.mkdirSync(wsDir, { recursive: true });
fs.writeFileSync(
path.join(wsDir, 'config.json'),
JSON.stringify(obj, null, 2)
);
}
test('workstream config inherits model_overrides from root when not defined in workstream', () => {
writeRootConfig({ model_overrides: { 'gsd-executor': 'opus' } });
writeWorkstreamConfig('feature-a', { model_profile: 'quality' });
process.env.GSD_WORKSTREAM = 'feature-a';
const config = loadConfig(tmpDir);
assert.deepStrictEqual(config.model_overrides, { 'gsd-executor': 'opus' });
assert.strictEqual(config.model_profile, 'quality');
});
test('workstream-specific keys override root config values', () => {
writeRootConfig({ model_profile: 'balanced', model_overrides: { 'gsd-executor': 'opus' } });
writeWorkstreamConfig('feature-b', { model_profile: 'speed', model_overrides: { 'gsd-executor': 'haiku' } });
process.env.GSD_WORKSTREAM = 'feature-b';
const config = loadConfig(tmpDir);
assert.strictEqual(config.model_profile, 'speed');
assert.deepStrictEqual(config.model_overrides, { 'gsd-executor': 'haiku' });
});
test('deep merge works for nested workflow.* keys', () => {
writeRootConfig({ workflow: { research: false, auto_advance: true } });
writeWorkstreamConfig('feature-c', { workflow: { auto_advance: false } });
process.env.GSD_WORKSTREAM = 'feature-c';
const config = loadConfig(tmpDir);
// research inherited from root
assert.strictEqual(config.research, false);
// auto_advance overridden by workstream
assert.strictEqual(config.auto_advance, false);
});
test('explicit null in workstream config overrides root value (PR #2717 null-override bug)', () => {
writeRootConfig({ model_overrides: { 'gsd-executor': 'opus', 'gsd-planner': 'sonnet' } });
writeWorkstreamConfig('feature-d', { model_overrides: null });
process.env.GSD_WORKSTREAM = 'feature-d';
const config = loadConfig(tmpDir);
// null in workstream should override root, not fall back to root value
assert.strictEqual(config.model_overrides, null);
});
test('workstream without config.json inherits root config', () => {
writeRootConfig({ model_profile: 'quality', model_overrides: { 'gsd-executor': 'opus' } });
// Create workstream dir without config.json
fs.mkdirSync(path.join(tmpDir, '.planning', 'workstreams', 'feature-e'), { recursive: true });
process.env.GSD_WORKSTREAM = 'feature-e';
const config = loadConfig(tmpDir);
assert.strictEqual(config.model_profile, 'quality');
assert.deepStrictEqual(config.model_overrides, { 'gsd-executor': 'opus' });
});
});
// ─── loadConfig commit_docs gitignore auto-detection (#1250) ──────────────────
describe('loadConfig commit_docs gitignore auto-detection (#1250)', () => {