mirror of
https://github.com/glittercowboy/get-shit-done
synced 2026-04-25 17:25:23 +02:00
* 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>
542 lines
19 KiB
TypeScript
542 lines
19 KiB
TypeScript
/**
|
|
* Unit tests for shared query helpers.
|
|
*/
|
|
|
|
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 { GSDError } from '../errors.js';
|
|
import {
|
|
escapeRegex,
|
|
normalizePhaseName,
|
|
comparePhaseNum,
|
|
extractPhaseToken,
|
|
phaseTokenMatches,
|
|
toPosixPath,
|
|
stateExtractField,
|
|
planningPaths,
|
|
normalizeMd,
|
|
resolvePathUnderProject,
|
|
resolveAgentsDir,
|
|
getRuntimeConfigDir,
|
|
detectRuntime,
|
|
findProjectRoot,
|
|
SUPPORTED_RUNTIMES,
|
|
type Runtime,
|
|
} from './helpers.js';
|
|
import { homedir } from 'node:os';
|
|
|
|
// ─── escapeRegex ────────────────────────────────────────────────────────────
|
|
|
|
describe('escapeRegex', () => {
|
|
it('escapes dots', () => {
|
|
expect(escapeRegex('foo.bar')).toBe('foo\\.bar');
|
|
});
|
|
|
|
it('escapes brackets', () => {
|
|
expect(escapeRegex('test[0]')).toBe('test\\[0\\]');
|
|
});
|
|
|
|
it('escapes all regex special characters', () => {
|
|
expect(escapeRegex('a.*+?^${}()|[]\\')).toBe('a\\.\\*\\+\\?\\^\\$\\{\\}\\(\\)\\|\\[\\]\\\\');
|
|
});
|
|
|
|
it('returns plain strings unchanged', () => {
|
|
expect(escapeRegex('hello')).toBe('hello');
|
|
});
|
|
});
|
|
|
|
// ─── normalizePhaseName ─────────────────────────────────────────────────────
|
|
|
|
describe('normalizePhaseName', () => {
|
|
it('pads single digit to 2 digits', () => {
|
|
expect(normalizePhaseName('9')).toBe('09');
|
|
});
|
|
|
|
it('strips project code prefix', () => {
|
|
expect(normalizePhaseName('CK-01')).toBe('01');
|
|
});
|
|
|
|
it('preserves letter suffix', () => {
|
|
expect(normalizePhaseName('12A')).toBe('12A');
|
|
});
|
|
|
|
it('preserves decimal parts', () => {
|
|
expect(normalizePhaseName('12.1')).toBe('12.1');
|
|
});
|
|
|
|
it('strips project code and normalizes numeric part', () => {
|
|
// PROJ-42 -> strip PROJ- prefix -> 42 -> pad to 42
|
|
expect(normalizePhaseName('PROJ-42')).toBe('42');
|
|
});
|
|
|
|
it('handles already-padded numbers', () => {
|
|
expect(normalizePhaseName('01')).toBe('01');
|
|
});
|
|
});
|
|
|
|
// ─── comparePhaseNum ────────────────────────────────────────────────────────
|
|
|
|
describe('comparePhaseNum', () => {
|
|
it('compares numeric phases', () => {
|
|
expect(comparePhaseNum('01-foo', '02-bar')).toBeLessThan(0);
|
|
});
|
|
|
|
it('compares letter suffixes', () => {
|
|
expect(comparePhaseNum('12A-foo', '12B-bar')).toBeLessThan(0);
|
|
});
|
|
|
|
it('sorts no-decimal before decimal', () => {
|
|
expect(comparePhaseNum('12-foo', '12.1-bar')).toBeLessThan(0);
|
|
});
|
|
|
|
it('returns 0 for equal phases', () => {
|
|
expect(comparePhaseNum('01-name', '01-other')).toBe(0);
|
|
});
|
|
|
|
it('falls back to string comparison for custom IDs', () => {
|
|
const result = comparePhaseNum('AUTH-name', 'PROJ-name');
|
|
expect(typeof result).toBe('number');
|
|
});
|
|
});
|
|
|
|
// ─── extractPhaseToken ──────────────────────────────────────────────────────
|
|
|
|
describe('extractPhaseToken', () => {
|
|
it('extracts plain numeric token', () => {
|
|
expect(extractPhaseToken('01-foundation')).toBe('01');
|
|
});
|
|
|
|
it('extracts project-code-prefixed token', () => {
|
|
expect(extractPhaseToken('CK-01-name')).toBe('CK-01');
|
|
});
|
|
|
|
it('extracts letter suffix token', () => {
|
|
expect(extractPhaseToken('12A-name')).toBe('12A');
|
|
});
|
|
|
|
it('extracts decimal token', () => {
|
|
expect(extractPhaseToken('999.6-name')).toBe('999.6');
|
|
});
|
|
});
|
|
|
|
// ─── phaseTokenMatches ──────────────────────────────────────────────────────
|
|
|
|
describe('phaseTokenMatches', () => {
|
|
it('matches normalized numeric phase', () => {
|
|
expect(phaseTokenMatches('09-foundation', '09')).toBe(true);
|
|
});
|
|
|
|
it('matches after stripping project code', () => {
|
|
expect(phaseTokenMatches('CK-01-name', '01')).toBe(true);
|
|
});
|
|
|
|
it('does not match different phases', () => {
|
|
expect(phaseTokenMatches('09-foundation', '10')).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ─── toPosixPath ────────────────────────────────────────────────────────────
|
|
|
|
describe('toPosixPath', () => {
|
|
it('converts backslashes to forward slashes', () => {
|
|
expect(toPosixPath('a\\b\\c')).toBe('a/b/c');
|
|
});
|
|
|
|
it('preserves already-posix paths', () => {
|
|
expect(toPosixPath('a/b/c')).toBe('a/b/c');
|
|
});
|
|
});
|
|
|
|
// ─── stateExtractField ──────────────────────────────────────────────────────
|
|
|
|
describe('stateExtractField', () => {
|
|
it('extracts bold field value', () => {
|
|
const content = '**Phase:** 10\n**Plan:** 1';
|
|
expect(stateExtractField(content, 'Phase')).toBe('10');
|
|
});
|
|
|
|
it('extracts plain field value', () => {
|
|
const content = 'Status: executing\nPlan: 1';
|
|
expect(stateExtractField(content, 'Status')).toBe('executing');
|
|
});
|
|
|
|
it('returns null for missing field', () => {
|
|
expect(stateExtractField('no fields here', 'Missing')).toBeNull();
|
|
});
|
|
|
|
it('is case-insensitive', () => {
|
|
const content = '**phase:** 10';
|
|
expect(stateExtractField(content, 'Phase')).toBe('10');
|
|
});
|
|
|
|
it('does not treat YAML progress: block as body Progress field', () => {
|
|
const content = [
|
|
'---',
|
|
'progress:',
|
|
' total: 5',
|
|
' done: 2',
|
|
'---',
|
|
'',
|
|
'**Progress:** 40%',
|
|
].join('\n');
|
|
expect(stateExtractField(content, 'Progress')).toBe('40%');
|
|
});
|
|
});
|
|
|
|
// ─── planningPaths ──────────────────────────────────────────────────────────
|
|
|
|
describe('planningPaths', () => {
|
|
it('returns all expected keys', () => {
|
|
const paths = planningPaths('/proj');
|
|
expect(paths).toHaveProperty('planning');
|
|
expect(paths).toHaveProperty('state');
|
|
expect(paths).toHaveProperty('roadmap');
|
|
expect(paths).toHaveProperty('project');
|
|
expect(paths).toHaveProperty('config');
|
|
expect(paths).toHaveProperty('phases');
|
|
expect(paths).toHaveProperty('requirements');
|
|
});
|
|
|
|
it('uses posix paths', () => {
|
|
const paths = planningPaths('/proj');
|
|
expect(paths.state).toContain('.planning/STATE.md');
|
|
expect(paths.config).toContain('.planning/config.json');
|
|
});
|
|
});
|
|
|
|
// ─── normalizeMd ───────────────────────────────────────────────────────────
|
|
|
|
describe('normalizeMd', () => {
|
|
it('converts CRLF to LF', () => {
|
|
const result = normalizeMd('line1\r\nline2\r\n');
|
|
expect(result).not.toContain('\r');
|
|
expect(result).toContain('line1\nline2');
|
|
});
|
|
|
|
it('ensures terminal newline', () => {
|
|
const result = normalizeMd('no trailing newline');
|
|
expect(result).toMatch(/\n$/);
|
|
});
|
|
|
|
it('collapses 3+ consecutive blank lines to 2', () => {
|
|
const result = normalizeMd('a\n\n\n\nb');
|
|
// Should have at most 2 consecutive newlines (1 blank line between)
|
|
expect(result).not.toContain('\n\n\n');
|
|
});
|
|
|
|
it('preserves content inside code fences', () => {
|
|
const input = '```\n code with trailing spaces \n```\n';
|
|
const result = normalizeMd(input);
|
|
expect(result).toContain(' code with trailing spaces ');
|
|
});
|
|
|
|
it('adds blank line before headings when missing', () => {
|
|
const result = normalizeMd('some text\n# Heading\n');
|
|
expect(result).toContain('some text\n\n# Heading');
|
|
});
|
|
|
|
it('returns empty-ish content unchanged', () => {
|
|
expect(normalizeMd('')).toBe('');
|
|
expect(normalizeMd(null as unknown as string)).toBe(null);
|
|
});
|
|
|
|
it('handles normal markdown without changes', () => {
|
|
const input = '# Title\n\nSome text.\n\n## Section\n\nMore text.\n';
|
|
const result = normalizeMd(input);
|
|
expect(result).toBe(input);
|
|
});
|
|
});
|
|
|
|
// ─── resolvePathUnderProject ────────────────────────────────────────────────
|
|
|
|
describe('resolvePathUnderProject', () => {
|
|
let tmpDir: string;
|
|
|
|
beforeEach(async () => {
|
|
tmpDir = await mkdtemp(join(tmpdir(), 'gsd-path-'));
|
|
await writeFile(join(tmpDir, 'safe.md'), 'x', 'utf-8');
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await rm(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('resolves a relative file under the project root', async () => {
|
|
const p = await resolvePathUnderProject(tmpDir, 'safe.md');
|
|
expect(p.endsWith('safe.md')).toBe(true);
|
|
});
|
|
|
|
it('rejects paths that escape the project root', async () => {
|
|
await expect(resolvePathUnderProject(tmpDir, '../../etc/passwd')).rejects.toThrow(GSDError);
|
|
});
|
|
});
|
|
|
|
// ─── Runtime-aware agents dir resolution (#2402) ───────────────────────────
|
|
|
|
const RUNTIME_ENV_VARS = [
|
|
'GSD_AGENTS_DIR', 'GSD_RUNTIME', 'CLAUDE_CONFIG_DIR', 'OPENCODE_CONFIG_DIR',
|
|
'OPENCODE_CONFIG', 'KILO_CONFIG_DIR', 'KILO_CONFIG', 'XDG_CONFIG_HOME',
|
|
'GEMINI_CONFIG_DIR', 'CODEX_HOME', 'COPILOT_CONFIG_DIR', 'ANTIGRAVITY_CONFIG_DIR',
|
|
'CURSOR_CONFIG_DIR', 'WINDSURF_CONFIG_DIR', 'AUGMENT_CONFIG_DIR', 'TRAE_CONFIG_DIR',
|
|
'QWEN_CONFIG_DIR', 'CODEBUDDY_CONFIG_DIR', 'CLINE_CONFIG_DIR',
|
|
] as const;
|
|
|
|
describe('getRuntimeConfigDir', () => {
|
|
const saved: Record<string, string | undefined> = {};
|
|
beforeEach(() => {
|
|
for (const k of RUNTIME_ENV_VARS) { saved[k] = process.env[k]; delete process.env[k]; }
|
|
});
|
|
afterEach(() => {
|
|
for (const k of RUNTIME_ENV_VARS) {
|
|
if (saved[k] === undefined) delete process.env[k];
|
|
else process.env[k] = saved[k];
|
|
}
|
|
});
|
|
|
|
const defaults: Record<Runtime, string> = {
|
|
claude: join(homedir(), '.claude'),
|
|
opencode: join(homedir(), '.config', 'opencode'),
|
|
kilo: join(homedir(), '.config', 'kilo'),
|
|
gemini: join(homedir(), '.gemini'),
|
|
codex: join(homedir(), '.codex'),
|
|
copilot: join(homedir(), '.copilot'),
|
|
antigravity: join(homedir(), '.gemini', 'antigravity'),
|
|
cursor: join(homedir(), '.cursor'),
|
|
windsurf: join(homedir(), '.codeium', 'windsurf'),
|
|
augment: join(homedir(), '.augment'),
|
|
trae: join(homedir(), '.trae'),
|
|
qwen: join(homedir(), '.qwen'),
|
|
codebuddy: join(homedir(), '.codebuddy'),
|
|
cline: join(homedir(), '.cline'),
|
|
};
|
|
|
|
for (const runtime of SUPPORTED_RUNTIMES) {
|
|
it(`resolves default path for ${runtime}`, () => {
|
|
expect(getRuntimeConfigDir(runtime)).toBe(defaults[runtime]);
|
|
});
|
|
}
|
|
|
|
const envOverrides: Array<[Runtime, string, string]> = [
|
|
['claude', 'CLAUDE_CONFIG_DIR', '/x/claude'],
|
|
['gemini', 'GEMINI_CONFIG_DIR', '/x/gemini'],
|
|
['codex', 'CODEX_HOME', '/x/codex'],
|
|
['copilot', 'COPILOT_CONFIG_DIR', '/x/copilot'],
|
|
['antigravity', 'ANTIGRAVITY_CONFIG_DIR', '/x/antigravity'],
|
|
['cursor', 'CURSOR_CONFIG_DIR', '/x/cursor'],
|
|
['windsurf', 'WINDSURF_CONFIG_DIR', '/x/windsurf'],
|
|
['augment', 'AUGMENT_CONFIG_DIR', '/x/augment'],
|
|
['trae', 'TRAE_CONFIG_DIR', '/x/trae'],
|
|
['qwen', 'QWEN_CONFIG_DIR', '/x/qwen'],
|
|
['codebuddy', 'CODEBUDDY_CONFIG_DIR', '/x/codebuddy'],
|
|
['cline', 'CLINE_CONFIG_DIR', '/x/cline'],
|
|
['opencode', 'OPENCODE_CONFIG_DIR', '/x/opencode'],
|
|
['kilo', 'KILO_CONFIG_DIR', '/x/kilo'],
|
|
];
|
|
for (const [runtime, envVar, value] of envOverrides) {
|
|
it(`${runtime} honors ${envVar}`, () => {
|
|
process.env[envVar] = value;
|
|
expect(getRuntimeConfigDir(runtime)).toBe(value);
|
|
});
|
|
}
|
|
|
|
it('opencode uses XDG_CONFIG_HOME when direct vars unset', () => {
|
|
process.env.XDG_CONFIG_HOME = '/xdg';
|
|
expect(getRuntimeConfigDir('opencode')).toBe(join('/xdg', 'opencode'));
|
|
});
|
|
|
|
it('opencode OPENCODE_CONFIG uses dirname', () => {
|
|
process.env.OPENCODE_CONFIG = '/cfg/opencode.json';
|
|
expect(getRuntimeConfigDir('opencode')).toBe('/cfg');
|
|
});
|
|
|
|
it('kilo uses XDG_CONFIG_HOME when direct vars unset', () => {
|
|
process.env.XDG_CONFIG_HOME = '/xdg';
|
|
expect(getRuntimeConfigDir('kilo')).toBe(join('/xdg', 'kilo'));
|
|
});
|
|
});
|
|
|
|
describe('detectRuntime', () => {
|
|
const saved: Record<string, string | undefined> = {};
|
|
beforeEach(() => {
|
|
for (const k of RUNTIME_ENV_VARS) { saved[k] = process.env[k]; delete process.env[k]; }
|
|
});
|
|
afterEach(() => {
|
|
for (const k of RUNTIME_ENV_VARS) {
|
|
if (saved[k] === undefined) delete process.env[k];
|
|
else process.env[k] = saved[k];
|
|
}
|
|
});
|
|
|
|
it('defaults to claude with no signals', () => {
|
|
expect(detectRuntime()).toBe('claude');
|
|
});
|
|
|
|
it('uses GSD_RUNTIME when set to a known runtime', () => {
|
|
process.env.GSD_RUNTIME = 'codex';
|
|
expect(detectRuntime()).toBe('codex');
|
|
});
|
|
|
|
it('falls back to config.runtime when GSD_RUNTIME unset', () => {
|
|
expect(detectRuntime({ runtime: 'gemini' })).toBe('gemini');
|
|
});
|
|
|
|
it('GSD_RUNTIME wins over config.runtime', () => {
|
|
process.env.GSD_RUNTIME = 'codex';
|
|
expect(detectRuntime({ runtime: 'gemini' })).toBe('codex');
|
|
});
|
|
|
|
it('unknown GSD_RUNTIME falls through to config then claude', () => {
|
|
process.env.GSD_RUNTIME = 'bogus';
|
|
expect(detectRuntime({ runtime: 'gemini' })).toBe('gemini');
|
|
expect(detectRuntime()).toBe('claude');
|
|
});
|
|
|
|
it('unknown config.runtime falls through to claude', () => {
|
|
expect(detectRuntime({ runtime: 'bogus' })).toBe('claude');
|
|
});
|
|
});
|
|
|
|
describe('resolveAgentsDir (runtime-aware)', () => {
|
|
const saved: Record<string, string | undefined> = {};
|
|
beforeEach(() => {
|
|
for (const k of RUNTIME_ENV_VARS) { saved[k] = process.env[k]; delete process.env[k]; }
|
|
});
|
|
afterEach(() => {
|
|
for (const k of RUNTIME_ENV_VARS) {
|
|
if (saved[k] === undefined) delete process.env[k];
|
|
else process.env[k] = saved[k];
|
|
}
|
|
});
|
|
|
|
it('defaults to Claude agents dir with no args', () => {
|
|
expect(resolveAgentsDir()).toBe(join(homedir(), '.claude', 'agents'));
|
|
});
|
|
|
|
it('GSD_AGENTS_DIR short-circuits regardless of runtime', () => {
|
|
process.env.GSD_AGENTS_DIR = '/explicit/agents';
|
|
expect(resolveAgentsDir('codex')).toBe('/explicit/agents');
|
|
expect(resolveAgentsDir('claude')).toBe('/explicit/agents');
|
|
});
|
|
|
|
it('appends /agents to the per-runtime config dir', () => {
|
|
process.env.CODEX_HOME = '/codex';
|
|
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);
|
|
});
|
|
});
|