mirror of
https://github.com/stablyai/orca.git
synced 2026-10-19 16:03:26 +00:00
fix(worktrees): harden include resolution
This commit is contained in:
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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, {
|
||||
|
||||
@@ -1115,6 +1115,7 @@ describe('registerWorktreeHandlers', () => {
|
||||
expect.arrayContaining([
|
||||
'git_worktree_add',
|
||||
'list_created_worktree',
|
||||
'resolve_worktreeinclude',
|
||||
'prepare_setup',
|
||||
'spawn_startup_terminal'
|
||||
])
|
||||
|
||||
Reference in New Issue
Block a user