From 30ca6bc953b8d03bb30be202d970a46de8930950 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 30 Aug 2026 23:03:55 -0700 Subject: [PATCH] perf(worktree): parallelize head identity metadata reads (#17449) --- ...e-head-identity-reader-concurrency.test.ts | 54 +++++++++++++++++++ src/main/ipc/worktree-head-identity-reader.ts | 53 +++++++++++------- 2 files changed, 87 insertions(+), 20 deletions(-) create mode 100644 src/main/ipc/worktree-head-identity-reader-concurrency.test.ts diff --git a/src/main/ipc/worktree-head-identity-reader-concurrency.test.ts b/src/main/ipc/worktree-head-identity-reader-concurrency.test.ts new file mode 100644 index 00000000000..396280f8295 --- /dev/null +++ b/src/main/ipc/worktree-head-identity-reader-concurrency.test.ts @@ -0,0 +1,54 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { readFileMock, readdirMock, concurrency } = vi.hoisted(() => ({ + readFileMock: vi.fn(), + readdirMock: vi.fn(), + concurrency: { active: 0, max: 0 } +})) + +vi.mock('node:fs/promises', () => ({ + readFile: readFileMock, + readdir: readdirMock +})) + +import { readGitCommonHeadIdentities } from './worktree-head-identity-reader' + +const WORKTREE_COUNT = 16 + +describe('readGitCommonHeadIdentities concurrency', () => { + beforeEach(() => { + readFileMock.mockReset() + readdirMock.mockReset() + concurrency.active = 0 + concurrency.max = 0 + readdirMock.mockResolvedValue( + Array.from({ length: WORKTREE_COUNT }, (_, index) => ({ + name: `wt-${index}`, + isDirectory: () => true + })) + ) + readFileMock.mockImplementation(async (filePath: string) => { + concurrency.active += 1 + concurrency.max = Math.max(concurrency.max, concurrency.active) + await new Promise((resolve) => setTimeout(resolve, 5)) + concurrency.active -= 1 + if (filePath.endsWith('/gitdir')) { + return `/workspace/${filePath.match(/wt-\d+/)?.[0] ?? 'wt'}/.git\n` + } + return `${'a'.repeat(40)}\n` + }) + }) + + it('overlaps linked-worktree metadata reads while preserving listing order', async () => { + const identities = await readGitCommonHeadIdentities('/repo/common') + + expect(identities).toHaveLength(WORKTREE_COUNT) + expect(identities.map((identity) => identity.worktreePath)).toEqual( + Array.from({ length: WORKTREE_COUNT }, (_, index) => `/workspace/wt-${index}`) + ) + // The bounded worker pool should overlap independent reads without launching + // an unbounded promise fan-out. + expect(concurrency.max).toBeGreaterThan(1) + expect(concurrency.max).toBeLessThanOrEqual(8) + }) +}) diff --git a/src/main/ipc/worktree-head-identity-reader.ts b/src/main/ipc/worktree-head-identity-reader.ts index 0e44376c00b..4ee84a4627f 100644 --- a/src/main/ipc/worktree-head-identity-reader.ts +++ b/src/main/ipc/worktree-head-identity-reader.ts @@ -1,12 +1,18 @@ import { readdir, readFile } from 'node:fs/promises' +import type { Dirent } from 'node:fs' import { basename, dirname, isAbsolute, join } from 'node:path' import type { WorktreeHeadIdentity } from '../../shared/worktree/types' +import { mapWithConcurrency } from '../../shared/map-with-concurrency' // Why: the whole point of this reader is replacing `git worktree list` fanout // with bounded metadata-file reads, so head freshness never re-creates the // spawn pressure that stalled terminal input. Keep it spawn-free. const MAX_SYMREF_DEPTH = 5 +// Head identity refreshes run on every git-common poll. Keep metadata reads +// bounded while avoiding a serial round trip per linked worktree (especially +// noticeable on WSL/UNC and network-backed worktrees). +const HEAD_IDENTITY_READ_CONCURRENCY = 8 async function readTrimmedFile(path: string): Promise { try { @@ -127,32 +133,39 @@ export async function readGitCommonHeadIdentities( } } - let entries + let entries: Dirent[] try { entries = await readdir(join(commonDirPath, 'worktrees'), { withFileTypes: true }) } catch { return identities } - for (const entry of entries) { - if (!entry.isDirectory()) { - continue + + const linkedEntries = entries.filter((entry) => entry.isDirectory()) + // mapWithConcurrency retains input order, so publishing identities stays + // deterministic while independent worktree metadata reads overlap. + const linkedIdentities = await mapWithConcurrency( + linkedEntries, + HEAD_IDENTITY_READ_CONCURRENCY, + async (entry) => { + const entryPath = join(commonDirPath, 'worktrees', entry.name) + const gitdirContent = await readTrimmedFile(join(entryPath, 'gitdir')) + if (!gitdirContent) { + return null + } + // `gitdir` holds `/.git`, absolute or (with relative-path + // worktrees) relative to the entry dir. + const gitdirAbsolute = isAbsolute(gitdirContent) + ? gitdirContent + : join(entryPath, gitdirContent) + return readHeadIdentity( + commonDirPath, + join(entryPath, 'HEAD'), + dirname(gitdirAbsolute), + packedRefs + ) } - const entryPath = join(commonDirPath, 'worktrees', entry.name) - const gitdirContent = await readTrimmedFile(join(entryPath, 'gitdir')) - if (!gitdirContent) { - continue - } - // `gitdir` holds `/.git`, absolute or (with relative-path - // worktrees) relative to the entry dir. - const gitdirAbsolute = isAbsolute(gitdirContent) - ? gitdirContent - : join(entryPath, gitdirContent) - const identity = await readHeadIdentity( - commonDirPath, - join(entryPath, 'HEAD'), - dirname(gitdirAbsolute), - packedRefs - ) + ) + for (const identity of linkedIdentities) { if (identity) { identities.push(identity) }