From 21081dc821c4883c0501a510bf177bf0be32a89b Mon Sep 17 00:00:00 2001 From: Srinivas Koduri Date: Wed, 18 Mar 2026 19:35:22 -0700 Subject: [PATCH 1/5] feat: multi-repo workspace support with auto-detection and project root resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- agents/gsd-executor.md | 10 +- get-shit-done/bin/gsd-tools.cjs | 16 +- get-shit-done/bin/lib/commands.cjs | 65 ++++++ get-shit-done/bin/lib/core.cjs | 122 +++++++++++ get-shit-done/bin/lib/init.cjs | 34 +-- get-shit-done/references/git-integration.md | 43 ++++ get-shit-done/templates/config.json | 3 +- get-shit-done/workflows/execute-plan.md | 16 +- get-shit-done/workflows/new-project.md | 33 +++ tests/core.test.cjs | 227 ++++++++++++++++++++ tests/init.test.cjs | 64 ++++++ 11 files changed, 616 insertions(+), 17 deletions(-) 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 // ───────────────────────────────────────────────────────────────────────────── From d0aae7b63c5b2e239acf59b0be649b8b8bea8901 Mon Sep 17 00:00:00 2001 From: Srinivas Koduri Date: Wed, 18 Mar 2026 20:06:49 -0700 Subject: [PATCH 2/5] =?UTF-8?q?fix:=20address=20PR=20review=20=E2=80=94=20?= =?UTF-8?q?nested=20path=20resolution,=20sub=5Frepos=20in=20init,=20depth-?= =?UTF-8?q?1=20detection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - findProjectRoot: use isInsideGitRepo() to walk up and find .git in ancestor dirs, fixing nested paths like backend/src/modules/ - Add sub_repos to cmdInitExecutePhase output so execute-plan.md and gsd-executor.md can route commits correctly - Align new-project.md sub-repo detection to maxdepth 1 matching detectSubRepos() behavior - Add 3 nested path tests for .git heuristic, sub_repos, and multiRepo Co-Authored-By: Claude Opus 4.6 (1M context) --- get-shit-done/bin/lib/core.cjs | 19 ++++++++--- get-shit-done/bin/lib/init.cjs | 1 + get-shit-done/workflows/new-project.md | 4 +-- tests/core.test.cjs | 47 ++++++++++++++++++++++++++ 4 files changed, 65 insertions(+), 6 deletions(-) diff --git a/get-shit-done/bin/lib/core.cjs b/get-shit-done/bin/lib/core.cjs index 3101f02e5..edcf95e3c 100644 --- a/get-shit-done/bin/lib/core.cjs +++ b/get-shit-done/bin/lib/core.cjs @@ -57,7 +57,18 @@ 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')); + + // Check if startDir or any of its ancestors (up to but not including a + // candidate project root) contains a .git directory. This handles both + // `backend/` (direct sub-repo) and `backend/src/modules/` (nested inside). + function isInsideGitRepo(candidateParent) { + let d = resolved; + while (d !== candidateParent && d !== root) { + if (fs.existsSync(path.join(d, '.git'))) return true; + d = path.dirname(d); + } + return false; + } let dir = resolved; while (dir !== root) { @@ -82,15 +93,15 @@ function findProjectRoot(startDir) { } // Check legacy multiRepo flag - if (config.multiRepo === true && startHasGit) { + if (config.multiRepo === true && isInsideGitRepo(parent)) { 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) { + // Heuristic: parent has .planning/ and we're inside a git repo + if (isInsideGitRepo(parent)) { return parent; } } diff --git a/get-shit-done/bin/lib/init.cjs b/get-shit-done/bin/lib/init.cjs index b19d3d1d8..6083dd908 100644 --- a/get-shit-done/bin/lib/init.cjs +++ b/get-shit-done/bin/lib/init.cjs @@ -57,6 +57,7 @@ function cmdInitExecutePhase(cwd, phase, raw) { // Config flags commit_docs: config.commit_docs, + sub_repos: config.sub_repos, parallelization: config.parallelization, context_window: config.context_window, branching_strategy: config.branching_strategy, diff --git a/get-shit-done/workflows/new-project.md b/get-shit-done/workflows/new-project.md index b5cb42482..9f1bd89d2 100644 --- a/get-shit-done/workflows/new-project.md +++ b/get-shit-done/workflows/new-project.md @@ -535,12 +535,12 @@ node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" commit "chore: add project Check for directories with their own `.git` folders (separate repos within the workspace): ```bash -find . -maxdepth 2 -type d -name ".git" -not -path "./.git" +find . -maxdepth 1 -type d -not -name ".*" -not -name "node_modules" -exec test -d "{}/.git" \; -print ``` **If sub-repos found:** -Strip the `/.git` suffix and `./` prefix to get directory names (e.g., `./backend/.git` → `backend`). +Strip the `./` prefix to get directory names (e.g., `./backend` → `backend`). Use AskUserQuestion: - header: "Multi-Repo Workspace" diff --git a/tests/core.test.cjs b/tests/core.test.cjs index eca98f1f4..77fb46956 100644 --- a/tests/core.test.cjs +++ b/tests/core.test.cjs @@ -1167,6 +1167,53 @@ describe('findProjectRoot', () => { assert.strictEqual(findProjectRoot(backendDir), projectRoot); }); + test('walks up from nested path inside sub-repo via .git heuristic', () => { + fs.mkdirSync(path.join(projectRoot, '.planning'), { recursive: true }); + + // Sub-repo with .git at its root + const backendDir = path.join(projectRoot, 'backend'); + fs.mkdirSync(path.join(backendDir, '.git'), { recursive: true }); + + // Nested path deep inside the sub-repo + const nestedDir = path.join(backendDir, 'src', 'modules', 'auth'); + fs.mkdirSync(nestedDir, { recursive: true }); + + // isInsideGitRepo walks up and finds backend/.git + assert.strictEqual(findProjectRoot(nestedDir), projectRoot); + }); + + test('walks up from nested path inside sub-repo via sub_repos config', () => { + fs.mkdirSync(path.join(projectRoot, '.planning'), { recursive: true }); + fs.writeFileSync( + path.join(projectRoot, '.planning', 'config.json'), + JSON.stringify({ sub_repos: ['backend'] }) + ); + + // Nested path deep inside the sub-repo + const nestedDir = path.join(projectRoot, 'backend', 'src', 'modules'); + fs.mkdirSync(nestedDir, { recursive: true }); + + // With sub_repos config, it checks topSegment of relative path + assert.strictEqual(findProjectRoot(nestedDir), projectRoot); + }); + + test('walks up from nested path 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 }); + + // Nested inside sub-repo — isInsideGitRepo walks up and finds backend/.git + const nestedDir = path.join(backendDir, 'src'); + fs.mkdirSync(nestedDir, { recursive: true }); + + assert.strictEqual(findProjectRoot(nestedDir), projectRoot); + }); + test('does not walk up for dirs without .git when no sub_repos config', () => { fs.mkdirSync(path.join(projectRoot, '.planning'), { recursive: true }); From 99b239dbaf6df1d1c69488675684c8763193d8e7 Mon Sep 17 00:00:00 2001 From: Srinivas Koduri Date: Wed, 18 Mar 2026 22:45:11 -0700 Subject: [PATCH 3/5] fix(executor): record per-repo commit hashes in multi-repo mode The hash recording step used `git rev-parse --short HEAD` which fails when the project root is not a git repo (multi-repo workspaces). Update the protocol to extract hashes from commit-to-subrepo JSON output and record all sub-repo hashes in the SUMMARY. Co-Authored-By: Claude Opus 4.6 (1M context) --- agents/gsd-executor.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/agents/gsd-executor.md b/agents/gsd-executor.md index fa3f8c5e1..9b818becd 100644 --- a/agents/gsd-executor.md +++ b/agents/gsd-executor.md @@ -344,7 +344,9 @@ git commit -m "{type}({phase}-{plan}): {concise task description} " ``` -**5. Record hash:** `TASK_COMMIT=$(git rev-parse --short HEAD)` — track for SUMMARY. +**5. Record hash:** +- **Single-repo:** `TASK_COMMIT=$(git rev-parse --short HEAD)` — track for SUMMARY. +- **Multi-repo (sub_repos):** Extract hashes from `commit-to-subrepo` JSON output (`repos.{name}.hash`). Record all hashes for SUMMARY (e.g., `backend@abc1234, frontend@def5678`). **6. Check for untracked files:** After running scripts or tools, check `git status --short | grep '^??'`. For any new untracked files: commit if intentional, add to `.gitignore` if generated/runtime output. Never leave generated files untracked. From fd0d5464842d5266be56bb0c85a25c048310e7cb Mon Sep 17 00:00:00 2001 From: Srinivas Koduri Date: Wed, 18 Mar 2026 22:45:19 -0700 Subject: [PATCH 4/5] fix(new-project): remove invalid commit step for multi-repo config The workflow set commit_docs to false for multi-repo workspaces then immediately ran gsd-tools commit on config.json, which would be skipped. Replace with a note that config changes are local-only. Co-Authored-By: Claude Opus 4.6 (1M context) --- get-shit-done/workflows/new-project.md | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/get-shit-done/workflows/new-project.md b/get-shit-done/workflows/new-project.md index 9f1bd89d2..5b5d6ad5a 100644 --- a/get-shit-done/workflows/new-project.md +++ b/get-shit-done/workflows/new-project.md @@ -554,10 +554,7 @@ Use AskUserQuestion: - 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 -``` +Config changes are saved locally — no commit needed since `commit_docs` is `false` in multi-repo mode. **If no sub-repos found or user selects none:** Continue with no changes to config. From 32c6d880bf2db6f2f9e92ac20b6c00ad7306eeb6 Mon Sep 17 00:00:00 2001 From: Srinivas Koduri Date: Thu, 19 Mar 2026 10:05:50 -0700 Subject: [PATCH 5/5] =?UTF-8?q?fix:=20address=20maintainer=20review=20?= =?UTF-8?q?=E2=80=94=20gate=20findProjectRoot,=20warn=20on=20unmatched=20f?= =?UTF-8?q?iles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Gate findProjectRoot to commands that access .planning/ — skip for pure-utility commands (generate-slug, current-timestamp, template, frontmatter, verify-path-exists, verify-summary) to avoid unnecessary filesystem traversal on every invocation. 2. Warn to stderr when commit-to-subrepo encounters files that don't match any configured sub-repo prefix. 3. Document that loadConfig auto-syncs sub_repos with the filesystem, so config.json may be rewritten when repos are added or removed. Co-Authored-By: Claude Opus 4.6 (1M context) --- get-shit-done/bin/gsd-tools.cjs | 15 +++++++++++---- get-shit-done/bin/lib/commands.cjs | 4 ++++ get-shit-done/references/git-integration.md | 2 +- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/get-shit-done/bin/gsd-tools.cjs b/get-shit-done/bin/gsd-tools.cjs index a7cd70377..4ee194ec1 100755 --- a/get-shit-done/bin/gsd-tools.cjs +++ b/get-shit-done/bin/gsd-tools.cjs @@ -181,10 +181,6 @@ async function main() { 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); @@ -195,6 +191,17 @@ async function main() { error('Usage: gsd-tools [args] [--raw] [--cwd ]\nCommands: state, resolve-model, find-phase, commit, verify-summary, verify, frontmatter, template, generate-slug, current-timestamp, list-todos, verify-path-exists, config-ensure-section, init'); } + // Multi-repo guard: resolve project root for commands that read/write .planning/. + // Skip for pure-utility commands that don't touch .planning/ to avoid unnecessary + // filesystem traversal on every invocation. + const SKIP_ROOT_RESOLUTION = new Set([ + 'generate-slug', 'current-timestamp', 'verify-path-exists', + 'verify-summary', 'template', 'frontmatter', + ]); + if (!SKIP_ROOT_RESOLUTION.has(command)) { + cwd = findProjectRoot(cwd); + } + switch (command) { case 'state': { const subcommand = args[1]; diff --git a/get-shit-done/bin/lib/commands.cjs b/get-shit-done/bin/lib/commands.cjs index 27461c393..f0d95a3a0 100644 --- a/get-shit-done/bin/lib/commands.cjs +++ b/get-shit-done/bin/lib/commands.cjs @@ -298,6 +298,10 @@ function cmdCommitToSubrepo(cwd, message, files, raw) { } } + if (unmatched.length > 0) { + process.stderr.write(`Warning: ${unmatched.length} file(s) did not match any sub-repo prefix: ${unmatched.join(', ')}\n`); + } + const repos = {}; for (const [repo, repoFiles] of Object.entries(grouped)) { const repoCwd = path.join(cwd, repo); diff --git a/get-shit-done/references/git-integration.md b/get-shit-done/references/git-integration.md index 2b6a9b733..ef530533d 100644 --- a/get-shit-done/references/git-integration.md +++ b/get-shit-done/references/git-integration.md @@ -274,7 +274,7 @@ Set `commit_docs: false` so planning docs stay local and are not committed to an ### 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. +1. **Auto-detection:** During `/gsd:new-project`, directories with their own `.git` folder are detected and offered for selection as sub-repos. On subsequent runs, `loadConfig` auto-syncs the `sub_repos` list with the filesystem — adding newly created repos and removing deleted ones. This means `config.json` may be rewritten automatically when repos change on disk. 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.