perf(git): cache project-root resolution to stop N² polling cascade (#1398)
* perf(git): cache project-root resolution to stop N² polling cascade
Opening a workspace with many projects/worktrees fired hundreds of
`POST /api/fs/exec` requests (e.g. ~700 for 19 projects) within seconds,
dominated by repeated `git rev-parse --absolute-git-dir` /
`--git-common-dir` for the same directories.
Root cause: in `useProjectRepoStatus`, each project's `ensureStatus`
settles independently and mutates the git store, which re-derives
`projectGitBranchesKey` and re-runs `getRootBranch` for *all* projects on
every change. `getRootBranch` had no caching, so this produced an N×N
burst of uncached git plumbing calls.
Changes:
- worktreeStatus: extract `resolveProjectRoot` to module scope with a
60s TTL cache + in-flight dedupe (root resolution is static within a
session). Combine the two `rev-parse` queries into one subprocess.
Add `getRootBranch(dir, { knownBranch })` fast-path that skips a
redundant git status when the directory is its own root, while still
resolving the primary-root branch correctly for linked worktrees.
Export `invalidateResolvedProjectRootCache`.
- useProjectRepoStatus: replace the cascade effect with a debounced,
diff-based pass that only resolves projects that are new or whose
branch actually changed, passing the known branch through.
- worktreeManager: invalidate the root cache on worktree create/remove.
- Add unit tests for caching, dedupe, invalidation, rev-parse
precedence, non-git fallback, linked-worktree resolution and the
knownBranch fast-path.
Reduces startup from hundreds of requests to roughly one root
resolution per project.
* fix(git): clear in-flight resolves and guard write-back on cache invalidation
`invalidateResolvedProjectRootCache` cleared `resolvedRootCache` but left
`inFlightRootResolves` intact, so during a worktree topology change a
resolution already in flight could (1) be handed to callers arriving after
invalidation and (2) re-seed the cache with the pre-invalidation root when it
settled, defeating invalidation for up to the full TTL.
Drop the in-flight entry on invalidation and add an epoch guard so a resolve
that was invalidated mid-flight does not write its now-stale result back.
Add a regression test for the concurrent-invalidation scenario.
* fix(git): bound root cache and avoid early sidebar resolves
This commit is contained in:
committed by
GitHub
parent
06526767a2
commit
f94d87b5fd
@@ -55,29 +55,83 @@ export const useProjectRepoStatus = (args: Args): void => {
|
||||
.join('|');
|
||||
}, [normalizedProjects, gitRepoStatus]);
|
||||
|
||||
// Tracks the project path + input branch we last resolved against, per project.
|
||||
// Used to resolve `getRootBranch` only for projects that are new or whose
|
||||
// input actually changed — rather than re-resolving every project whenever
|
||||
// any single project's branch settles (the old N² cascade).
|
||||
const resolvedInputKeyByProjectId = React.useRef<Map<string, string>>(new Map());
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
const run = async () => {
|
||||
const entries = await mapWithConcurrency(normalizedProjects, 2, async (project) => {
|
||||
const branch = await getRootBranch(project.normalizedPath).catch(() => null);
|
||||
return { id: project.id, branch };
|
||||
});
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setProjectRootBranches((prev) => {
|
||||
const next = new Map(prev);
|
||||
entries.forEach(({ id, branch }) => {
|
||||
if (branch) {
|
||||
next.set(id, branch);
|
||||
|
||||
// Debounce so the initial burst of per-project `ensureStatus` updates
|
||||
// settles into a single resolution pass instead of one pass per project.
|
||||
const timer = setTimeout(() => {
|
||||
const run = async () => {
|
||||
const validIds = new Set(normalizedProjects.map((project) => project.id));
|
||||
// Drop bookkeeping for projects that are no longer present.
|
||||
for (const id of resolvedInputKeyByProjectId.current.keys()) {
|
||||
if (!validIds.has(id)) {
|
||||
resolvedInputKeyByProjectId.current.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
const pending = normalizedProjects.filter((project) => {
|
||||
const status = gitRepoStatus.get(project.normalizedPath);
|
||||
if (status?.isGitRepo === false) {
|
||||
resolvedInputKeyByProjectId.current.delete(project.id);
|
||||
return false;
|
||||
}
|
||||
if (status?.isGitRepo !== true || status.branch === null) {
|
||||
return false;
|
||||
}
|
||||
const currentBranch = status.branch.trim();
|
||||
const currentInputKey = `${project.normalizedPath}\0${currentBranch}`;
|
||||
const lastInputKey = resolvedInputKeyByProjectId.current.get(project.id);
|
||||
return lastInputKey === undefined || lastInputKey !== currentInputKey;
|
||||
});
|
||||
return next;
|
||||
});
|
||||
};
|
||||
void run();
|
||||
|
||||
if (pending.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = await mapWithConcurrency(pending, 2, async (project) => {
|
||||
const inputBranch = gitRepoStatus.get(project.normalizedPath)?.branch?.trim() ?? '';
|
||||
const inputKey = `${project.normalizedPath}\0${inputBranch}`;
|
||||
const branch = await getRootBranch(
|
||||
project.normalizedPath,
|
||||
inputBranch ? { knownBranch: inputBranch } : undefined,
|
||||
).catch(() => null);
|
||||
return { id: project.id, inputKey, branch };
|
||||
});
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const resolved = entries.filter((entry) => entry.branch);
|
||||
if (resolved.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setProjectRootBranches((prev) => {
|
||||
const next = new Map(prev);
|
||||
resolved.forEach(({ id, branch }) => {
|
||||
if (branch) {
|
||||
next.set(id, branch);
|
||||
}
|
||||
});
|
||||
return next;
|
||||
});
|
||||
resolved.forEach(({ id, inputKey }) => {
|
||||
resolvedInputKeyByProjectId.current.set(id, inputKey);
|
||||
});
|
||||
};
|
||||
void run();
|
||||
}, 150);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [normalizedProjects, projectGitBranchesKey, setProjectRootBranches]);
|
||||
}, [normalizedProjects, projectGitBranchesKey, gitRepoStatus, setProjectRootBranches]);
|
||||
};
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
clearWorktreeBootstrapState,
|
||||
markWorktreeBootstrapPending,
|
||||
} from '@/lib/worktrees/worktreeBootstrap';
|
||||
import { invalidateResolvedProjectRootCache } from '@/lib/worktrees/worktreeStatus';
|
||||
import type {
|
||||
CreateGitWorktreePayload,
|
||||
GitWorktreeValidationResult,
|
||||
@@ -316,6 +317,9 @@ export async function createWorktree(project: ProjectRef, args: CreateWorktreeAr
|
||||
markWorktreeBootstrapPending(metadata.path);
|
||||
|
||||
_worktreeListCache.delete(projectDirectory);
|
||||
// The new worktree changes the repo's worktree topology; drop cached root
|
||||
// resolutions so root-branch lookups re-resolve against the new layout.
|
||||
invalidateResolvedProjectRootCache();
|
||||
|
||||
// Update sidebar store so new worktree appears immediately
|
||||
const sidebarProjectKey = projectDirectory;
|
||||
@@ -358,6 +362,9 @@ export async function removeProjectWorktree(project: ProjectRef, worktree: Workt
|
||||
clearWorktreeBootstrapState(worktree.path);
|
||||
|
||||
_worktreeListCache.delete(normalizePath(project.path));
|
||||
// Removing a worktree changes the repo's worktree topology; drop cached root
|
||||
// resolutions so root-branch lookups re-resolve against the new layout.
|
||||
invalidateResolvedProjectRootCache();
|
||||
|
||||
// Update sidebar store so removed worktree disappears immediately
|
||||
const normalizedWorktreePath = normalizePath(worktree.path);
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { beforeEach, describe, expect, mock, test } from 'bun:test';
|
||||
|
||||
type ExecResult = { command: string; success: boolean; stdout?: string };
|
||||
|
||||
// Per-test controllable behaviour plus manual call tracking (the project's
|
||||
// tsconfig does not load bun-test's mock matcher types, so existing tests track
|
||||
// calls via plain arrays rather than `toHaveBeenCalled*`).
|
||||
let execImpl: (command: string, cwd: string) => ExecResult | Promise<ExecResult> = () => ({ command: '', success: false });
|
||||
let statusImpl: (directory: string) => { current: string } = () => ({ current: 'HEAD' });
|
||||
|
||||
const execCalls: Array<{ command: string; cwd: string }> = [];
|
||||
const statusCalls: string[] = [];
|
||||
|
||||
mock.module('@/lib/execCommands', () => ({
|
||||
execCommand: (command: string, cwd: string) => {
|
||||
execCalls.push({ command, cwd });
|
||||
return Promise.resolve(execImpl(command, cwd));
|
||||
},
|
||||
execCommands: () => Promise.resolve({ success: false, results: [] }),
|
||||
}));
|
||||
|
||||
mock.module('@/lib/gitApi', () => ({
|
||||
getGitStatus: (directory: string) => {
|
||||
statusCalls.push(directory);
|
||||
return Promise.resolve(statusImpl(directory));
|
||||
},
|
||||
}));
|
||||
|
||||
const { getRootBranch, invalidateResolvedProjectRootCache } = await import('./worktreeStatus');
|
||||
|
||||
// Helper: a single `git rev-parse --absolute-git-dir --git-common-dir` reply.
|
||||
const revParse = (absoluteGitDir: string, commonDir: string): ExecResult => ({
|
||||
command: 'git rev-parse --absolute-git-dir --git-common-dir',
|
||||
success: true,
|
||||
stdout: `${absoluteGitDir}\n${commonDir}`,
|
||||
});
|
||||
|
||||
describe('worktreeStatus.getRootBranch', () => {
|
||||
beforeEach(() => {
|
||||
invalidateResolvedProjectRootCache();
|
||||
execCalls.length = 0;
|
||||
statusCalls.length = 0;
|
||||
execImpl = () => ({ command: '', success: false });
|
||||
statusImpl = () => ({ current: 'HEAD' });
|
||||
});
|
||||
|
||||
test('derives root from absolute-git-dir and returns its branch', async () => {
|
||||
execImpl = () => revParse('/repo/.git', '.git');
|
||||
statusImpl = () => ({ current: 'main' });
|
||||
|
||||
expect(await getRootBranch('/repo')).toBe('main');
|
||||
expect(statusCalls).toEqual(['/repo']);
|
||||
});
|
||||
|
||||
test('caches root resolution across repeated calls', async () => {
|
||||
execImpl = () => revParse('/repo/.git', '.git');
|
||||
statusImpl = () => ({ current: 'main' });
|
||||
|
||||
await getRootBranch('/repo');
|
||||
await getRootBranch('/repo');
|
||||
await getRootBranch('/repo');
|
||||
|
||||
// rev-parse runs once; the static root resolution is cached.
|
||||
expect(execCalls.length).toBe(1);
|
||||
});
|
||||
|
||||
test('dedupes concurrent resolutions of the same directory', async () => {
|
||||
execImpl = () => revParse('/repo/.git', '.git');
|
||||
statusImpl = () => ({ current: 'main' });
|
||||
|
||||
await Promise.all([getRootBranch('/repo'), getRootBranch('/repo'), getRootBranch('/repo')]);
|
||||
|
||||
expect(execCalls.length).toBe(1);
|
||||
});
|
||||
|
||||
test('invalidation forces re-resolution', async () => {
|
||||
execImpl = () => revParse('/repo/.git', '.git');
|
||||
statusImpl = () => ({ current: 'main' });
|
||||
|
||||
await getRootBranch('/repo');
|
||||
invalidateResolvedProjectRootCache('/repo');
|
||||
await getRootBranch('/repo');
|
||||
|
||||
expect(execCalls.length).toBe(2);
|
||||
});
|
||||
|
||||
test('falls back to the directory itself in a non-git folder', async () => {
|
||||
execImpl = () => ({ command: '', success: false });
|
||||
statusImpl = () => ({ current: 'HEAD' });
|
||||
|
||||
expect(await getRootBranch('/plain')).toBe('HEAD');
|
||||
expect(statusCalls).toEqual(['/plain']);
|
||||
});
|
||||
|
||||
test('resolves a linked worktree to its primary root and fetches that branch', async () => {
|
||||
// Worktree's own git dir lives under the primary repo's .git/worktrees.
|
||||
execImpl = () => revParse('/repo/.git/worktrees/wt', '/repo/.git');
|
||||
statusImpl = () => ({ current: 'main' });
|
||||
|
||||
// knownBranch is the *worktree* branch, which must NOT be returned for the root.
|
||||
expect(await getRootBranch('/repo-wt', { knownBranch: 'feature/x' })).toBe('main');
|
||||
expect(statusCalls).toEqual(['/repo']);
|
||||
});
|
||||
|
||||
test('invalidation mid-flight does not let a stale resolve re-seed the cache', async () => {
|
||||
let releaseExec: (result: ExecResult) => void = () => {};
|
||||
execImpl = () =>
|
||||
new Promise<ExecResult>((resolve) => {
|
||||
releaseExec = resolve;
|
||||
});
|
||||
statusImpl = () => ({ current: 'main' });
|
||||
|
||||
// Start a resolution and leave it in flight.
|
||||
const pending = getRootBranch('/repo');
|
||||
// A worktree topology change invalidates the cache while the resolve runs.
|
||||
invalidateResolvedProjectRootCache();
|
||||
// Now let the original resolve settle — it must NOT populate the cache.
|
||||
releaseExec(revParse('/repo/.git', '.git'));
|
||||
await pending;
|
||||
|
||||
execImpl = () => revParse('/repo/.git', '.git');
|
||||
await getRootBranch('/repo');
|
||||
|
||||
// Second call recomputes because the stale in-flight result was discarded.
|
||||
expect(execCalls.length).toBe(2);
|
||||
});
|
||||
|
||||
test('bounds the root cache by evicting the least-recently-used entry past the count cap', async () => {
|
||||
execImpl = (_command, cwd) => revParse(`${cwd}/.git`, '.git');
|
||||
statusImpl = () => ({ current: 'main' });
|
||||
|
||||
for (let i = 0; i < 500; i += 1) {
|
||||
await getRootBranch(`/repo-${i}`);
|
||||
}
|
||||
const afterFill = execCalls.length;
|
||||
expect(afterFill).toBe(500);
|
||||
|
||||
await getRootBranch('/repo-overflow');
|
||||
await getRootBranch('/repo-0');
|
||||
await getRootBranch('/repo-499');
|
||||
|
||||
// /repo-overflow and evicted /repo-0 re-run; /repo-499 remains cached.
|
||||
expect(execCalls.length).toBe(afterFill + 2);
|
||||
});
|
||||
|
||||
test('uses knownBranch fast-path when the directory is its own root', async () => {
|
||||
execImpl = () => revParse('/repo/.git', '.git');
|
||||
|
||||
expect(await getRootBranch('/repo', { knownBranch: 'develop' })).toBe('develop');
|
||||
// No git status round-trip needed in the fast path.
|
||||
expect(statusCalls).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -57,37 +57,159 @@ export async function getWorktreeStatus(worktreePath: string): Promise<WorktreeM
|
||||
};
|
||||
}
|
||||
|
||||
export async function getRootBranch(projectDirectory: string): Promise<string> {
|
||||
const normalizedPath = normalizePath(projectDirectory);
|
||||
if (!normalizedPath) {
|
||||
return 'HEAD';
|
||||
// Resolving a project's root (primary worktree) requires shelling out to
|
||||
// `git rev-parse`, whose answer is effectively static for the lifetime of a
|
||||
// session — the location of a repo's git directory does not change while the
|
||||
// app is open. Caching it (with in-flight dedupe) collapses what used to be an
|
||||
// N² burst of `/api/fs/exec` calls into roughly one resolution per directory.
|
||||
const RESOLVED_ROOT_TTL_MS = 60_000;
|
||||
const RESOLVED_ROOT_CACHE_MAX_ENTRIES = 500;
|
||||
const RESOLVED_ROOT_CACHE_MAX_BYTES = 1024 * 1024;
|
||||
const resolvedRootCache = new Map<string, { root: string; resolvedAt: number }>();
|
||||
const inFlightRootResolves = new Map<string, Promise<string>>();
|
||||
// Bumped on every invalidation so a resolution that was already in flight when
|
||||
// the cache was invalidated does not write its now-stale result back.
|
||||
let resolveCacheEpoch = 0;
|
||||
|
||||
const resolvedRootEntryBytes = (directory: string, root: string): number => directory.length + root.length;
|
||||
|
||||
const setResolvedRootCacheEntry = (directory: string, root: string): void => {
|
||||
resolvedRootCache.delete(directory);
|
||||
resolvedRootCache.set(directory, { root, resolvedAt: Date.now() });
|
||||
|
||||
let totalBytes = 0;
|
||||
for (const [key, entry] of resolvedRootCache) {
|
||||
totalBytes += resolvedRootEntryBytes(key, entry.root);
|
||||
}
|
||||
|
||||
const resolveProjectRoot = async (directory: string): Promise<string> => {
|
||||
const absoluteGitDirResult = await execCommand('git rev-parse --absolute-git-dir', directory);
|
||||
const absoluteGitDir = normalizePath((absoluteGitDirResult.stdout || '').trim());
|
||||
if (absoluteGitDirResult.success && absoluteGitDir) {
|
||||
const rootFromAbsoluteGitDir = derivePrimaryWorktreeRootFromGitDir(absoluteGitDir);
|
||||
if (rootFromAbsoluteGitDir) {
|
||||
return rootFromAbsoluteGitDir;
|
||||
}
|
||||
while (
|
||||
resolvedRootCache.size > RESOLVED_ROOT_CACHE_MAX_ENTRIES ||
|
||||
(totalBytes > RESOLVED_ROOT_CACHE_MAX_BYTES && resolvedRootCache.size > 1)
|
||||
) {
|
||||
const oldest = resolvedRootCache.entries().next().value;
|
||||
if (!oldest) {
|
||||
break;
|
||||
}
|
||||
totalBytes -= resolvedRootEntryBytes(oldest[0], oldest[1].root);
|
||||
resolvedRootCache.delete(oldest[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const commonDirResult = await execCommand('git rev-parse --git-common-dir', directory);
|
||||
const rawCommonDir = normalizePath((commonDirResult.stdout || '').trim());
|
||||
if (!commonDirResult.success || !rawCommonDir) return directory;
|
||||
/**
|
||||
* Invalidate cached project-root resolutions. Call this when the worktree
|
||||
* topology changes (e.g. after creating or removing a worktree), since that can
|
||||
* alter which primary root a directory resolves to. With no argument, the whole
|
||||
* cache is cleared.
|
||||
*/
|
||||
export function invalidateResolvedProjectRootCache(directory?: string): void {
|
||||
// Bumping the epoch prevents any in-flight resolution from re-seeding the
|
||||
// cache with its pre-invalidation result once it settles.
|
||||
resolveCacheEpoch += 1;
|
||||
if (typeof directory === 'string' && directory) {
|
||||
const normalized = normalizePath(directory);
|
||||
resolvedRootCache.delete(normalized);
|
||||
// Drop the in-flight entry too, so callers arriving during the window
|
||||
// trigger a fresh resolution instead of receiving the stale in-flight one.
|
||||
inFlightRootResolves.delete(normalized);
|
||||
return;
|
||||
}
|
||||
resolvedRootCache.clear();
|
||||
inFlightRootResolves.clear();
|
||||
}
|
||||
|
||||
const computeProjectRoot = async (directory: string): Promise<string> => {
|
||||
// A single `git rev-parse` invocation returns both paths (absolute-git-dir on
|
||||
// the first line, git-common-dir on the second), halving subprocess spawns
|
||||
// versus issuing the two queries separately. In a non-git directory the whole
|
||||
// command fails, mirroring the previous fall-through to `directory`.
|
||||
const result = await execCommand('git rev-parse --absolute-git-dir --git-common-dir', directory);
|
||||
if (!result.success) {
|
||||
return directory;
|
||||
}
|
||||
|
||||
const lines = (result.stdout || '')
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const absoluteGitDir = normalizePath(lines[0] || '');
|
||||
if (absoluteGitDir) {
|
||||
const rootFromAbsoluteGitDir = derivePrimaryWorktreeRootFromGitDir(absoluteGitDir);
|
||||
if (rootFromAbsoluteGitDir) {
|
||||
return rootFromAbsoluteGitDir;
|
||||
}
|
||||
}
|
||||
|
||||
const rawCommonDir = normalizePath(lines[1] || '');
|
||||
if (rawCommonDir) {
|
||||
const commonDir = toAbsolutePath(directory, rawCommonDir);
|
||||
const rootFromCommonDir = derivePrimaryWorktreeRootFromGitDir(commonDir);
|
||||
if (rootFromCommonDir) {
|
||||
return rootFromCommonDir;
|
||||
}
|
||||
}
|
||||
|
||||
return directory;
|
||||
};
|
||||
return directory;
|
||||
};
|
||||
|
||||
const resolveProjectRoot = async (directory: string): Promise<string> => {
|
||||
const cached = resolvedRootCache.get(directory);
|
||||
if (cached && Date.now() - cached.resolvedAt < RESOLVED_ROOT_TTL_MS) {
|
||||
// Refresh recency without extending TTL.
|
||||
resolvedRootCache.delete(directory);
|
||||
resolvedRootCache.set(directory, cached);
|
||||
return cached.root;
|
||||
}
|
||||
if (cached) {
|
||||
resolvedRootCache.delete(directory);
|
||||
}
|
||||
|
||||
const inflight = inFlightRootResolves.get(directory);
|
||||
if (inflight) {
|
||||
return inflight;
|
||||
}
|
||||
|
||||
const startEpoch = resolveCacheEpoch;
|
||||
const promise = computeProjectRoot(directory)
|
||||
.then((root) => {
|
||||
// Skip the write-back if the cache was invalidated while we resolved.
|
||||
if (resolveCacheEpoch === startEpoch) {
|
||||
setResolvedRootCacheEntry(directory, root);
|
||||
}
|
||||
return root;
|
||||
})
|
||||
.catch(() => directory)
|
||||
.finally(() => {
|
||||
if (inFlightRootResolves.get(directory) === promise) {
|
||||
inFlightRootResolves.delete(directory);
|
||||
}
|
||||
});
|
||||
|
||||
inFlightRootResolves.set(directory, promise);
|
||||
return promise;
|
||||
};
|
||||
|
||||
export async function getRootBranch(
|
||||
projectDirectory: string,
|
||||
options?: { knownBranch?: string },
|
||||
): Promise<string> {
|
||||
const normalizedPath = normalizePath(projectDirectory);
|
||||
if (!normalizedPath) {
|
||||
return 'HEAD';
|
||||
}
|
||||
|
||||
try {
|
||||
const projectRoot = await resolveProjectRoot(normalizedPath).catch(() => normalizedPath);
|
||||
|
||||
// Fast path: when a project directory *is* its own root (i.e. not a linked
|
||||
// worktree), the caller's already-known branch refers to the root branch,
|
||||
// so we can skip a redundant git status round-trip. For linked worktrees the
|
||||
// root branch differs from the worktree's branch, so we must fetch it.
|
||||
const knownBranch = options?.knownBranch?.trim();
|
||||
if (knownBranch && projectRoot === normalizedPath) {
|
||||
return knownBranch;
|
||||
}
|
||||
|
||||
const status = await getGitStatus(projectRoot);
|
||||
const branch = typeof status.current === 'string' ? status.current.trim() : '';
|
||||
return branch || 'HEAD';
|
||||
|
||||
Reference in New Issue
Block a user