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

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