loadConfig's multiRepo migration and filesystem-sync writers targeted the top-level parsed.sub_repos, but KNOWN_TOP_LEVEL (the unknown-key validator's allowlist) only recognizes planning.sub_repos (canonical per #2561). Each migration/sync therefore persisted a key the next loadConfig call warned was unknown. Redirect both writers to parsed.planning.sub_repos, ensuring parsed.planning is initialized first. Also self-heal legacy/buggy installs by stripping any stale top-level sub_repos on load, preserving its value as the planning.sub_repos seed if that slot is empty. Tests cover: (a) canonical planning.sub_repos emits no warning, (b) multiRepo migration writes to planning.sub_repos with no top-level residue, (c) filesystem sync relocates to planning.sub_repos, (d) stale top-level sub_repos from older buggy installs is stripped on load. Closes #2638
144 lines
4.3 KiB
JavaScript
144 lines
4.3 KiB
JavaScript
/**
|
|
* Regression test for bug #2638.
|
|
*
|
|
* loadConfig previously migrated/synced sub_repos to the TOP-LEVEL
|
|
* `parsed.sub_repos`, but the KNOWN_TOP_LEVEL allowlist only recognizes
|
|
* `planning.sub_repos` (per #2561 — canonical location). That asymmetry
|
|
* made loadConfig write a key it then warns is unknown on the next read.
|
|
*
|
|
* Fix: writers target `parsed.planning.sub_repos` and strip any stale
|
|
* top-level copy during the same migration pass.
|
|
*/
|
|
|
|
const { test, describe, beforeEach, afterEach } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const { execFileSync } = require('child_process');
|
|
const { createTempProject, cleanup } = require('./helpers.cjs');
|
|
|
|
const { loadConfig } = require('../get-shit-done/bin/lib/core.cjs');
|
|
|
|
function makeSubRepo(parent, name) {
|
|
const dir = path.join(parent, name);
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
execFileSync('git', ['init'], { cwd: dir, stdio: 'pipe' });
|
|
}
|
|
|
|
function readConfig(tmpDir) {
|
|
return JSON.parse(
|
|
fs.readFileSync(path.join(tmpDir, '.planning', 'config.json'), 'utf-8')
|
|
);
|
|
}
|
|
|
|
function writeConfig(tmpDir, obj) {
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'config.json'),
|
|
JSON.stringify(obj, null, 2)
|
|
);
|
|
}
|
|
|
|
describe('bug #2638 — sub_repos canonical location', () => {
|
|
let tmpDir;
|
|
let originalCwd;
|
|
let stderrCapture;
|
|
let origStderrWrite;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempProject();
|
|
originalCwd = process.cwd();
|
|
stderrCapture = '';
|
|
origStderrWrite = process.stderr.write;
|
|
process.stderr.write = (chunk) => { stderrCapture += chunk; return true; };
|
|
});
|
|
|
|
afterEach(() => {
|
|
process.stderr.write = origStderrWrite;
|
|
process.chdir(originalCwd);
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('does not warn when planning.sub_repos is set (no top-level sub_repos)', () => {
|
|
makeSubRepo(tmpDir, 'backend');
|
|
makeSubRepo(tmpDir, 'frontend');
|
|
writeConfig(tmpDir, {
|
|
planning: { sub_repos: ['backend', 'frontend'] },
|
|
});
|
|
|
|
loadConfig(tmpDir);
|
|
|
|
assert.ok(
|
|
!stderrCapture.includes('unknown config key'),
|
|
`should not warn for planning.sub_repos, got: ${stderrCapture}`
|
|
);
|
|
assert.ok(
|
|
!stderrCapture.includes('sub_repos'),
|
|
`should not mention sub_repos at all, got: ${stderrCapture}`
|
|
);
|
|
});
|
|
|
|
test('migrates legacy multiRepo:true into planning.sub_repos (not top-level)', () => {
|
|
makeSubRepo(tmpDir, 'backend');
|
|
makeSubRepo(tmpDir, 'frontend');
|
|
writeConfig(tmpDir, { multiRepo: true });
|
|
|
|
loadConfig(tmpDir);
|
|
|
|
const after = readConfig(tmpDir);
|
|
assert.deepStrictEqual(
|
|
after.planning?.sub_repos,
|
|
['backend', 'frontend'],
|
|
'migration should write to planning.sub_repos'
|
|
);
|
|
assert.strictEqual(
|
|
Object.prototype.hasOwnProperty.call(after, 'sub_repos'),
|
|
false,
|
|
'migration must not leave a top-level sub_repos key'
|
|
);
|
|
assert.strictEqual(after.multiRepo, undefined, 'legacy multiRepo should be removed');
|
|
|
|
assert.ok(
|
|
!stderrCapture.includes('unknown config key'),
|
|
`post-migration read should not warn, got: ${stderrCapture}`
|
|
);
|
|
});
|
|
|
|
test('filesystem sync writes detected list to planning.sub_repos only', () => {
|
|
makeSubRepo(tmpDir, 'api');
|
|
makeSubRepo(tmpDir, 'web');
|
|
writeConfig(tmpDir, { planning: { sub_repos: ['api'] } });
|
|
|
|
loadConfig(tmpDir);
|
|
|
|
const after = readConfig(tmpDir);
|
|
assert.deepStrictEqual(after.planning?.sub_repos, ['api', 'web']);
|
|
assert.strictEqual(
|
|
Object.prototype.hasOwnProperty.call(after, 'sub_repos'),
|
|
false,
|
|
'sync must not create a top-level sub_repos key'
|
|
);
|
|
assert.ok(
|
|
!stderrCapture.includes('unknown config key'),
|
|
`sync should not produce unknown-key warning, got: ${stderrCapture}`
|
|
);
|
|
});
|
|
|
|
test('stale top-level sub_repos is stripped on load', () => {
|
|
makeSubRepo(tmpDir, 'backend');
|
|
writeConfig(tmpDir, {
|
|
sub_repos: ['backend'],
|
|
planning: { sub_repos: ['backend'] },
|
|
});
|
|
|
|
loadConfig(tmpDir);
|
|
|
|
const after = readConfig(tmpDir);
|
|
assert.strictEqual(
|
|
Object.prototype.hasOwnProperty.call(after, 'sub_repos'),
|
|
false,
|
|
'stale top-level sub_repos should be removed to self-heal legacy installs'
|
|
);
|
|
assert.deepStrictEqual(after.planning?.sub_repos, ['backend']);
|
|
});
|
|
});
|