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:
Bohdan Triapitsyn
2026-05-24 13:20:55 +03:00
committed by GitHub
parent 06526767a2
commit f94d87b5fd
4 changed files with 371 additions and 35 deletions
@@ -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]);
};