fix(#2843): findProjectRoot stops at git-repo boundaries, not just MAX_DEPTH (#2909)

* fix(#2843): findProjectRoot stops at git-repo boundaries, not just MAX_DEPTH

findProjectRoot's heuristic (3) used isInsideGitRepo(parent), which only checked
'does SOME .git exist between start and the ancestor' — it never verified the
.git was co-located with / bounded the trusted .planning/. A nested child repo
(own .git, no .planning) under an ancestor GSD project satisfied the check, so
resolution silently crossed into the ancestor project (wrong identity, exit 0).

Add nearestGitRoot(from, upTo) (fs-walk, no spawn) and use it in heuristics (3)
and (4): if the caller is inside its own nested repo whose root is strictly below
the candidate ancestor, do not return that ancestor. The plain-descendant (#1414),
co-located .git+.planning, and sub_repos/multiRepo cases are unchanged.

Regression test: a nested child .git under an ancestor .planning no longer
resolves to the ancestor; the co-located single-repo case still resolves.

* chore(#2843): backfill changeset PR 2909

---------

Co-authored-by: Test <test@example.com>
(cherry picked from commit 39dbe5e0f5)
This commit is contained in:
Tom Boucher
2026-07-31 02:30:50 -04:00
committed by sim
parent 3aabb0f441
commit e1b275766d
3 changed files with 90 additions and 0 deletions

View File

@@ -275,4 +275,44 @@ describe('findProjectRoot nearest-.planning resolution (#1414)', () => {
assert.strictEqual(result, nested,
'Should return startDir unchanged when no ancestor has .planning/ within the depth bound');
});
// #2843: findProjectRoot must NOT cross a git-repo boundary. A nested child
// repo (own .git, no .planning of its own) under an ancestor GSD project must
// NOT resolve to the ancestor's root — that would silently return a different
// project's identity with exit 0. Pre-fix heuristic (3)'s isInsideGitRepo only
// checked "does SOME .git exist between start and the ancestor" — the child's
// own .git satisfied it, crossing the boundary.
test('#2843 does not resolve to an ancestor project across a nested child .git boundary', () => {
// tmpDir/ (plain — not a git repo, not HOME)
// parent-gsd/.planning/ ← ancestor GSD project
// parent-gsd/child-app/.git ← nested child repo, NO .planning of its own
// parent-gsd/child-app/src/ ← startDir
const parentGsd = mkDeep(tmpDir, 'parent-gsd');
fs.mkdirSync(path.join(parentGsd, '.planning'), { recursive: true });
const childApp = mkDeep(parentGsd, 'child-app');
fs.mkdirSync(path.join(childApp, '.git'), { recursive: true });
const startDir = mkDeep(childApp, 'src');
const result = findProjectRoot(startDir);
assert.notStrictEqual(result, parentGsd,
'must NOT cross child-app\'s .git boundary to resolve to the ancestor parent-gsd project');
// The child repo has no .planning of its own, so resolution falls back to
// the startDir (or a path within child-app) — never the ancestor.
assert.ok(
result === startDir || result === childApp,
`expected to stay within the child repo (startDir or childApp fallback), got: ${result}`,
);
});
test('#2843 negative-space: a co-located .git + .planning (normal single-repo) still resolves to the project root', () => {
// The normal case: .git and .planning at the SAME level. The caller's .git
// IS the project's .git, so the boundary check passes (no nested child repo).
fs.mkdirSync(path.join(tmpDir, '.git'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true });
const nested = mkDeep(tmpDir, 'src', 'lib');
const result = findProjectRoot(nested);
assert.strictEqual(result, tmpDir,
'a co-located .git + .planning (single-repo project) must still resolve to the project root');
});
});