diff --git a/src/main/git/worktree-include-file.test.ts b/src/main/git/worktree-include-file.test.ts index 97f3eb06b32..00a8ce15f3f 100644 --- a/src/main/git/worktree-include-file.test.ts +++ b/src/main/git/worktree-include-file.test.ts @@ -96,6 +96,13 @@ describe('resolveWorktreeIncludePaths', () => { mockGit({ entries: ['.env', 'apps/web/.env'], ignored: ['.env', 'apps/web/.env'] }) await expect(resolveWorktreeIncludePaths(repo)).resolves.toEqual(['.env', 'apps/web/.env']) + const lsFilesCalls = gitExecFileAsyncMock.mock.calls.filter(([args]) => + args.includes('ls-files') + ) + expect(lsFilesCalls).toHaveLength(2) + expect(lsFilesCalls[0][0]).toContain('--directory') + expect(lsFilesCalls[1][0]).not.toContain('--directory') + expect(lsFilesCalls[1][0]).toContain(':(glob)**/.env') }) it('avoids enumeration for root-anchored literal patterns', async () => { diff --git a/src/main/git/worktree-include-file.ts b/src/main/git/worktree-include-file.ts index 6cfe375d6d5..4e60d2b42b4 100644 --- a/src/main/git/worktree-include-file.ts +++ b/src/main/git/worktree-include-file.ts @@ -13,6 +13,9 @@ export const WORKTREE_INCLUDE_FILE = '.worktreeinclude' // filesystem or a pathological repo; bail and create the worktree without it. const WORKTREE_INCLUDE_GIT_TIMEOUT_MS = 15_000 const WORKTREE_INCLUDE_MAX_FILE_BYTES = 256 * 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 type WorktreeIncludePattern = { negated: boolean @@ -135,29 +138,32 @@ type GitignoredEntry = { isDirectory: boolean } -/** Untracked gitignored files/dirs at directory granularity. `--directory` - * collapses wholly-ignored dirs (e.g. `node_modules/`) into one entry and - * prunes traversal beneath them, keeping this fast in huge repos. */ +type ListGitignoredEntriesOptions = { + collapseDirectories: boolean + pathspecs?: readonly string[] + timeout: number +} + async function listGitignoredEntries( repoPath: string, - options: GitRuntimeOptions + options: GitRuntimeOptions, + listOptions: ListGitignoredEntriesOptions ): Promise { - const { stdout } = await gitExecFileAsync( - [ - '-c', - 'core.quotePath=false', - 'ls-files', - '--others', - '--ignored', - '--exclude-standard', - '--directory', - '-z' - ], - { - ...gitOptionsForWorktree(repoPath, options), - timeout: WORKTREE_INCLUDE_GIT_TIMEOUT_MS - } - ) + 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) { @@ -172,6 +178,40 @@ async function listGitignoredEntries( 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 chunkPathspecs(pathspecs: readonly string[]): string[][] { + const chunks: string[][] = [] + let chunk: string[] = [] + let chunkLength = 0 + 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 > WORKTREE_INCLUDE_PATHSPEC_CHUNK_LENGTH) { + continue + } + if ( + chunk.length > 0 && + chunkLength + pathspec.length > WORKTREE_INCLUDE_PATHSPEC_CHUNK_LENGTH + ) { + chunks.push(chunk) + chunk = [] + chunkLength = 0 + } + chunk.push(pathspec) + chunkLength += pathspec.length + } + if (chunk.length > 0) { + chunks.push(chunk) + } + return chunks +} + async function readWorktreeIncludeFile(repoPath: string): Promise { const includePath = join(repoPath, WORKTREE_INCLUDE_FILE) try { @@ -233,12 +273,39 @@ export async function resolveWorktreeIncludePaths( } } - // Why: bare gitignore patterns match at every depth; direct stat covers the - // root candidate, while one collapsed enumeration discovers nested matches. - if (patterns.some((pattern) => !pattern.negated && (pattern.hasGlob || !pattern.anchored))) { - for (const entry of await listGitignoredEntries(repoPath, options)) { + const broadPatterns = patterns.filter( + (pattern) => !pattern.negated && (pattern.hasGlob || !pattern.anchored) + ) + if (broadPatterns.length > 0) { + const deadline = Date.now() + WORKTREE_INCLUDE_GIT_TIMEOUT_MS + // Why: collapsing ignored directories prevents huge trees such as + // node_modules from producing one entry per file. + for (const entry of await listGitignoredEntries(repoPath, options, { + collapseDirectories: true, + timeout: Math.max(1, deadline - Date.now()) + })) { 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 + .filter((pattern) => !pattern.dirOnly) + .map(patternToGitPathspec) + for (const pathspecs of chunkPathspecs(filePathspecs)) { + const timeout = deadline - Date.now() + if (timeout <= 0) { + throw new Error(`${WORKTREE_INCLUDE_FILE} Git enumeration timed out`) + } + for (const entry of await listGitignoredEntries(repoPath, options, { + collapseDirectories: false, + pathspecs, + timeout + })) { + addCandidate(entry) + } + } } if (candidates.size === 0) { return [] diff --git a/src/shared/git-binary-compatibility.test.ts b/src/shared/git-binary-compatibility.test.ts index 1b13fae74a1..abac397a9a1 100644 --- a/src/shared/git-binary-compatibility.test.ts +++ b/src/shared/git-binary-compatibility.test.ts @@ -174,7 +174,7 @@ describeBinaryCompatibility('real Git binary compatibility', () => { } }) - it('lists collapsed ignored entries for worktree includes on the baseline', async () => { + it('lists collapsed directories and targeted nested files for worktree includes', async () => { await writeFile(join(repoPath, '.gitignore'), '.env\ncache/\n') await writeFile(join(repoPath, '.env'), 'ROOT=1\n') await mkdir(join(repoPath, 'apps', 'web'), { recursive: true }) @@ -182,7 +182,7 @@ describeBinaryCompatibility('real Git binary compatibility', () => { await mkdir(join(repoPath, 'cache')) await writeFile(join(repoPath, 'cache', 'artifact'), 'ignored\n') - const { stdout } = await runGit([ + const collapsed = await runGit([ '-c', 'core.quotePath=false', 'ls-files', @@ -192,10 +192,22 @@ describeBinaryCompatibility('real Git binary compatibility', () => { '--directory', '-z' ]) - const entries = stdout.split('\0').filter(Boolean) - expect(entries).toContain('.env') - expect(entries).toContain('apps/web/.env') - expect(entries).toContain('cache/') - expect(entries).not.toContain('cache/artifact') + const collapsedEntries = collapsed.stdout.split('\0').filter(Boolean) + expect(collapsedEntries).toContain('.env') + expect(collapsedEntries).toContain('cache/') + expect(collapsedEntries).not.toContain('cache/artifact') + + const targeted = await runGit([ + '-c', + 'core.quotePath=false', + 'ls-files', + '--others', + '--ignored', + '--exclude-standard', + '-z', + '--', + ':(glob)**/.env' + ]) + expect(targeted.stdout.split('\0').filter(Boolean)).toEqual(['.env', 'apps/web/.env']) }) })