From 96b94065ff98babbab25ca6c85b41d63114eb4fb Mon Sep 17 00:00:00 2001 From: Jeremy McSpadden Date: Thu, 19 Mar 2026 17:50:10 -0500 Subject: [PATCH] =?UTF-8?q?fix:=20smarter=20.gsd=20root=20discovery=20?= =?UTF-8?q?=E2=80=94=20git-root=20anchor=20+=20walk-up=20replaces=20symlin?= =?UTF-8?q?k=20hack=20(#1386)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- src/resources/extensions/gsd/paths.ts | 82 +++++++++++-- .../extensions/gsd/tests/paths.test.ts | 113 ++++++++++++++++++ 2 files changed, 187 insertions(+), 8 deletions(-) create mode 100644 src/resources/extensions/gsd/tests/paths.test.ts diff --git a/src/resources/extensions/gsd/paths.ts b/src/resources/extensions/gsd/paths.ts index 4c523837a..8d77bf21e 100644 --- a/src/resources/extensions/gsd/paths.ts +++ b/src/resources/extensions/gsd/paths.ts @@ -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 = { 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(); + +/** 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"); } diff --git a/src/resources/extensions/gsd/tests/paths.test.ts b/src/resources/extensions/gsd/tests/paths.test.ts new file mode 100644 index 000000000..c27f01976 --- /dev/null +++ b/src/resources/extensions/gsd/tests/paths.test.ts @@ -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();