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:
@@ -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}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
* resolve-model <agent-type> Get model for agent based on profile
|
||||
* find-phase <phase> Find phase directory by number
|
||||
* commit <message> [--files f1 f2] [--no-verify] Commit planning docs
|
||||
* commit-to-subrepo <msg> --files f1 f2 Route commits to sub-repos
|
||||
* verify-summary <path> Verify a SUMMARY.md file
|
||||
* generate-slug <text> 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');
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -250,3 +250,46 @@ Each plan produces 2-4 commits (tasks + metadata). Clear, granular, bisectable.
|
||||
- "Commit noise" irrelevant when consumer is Claude, not humans
|
||||
|
||||
</commit_strategy_rationale>
|
||||
|
||||
<sub_repos_support>
|
||||
|
||||
## 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.
|
||||
|
||||
</sub_repos_support>
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
},
|
||||
"planning": {
|
||||
"commit_docs": true,
|
||||
"search_gitignored": false
|
||||
"search_gitignored": false,
|
||||
"sub_repos": []
|
||||
},
|
||||
"parallelization": {
|
||||
"enabled": true,
|
||||
|
||||
@@ -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.
|
||||
</step>
|
||||
@@ -276,6 +276,20 @@ git add src/types/user.ts
|
||||
|
||||
**4. Format:** `{type}({phase}-{plan}): {description}` with bullet points for key changes.
|
||||
|
||||
<sub_repos_commit_flow>
|
||||
**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.
|
||||
</sub_repos_commit_flow>
|
||||
|
||||
**5. Record hash:**
|
||||
```bash
|
||||
TASK_COMMIT=$(git rev-parse --short HEAD)
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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