Merge pull request #1213 from skoduri-epic/feat/multi-repo-workspace-v2

This commit is contained in:
Tom Boucher
2026-03-19 17:26:18 -04:00
committed by GitHub
11 changed files with 686 additions and 18 deletions

View File

@@ -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}
@@ -336,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.
</task_commit_protocol>

View File

@@ -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,7 +178,6 @@ 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;
}
@@ -191,6 +191,17 @@ async function main() {
error('Usage: gsd-tools <command> [args] [--raw] [--cwd <path>]\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];
@@ -314,6 +325,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');

View File

@@ -269,6 +269,74 @@ 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);
}
}
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);
// 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 +899,7 @@ module.exports = {
cmdHistoryDigest,
cmdResolveModel,
cmdCommit,
cmdCommitToSubrepo,
cmdSummaryExtract,
cmdWebsearch,
cmdProgressRender,

View File

@@ -14,6 +14,102 @@ 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();
// 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) {
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 && isInsideGitRepo(parent)) {
return parent;
}
} catch {
// config.json missing or malformed — fall back to .git heuristic
}
// Heuristic: parent has .planning/ and we're inside a git repo
if (isInsideGitRepo(parent)) {
return parent;
}
}
dir = parent;
}
return startDir;
}
// ─── Output helpers ───────────────────────────────────────────────────────────
function output(result, raw, rawValue) {
@@ -66,6 +162,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 +180,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 +243,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 +991,8 @@ module.exports = {
extractOneLinerFromBody,
resolveWorktreeRoot,
withPlanningLock,
findProjectRoot,
detectSubRepos,
MODEL_ALIAS_MAP,
planningDir,
planningPaths,

View File

@@ -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');
@@ -47,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,
@@ -95,7 +106,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 +185,7 @@ function cmdInitPlanPhase(cwd, phase, raw) {
} catch { /* intentionally empty */ }
}
output(result, raw);
output(withProjectRoot(cwd, result), raw);
}
function cmdInitNewProject(cwd, raw) {
@@ -242,7 +253,7 @@ function cmdInitNewProject(cwd, raw) {
project_path: '.planning/PROJECT.md',
};
output(result, raw);
output(withProjectRoot(cwd, result), raw);
}
function cmdInitNewMilestone(cwd, raw) {
@@ -289,7 +300,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 +358,7 @@ function cmdInitQuick(cwd, description, raw) {
};
output(result, raw);
output(withProjectRoot(cwd, result), raw);
}
function cmdInitResume(cwd, raw) {
@@ -379,7 +390,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 +419,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 +523,7 @@ function cmdInitPhaseOp(cwd, phase, raw) {
} catch { /* intentionally empty */ }
}
output(result, raw);
output(withProjectRoot(cwd, result), raw);
}
function cmdInitTodos(cwd, area, raw) {
@@ -571,7 +582,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 +643,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 +677,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 +824,7 @@ function cmdInitProgress(cwd, raw) {
config_path: '.planning/config.json',
};
output(result, raw);
output(withProjectRoot(cwd, result), raw);
}
module.exports = {

View File

@@ -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. 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.
### 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>

View File

@@ -10,7 +10,8 @@
},
"planning": {
"commit_docs": true,
"search_gitignored": false
"search_gitignored": false,
"sub_repos": []
},
"parallelization": {
"enabled": true,

View File

@@ -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)

View File

@@ -528,6 +528,36 @@ 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 1 -type d -not -name ".*" -not -name "node_modules" -exec test -d "{}/.git" \; -print
```
**If sub-repos found:**
Strip the `./` prefix to get directory names (e.g., `./backend` → `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
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.
## 5.5. Resolve Model Profile
Use models from init: `researcher_model`, `synthesizer_model`, `roadmapper_model`.

View File

@@ -27,6 +27,8 @@ const {
getRoadmapPhaseInternal,
searchPhaseInDir,
findPhaseInternal,
findProjectRoot,
detectSubRepos,
} = require('../get-shit-done/bin/lib/core.cjs');
// ─── loadConfig ────────────────────────────────────────────────────────────────
@@ -975,3 +977,275 @@ 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('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 });
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);
});
});

View File

@@ -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
// ─────────────────────────────────────────────────────────────────────────────