Skip to content

Commit b9efd82

Browse files
Merge pull request #1502 from corbitsdev/cl-10807-memoize-project-key-resolution-to-stop-re-spawning-git-rev
perf(session): memoize project root lookups
2 parents 97ff9d0 + e84f8ca commit b9efd82

2 files changed

Lines changed: 108 additions & 14 deletions

File tree

‎src/session/project-key.test.ts‎

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { afterEach, beforeEach, expect, test } from "bun:test";
2-
import { mkdir, rm, writeFile } from "node:fs/promises";
2+
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
33
import { realpathSync } from "node:fs";
44
import { join } from "node:path";
55
import { tmpdir } from "node:os";
@@ -51,6 +51,73 @@ test("projectKeyFor shares nested dirs under the same git toplevel", async () =>
5151
expect(projectKeyFor(nested)).toBe(projectKeyFor(root));
5252
});
5353

54+
test("projectRootFor spawns git rev-parse once for the same cwd", async () => {
55+
initTemporaryGitRepo(root);
56+
await commitReadme(root);
57+
58+
const traceFile = join(root, "git-trace2-events");
59+
const previousTrace = process.env.GIT_TRACE2_EVENT;
60+
process.env.GIT_TRACE2_EVENT = traceFile;
61+
try {
62+
const first = projectRootFor(root);
63+
const second = projectRootFor(root);
64+
expect(second).toBe(first);
65+
expect(first).toBe(realpathSync(root));
66+
const spawns = (await readFile(traceFile, "utf8"))
67+
.split("\n")
68+
.filter(
69+
(line) =>
70+
line.includes('"event":"start"') && line.includes("rev-parse"),
71+
).length;
72+
expect(spawns).toBe(1);
73+
} finally {
74+
if (previousTrace === undefined) {
75+
delete process.env.GIT_TRACE2_EVENT;
76+
} else {
77+
process.env.GIT_TRACE2_EVENT = previousTrace;
78+
}
79+
}
80+
});
81+
82+
test("projectRootFor resolves a directory created after a missing lookup", async () => {
83+
initTemporaryGitRepo(root);
84+
await commitReadme(root);
85+
86+
const parent = realpathSync(root);
87+
const child = join(parent, "child");
88+
expect(projectRootFor(child)).toBe(child);
89+
await mkdir(child);
90+
expect(projectRootFor(child)).toBe(parent);
91+
});
92+
93+
test("projectRootFor retries a nested dir after git was unavailable", async () => {
94+
initTemporaryGitRepo(root);
95+
await commitReadme(root);
96+
97+
const nested = join(root, "nested");
98+
await mkdir(nested);
99+
const parent = realpathSync(root);
100+
// Bun 1.3 ignores PATH rewrites and does not forward process.env mutations
101+
// unless the child is given `env`. A missing GIT_DIR is not enough: git may
102+
// keep discovering the work tree. An existing file that is not a git dir
103+
// makes rev-parse exit non-zero.
104+
const notGitDir = join(root, "not-a-git-dir");
105+
await writeFile(notGitDir, "not a git dir\n");
106+
107+
const previousGitDir = process.env.GIT_DIR;
108+
process.env.GIT_DIR = notGitDir;
109+
try {
110+
expect(projectRootFor(nested)).toBe(realpathSync(nested));
111+
} finally {
112+
if (previousGitDir === undefined) {
113+
delete process.env.GIT_DIR;
114+
} else {
115+
process.env.GIT_DIR = previousGitDir;
116+
}
117+
}
118+
expect(projectRootFor(nested)).toBe(parent);
119+
});
120+
54121
test("linked worktrees have distinct project roots and keys from main and each other", async () => {
55122
initTemporaryGitRepo(root);
56123
await commitReadme(root);

‎src/session/project-key.ts‎

Lines changed: 40 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -29,31 +29,58 @@ function slugSegment(name: string): string {
2929
return slug.length > 0 ? slug : "project";
3030
}
3131

32-
/**
33-
* Checkout project root for session identity.
34-
*
35-
* Nested cwd under a git toplevel shares that root (and therefore the same
36-
* project key). Linked worktrees each have their own `--show-toplevel`.
37-
*/
38-
export function projectRootFor(cwd: string): string {
32+
// Repeated boot lookups of one cwd must not each spawn git. Only a successful
33+
// toplevel is stored: a missing directory or a failed git lookup must be tried
34+
// again, or the session key stays on the fallback for the process lifetime.
35+
const projectRootByResolvedCwd = new Map<string, string>();
36+
37+
function resolvedCwd(cwd: string): string {
3938
try {
39+
return realpathSync(cwd);
40+
} catch {
41+
return cwd;
42+
}
43+
}
44+
45+
function gitToplevel(cwd: string): string | undefined {
46+
try {
47+
// Pass `env` explicitly. Bun 1.3's execFileSync otherwise keeps the
48+
// startup environ, so later process.env changes never reach git.
4049
const toplevelRaw = execFileSync(
4150
"git",
4251
["rev-parse", "--path-format=absolute", "--show-toplevel"],
4352
{
4453
cwd,
4554
encoding: "utf8",
55+
env: process.env,
4656
stdio: ["ignore", "pipe", "ignore"],
4757
},
4858
).trim();
49-
if (toplevelRaw.length > 0) {
50-
const toplevelAbs = isAbsolute(toplevelRaw)
51-
? toplevelRaw
52-
: resolve(cwd, toplevelRaw);
53-
return realpathOr(toplevelAbs);
54-
}
59+
if (toplevelRaw.length === 0) return undefined;
60+
const toplevelAbs = isAbsolute(toplevelRaw)
61+
? toplevelRaw
62+
: resolve(cwd, toplevelRaw);
63+
return realpathOr(toplevelAbs);
5564
} catch {
5665
// Not a git checkout (or git unavailable) — use the workspace path.
66+
return undefined;
67+
}
68+
}
69+
70+
/**
71+
* Checkout project root for session identity.
72+
*
73+
* Nested cwd under a git toplevel shares that root (and therefore the same
74+
* project key). Linked worktrees each have their own `--show-toplevel`.
75+
*/
76+
export function projectRootFor(cwd: string): string {
77+
const key = resolvedCwd(cwd);
78+
const cached = projectRootByResolvedCwd.get(key);
79+
if (cached !== undefined) return cached;
80+
const toplevel = gitToplevel(cwd);
81+
if (toplevel !== undefined) {
82+
projectRootByResolvedCwd.set(key, toplevel);
83+
return toplevel;
5784
}
5885
return realpathOr(cwd);
5986
}

0 commit comments

Comments
 (0)