* 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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user