diff --git a/agents/gsd-executor.md b/agents/gsd-executor.md index 03f604248..fa3f8c5e1 100644 --- a/agents/gsd-executor.md +++ b/agents/gsd-executor.md @@ -47,7 +47,7 @@ INIT=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" init execute-phase " if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi ``` -Extract from init JSON: `executor_model`, `commit_docs`, `phase_dir`, `plans`, `incomplete_plans`. +Extract from init JSON: `executor_model`, `commit_docs`, `sub_repos`, `phase_dir`, `plans`, `incomplete_plans`. Also read STATE.md for position, decisions, blockers: ```bash @@ -328,6 +328,14 @@ git add src/types/user.ts | `chore` | Config, tooling, dependencies | **4. Commit:** + +**If `sub_repos` is configured (non-empty array from init context):** Use `commit-to-subrepo` to route files to their correct sub-repo: +```bash +node ~/.claude/get-shit-done/bin/gsd-tools.cjs commit-to-subrepo "{type}({phase}-{plan}): {concise task description}" --files file1 file2 ... +``` +Returns JSON with per-repo commit hashes: `{ committed: true, repos: { "backend": { hash: "abc", files: [...] }, ... } }`. Record all hashes for SUMMARY. + +**Otherwise (standard single-repo):** ```bash git commit -m "{type}({phase}-{plan}): {concise task description} diff --git a/get-shit-done/bin/gsd-tools.cjs b/get-shit-done/bin/gsd-tools.cjs index 7842fb6d8..a7cd70377 100755 --- a/get-shit-done/bin/gsd-tools.cjs +++ b/get-shit-done/bin/gsd-tools.cjs @@ -20,6 +20,7 @@ * resolve-model Get model for agent based on profile * find-phase Find phase directory by number * commit [--files f1 f2] [--no-verify] Commit planning docs + * commit-to-subrepo --files f1 f2 Route commits to sub-repos * verify-summary Verify a SUMMARY.md file * generate-slug Convert text to URL-safe slug * current-timestamp [format] Get timestamp (full|date|filename) @@ -134,7 +135,7 @@ const fs = require('fs'); const path = require('path'); -const { error } = require('./lib/core.cjs'); +const { error, findProjectRoot } = require('./lib/core.cjs'); const state = require('./lib/state.cjs'); const phase = require('./lib/phase.cjs'); const roadmap = require('./lib/roadmap.cjs'); @@ -177,10 +178,13 @@ async function main() { const { resolveWorktreeRoot } = require('./lib/core.cjs'); const worktreeRoot = resolveWorktreeRoot(cwd); if (worktreeRoot !== cwd) { - // Only override cwd for planning-related commands — keep original cwd for git operations cwd = worktreeRoot; } + // Multi-repo guard: if CWD is inside a sub-repo, walk up to the project root + // so .planning/ is read/written at the correct level. + cwd = findProjectRoot(cwd); + const rawIndex = args.indexOf('--raw'); const raw = rawIndex !== -1; if (rawIndex !== -1) args.splice(rawIndex, 1); @@ -314,6 +318,14 @@ async function main() { break; } + case 'commit-to-subrepo': { + const message = args[1]; + const filesIndex = args.indexOf('--files'); + const files = filesIndex !== -1 ? args.slice(filesIndex + 1).filter(a => !a.startsWith('--')) : []; + commands.cmdCommitToSubrepo(cwd, message, files, raw); + break; + } + case 'verify-summary': { const summaryPath = args[1]; const countIndex = args.indexOf('--check-count'); diff --git a/get-shit-done/bin/lib/commands.cjs b/get-shit-done/bin/lib/commands.cjs index a43a0e822..27461c393 100644 --- a/get-shit-done/bin/lib/commands.cjs +++ b/get-shit-done/bin/lib/commands.cjs @@ -269,6 +269,70 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify) { output(result, raw, hash || 'committed'); } +function cmdCommitToSubrepo(cwd, message, files, raw) { + if (!message) { + error('commit message required'); + } + + const config = loadConfig(cwd); + const subRepos = config.sub_repos; + + if (!subRepos || subRepos.length === 0) { + error('no sub_repos configured in .planning/config.json'); + } + + if (!files || files.length === 0) { + error('--files required for commit-to-subrepo'); + } + + // Group files by sub-repo prefix + const grouped = {}; + const unmatched = []; + for (const file of files) { + const match = subRepos.find(repo => file.startsWith(repo + '/')); + if (match) { + if (!grouped[match]) grouped[match] = []; + grouped[match].push(file); + } else { + unmatched.push(file); + } + } + + const repos = {}; + for (const [repo, repoFiles] of Object.entries(grouped)) { + const repoCwd = path.join(cwd, repo); + + // Stage files (strip sub-repo prefix for paths relative to that repo) + for (const file of repoFiles) { + const relativePath = file.slice(repo.length + 1); + execGit(repoCwd, ['add', relativePath]); + } + + // Commit + const commitResult = execGit(repoCwd, ['commit', '-m', message]); + if (commitResult.exitCode !== 0) { + if (commitResult.stdout.includes('nothing to commit') || commitResult.stderr.includes('nothing to commit')) { + repos[repo] = { committed: false, hash: null, files: repoFiles, reason: 'nothing_to_commit' }; + continue; + } + repos[repo] = { committed: false, hash: null, files: repoFiles, reason: 'error', error: commitResult.stderr }; + continue; + } + + // Get hash + const hashResult = execGit(repoCwd, ['rev-parse', '--short', 'HEAD']); + const hash = hashResult.exitCode === 0 ? hashResult.stdout : null; + repos[repo] = { committed: true, hash, files: repoFiles }; + } + + const result = { + committed: Object.values(repos).some(r => r.committed), + repos, + unmatched: unmatched.length > 0 ? unmatched : undefined, + }; + output(result, raw, Object.entries(repos).map(([r, v]) => `${r}:${v.hash || 'skip'}`).join(' ')); +} + function cmdSummaryExtract(cwd, summaryPath, fields, raw) { if (!summaryPath) { error('summary-path required for summary-extract'); @@ -831,6 +895,7 @@ module.exports = { cmdHistoryDigest, cmdResolveModel, cmdCommit, + cmdCommitToSubrepo, cmdSummaryExtract, cmdWebsearch, cmdProgressRender, diff --git a/get-shit-done/bin/lib/core.cjs b/get-shit-done/bin/lib/core.cjs index c4c7ac707..3101f02e5 100644 --- a/get-shit-done/bin/lib/core.cjs +++ b/get-shit-done/bin/lib/core.cjs @@ -14,6 +14,91 @@ function toPosixPath(p) { return p.split(path.sep).join('/'); } +/** + * Scan immediate child directories for separate git repos. + * Returns a sorted array of directory names that have their own `.git`. + * Excludes hidden directories and node_modules. + */ +function detectSubRepos(cwd) { + const results = []; + try { + const entries = fs.readdirSync(cwd, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (entry.name.startsWith('.') || entry.name === 'node_modules') continue; + const gitPath = path.join(cwd, entry.name, '.git'); + try { + if (fs.existsSync(gitPath)) { + results.push(entry.name); + } + } catch {} + } + } catch {} + return results.sort(); +} + +/** + * Walk up from `startDir` to find the project root that owns `.planning/`. + * + * In multi-repo workspaces, Claude may open inside a sub-repo (e.g. `backend/`) + * instead of the project root. This function prevents `.planning/` from being + * created inside the sub-repo by locating the nearest ancestor that already has + * a `.planning/` directory. + * + * Detection strategy (checked in order for each ancestor): + * 1. Parent has `.planning/config.json` with `sub_repos` listing this directory + * 2. Parent has `.planning/config.json` with `multiRepo: true` (legacy format) + * 3. Parent has `.planning/` and current dir has its own `.git` (heuristic) + * + * Returns `startDir` unchanged when no ancestor `.planning/` is found (first-run + * or single-repo projects). + */ +function findProjectRoot(startDir) { + const resolved = path.resolve(startDir); + const root = path.parse(resolved).root; + const homedir = require('os').homedir(); + const startHasGit = fs.existsSync(path.join(resolved, '.git')); + + let dir = resolved; + while (dir !== root) { + const parent = path.dirname(dir); + if (parent === dir) break; // filesystem root + if (parent === homedir) break; // never go above home + + const parentPlanning = path.join(parent, '.planning'); + if (fs.existsSync(parentPlanning) && fs.statSync(parentPlanning).isDirectory()) { + const configPath = path.join(parentPlanning, 'config.json'); + try { + const config = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + const subRepos = config.sub_repos || config.planning?.sub_repos || []; + + // Check explicit sub_repos list + if (Array.isArray(subRepos) && subRepos.length > 0) { + const relPath = path.relative(parent, resolved); + const topSegment = relPath.split(path.sep)[0]; + if (subRepos.includes(topSegment)) { + return parent; + } + } + + // Check legacy multiRepo flag + if (config.multiRepo === true && startHasGit) { + return parent; + } + } catch { + // config.json missing or malformed — fall back to .git heuristic + } + + // Heuristic: parent has .planning/ and startDir has its own .git + if (startHasGit) { + return parent; + } + } + dir = parent; + } + return startDir; +} + // ─── Output helpers ─────────────────────────────────────────────────────────── function output(result, raw, rawValue) { @@ -66,6 +151,7 @@ function loadConfig(cwd) { parallelization: true, brave_search: false, text_mode: false, // when true, use plain-text numbered lists instead of AskUserQuestion menus + sub_repos: [], resolve_model_ids: false, // when true, resolve aliases (opus/sonnet/haiku) to full model IDs context_window: 200000, // default 200k; set to 1000000 for Opus/Sonnet 4.6 1M models phase_naming: 'sequential', // 'sequential' (default, auto-increment) or 'custom' (arbitrary string IDs) @@ -83,6 +169,39 @@ function loadConfig(cwd) { try { fs.writeFileSync(configPath, JSON.stringify(parsed, null, 2), 'utf-8'); } catch { /* intentionally empty */ } } + // Auto-detect and sync sub_repos: scan for child directories with .git + let configDirty = false; + + // Migrate legacy "multiRepo: true" boolean → sub_repos array + if (parsed.multiRepo === true && !parsed.sub_repos && !parsed.planning?.sub_repos) { + const detected = detectSubRepos(cwd); + if (detected.length > 0) { + parsed.sub_repos = detected; + if (!parsed.planning) parsed.planning = {}; + parsed.planning.commit_docs = false; + delete parsed.multiRepo; + configDirty = true; + } + } + + // Keep sub_repos in sync with actual filesystem + const currentSubRepos = parsed.sub_repos || parsed.planning?.sub_repos || []; + if (Array.isArray(currentSubRepos) && currentSubRepos.length > 0) { + const detected = detectSubRepos(cwd); + if (detected.length > 0) { + const sorted = [...currentSubRepos].sort(); + if (JSON.stringify(sorted) !== JSON.stringify(detected)) { + parsed.sub_repos = detected; + configDirty = true; + } + } + } + + // Persist sub_repos changes (migration or sync) + if (configDirty) { + try { fs.writeFileSync(configPath, JSON.stringify(parsed, null, 2), 'utf-8'); } catch {} + } + const get = (key, nested) => { if (parsed[key] !== undefined) return parsed[key]; if (nested && parsed[nested.section] && parsed[nested.section][nested.field] !== undefined) { @@ -113,6 +232,7 @@ function loadConfig(cwd) { parallelization, brave_search: get('brave_search') ?? defaults.brave_search, text_mode: get('text_mode', { section: 'workflow', field: 'text_mode' }) ?? defaults.text_mode, + sub_repos: get('sub_repos', { section: 'planning', field: 'sub_repos' }) ?? defaults.sub_repos, resolve_model_ids: get('resolve_model_ids') ?? defaults.resolve_model_ids, context_window: get('context_window') ?? defaults.context_window, phase_naming: get('phase_naming') ?? defaults.phase_naming, @@ -860,6 +980,8 @@ module.exports = { extractOneLinerFromBody, resolveWorktreeRoot, withPlanningLock, + findProjectRoot, + detectSubRepos, MODEL_ALIAS_MAP, planningDir, planningPaths, diff --git a/get-shit-done/bin/lib/init.cjs b/get-shit-done/bin/lib/init.cjs index 3f21237a3..b19d3d1d8 100644 --- a/get-shit-done/bin/lib/init.cjs +++ b/get-shit-done/bin/lib/init.cjs @@ -24,6 +24,16 @@ function getLatestCompletedMilestone(cwd) { } } +/** + * Inject `project_root` into an init result object. + * Workflows use this to prefix `.planning/` paths correctly when Claude's CWD + * differs from the project root (e.g., inside a sub-repo). + */ +function withProjectRoot(cwd, result) { + result.project_root = cwd; + return result; +} + function cmdInitExecutePhase(cwd, phase, raw) { if (!phase) { error('phase required for init execute-phase'); @@ -95,7 +105,7 @@ function cmdInitExecutePhase(cwd, phase, raw) { config_path: '.planning/config.json', }; - output(result, raw); + output(withProjectRoot(cwd, result), raw); } function cmdInitPlanPhase(cwd, phase, raw) { @@ -174,7 +184,7 @@ function cmdInitPlanPhase(cwd, phase, raw) { } catch { /* intentionally empty */ } } - output(result, raw); + output(withProjectRoot(cwd, result), raw); } function cmdInitNewProject(cwd, raw) { @@ -242,7 +252,7 @@ function cmdInitNewProject(cwd, raw) { project_path: '.planning/PROJECT.md', }; - output(result, raw); + output(withProjectRoot(cwd, result), raw); } function cmdInitNewMilestone(cwd, raw) { @@ -289,7 +299,7 @@ function cmdInitNewMilestone(cwd, raw) { state_path: '.planning/STATE.md', }; - output(result, raw); + output(withProjectRoot(cwd, result), raw); } function cmdInitQuick(cwd, description, raw) { @@ -347,7 +357,7 @@ function cmdInitQuick(cwd, description, raw) { }; - output(result, raw); + output(withProjectRoot(cwd, result), raw); } function cmdInitResume(cwd, raw) { @@ -379,7 +389,7 @@ function cmdInitResume(cwd, raw) { commit_docs: config.commit_docs, }; - output(result, raw); + output(withProjectRoot(cwd, result), raw); } function cmdInitVerifyWork(cwd, phase, raw) { @@ -408,7 +418,7 @@ function cmdInitVerifyWork(cwd, phase, raw) { has_verification: phaseInfo?.has_verification || false, }; - output(result, raw); + output(withProjectRoot(cwd, result), raw); } function cmdInitPhaseOp(cwd, phase, raw) { @@ -512,7 +522,7 @@ function cmdInitPhaseOp(cwd, phase, raw) { } catch { /* intentionally empty */ } } - output(result, raw); + output(withProjectRoot(cwd, result), raw); } function cmdInitTodos(cwd, area, raw) { @@ -571,7 +581,7 @@ function cmdInitTodos(cwd, area, raw) { pending_dir_exists: pathExistsInternal(cwd, '.planning/todos/pending'), }; - output(result, raw); + output(withProjectRoot(cwd, result), raw); } function cmdInitMilestoneOp(cwd, raw) { @@ -632,7 +642,7 @@ function cmdInitMilestoneOp(cwd, raw) { phases_dir_exists: pathExistsInternal(cwd, '.planning/phases'), }; - output(result, raw); + output(withProjectRoot(cwd, result), raw); } function cmdInitMapCodebase(cwd, raw) { @@ -666,7 +676,7 @@ function cmdInitMapCodebase(cwd, raw) { codebase_dir_exists: pathExistsInternal(cwd, '.planning/codebase'), }; - output(result, raw); + output(withProjectRoot(cwd, result), raw); } function cmdInitProgress(cwd, raw) { @@ -813,7 +823,7 @@ function cmdInitProgress(cwd, raw) { config_path: '.planning/config.json', }; - output(result, raw); + output(withProjectRoot(cwd, result), raw); } module.exports = { diff --git a/get-shit-done/references/git-integration.md b/get-shit-done/references/git-integration.md index d9bbecac2..2b6a9b733 100644 --- a/get-shit-done/references/git-integration.md +++ b/get-shit-done/references/git-integration.md @@ -250,3 +250,46 @@ Each plan produces 2-4 commits (tasks + metadata). Clear, granular, bisectable. - "Commit noise" irrelevant when consumer is Claude, not humans + + + +## Multi-Repo Workspace Support (sub_repos) + +For workspaces with separate git repos (e.g., `backend/`, `frontend/`, `shared/`), GSD routes commits to each repo independently. + +### Configuration + +In `.planning/config.json`, list sub-repo directories under `planning.sub_repos`: + +```json +{ + "planning": { + "commit_docs": false, + "sub_repos": ["backend", "frontend", "shared"] + } +} +``` + +Set `commit_docs: false` so planning docs stay local and are not committed to any sub-repo. + +### How It Works + +1. **Auto-detection:** During `/gsd:new-project`, directories with their own `.git` folder are detected and offered for selection as sub-repos. +2. **File grouping:** Code files are grouped by their sub-repo prefix (e.g., `backend/src/api/users.ts` belongs to the `backend/` repo). +3. **Independent commits:** Each sub-repo receives its own atomic commit via `gsd-tools.cjs commit-to-subrepo`. File paths are made relative to the sub-repo root before staging. +4. **Planning stays local:** The `.planning/` directory is not committed; it acts as cross-repo coordination. + +### Commit Routing + +Instead of the standard `commit` command, use `commit-to-subrepo` when `sub_repos` is configured: + +```bash +node ~/.claude/get-shit-done/bin/gsd-tools.cjs commit-to-subrepo "feat(02-01): add user API" \ + --files backend/src/api/users.ts backend/src/types/user.ts frontend/src/components/UserForm.tsx +``` + +This stages `src/api/users.ts` and `src/types/user.ts` in the `backend/` repo, and `src/components/UserForm.tsx` in the `frontend/` repo, then commits each independently with the same message. + +Files that don't match any configured sub-repo are reported as unmatched. + + diff --git a/get-shit-done/templates/config.json b/get-shit-done/templates/config.json index 462e63628..6b8b46064 100644 --- a/get-shit-done/templates/config.json +++ b/get-shit-done/templates/config.json @@ -10,7 +10,8 @@ }, "planning": { "commit_docs": true, - "search_gitignored": false + "search_gitignored": false, + "sub_repos": [] }, "parallelization": { "enabled": true, diff --git a/get-shit-done/workflows/execute-plan.md b/get-shit-done/workflows/execute-plan.md index 0e5d8e8a5..437e8302a 100644 --- a/get-shit-done/workflows/execute-plan.md +++ b/get-shit-done/workflows/execute-plan.md @@ -19,7 +19,7 @@ INIT=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" init execute-phase " if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi ``` -Extract from init JSON: `executor_model`, `commit_docs`, `phase_dir`, `phase_number`, `plans`, `summaries`, `incomplete_plans`, `state_path`, `config_path`. +Extract from init JSON: `executor_model`, `commit_docs`, `sub_repos`, `phase_dir`, `phase_number`, `plans`, `summaries`, `incomplete_plans`, `state_path`, `config_path`. If `.planning/` missing: error. @@ -276,6 +276,20 @@ git add src/types/user.ts **4. Format:** `{type}({phase}-{plan}): {description}` with bullet points for key changes. + +**Sub-repos mode:** If `sub_repos` is configured (non-empty array from init context), use `commit-to-subrepo` instead of standard git commit. This routes files to their correct sub-repo based on path prefix. + +```bash +node ~/.claude/get-shit-done/bin/gsd-tools.cjs commit-to-subrepo "{type}({phase}-{plan}): {description}" --files file1 file2 ... +``` + +The command groups files by sub-repo prefix and commits atomically to each. Returns JSON: `{ committed: true, repos: { "backend": { hash: "abc", files: [...] }, ... } }`. + +Record hashes from each repo in the response for SUMMARY tracking. + +**If `sub_repos` is empty or not set:** Use standard git commit flow below. + + **5. Record hash:** ```bash TASK_COMMIT=$(git rev-parse --short HEAD) diff --git a/get-shit-done/workflows/new-project.md b/get-shit-done/workflows/new-project.md index b5876e989..b5cb42482 100644 --- a/get-shit-done/workflows/new-project.md +++ b/get-shit-done/workflows/new-project.md @@ -528,6 +528,39 @@ node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" commit "chore: add project **Note:** Run `/gsd:settings` anytime to update these preferences. +## 5.1. Sub-Repo Detection + +**Detect multi-repo workspace:** + +Check for directories with their own `.git` folders (separate repos within the workspace): + +```bash +find . -maxdepth 2 -type d -name ".git" -not -path "./.git" +``` + +**If sub-repos found:** + +Strip the `/.git` suffix and `./` prefix to get directory names (e.g., `./backend/.git` → `backend`). + +Use AskUserQuestion: +- header: "Multi-Repo Workspace" +- question: "I detected separate git repos in this workspace. Which directories contain code that GSD should commit to?" +- multiSelect: true +- options: one option per detected directory + - "[directory name]" — Separate git repo + +**If user selects one or more directories:** +- Set `planning.sub_repos` in config.json to the selected directory names array (e.g., `["backend", "frontend"]`) +- Auto-set `planning.commit_docs` to `false` (planning docs stay local in multi-repo workspaces) +- Add `.planning/` to `.gitignore` if not already present + +Update the config file: +```bash +node ~/.claude/get-shit-done/bin/gsd-tools.cjs commit "chore: configure multi-repo workspace" --files .planning/config.json +``` + +**If no sub-repos found or user selects none:** Continue with no changes to config. + ## 5.5. Resolve Model Profile Use models from init: `researcher_model`, `synthesizer_model`, `roadmapper_model`. diff --git a/tests/core.test.cjs b/tests/core.test.cjs index 3ed18ef01..eca98f1f4 100644 --- a/tests/core.test.cjs +++ b/tests/core.test.cjs @@ -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); + }); +}); diff --git a/tests/init.test.cjs b/tests/init.test.cjs index d36762860..740fca823 100644 --- a/tests/init.test.cjs +++ b/tests/init.test.cjs @@ -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 // ─────────────────────────────────────────────────────────────────────────────