fix(worktrees): harden include resolution

This commit is contained in:
Brennan Benson
2026-07-22 12:46:08 -07:00
parent b574104d46
commit 5284a2e6c8
8 changed files with 313 additions and 141 deletions
+34 -10
View File
@@ -3,7 +3,11 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { resolveWorktreeIncludePaths } from './worktree-include-file'
import { matchesWorktreeIncludeGlob } from './worktree-include-glob'
import {
compileWorktreeIncludeGlob,
getWorktreeIncludeGlobStepCount,
matchesWorktreeIncludeGlob
} from './worktree-include-glob'
import { parseWorktreeIncludePatterns } from './worktree-include-pattern'
import { gitExecFileAsync } from './runner'
@@ -75,6 +79,13 @@ describe('parseWorktreeIncludePatterns', () => {
expect(glob.glob && matchesWorktreeIncludeGlob(glob.glob, longNonMatch)).toBe(false)
})
it('charges variable-width regex backtracking against the literal suffix', () => {
const glob = compileWorktreeIncludeGlob(`*${'a'.repeat(1_000)}`)
expect(getWorktreeIncludeGlobStepCount(glob, 'short')).toBe(6_006)
expect(getWorktreeIncludeGlobStepCount(compileWorktreeIncludeGlob('prefix*'), 'short')).toBe(12)
})
it('rejects pathological pattern counts before worktree creation can fan out', () => {
expect(() => parseWorktreeIncludePatterns(`${'.env\n'.repeat(4_097)}`)).toThrow(
'contains too many patterns'
@@ -133,11 +144,13 @@ describe('resolveWorktreeIncludePaths', () => {
})
it('routes every Git probe through the selected WSL runtime', async () => {
writeInclude('.env\n')
mockGit({ entries: ['.env'], ignored: ['.env'] })
writeInclude('/cache/\n.env\n')
mkdirSync(join(repo, 'cache'))
mockGit({ collapsedEntries: ['cache/', '.env'], targetedEntries: ['.env'] })
await expect(resolveWorktreeIncludePaths(repo, { wslDistro: 'Ubuntu' })).resolves.toEqual([
'.env'
'.env',
'cache'
])
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(3)
@@ -146,16 +159,27 @@ describe('resolveWorktreeIncludePaths', () => {
).toBe(true)
})
it('avoids enumeration for root-anchored literal patterns', async () => {
writeInclude('/config/secrets.json\n')
mkdirSync(join(repo, 'config'))
writeFileSync(join(repo, 'config', 'secrets.json'), '{}')
mockGit({ ignored: ['config/secrets.json'] })
it('avoids enumeration for a fully ignored root-anchored directory', async () => {
writeInclude('/cache/\n')
mkdirSync(join(repo, 'cache'))
mockGit({ ignored: ['cache'] })
await expect(resolveWorktreeIncludePaths(repo)).resolves.toEqual(['config/secrets.json'])
await expect(resolveWorktreeIncludePaths(repo)).resolves.toEqual(['cache'])
expect(gitExecFileAsyncMock.mock.calls.every(([args]) => !args.includes('ls-files'))).toBe(true)
})
it('finds ignored descendants when a root-anchored directory is not itself ignored', async () => {
writeInclude('/config/\n')
mkdirSync(join(repo, 'config'))
mockGit({ targetedEntries: ['config/local.json'], ignored: ['config/local.json'] })
await expect(resolveWorktreeIncludePaths(repo)).resolves.toEqual(['config/local.json'])
const targetedArgs = gitExecFileAsyncMock.mock.calls.find(
([args]) => args.includes('ls-files') && !args.includes('--directory')
)?.[0]
expect(targetedArgs).toContain(':(glob)config/**')
})
it('drops listed paths that are not actually gitignored', async () => {
writeInclude('.env\ntracked.json\n')
writeFileSync(join(repo, '.env'), 'A=1')
+98 -124
View File
@@ -2,14 +2,20 @@ import { lstat, readFile } from 'node:fs/promises'
import { isAbsolute, join } from 'node:path'
import { checkIgnoredPaths } from './check-ignored-paths'
import type { GitRuntimeOptions } from './git-runtime-options'
import { gitOptionsForWorktree } from './git-runtime-options'
import { gitExecFileAsync } from './runner'
import {
chunkWorktreeIncludePathspecs,
listGitignoredEntries,
worktreeIncludePatternToGitDescendantPathspec,
worktreeIncludePatternToGitPathspec,
type GitignoredEntry
} from './worktree-include-git-enumeration'
import {
isIncludedByWorktreePatterns,
parseWorktreeIncludePatterns,
WORKTREE_INCLUDE_MATCH_STEP_BUDGET,
type WorktreeIncludePattern
} from './worktree-include-pattern'
import { mapWithConcurrency } from '../../shared/map-with-concurrency'
/** Project-level list of gitignored paths to copy into each new worktree.
* Cross-tool convention (gitignore syntax); see issue #7549. */
@@ -21,9 +27,7 @@ const WORKTREE_INCLUDE_GIT_TIMEOUT_MS = 15_000
const WORKTREE_INCLUDE_MAX_FILE_BYTES = 256 * 1024
const WORKTREE_INCLUDE_MAX_CANDIDATES = 10_000
const WORKTREE_INCLUDE_MAX_CANDIDATE_BYTES = 1024 * 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
const WORKTREE_INCLUDE_STAT_CONCURRENCY = 8
function isSafeIncludeCandidate(relativePath: string): boolean {
if (!relativePath || isAbsolute(relativePath)) {
@@ -33,12 +37,6 @@ function isSafeIncludeCandidate(relativePath: string): boolean {
return !segments.includes('..') && segments[0] !== '.git'
}
type GitignoredEntry = {
relativePath: string
isDirectory: boolean
fromIgnoredScan: boolean
}
function hasCandidateDirectoryAncestor(
candidates: ReadonlyMap<string, GitignoredEntry>,
relativePath: string
@@ -46,7 +44,7 @@ function hasCandidateDirectoryAncestor(
let separatorIndex = relativePath.indexOf('/')
while (separatorIndex !== -1) {
const ancestor = candidates.get(relativePath.slice(0, separatorIndex))
if (ancestor?.isDirectory && ancestor.fromIgnoredScan) {
if (ancestor?.isDirectory && ancestor.coversDescendants) {
return true
}
separatorIndex = relativePath.indexOf('/', separatorIndex + 1)
@@ -54,93 +52,6 @@ function hasCandidateDirectoryAncestor(
return false
}
type ListGitignoredEntriesOptions = {
collapseDirectories: boolean
pathspecs?: readonly string[]
timeout: number
}
async function listGitignoredEntries(
repoPath: string,
options: GitRuntimeOptions,
listOptions: ListGitignoredEntriesOptions
): Promise<GitignoredEntry[]> {
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) {
continue
}
const isDirectory = rawEntry.endsWith('/')
entries.push({
relativePath: isDirectory ? rawEntry.replace(/\/+$/, '') : rawEntry,
isDirectory,
fromIgnoredScan: true
})
}
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 patternToGitDescendantPathspec(pattern: WorktreeIncludePattern): string {
return `${patternToGitPathspec(pattern)}/**`
}
function chunkPathspecs(
pathspecs: readonly string[],
sharedPathspecs: readonly string[] = []
): string[][] {
const chunks: string[][] = []
let chunk: string[] = []
const shared = Array.from(new Set(sharedPathspecs))
const sharedLength = shared.reduce((length, pathspec) => length + pathspec.length, 0)
if (sharedLength >= WORKTREE_INCLUDE_PATHSPEC_CHUNK_LENGTH) {
return chunks
}
let chunkLength = sharedLength
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 + sharedLength > WORKTREE_INCLUDE_PATHSPEC_CHUNK_LENGTH) {
continue
}
if (
chunk.length > 0 &&
chunkLength + pathspec.length > WORKTREE_INCLUDE_PATHSPEC_CHUNK_LENGTH
) {
chunks.push([...chunk, ...shared])
chunk = []
chunkLength = sharedLength
}
chunk.push(pathspec)
chunkLength += pathspec.length
}
if (chunk.length > 0) {
chunks.push([...chunk, ...shared])
}
return chunks
}
async function readWorktreeIncludeFile(repoPath: string): Promise<string | null> {
const includePath = join(repoPath, WORKTREE_INCLUDE_FILE)
try {
@@ -201,7 +112,7 @@ export async function resolveWorktreeIncludePaths(
// Why: anchored literals cannot be discovered more cheaply than a direct probe;
// broad literals use the bounded Git scans below instead of serial filesystem I/O.
const anchoredLiteralPaths = new Set(
const anchoredLiteralPatterns = new Map(
patterns
.filter(
(pattern) =>
@@ -210,18 +121,33 @@ export async function resolveWorktreeIncludePaths(
pattern.anchored &&
isSafeIncludeCandidate(pattern.body)
)
.map((pattern) => pattern.body)
.map((pattern) => [pattern.body, pattern] as const)
)
for (const relativePath of anchoredLiteralPaths) {
try {
const stats = await lstat(join(repoPath, relativePath))
const anchoredLiteralDirectories = new Map<string, WorktreeIncludePattern>()
// Why: thousands of serial UNC/WSL stats can stall creation, while unbounded fanout can swamp the filesystem.
const anchoredLiteralStats = await mapWithConcurrency(
Array.from(anchoredLiteralPatterns),
WORKTREE_INCLUDE_STAT_CONCURRENCY,
async ([relativePath, pattern]) => {
try {
const stats = await lstat(join(repoPath, relativePath))
return { relativePath, pattern, isDirectory: stats.isDirectory() }
} catch {
return null
}
}
)
for (const result of anchoredLiteralStats) {
if (result) {
const { relativePath, pattern, isDirectory } = result
addCandidate({
relativePath,
isDirectory: stats.isDirectory(),
fromIgnoredScan: false
isDirectory,
coversDescendants: false
})
} catch {
// Absent in the primary checkout — nothing to copy.
if (isDirectory && candidates.has(relativePath)) {
anchoredLiteralDirectories.set(relativePath, pattern)
}
}
}
@@ -237,22 +163,60 @@ export async function resolveWorktreeIncludePaths(
})) {
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.flatMap((pattern) => [
...(pattern.dirOnly ? [] : [patternToGitPathspec(pattern)]),
// Why: an anchored directory can contain tracked files while only some descendants are ignored; only fully ignored directories are safe to copy as one unit.
const checkedAnchoredDirectories = new Set(anchoredLiteralDirectories.keys())
const ignoredAnchoredDirectories = new Set<string>()
const anchoredDirectoriesToCheck: string[] = []
for (const relativePath of checkedAnchoredDirectories) {
if (candidates.get(relativePath)?.coversDescendants) {
ignoredAnchoredDirectories.add(relativePath)
} else {
anchoredDirectoriesToCheck.push(relativePath)
}
}
if (anchoredDirectoriesToCheck.length > 0) {
const timeout = deadline - Date.now()
if (timeout <= 0) {
throw new Error(`${WORKTREE_INCLUDE_FILE} resolution timed out`)
}
for (const relativePath of await checkIgnoredPaths(
repoPath,
anchoredDirectoriesToCheck,
options,
timeout
)) {
ignoredAnchoredDirectories.add(relativePath)
const candidate = candidates.get(relativePath)
if (candidate?.isDirectory) {
candidate.coversDescendants = true
}
}
}
// 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)]),
// Why: files beneath a matching directory expose directory matches that
// Git 2.25 otherwise hides behind a collapsed ignored parent.
patternToGitDescendantPathspec(pattern)
])
worktreeIncludePatternToGitDescendantPathspec(pattern)
]),
...Array.from(anchoredLiteralDirectories.entries())
.filter(([relativePath]) => !ignoredAnchoredDirectories.has(relativePath))
.map(([, pattern]) => worktreeIncludePatternToGitDescendantPathspec(pattern))
]
if (filePathspecs.length > 0) {
// Why: collapsed directories are already copied as units; excluding them
// keeps a targeted nested scan from expanding node_modules-scale trees.
const coveredDirectoryPathspecs = Array.from(candidates.values())
.filter((entry) => entry.isDirectory && entry.fromIgnoredScan)
.filter((entry) => entry.isDirectory && entry.coversDescendants)
.map((entry) => `:(exclude,literal)${entry.relativePath}`)
for (const pathspecs of chunkPathspecs(filePathspecs, coveredDirectoryPathspecs)) {
for (const pathspecs of chunkWorktreeIncludePathspecs(
filePathspecs,
coveredDirectoryPathspecs
)) {
const timeout = deadline - Date.now()
if (timeout <= 0) {
throw new Error(`${WORKTREE_INCLUDE_FILE} Git enumeration timed out`)
@@ -279,16 +243,26 @@ export async function resolveWorktreeIncludePaths(
) {
throw new Error(`${WORKTREE_INCLUDE_FILE} matched paths exceed its byte budget`)
}
const checkIgnoreTimeout = deadline - Date.now()
if (checkIgnoreTimeout <= 0) {
throw new Error(`${WORKTREE_INCLUDE_FILE} resolution timed out`)
}
// Why: enforce the gitignored-only contract for literal-derived candidates
// too — a listed-but-not-ignored path must not be copied (issue #7549).
const ignored = new Set(
await checkIgnoredPaths(repoPath, candidatePaths, options, checkIgnoreTimeout)
const ignored = new Set(ignoredAnchoredDirectories)
const uncheckedCandidatePaths = candidatePaths.filter(
(relativePath) => !checkedAnchoredDirectories.has(relativePath)
)
if (uncheckedCandidatePaths.length > 0) {
const timeout = deadline - Date.now()
if (timeout <= 0) {
throw new Error(`${WORKTREE_INCLUDE_FILE} resolution timed out`)
}
for (const relativePath of await checkIgnoredPaths(
repoPath,
uncheckedCandidatePaths,
options,
timeout
)) {
ignored.add(relativePath)
}
}
return candidatePaths.filter((relativePath) => ignored.has(relativePath)).sort()
} catch (error) {
console.warn(`[worktree-include] Failed to resolve ${WORKTREE_INCLUDE_FILE} patterns:`, error)
+18 -4
View File
@@ -33,8 +33,11 @@ describe('worktree include creation flow', () => {
})
it('copies only matched ignored paths and leaves ordinary removal clean', async () => {
await writeFile(join(primary, '.gitignore'), '.env\ncache/\nparent/\n')
await writeFile(join(primary, '.worktreeinclude'), '.env\ncache/\nbuild/\ntracked.txt\n')
await writeFile(join(primary, '.gitignore'), '.env\ncache/\nparent/\nmixed/local.txt\n')
await writeFile(
join(primary, '.worktreeinclude'),
'.env\ncache/\nbuild/\n/mixed/\ntracked.txt\n'
)
await writeFile(join(primary, 'tracked.txt'), 'tracked\n')
await writeFile(join(primary, '.env'), 'ROOT=1\n')
await mkdir(join(primary, 'apps', 'web'), { recursive: true })
@@ -43,18 +46,29 @@ describe('worktree include creation flow', () => {
await writeFile(join(primary, 'cache', 'artifact'), 'cached\n')
await mkdir(join(primary, 'parent', 'build'), { recursive: true })
await writeFile(join(primary, 'parent', 'build', 'marker'), 'built\n')
await git(['add', '.gitignore', '.worktreeinclude', 'tracked.txt'])
await mkdir(join(primary, 'mixed'))
await writeFile(join(primary, 'mixed', 'tracked.txt'), 'tracked\n')
await writeFile(join(primary, 'mixed', 'local.txt'), 'local\n')
await git(['add', '.gitignore', '.worktreeinclude', 'tracked.txt', 'mixed/tracked.txt'])
await git(['commit', '-qm', 'initial'])
await git(['worktree', 'add', '-q', '-b', 'feature', worktree])
const includePaths = await resolveWorktreeIncludePaths(primary)
expect(includePaths).toEqual(['.env', 'apps/web/.env', 'cache', 'parent/build/marker'])
expect(includePaths).toEqual([
'.env',
'apps/web/.env',
'cache',
'mixed/local.txt',
'parent/build/marker'
])
await createWorktreeCopiedPaths(primary, worktree, includePaths, { platform: 'linux' })
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, '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')
expect((await lstat(join(worktree, 'tracked.txt'))).isFile()).toBe(true)
@@ -0,0 +1,100 @@
import type { GitRuntimeOptions } from './git-runtime-options'
import { gitOptionsForWorktree } from './git-runtime-options'
import { gitExecFileAsync } from './runner'
import type { WorktreeIncludePattern } from './worktree-include-pattern'
// Why: Windows limits native command lines to 32K UTF-16 code units; leave room for Git's fixed arguments and repo path.
const WORKTREE_INCLUDE_PATHSPEC_CHUNK_LENGTH = 12 * 1024
export type GitignoredEntry = {
relativePath: string
isDirectory: boolean
coversDescendants: boolean
}
type ListGitignoredEntriesOptions = {
collapseDirectories: boolean
pathspecs?: readonly string[]
timeout: number
}
export async function listGitignoredEntries(
repoPath: string,
options: GitRuntimeOptions,
listOptions: ListGitignoredEntriesOptions
): Promise<GitignoredEntry[]> {
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) {
continue
}
const isDirectory = rawEntry.endsWith('/')
entries.push({
relativePath: isDirectory ? rawEntry.replace(/\/+$/, '') : rawEntry,
isDirectory,
coversDescendants: isDirectory && listOptions.collapseDirectories
})
}
return entries
}
export function worktreeIncludePatternToGitPathspec(pattern: WorktreeIncludePattern): 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}`
}
export function worktreeIncludePatternToGitDescendantPathspec(
pattern: WorktreeIncludePattern
): string {
return `${worktreeIncludePatternToGitPathspec(pattern)}/**`
}
export function chunkWorktreeIncludePathspecs(
pathspecs: readonly string[],
sharedPathspecs: readonly string[] = []
): string[][] {
const chunks: string[][] = []
let chunk: string[] = []
const shared = Array.from(new Set(sharedPathspecs))
const sharedLength = shared.reduce((length, pathspec) => length + pathspec.length, 0)
if (sharedLength >= WORKTREE_INCLUDE_PATHSPEC_CHUNK_LENGTH) {
return chunks
}
let chunkLength = sharedLength
for (const pathspec of new Set(pathspecs)) {
// Why: the collapsed scan remains a safe fallback when one pattern cannot fit the Windows command line.
if (pathspec.length + sharedLength > WORKTREE_INCLUDE_PATHSPEC_CHUNK_LENGTH) {
continue
}
if (
chunk.length > 0 &&
chunkLength + pathspec.length > WORKTREE_INCLUDE_PATHSPEC_CHUNK_LENGTH
) {
chunks.push([...chunk, ...shared])
chunk = []
chunkLength = sharedLength
}
chunk.push(pathspec)
chunkLength += pathspec.length
}
if (chunk.length > 0) {
chunks.push([...chunk, ...shared])
}
return chunks
}
+15 -2
View File
@@ -8,6 +8,7 @@ type WorktreeIncludeGlobToken =
export type CompiledWorktreeIncludeGlob = {
regExp: RegExp | null
tokens: readonly WorktreeIncludeGlobToken[]
variableWidthSuffixLength: number | null
}
export function compileWorktreeIncludeGlob(pattern: string): CompiledWorktreeIncludeGlob {
@@ -39,10 +40,15 @@ export function compileWorktreeIncludeGlob(pattern: string): CompiledWorktreeInc
regex += char.replace(/[.+^${}()|[\]\\]/, '\\$&')
}
}
const variableWidthIndex = tokens.findIndex(
(token) => token.kind === 'segment-star' || token.kind === 'recursive-star'
)
return {
// Why: multiple variable-width groups can make JS regex backtracking exponential.
regExp: starCount <= 1 ? new RegExp(`^${regex}$`) : null,
tokens
tokens,
variableWidthSuffixLength:
variableWidthIndex === -1 ? null : tokens.length - variableWidthIndex - 1
}
}
@@ -50,7 +56,14 @@ export function getWorktreeIncludeGlobStepCount(
pattern: CompiledWorktreeIncludeGlob,
value: string
): number {
return pattern.regExp === null ? pattern.tokens.length * (value.length + 1) : value.length + 1
if (pattern.regExp === null) {
return pattern.tokens.length * (value.length + 1)
}
if (pattern.variableWidthSuffixLength !== null) {
const prefixLength = pattern.tokens.length - pattern.variableWidthSuffixLength - 1
return prefixLength + (pattern.variableWidthSuffixLength + 1) * (value.length + 1)
}
return pattern.tokens.length + value.length + 1
}
export function matchesWorktreeIncludeGlob(
@@ -0,0 +1,44 @@
import { describe, expect, it, vi } from 'vitest'
import { resolveWorktreeIncludePaths, WORKTREE_INCLUDE_FILE } from './worktree-include-file'
import { gitExecFileAsync } from './runner'
const lstatMock = vi.hoisted(() => vi.fn())
const readFileMock = vi.hoisted(() => vi.fn())
vi.mock('node:fs/promises', () => ({
lstat: lstatMock,
readFile: readFileMock
}))
vi.mock('./runner', () => ({
gitExecFileAsync: vi.fn()
}))
describe('worktree include stat concurrency', () => {
it('bounds anchored literal probes instead of awaiting thousands serially', async () => {
let active = 0
let peak = 0
readFileMock.mockResolvedValue(
Array.from({ length: 24 }, (_, index) => `/config/file-${index}`).join('\n')
)
lstatMock.mockImplementation(async (path: string) => {
if (path.endsWith(WORKTREE_INCLUDE_FILE)) {
return { isFile: () => true, size: 1_024 }
}
active++
peak = Math.max(peak, active)
await new Promise((resolve) => setTimeout(resolve, 1))
active--
return { isDirectory: () => false }
})
vi.mocked(gitExecFileAsync).mockRejectedValue(
Object.assign(new Error('no ignored paths'), { code: 1 })
)
await expect(resolveWorktreeIncludePaths('/repo')).resolves.toEqual([])
expect(peak).toBe(8)
expect(lstatMock).toHaveBeenCalledTimes(25)
expect(gitExecFileAsync).toHaveBeenCalledTimes(1)
})
})
+3 -1
View File
@@ -2461,7 +2461,9 @@ export async function createLocalWorktree(
// Why: project-level `.worktreeinclude` travels with the repo (issue #7549); copy semantics
// (never symlink) so each worktree owns its files. Paths already linked above are skipped.
const includePaths = await resolveWorktreeIncludePaths(repo.path, localWorktreeGitOptions)
const includePaths = await timing.time('resolve_worktreeinclude', () =>
resolveWorktreeIncludePaths(repo.path, localWorktreeGitOptions)
)
if (includePaths.length > 0) {
await timing.time('copy_worktreeinclude', async () => {
await createWorktreeCopiedPaths(repo.path, created.path, includePaths, {
+1
View File
@@ -1115,6 +1115,7 @@ describe('registerWorktreeHandlers', () => {
expect.arrayContaining([
'git_worktree_add',
'list_created_worktree',
'resolve_worktreeinclude',
'prepare_setup',
'spawn_startup_terminal'
])