feat: multi-repo workspace support with auto-detection and project root resolution
Add support for workspaces with multiple independent git repositories. When configured, GSD routes commits to the correct sub-repo and ensures .planning/ stays at the project root. Core features: - detectSubRepos(): scans child directories for .git to discover repos - findProjectRoot(): walks up from CWD to find the project root that owns .planning/, preventing orphaned .planning/ in sub-repos - loadConfig auto-syncs sub_repos when repos are added or removed - Migrates legacy "multiRepo: true" to sub_repos array automatically - All init commands include project_root in output - cmdCommitToSubrepo: groups files by sub-repo prefix, commits independently Zero impact on single-repo workflows — sub_repos defaults to empty array. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -27,6 +27,8 @@ const {
|
||||
getRoadmapPhaseInternal,
|
||||
searchPhaseInDir,
|
||||
findPhaseInternal,
|
||||
findProjectRoot,
|
||||
detectSubRepos,
|
||||
} = require('../get-shit-done/bin/lib/core.cjs');
|
||||
|
||||
// ─── loadConfig ────────────────────────────────────────────────────────────────
|
||||
@@ -975,3 +977,228 @@ describe('withPlanningLock', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── detectSubRepos ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('detectSubRepos', () => {
|
||||
let projectRoot;
|
||||
|
||||
beforeEach(() => {
|
||||
projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-detect-test-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(projectRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('returns empty array when no child directories have .git', () => {
|
||||
fs.mkdirSync(path.join(projectRoot, 'src'));
|
||||
fs.mkdirSync(path.join(projectRoot, 'lib'));
|
||||
assert.deepStrictEqual(detectSubRepos(projectRoot), []);
|
||||
});
|
||||
|
||||
test('detects directories with .git', () => {
|
||||
fs.mkdirSync(path.join(projectRoot, 'backend', '.git'), { recursive: true });
|
||||
fs.mkdirSync(path.join(projectRoot, 'frontend', '.git'), { recursive: true });
|
||||
fs.mkdirSync(path.join(projectRoot, 'scripts')); // no .git
|
||||
assert.deepStrictEqual(detectSubRepos(projectRoot), ['backend', 'frontend']);
|
||||
});
|
||||
|
||||
test('returns sorted results', () => {
|
||||
fs.mkdirSync(path.join(projectRoot, 'zeta', '.git'), { recursive: true });
|
||||
fs.mkdirSync(path.join(projectRoot, 'alpha', '.git'), { recursive: true });
|
||||
fs.mkdirSync(path.join(projectRoot, 'mid', '.git'), { recursive: true });
|
||||
assert.deepStrictEqual(detectSubRepos(projectRoot), ['alpha', 'mid', 'zeta']);
|
||||
});
|
||||
|
||||
test('skips hidden directories', () => {
|
||||
fs.mkdirSync(path.join(projectRoot, '.hidden', '.git'), { recursive: true });
|
||||
fs.mkdirSync(path.join(projectRoot, 'visible', '.git'), { recursive: true });
|
||||
assert.deepStrictEqual(detectSubRepos(projectRoot), ['visible']);
|
||||
});
|
||||
|
||||
test('skips node_modules', () => {
|
||||
fs.mkdirSync(path.join(projectRoot, 'node_modules', '.git'), { recursive: true });
|
||||
fs.mkdirSync(path.join(projectRoot, 'app', '.git'), { recursive: true });
|
||||
assert.deepStrictEqual(detectSubRepos(projectRoot), ['app']);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── loadConfig sub_repos auto-sync ──────────────────────────────────────────
|
||||
|
||||
describe('loadConfig sub_repos auto-sync', () => {
|
||||
let projectRoot;
|
||||
|
||||
beforeEach(() => {
|
||||
projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-sync-test-'));
|
||||
fs.mkdirSync(path.join(projectRoot, '.planning'), { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(projectRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('migrates multiRepo: true to sub_repos array', () => {
|
||||
// Create config with legacy multiRepo flag
|
||||
fs.writeFileSync(
|
||||
path.join(projectRoot, '.planning', 'config.json'),
|
||||
JSON.stringify({ multiRepo: true, model_profile: 'quality' })
|
||||
);
|
||||
// Create sub-repos
|
||||
fs.mkdirSync(path.join(projectRoot, 'backend', '.git'), { recursive: true });
|
||||
fs.mkdirSync(path.join(projectRoot, 'frontend', '.git'), { recursive: true });
|
||||
|
||||
const config = loadConfig(projectRoot);
|
||||
assert.deepStrictEqual(config.sub_repos, ['backend', 'frontend']);
|
||||
assert.strictEqual(config.commit_docs, false);
|
||||
|
||||
// Verify config was persisted
|
||||
const saved = JSON.parse(fs.readFileSync(path.join(projectRoot, '.planning', 'config.json'), 'utf-8'));
|
||||
assert.deepStrictEqual(saved.sub_repos, ['backend', 'frontend']);
|
||||
assert.strictEqual(saved.multiRepo, undefined, 'multiRepo should be removed');
|
||||
});
|
||||
|
||||
test('adds newly detected repos to sub_repos', () => {
|
||||
fs.mkdirSync(path.join(projectRoot, 'backend', '.git'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(projectRoot, '.planning', 'config.json'),
|
||||
JSON.stringify({ sub_repos: ['backend'] })
|
||||
);
|
||||
|
||||
// Add a new repo
|
||||
fs.mkdirSync(path.join(projectRoot, 'frontend', '.git'), { recursive: true });
|
||||
|
||||
const config = loadConfig(projectRoot);
|
||||
assert.deepStrictEqual(config.sub_repos, ['backend', 'frontend']);
|
||||
});
|
||||
|
||||
test('removes repos that no longer have .git', () => {
|
||||
fs.mkdirSync(path.join(projectRoot, 'backend', '.git'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(projectRoot, '.planning', 'config.json'),
|
||||
JSON.stringify({ sub_repos: ['backend', 'old-repo'] })
|
||||
);
|
||||
|
||||
const config = loadConfig(projectRoot);
|
||||
assert.deepStrictEqual(config.sub_repos, ['backend']);
|
||||
});
|
||||
|
||||
test('does not sync when sub_repos is empty and no repos detected', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(projectRoot, '.planning', 'config.json'),
|
||||
JSON.stringify({ sub_repos: [] })
|
||||
);
|
||||
|
||||
const config = loadConfig(projectRoot);
|
||||
assert.deepStrictEqual(config.sub_repos, []);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── findProjectRoot ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('findProjectRoot', () => {
|
||||
let projectRoot;
|
||||
|
||||
beforeEach(() => {
|
||||
projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-root-test-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(projectRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('returns startDir when no .planning/ exists anywhere', () => {
|
||||
const subDir = path.join(projectRoot, 'backend');
|
||||
fs.mkdirSync(subDir);
|
||||
assert.strictEqual(findProjectRoot(subDir), subDir);
|
||||
});
|
||||
|
||||
test('returns startDir when .planning/ is in startDir itself', () => {
|
||||
fs.mkdirSync(path.join(projectRoot, '.planning'), { recursive: true });
|
||||
assert.strictEqual(findProjectRoot(projectRoot), projectRoot);
|
||||
});
|
||||
|
||||
test('walks up to parent with .planning/ and sub_repos config listing this dir', () => {
|
||||
fs.mkdirSync(path.join(projectRoot, '.planning'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(projectRoot, '.planning', 'config.json'),
|
||||
JSON.stringify({ sub_repos: ['backend', 'frontend'] })
|
||||
);
|
||||
|
||||
const backendDir = path.join(projectRoot, 'backend');
|
||||
fs.mkdirSync(backendDir);
|
||||
|
||||
assert.strictEqual(findProjectRoot(backendDir), projectRoot);
|
||||
});
|
||||
|
||||
test('walks up from nested sub-repo subdirectory', () => {
|
||||
fs.mkdirSync(path.join(projectRoot, '.planning'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(projectRoot, '.planning', 'config.json'),
|
||||
JSON.stringify({ sub_repos: ['backend', 'frontend'] })
|
||||
);
|
||||
|
||||
const deepDir = path.join(projectRoot, 'backend', 'src', 'services');
|
||||
fs.mkdirSync(deepDir, { recursive: true });
|
||||
|
||||
assert.strictEqual(findProjectRoot(deepDir), projectRoot);
|
||||
});
|
||||
|
||||
test('walks up via legacy multiRepo flag', () => {
|
||||
fs.mkdirSync(path.join(projectRoot, '.planning'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(projectRoot, '.planning', 'config.json'),
|
||||
JSON.stringify({ multiRepo: true })
|
||||
);
|
||||
|
||||
const backendDir = path.join(projectRoot, 'backend');
|
||||
fs.mkdirSync(path.join(backendDir, '.git'), { recursive: true });
|
||||
|
||||
assert.strictEqual(findProjectRoot(backendDir), projectRoot);
|
||||
});
|
||||
|
||||
test('walks up via .git heuristic when no config exists', () => {
|
||||
fs.mkdirSync(path.join(projectRoot, '.planning'), { recursive: true });
|
||||
// No config.json at all
|
||||
|
||||
const backendDir = path.join(projectRoot, 'backend');
|
||||
fs.mkdirSync(path.join(backendDir, '.git'), { recursive: true });
|
||||
|
||||
assert.strictEqual(findProjectRoot(backendDir), projectRoot);
|
||||
});
|
||||
|
||||
test('does not walk up for dirs without .git when no sub_repos config', () => {
|
||||
fs.mkdirSync(path.join(projectRoot, '.planning'), { recursive: true });
|
||||
|
||||
const scriptsDir = path.join(projectRoot, 'scripts');
|
||||
fs.mkdirSync(scriptsDir);
|
||||
|
||||
assert.strictEqual(findProjectRoot(scriptsDir), scriptsDir);
|
||||
});
|
||||
|
||||
test('handles planning.sub_repos nested config format', () => {
|
||||
fs.mkdirSync(path.join(projectRoot, '.planning'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(projectRoot, '.planning', 'config.json'),
|
||||
JSON.stringify({ planning: { sub_repos: ['backend'] } })
|
||||
);
|
||||
|
||||
const backendDir = path.join(projectRoot, 'backend');
|
||||
fs.mkdirSync(backendDir);
|
||||
|
||||
assert.strictEqual(findProjectRoot(backendDir), projectRoot);
|
||||
});
|
||||
|
||||
test('returns startDir when sub_repos is empty and no .git', () => {
|
||||
fs.mkdirSync(path.join(projectRoot, '.planning'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(projectRoot, '.planning', 'config.json'),
|
||||
JSON.stringify({ sub_repos: [] })
|
||||
);
|
||||
|
||||
const backendDir = path.join(projectRoot, 'backend');
|
||||
fs.mkdirSync(backendDir);
|
||||
|
||||
assert.strictEqual(findProjectRoot(backendDir), backendDir);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user