mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 16:03:17 +00:00
fix(worktrees): harden include matching
This commit is contained in:
@@ -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')
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user