Files
orca/src/main/ipc/filesystem-list-files-git-fallback.ts
T

304 lines
9.4 KiB
TypeScript

import type { ChildProcess } from 'node:child_process'
import { gitSpawn } from '../git/runner'
import {
buildGitLsFilesArgsForQuickOpen,
shouldExcludeQuickOpenRelPath,
shouldIncludeQuickOpenPath
} from '../../shared/quick-open-filter'
import {
createQuickOpenReaddirBudget,
expandQuickOpenGitFileListing,
listQuickOpenFilesWithReaddir,
parseQuickOpenGitLsFilesEntry
} from '../../shared/quick-open-readdir-walk'
import { fileListingCancellationError } from '../../shared/file-listing-cancellation'
/**
* Fallback file lister using git ls-files. Used when rg is not available.
*
* Why two git ls-files calls: the first lists tracked + untracked-but-not-ignored
* files (mirrors rg --files --hidden with gitignore respect). The second
* surfaces ignored files (mirrors the second rg call with --no-ignore-vcs).
*/
async function isInsideGitWorkTree(
rootPath: string,
localGitOptions: { wslDistro?: string },
signal?: AbortSignal
): Promise<boolean> {
if (signal?.aborted) {
throw fileListingCancellationError(signal)
}
return new Promise((resolve, reject) => {
const child = gitSpawn(['rev-parse', '--is-inside-work-tree'], {
cwd: rootPath,
...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}),
stdio: ['ignore', 'ignore', 'ignore']
})
let done = false
let timer: ReturnType<typeof setTimeout>
const cleanup = (): void => {
clearTimeout(timer)
child.off('error', handleError)
child.off('close', handleClose)
signal?.removeEventListener('abort', handleAbort)
}
const finish = (isGitRepo: boolean): void => {
if (done) {
return
}
done = true
cleanup()
resolve(isGitRepo)
}
const cancel = (): void => {
if (done) {
return
}
done = true
child.kill()
cleanup()
reject(fileListingCancellationError(signal))
}
const handleError = (): void => finish(false)
const handleClose = (code: number | null, signal: NodeJS.Signals | null): void =>
finish(code === 0 && signal === null)
const handleAbort = (): void => cancel()
child.once('error', handleError)
child.once('close', handleClose)
signal?.addEventListener('abort', handleAbort, { once: true })
timer = setTimeout(() => {
child.kill()
finish(false)
}, 10_000)
})
}
export async function listFilesWithGit(
rootPath: string,
excludePathPrefixes: readonly string[],
localGitOptions: { wslDistro?: string },
signal?: AbortSignal,
maxResults?: number
): Promise<string[]> {
const isGitWorkTree = await isInsideGitWorkTree(rootPath, localGitOptions, signal)
if (signal?.aborted) {
throw fileListingCancellationError(signal)
}
if (!isGitWorkTree) {
return listQuickOpenFilesWithReaddir(rootPath, {
excludePathPrefixes,
budget: createQuickOpenReaddirBudget(),
maxResults,
signal
})
}
const gitPaths = new Set<string>()
const directoryPaths = new Set<string>()
const directFileCandidates = new Set<string>()
const { primary, ignoredPass } = buildGitLsFilesArgsForQuickOpen(excludePathPrefixes)
const children: {
child: ChildProcess
isDone: () => boolean
reject: (error: Error) => void
resolve: () => void
}[] = []
const runGitLsFiles = (args: string[]): Promise<void> => {
return new Promise((resolve, reject) => {
let buf = ''
let done = false
const processPath = (path: string): boolean => {
if (!path) {
return false
}
if (path.endsWith('/')) {
directoryPaths.add(path)
} else {
gitPaths.add(path)
if (maxResults !== undefined) {
// Why: this duplicate classification exists only to stop bounded
// scans; unbounded scans must not retain a second repo-sized set.
const parsed = parseQuickOpenGitLsFilesEntry(path)
const relPath = parsed.path.replace(/\/+$/, '')
if (
!parsed.isGitlink &&
!parsed.isUntrackedDir &&
shouldIncludeQuickOpenPath(relPath) &&
!shouldExcludeQuickOpenRelPath(relPath, excludePathPrefixes)
) {
directFileCandidates.add(relPath)
}
}
}
// Why: collapsed directories and gitlinks may be discarded during the
// later filesystem classification, so they cannot consume the stop cap.
return maxResults !== undefined && directFileCandidates.size >= maxResults
}
// Why: git ls-files outputs paths relative to cwd, so we set cwd to
// rootPath and use the output directly — no prefix stripping needed.
const child = gitSpawn(['ls-files', ...args], {
cwd: rootPath,
...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}),
stdio: ['ignore', 'pipe', 'pipe']
})
let timer: ReturnType<typeof setTimeout>
const cleanup = (): void => {
clearTimeout(timer)
// Why: child.kill() is advisory. If git ignores it, detach our
// closures so repeated Quick Open attempts do not retain old scans.
child.stdout!.off('data', handleStdoutData)
child.stderr!.off('data', handleStderrData)
child.off('error', handleError)
child.off('close', handleClose)
}
const rejectPass = (err: Error): void => {
if (done) {
return
}
done = true
buf = ''
cleanup()
reject(err)
}
const resolvePass = (): void => {
if (done) {
return
}
done = true
cleanup()
resolve()
}
children.push({
child,
isDone: () => done,
reject: rejectPass,
resolve: resolvePass
})
const handleStdoutData = (chunk: string): void => {
buf += chunk
let start = 0
let nulIdx = buf.indexOf('\0', start)
while (nulIdx !== -1) {
if (processPath(buf.substring(start, nulIdx))) {
buf = ''
finishAtLimit()
return
}
start = nulIdx + 1
nulIdx = buf.indexOf('\0', start)
}
buf = start < buf.length ? buf.substring(start) : ''
}
const handleStderrData = (): void => {
/* drain */
}
const handleError = (err: Error): void => {
rejectPass(err)
}
const handleClose = (code: number | null, signal: NodeJS.Signals | null): void => {
if (done) {
return
}
if (signal) {
rejectPass(new Error(`git ls-files killed by ${signal}`))
return
}
if (buf && processPath(buf)) {
buf = ''
finishAtLimit()
return
}
if (code === 0) {
resolvePass()
return
}
rejectPass(new Error(`git ls-files exited with code ${code}`))
}
child.stdout!.setEncoding('utf-8')
child.stdout!.on('data', handleStdoutData)
child.stderr!.on('data', handleStderrData)
child.once('error', handleError)
child.once('close', handleClose)
timer = setTimeout(() => {
buf = ''
child.kill()
rejectPass(new Error('git ls-files timed out'))
}, 10000)
})
}
const killSurvivors = (reason = 'git ls-files canceled after sibling failure'): void => {
// Why: Promise.all rejects on the first failed pass; cancel the sibling so
// a stuck git process cannot keep scanning after Quick Open has failed.
for (const entry of children) {
if (entry.isDone()) {
continue
}
if (entry.child.exitCode === null && entry.child.signalCode === null) {
entry.child.kill()
}
entry.reject(new Error(reason))
}
}
function finishAtLimit(): void {
for (const entry of children) {
if (entry.isDone()) {
continue
}
entry.resolve()
if (entry.child.exitCode === null && entry.child.signalCode === null) {
entry.child.kill()
}
}
}
const onAbort = (): void => killSurvivors('git ls-files cancelled')
signal?.addEventListener('abort', onAbort, { once: true })
try {
const runIgnoredPass = () =>
// Why: ignored files are supplementary — a failed or timed-out ignored
// pass must not discard the primary listing the user actually needs.
runGitLsFiles(ignoredPass).catch((err: Error) => {
if (!signal?.aborted) {
console.warn('[quick-open] git ignored-file pass failed; keeping primary results:', err)
}
})
if (maxResults === undefined) {
await Promise.all([runGitLsFiles(primary), runIgnoredPass()])
} else {
// Why: give ordinary source files first claim on a bounded autocomplete
// inventory; a large ignored tree must not win a parallel-output race.
await runGitLsFiles(primary)
if (directFileCandidates.size < maxResults) {
await runIgnoredPass()
}
}
} catch (err) {
killSurvivors()
if (signal?.aborted) {
throw fileListingCancellationError(signal)
}
throw err
} finally {
signal?.removeEventListener('abort', onAbort)
}
const files = await expandQuickOpenGitFileListing({
rootPath,
gitPaths,
directoryPaths,
excludePathPrefixes,
signal,
maxResults
})
// Why: directory placeholders are expanded after Git exits; restore Git's
// path order so empty queries and fuzzy-score ties remain stable.
return files.sort().slice(0, maxResults)
}