diff --git a/src/main/git/worktree-include-file.test.ts b/src/main/git/worktree-include-file.test.ts index f4624d4ea04..baf88678917 100644 --- a/src/main/git/worktree-include-file.test.ts +++ b/src/main/git/worktree-include-file.test.ts @@ -3,7 +3,11 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { resolveWorktreeIncludePaths } from './worktree-include-file' -import { matchesWorktreeIncludeGlob } from './worktree-include-glob' +import { + compileWorktreeIncludeGlob, + getWorktreeIncludeGlobStepCount, + matchesWorktreeIncludeGlob +} from './worktree-include-glob' import { parseWorktreeIncludePatterns } from './worktree-include-pattern' import { gitExecFileAsync } from './runner' @@ -75,6 +79,13 @@ describe('parseWorktreeIncludePatterns', () => { expect(glob.glob && matchesWorktreeIncludeGlob(glob.glob, longNonMatch)).toBe(false) }) + it('charges variable-width regex backtracking against the literal suffix', () => { + const glob = compileWorktreeIncludeGlob(`*${'a'.repeat(1_000)}`) + + expect(getWorktreeIncludeGlobStepCount(glob, 'short')).toBe(6_006) + expect(getWorktreeIncludeGlobStepCount(compileWorktreeIncludeGlob('prefix*'), 'short')).toBe(12) + }) + it('rejects pathological pattern counts before worktree creation can fan out', () => { expect(() => parseWorktreeIncludePatterns(`${'.env\n'.repeat(4_097)}`)).toThrow( 'contains too many patterns' @@ -133,11 +144,13 @@ describe('resolveWorktreeIncludePaths', () => { }) it('routes every Git probe through the selected WSL runtime', async () => { - writeInclude('.env\n') - mockGit({ entries: ['.env'], ignored: ['.env'] }) + writeInclude('/cache/\n.env\n') + mkdirSync(join(repo, 'cache')) + mockGit({ collapsedEntries: ['cache/', '.env'], targetedEntries: ['.env'] }) await expect(resolveWorktreeIncludePaths(repo, { wslDistro: 'Ubuntu' })).resolves.toEqual([ - '.env' + '.env', + 'cache' ]) expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(3) @@ -146,16 +159,27 @@ describe('resolveWorktreeIncludePaths', () => { ).toBe(true) }) - it('avoids enumeration for root-anchored literal patterns', async () => { - writeInclude('/config/secrets.json\n') - mkdirSync(join(repo, 'config')) - writeFileSync(join(repo, 'config', 'secrets.json'), '{}') - mockGit({ ignored: ['config/secrets.json'] }) + it('avoids enumeration for a fully ignored root-anchored directory', async () => { + writeInclude('/cache/\n') + mkdirSync(join(repo, 'cache')) + mockGit({ ignored: ['cache'] }) - await expect(resolveWorktreeIncludePaths(repo)).resolves.toEqual(['config/secrets.json']) + await expect(resolveWorktreeIncludePaths(repo)).resolves.toEqual(['cache']) expect(gitExecFileAsyncMock.mock.calls.every(([args]) => !args.includes('ls-files'))).toBe(true) }) + it('finds ignored descendants when a root-anchored directory is not itself ignored', async () => { + writeInclude('/config/\n') + mkdirSync(join(repo, 'config')) + mockGit({ targetedEntries: ['config/local.json'], ignored: ['config/local.json'] }) + + await expect(resolveWorktreeIncludePaths(repo)).resolves.toEqual(['config/local.json']) + const targetedArgs = gitExecFileAsyncMock.mock.calls.find( + ([args]) => args.includes('ls-files') && !args.includes('--directory') + )?.[0] + expect(targetedArgs).toContain(':(glob)config/**') + }) + it('drops listed paths that are not actually gitignored', async () => { writeInclude('.env\ntracked.json\n') writeFileSync(join(repo, '.env'), 'A=1') diff --git a/src/main/git/worktree-include-file.ts b/src/main/git/worktree-include-file.ts index 685649ca149..ec3af45fcd8 100644 --- a/src/main/git/worktree-include-file.ts +++ b/src/main/git/worktree-include-file.ts @@ -2,14 +2,20 @@ import { lstat, readFile } from 'node:fs/promises' import { isAbsolute, join } from 'node:path' import { checkIgnoredPaths } from './check-ignored-paths' import type { GitRuntimeOptions } from './git-runtime-options' -import { gitOptionsForWorktree } from './git-runtime-options' -import { gitExecFileAsync } from './runner' +import { + chunkWorktreeIncludePathspecs, + listGitignoredEntries, + worktreeIncludePatternToGitDescendantPathspec, + worktreeIncludePatternToGitPathspec, + type GitignoredEntry +} from './worktree-include-git-enumeration' import { isIncludedByWorktreePatterns, parseWorktreeIncludePatterns, WORKTREE_INCLUDE_MATCH_STEP_BUDGET, type WorktreeIncludePattern } from './worktree-include-pattern' +import { mapWithConcurrency } from '../../shared/map-with-concurrency' /** Project-level list of gitignored paths to copy into each new worktree. * Cross-tool convention (gitignore syntax); see issue #7549. */ @@ -21,9 +27,7 @@ const WORKTREE_INCLUDE_GIT_TIMEOUT_MS = 15_000 const WORKTREE_INCLUDE_MAX_FILE_BYTES = 256 * 1024 const WORKTREE_INCLUDE_MAX_CANDIDATES = 10_000 const WORKTREE_INCLUDE_MAX_CANDIDATE_BYTES = 1024 * 1024 -// Why: Windows limits native command lines to 32K UTF-16 code units; leave -// room for Git's executable, fixed arguments, and the repository path. -const WORKTREE_INCLUDE_PATHSPEC_CHUNK_LENGTH = 12 * 1024 +const WORKTREE_INCLUDE_STAT_CONCURRENCY = 8 function isSafeIncludeCandidate(relativePath: string): boolean { if (!relativePath || isAbsolute(relativePath)) { @@ -33,12 +37,6 @@ function isSafeIncludeCandidate(relativePath: string): boolean { return !segments.includes('..') && segments[0] !== '.git' } -type GitignoredEntry = { - relativePath: string - isDirectory: boolean - fromIgnoredScan: boolean -} - function hasCandidateDirectoryAncestor( candidates: ReadonlyMap, relativePath: string @@ -46,7 +44,7 @@ function hasCandidateDirectoryAncestor( let separatorIndex = relativePath.indexOf('/') while (separatorIndex !== -1) { const ancestor = candidates.get(relativePath.slice(0, separatorIndex)) - if (ancestor?.isDirectory && ancestor.fromIgnoredScan) { + if (ancestor?.isDirectory && ancestor.coversDescendants) { return true } separatorIndex = relativePath.indexOf('/', separatorIndex + 1) @@ -54,93 +52,6 @@ function hasCandidateDirectoryAncestor( return false } -type ListGitignoredEntriesOptions = { - collapseDirectories: boolean - pathspecs?: readonly string[] - timeout: number -} - -async function listGitignoredEntries( - repoPath: string, - options: GitRuntimeOptions, - listOptions: ListGitignoredEntriesOptions -): Promise { - const args = [ - '-c', - 'core.quotePath=false', - 'ls-files', - '--others', - '--ignored', - '--exclude-standard', - ...(listOptions.collapseDirectories ? ['--directory'] : []), - '-z', - ...(listOptions.pathspecs ? ['--', ...listOptions.pathspecs] : []) - ] - const { stdout } = await gitExecFileAsync(args, { - ...gitOptionsForWorktree(repoPath, options), - timeout: listOptions.timeout - }) - const entries: GitignoredEntry[] = [] - for (const rawEntry of stdout.split('\0')) { - if (!rawEntry) { - continue - } - const isDirectory = rawEntry.endsWith('/') - entries.push({ - relativePath: isDirectory ? rawEntry.replace(/\/+$/, '') : rawEntry, - isDirectory, - fromIgnoredScan: true - }) - } - return entries -} - -function patternToGitPathspec(pattern: WorktreeIncludePattern): string { - // Why: our supported matcher treats `[` literally, while Git pathspec globs - // treat it as a character class opener. - const body = pattern.body.replaceAll('[', '[[]') - return `:(glob)${pattern.anchored ? '' : '**/'}${body}` -} - -function patternToGitDescendantPathspec(pattern: WorktreeIncludePattern): string { - return `${patternToGitPathspec(pattern)}/**` -} - -function chunkPathspecs( - pathspecs: readonly string[], - sharedPathspecs: readonly string[] = [] -): string[][] { - const chunks: string[][] = [] - let chunk: string[] = [] - const shared = Array.from(new Set(sharedPathspecs)) - const sharedLength = shared.reduce((length, pathspec) => length + pathspec.length, 0) - if (sharedLength >= WORKTREE_INCLUDE_PATHSPEC_CHUNK_LENGTH) { - return chunks - } - let chunkLength = sharedLength - for (const pathspec of new Set(pathspecs)) { - // A single impossible-to-match pattern must not exceed Windows' process - // command-line limit; the collapsed scan still provides safe fallback. - if (pathspec.length + sharedLength > WORKTREE_INCLUDE_PATHSPEC_CHUNK_LENGTH) { - continue - } - if ( - chunk.length > 0 && - chunkLength + pathspec.length > WORKTREE_INCLUDE_PATHSPEC_CHUNK_LENGTH - ) { - chunks.push([...chunk, ...shared]) - chunk = [] - chunkLength = sharedLength - } - chunk.push(pathspec) - chunkLength += pathspec.length - } - if (chunk.length > 0) { - chunks.push([...chunk, ...shared]) - } - return chunks -} - async function readWorktreeIncludeFile(repoPath: string): Promise { const includePath = join(repoPath, WORKTREE_INCLUDE_FILE) try { @@ -201,7 +112,7 @@ export async function resolveWorktreeIncludePaths( // Why: anchored literals cannot be discovered more cheaply than a direct probe; // broad literals use the bounded Git scans below instead of serial filesystem I/O. - const anchoredLiteralPaths = new Set( + const anchoredLiteralPatterns = new Map( patterns .filter( (pattern) => @@ -210,18 +121,33 @@ export async function resolveWorktreeIncludePaths( pattern.anchored && isSafeIncludeCandidate(pattern.body) ) - .map((pattern) => pattern.body) + .map((pattern) => [pattern.body, pattern] as const) ) - for (const relativePath of anchoredLiteralPaths) { - try { - const stats = await lstat(join(repoPath, relativePath)) + const anchoredLiteralDirectories = new Map() + // Why: thousands of serial UNC/WSL stats can stall creation, while unbounded fanout can swamp the filesystem. + const anchoredLiteralStats = await mapWithConcurrency( + Array.from(anchoredLiteralPatterns), + WORKTREE_INCLUDE_STAT_CONCURRENCY, + async ([relativePath, pattern]) => { + try { + const stats = await lstat(join(repoPath, relativePath)) + return { relativePath, pattern, isDirectory: stats.isDirectory() } + } catch { + return null + } + } + ) + for (const result of anchoredLiteralStats) { + if (result) { + const { relativePath, pattern, isDirectory } = result addCandidate({ relativePath, - isDirectory: stats.isDirectory(), - fromIgnoredScan: false + isDirectory, + coversDescendants: false }) - } catch { - // Absent in the primary checkout — nothing to copy. + if (isDirectory && candidates.has(relativePath)) { + anchoredLiteralDirectories.set(relativePath, pattern) + } } } @@ -237,22 +163,60 @@ export async function resolveWorktreeIncludePaths( })) { addCandidate(entry) } + } - // Why: Git 2.25's directory collapsing can stop at an untracked parent - // before discovering nested ignored files; targeted scans preserve the - // cross-version behavior without expanding unrelated ignored trees. - const filePathspecs = broadPatterns.flatMap((pattern) => [ - ...(pattern.dirOnly ? [] : [patternToGitPathspec(pattern)]), + // Why: an anchored directory can contain tracked files while only some descendants are ignored; only fully ignored directories are safe to copy as one unit. + const checkedAnchoredDirectories = new Set(anchoredLiteralDirectories.keys()) + const ignoredAnchoredDirectories = new Set() + const anchoredDirectoriesToCheck: string[] = [] + for (const relativePath of checkedAnchoredDirectories) { + if (candidates.get(relativePath)?.coversDescendants) { + ignoredAnchoredDirectories.add(relativePath) + } else { + anchoredDirectoriesToCheck.push(relativePath) + } + } + if (anchoredDirectoriesToCheck.length > 0) { + const timeout = deadline - Date.now() + if (timeout <= 0) { + throw new Error(`${WORKTREE_INCLUDE_FILE} resolution timed out`) + } + for (const relativePath of await checkIgnoredPaths( + repoPath, + anchoredDirectoriesToCheck, + options, + timeout + )) { + ignoredAnchoredDirectories.add(relativePath) + const candidate = candidates.get(relativePath) + if (candidate?.isDirectory) { + candidate.coversDescendants = true + } + } + } + + // Why: Git 2.25's directory collapsing can stop at an untracked parent, so targeted scans preserve nested matches without expanding unrelated ignored trees. + const filePathspecs = [ + ...broadPatterns.flatMap((pattern) => [ + ...(pattern.dirOnly ? [] : [worktreeIncludePatternToGitPathspec(pattern)]), // Why: files beneath a matching directory expose directory matches that // Git 2.25 otherwise hides behind a collapsed ignored parent. - patternToGitDescendantPathspec(pattern) - ]) + worktreeIncludePatternToGitDescendantPathspec(pattern) + ]), + ...Array.from(anchoredLiteralDirectories.entries()) + .filter(([relativePath]) => !ignoredAnchoredDirectories.has(relativePath)) + .map(([, pattern]) => worktreeIncludePatternToGitDescendantPathspec(pattern)) + ] + if (filePathspecs.length > 0) { // Why: collapsed directories are already copied as units; excluding them // keeps a targeted nested scan from expanding node_modules-scale trees. const coveredDirectoryPathspecs = Array.from(candidates.values()) - .filter((entry) => entry.isDirectory && entry.fromIgnoredScan) + .filter((entry) => entry.isDirectory && entry.coversDescendants) .map((entry) => `:(exclude,literal)${entry.relativePath}`) - for (const pathspecs of chunkPathspecs(filePathspecs, coveredDirectoryPathspecs)) { + for (const pathspecs of chunkWorktreeIncludePathspecs( + filePathspecs, + coveredDirectoryPathspecs + )) { const timeout = deadline - Date.now() if (timeout <= 0) { throw new Error(`${WORKTREE_INCLUDE_FILE} Git enumeration timed out`) @@ -279,16 +243,26 @@ export async function resolveWorktreeIncludePaths( ) { throw new Error(`${WORKTREE_INCLUDE_FILE} matched paths exceed its byte budget`) } - const checkIgnoreTimeout = deadline - Date.now() - if (checkIgnoreTimeout <= 0) { - throw new Error(`${WORKTREE_INCLUDE_FILE} resolution timed out`) - } - // Why: enforce the gitignored-only contract for literal-derived candidates // too — a listed-but-not-ignored path must not be copied (issue #7549). - const ignored = new Set( - await checkIgnoredPaths(repoPath, candidatePaths, options, checkIgnoreTimeout) + const ignored = new Set(ignoredAnchoredDirectories) + const uncheckedCandidatePaths = candidatePaths.filter( + (relativePath) => !checkedAnchoredDirectories.has(relativePath) ) + if (uncheckedCandidatePaths.length > 0) { + const timeout = deadline - Date.now() + if (timeout <= 0) { + throw new Error(`${WORKTREE_INCLUDE_FILE} resolution timed out`) + } + for (const relativePath of await checkIgnoredPaths( + repoPath, + uncheckedCandidatePaths, + options, + timeout + )) { + ignored.add(relativePath) + } + } return candidatePaths.filter((relativePath) => ignored.has(relativePath)).sort() } catch (error) { console.warn(`[worktree-include] Failed to resolve ${WORKTREE_INCLUDE_FILE} patterns:`, error) diff --git a/src/main/git/worktree-include-flow.test.ts b/src/main/git/worktree-include-flow.test.ts index 722a6addbce..b90d810f1e4 100644 --- a/src/main/git/worktree-include-flow.test.ts +++ b/src/main/git/worktree-include-flow.test.ts @@ -33,8 +33,11 @@ describe('worktree include creation flow', () => { }) it('copies only matched ignored paths and leaves ordinary removal clean', async () => { - await writeFile(join(primary, '.gitignore'), '.env\ncache/\nparent/\n') - await writeFile(join(primary, '.worktreeinclude'), '.env\ncache/\nbuild/\ntracked.txt\n') + await writeFile(join(primary, '.gitignore'), '.env\ncache/\nparent/\nmixed/local.txt\n') + await writeFile( + join(primary, '.worktreeinclude'), + '.env\ncache/\nbuild/\n/mixed/\ntracked.txt\n' + ) await writeFile(join(primary, 'tracked.txt'), 'tracked\n') await writeFile(join(primary, '.env'), 'ROOT=1\n') await mkdir(join(primary, 'apps', 'web'), { recursive: true }) @@ -43,18 +46,29 @@ describe('worktree include creation flow', () => { await writeFile(join(primary, 'cache', 'artifact'), 'cached\n') await mkdir(join(primary, 'parent', 'build'), { recursive: true }) await writeFile(join(primary, 'parent', 'build', 'marker'), 'built\n') - await git(['add', '.gitignore', '.worktreeinclude', 'tracked.txt']) + await mkdir(join(primary, 'mixed')) + await writeFile(join(primary, 'mixed', 'tracked.txt'), 'tracked\n') + await writeFile(join(primary, 'mixed', 'local.txt'), 'local\n') + await git(['add', '.gitignore', '.worktreeinclude', 'tracked.txt', 'mixed/tracked.txt']) await git(['commit', '-qm', 'initial']) await git(['worktree', 'add', '-q', '-b', 'feature', worktree]) const includePaths = await resolveWorktreeIncludePaths(primary) - expect(includePaths).toEqual(['.env', 'apps/web/.env', 'cache', 'parent/build/marker']) + expect(includePaths).toEqual([ + '.env', + 'apps/web/.env', + 'cache', + 'mixed/local.txt', + 'parent/build/marker' + ]) await createWorktreeCopiedPaths(primary, worktree, includePaths, { platform: 'linux' }) expect(await readFile(join(worktree, '.env'), 'utf8')).toBe('ROOT=1\n') expect(await readFile(join(worktree, 'apps', 'web', '.env'), 'utf8')).toBe('NESTED=1\n') expect(await readFile(join(worktree, 'cache', 'artifact'), 'utf8')).toBe('cached\n') + expect(await readFile(join(worktree, 'mixed', 'local.txt'), 'utf8')).toBe('local\n') + expect(await readFile(join(worktree, 'mixed', 'tracked.txt'), 'utf8')).toBe('tracked\n') expect(await readFile(join(worktree, 'parent', 'build', 'marker'), 'utf8')).toBe('built\n') expect((await lstat(join(worktree, 'tracked.txt'))).isFile()).toBe(true) diff --git a/src/main/git/worktree-include-git-enumeration.ts b/src/main/git/worktree-include-git-enumeration.ts new file mode 100644 index 00000000000..70bd4716d9b --- /dev/null +++ b/src/main/git/worktree-include-git-enumeration.ts @@ -0,0 +1,100 @@ +import type { GitRuntimeOptions } from './git-runtime-options' +import { gitOptionsForWorktree } from './git-runtime-options' +import { gitExecFileAsync } from './runner' +import type { WorktreeIncludePattern } from './worktree-include-pattern' + +// Why: Windows limits native command lines to 32K UTF-16 code units; leave room for Git's fixed arguments and repo path. +const WORKTREE_INCLUDE_PATHSPEC_CHUNK_LENGTH = 12 * 1024 + +export type GitignoredEntry = { + relativePath: string + isDirectory: boolean + coversDescendants: boolean +} + +type ListGitignoredEntriesOptions = { + collapseDirectories: boolean + pathspecs?: readonly string[] + timeout: number +} + +export async function listGitignoredEntries( + repoPath: string, + options: GitRuntimeOptions, + listOptions: ListGitignoredEntriesOptions +): Promise { + const args = [ + '-c', + 'core.quotePath=false', + 'ls-files', + '--others', + '--ignored', + '--exclude-standard', + ...(listOptions.collapseDirectories ? ['--directory'] : []), + '-z', + ...(listOptions.pathspecs ? ['--', ...listOptions.pathspecs] : []) + ] + const { stdout } = await gitExecFileAsync(args, { + ...gitOptionsForWorktree(repoPath, options), + timeout: listOptions.timeout + }) + const entries: GitignoredEntry[] = [] + for (const rawEntry of stdout.split('\0')) { + if (!rawEntry) { + continue + } + const isDirectory = rawEntry.endsWith('/') + entries.push({ + relativePath: isDirectory ? rawEntry.replace(/\/+$/, '') : rawEntry, + isDirectory, + coversDescendants: isDirectory && listOptions.collapseDirectories + }) + } + return entries +} + +export function worktreeIncludePatternToGitPathspec(pattern: WorktreeIncludePattern): string { + // Why: our matcher treats `[` literally, while Git pathspec globs treat it as a character class opener. + const body = pattern.body.replaceAll('[', '[[]') + return `:(glob)${pattern.anchored ? '' : '**/'}${body}` +} + +export function worktreeIncludePatternToGitDescendantPathspec( + pattern: WorktreeIncludePattern +): string { + return `${worktreeIncludePatternToGitPathspec(pattern)}/**` +} + +export function chunkWorktreeIncludePathspecs( + pathspecs: readonly string[], + sharedPathspecs: readonly string[] = [] +): string[][] { + const chunks: string[][] = [] + let chunk: string[] = [] + const shared = Array.from(new Set(sharedPathspecs)) + const sharedLength = shared.reduce((length, pathspec) => length + pathspec.length, 0) + if (sharedLength >= WORKTREE_INCLUDE_PATHSPEC_CHUNK_LENGTH) { + return chunks + } + let chunkLength = sharedLength + for (const pathspec of new Set(pathspecs)) { + // Why: the collapsed scan remains a safe fallback when one pattern cannot fit the Windows command line. + if (pathspec.length + sharedLength > WORKTREE_INCLUDE_PATHSPEC_CHUNK_LENGTH) { + continue + } + if ( + chunk.length > 0 && + chunkLength + pathspec.length > WORKTREE_INCLUDE_PATHSPEC_CHUNK_LENGTH + ) { + chunks.push([...chunk, ...shared]) + chunk = [] + chunkLength = sharedLength + } + chunk.push(pathspec) + chunkLength += pathspec.length + } + if (chunk.length > 0) { + chunks.push([...chunk, ...shared]) + } + return chunks +} diff --git a/src/main/git/worktree-include-glob.ts b/src/main/git/worktree-include-glob.ts index b9f362f5f56..a4946f6b478 100644 --- a/src/main/git/worktree-include-glob.ts +++ b/src/main/git/worktree-include-glob.ts @@ -8,6 +8,7 @@ type WorktreeIncludeGlobToken = export type CompiledWorktreeIncludeGlob = { regExp: RegExp | null tokens: readonly WorktreeIncludeGlobToken[] + variableWidthSuffixLength: number | null } export function compileWorktreeIncludeGlob(pattern: string): CompiledWorktreeIncludeGlob { @@ -39,10 +40,15 @@ export function compileWorktreeIncludeGlob(pattern: string): CompiledWorktreeInc regex += char.replace(/[.+^${}()|[\]\\]/, '\\$&') } } + const variableWidthIndex = tokens.findIndex( + (token) => token.kind === 'segment-star' || token.kind === 'recursive-star' + ) return { // Why: multiple variable-width groups can make JS regex backtracking exponential. regExp: starCount <= 1 ? new RegExp(`^${regex}$`) : null, - tokens + tokens, + variableWidthSuffixLength: + variableWidthIndex === -1 ? null : tokens.length - variableWidthIndex - 1 } } @@ -50,7 +56,14 @@ export function getWorktreeIncludeGlobStepCount( pattern: CompiledWorktreeIncludeGlob, value: string ): number { - return pattern.regExp === null ? pattern.tokens.length * (value.length + 1) : value.length + 1 + if (pattern.regExp === null) { + return pattern.tokens.length * (value.length + 1) + } + if (pattern.variableWidthSuffixLength !== null) { + const prefixLength = pattern.tokens.length - pattern.variableWidthSuffixLength - 1 + return prefixLength + (pattern.variableWidthSuffixLength + 1) * (value.length + 1) + } + return pattern.tokens.length + value.length + 1 } export function matchesWorktreeIncludeGlob( diff --git a/src/main/git/worktree-include-stat-concurrency.test.ts b/src/main/git/worktree-include-stat-concurrency.test.ts new file mode 100644 index 00000000000..27c73cf8cc9 --- /dev/null +++ b/src/main/git/worktree-include-stat-concurrency.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it, vi } from 'vitest' +import { resolveWorktreeIncludePaths, WORKTREE_INCLUDE_FILE } from './worktree-include-file' +import { gitExecFileAsync } from './runner' + +const lstatMock = vi.hoisted(() => vi.fn()) +const readFileMock = vi.hoisted(() => vi.fn()) + +vi.mock('node:fs/promises', () => ({ + lstat: lstatMock, + readFile: readFileMock +})) + +vi.mock('./runner', () => ({ + gitExecFileAsync: vi.fn() +})) + +describe('worktree include stat concurrency', () => { + it('bounds anchored literal probes instead of awaiting thousands serially', async () => { + let active = 0 + let peak = 0 + readFileMock.mockResolvedValue( + Array.from({ length: 24 }, (_, index) => `/config/file-${index}`).join('\n') + ) + lstatMock.mockImplementation(async (path: string) => { + if (path.endsWith(WORKTREE_INCLUDE_FILE)) { + return { isFile: () => true, size: 1_024 } + } + active++ + peak = Math.max(peak, active) + await new Promise((resolve) => setTimeout(resolve, 1)) + active-- + return { isDirectory: () => false } + }) + vi.mocked(gitExecFileAsync).mockRejectedValue( + Object.assign(new Error('no ignored paths'), { code: 1 }) + ) + + await expect(resolveWorktreeIncludePaths('/repo')).resolves.toEqual([]) + + expect(peak).toBe(8) + expect(lstatMock).toHaveBeenCalledTimes(25) + expect(gitExecFileAsync).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index 7e9740db391..4f57d53c451 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -2461,7 +2461,9 @@ export async function createLocalWorktree( // Why: project-level `.worktreeinclude` travels with the repo (issue #7549); copy semantics // (never symlink) so each worktree owns its files. Paths already linked above are skipped. - const includePaths = await resolveWorktreeIncludePaths(repo.path, localWorktreeGitOptions) + const includePaths = await timing.time('resolve_worktreeinclude', () => + resolveWorktreeIncludePaths(repo.path, localWorktreeGitOptions) + ) if (includePaths.length > 0) { await timing.time('copy_worktreeinclude', async () => { await createWorktreeCopiedPaths(repo.path, created.path, includePaths, { diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index 5e57409b6f5..4712f839d18 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -1115,6 +1115,7 @@ describe('registerWorktreeHandlers', () => { expect.arrayContaining([ 'git_worktree_add', 'list_created_worktree', + 'resolve_worktreeinclude', 'prepare_setup', 'spawn_startup_terminal' ])