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

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 2909
---
**`findProjectRoot` no longer silently resolves to a parent project across a git-repo boundary** — when invoked from a nested git repository that has no `.planning/` of its own, resolution stays within the caller's repo (or falls back to the start directory) instead of crossing into an ancestor GSD project. The existing plain-descendant and co-located `.git`+`.planning` cases are unchanged.

View File

@@ -57,6 +57,30 @@ export function findProjectRoot(startDir: string): string {
return false;
}
// #2843: nearest ancestor (including `from` itself) that contains a `.git`,
// bounded by `upTo` (exclusive). Returns the git-repo root, or null if none
// exists before `upTo` / the filesystem root. Used to detect a NESTED child
// repo whose root is strictly below a candidate ancestor `.planning/` — in
// that case the caller's repo boundary sits between start and the ancestor,
// so trusting the ancestor's `.planning/` would silently cross into a
// different project. (No `git` subprocess — fs walk only, matching
// isInsideGitRepo's deliberate no-spawn contract.)
function nearestGitRoot(from: string, upTo: string): string | null {
let d = from;
while (d !== fsRoot) {
if (d === upTo) break;
try {
if (fs.existsSync(d + path.sep + '.git')) return d;
} catch {
// ignore
}
const next = path.dirname(d);
if (next === d) break;
d = next;
}
return null;
}
let dir = resolvedStart;
let depth = 0;
@@ -107,6 +131,18 @@ export function findProjectRoot(startDir: string): string {
// claims our startDir — explicit sub_repos config takes precedence over the
// implicit .git signal. (#1422)
if (isInsideGitRepo(parent)) {
// #2843: do NOT cross a git-repo boundary. If the caller is inside its
// OWN nested repo whose root is strictly below `parent`, trusting
// `parent`'s .planning/ would silently resolve to a DIFFERENT project.
// isInsideGitRepo only proved SOME .git exists between start and parent;
// verify that .git is parent's own (or absent between), not a nested
// child repo. If nearestGitRoot finds a .git strictly below parent,
// the boundary is crossed — fall through (do not return parent).
if (nearestGitRoot(resolvedStart, parent) !== null) {
dir = parent;
depth += 1;
continue;
}
// Lookahead: walk ancestors above `parent` to find a sub_repos claim.
let ancestor = path.dirname(parent);
let ancestorDepth = 0;
@@ -162,6 +198,15 @@ export function findProjectRoot(startDir: string): string {
try {
const candidatePlanning = parent2 + path.sep + '.planning';
if (fs.existsSync(candidatePlanning) && fs.statSync(candidatePlanning).isDirectory()) {
// #2843: do not cross a git-repo boundary. If the caller is inside its
// own nested repo (a .git strictly below parent2), parent2's .planning/
// belongs to a DIFFERENT project — keep walking is wrong; stop and fall
// through to the startDir fallback instead of silently resolving to the
// ancestor project. (Reached only when no .git exists anywhere in the
// chain per the triage, but guard defensively.)
if (nearestGitRoot(resolvedStart, parent2) !== null) {
break;
}
return parent2;
}
} catch {

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