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 { chunkWorktreeIncludePathspecs, getWorktreeIncludeIgnoreCase, 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. */ export const WORKTREE_INCLUDE_FILE = '.worktreeinclude' // Why: reading the include file must never delay worktree creation on a wedged // filesystem or a pathological repo; bail and create the worktree without it. const WORKTREE_INCLUDE_RESOLUTION_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 const WORKTREE_INCLUDE_STAT_CONCURRENCY = 8 function isSafeIncludeCandidate(relativePath: string): boolean { if (!relativePath || isAbsolute(relativePath)) { return false } const segments = relativePath.split(/[\\/]/) return !segments.includes('..') && segments[0] !== '.git' } function hasCandidateDirectoryAncestor( candidates: ReadonlyMap, relativePath: string ): boolean { let separatorIndex = relativePath.indexOf('/') while (separatorIndex !== -1) { const ancestor = candidates.get(relativePath.slice(0, separatorIndex)) if (ancestor?.isDirectory && ancestor.coversDescendants) { return true } separatorIndex = relativePath.indexOf('/', separatorIndex + 1) } return false } async function completeBeforeDeadline( operation: () => Promise, deadline: number ): Promise { const remainingMs = deadline - Date.now() if (remainingMs <= 0) { throw new Error(`${WORKTREE_INCLUDE_FILE} resolution timed out`) } let timeout: ReturnType | undefined try { return await Promise.race([ operation(), new Promise((_resolve, reject) => { timeout = setTimeout( () => reject(new Error(`${WORKTREE_INCLUDE_FILE} resolution timed out`)), remainingMs ) }) ]) } finally { clearTimeout(timeout) } } async function readWorktreeIncludeFile(repoPath: string, deadline: number): Promise { const includePath = join(repoPath, WORKTREE_INCLUDE_FILE) try { const stats = await completeBeforeDeadline(() => lstat(includePath), deadline) if (!stats.isFile() || stats.size > WORKTREE_INCLUDE_MAX_FILE_BYTES) { return null } return await completeBeforeDeadline(() => readFile(includePath, 'utf8'), deadline) } catch { return null } } /** Resolve `.worktreeinclude` at the repo root to concrete repo-relative paths * to copy into a new worktree. * * Semantics match the cross-tool convention: gitignore syntax, and only paths * that are actually gitignored are ever returned — tracked files are already * present in a fresh worktree, and untracked-but-not-ignored files would show * up as spurious diffs. * * Never throws: any read/parse/git failure resolves to `[]` so worktree * creation is never blocked by this file. */ export async function resolveWorktreeIncludePaths( repoPath: string, options: GitRuntimeOptions = {} ): Promise { try { const deadline = Date.now() + WORKTREE_INCLUDE_RESOLUTION_TIMEOUT_MS const content = await readWorktreeIncludeFile(repoPath, deadline) if (content === null) { return [] } const patterns = parseWorktreeIncludePatterns(content) 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() let candidateBytes = 0 const matchBudget = { remaining: WORKTREE_INCLUDE_MATCH_STEP_BUDGET } const pruneCoveredCandidateDescendants = (): void => { for (const candidatePath of candidates.keys()) { if (hasCandidateDirectoryAncestor(candidates, candidatePath)) { candidates.delete(candidatePath) candidateBytes -= Buffer.byteLength(candidatePath) + 1 } } } const markCandidateDirectoryAsCovering = (relativePath: string): void => { const candidate = candidates.get(relativePath) if (candidate?.isDirectory) { candidate.coversDescendants = true } } const addCandidate = (entry: GitignoredEntry): void => { if ( isSafeIncludeCandidate(entry.relativePath) && isIncludedByWorktreePatterns( patterns, entry.relativePath, entry.isDirectory, matchBudget, ignoreCase ) ) { 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`) } if (!candidates.has(entry.relativePath)) { candidateBytes += Buffer.byteLength(entry.relativePath) + 1 if (candidateBytes > WORKTREE_INCLUDE_MAX_CANDIDATE_BYTES) { throw new Error(`${WORKTREE_INCLUDE_FILE} matched paths exceed its byte budget`) } } candidates.set(entry.relativePath, entry) } } // 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 anchoredLiteralPatterns = new Map( patterns .filter( (pattern) => !pattern.negated && !pattern.hasGlob && pattern.anchored && isSafeIncludeCandidate(pattern.body) ) .map((pattern) => [pattern.body, pattern] as const) ) const anchoredLiteralDirectories = new Map() // 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 completeBeforeDeadline( () => lstat(join(repoPath, relativePath)), deadline ) return { relativePath, pattern, isDirectory: stats.isDirectory() } } catch { return null } } ) for (const result of anchoredLiteralStats) { if (result) { const { relativePath, pattern, isDirectory } = result addCandidate({ relativePath, isDirectory, coversDescendants: false }) if (isDirectory && candidates.has(relativePath)) { anchoredLiteralDirectories.set(relativePath, pattern) } } } const broadPatterns = patterns.filter( (pattern) => !pattern.negated && (pattern.hasGlob || !pattern.anchored) ) if (broadPatterns.length > 0) { // 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: 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() 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) markCandidateDirectoryAsCovering(relativePath) } // Why: a directory validated after its children were collected replaces // them as one bounded copy instead of duplicating every descendant. pruneCoveredCandidateDescendants() } // 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, ignoreCase)]), // Why: files beneath a matching directory expose directory matches that // Git 2.25 otherwise hides behind a collapsed ignored parent. worktreeIncludePatternToGitDescendantPathspec(pattern, ignoreCase) ]), ...Array.from(anchoredLiteralDirectories.entries()) .filter(([relativePath]) => !ignoredAnchoredDirectories.has(relativePath)) .map(([, pattern]) => worktreeIncludePatternToGitDescendantPathspec(pattern, ignoreCase)) ] 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.coversDescendants) .map((entry) => `:(exclude,literal)${entry.relativePath}`) 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`) } for (const entry of await listGitignoredEntries(repoPath, options, { collapseDirectories: false, pathspecs, timeout })) { addCandidate(entry) } } } if (candidates.size === 0) { return [] } const candidatePaths = Array.from(candidates.keys()) // 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(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) return [] } }