diff --git a/src/session/project-key.test.ts b/src/session/project-key.test.ts index 17754aa0c..8af2bc6e3 100644 --- a/src/session/project-key.test.ts +++ b/src/session/project-key.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; -import { mkdir, rm, writeFile } from "node:fs/promises"; +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { realpathSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; @@ -51,6 +51,73 @@ test("projectKeyFor shares nested dirs under the same git toplevel", async () => expect(projectKeyFor(nested)).toBe(projectKeyFor(root)); }); +test("projectRootFor spawns git rev-parse once for the same cwd", async () => { + initTemporaryGitRepo(root); + await commitReadme(root); + + const traceFile = join(root, "git-trace2-events"); + const previousTrace = process.env.GIT_TRACE2_EVENT; + process.env.GIT_TRACE2_EVENT = traceFile; + try { + const first = projectRootFor(root); + const second = projectRootFor(root); + expect(second).toBe(first); + expect(first).toBe(realpathSync(root)); + const spawns = (await readFile(traceFile, "utf8")) + .split("\n") + .filter( + (line) => + line.includes('"event":"start"') && line.includes("rev-parse"), + ).length; + expect(spawns).toBe(1); + } finally { + if (previousTrace === undefined) { + delete process.env.GIT_TRACE2_EVENT; + } else { + process.env.GIT_TRACE2_EVENT = previousTrace; + } + } +}); + +test("projectRootFor resolves a directory created after a missing lookup", async () => { + initTemporaryGitRepo(root); + await commitReadme(root); + + const parent = realpathSync(root); + const child = join(parent, "child"); + expect(projectRootFor(child)).toBe(child); + await mkdir(child); + expect(projectRootFor(child)).toBe(parent); +}); + +test("projectRootFor retries a nested dir after git was unavailable", async () => { + initTemporaryGitRepo(root); + await commitReadme(root); + + const nested = join(root, "nested"); + await mkdir(nested); + const parent = realpathSync(root); + // Bun 1.3 ignores PATH rewrites and does not forward process.env mutations + // unless the child is given `env`. A missing GIT_DIR is not enough: git may + // keep discovering the work tree. An existing file that is not a git dir + // makes rev-parse exit non-zero. + const notGitDir = join(root, "not-a-git-dir"); + await writeFile(notGitDir, "not a git dir\n"); + + const previousGitDir = process.env.GIT_DIR; + process.env.GIT_DIR = notGitDir; + try { + expect(projectRootFor(nested)).toBe(realpathSync(nested)); + } finally { + if (previousGitDir === undefined) { + delete process.env.GIT_DIR; + } else { + process.env.GIT_DIR = previousGitDir; + } + } + expect(projectRootFor(nested)).toBe(parent); +}); + test("linked worktrees have distinct project roots and keys from main and each other", async () => { initTemporaryGitRepo(root); await commitReadme(root); diff --git a/src/session/project-key.ts b/src/session/project-key.ts index bfc434a6e..1f76d6b90 100644 --- a/src/session/project-key.ts +++ b/src/session/project-key.ts @@ -29,31 +29,58 @@ function slugSegment(name: string): string { return slug.length > 0 ? slug : "project"; } -/** - * Checkout project root for session identity. - * - * Nested cwd under a git toplevel shares that root (and therefore the same - * project key). Linked worktrees each have their own `--show-toplevel`. - */ -export function projectRootFor(cwd: string): string { +// Repeated boot lookups of one cwd must not each spawn git. Only a successful +// toplevel is stored: a missing directory or a failed git lookup must be tried +// again, or the session key stays on the fallback for the process lifetime. +const projectRootByResolvedCwd = new Map(); + +function resolvedCwd(cwd: string): string { try { + return realpathSync(cwd); + } catch { + return cwd; + } +} + +function gitToplevel(cwd: string): string | undefined { + try { + // Pass `env` explicitly. Bun 1.3's execFileSync otherwise keeps the + // startup environ, so later process.env changes never reach git. const toplevelRaw = execFileSync( "git", ["rev-parse", "--path-format=absolute", "--show-toplevel"], { cwd, encoding: "utf8", + env: process.env, stdio: ["ignore", "pipe", "ignore"], }, ).trim(); - if (toplevelRaw.length > 0) { - const toplevelAbs = isAbsolute(toplevelRaw) - ? toplevelRaw - : resolve(cwd, toplevelRaw); - return realpathOr(toplevelAbs); - } + if (toplevelRaw.length === 0) return undefined; + const toplevelAbs = isAbsolute(toplevelRaw) + ? toplevelRaw + : resolve(cwd, toplevelRaw); + return realpathOr(toplevelAbs); } catch { // Not a git checkout (or git unavailable) — use the workspace path. + return undefined; + } +} + +/** + * Checkout project root for session identity. + * + * Nested cwd under a git toplevel shares that root (and therefore the same + * project key). Linked worktrees each have their own `--show-toplevel`. + */ +export function projectRootFor(cwd: string): string { + const key = resolvedCwd(cwd); + const cached = projectRootByResolvedCwd.get(key); + if (cached !== undefined) return cached; + const toplevel = gitToplevel(cwd); + if (toplevel !== undefined) { + projectRootByResolvedCwd.set(key, toplevel); + return toplevel; } return realpathOr(cwd); }