fix(worktrees): harden include matching

This commit is contained in:
Brennan Benson
2026-07-22 15:48:33 -07:00
parent ac9bb3b2fe
commit 8c30d6dada
8 changed files with 167 additions and 33 deletions
+54 -2
View File
@@ -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')
+16 -4
View File
@@ -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<string, GitignoredEntry>()
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
@@ -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')
@@ -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<boolean> {
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(
+19 -7
View File
@@ -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
}
}
+24 -13
View File
@@ -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
}
}
@@ -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)
})
})
+16 -1
View File
@@ -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',