fix(worktrees): close include correctness and perf gaps

This commit is contained in:
Brennan Benson
2026-07-21 18:45:41 -07:00
parent 7f5f9f4d70
commit de9c845371
11 changed files with 469 additions and 156 deletions
+8
View File
@@ -44,6 +44,14 @@ describe('checkIgnoredPaths', () => {
expect(gitExecFileAsyncMock.mock.calls[0]?.[1].stdin?.split('\0')).toHaveLength(10_001)
})
it('accepts a shorter caller-owned deadline', async () => {
gitExecFileAsyncMock.mockResolvedValue({ stdout: '.env\0', stderr: '' })
await expect(checkIgnoredPaths('/repo', ['.env'], {}, 250)).resolves.toEqual(['.env'])
expect(gitExecFileAsyncMock.mock.calls[0]?.[1]).toMatchObject({ timeout: 250 })
})
it('treats exit code 1 as no ignored paths', async () => {
gitExecFileAsyncMock.mockRejectedValue(Object.assign(new Error('no matches'), { code: 1 }))
+6 -4
View File
@@ -14,13 +14,14 @@ type GitExecError = Error & { stdout?: string; code?: number | string }
async function runCheckIgnoredPaths(
worktreePath: string,
relativePaths: string[],
options: GitRuntimeOptions
options: GitRuntimeOptions,
timeoutMs: number
): Promise<string[]> {
try {
const { stdout } = await gitExecFileAsync([...GIT_CHECK_IGNORE_STDIN_ARGS], {
...gitOptionsForWorktree(worktreePath, options),
stdin: encodeGitCheckIgnorePaths(relativePaths),
timeout: GIT_CHECK_IGNORE_TIMEOUT_MS
timeout: timeoutMs
})
return parseGitCheckIgnorePaths(stdout)
} catch (error) {
@@ -35,14 +36,15 @@ async function runCheckIgnoredPaths(
export async function checkIgnoredPaths(
worktreePath: string,
relativePaths: string[],
options: GitRuntimeOptions = {}
options: GitRuntimeOptions = {},
timeoutMs: number = GIT_CHECK_IGNORE_TIMEOUT_MS
): Promise<string[]> {
if (relativePaths.length === 0) {
return []
}
const ignored = new Set<string>()
for (const chunk of splitGitCheckIgnorePathsByStdinBytes(relativePaths)) {
for (const ignoredPath of await runCheckIgnoredPaths(worktreePath, chunk, options)) {
for (const ignoredPath of await runCheckIgnoredPaths(worktreePath, chunk, options, timeoutMs)) {
ignored.add(ignoredPath)
}
}
+90 -9
View File
@@ -2,8 +2,9 @@ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { parseWorktreeIncludePatterns, resolveWorktreeIncludePaths } from './worktree-include-file'
import { resolveWorktreeIncludePaths } from './worktree-include-file'
import { matchesWorktreeIncludeGlob } from './worktree-include-glob'
import { parseWorktreeIncludePatterns } from './worktree-include-pattern'
import { gitExecFileAsync } from './runner'
vi.mock('./runner', () => ({
@@ -12,12 +13,19 @@ vi.mock('./runner', () => ({
const gitExecFileAsyncMock = vi.mocked(gitExecFileAsync)
/** Dispatch on subcommand: ls-files returns `entries`; check-ignore echoes back
* every stdin path present in `ignored`. */
function mockGit(options: { entries?: string[]; ignored?: string[] }): void {
/** Dispatch on subcommand: check-ignore echoes every stdin path present in `ignored`. */
function mockGit(options: {
entries?: string[]
collapsedEntries?: string[]
targetedEntries?: string[]
ignored?: string[]
}): void {
gitExecFileAsyncMock.mockImplementation(async (args, execOptions) => {
if (args.includes('ls-files')) {
return { stdout: (options.entries ?? []).map((entry) => `${entry}\0`).join(''), stderr: '' }
const entries = args.includes('--directory')
? (options.collapsedEntries ?? options.entries ?? [])
: (options.targetedEntries ?? options.entries ?? [])
return { stdout: entries.map((entry) => `${entry}\0`).join(''), stderr: '' }
}
if (args.includes('check-ignore')) {
const requested = (execOptions.stdin ?? '').split('\0').filter(Boolean)
@@ -66,6 +74,12 @@ describe('parseWorktreeIncludePatterns', () => {
).toBe(true)
expect(glob.glob && matchesWorktreeIncludeGlob(glob.glob, longNonMatch)).toBe(false)
})
it('rejects pathological pattern counts before worktree creation can fan out', () => {
expect(() => parseWorktreeIncludePatterns(`${'.env\n'.repeat(4_097)}`)).toThrow(
'contains too many patterns'
)
})
})
describe('resolveWorktreeIncludePaths', () => {
@@ -94,7 +108,7 @@ describe('resolveWorktreeIncludePaths', () => {
writeFileSync(join(repo, '.env'), 'A=1')
mkdirSync(join(repo, 'config'))
writeFileSync(join(repo, 'config', 'secrets.json'), '{}')
mockGit({ ignored: ['.env', 'config/secrets.json'] })
mockGit({ entries: ['.env'], ignored: ['.env', 'config/secrets.json'] })
await expect(resolveWorktreeIncludePaths(repo)).resolves.toEqual([
'.env',
@@ -118,6 +132,20 @@ describe('resolveWorktreeIncludePaths', () => {
expect(lsFilesCalls[1][0]).toContain(':(glob)**/.env')
})
it('routes every Git probe through the selected WSL runtime', async () => {
writeInclude('.env\n')
mockGit({ entries: ['.env'], ignored: ['.env'] })
await expect(resolveWorktreeIncludePaths(repo, { wslDistro: 'Ubuntu' })).resolves.toEqual([
'.env'
])
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(3)
expect(
gitExecFileAsyncMock.mock.calls.every(([, options]) => options.wslDistro === 'Ubuntu')
).toBe(true)
})
it('avoids enumeration for root-anchored literal patterns', async () => {
writeInclude('/config/secrets.json\n')
mkdirSync(join(repo, 'config'))
@@ -132,7 +160,7 @@ describe('resolveWorktreeIncludePaths', () => {
writeInclude('.env\ntracked.json\n')
writeFileSync(join(repo, '.env'), 'A=1')
writeFileSync(join(repo, 'tracked.json'), '{}')
mockGit({ ignored: ['.env'] })
mockGit({ entries: ['.env'], ignored: ['.env'] })
await expect(resolveWorktreeIncludePaths(repo)).resolves.toEqual(['.env'])
})
@@ -160,10 +188,63 @@ describe('resolveWorktreeIncludePaths', () => {
await expect(resolveWorktreeIncludePaths(repo)).resolves.toEqual([])
rmSync(join(repo, 'logs'))
mkdirSync(join(repo, 'logs'))
mockGit({ ignored: ['logs'] })
mockGit({ collapsedEntries: ['logs/'], ignored: ['logs'] })
await expect(resolveWorktreeIncludePaths(repo)).resolves.toEqual(['logs'])
})
it('finds a matching directory beneath a collapsed ignored parent', async () => {
writeInclude('build/\n')
mockGit({
collapsedEntries: ['parent/'],
targetedEntries: ['parent/build/marker'],
ignored: ['parent/build/marker']
})
await expect(resolveWorktreeIncludePaths(repo)).resolves.toEqual(['parent/build/marker'])
const targetedArgs = gitExecFileAsyncMock.mock.calls.find(
([args]) => args.includes('ls-files') && !args.includes('--directory')
)?.[0]
expect(targetedArgs).toContain(':(glob)**/build/**')
})
it('does not expand a matched directory during the targeted scan', async () => {
writeInclude('node_modules/\n')
mockGit({ collapsedEntries: ['node_modules/'], ignored: ['node_modules'] })
await expect(resolveWorktreeIncludePaths(repo)).resolves.toEqual(['node_modules'])
const targetedArgs = gitExecFileAsyncMock.mock.calls.find(
([args]) => args.includes('ls-files') && !args.includes('--directory')
)?.[0]
expect(targetedArgs).toContain(':(glob)**/node_modules/**')
expect(targetedArgs).toContain(':(exclude,literal)node_modules')
})
it('does not let an unignored literal parent hide an ignored nested match', async () => {
writeInclude('/config\n.env\n')
mkdirSync(join(repo, 'config'))
mockGit({ entries: ['config/.env'], ignored: ['config/.env'] })
await expect(resolveWorktreeIncludePaths(repo)).resolves.toEqual(['config/.env'])
})
it('bounds the number of paths that can reach copy materialization', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
writeInclude('*\n')
mockGit({
collapsedEntries: Array.from({ length: 10_001 }, (_, index) => `ignored-${index}`)
})
await expect(resolveWorktreeIncludePaths(repo)).resolves.toEqual([])
expect(warn).toHaveBeenCalledWith(
expect.stringContaining('Failed to resolve'),
expect.objectContaining({ message: expect.stringContaining('matched too many paths') })
)
expect(gitExecFileAsyncMock.mock.calls.some(([args]) => args.includes('check-ignore'))).toBe(
false
)
warn.mockRestore()
})
it('applies negations with last-match-wins semantics', async () => {
writeInclude('.env.*\n!.env.production\n')
mockGit({
@@ -177,7 +258,7 @@ describe('resolveWorktreeIncludePaths', () => {
it('rejects traversal, absolute, and .git patterns', async () => {
writeInclude('../outside\n/etc/passwd\n.git/config\n.env\n')
writeFileSync(join(repo, '.env'), 'A=1')
mockGit({ ignored: ['.env'] })
mockGit({ entries: ['.env'], ignored: ['.env'] })
await expect(resolveWorktreeIncludePaths(repo)).resolves.toEqual(['.env'])
})
+103 -117
View File
@@ -5,11 +5,11 @@ import type { GitRuntimeOptions } from './git-runtime-options'
import { gitOptionsForWorktree } from './git-runtime-options'
import { gitExecFileAsync } from './runner'
import {
compileWorktreeIncludeGlob,
getWorktreeIncludeGlobStepCount,
matchesWorktreeIncludeGlob,
type CompiledWorktreeIncludeGlob
} from './worktree-include-glob'
isIncludedByWorktreePatterns,
parseWorktreeIncludePatterns,
WORKTREE_INCLUDE_MATCH_STEP_BUDGET,
type WorktreeIncludePattern
} from './worktree-include-pattern'
/** Project-level list of gitignored paths to copy into each new worktree.
* Cross-tool convention (gitignore syntax); see issue #7549. */
@@ -19,96 +19,12 @@ 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: glob filtering runs on the main process outside the Git subprocess deadline.
const WORKTREE_INCLUDE_GLOB_STEP_BUDGET = 5_000_000
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
type WorktreeIncludePattern = {
negated: boolean
/** Pattern with `!`, leading `/`, and trailing `/` stripped. */
body: string
dirOnly: boolean
anchored: boolean
hasGlob: boolean
glob: CompiledWorktreeIncludeGlob | null
}
export function parseWorktreeIncludePatterns(content: string): WorktreeIncludePattern[] {
const patterns: WorktreeIncludePattern[] = []
for (const rawLine of content.split(/\r?\n/)) {
const line = rawLine.trim()
if (!line || line.startsWith('#')) {
continue
}
const negated = line.startsWith('!')
const withoutNegation = negated ? line.slice(1) : line
const dirOnly = withoutNegation.endsWith('/')
// Why: gitignore semantics — a slash anywhere except the end anchors the
// pattern to the repo root; a bare name matches at any depth.
const trimmed = withoutNegation.replace(/^\//, '').replace(/\/+$/, '')
if (!trimmed) {
continue
}
const anchored = withoutNegation.startsWith('/') || trimmed.includes('/')
const hasGlob = /[*?]/.test(trimmed)
patterns.push({
negated,
body: trimmed,
dirOnly,
anchored,
hasGlob,
glob: hasGlob ? compileWorktreeIncludeGlob(trimmed) : null
})
}
return patterns
}
function patternMatches(
pattern: WorktreeIncludePattern,
relativePath: string,
isDirectory: boolean,
globBudget: { remaining: number }
): boolean {
if (pattern.dirOnly && !isDirectory) {
return false
}
// Why: like gitignore, a matched directory brings everything beneath it, so
// a path inside a matched prefix counts as matched.
const subject = pattern.anchored ? relativePath : (relativePath.split('/').at(-1) ?? relativePath)
if (!pattern.hasGlob) {
return (
subject === pattern.body || (pattern.anchored && relativePath.startsWith(`${pattern.body}/`))
)
}
if (pattern.glob === null) {
return false
}
globBudget.remaining -= getWorktreeIncludeGlobStepCount(pattern.glob, subject)
if (globBudget.remaining < 0) {
throw new Error(`${WORKTREE_INCLUDE_FILE} glob matching exceeded its CPU budget`)
}
return matchesWorktreeIncludeGlob(pattern.glob, subject)
}
function isIncludedByPatterns(
patterns: readonly WorktreeIncludePattern[],
relativePath: string,
isDirectory: boolean,
globBudget: { remaining: number }
): boolean {
// Why: gitignore semantics — the last matching pattern wins, so `!` lines
// can carve exceptions out of an earlier broad include.
let included = false
for (const pattern of patterns) {
if (patternMatches(pattern, relativePath, isDirectory, globBudget)) {
included = !pattern.negated
}
}
return included
}
function isSafeIncludeCandidate(relativePath: string): boolean {
if (!relativePath || isAbsolute(relativePath)) {
return false
@@ -120,6 +36,22 @@ function isSafeIncludeCandidate(relativePath: string): boolean {
type GitignoredEntry = {
relativePath: string
isDirectory: boolean
fromIgnoredScan: boolean
}
function hasCandidateDirectoryAncestor(
candidates: ReadonlyMap<string, GitignoredEntry>,
relativePath: string
): boolean {
let separatorIndex = relativePath.indexOf('/')
while (separatorIndex !== -1) {
const ancestor = candidates.get(relativePath.slice(0, separatorIndex))
if (ancestor?.isDirectory && ancestor.fromIgnoredScan) {
return true
}
separatorIndex = relativePath.indexOf('/', separatorIndex + 1)
}
return false
}
type ListGitignoredEntriesOptions = {
@@ -156,7 +88,8 @@ async function listGitignoredEntries(
const isDirectory = rawEntry.endsWith('/')
entries.push({
relativePath: isDirectory ? rawEntry.replace(/\/+$/, '') : rawEntry,
isDirectory
isDirectory,
fromIgnoredScan: true
})
}
return entries
@@ -169,29 +102,41 @@ function patternToGitPathspec(pattern: WorktreeIncludePattern): string {
return `:(glob)${pattern.anchored ? '' : '**/'}${body}`
}
function chunkPathspecs(pathspecs: readonly string[]): string[][] {
function patternToGitDescendantPathspec(pattern: WorktreeIncludePattern): string {
return `${patternToGitPathspec(pattern)}/**`
}
function chunkPathspecs(
pathspecs: readonly string[],
sharedPathspecs: readonly string[] = []
): string[][] {
const chunks: string[][] = []
let chunk: string[] = []
let chunkLength = 0
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 > WORKTREE_INCLUDE_PATHSPEC_CHUNK_LENGTH) {
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)
chunks.push([...chunk, ...shared])
chunk = []
chunkLength = 0
chunkLength = sharedLength
}
chunk.push(pathspec)
chunkLength += pathspec.length
}
if (chunk.length > 0) {
chunks.push(chunk)
chunks.push([...chunk, ...shared])
}
return chunks
}
@@ -234,25 +179,47 @@ export async function resolveWorktreeIncludePaths(
}
const candidates = new Map<string, GitignoredEntry>()
const globBudget = { remaining: WORKTREE_INCLUDE_GLOB_STEP_BUDGET }
const matchBudget = { remaining: WORKTREE_INCLUDE_MATCH_STEP_BUDGET }
const deadline = Date.now() + WORKTREE_INCLUDE_GIT_TIMEOUT_MS
const addCandidate = (entry: GitignoredEntry): void => {
if (
isSafeIncludeCandidate(entry.relativePath) &&
isIncludedByPatterns(patterns, entry.relativePath, entry.isDirectory, globBudget)
isIncludedByWorktreePatterns(patterns, entry.relativePath, entry.isDirectory, matchBudget)
) {
if (hasCandidateDirectoryAncestor(candidates, entry.relativePath)) {
return
}
if (
!candidates.has(entry.relativePath) &&
candidates.size >= WORKTREE_INCLUDE_MAX_CANDIDATES
) {
throw new Error(`${WORKTREE_INCLUDE_FILE} matched too many paths`)
}
candidates.set(entry.relativePath, entry)
}
}
// Literal patterns resolve by direct stat so they work even for paths
// nested inside a wholly-gitignored directory that ls-files collapses.
for (const pattern of patterns) {
if (pattern.negated || pattern.hasGlob || !isSafeIncludeCandidate(pattern.body)) {
continue
}
// 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(
patterns
.filter(
(pattern) =>
!pattern.negated &&
!pattern.hasGlob &&
pattern.anchored &&
isSafeIncludeCandidate(pattern.body)
)
.map((pattern) => pattern.body)
)
for (const relativePath of anchoredLiteralPaths) {
try {
const stats = await lstat(join(repoPath, pattern.body))
addCandidate({ relativePath: pattern.body, isDirectory: stats.isDirectory() })
const stats = await lstat(join(repoPath, relativePath))
addCandidate({
relativePath,
isDirectory: stats.isDirectory(),
fromIgnoredScan: false
})
} catch {
// Absent in the primary checkout — nothing to copy.
}
@@ -262,7 +229,6 @@ export async function resolveWorktreeIncludePaths(
(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, {
@@ -275,10 +241,18 @@ export async function resolveWorktreeIncludePaths(
// 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 filePathspecs = broadPatterns.flatMap((pattern) => [
...(pattern.dirOnly ? [] : [patternToGitPathspec(pattern)]),
// Why: files beneath a matching directory expose directory matches that
// Git 2.25 otherwise hides behind a collapsed ignored parent.
patternToGitDescendantPathspec(pattern)
])
// 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)
.map((entry) => `:(exclude,literal)${entry.relativePath}`)
for (const pathspecs of chunkPathspecs(filePathspecs, coveredDirectoryPathspecs)) {
const timeout = deadline - Date.now()
if (timeout <= 0) {
throw new Error(`${WORKTREE_INCLUDE_FILE} Git enumeration timed out`)
@@ -296,14 +270,26 @@ export async function resolveWorktreeIncludePaths(
return []
}
const candidatePaths = Array.from(candidates.keys())
if (
candidatePaths.reduce(
(bytes, relativePath) => bytes + Buffer.byteLength(relativePath) + 1,
0
) > WORKTREE_INCLUDE_MAX_CANDIDATE_BYTES
) {
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, Array.from(candidates.keys()), options)
await checkIgnoredPaths(repoPath, candidatePaths, options, checkIgnoreTimeout)
)
return Array.from(candidates.keys())
.filter((relativePath) => ignored.has(relativePath))
.sort()
return candidatePaths.filter((relativePath) => ignored.has(relativePath)).sort()
} catch (error) {
console.warn(`[worktree-include] Failed to resolve ${WORKTREE_INCLUDE_FILE} patterns:`, error)
return []
@@ -0,0 +1,65 @@
import { execFile } from 'node:child_process'
import { lstat, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { promisify } from 'node:util'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { createWorktreeCopiedPaths } from '../ipc/worktree-symlinks'
import { resolveWorktreeIncludePaths } from './worktree-include-file'
const execFileAsync = promisify(execFile)
describe('worktree include creation flow', () => {
let root = ''
let primary = ''
let worktree = ''
async function git(args: string[], cwd = primary): Promise<void> {
await execFileAsync('git', args, { cwd })
}
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), 'orca-worktree-include-flow-'))
primary = join(root, 'primary')
worktree = join(root, 'feature')
await mkdir(primary)
await git(['init', '-q'])
await git(['config', 'user.email', 'worktree-include@example.invalid'])
await git(['config', 'user.name', 'Worktree Include Test'])
})
afterEach(async () => {
await rm(root, { recursive: true, force: true })
})
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, 'tracked.txt'), 'tracked\n')
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, 'cache'))
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 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'])
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, 'parent', 'build', 'marker'), 'utf8')).toBe('built\n')
expect((await lstat(join(worktree, 'tracked.txt'))).isFile()).toBe(true)
await writeFile(join(worktree, '.env'), 'ROOT=2\n')
expect(await readFile(join(primary, '.env'), 'utf8')).toBe('ROOT=1\n')
await git(['worktree', 'remove', worktree])
})
})
+1 -1
View File
@@ -50,7 +50,7 @@ export function getWorktreeIncludeGlobStepCount(
pattern: CompiledWorktreeIncludeGlob,
value: string
): number {
return pattern.regExp === null ? pattern.tokens.length * (value.length + 1) : 1
return pattern.regExp === null ? pattern.tokens.length * (value.length + 1) : value.length + 1
}
export function matchesWorktreeIncludeGlob(
+135
View File
@@ -0,0 +1,135 @@
import {
compileWorktreeIncludeGlob,
getWorktreeIncludeGlobStepCount,
matchesWorktreeIncludeGlob,
type CompiledWorktreeIncludeGlob
} from './worktree-include-glob'
const WORKTREE_INCLUDE_FILE = '.worktreeinclude'
const WORKTREE_INCLUDE_MAX_PATTERNS = 4_096
export const WORKTREE_INCLUDE_MATCH_STEP_BUDGET = 5_000_000
export type WorktreeIncludePattern = {
negated: boolean
/** Pattern with `!`, leading `/`, and trailing `/` stripped. */
body: string
dirOnly: boolean
anchored: boolean
hasGlob: boolean
glob: CompiledWorktreeIncludeGlob | null
}
export type WorktreeIncludeMatchBudget = { remaining: number }
export function parseWorktreeIncludePatterns(content: string): WorktreeIncludePattern[] {
const patterns: WorktreeIncludePattern[] = []
for (const rawLine of content.split(/\r?\n/)) {
const line = rawLine.trim()
if (!line || line.startsWith('#')) {
continue
}
const negated = line.startsWith('!')
const withoutNegation = negated ? line.slice(1) : line
const dirOnly = withoutNegation.endsWith('/')
// Why: a slash anywhere except the end anchors a gitignore pattern to the repo root.
const trimmed = withoutNegation.replace(/^\//, '').replace(/\/+$/, '')
if (!trimmed) {
continue
}
const anchored = withoutNegation.startsWith('/') || trimmed.includes('/')
const hasGlob = /[*?]/.test(trimmed)
patterns.push({
negated,
body: trimmed,
dirOnly,
anchored,
hasGlob,
glob: hasGlob ? compileWorktreeIncludeGlob(trimmed) : null
})
if (patterns.length > WORKTREE_INCLUDE_MAX_PATTERNS) {
throw new Error(`${WORKTREE_INCLUDE_FILE} contains too many patterns`)
}
}
return patterns
}
function spendMatchBudget(budget: WorktreeIncludeMatchBudget, steps: number): void {
budget.remaining -= steps
if (budget.remaining < 0) {
throw new Error(`${WORKTREE_INCLUDE_FILE} matching exceeded its CPU budget`)
}
}
function globMatches(
pattern: WorktreeIncludePattern,
subject: string,
budget: WorktreeIncludeMatchBudget
): boolean {
if (pattern.glob === null) {
return false
}
spendMatchBudget(budget, getWorktreeIncludeGlobStepCount(pattern.glob, subject))
return matchesWorktreeIncludeGlob(pattern.glob, subject)
}
function patternMatches(
pattern: WorktreeIncludePattern,
relativePath: string,
isDirectory: boolean,
budget: WorktreeIncludeMatchBudget
): boolean {
if (!pattern.hasGlob) {
if (pattern.anchored) {
spendMatchBudget(budget, relativePath.length + 1)
return (
(relativePath === pattern.body && (!pattern.dirOnly || isDirectory)) ||
relativePath.startsWith(`${pattern.body}/`)
)
}
const segments = relativePath.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)
}
const segments = relativePath.split('/')
const lastMatchableIndex =
pattern.dirOnly && !isDirectory ? segments.length - 2 : segments.length - 1
if (lastMatchableIndex < 0) {
return false
}
if (!pattern.anchored) {
for (let index = 0; index <= lastMatchableIndex; index++) {
if (globMatches(pattern, segments[index], budget)) {
return true
}
}
return false
}
let prefix = ''
for (let index = 0; index <= lastMatchableIndex; index++) {
prefix = prefix ? `${prefix}/${segments[index]}` : segments[index]
if (globMatches(pattern, prefix, budget)) {
return true
}
}
return false
}
export function isIncludedByWorktreePatterns(
patterns: readonly WorktreeIncludePattern[],
relativePath: string,
isDirectory: boolean,
budget: WorktreeIncludeMatchBudget
): boolean {
// Why: gitignore patterns are order-sensitive, so negations must use last-match-wins.
let included = false
for (const pattern of patterns) {
if (patternMatches(pattern, relativePath, isDirectory, budget)) {
included = !pattern.negated
}
}
return included
}
+6 -13
View File
@@ -1,7 +1,7 @@
import { execFile } from 'node:child_process'
import { randomUUID } from 'node:crypto'
import { mkdir, stat, rm, link, rmdir, chmod } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import { dirname, resolve, sep } from 'node:path'
import { promisify } from 'node:util'
type ExecFileAsync = (
@@ -29,7 +29,6 @@ type DarwinFilesystemInfo = {
export type ApfsCloneFilesystemCache = Map<number, Promise<DarwinFilesystemInfo>>
export type ApfsCloneOptions = {
dereferenceSymlinks?: boolean
filesystemCache?: ApfsCloneFilesystemCache
}
@@ -141,8 +140,7 @@ async function cloneFileWithApfs(
async function cloneDirectoryWithApfs(
source: string,
target: string,
deps: ApfsCloneDeps,
dereferenceSymlinks: boolean
deps: ApfsCloneDeps
): Promise<void> {
const sourceMode = (await stat(source)).mode & 0o777
try {
@@ -159,14 +157,9 @@ async function cloneDirectoryWithApfs(
try {
// Why: the top-level directory is reserved before cp runs, so use `-n`
// to keep a raced nested file from being overwritten during the copy.
await deps.execFileAsync('/bin/cp', [
'-n',
'-c',
'-R',
...(dereferenceSymlinks ? ['-L'] : []),
source,
dirname(target)
])
// Why: copy into the reserved target so a resolved top-level symlink whose
// target has another basename still lands at the requested path.
await deps.execFileAsync('/bin/cp', ['-n', '-c', '-R', `${source}${sep}.`, target])
await chmod(target, sourceMode)
} catch (error) {
// Why: remove only the empty reservation. If cp wrote anything, or another
@@ -190,6 +183,6 @@ export async function cloneWorktreePathWithApfs(
// while Darwin's cp exposes APFS clonefile via -c. Preflight the volume so
// cp's non-APFS full-copy fallback cannot surprise users.
await (sourceIsDirectory
? cloneDirectoryWithApfs(source, target, deps, options.dereferenceSymlinks === true)
? cloneDirectoryWithApfs(source, target, deps)
: cloneFileWithApfs(source, target, deps))
}
+31 -7
View File
@@ -6,13 +6,14 @@ import {
lstatSync,
readlinkSync,
readFileSync,
realpathSync,
rmSync,
symlinkSync,
existsSync,
chmodSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { join, sep } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
createWorktreeCopiedPaths,
@@ -302,7 +303,7 @@ describe('createWorktreeSymlinks', () => {
apfsCloneDeps: deps
})
expect(cpArgs).toEqual(['-n', '-c', '-R', source, worktree])
expect(cpArgs).toEqual(['-n', '-c', '-R', `${source}${sep}.`, target])
expect(readFileSync(join(target, 'primary-marker'), 'utf8')).toBe('USER\n')
expect(warn).toHaveBeenCalledWith(
expect.stringContaining('[worktree-symlinks] APFS clone-copy unavailable'),
@@ -449,7 +450,7 @@ describe('createWorktreeCopiedPaths', () => {
expect(readFileSync(join(primary, '.env.shared'), 'utf8')).toBe('SECRET=1\n')
})
posixIt('dereferences nested symlinks so copied directories remain independent', async () => {
posixIt('preserves nested symlinks instead of expanding external trees', async () => {
mkdirSync(join(primary, 'config'))
writeFileSync(join(primary, 'shared.json'), '{"owner":"primary"}')
symlinkSync(join(primary, 'shared.json'), join(primary, 'config', 'settings.json'))
@@ -457,9 +458,8 @@ describe('createWorktreeCopiedPaths', () => {
await createWorktreeCopiedPaths(primary, worktree, ['config'], { platform: 'linux' })
const copiedSettings = join(worktree, 'config', 'settings.json')
expect(lstatSync(copiedSettings).isSymbolicLink()).toBe(false)
writeFileSync(copiedSettings, '{"owner":"worktree"}')
expect(readFileSync(join(primary, 'shared.json'), 'utf8')).toBe('{"owner":"primary"}')
expect(lstatSync(copiedSettings).isSymbolicLink()).toBe(true)
expect(readlinkSync(copiedSettings)).toBe(join(primary, 'shared.json'))
})
it('creates parent directories lazily for nested paths', async () => {
@@ -545,6 +545,30 @@ describe('createWorktreeCopiedPaths', () => {
expect(readFileSync(join(worktree, '.env'), 'utf8')).toBe('CLONED=1\n')
})
posixIt('copies a symlinked directory to its requested APFS destination', async () => {
const realSource = join(primary, 'shared-config')
mkdirSync(realSource)
writeFileSync(join(realSource, 'settings.json'), '{}')
symlinkSync(realSource, join(primary, 'config'), 'dir')
const target = join(worktree, 'config')
let cpArgs: readonly string[] | undefined
const deps = createApfsCloneDeps({
onCp: (args) => {
cpArgs = args
writeFileSync(join(target, 'settings.json'), '{}')
}
})
await createWorktreeCopiedPaths(primary, worktree, ['config'], {
platform: 'darwin',
apfsCloneDeps: deps
})
expect(cpArgs).toEqual(['-n', '-c', '-R', `${realpathSync(realSource)}${sep}.`, target])
expect(readFileSync(join(target, 'settings.json'), 'utf8')).toBe('{}')
expect(existsSync(join(worktree, 'shared-config'))).toBe(false)
})
it('probes an APFS volume once when copying multiple paths', async () => {
mkdirSync(join(primary, 'config'))
mkdirSync(join(primary, 'secrets'))
@@ -562,7 +586,7 @@ describe('createWorktreeCopiedPaths', () => {
expect(filesystemProbeCalls).toHaveLength(2)
const copyCalls = execFileAsyncMock.mock.calls.filter(([file]) => file === '/bin/cp')
expect(copyCalls).toHaveLength(2)
expect(copyCalls.every(([, args]) => args.includes('-L'))).toBe(true)
expect(copyCalls.every(([, args]) => !args.includes('-L'))).toBe(true)
})
})
+3 -4
View File
@@ -95,7 +95,7 @@ async function copyWorktreePath(
recursive: true,
force: false,
errorOnExist: false,
dereference: true
dereference: false
})
if (process.platform !== 'win32') {
await chmod(target, sourceMode)
@@ -113,7 +113,7 @@ async function copyWorktreePath(
recursive: true,
force: false,
errorOnExist: false,
dereference: true
dereference: false
})
}
@@ -140,8 +140,7 @@ async function createWorktreeLinkedPath(
cloneSourceIsDirectory,
options.apfsCloneDeps ?? defaultApfsCloneDeps,
{
filesystemCache: apfsFilesystemCache,
dereferenceSymlinks: mode === 'copy'
filesystemCache: apfsFilesystemCache
}
))
await cloneWorktreePath(copySource, target, sourceIsDirectory)
+21 -1
View File
@@ -175,12 +175,16 @@ describeBinaryCompatibility('real Git binary compatibility', () => {
})
it('lists collapsed directories and targeted nested files for worktree includes', async () => {
await writeFile(join(repoPath, '.gitignore'), '.env\ncache/\n')
await writeFile(join(repoPath, '.gitignore'), '.env\ncache/\nbuild/\nparent/\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')
await mkdir(join(repoPath, 'cache'))
await writeFile(join(repoPath, 'cache', 'artifact'), 'ignored\n')
await mkdir(join(repoPath, 'build'))
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')
const collapsed = await runGit([
'-c',
@@ -209,5 +213,21 @@ describeBinaryCompatibility('real Git binary compatibility', () => {
':(glob)**/.env'
])
expect(targeted.stdout.split('\0').filter(Boolean)).toEqual(['.env', 'apps/web/.env'])
const targetedDirectory = await runGit([
'-c',
'core.quotePath=false',
'ls-files',
'--others',
'--ignored',
'--exclude-standard',
'-z',
'--',
':(glob)**/build/**',
':(exclude,literal)build'
])
expect(targetedDirectory.stdout.split('\0').filter(Boolean)).toEqual([
'parent/build/nested-artifact'
])
})
})