mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 00:03:45 +00:00
fix(worktrees): support nested includes on Git 2.25
This commit is contained in:
@@ -96,6 +96,13 @@ describe('resolveWorktreeIncludePaths', () => {
|
||||
mockGit({ entries: ['.env', 'apps/web/.env'], ignored: ['.env', 'apps/web/.env'] })
|
||||
|
||||
await expect(resolveWorktreeIncludePaths(repo)).resolves.toEqual(['.env', 'apps/web/.env'])
|
||||
const lsFilesCalls = gitExecFileAsyncMock.mock.calls.filter(([args]) =>
|
||||
args.includes('ls-files')
|
||||
)
|
||||
expect(lsFilesCalls).toHaveLength(2)
|
||||
expect(lsFilesCalls[0][0]).toContain('--directory')
|
||||
expect(lsFilesCalls[1][0]).not.toContain('--directory')
|
||||
expect(lsFilesCalls[1][0]).toContain(':(glob)**/.env')
|
||||
})
|
||||
|
||||
it('avoids enumeration for root-anchored literal patterns', async () => {
|
||||
|
||||
@@ -13,6 +13,9 @@ 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: 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
|
||||
@@ -135,29 +138,32 @@ type GitignoredEntry = {
|
||||
isDirectory: boolean
|
||||
}
|
||||
|
||||
/** Untracked gitignored files/dirs at directory granularity. `--directory`
|
||||
* collapses wholly-ignored dirs (e.g. `node_modules/`) into one entry and
|
||||
* prunes traversal beneath them, keeping this fast in huge repos. */
|
||||
type ListGitignoredEntriesOptions = {
|
||||
collapseDirectories: boolean
|
||||
pathspecs?: readonly string[]
|
||||
timeout: number
|
||||
}
|
||||
|
||||
async function listGitignoredEntries(
|
||||
repoPath: string,
|
||||
options: GitRuntimeOptions
|
||||
options: GitRuntimeOptions,
|
||||
listOptions: ListGitignoredEntriesOptions
|
||||
): Promise<GitignoredEntry[]> {
|
||||
const { stdout } = await gitExecFileAsync(
|
||||
[
|
||||
'-c',
|
||||
'core.quotePath=false',
|
||||
'ls-files',
|
||||
'--others',
|
||||
'--ignored',
|
||||
'--exclude-standard',
|
||||
'--directory',
|
||||
'-z'
|
||||
],
|
||||
{
|
||||
...gitOptionsForWorktree(repoPath, options),
|
||||
timeout: WORKTREE_INCLUDE_GIT_TIMEOUT_MS
|
||||
}
|
||||
)
|
||||
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) {
|
||||
@@ -172,6 +178,40 @@ async function listGitignoredEntries(
|
||||
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 chunkPathspecs(pathspecs: readonly string[]): string[][] {
|
||||
const chunks: string[][] = []
|
||||
let chunk: string[] = []
|
||||
let chunkLength = 0
|
||||
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) {
|
||||
continue
|
||||
}
|
||||
if (
|
||||
chunk.length > 0 &&
|
||||
chunkLength + pathspec.length > WORKTREE_INCLUDE_PATHSPEC_CHUNK_LENGTH
|
||||
) {
|
||||
chunks.push(chunk)
|
||||
chunk = []
|
||||
chunkLength = 0
|
||||
}
|
||||
chunk.push(pathspec)
|
||||
chunkLength += pathspec.length
|
||||
}
|
||||
if (chunk.length > 0) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
return chunks
|
||||
}
|
||||
|
||||
async function readWorktreeIncludeFile(repoPath: string): Promise<string | null> {
|
||||
const includePath = join(repoPath, WORKTREE_INCLUDE_FILE)
|
||||
try {
|
||||
@@ -233,12 +273,39 @@ export async function resolveWorktreeIncludePaths(
|
||||
}
|
||||
}
|
||||
|
||||
// Why: bare gitignore patterns match at every depth; direct stat covers the
|
||||
// root candidate, while one collapsed enumeration discovers nested matches.
|
||||
if (patterns.some((pattern) => !pattern.negated && (pattern.hasGlob || !pattern.anchored))) {
|
||||
for (const entry of await listGitignoredEntries(repoPath, options)) {
|
||||
const broadPatterns = patterns.filter(
|
||||
(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, {
|
||||
collapseDirectories: true,
|
||||
timeout: Math.max(1, deadline - Date.now())
|
||||
})) {
|
||||
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
|
||||
.filter((pattern) => !pattern.dirOnly)
|
||||
.map(patternToGitPathspec)
|
||||
for (const pathspecs of chunkPathspecs(filePathspecs)) {
|
||||
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 []
|
||||
|
||||
@@ -174,7 +174,7 @@ describeBinaryCompatibility('real Git binary compatibility', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('lists collapsed ignored entries for worktree includes on the baseline', async () => {
|
||||
it('lists collapsed directories and targeted nested files for worktree includes', async () => {
|
||||
await writeFile(join(repoPath, '.gitignore'), '.env\ncache/\n')
|
||||
await writeFile(join(repoPath, '.env'), 'ROOT=1\n')
|
||||
await mkdir(join(repoPath, 'apps', 'web'), { recursive: true })
|
||||
@@ -182,7 +182,7 @@ describeBinaryCompatibility('real Git binary compatibility', () => {
|
||||
await mkdir(join(repoPath, 'cache'))
|
||||
await writeFile(join(repoPath, 'cache', 'artifact'), 'ignored\n')
|
||||
|
||||
const { stdout } = await runGit([
|
||||
const collapsed = await runGit([
|
||||
'-c',
|
||||
'core.quotePath=false',
|
||||
'ls-files',
|
||||
@@ -192,10 +192,22 @@ describeBinaryCompatibility('real Git binary compatibility', () => {
|
||||
'--directory',
|
||||
'-z'
|
||||
])
|
||||
const entries = stdout.split('\0').filter(Boolean)
|
||||
expect(entries).toContain('.env')
|
||||
expect(entries).toContain('apps/web/.env')
|
||||
expect(entries).toContain('cache/')
|
||||
expect(entries).not.toContain('cache/artifact')
|
||||
const collapsedEntries = collapsed.stdout.split('\0').filter(Boolean)
|
||||
expect(collapsedEntries).toContain('.env')
|
||||
expect(collapsedEntries).toContain('cache/')
|
||||
expect(collapsedEntries).not.toContain('cache/artifact')
|
||||
|
||||
const targeted = await runGit([
|
||||
'-c',
|
||||
'core.quotePath=false',
|
||||
'ls-files',
|
||||
'--others',
|
||||
'--ignored',
|
||||
'--exclude-standard',
|
||||
'-z',
|
||||
'--',
|
||||
':(glob)**/.env'
|
||||
])
|
||||
expect(targeted.stdout.split('\0').filter(Boolean)).toEqual(['.env', 'apps/web/.env'])
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user