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]);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user