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('|');
|
.join('|');
|
||||||
}, [normalizedProjects, gitRepoStatus]);
|
}, [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(() => {
|
React.useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const run = async () => {
|
|
||||||
const entries = await mapWithConcurrency(normalizedProjects, 2, async (project) => {
|
// Debounce so the initial burst of per-project `ensureStatus` updates
|
||||||
const branch = await getRootBranch(project.normalizedPath).catch(() => null);
|
// settles into a single resolution pass instead of one pass per project.
|
||||||
return { id: project.id, branch };
|
const timer = setTimeout(() => {
|
||||||
});
|
const run = async () => {
|
||||||
if (cancelled) {
|
const validIds = new Set(normalizedProjects.map((project) => project.id));
|
||||||
return;
|
// Drop bookkeeping for projects that are no longer present.
|
||||||
}
|
for (const id of resolvedInputKeyByProjectId.current.keys()) {
|
||||||
setProjectRootBranches((prev) => {
|
if (!validIds.has(id)) {
|
||||||
const next = new Map(prev);
|
resolvedInputKeyByProjectId.current.delete(id);
|
||||||
entries.forEach(({ id, branch }) => {
|
|
||||||
if (branch) {
|
|
||||||
next.set(id, branch);
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
|
||||||
});
|
if (pending.length === 0) {
|
||||||
};
|
return;
|
||||||
void run();
|
}
|
||||||
|
|
||||||
|
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 () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
|
clearTimeout(timer);
|
||||||
};
|
};
|
||||||
}, [normalizedProjects, projectGitBranchesKey, setProjectRootBranches]);
|
}, [normalizedProjects, projectGitBranchesKey, gitRepoStatus, setProjectRootBranches]);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
clearWorktreeBootstrapState,
|
clearWorktreeBootstrapState,
|
||||||
markWorktreeBootstrapPending,
|
markWorktreeBootstrapPending,
|
||||||
} from '@/lib/worktrees/worktreeBootstrap';
|
} from '@/lib/worktrees/worktreeBootstrap';
|
||||||
|
import { invalidateResolvedProjectRootCache } from '@/lib/worktrees/worktreeStatus';
|
||||||
import type {
|
import type {
|
||||||
CreateGitWorktreePayload,
|
CreateGitWorktreePayload,
|
||||||
GitWorktreeValidationResult,
|
GitWorktreeValidationResult,
|
||||||
@@ -316,6 +317,9 @@ export async function createWorktree(project: ProjectRef, args: CreateWorktreeAr
|
|||||||
markWorktreeBootstrapPending(metadata.path);
|
markWorktreeBootstrapPending(metadata.path);
|
||||||
|
|
||||||
_worktreeListCache.delete(projectDirectory);
|
_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
|
// Update sidebar store so new worktree appears immediately
|
||||||
const sidebarProjectKey = projectDirectory;
|
const sidebarProjectKey = projectDirectory;
|
||||||
@@ -358,6 +362,9 @@ export async function removeProjectWorktree(project: ProjectRef, worktree: Workt
|
|||||||
clearWorktreeBootstrapState(worktree.path);
|
clearWorktreeBootstrapState(worktree.path);
|
||||||
|
|
||||||
_worktreeListCache.delete(normalizePath(project.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
|
// Update sidebar store so removed worktree disappears immediately
|
||||||
const normalizedWorktreePath = normalizePath(worktree.path);
|
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> {
|
// Resolving a project's root (primary worktree) requires shelling out to
|
||||||
const normalizedPath = normalizePath(projectDirectory);
|
// `git rev-parse`, whose answer is effectively static for the lifetime of a
|
||||||
if (!normalizedPath) {
|
// session — the location of a repo's git directory does not change while the
|
||||||
return 'HEAD';
|
// 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> => {
|
while (
|
||||||
const absoluteGitDirResult = await execCommand('git rev-parse --absolute-git-dir', directory);
|
resolvedRootCache.size > RESOLVED_ROOT_CACHE_MAX_ENTRIES ||
|
||||||
const absoluteGitDir = normalizePath((absoluteGitDirResult.stdout || '').trim());
|
(totalBytes > RESOLVED_ROOT_CACHE_MAX_BYTES && resolvedRootCache.size > 1)
|
||||||
if (absoluteGitDirResult.success && absoluteGitDir) {
|
) {
|
||||||
const rootFromAbsoluteGitDir = derivePrimaryWorktreeRootFromGitDir(absoluteGitDir);
|
const oldest = resolvedRootCache.entries().next().value;
|
||||||
if (rootFromAbsoluteGitDir) {
|
if (!oldest) {
|
||||||
return rootFromAbsoluteGitDir;
|
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());
|
* Invalidate cached project-root resolutions. Call this when the worktree
|
||||||
if (!commonDirResult.success || !rawCommonDir) return directory;
|
* 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 commonDir = toAbsolutePath(directory, rawCommonDir);
|
||||||
const rootFromCommonDir = derivePrimaryWorktreeRootFromGitDir(commonDir);
|
const rootFromCommonDir = derivePrimaryWorktreeRootFromGitDir(commonDir);
|
||||||
if (rootFromCommonDir) {
|
if (rootFromCommonDir) {
|
||||||
return 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 {
|
try {
|
||||||
const projectRoot = await resolveProjectRoot(normalizedPath).catch(() => normalizedPath);
|
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 status = await getGitStatus(projectRoot);
|
||||||
const branch = typeof status.current === 'string' ? status.current.trim() : '';
|
const branch = typeof status.current === 'string' ? status.current.trim() : '';
|
||||||
return branch || 'HEAD';
|
return branch || 'HEAD';
|
||||||
|
|||||||
Reference in New Issue
Block a user