From d303deefaf58a43f9ec3b7c962bf0e7742d86cd0 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 17 Oct 2026 17:01:04 -0700 Subject: [PATCH 1/4] perf(session): memoize project root lookups --- src/session/project-key.test.ts | 51 +++++++++++++++++++++++++++++++-- src/session/project-key.ts | 36 ++++++++++++++++++----- 2 files changed, 78 insertions(+), 9 deletions(-) diff --git a/src/session/project-key.test.ts b/src/session/project-key.test.ts index 17754aa0c..2031df9a0 100644 --- a/src/session/project-key.test.ts +++ b/src/session/project-key.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; -import { mkdir, rm, writeFile } from "node:fs/promises"; +import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { realpathSync } from "node:fs"; -import { join } from "node:path"; +import { delimiter, join } from "node:path"; import { tmpdir } from "node:os"; import { execFileSync } from "node:child_process"; @@ -27,6 +27,10 @@ afterEach(async () => { await rm(root, { recursive: true, force: true }); }); +function shQuote(value: string): string { + return `'${value.replaceAll("'", "'\\''")}'`; +} + async function commitReadme(dir: string): Promise { await writeFile(join(dir, "README"), "x"); execFileSync("git", ["add", "README"], { cwd: dir, stdio: "ignore" }); @@ -51,6 +55,49 @@ 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 countFile = join(root, "rev-parse-spawns"); + await writeFile(countFile, "0\n"); + const realGit = execFileSync("/usr/bin/which", ["git"], { + encoding: "utf8", + }).trim(); + const bin = join(root, "git-bin"); + await mkdir(bin); + const wrapper = join(bin, "git"); + const resolved = realpathSync(root); + await writeFile( + wrapper, + `#!/bin/sh +if [ "$1" = "rev-parse" ] && [ "$(pwd)" = ${shQuote(resolved)} ]; then + n=$(cat ${shQuote(countFile)}) + echo $((n + 1)) > ${shQuote(countFile)} +fi +exec ${shQuote(realGit)} "$@" +`, + ); + await chmod(wrapper, 0o755); + + const previousPath = process.env.PATH; + process.env.PATH = `${bin}${delimiter}${previousPath ?? ""}`; + try { + const first = projectRootFor(root); + const second = projectRootFor(root); + expect(second).toBe(first); + expect(first).toBe(realpathSync(root)); + const spawns = Number((await readFile(countFile, "utf8")).trim()); + expect(spawns).toBe(1); + } finally { + if (previousPath === undefined) { + delete process.env.PATH; + } else { + process.env.PATH = previousPath; + } + } +}); + 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..98cc31369 100644 --- a/src/session/project-key.ts +++ b/src/session/project-key.ts @@ -29,13 +29,20 @@ 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. Keyed by the +// resolved directory when it exists so equivalent spellings share one entry. +// Missing paths keep the original string, because that string is the result. +const projectRootByResolvedCwd = new Map(); + +function resolvedCwd(cwd: string): string { + try { + return realpathSync(cwd); + } catch { + return cwd; + } +} + +function projectRootUncached(cwd: string): string { try { const toplevelRaw = execFileSync( "git", @@ -58,6 +65,21 @@ export function projectRootFor(cwd: string): string { return realpathOr(cwd); } +/** + * 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 root = projectRootUncached(cwd); + projectRootByResolvedCwd.set(key, root); + return root; +} + export function projectKeyFor(cwd: string): string { const root = projectRootFor(cwd); const base = slugSegment(basename(root)); From eac8b3fb5dad35c959eb93b0e641e04e00e69472 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 17 Oct 2026 17:12:14 -0700 Subject: [PATCH 2/4] fix(session): skip caching failed project root lookups A missing directory or a failed git lookup is not a stable root. Caching it pins the session key to the fallback for the process. --- src/session/project-key.test.ts | 35 +++++++++++++++++++++++++++++++++ src/session/project-key.ts | 30 +++++++++++++++------------- 2 files changed, 51 insertions(+), 14 deletions(-) diff --git a/src/session/project-key.test.ts b/src/session/project-key.test.ts index 2031df9a0..4fbc671f4 100644 --- a/src/session/project-key.test.ts +++ b/src/session/project-key.test.ts @@ -98,6 +98,41 @@ exec ${shQuote(realGit)} "$@" } }); +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); + const emptyBin = join(root, "empty-bin"); + await mkdir(emptyBin); + + const previousPath = process.env.PATH; + process.env.PATH = emptyBin; + try { + expect(projectRootFor(nested)).toBe(realpathSync(nested)); + } finally { + if (previousPath === undefined) { + delete process.env.PATH; + } else { + process.env.PATH = previousPath; + } + } + 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 98cc31369..941abc4f6 100644 --- a/src/session/project-key.ts +++ b/src/session/project-key.ts @@ -29,9 +29,9 @@ function slugSegment(name: string): string { return slug.length > 0 ? slug : "project"; } -// Repeated boot lookups of one cwd must not each spawn git. Keyed by the -// resolved directory when it exists so equivalent spellings share one entry. -// Missing paths keep the original string, because that string is the result. +// 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 { @@ -42,7 +42,7 @@ function resolvedCwd(cwd: string): string { } } -function projectRootUncached(cwd: string): string { +function gitToplevel(cwd: string): string | undefined { try { const toplevelRaw = execFileSync( "git", @@ -53,16 +53,15 @@ function projectRootUncached(cwd: string): string { 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; } - return realpathOr(cwd); } /** @@ -75,9 +74,12 @@ export function projectRootFor(cwd: string): string { const key = resolvedCwd(cwd); const cached = projectRootByResolvedCwd.get(key); if (cached !== undefined) return cached; - const root = projectRootUncached(cwd); - projectRootByResolvedCwd.set(key, root); - return root; + const toplevel = gitToplevel(cwd); + if (toplevel !== undefined) { + projectRootByResolvedCwd.set(key, toplevel); + return toplevel; + } + return realpathOr(cwd); } export function projectKeyFor(cwd: string): string { From 34056f9da6ce5dce5328f109e86a281a7331a268 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 18 Oct 2026 21:25:51 -0700 Subject: [PATCH 3/4] test(session): fail git without rewriting PATH Bun resolves execFileSync("git") from the startup PATH, so rewriting PATH neither wraps nor hides git. Count real rev-parse starts with GIT_TRACE2_EVENT, and point GIT_DIR at a missing directory to fail it. --- src/session/project-key.test.ts | 62 ++++++++++++--------------------- 1 file changed, 22 insertions(+), 40 deletions(-) diff --git a/src/session/project-key.test.ts b/src/session/project-key.test.ts index 4fbc671f4..7c119003d 100644 --- a/src/session/project-key.test.ts +++ b/src/session/project-key.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; -import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { realpathSync } from "node:fs"; -import { delimiter, join } from "node:path"; +import { join } from "node:path"; import { tmpdir } from "node:os"; import { execFileSync } from "node:child_process"; @@ -27,10 +27,6 @@ afterEach(async () => { await rm(root, { recursive: true, force: true }); }); -function shQuote(value: string): string { - return `'${value.replaceAll("'", "'\\''")}'`; -} - async function commitReadme(dir: string): Promise { await writeFile(join(dir, "README"), "x"); execFileSync("git", ["add", "README"], { cwd: dir, stdio: "ignore" }); @@ -59,41 +55,26 @@ test("projectRootFor spawns git rev-parse once for the same cwd", async () => { initTemporaryGitRepo(root); await commitReadme(root); - const countFile = join(root, "rev-parse-spawns"); - await writeFile(countFile, "0\n"); - const realGit = execFileSync("/usr/bin/which", ["git"], { - encoding: "utf8", - }).trim(); - const bin = join(root, "git-bin"); - await mkdir(bin); - const wrapper = join(bin, "git"); - const resolved = realpathSync(root); - await writeFile( - wrapper, - `#!/bin/sh -if [ "$1" = "rev-parse" ] && [ "$(pwd)" = ${shQuote(resolved)} ]; then - n=$(cat ${shQuote(countFile)}) - echo $((n + 1)) > ${shQuote(countFile)} -fi -exec ${shQuote(realGit)} "$@" -`, - ); - await chmod(wrapper, 0o755); - - const previousPath = process.env.PATH; - process.env.PATH = `${bin}${delimiter}${previousPath ?? ""}`; + 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 = Number((await readFile(countFile, "utf8")).trim()); + 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 (previousPath === undefined) { - delete process.env.PATH; + if (previousTrace === undefined) { + delete process.env.GIT_TRACE2_EVENT; } else { - process.env.PATH = previousPath; + process.env.GIT_TRACE2_EVENT = previousTrace; } } }); @@ -116,18 +97,19 @@ test("projectRootFor retries a nested dir after git was unavailable", async () = const nested = join(root, "nested"); await mkdir(nested); const parent = realpathSync(root); - const emptyBin = join(root, "empty-bin"); - await mkdir(emptyBin); + // Bun resolves execFileSync("git") from the startup PATH, so emptying PATH + // does not hide git. A missing GIT_DIR is forwarded and makes rev-parse fail. + const missingGitDir = join(root, "missing-git-dir"); - const previousPath = process.env.PATH; - process.env.PATH = emptyBin; + const previousGitDir = process.env.GIT_DIR; + process.env.GIT_DIR = missingGitDir; try { expect(projectRootFor(nested)).toBe(realpathSync(nested)); } finally { - if (previousPath === undefined) { - delete process.env.PATH; + if (previousGitDir === undefined) { + delete process.env.GIT_DIR; } else { - process.env.PATH = previousPath; + process.env.GIT_DIR = previousGitDir; } } expect(projectRootFor(nested)).toBe(parent); From e84f8caa9fca388ba840fdb199b95e4be2492621 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 18 Oct 2026 21:53:47 -0700 Subject: [PATCH 4/4] fix(session): forward env so a bad GIT_DIR fails rev-parse Bun 1.3 keeps the startup environ unless execFileSync is given env, so GIT_DIR and GIT_TRACE2_EVENT never reached git on CI. Pass process.env, and point GIT_DIR at a real non-git file so rev-parse exits non-zero instead of discovering the work tree. --- src/session/project-key.test.ts | 11 +++++++---- src/session/project-key.ts | 3 +++ 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/session/project-key.test.ts b/src/session/project-key.test.ts index 7c119003d..8af2bc6e3 100644 --- a/src/session/project-key.test.ts +++ b/src/session/project-key.test.ts @@ -97,12 +97,15 @@ test("projectRootFor retries a nested dir after git was unavailable", async () = const nested = join(root, "nested"); await mkdir(nested); const parent = realpathSync(root); - // Bun resolves execFileSync("git") from the startup PATH, so emptying PATH - // does not hide git. A missing GIT_DIR is forwarded and makes rev-parse fail. - const missingGitDir = join(root, "missing-git-dir"); + // 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 = missingGitDir; + process.env.GIT_DIR = notGitDir; try { expect(projectRootFor(nested)).toBe(realpathSync(nested)); } finally { diff --git a/src/session/project-key.ts b/src/session/project-key.ts index 941abc4f6..1f76d6b90 100644 --- a/src/session/project-key.ts +++ b/src/session/project-key.ts @@ -44,12 +44,15 @@ function resolvedCwd(cwd: string): string { 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();