import type { ChildProcess } from 'node:child_process' import { gitSpawnAfterWindowsEnvironmentReady } from '../git/runner' import { isWslLinkedWorktreeGitRoutingCandidate, prepareWslLinkedWorktreeGitRouting } from '../git/wsl-linked-worktree-git-routing' 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 { if (signal?.aborted) { throw fileListingCancellationError(signal) } if (isWslLinkedWorktreeGitRoutingCandidate(rootPath, localGitOptions.wslDistro)) { try { await prepareWslLinkedWorktreeGitRouting(rootPath, localGitOptions.wslDistro, { signal }) } catch (error) { if (signal?.aborted) { throw fileListingCancellationError(signal) } throw error } } if (signal?.aborted) { throw fileListingCancellationError(signal) } const child = await gitSpawnAfterWindowsEnvironmentReady(['rev-parse', '--is-inside-work-tree'], { cwd: rootPath, ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}), ...(signal ? { signal } : {}), stdio: ['ignore', 'ignore', 'ignore'] }) return new Promise((resolve, reject) => { let done = false let timer: ReturnType 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) if (signal?.aborted) { cancel() } }) } export async function listFilesWithGit( rootPath: string, excludePathPrefixes: readonly string[], localGitOptions: { wslDistro?: string }, signal?: AbortSignal, maxResults?: number ): Promise { 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() const directoryPaths = new Set() const directFileCandidates = new Set() const { primary, ignoredPass } = buildGitLsFilesArgsForQuickOpen(excludePathPrefixes) const children: { child: ChildProcess isDone: () => boolean reject: (error: Error) => void resolve: () => void }[] = [] const scanController = new AbortController() const runGitLsFiles = async (args: string[]): Promise => { // 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 = await gitSpawnAfterWindowsEnvironmentReady(['ls-files', ...args], { cwd: rootPath, admissionTier: 'interactive', ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}), signal: scanController.signal, stdio: ['ignore', 'pipe', 'pipe'] }) 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 } let timer: ReturnType 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) if (scanController.signal.aborted) { child.kill() rejectPass(fileListingCancellationError(scanController.signal)) } }) } 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 => { scanController.abort(signal?.reason) 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 (!scanController.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) { scanController.abort(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) }