Files
orca/src/relay/fs-handler-git-fallback.ts
T

347 lines
11 KiB
TypeScript

/**
* Git-based fallbacks for file listing and text search.
*
* Why: the relay depends on ripgrep (rg) for fs.listFiles and fs.search, but
* rg is not installed on many remote machines. These functions use git ls-files
* and git grep as universal fallbacks — git is always available since this is
* a git-focused app.
*/
import { SearchSubprocessLineAccumulator } from '../shared/search-subprocess-lines'
import { spawn } from 'node:child_process'
import { fileListingCancellationError } from '../shared/file-listing-cancellation'
import type { SearchOptions, SearchResult } from './fs-handler-utils'
import {
buildGitLsFilesArgsForQuickOpen,
shouldExcludeQuickOpenRelPath,
shouldIncludeQuickOpenPath
} from '../shared/quick-open-filter'
import {
expandQuickOpenGitFileListing,
parseQuickOpenGitLsFilesEntry
} from '../shared/quick-open-readdir-walk'
import {
buildGitGrepArgs,
buildSubmatchRegex,
createAccumulator,
finalize,
ingestGitGrepLine,
SEARCH_TIMEOUT_MS
} from '../shared/text-search'
import { buildRelayGitEnv } from './relay-command-env'
/**
* List files using `git ls-files`. Fallback when rg is not installed.
*
* Why both passes: primary surfaces tracked + untracked-non-ignored;
* ignoredPass surfaces gitignored files that users frequently Quick Open.
* Exclude pathspecs are prepended by the shared builder so nested linked
* worktrees are pruned by git directly; post-filtering remains as a
* correctness backstop.
*/
export function listFilesWithGit(
rootPath: string,
excludePathPrefixes: readonly string[] = [],
options: { signal?: AbortSignal; maxResults?: number } = {}
): Promise<string[]> {
const { signal, maxResults } = options
if (signal?.aborted) {
return Promise.reject(fileListingCancellationError(signal))
}
const gitPaths = new Set<string>()
const directoryPaths = new Set<string>()
const directFileCandidates = new Set<string>()
const { primary, ignoredPass } = buildGitLsFilesArgsForQuickOpen(excludePathPrefixes)
const children: {
child: ReturnType<typeof spawn>
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 SSH scans must not retain another full listing.
const parsed = parseQuickOpenGitLsFilesEntry(path)
const relPath = parsed.path.replace(/\/+$/, '')
if (
!parsed.isGitlink &&
!parsed.isUntrackedDir &&
shouldIncludeQuickOpenPath(relPath) &&
!shouldExcludeQuickOpenRelPath(relPath, excludePathPrefixes)
) {
directFileCandidates.add(relPath)
}
}
}
// Why: placeholders need IO classification and can disappear; only
// guaranteed final files are allowed to stop the remote Git processes.
return maxResults !== undefined && directFileCandidates.size >= maxResults
}
const child = spawn('git', ['ls-files', ...args], {
cwd: rootPath,
env: buildRelayGitEnv(),
stdio: ['ignore', 'pipe', 'pipe']
})
let timer: ReturnType<typeof setTimeout> | null = null
const cleanup = (): void => {
if (timer) {
clearTimeout(timer)
timer = null
}
child.stdout!.off('data', handleStdoutData)
child.stderr!.off('data', handleStderrData)
child.off('error', handleError)
child.off('close', handleClose)
}
const rejectPass = (error: Error): void => {
if (done) {
return
}
done = true
buf = ''
cleanup()
reject(error)
}
const resolvePass = (): void => {
if (done) {
return
}
done = true
cleanup()
resolve()
}
children.push({
child,
isDone: () => done,
reject: rejectPass,
resolve: resolvePass
})
function handleStdoutData(chunk: string): void {
buf += chunk
let start = 0
let idx = buf.indexOf('\0', start)
while (idx !== -1) {
if (processPath(buf.substring(start, idx))) {
buf = ''
finishAtLimit()
return
}
start = idx + 1
idx = buf.indexOf('\0', start)
}
buf = start < buf.length ? buf.substring(start) : ''
}
function handleStderrData(): void {
/* drain */
}
function handleError(err: Error): void {
rejectPass(err)
}
function handleClose(code: number | null, signal: NodeJS.Signals | null): void {
if (done) {
return
}
if (signal) {
// Why: a signal exit means the child was killed (timeout or
// external). Treat that as a load failure rather than silently
// resolving with whatever git had managed to print.
rejectPass(new Error(`git ls-files killed by ${signal}`))
return
}
if (buf && processPath(buf)) {
buf = ''
finishAtLimit()
return
}
if (code === 0) {
resolvePass()
return
}
// Why: a non-zero exit (e.g. not a git repo) means the listing is
// incomplete; reject so the caller surfaces the failure instead of
// expanding a partial result set. Matches the main-process fallback.
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(() => {
child.kill()
rejectPass(new Error('git ls-files timed out'))
}, 10_000)
})
}
const killSurvivors = (reason: string): void => {
// Why: Promise.all returns after the first failed pass, but the sibling
// git process can keep streaming on SSH unless we cancel it explicitly.
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()
}
}
}
// Why: a cancelled scan (workspace switch, superseded request) must stop
// its git children right away instead of streaming a huge tree the caller
// has already abandoned over the shared SSH channel.
const onAbort = (): void => killSurvivors('git ls-files cancelled')
signal?.addEventListener('abort', onAbort, { once: true })
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(
'[relay quick-open] git ignored-file pass failed; keeping primary results:',
err
)
}
})
const passes =
maxResults === undefined
? Promise.all([runGitLsFiles(primary), runIgnoredPass()])
: runGitLsFiles(primary).then(() =>
directFileCandidates.size < maxResults ? runIgnoredPass() : Promise.resolve()
)
return passes
.then(async () => {
const files = await expandQuickOpenGitFileListing({
rootPath,
gitPaths,
directoryPaths,
excludePathPrefixes,
signal,
maxResults
})
// Why: directory placeholders are expanded after Git exits; restore
// Git's path order for empty queries and fuzzy-score ties over SSH.
return files.sort().slice(0, maxResults)
})
.catch((err) => {
killSurvivors('git ls-files canceled after sibling failure')
if (signal?.aborted) {
throw fileListingCancellationError(signal)
}
throw err
})
.finally(() => {
signal?.removeEventListener('abort', onAbort)
})
}
/**
* Text search using `git grep`. Fallback when rg is not installed.
*/
export function searchWithGitGrep(
rootPath: string,
query: string,
opts: SearchOptions
): Promise<SearchResult> {
return new Promise((resolve) => {
const gitArgs = buildGitGrepArgs(query, opts)
const matchRegex = buildSubmatchRegex(query, opts)
const acc = createAccumulator()
const lines = new SearchSubprocessLineAccumulator(Number.MAX_SAFE_INTEGER)
let done = false
const child = spawn('git', gitArgs, {
cwd: rootPath,
env: buildRelayGitEnv(),
stdio: ['ignore', 'pipe', 'pipe']
})
let killTimeout: ReturnType<typeof setTimeout>
function resolveOnce(): void {
if (done) {
return
}
done = true
lines.clear()
clearTimeout(killTimeout)
// Why: child.kill() is advisory. If git ignores it, detach our
// closures so repeated relay searches do not retain old scans.
child.stdout!.off('data', handleStdoutData)
child.stderr!.off('data', handleStderrData)
child.off('error', handleError)
child.off('close', handleClose)
resolve(finalize(acc))
}
function processLine(line: string): void {
const verdict = ingestGitGrepLine(line, rootPath, matchRegex, acc, opts.maxResults)
if (verdict === 'stop') {
child.kill()
}
}
function handleStdoutData(chunk: string): void {
lines.push(chunk, processLine)
}
function handleStderrData(): void {
/* drain */
}
function handleError(): void {
resolveOnce()
}
function handleClose(): void {
const tail = lines.finish()
if (tail !== null) {
processLine(tail)
}
resolveOnce()
}
child.stdout!.setEncoding('utf-8')
child.stdout!.on('data', handleStdoutData)
child.stderr!.on('data', handleStderrData)
child.once('error', handleError)
child.once('close', handleClose)
killTimeout = setTimeout(() => {
acc.truncated = true
child.kill()
resolveOnce()
}, SEARCH_TIMEOUT_MS)
})
}