fix(#2623): resolve parent .planning root for sub_repos workspaces in SDK query dispatch (#2629)

* fix(#2623): resolve parent .planning root for sub_repos workspaces in SDK query dispatch

When `gsd-sdk query` is invoked from inside a `sub_repos`-listed child repo,
`projectDir` defaulted to `process.cwd()` which pointed at the child repo,
not the parent workspace that owns `.planning/`. Handlers then directly
checked `${projectDir}/.planning` and reported `project_exists: false`.

The legacy `gsd-tools.cjs` CLI does not have this gap — it calls
`findProjectRoot(cwd)` from `bin/lib/core.cjs`, which walks up from the
starting directory checking each ancestor's `.planning/config.json` for a
`sub_repos` entry that lists the starting directory's top-level segment.

This change ports that walk-up as a new `findProjectRoot` helper in
`sdk/src/query/helpers.ts` and applies it once in `cli.ts:main()` before
dispatching `query`, `run`, `init`, or `auto`. Resolution is idempotent:
if `projectDir` already owns `.planning/` (including an explicit
`--project-dir` pointing at the workspace root), the helper returns it
unchanged. The walk is capped at 10 parent levels and never crosses
`$HOME`. All filesystem errors are swallowed.

Regression coverage:
- `helpers.test.ts` — 8 unit tests covering own-`.planning` guard (#1362),
  sub_repos match, nested-path match, `planning.sub_repos` shape,
  heuristic fallback, unparseable config, legacy `multiRepo: true`.
- `sub-repos-root.integration.test.ts` — end-to-end baseline (reproduces
  the bug without the walk-up) and fixed behavior (walk-up + dispatch of
  `init.new-milestone` reports `project_exists: true` with the parent
  workspace as `project_root`).

sdk vitest: 1511 pass / 24 fail (all 24 failures pre-existing on main,
baseline is 26 failing — `comm -23` against baseline produces zero new
failures). CJS: 5410 pass / 0 fail.

Closes #2623

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(#2623): remove stray .planing typo from integration test setup

Address CodeRabbit nitpick: the mkdir('.planing') call on line 23 was
dead code from a typo, with errors silently swallowed via .catch(() => {}).
The test already creates '.planning' correctly on the next line.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-04-23 11:58:23 -04:00
committed by GitHub
parent 5a8a6fb511
commit eba0c99698
6 changed files with 352 additions and 2 deletions

View File

@@ -28,6 +28,7 @@ If you use GSD **as a workflow**—milestones, phases, `.planning/` artifacts, b
### Fixed
- **`gsd-sdk query` now resolves parent `.planning/` root in multi-repo (`sub_repos`) workspaces** — when invoked from inside a `sub_repos`-listed child repo (e.g. `workspace/app/`), the SDK now walks up to the parent workspace that owns `.planning/`, matching the legacy `gsd-tools.cjs` `findProjectRoot` behavior. Previously `gsd-sdk query init.new-milestone` reported `project_exists: false` from the sub-repo, while `gsd-tools.cjs` resolved the parent root correctly. Resolution happens once in `cli.ts` before dispatch; if `projectDir` already owns `.planning/` (including explicit `--project-dir`), the walk is a no-op. Ported as `findProjectRoot` in `sdk/src/query/helpers.ts` with the same detection order (own `.planning/` wins, then parent `sub_repos` match, then legacy `multiRepo: true`, then `.git` heuristic), capped at 10 parent levels and never crossing `$HOME`. Closes #2623.
- **Shell hooks falsely flagged as stale on every session** — `gsd-phase-boundary.sh`, `gsd-session-state.sh`, and `gsd-validate-commit.sh` now ship with a `# gsd-hook-version: {{GSD_VERSION}}` header; the installer substitutes `{{GSD_VERSION}}` in `.sh` hooks the same way it does for `.js` hooks; and the stale-hook detector in `gsd-check-update.js` now matches bash `#` comment syntax in addition to JS `//` syntax. All three changes are required together — neither the regex fix alone nor the install fix alone is sufficient to resolve the false positive (#2136, #2206, #2209, #2210, #2212)
## [1.38.2] - 2026-04-19

View File

@@ -227,6 +227,17 @@ All workflow toggles follow the **absent = enabled** pattern. If a key is missin
| `planning.search_gitignored` | boolean | `false` | Add `--no-ignore` to broad searches to include `.planning/` |
| `planning.sub_repos` | array of strings | `[]` | Paths of nested sub-repos relative to the project root. When set, GSD-aware tooling scopes phase-lookup, path-resolution, and commit operations per sub-repo instead of treating the outer repo as a monorepo |
### Project-Root Resolution in Multi-Repo Workspaces
When `sub_repos` is set and `gsd-tools.cjs` or `gsd-sdk query` is invoked from inside a listed child repo, both CLIs walk up to the parent workspace that owns `.planning/` before dispatching handlers. Resolution order (checked at each ancestor up to 10 levels, never above `$HOME`):
1. If the starting directory already has its own `.planning/`, it is the project root (no walk-up).
2. Parent has `.planning/config.json` listing the starting directory's top-level segment in `sub_repos` (or the legacy `planning.sub_repos` shape).
3. Parent has `.planning/config.json` with legacy `multiRepo: true` and the starting directory is inside a git repo.
4. Parent has `.planning/` and an ancestor up to the candidate parent contains `.git` (heuristic fallback).
If none match, the starting directory is returned unchanged. Explicit `--project-dir /path/to/workspace` is idempotent under this resolution.
### Auto-Detection
If `.planning/` is in `.gitignore`, `commit_docs` is automatically `false` regardless of config.json. This prevents git errors.

View File

@@ -341,6 +341,21 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise<void
return;
}
// Multi-repo project-root resolution (issue #2623).
//
// When the user launches `gsd-sdk` from inside a `sub_repos`-listed child repo,
// `projectDir` defaults to `process.cwd()` which points at the child, not the
// parent workspace that owns `.planning/`. Mirror the legacy `gsd-tools.cjs`
// walk-up semantics so handlers see the correct project root.
//
// Idempotent: if `projectDir` already has its own `.planning/` (including an
// explicit `--project-dir` pointing at the workspace root), findProjectRoot
// returns it unchanged.
{
const { findProjectRoot } = await import('./query/helpers.js');
args = { ...args, projectDir: findProjectRoot(args.projectDir) };
}
// ─── Query command ──────────────────────────────────────────────────────
if (args.command === 'query') {
const { createRegistry } = await import('./query/index.js');

View File

@@ -3,7 +3,7 @@
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { mkdtemp, rm, writeFile, mkdir } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { GSDError } from '../errors.js';
@@ -21,6 +21,7 @@ import {
resolveAgentsDir,
getRuntimeConfigDir,
detectRuntime,
findProjectRoot,
SUPPORTED_RUNTIMES,
type Runtime,
} from './helpers.js';
@@ -424,3 +425,117 @@ describe('resolveAgentsDir (runtime-aware)', () => {
expect(resolveAgentsDir('codex')).toBe(join('/codex', 'agents'));
});
});
// ─── findProjectRoot (issue #2623) ─────────────────────────────────────────
describe('findProjectRoot (multi-repo .planning resolution)', () => {
let workspace: string;
beforeEach(async () => {
workspace = await mkdtemp(join(tmpdir(), 'gsd-find-root-'));
});
afterEach(async () => {
await rm(workspace, { recursive: true, force: true });
});
it('returns startDir unchanged when startDir has its own .planning/', async () => {
await mkdir(join(workspace, '.planning'), { recursive: true });
expect(findProjectRoot(workspace)).toBe(workspace);
});
it('returns startDir unchanged when no ancestor has .planning/', () => {
expect(findProjectRoot(workspace)).toBe(workspace);
});
it('walks up to parent .planning/ when config lists the child in sub_repos (#2623)', async () => {
// workspace/.planning/{config.json, PROJECT.md}
// workspace/app/.git/
await mkdir(join(workspace, '.planning'), { recursive: true });
await writeFile(
join(workspace, '.planning', 'config.json'),
JSON.stringify({ sub_repos: ['app'] }),
'utf-8',
);
const app = join(workspace, 'app');
await mkdir(join(app, '.git'), { recursive: true });
expect(findProjectRoot(app)).toBe(workspace);
});
it('resolves parent root from deeply nested dir inside a sub_repo', async () => {
await mkdir(join(workspace, '.planning'), { recursive: true });
await writeFile(
join(workspace, '.planning', 'config.json'),
JSON.stringify({ sub_repos: ['app'] }),
'utf-8',
);
const nested = join(workspace, 'app', 'src', 'modules');
await mkdir(join(workspace, 'app', '.git'), { recursive: true });
await mkdir(nested, { recursive: true });
expect(findProjectRoot(nested)).toBe(workspace);
});
it('supports planning.sub_repos nested config shape', async () => {
await mkdir(join(workspace, '.planning'), { recursive: true });
await writeFile(
join(workspace, '.planning', 'config.json'),
JSON.stringify({ planning: { sub_repos: ['app'] } }),
'utf-8',
);
const app = join(workspace, 'app');
await mkdir(join(app, '.git'), { recursive: true });
expect(findProjectRoot(app)).toBe(workspace);
});
it('falls back to .git heuristic when parent has .planning/ but no matching sub_repos', async () => {
await mkdir(join(workspace, '.planning'), { recursive: true });
// Config doesn't list the child, but child has .git and parent has .planning/.
await writeFile(
join(workspace, '.planning', 'config.json'),
JSON.stringify({ sub_repos: [] }),
'utf-8',
);
const app = join(workspace, 'app');
await mkdir(join(app, '.git'), { recursive: true });
expect(findProjectRoot(app)).toBe(workspace);
});
it('swallows unparseable config.json and falls back to .git heuristic', async () => {
await mkdir(join(workspace, '.planning'), { recursive: true });
await writeFile(join(workspace, '.planning', 'config.json'), '{ not json', 'utf-8');
const app = join(workspace, 'app');
await mkdir(join(app, '.git'), { recursive: true });
expect(findProjectRoot(app)).toBe(workspace);
});
it('supports legacy multiRepo: true when child is inside a git repo', async () => {
await mkdir(join(workspace, '.planning'), { recursive: true });
await writeFile(
join(workspace, '.planning', 'config.json'),
JSON.stringify({ multiRepo: true }),
'utf-8',
);
const app = join(workspace, 'app');
await mkdir(join(app, '.git'), { recursive: true });
expect(findProjectRoot(app)).toBe(workspace);
});
it('does not walk up when child has its own .planning/ (#1362 guard)', async () => {
await mkdir(join(workspace, '.planning'), { recursive: true });
await writeFile(
join(workspace, '.planning', 'config.json'),
JSON.stringify({ sub_repos: ['app'] }),
'utf-8',
);
const app = join(workspace, 'app');
await mkdir(join(app, '.planning'), { recursive: true });
expect(findProjectRoot(app)).toBe(app);
});
});

View File

@@ -17,8 +17,9 @@
* ```
*/
import { join, dirname, relative, resolve, isAbsolute, normalize } from 'node:path';
import { join, dirname, relative, resolve, isAbsolute, normalize, parse as parsePath, sep as pathSep } from 'node:path';
import { realpath } from 'node:fs/promises';
import { existsSync, statSync, readFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { GSDError, ErrorClassification } from '../errors.js';
import { relPlanningPath } from '../workstream-utils.js';
@@ -428,6 +429,134 @@ export function planningPaths(projectDir: string, workstream?: string): Planning
};
}
// ─── findProjectRoot (multi-repo .planning resolution) ─────────────────────
/**
* Maximum number of parent directories to walk when searching for a
* multi-repo `.planning/` root. Bounded to avoid scanning to the filesystem
* root in pathological cases.
*/
const FIND_PROJECT_ROOT_MAX_DEPTH = 10;
/**
* Walk up from `startDir` to find the project root that owns `.planning/`.
*
* Ported from `get-shit-done/bin/lib/core.cjs:findProjectRoot` so that
* `gsd-sdk query` resolves the same parent `.planning/` root as the legacy
* `gsd-tools.cjs` CLI when invoked inside a `sub_repos`-listed child repo.
*
* Detection strategy (checked in order for each ancestor, up to
* `FIND_PROJECT_ROOT_MAX_DEPTH` levels):
* 1. `startDir` itself has `.planning/` — return it unchanged (#1362).
* 2. Parent has `.planning/config.json` with `sub_repos` listing the
* immediate child segment of the starting directory.
* 3. Parent has `.planning/config.json` with `multiRepo: true` (legacy).
* 4. Parent has `.planning/` AND an ancestor of `startDir` (up to the
* candidate parent) contains `.git` — heuristic fallback.
*
* Returns `startDir` unchanged when no ancestor `.planning/` is found
* (first-run or single-repo projects). Never walks above the user's home
* directory.
*
* All filesystem errors are swallowed — a missing or unparseable
* `config.json` falls back to the `.git` heuristic, and unreadable
* directories terminate the walk at that level.
*/
export function findProjectRoot(startDir: string): string {
let resolvedStart: string;
try {
resolvedStart = resolve(startDir);
} catch {
return startDir;
}
const fsRoot = parsePath(resolvedStart).root;
const home = homedir();
// If startDir already contains .planning/, it IS the project root.
try {
const ownPlanning = join(resolvedStart, '.planning');
if (existsSync(ownPlanning) && statSync(ownPlanning).isDirectory()) {
return startDir;
}
} catch {
// fall through
}
// Walk upward, mirroring isInsideGitRepo from the CJS reference.
function isInsideGitRepo(candidateParent: string): boolean {
let d = resolvedStart;
while (d !== fsRoot) {
try {
if (existsSync(join(d, '.git'))) return true;
} catch {
// ignore
}
if (d === candidateParent) break;
const next = dirname(d);
if (next === d) break;
d = next;
}
return false;
}
let dir = resolvedStart;
let depth = 0;
while (dir !== fsRoot && depth < FIND_PROJECT_ROOT_MAX_DEPTH) {
const parent = dirname(dir);
if (parent === dir) break;
if (parent === home) break;
const parentPlanning = join(parent, '.planning');
let parentPlanningIsDir = false;
try {
parentPlanningIsDir = existsSync(parentPlanning) && statSync(parentPlanning).isDirectory();
} catch {
parentPlanningIsDir = false;
}
if (parentPlanningIsDir) {
const configPath = join(parentPlanning, 'config.json');
let matched = false;
try {
const raw = readFileSync(configPath, 'utf-8');
const config = JSON.parse(raw) as {
sub_repos?: unknown;
planning?: { sub_repos?: unknown };
multiRepo?: unknown;
};
const subReposValue =
(config.sub_repos as unknown) ?? (config.planning && config.planning.sub_repos);
const subRepos = Array.isArray(subReposValue) ? (subReposValue as unknown[]) : [];
if (subRepos.length > 0) {
const relPath = relative(parent, resolvedStart);
const topSegment = relPath.split(pathSep)[0];
if (subRepos.includes(topSegment)) {
return parent;
}
}
if (config.multiRepo === true && isInsideGitRepo(parent)) {
matched = true;
}
} catch {
// config.json missing or unparseable — fall through to .git heuristic.
}
if (matched) return parent;
// Heuristic: parent has .planning/ and we're inside a git repo.
if (isInsideGitRepo(parent)) {
return parent;
}
}
dir = parent;
depth += 1;
}
return startDir;
}
// ─── resolvePathUnderProject ───────────────────────────────────────────────
/**

View File

@@ -0,0 +1,79 @@
/**
* Regression: issue #2623 — `gsd-sdk query` must resolve the parent
* `.planning/` root when invoked from a `sub_repos`-listed child repo.
*
* Exercises the end-to-end path: findProjectRoot(startDir) -> registry dispatch
* of `init.new-milestone`, and asserts the handler reports the parent workspace
* as `project_root` with `project_exists: true`.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtemp, rm, writeFile, mkdir } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { findProjectRoot } from './helpers.js';
import { createRegistry } from './index.js';
describe('issue #2623 — sub_repos project-root resolution through query dispatch', () => {
let workspace: string;
let appDir: string;
beforeEach(async () => {
workspace = await mkdtemp(join(tmpdir(), 'gsd-2623-'));
await mkdir(join(workspace, '.planning'), { recursive: true });
await mkdir(join(workspace, '.planning', 'phases'), { recursive: true });
await writeFile(
join(workspace, '.planning', 'config.json'),
JSON.stringify({ sub_repos: ['app'] }),
'utf-8',
);
await writeFile(join(workspace, '.planning', 'PROJECT.md'), '# Project\n', 'utf-8');
await writeFile(
join(workspace, '.planning', 'ROADMAP.md'),
'# Roadmap\n\n## Milestone v1.0.0 — Bootstrap\n',
'utf-8',
);
await writeFile(
join(workspace, '.planning', 'STATE.md'),
'---\ncurrent_phase: 01-bootstrap\n---\n',
'utf-8',
);
appDir = join(workspace, 'app');
await mkdir(join(appDir, '.git'), { recursive: true });
});
afterEach(async () => {
await rm(workspace, { recursive: true, force: true });
});
it('findProjectRoot(app) resolves to the parent workspace that owns .planning/', () => {
expect(findProjectRoot(appDir)).toBe(workspace);
});
it('init.new-milestone dispatched with resolved root reports project_exists:true', async () => {
// Simulate the CLI path: user starts inside the sub_repo.
const resolved = findProjectRoot(appDir);
expect(resolved).toBe(workspace);
const registry = createRegistry();
const result = await registry.dispatch('init.new-milestone', [], resolved, undefined);
const data = result.data as Record<string, unknown>;
expect(data.project_exists).toBe(true);
expect(data.roadmap_exists).toBe(true);
expect(data.state_exists).toBe(true);
expect(data.project_root).toBe(workspace);
});
it('without findProjectRoot walk-up, the same handler reports project_exists:false (baseline)', async () => {
// Proves the walk-up is load-bearing — invoking from the child directly
// reproduces the bug described in #2623.
const registry = createRegistry();
const result = await registry.dispatch('init.new-milestone', [], appDir, undefined);
const data = result.data as Record<string, unknown>;
expect(data.project_exists).toBe(false);
expect(data.project_root).toBe(appDir);
});
});