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:
parent
fecf32dc1e
commit
96b94065ff
2 changed files with 187 additions and 8 deletions
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
113
src/resources/extensions/gsd/tests/paths.test.ts
Normal file
113
src/resources/extensions/gsd/tests/paths.test.ts
Normal 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();
|
||||
Loading…
Add table
Reference in a new issue