fix(worktrees): support nested includes on Git 2.25

This commit is contained in:
Brennan Benson
2026-07-21 16:34:39 -07:00
parent 0403321500
commit bf50aa97d5
3 changed files with 117 additions and 31 deletions
@@ -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 () => {
+91 -24
View File
@@ -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<GitignoredEntry[]> {
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<string | null> {
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 []
+19 -7
View File
@@ -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'])
})
})