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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -977,6 +977,70 @@ describe('cmdInitNewMilestone', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// findProjectRoot integration — gsd-tools resolves project root from sub-repo
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('findProjectRoot integration via --cwd', () => {
|
||||
let projectRoot;
|
||||
|
||||
beforeEach(() => {
|
||||
projectRoot = createTempProject();
|
||||
// Add ROADMAP.md so init quick doesn't error
|
||||
fs.writeFileSync(
|
||||
path.join(projectRoot, '.planning', 'ROADMAP.md'),
|
||||
'# Roadmap\n\n## Phase 1: Foundation\n**Goal:** Setup\n'
|
||||
);
|
||||
// Write sub_repos config
|
||||
fs.writeFileSync(
|
||||
path.join(projectRoot, '.planning', 'config.json'),
|
||||
JSON.stringify({ sub_repos: ['backend', 'frontend'] })
|
||||
);
|
||||
// Create sub-repo directory
|
||||
fs.mkdirSync(path.join(projectRoot, 'backend'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(projectRoot);
|
||||
});
|
||||
|
||||
test('init quick from sub-repo CWD returns project_root pointing to parent', () => {
|
||||
const backendDir = path.join(projectRoot, 'backend');
|
||||
const result = runGsdTools(['init', 'quick', 'test task', '--cwd', backendDir]);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.ok('project_root' in output, 'Should have project_root');
|
||||
assert.strictEqual(output.project_root, projectRoot, 'project_root should be the parent, not the sub-repo');
|
||||
assert.ok(output.roadmap_exists, 'Should find ROADMAP.md at project root');
|
||||
});
|
||||
|
||||
test('init quick from project root returns project_root as-is', () => {
|
||||
const result = runGsdTools(['init', 'quick', 'test task', '--cwd', projectRoot]);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output.project_root, projectRoot);
|
||||
});
|
||||
|
||||
test('state load from sub-repo CWD reads project root config', () => {
|
||||
// Write STATE.md at project root
|
||||
fs.writeFileSync(
|
||||
path.join(projectRoot, '.planning', 'STATE.md'),
|
||||
'---\ncurrent_phase: 1\nphase_name: Foundation\n---\n# State\n'
|
||||
);
|
||||
|
||||
const backendDir = path.join(projectRoot, 'backend');
|
||||
const result = runGsdTools(['state', '--cwd', backendDir]);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
// Should find config from project root, not from backend/
|
||||
assert.deepStrictEqual(output.config.sub_repos, ['backend', 'frontend'],
|
||||
'Should read sub_repos from project root config');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// roadmap analyze command
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user