Files
orca/src/main/git/worktree-include-file.ts
T

324 lines
12 KiB
TypeScript

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<string, GitignoredEntry>,
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<T>(
operation: () => Promise<T>,
deadline: number
): Promise<T> {
const remainingMs = deadline - Date.now()
if (remainingMs <= 0) {
throw new Error(`${WORKTREE_INCLUDE_FILE} resolution timed out`)
}
let timeout: ReturnType<typeof setTimeout> | undefined
try {
return await Promise.race([
operation(),
new Promise<never>((_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<string | null> {
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<string[]> {
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<string, GitignoredEntry>()
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<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 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<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)
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 []
}
}