diff --git a/src/main/git/worktree-include-file.test.ts b/src/main/git/worktree-include-file.test.ts index a128d12a41d..c01a6c01b12 100644 --- a/src/main/git/worktree-include-file.test.ts +++ b/src/main/git/worktree-include-file.test.ts @@ -8,7 +8,10 @@ import { getWorktreeIncludeGlobStepCount, matchesWorktreeIncludeGlob } from './worktree-include-glob' -import { parseWorktreeIncludePatterns } from './worktree-include-pattern' +import { + isIncludedByWorktreePatterns, + parseWorktreeIncludePatterns +} from './worktree-include-pattern' import { gitExecFileAsync } from './runner' vi.mock('./runner', () => ({ @@ -23,8 +26,15 @@ function mockGit(options: { collapsedEntries?: string[] targetedEntries?: string[] ignored?: string[] + ignoreCase?: boolean }): void { gitExecFileAsyncMock.mockImplementation(async (args, execOptions) => { + if (args.includes('config')) { + if (options.ignoreCase === undefined) { + throw Object.assign(new Error('unset'), { code: 1 }) + } + return { stdout: `${options.ignoreCase}\n`, stderr: '' } + } if (args.includes('ls-files')) { const entries = args.includes('--directory') ? (options.collapsedEntries ?? options.entries ?? []) @@ -79,6 +89,24 @@ describe('parseWorktreeIncludePatterns', () => { expect(glob.glob && matchesWorktreeIncludeGlob(glob.glob, longNonMatch)).toBe(false) }) + it('matches literals and globs case-insensitively when the repository requires it', () => { + const literal = parseWorktreeIncludePatterns('.env\n') + const glob = parseWorktreeIncludePatterns('config/**/secret.json\n') + + expect(isIncludedByWorktreePatterns(literal, '.ENV', false, { remaining: 100 }, true)).toBe( + true + ) + expect( + isIncludedByWorktreePatterns( + glob, + 'CONFIG/LOCAL/SECRET.JSON', + false, + { remaining: 1_000 }, + true + ) + ).toBe(true) + }) + it('charges variable-width regex backtracking against the literal suffix', () => { const glob = compileWorktreeIncludeGlob(`*${'a'.repeat(1_000)}`) @@ -86,6 +114,14 @@ describe('parseWorktreeIncludePatterns', () => { expect(getWorktreeIncludeGlobStepCount(compileWorktreeIncludeGlob('prefix*'), 'short')).toBe(12) }) + it('charges directory-only rules that reject a root file before glob evaluation', () => { + const patterns = parseWorktreeIncludePatterns('dir-*/\n') + + expect(() => + isIncludedByWorktreePatterns(patterns, 'root-file', false, { remaining: 1 }) + ).toThrow('matching exceeded its CPU budget') + }) + it('rejects pathological pattern counts before worktree creation can fan out', () => { expect(() => parseWorktreeIncludePatterns(`${'.env\n'.repeat(4_097)}`)).toThrow( 'contains too many patterns' @@ -153,7 +189,7 @@ describe('resolveWorktreeIncludePaths', () => { 'cache' ]) - expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(3) + expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(4) expect( gitExecFileAsyncMock.mock.calls.every(([, options]) => options.wslDistro === 'Ubuntu') ).toBe(true) @@ -213,6 +249,22 @@ describe('resolveWorktreeIncludePaths', () => { ]) }) + it('uses Git case-folding for collapsed and targeted matches', async () => { + writeInclude('.env\n') + mockGit({ + collapsedEntries: ['.ENV'], + targetedEntries: ['parent/.ENV'], + ignored: ['.ENV', 'parent/.ENV'], + ignoreCase: true + }) + + await expect(resolveWorktreeIncludePaths(repo)).resolves.toEqual(['.ENV', 'parent/.ENV']) + const targetedArgs = gitExecFileAsyncMock.mock.calls.find( + ([args]) => args.includes('ls-files') && !args.includes('--directory') + )?.[0] + expect(targetedArgs).toContain(':(icase,glob)**/.env') + }) + it('honors dir-only patterns against directory entries', async () => { writeInclude('logs/\n') writeFileSync(join(repo, 'logs'), 'not a dir') diff --git a/src/main/git/worktree-include-file.ts b/src/main/git/worktree-include-file.ts index 0e356d9d5af..b1138f7a749 100644 --- a/src/main/git/worktree-include-file.ts +++ b/src/main/git/worktree-include-file.ts @@ -4,6 +4,7 @@ import { checkIgnoredPaths } from './check-ignored-paths' import type { GitRuntimeOptions } from './git-runtime-options' import { chunkWorktreeIncludePathspecs, + getWorktreeIncludeIgnoreCase, listGitignoredEntries, worktreeIncludePatternToGitDescendantPathspec, worktreeIncludePatternToGitPathspec, @@ -113,6 +114,11 @@ export async function resolveWorktreeIncludePaths( if (!patterns.some((pattern) => !pattern.negated)) { return [] } + const ignoreCaseTimeout = deadline - Date.now() + if (ignoreCaseTimeout <= 0) { + throw new Error(`${WORKTREE_INCLUDE_FILE} resolution timed out`) + } + const ignoreCase = await getWorktreeIncludeIgnoreCase(repoPath, options, ignoreCaseTimeout) const candidates = new Map() let candidateBytes = 0 @@ -134,7 +140,13 @@ export async function resolveWorktreeIncludePaths( const addCandidate = (entry: GitignoredEntry): void => { if ( isSafeIncludeCandidate(entry.relativePath) && - isIncludedByWorktreePatterns(patterns, entry.relativePath, entry.isDirectory, matchBudget) + isIncludedByWorktreePatterns( + patterns, + entry.relativePath, + entry.isDirectory, + matchBudget, + ignoreCase + ) ) { if (hasCandidateDirectoryAncestor(candidates, entry.relativePath)) { return @@ -246,14 +258,14 @@ export async function resolveWorktreeIncludePaths( // 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)]), + ...(pattern.dirOnly ? [] : [worktreeIncludePatternToGitPathspec(pattern, ignoreCase)]), // Why: files beneath a matching directory expose directory matches that // Git 2.25 otherwise hides behind a collapsed ignored parent. - worktreeIncludePatternToGitDescendantPathspec(pattern) + worktreeIncludePatternToGitDescendantPathspec(pattern, ignoreCase) ]), ...Array.from(anchoredLiteralDirectories.entries()) .filter(([relativePath]) => !ignoredAnchoredDirectories.has(relativePath)) - .map(([, pattern]) => worktreeIncludePatternToGitDescendantPathspec(pattern)) + .map(([, pattern]) => worktreeIncludePatternToGitDescendantPathspec(pattern, ignoreCase)) ] if (filePathspecs.length > 0) { // Why: collapsed directories are already copied as units; excluding them diff --git a/src/main/git/worktree-include-flow.test.ts b/src/main/git/worktree-include-flow.test.ts index b90d810f1e4..b390ab33557 100644 --- a/src/main/git/worktree-include-flow.test.ts +++ b/src/main/git/worktree-include-flow.test.ts @@ -33,6 +33,7 @@ describe('worktree include creation flow', () => { }) it('copies only matched ignored paths and leaves ordinary removal clean', async () => { + await git(['config', 'core.ignoreCase', 'true']) await writeFile(join(primary, '.gitignore'), '.env\ncache/\nparent/\nmixed/local.txt\n') await writeFile( join(primary, '.worktreeinclude'), @@ -42,6 +43,8 @@ describe('worktree include creation flow', () => { await writeFile(join(primary, '.env'), 'ROOT=1\n') await mkdir(join(primary, 'apps', 'web'), { recursive: true }) await writeFile(join(primary, 'apps', 'web', '.env'), 'NESTED=1\n') + await mkdir(join(primary, 'case')) + await writeFile(join(primary, 'case', '.ENV'), 'CASE=1\n') await mkdir(join(primary, 'cache')) await writeFile(join(primary, 'cache', 'artifact'), 'cached\n') await mkdir(join(primary, 'parent', 'build'), { recursive: true }) @@ -58,6 +61,7 @@ describe('worktree include creation flow', () => { '.env', 'apps/web/.env', 'cache', + 'case/.ENV', 'mixed/local.txt', 'parent/build/marker' ]) @@ -67,6 +71,7 @@ describe('worktree include creation flow', () => { 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, 'case', '.ENV'), 'utf8')).toBe('CASE=1\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') diff --git a/src/main/git/worktree-include-git-enumeration.ts b/src/main/git/worktree-include-git-enumeration.ts index 38a4682400c..01d60ffbec3 100644 --- a/src/main/git/worktree-include-git-enumeration.ts +++ b/src/main/git/worktree-include-git-enumeration.ts @@ -18,6 +18,28 @@ type ListGitignoredEntriesOptions = { timeout: number } +type GitExecError = Error & { code?: number | string } + +export async function getWorktreeIncludeIgnoreCase( + repoPath: string, + options: GitRuntimeOptions, + timeout: number +): Promise { + try { + const { stdout } = await gitExecFileAsync(['config', '--bool', 'core.ignoreCase'], { + ...gitOptionsForWorktree(repoPath, options), + timeout + }) + return stdout.trim() === 'true' + } catch (error) { + const code = (error as GitExecError).code + if (code === 1 || code === '1') { + return false + } + throw error + } +} + export async function listGitignoredEntries( repoPath: string, options: GitRuntimeOptions, @@ -53,16 +75,20 @@ export async function listGitignoredEntries( return entries } -export function worktreeIncludePatternToGitPathspec(pattern: WorktreeIncludePattern): string { +export function worktreeIncludePatternToGitPathspec( + pattern: WorktreeIncludePattern, + ignoreCase: boolean = false +): 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}` + return `:(${ignoreCase ? 'icase,' : ''}glob)${pattern.anchored ? '' : '**/'}${body}` } export function worktreeIncludePatternToGitDescendantPathspec( - pattern: WorktreeIncludePattern + pattern: WorktreeIncludePattern, + ignoreCase: boolean = false ): string { - return `${worktreeIncludePatternToGitPathspec(pattern)}/**` + return `${worktreeIncludePatternToGitPathspec(pattern, ignoreCase)}/**` } export function chunkWorktreeIncludePathspecs( diff --git a/src/main/git/worktree-include-glob.ts b/src/main/git/worktree-include-glob.ts index a4946f6b478..f8a110bc5f5 100644 --- a/src/main/git/worktree-include-glob.ts +++ b/src/main/git/worktree-include-glob.ts @@ -1,5 +1,5 @@ type WorktreeIncludeGlobToken = - | { kind: 'literal'; value: string } + | { kind: 'literal'; value: string; caseFoldedValue: string } | { kind: 'one' } | { kind: 'segment-star' } | { kind: 'recursive-star' } @@ -7,6 +7,7 @@ type WorktreeIncludeGlobToken = export type CompiledWorktreeIncludeGlob = { regExp: RegExp | null + ignoreCaseRegExp: RegExp | null | undefined tokens: readonly WorktreeIncludeGlobToken[] variableWidthSuffixLength: number | null } @@ -36,7 +37,7 @@ export function compileWorktreeIncludeGlob(pattern: string): CompiledWorktreeInc tokens.push({ kind: 'one' }) regex += '[^/]' } else { - tokens.push({ kind: 'literal', value: char }) + tokens.push({ kind: 'literal', value: char, caseFoldedValue: char.toLowerCase() }) regex += char.replace(/[.+^${}()|[\]\\]/, '\\$&') } } @@ -46,6 +47,7 @@ export function compileWorktreeIncludeGlob(pattern: string): CompiledWorktreeInc return { // Why: multiple variable-width groups can make JS regex backtracking exponential. regExp: starCount <= 1 ? new RegExp(`^${regex}$`) : null, + ignoreCaseRegExp: starCount <= 1 ? undefined : null, tokens, variableWidthSuffixLength: variableWidthIndex === -1 ? null : tokens.length - variableWidthIndex - 1 @@ -68,12 +70,18 @@ export function getWorktreeIncludeGlobStepCount( export function matchesWorktreeIncludeGlob( pattern: CompiledWorktreeIncludeGlob, - value: string + value: string, + ignoreCase: boolean = false ): boolean { if (pattern.regExp !== null) { - return pattern.regExp.test(value) + if (!ignoreCase) { + return pattern.regExp.test(value) + } + pattern.ignoreCaseRegExp ??= new RegExp(pattern.regExp.source, 'i') + return pattern.ignoreCaseRegExp.test(value) } + const comparableValue = ignoreCase ? value.toLowerCase() : value let previous = new Uint8Array(value.length + 1) previous[0] = 1 for (const token of pattern.tokens) { @@ -81,19 +89,23 @@ export function matchesWorktreeIncludeGlob( if (token.kind === 'segment-star' || token.kind === 'recursive-star') { current[0] = previous[0] for (let index = 1; index <= value.length; index++) { - const canConsume = token.kind === 'recursive-star' || value[index - 1] !== '/' + const canConsume = token.kind === 'recursive-star' || comparableValue[index - 1] !== '/' current[index] = previous[index] || (canConsume && current[index - 1]) ? 1 : 0 } } else if (token.kind === 'optional-slash') { current[0] = previous[0] for (let index = 1; index <= value.length; index++) { current[index] = - previous[index] || (value[index - 1] === '/' && previous[index - 1]) ? 1 : 0 + previous[index] || (comparableValue[index - 1] === '/' && previous[index - 1]) ? 1 : 0 } } else { for (let index = 1; index <= value.length; index++) { const matches = - token.kind === 'one' ? value[index - 1] !== '/' : value[index - 1] === token.value + token.kind === 'one' + ? comparableValue[index - 1] !== '/' + : ignoreCase + ? comparableValue[index - 1] === token.caseFoldedValue + : comparableValue[index - 1] === token.value current[index] = previous[index - 1] && matches ? 1 : 0 } } diff --git a/src/main/git/worktree-include-pattern.ts b/src/main/git/worktree-include-pattern.ts index acb62a975f8..18161d05604 100644 --- a/src/main/git/worktree-include-pattern.ts +++ b/src/main/git/worktree-include-pattern.ts @@ -13,6 +13,7 @@ export type WorktreeIncludePattern = { negated: boolean /** Pattern with `!`, leading `/`, and trailing `/` stripped. */ body: string + caseFoldedBody: string dirOnly: boolean anchored: boolean hasGlob: boolean @@ -41,6 +42,7 @@ export function parseWorktreeIncludePatterns(content: string): WorktreeIncludePa patterns.push({ negated, body: trimmed, + caseFoldedBody: trimmed.toLowerCase(), dirOnly, anchored, hasGlob, @@ -63,34 +65,39 @@ function spendMatchBudget(budget: WorktreeIncludeMatchBudget, steps: number): vo function globMatches( pattern: WorktreeIncludePattern, subject: string, - budget: WorktreeIncludeMatchBudget + budget: WorktreeIncludeMatchBudget, + ignoreCase: boolean ): boolean { if (pattern.glob === null) { return false } spendMatchBudget(budget, getWorktreeIncludeGlobStepCount(pattern.glob, subject)) - return matchesWorktreeIncludeGlob(pattern.glob, subject) + return matchesWorktreeIncludeGlob(pattern.glob, subject, ignoreCase) } function patternMatches( pattern: WorktreeIncludePattern, relativePath: string, + caseFoldedRelativePath: string, isDirectory: boolean, - budget: WorktreeIncludeMatchBudget + budget: WorktreeIncludeMatchBudget, + ignoreCase: boolean ): boolean { + // Why: directory-only rules can reject root files before glob evaluation, so charge the otherwise-free scan. + spendMatchBudget(budget, relativePath.length + 1) if (!pattern.hasGlob) { + const matchPath = ignoreCase ? caseFoldedRelativePath : relativePath + const matchBody = ignoreCase ? pattern.caseFoldedBody : pattern.body if (pattern.anchored) { - spendMatchBudget(budget, relativePath.length + 1) return ( - (relativePath === pattern.body && (!pattern.dirOnly || isDirectory)) || - relativePath.startsWith(`${pattern.body}/`) + (matchPath === matchBody && (!pattern.dirOnly || isDirectory)) || + matchPath.startsWith(`${matchBody}/`) ) } - const segments = relativePath.split('/') + const segments = matchPath.split('/') const lastMatchableIndex = pattern.dirOnly && !isDirectory ? segments.length - 2 : segments.length - 1 - spendMatchBudget(budget, relativePath.length + 1) - return segments.slice(0, lastMatchableIndex + 1).includes(pattern.body) + return segments.slice(0, lastMatchableIndex + 1).includes(matchBody) } const segments = relativePath.split('/') @@ -101,7 +108,7 @@ function patternMatches( } if (!pattern.anchored) { for (let index = 0; index <= lastMatchableIndex; index++) { - if (globMatches(pattern, segments[index], budget)) { + if (globMatches(pattern, segments[index], budget, ignoreCase)) { return true } } @@ -111,7 +118,7 @@ function patternMatches( let prefix = '' for (let index = 0; index <= lastMatchableIndex; index++) { prefix = prefix ? `${prefix}/${segments[index]}` : segments[index] - if (globMatches(pattern, prefix, budget)) { + if (globMatches(pattern, prefix, budget, ignoreCase)) { return true } } @@ -122,12 +129,16 @@ export function isIncludedByWorktreePatterns( patterns: readonly WorktreeIncludePattern[], relativePath: string, isDirectory: boolean, - budget: WorktreeIncludeMatchBudget + budget: WorktreeIncludeMatchBudget, + ignoreCase: boolean = false ): boolean { // Why: gitignore patterns are order-sensitive, so negations must use last-match-wins. let included = false + const caseFoldedRelativePath = ignoreCase ? relativePath.toLowerCase() : relativePath for (const pattern of patterns) { - if (patternMatches(pattern, relativePath, isDirectory, budget)) { + if ( + patternMatches(pattern, relativePath, caseFoldedRelativePath, isDirectory, budget, ignoreCase) + ) { included = !pattern.negated } } diff --git a/src/main/git/worktree-include-stat-concurrency.test.ts b/src/main/git/worktree-include-stat-concurrency.test.ts index 4426e2be904..52ab005e643 100644 --- a/src/main/git/worktree-include-stat-concurrency.test.ts +++ b/src/main/git/worktree-include-stat-concurrency.test.ts @@ -49,7 +49,7 @@ describe('worktree include stat concurrency', () => { expect(peak).toBe(8) expect(lstatMock).toHaveBeenCalledTimes(25) - expect(gitExecFileAsync).toHaveBeenCalledTimes(1) + expect(gitExecFileAsync).toHaveBeenCalledTimes(2) }) it('stops starting filesystem probes when the resolution deadline expires', async () => { @@ -63,12 +63,13 @@ describe('worktree include stat concurrency', () => { } return new Promise(() => {}) }) + vi.mocked(gitExecFileAsync).mockRejectedValue(Object.assign(new Error('unset'), { code: 1 })) const resolution = resolveWorktreeIncludePaths('/repo') await vi.advanceTimersByTimeAsync(15_000) await expect(resolution).resolves.toEqual([]) expect(lstatMock).toHaveBeenCalledTimes(9) - expect(gitExecFileAsync).not.toHaveBeenCalled() + expect(gitExecFileAsync).toHaveBeenCalledTimes(1) }) }) diff --git a/src/shared/git-binary-compatibility.test.ts b/src/shared/git-binary-compatibility.test.ts index 2b471b006c5..7b35444d3bf 100644 --- a/src/shared/git-binary-compatibility.test.ts +++ b/src/shared/git-binary-compatibility.test.ts @@ -175,7 +175,7 @@ describeBinaryCompatibility('real Git binary compatibility', () => { }) it('lists collapsed directories and targeted nested files for worktree includes', async () => { - await writeFile(join(repoPath, '.gitignore'), '.env\ncache/\nbuild/\nparent/\n') + await writeFile(join(repoPath, '.gitignore'), '.env\ncache/\nbuild/\nparent/\ncase/\n') await writeFile(join(repoPath, '.env'), 'ROOT=1\n') await mkdir(join(repoPath, 'apps', 'web'), { recursive: true }) await writeFile(join(repoPath, 'apps', 'web', '.env'), 'NESTED=1\n') @@ -185,6 +185,8 @@ describeBinaryCompatibility('real Git binary compatibility', () => { await writeFile(join(repoPath, 'build', 'root-artifact'), 'ignored\n') await mkdir(join(repoPath, 'parent', 'build'), { recursive: true }) await writeFile(join(repoPath, 'parent', 'build', 'nested-artifact'), 'ignored\n') + await mkdir(join(repoPath, 'case')) + await writeFile(join(repoPath, 'case', '.ENV'), 'CASE=1\n') const collapsed = await runGit([ '-c', @@ -214,6 +216,19 @@ describeBinaryCompatibility('real Git binary compatibility', () => { ]) expect(targeted.stdout.split('\0').filter(Boolean)).toEqual(['.env', 'apps/web/.env']) + const caseInsensitiveTargeted = await runGit([ + '-c', + 'core.quotePath=false', + 'ls-files', + '--others', + '--ignored', + '--exclude-standard', + '-z', + '--', + ':(icase,glob)**/.env' + ]) + expect(caseInsensitiveTargeted.stdout.split('\0').filter(Boolean)).toContain('case/.ENV') + const targetedDirectory = await runGit([ '-c', 'core.quotePath=false',