fix: smarter .gsd root discovery — git-root anchor + walk-up replaces symlink hack (#1386)

* fix: replace symlink-follow in gsdRoot() with git-root-anchored walk-up discovery

The old implementation blindly assumed .gsd lived at basePath and only
followed symlinks as a migration escape hatch. This caused the health
widget to show "No project loaded" when:
- .gsd was moved to a non-default location
- cwd was a subdirectory of the project root
- the session started inside a worktree path

New probe chain in gsdRoot():
  1. basePath/.gsd         — fast path (common case, zero overhead)
  2. git rev-parse root    — anchors to the repo root regardless of cwd
  3. Walk up from basePath — finds .gsd in an ancestor (bounded by git root)
  4. basePath/.gsd         — creation fallback for init/new projects

Key correctness detail: basePath is normalized via realpathSync before
any comparisons, ensuring the git-root boundary check works on macOS
where /var is a symlink to /private/var. Walk-up only runs when inside
a git repo and only when basePath != gitRoot — preventing escape into
unrelated filesystem directories.

Result is cached per-basePath for the process lifetime. All 52 callers
of gsdRoot() benefit with no call-site changes.

Adds tests/paths.test.ts covering all 6 probe cases.

* fix: correct report() call signature in paths.test.ts — takes no arguments

* fix: normalize git output paths and use realpathSync.native for Windows compatibility

- Use path.normalize() on git rev-parse output to convert forward slashes
  to backslashes on Windows, so the git-root boundary check fires correctly
- Use realpathSync.native() instead of realpathSync() to resolve both
  symlinks (macOS /var→/private/var) and 8.3 short names (Windows RUNNER~1)
- Update test tmp() helper to use realpathSync.native so expected paths
  match the resolved paths returned by probeGsdRoot
This commit is contained in:
Jeremy McSpadden 2026-03-19 17:50:10 -05:00 committed by GitHub
parent fecf32dc1e
commit 96b94065ff
2 changed files with 187 additions and 8 deletions

View file

@ -10,7 +10,8 @@
*/
import { readdirSync, existsSync, realpathSync, Dirent } from "node:fs";
import { join } from "node:path";
import { join, dirname, normalize } from "node:path";
import { spawnSync } from "node:child_process";
import { nativeScanGsdTree, type GsdTreeEntry } from "./native-parser-bridge.js";
import { DIR_CACHE_MAX } from "./constants.js";
@ -277,15 +278,80 @@ const LEGACY_GSD_ROOT_FILES: Record<GSDRootFileKey, string> = {
KNOWLEDGE: "knowledge.md",
};
export function gsdRoot(basePath: string): string {
const local = join(basePath, ".gsd");
try {
const resolved = realpathSync(local);
if (resolved !== local) return resolved; // symlink resolved
} catch { /* doesn't exist yet — fall through */ }
return local; // backwards compat: unmigrated projects
// ─── GSD Root Discovery ───────────────────────────────────────────────────────
const gsdRootCache = new Map<string, string>();
/** Exported for tests only — do not call in production code. */
export function _clearGsdRootCache(): void {
gsdRootCache.clear();
}
/**
* Resolve the `.gsd` directory for a given project base path.
*
* Probe order:
* 1. basePath/.gsd fast path (common case)
* 2. git rev-parse root handles cwd-is-a-subdirectory
* 3. Walk up from basePath handles moved .gsd in an ancestor (bounded by git root)
* 4. basePath/.gsd creation fallback (init scenario)
*
* Result is cached per basePath for the process lifetime.
*/
export function gsdRoot(basePath: string): string {
const cached = gsdRootCache.get(basePath);
if (cached) return cached;
const result = probeGsdRoot(basePath);
gsdRootCache.set(basePath, result);
return result;
}
function probeGsdRoot(rawBasePath: string): string {
// 1. Fast path — check the input path directly
const local = join(rawBasePath, ".gsd");
if (existsSync(local)) return local;
// Resolve symlinks so path comparisons work correctly across platforms
// (e.g. macOS /var → /private/var). Use rawBasePath as fallback if not resolvable.
let basePath: string;
try { basePath = realpathSync.native(rawBasePath); } catch { basePath = rawBasePath; }
// 2. Git root anchor — used as both probe target and walk-up boundary
// Only walk if we're inside a git project — prevents escaping into
// unrelated filesystem territory when running outside any repo.
let gitRoot: string | null = null;
try {
const out = spawnSync("git", ["rev-parse", "--show-toplevel"], {
cwd: basePath,
encoding: "utf-8",
});
if (out.status === 0) {
const r = out.stdout.trim();
if (r) gitRoot = normalize(r);
}
} catch { /* git not available */ }
if (gitRoot) {
const candidate = join(gitRoot, ".gsd");
if (existsSync(candidate)) return candidate;
}
// 3. Walk up from basePath to the git root (only if we are in a subdirectory)
if (gitRoot && basePath !== gitRoot) {
let cur = dirname(basePath);
while (cur !== basePath) {
const candidate = join(cur, ".gsd");
if (existsSync(candidate)) return candidate;
if (cur === gitRoot) break;
basePath = cur;
cur = dirname(cur);
}
}
// 4. Fallback for init/creation
return local;
}
export function milestonesDir(basePath: string): string {
return join(gsdRoot(basePath), "milestones");
}

View file

@ -0,0 +1,113 @@
import { mkdtempSync, mkdirSync, rmSync, realpathSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { spawnSync } from "node:child_process";
import { gsdRoot, _clearGsdRootCache } from "../paths.ts";
import { createTestContext } from "./test-helpers.ts";
const { assertEq, assertTrue, report } = createTestContext();
/** Create a tmp dir and resolve symlinks + 8.3 short names (macOS /var→/private/var, Windows RUNNER~1→runneradmin). */
function tmp(): string {
const p = mkdtempSync(join(tmpdir(), "gsd-paths-test-"));
try { return realpathSync.native(p); } catch { return p; }
}
function cleanup(dir: string): void {
try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
}
function initGit(dir: string): void {
spawnSync("git", ["init"], { cwd: dir });
spawnSync("git", ["commit", "--allow-empty", "-m", "init"], { cwd: dir });
}
// ── tests ──────────────────────────────────────────────────────────────────
{
// Case 1: .gsd exists at basePath — fast path
const root = tmp();
try {
mkdirSync(join(root, ".gsd"));
_clearGsdRootCache();
const result = gsdRoot(root);
assertEq(result, join(root, ".gsd"), "fast path: returns basePath/.gsd");
} finally { cleanup(root); }
}
{
// Case 2: .gsd exists at git root, cwd is a subdirectory
const root = tmp();
try {
initGit(root);
mkdirSync(join(root, ".gsd"));
const sub = join(root, "src", "deep");
mkdirSync(sub, { recursive: true });
_clearGsdRootCache();
const result = gsdRoot(sub);
assertEq(result, join(root, ".gsd"), "git-root probe: finds .gsd at git root from subdirectory");
} finally { cleanup(root); }
}
{
// Case 3: .gsd in an ancestor — walk-up finds it (git repo with no .gsd at root)
const root = tmp();
try {
// Init a git repo so git probe returns root — but put .gsd one level deeper
// to force the walk-up path: root/project/.gsd, cwd = root/project/src/deep
initGit(root);
const project = join(root, "project");
mkdirSync(join(project, ".gsd"), { recursive: true });
const deep = join(project, "src", "deep");
mkdirSync(deep, { recursive: true });
_clearGsdRootCache();
// git probe returns root (no .gsd there), so walk-up takes over and finds project/.gsd
const result = gsdRoot(deep);
assertEq(result, join(project, ".gsd"), "walk-up: finds .gsd in ancestor when git root has none");
} finally { cleanup(root); }
}
{
// Case 4: .gsd nowhere — fallback returns original basePath/.gsd
// Use an isolated git repo so we fully control the environment above basePath
const root = tmp();
try {
initGit(root); // git root = root, no .gsd anywhere
const sub = join(root, "src");
mkdirSync(sub, { recursive: true });
_clearGsdRootCache();
const result = gsdRoot(sub);
// git probe finds root (no .gsd), walk-up finds nothing → fallback = sub/.gsd
assertEq(result, join(sub, ".gsd"), "fallback: returns basePath/.gsd when .gsd not found anywhere");
} finally { cleanup(root); }
}
{
// Case 5: cache — second call returns same value without re-probing
const root = tmp();
try {
mkdirSync(join(root, ".gsd"));
_clearGsdRootCache();
const first = gsdRoot(root);
const second = gsdRoot(root);
assertEq(first, second, "cache: same result returned on second call");
assertTrue(first === second, "cache: identity check (same string)");
} finally { cleanup(root); }
}
{
// Case 6: .gsd at basePath takes precedence over ancestor .gsd
const outer = tmp();
try {
initGit(outer);
mkdirSync(join(outer, ".gsd"));
const inner = join(outer, "nested");
mkdirSync(join(inner, ".gsd"), { recursive: true });
_clearGsdRootCache();
const result = gsdRoot(inner);
assertEq(result, join(inner, ".gsd"), "precedence: nearest .gsd wins over ancestor");
} finally { cleanup(outer); }
}
report();