From f8e8d5d1497dbfe47ef71dcb0aa087591f501bd7 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 28 Jun 2026 23:56:05 -0700 Subject: [PATCH] Show nested sub-project files in file-explorer name search (#6481) When Quick Open and File Explorer name search fall back to git ls-files, expand nested git repo placeholders with a bounded readdir walk so monorepo parent workspaces include files inside sub-projects. Keep local main-process and SSH relay behavior aligned, including non-git root fallback and non-zero git failure handling. Co-authored-by: Orca --- ...ystem-list-files-git-fallback-real.test.ts | 94 ++++++- .../ipc/filesystem-list-files-git-fallback.ts | 197 +++++++++++++ src/main/ipc/filesystem-list-files.test.ts | 80 +++++- src/main/ipc/filesystem-list-files.ts | 104 +------ src/relay/fs-handler-git-fallback.ts | 35 ++- .../fs-handler-list-files-ignored.test.ts | 97 ++++++- src/relay/fs-handler-readdir-fallback.ts | 77 +---- src/relay/fs-handler.ts | 14 +- src/shared/quick-open-filter.test.ts | 4 +- src/shared/quick-open-filter.ts | 15 +- src/shared/quick-open-readdir-walk.test.ts | 262 ++++++++++++++++++ src/shared/quick-open-readdir-walk.ts | 251 +++++++++++++++++ 12 files changed, 1017 insertions(+), 213 deletions(-) create mode 100644 src/main/ipc/filesystem-list-files-git-fallback.ts create mode 100644 src/shared/quick-open-readdir-walk.test.ts create mode 100644 src/shared/quick-open-readdir-walk.ts diff --git a/src/main/ipc/filesystem-list-files-git-fallback-real.test.ts b/src/main/ipc/filesystem-list-files-git-fallback-real.test.ts index 17ae0a866..95315f6b0 100644 --- a/src/main/ipc/filesystem-list-files-git-fallback-real.test.ts +++ b/src/main/ipc/filesystem-list-files-git-fallback-real.test.ts @@ -1,7 +1,7 @@ import { execFile as execFileCallback } from 'node:child_process' -import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { dirname, join } from 'node:path' import { promisify } from 'node:util' import { afterEach, describe, expect, it, vi } from 'vitest' import type { Store } from '../persistence' @@ -34,6 +34,19 @@ function makeStore(repoPath: string): Store { } as unknown as Store } +async function writeRel(root: string, relPath: string, content = 'x'): Promise { + const absPath = join(root, ...relPath.split('/')) + await mkdir(dirname(absPath), { recursive: true }) + await writeFile(absPath, content) +} + +async function initRepo(repoPath: string): Promise { + await mkdir(repoPath, { recursive: true }) + await execFile('git', ['init', '-q', repoPath]) + await execFile('git', ['config', 'user.email', 'orca@example.invalid'], { cwd: repoPath }) + await execFile('git', ['config', 'user.name', 'Orca Test'], { cwd: repoPath }) +} + describe('filesystem-list-files real git fallback', () => { let tempDir: string | null = null @@ -56,4 +69,81 @@ describe('filesystem-list-files real git fallback', () => { await expect(listQuickOpenFiles(repoPath, makeStore(repoPath))).resolves.toEqual([utf8FileName]) }) + + it('fills nested git repos from gitlink and untracked embedded-repo entries', async () => { + checkRgAvailableMock.mockResolvedValue(false) + tempDir = await mkdtemp(join(tmpdir(), 'orca-quick-open-monorepo-')) + const repoPath = join(tempDir, 'parent') + const appPath = join(repoPath, 'packages', 'app') + const libPath = join(repoPath, 'packages', 'lib') + await initRepo(repoPath) + await writeRel(repoPath, 'README.md') + await writeRel(repoPath, 'src/index.ts') + await execFile('git', ['add', 'README.md', 'src/index.ts'], { cwd: repoPath }) + + await initRepo(appPath) + await writeRel(appPath, 'package.json', '{}') + await writeRel(appPath, 'src/main.ts') + await writeRel(appPath, 'node_modules/pkg/index.js') + await execFile('git', ['add', '.'], { cwd: appPath }) + await execFile('git', ['commit', '-qm', 'init'], { cwd: appPath }) + const { stdout: appSha } = await execFile('git', ['rev-parse', 'HEAD'], { cwd: appPath }) + await execFile( + 'git', + ['update-index', '--add', '--cacheinfo', `160000,${appSha.trim()},packages/app`], + { cwd: repoPath } + ) + + await initRepo(libPath) + await writeRel(libPath, 'package.json', '{}') + await writeRel(libPath, 'src/lib.ts') + + const result = await listQuickOpenFiles(repoPath, makeStore(repoPath)) + + expect(result).toEqual( + expect.arrayContaining([ + 'README.md', + 'src/index.ts', + 'packages/app/package.json', + 'packages/app/src/main.ts', + 'packages/lib/package.json', + 'packages/lib/src/lib.ts' + ]) + ) + expect(result).not.toContain('packages/app') + expect(result).not.toContain('packages/lib') + expect(result).not.toContain('packages/app/node_modules/pkg/index.js') + expect(result).not.toContain('packages/app/.git/config') + }) + + it('walks a non-git root instead of returning an empty git fallback result', async () => { + checkRgAvailableMock.mockResolvedValue(false) + tempDir = await mkdtemp(join(tmpdir(), 'orca-quick-open-non-git-')) + await writeRel(tempDir, 'folder/file.ts') + + await expect(listQuickOpenFiles(tempDir, makeStore(tempDir))).resolves.toEqual([ + 'folder/file.ts' + ]) + }) + + it('rejects abnormal git ls-files failures instead of resolving an empty list', async () => { + checkRgAvailableMock.mockResolvedValue(false) + tempDir = await mkdtemp(join(tmpdir(), 'orca-quick-open-bad-index-')) + const repoPath = join(tempDir, 'repo') + await initRepo(repoPath) + await writeFile(join(repoPath, '.git', 'index'), 'not a git index') + + await expect(listQuickOpenFiles(repoPath, makeStore(repoPath))).rejects.toThrow( + 'git ls-files exited with code' + ) + }) + + it('resolves an empty repo as an empty list', async () => { + checkRgAvailableMock.mockResolvedValue(false) + tempDir = await mkdtemp(join(tmpdir(), 'orca-quick-open-empty-repo-')) + const repoPath = join(tempDir, 'repo') + await initRepo(repoPath) + + await expect(listQuickOpenFiles(repoPath, makeStore(repoPath))).resolves.toEqual([]) + }) }) diff --git a/src/main/ipc/filesystem-list-files-git-fallback.ts b/src/main/ipc/filesystem-list-files-git-fallback.ts new file mode 100644 index 000000000..4a9c8ee23 --- /dev/null +++ b/src/main/ipc/filesystem-list-files-git-fallback.ts @@ -0,0 +1,197 @@ +import type { ChildProcess } from 'child_process' +import { gitSpawn } from '../git/runner' +import { buildGitLsFilesArgsForQuickOpen } from '../../shared/quick-open-filter' +import { + createQuickOpenReaddirBudget, + expandQuickOpenGitFilesWithNestedRepos, + listQuickOpenFilesWithReaddir +} from '../../shared/quick-open-readdir-walk' + +/** + * 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 } +): Promise { + return new Promise((resolve) => { + 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 + const finish = (isGitRepo: boolean): void => { + if (done) { + return + } + done = true + clearTimeout(timer) + child.off('error', handleError) + child.off('close', handleClose) + resolve(isGitRepo) + } + const handleError = (): void => finish(false) + const handleClose = (code: number | null, signal: NodeJS.Signals | null): void => + finish(code === 0 && signal === null) + + child.once('error', handleError) + child.once('close', handleClose) + timer = setTimeout(() => { + child.kill() + finish(false) + }, 10_000) + }) +} + +export async function listFilesWithGit( + rootPath: string, + excludePathPrefixes: readonly string[], + localGitOptions: { wslDistro?: string } +): Promise { + if (!(await isInsideGitWorkTree(rootPath, localGitOptions))) { + return listQuickOpenFilesWithReaddir(rootPath, { + excludePathPrefixes, + budget: createQuickOpenReaddirBudget() + }) + } + + const gitPaths = new Set() + const { primary, ignoredPass } = buildGitLsFilesArgsForQuickOpen(excludePathPrefixes) + const children: { + child: ChildProcess + isDone: () => boolean + reject: (error: Error) => void + }[] = [] + + const runGitLsFiles = (args: string[]): Promise => { + return new Promise((resolve, reject) => { + let buf = '' + let done = false + + const processPath = (path: string): void => { + if (!path) { + return + } + gitPaths.add(path) + } + + // 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 + 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 + }) + const handleStdoutData = (chunk: string): void => { + buf += chunk + let start = 0 + let nulIdx = buf.indexOf('\0', start) + while (nulIdx !== -1) { + processPath(buf.substring(start, nulIdx)) + 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) + } + 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 = (): 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('git ls-files canceled after sibling failure')) + } + } + + try { + await Promise.all([runGitLsFiles(primary), runGitLsFiles(ignoredPass)]) + } catch (err) { + killSurvivors() + throw err + } + + return expandQuickOpenGitFilesWithNestedRepos({ + rootPath, + gitPaths, + excludePathPrefixes + }) +} diff --git a/src/main/ipc/filesystem-list-files.test.ts b/src/main/ipc/filesystem-list-files.test.ts index 499f91c04..8b62a2dd0 100644 --- a/src/main/ipc/filesystem-list-files.test.ts +++ b/src/main/ipc/filesystem-list-files.test.ts @@ -38,6 +38,12 @@ import { EventEmitter } from 'events' import type { Store } from '../persistence' import type { ChildProcess } from 'child_process' +const SHA1 = '0123456789abcdef0123456789abcdef01234567' + +function staged(mode: string, path: string): string { + return `${mode} ${SHA1} 0\t${path}` +} + function createMockProcess(): ChildProcess { const p = new EventEmitter() as unknown as ChildProcess ;(p as unknown as Record).stdout = new EventEmitter() @@ -306,11 +312,15 @@ describe('filesystem-list-files', () => { checkRgAvailableMock.mockResolvedValue(false) let callIndex = 0 + const revParseProc = createMockProcess() const gitP1 = createMockProcess() const gitP2 = createMockProcess() - spawnMock.mockImplementation((cmd: string) => { - if (cmd === 'git') { + spawnMock.mockImplementation((cmd: string, args: string[]) => { + if (cmd === 'git' && args.includes('rev-parse')) { + return revParseProc + } + if (cmd === 'git' && args.includes('ls-files')) { callIndex++ return callIndex === 1 ? gitP1 : gitP2 } @@ -321,9 +331,21 @@ describe('filesystem-list-files', () => { const promise = listQuickOpenFiles('/mock/root', storeMock) setTimeout(() => { - ;(gitP1.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\0') - ;(gitP1.stdout as unknown as EventEmitter).emit('data', 'package.json\0') - ;(gitP1.stdout as unknown as EventEmitter).emit('data', 'node_modules/dep/index.js\0') + revParseProc.emit('close', 0, null) + }, 0) + setTimeout(() => { + ;(gitP1.stdout as unknown as EventEmitter).emit( + 'data', + `${staged('100644', 'src/index.ts')}\0` + ) + ;(gitP1.stdout as unknown as EventEmitter).emit( + 'data', + `${staged('100644', 'package.json')}\0` + ) + ;(gitP1.stdout as unknown as EventEmitter).emit( + 'data', + `${staged('100644', 'node_modules/dep/index.js')}\0` + ) gitP1.emit('close', 0, null) ;(gitP2.stdout as unknown as EventEmitter).emit('data', '.env.local\0') @@ -338,9 +360,12 @@ describe('filesystem-list-files', () => { expect(rgCalls.length).toBe(0) // Verify git ls-files was called - const gitCalls = spawnMock.mock.calls.filter((call) => call[0] === 'git') + const gitCalls = spawnMock.mock.calls.filter( + (call) => call[0] === 'git' && (call[1] as string[]).includes('ls-files') + ) expect(gitCalls.length).toBe(2) expect(gitCalls[0][1]).toContain('ls-files') + expect(gitCalls[0][1]).toContain('-s') // Should include valid files and filter node_modules expect(result).toContain('src/index.ts') @@ -353,12 +378,16 @@ describe('filesystem-list-files', () => { it('git fallback applies hidden dir blocklist', async () => { checkRgAvailableMock.mockResolvedValue(false) + const revParseProc = createMockProcess() const gitP1 = createMockProcess() const gitP2 = createMockProcess() let callIndex = 0 - spawnMock.mockImplementation((cmd: string) => { - if (cmd === 'git') { + spawnMock.mockImplementation((cmd: string, args: string[]) => { + if (cmd === 'git' && args.includes('rev-parse')) { + return revParseProc + } + if (cmd === 'git' && args.includes('ls-files')) { callIndex++ return callIndex === 1 ? gitP1 : gitP2 } @@ -369,10 +398,22 @@ describe('filesystem-list-files', () => { const promise = listQuickOpenFiles('/mock/root', storeMock) setTimeout(() => { - ;(gitP1.stdout as unknown as EventEmitter).emit('data', '.next/cache/1.js\0') - ;(gitP1.stdout as unknown as EventEmitter).emit('data', '.vscode/settings.json\0') - ;(gitP1.stdout as unknown as EventEmitter).emit('data', '.github/workflows/ci.yml\0') - ;(gitP1.stdout as unknown as EventEmitter).emit('data', 'valid.ts\0') + revParseProc.emit('close', 0, null) + }, 0) + setTimeout(() => { + ;(gitP1.stdout as unknown as EventEmitter).emit( + 'data', + `${staged('100644', '.next/cache/1.js')}\0` + ) + ;(gitP1.stdout as unknown as EventEmitter).emit( + 'data', + `${staged('100644', '.vscode/settings.json')}\0` + ) + ;(gitP1.stdout as unknown as EventEmitter).emit( + 'data', + `${staged('100644', '.github/workflows/ci.yml')}\0` + ) + ;(gitP1.stdout as unknown as EventEmitter).emit('data', `${staged('100644', 'valid.ts')}\0`) gitP1.emit('close', 0, null) gitP2.emit('close', 0, null) @@ -388,12 +429,16 @@ describe('filesystem-list-files', () => { vi.useFakeTimers() try { + const revParseProc = createMockProcess() const gitP1 = createMockProcess() const gitP2 = createMockProcess() let callIndex = 0 - spawnMock.mockImplementation((cmd: string) => { - if (cmd === 'git') { + spawnMock.mockImplementation((cmd: string, args: string[]) => { + if (cmd === 'git' && args.includes('rev-parse')) { + return revParseProc + } + if (cmd === 'git' && args.includes('ls-files')) { callIndex++ return callIndex === 1 ? gitP1 : gitP2 } @@ -403,15 +448,20 @@ describe('filesystem-list-files', () => { const storeMock = {} as unknown as Store const promise = listQuickOpenFiles('/mock/root', storeMock) + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + revParseProc.emit('close', 0, null) await Promise.resolve() await Promise.resolve() await Promise.resolve() ;(gitP1.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\0partial') + const rejection = expect(promise).rejects.toThrow('git ls-files timed out') await vi.advanceTimersByTimeAsync(10000) - await expect(promise).resolves.toEqual(['src/index.ts']) + await rejection expect(gitP1.kill).toHaveBeenCalled() expect(gitP2.kill).toHaveBeenCalled() expect((gitP1.stdout as unknown as EventEmitter).listenerCount('data')).toBe(0) diff --git a/src/main/ipc/filesystem-list-files.ts b/src/main/ipc/filesystem-list-files.ts index 2dd3cd9ce..512f38c85 100644 --- a/src/main/ipc/filesystem-list-files.ts +++ b/src/main/ipc/filesystem-list-files.ts @@ -3,18 +3,18 @@ import type { ChildProcess } from 'child_process' import type { Store } from '../persistence' import { resolveAuthorizedPath } from './filesystem-auth' import { checkRgAvailable } from './rg-availability' -import { gitSpawn, wslAwareSpawn } from '../git/runner' +import { wslAwareSpawn } from '../git/runner' import { parseWslPath, toWindowsWslPath } from '../wsl' import { getLocalGitOptionsForRegisteredWorktree } from './local-worktree-runtime-options' import { buildExcludePathPrefixes, - buildGitLsFilesArgsForQuickOpen, buildRgArgsForQuickOpen, normalizeQuickOpenRgLine, type RgOutputMode, shouldExcludeQuickOpenRelPath, shouldIncludeQuickOpenPath } from '../../shared/quick-open-filter' +import { listFilesWithGit } from './filesystem-list-files-git-fallback' export async function listQuickOpenFiles( rootPath: string, @@ -206,103 +206,3 @@ function getQuickOpenRgOutputMode( } return { kind: 'cwd-relative' } } - -/** - * 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). - */ -function listFilesWithGit( - rootPath: string, - excludePathPrefixes: readonly string[], - localGitOptions: { wslDistro?: string } -): Promise { - const files = new Set() - const { primary, ignoredPass } = buildGitLsFilesArgsForQuickOpen(excludePathPrefixes) - - const runGitLsFiles = (args: string[]): Promise => { - return new Promise((resolve) => { - let buf = '' - let done = false - - const processPath = (path: string): void => { - if (!path) { - return - } - // Why: git exclude pathspecs prune most hits, but post-filter is - // still required because pathspec semantics differ subtly from the - // rg globs and exist as a correctness backstop. - if (shouldExcludeQuickOpenRelPath(path, excludePathPrefixes)) { - return - } - if (shouldIncludeQuickOpenPath(path)) { - files.add(path) - } - } - - // 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 - const handleStdoutData = (chunk: string): void => { - buf += chunk - let start = 0 - let nulIdx = buf.indexOf('\0', start) - while (nulIdx !== -1) { - processPath(buf.substring(start, nulIdx)) - start = nulIdx + 1 - nulIdx = buf.indexOf('\0', start) - } - buf = start < buf.length ? buf.substring(start) : '' - } - const handleStderrData = (): void => { - /* drain */ - } - const handleError = (): void => { - buf = '' - finish() - } - const handleClose = (): void => { - if (buf) { - processPath(buf) - } - finish() - } - const finish = (): void => { - if (done) { - return - } - done = true - 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) - resolve() - } - - 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() - finish() - }, 10000) - }) - } - - return Promise.all([runGitLsFiles(primary), runGitLsFiles(ignoredPass)]).then(() => - Array.from(files) - ) -} diff --git a/src/relay/fs-handler-git-fallback.ts b/src/relay/fs-handler-git-fallback.ts index d4c46f35e..14d8e198c 100644 --- a/src/relay/fs-handler-git-fallback.ts +++ b/src/relay/fs-handler-git-fallback.ts @@ -8,11 +8,8 @@ */ import { spawn } from 'child_process' import { type SearchOptions, type SearchResult } from './fs-handler-utils' -import { - buildGitLsFilesArgsForQuickOpen, - shouldExcludeQuickOpenRelPath, - shouldIncludeQuickOpenPath -} from '../shared/quick-open-filter' +import { buildGitLsFilesArgsForQuickOpen } from '../shared/quick-open-filter' +import { expandQuickOpenGitFilesWithNestedRepos } from '../shared/quick-open-readdir-walk' import { buildGitGrepArgs, buildSubmatchRegex, @@ -36,7 +33,7 @@ export function listFilesWithGit( rootPath: string, excludePathPrefixes: readonly string[] = [] ): Promise { - const files = new Set() + const gitPaths = new Set() const { primary, ignoredPass } = buildGitLsFilesArgsForQuickOpen(excludePathPrefixes) const children: { child: ReturnType @@ -53,12 +50,7 @@ export function listFilesWithGit( if (!path) { return } - if (shouldExcludeQuickOpenRelPath(path, excludePathPrefixes)) { - return - } - if (shouldIncludeQuickOpenPath(path)) { - files.add(path) - } + gitPaths.add(path) } const child = spawn('git', ['ls-files', ...args], { @@ -117,7 +109,7 @@ export function listFilesWithGit( function handleError(err: Error): void { rejectPass(err) } - function handleClose(_code: number | null, signal: NodeJS.Signals | null): void { + function handleClose(code: number | null, signal: NodeJS.Signals | null): void { if (done) { return } @@ -131,7 +123,14 @@ export function listFilesWithGit( if (buf) { processPath(buf) } - resolvePass() + 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') @@ -161,7 +160,13 @@ export function listFilesWithGit( } return Promise.all([runGitLsFiles(primary), runGitLsFiles(ignoredPass)]) - .then(() => Array.from(files)) + .then(() => + expandQuickOpenGitFilesWithNestedRepos({ + rootPath, + gitPaths, + excludePathPrefixes + }) + ) .catch((err) => { killSurvivors() throw err diff --git a/src/relay/fs-handler-list-files-ignored.test.ts b/src/relay/fs-handler-list-files-ignored.test.ts index 6329c78fc..dc53e5427 100644 --- a/src/relay/fs-handler-list-files-ignored.test.ts +++ b/src/relay/fs-handler-list-files-ignored.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi, beforeEach } from 'vitest' +import { afterEach, describe, expect, it, vi, beforeEach } from 'vitest' const { spawnMock } = vi.hoisted(() => ({ spawnMock: vi.fn() @@ -10,10 +10,20 @@ vi.mock('child_process', () => ({ import { EventEmitter } from 'events' import type { ChildProcess } from 'child_process' +import { mkdir, mkdtemp, rm, writeFile } from 'fs/promises' +import { dirname, join } from 'path' +import { tmpdir } from 'os' import { listFilesWithGit } from './fs-handler-git-fallback' import { listFilesWithRg } from './fs-handler-list-files' import { searchWithRg } from './fs-handler-utils' +const tempDirs: string[] = [] +const SHA1 = '0123456789abcdef0123456789abcdef01234567' + +function staged(mode: string, path: string): string { + return `${mode} ${SHA1} 0\t${path}` +} + function createMockProcess(): ChildProcess { const p = new EventEmitter() as unknown as ChildProcess ;(p as unknown as Record).stdout = new EventEmitter() @@ -29,11 +39,27 @@ function createMockProcess(): ChildProcess { return p } +async function makeTempRoot(): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-relay-git-list-files-')) + tempDirs.push(root) + return root +} + +async function writeRel(root: string, relPath: string, content = 'x'): Promise { + const absPath = join(root, ...relPath.split('/')) + await mkdir(dirname(absPath), { recursive: true }) + await writeFile(absPath, content) +} + describe('relay quick open ignored file listing', () => { beforeEach(() => { vi.clearAllMocks() }) + afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))) + }) + it('rg ignored pass includes ignored non-env files and keeps blocklists/excludes', async () => { const primaryProc = createMockProcess() const ignoredProc = createMockProcess() @@ -84,8 +110,14 @@ describe('relay quick open ignored file listing', () => { const promise = listFilesWithGit('/remote/root', ['packages/other']) setTimeout(() => { - ;(primaryProc.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\0') - ;(primaryProc.stdout as unknown as EventEmitter).emit('data', 'tab\tfile.txt\0') + ;(primaryProc.stdout as unknown as EventEmitter).emit( + 'data', + `${staged('100644', 'src/index.ts')}\0` + ) + ;(primaryProc.stdout as unknown as EventEmitter).emit( + 'data', + `${staged('100644', 'tab\tfile.txt')}\0` + ) primaryProc.emit('close', 0, null) ;(ignoredProc.stdout as unknown as EventEmitter).emit('data', 'dist/generated.js\0') @@ -99,6 +131,7 @@ describe('relay quick open ignored file listing', () => { expect(ignoredArgs).toEqual([ 'ls-files', '-z', + '-s', '--others', '--ignored', '--exclude-standard', @@ -109,6 +142,42 @@ describe('relay quick open ignored file listing', () => { ]) }) + it('git fallback fills nested git repos returned as root-relative placeholders', async () => { + const root = await makeTempRoot() + await writeRel(root, 'README.md') + await mkdir(join(root, 'packages', 'app', '.git'), { recursive: true }) + await writeRel(root, 'packages/app/src/main.ts') + await mkdir(join(root, 'packages', 'lib'), { recursive: true }) + await writeFile(join(root, 'packages', 'lib', '.git'), 'gitdir: ../.git/worktrees/lib') + await writeRel(root, 'packages/lib/src/lib.ts') + + const primaryProc = createMockProcess() + const ignoredProc = createMockProcess() + let callIndex = 0 + + spawnMock.mockImplementation(() => { + callIndex++ + return callIndex === 1 ? primaryProc : ignoredProc + }) + + const promise = listFilesWithGit(root) + + setTimeout(() => { + ;(primaryProc.stdout as unknown as EventEmitter).emit( + 'data', + `${staged('100644', 'README.md')}\0${staged('160000', 'packages/app')}\0packages/lib/\0` + ) + primaryProc.emit('close', 0, null) + ignoredProc.emit('close', 0, null) + }, 10) + + await expect(promise).resolves.toEqual([ + 'README.md', + 'packages/app/src/main.ts', + 'packages/lib/src/lib.ts' + ]) + }) + it('git fallback rejects signal exits instead of returning partial results', async () => { const primaryProc = createMockProcess() const ignoredProc = createMockProcess() @@ -132,6 +201,28 @@ describe('relay quick open ignored file listing', () => { await expect(promise).rejects.toThrow('git ls-files killed by SIGTERM') }) + it('git fallback rejects non-zero exits instead of expanding a partial result set', async () => { + const primaryProc = createMockProcess() + const ignoredProc = createMockProcess() + let callIndex = 0 + + spawnMock.mockImplementation(() => { + callIndex++ + return callIndex === 1 ? primaryProc : ignoredProc + }) + + const promise = listFilesWithGit('/remote/root') + + setTimeout(() => { + ;(primaryProc.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\0') + primaryProc.emit('close', 0, null) + + ignoredProc.emit('close', 128, null) + }, 10) + + await expect(promise).rejects.toThrow('git ls-files exited with code 128') + }) + it('git fallback rejects when a timed-out child does not emit close', async () => { vi.useFakeTimers() try { diff --git a/src/relay/fs-handler-readdir-fallback.ts b/src/relay/fs-handler-readdir-fallback.ts index 3e961c76f..e49609996 100644 --- a/src/relay/fs-handler-readdir-fallback.ts +++ b/src/relay/fs-handler-readdir-fallback.ts @@ -11,19 +11,10 @@ * otherwise Quick Open would display "No matching files" for what was * actually an incomplete scan. */ -import { readdir } from 'fs/promises' -import { join, relative } from 'path' -import { HIDDEN_DIR_BLOCKLIST, shouldExcludeQuickOpenRelPath } from '../shared/quick-open-filter' - -const MAX_FILES = 10_000 -const TIMEOUT_MS = 10_000 - -function shouldDescend(name: string): boolean { - if (name === 'node_modules' || HIDDEN_DIR_BLOCKLIST.has(name)) { - return false - } - return true -} +import { + createQuickOpenReaddirBudget, + listQuickOpenFilesWithReaddir +} from '../shared/quick-open-readdir-walk' /** * Recursively list files under `rootPath` using fs.readdir. @@ -34,60 +25,8 @@ export async function listFilesWithReaddir( rootPath: string, excludePathPrefixes: readonly string[] = [] ): Promise { - const files: string[] = [] - const deadline = Date.now() + TIMEOUT_MS - let hitLimit = false - - async function walk(dir: string): Promise { - if (hitLimit) { - return - } - if (files.length >= MAX_FILES || Date.now() > deadline) { - hitLimit = true - return - } - - let entries - try { - entries = await readdir(dir, { withFileTypes: true }) - } catch { - // Why: permission denied / symlink loop on an individual subtree is - // expected on home-dir roots (e.g. root-owned mounts). Skip the - // subtree silently — this does NOT promote to a full-listing failure. - return - } - - for (const entry of entries) { - if (files.length >= MAX_FILES || Date.now() > deadline) { - hitLimit = true - return - } - - const name = entry.name - const absPath = join(dir, name) - // Why: path.relative returns backslashes on Windows. Quick-open UI - // assumes POSIX separators for display and fuzzy matching. - const relPath = relative(rootPath, absPath).replace(/\\/g, '/') - if (shouldExcludeQuickOpenRelPath(relPath, excludePathPrefixes)) { - continue - } - if (entry.isDirectory()) { - if (shouldDescend(name)) { - await walk(absPath) - } - } else if (entry.isFile()) { - files.push(relPath) - } - } - } - - await walk(rootPath) - if (hitLimit) { - throw new Error( - files.length >= MAX_FILES - ? `File listing exceeded ${MAX_FILES} files` - : 'File listing timed out' - ) - } - return files + return listQuickOpenFilesWithReaddir(rootPath, { + excludePathPrefixes, + budget: createQuickOpenReaddirBudget() + }) } diff --git a/src/relay/fs-handler.ts b/src/relay/fs-handler.ts index 3760a6b5a..2e15408fe 100644 --- a/src/relay/fs-handler.ts +++ b/src/relay/fs-handler.ts @@ -17,6 +17,7 @@ import { } from './fs-handler-utils' import { listFilesWithGit, searchWithGitGrep } from './fs-handler-git-fallback' import { listFilesWithReaddir } from './fs-handler-readdir-fallback' +import { isQuickOpenReaddirBudgetError } from '../shared/quick-open-readdir-walk' import { buildExcludePathPrefixes } from '../shared/quick-open-filter' import { buildInstallRgMessage } from './fs-handler-install-rg' import { readRelayFileContent, readRelayFileStreamMetadata } from './fs-handler-file-read' @@ -298,7 +299,18 @@ export class FsHandler { ) }) if (isGitRepo) { - return listFilesWithGit(rootPath, excludePathPrefixes) + // Why: a git monorepo parent fills nested-repo subtrees via the readdir + // walk, which can exhaust the same cap/deadline. Translate only those + // budget errors into install-rg guidance; genuine git failures keep + // their own messages. + try { + return await listFilesWithGit(rootPath, excludePathPrefixes) + } catch (err) { + if (isQuickOpenReaddirBudgetError(err)) { + throw new Error(await buildInstallRgMessage(err)) + } + throw err + } } // Why: the readdir walker rejects on cap/deadline instead of returning a // partial list (design doc: silent truncation is worse than an explicit diff --git a/src/shared/quick-open-filter.test.ts b/src/shared/quick-open-filter.test.ts index db18983f3..2b079f6b7 100644 --- a/src/shared/quick-open-filter.test.ts +++ b/src/shared/quick-open-filter.test.ts @@ -239,12 +239,12 @@ describe('normalizeQuickOpenRgLine', () => { describe('buildGitLsFilesArgsForQuickOpen', () => { it('primary pass is --cached --others --exclude-standard', () => { const { primary } = buildGitLsFilesArgsForQuickOpen() - expect(primary).toEqual(['-z', '--cached', '--others', '--exclude-standard']) + expect(primary).toEqual(['-z', '-s', '--cached', '--others', '--exclude-standard']) }) it('ignored pass surfaces ignored files without .env* pathspec whitelist', () => { const { ignoredPass } = buildGitLsFilesArgsForQuickOpen() - expect(ignoredPass).toEqual(['-z', '--others', '--ignored', '--exclude-standard']) + expect(ignoredPass).toEqual(['-z', '-s', '--others', '--ignored', '--exclude-standard']) expect(ignoredPass).not.toContain('.env*') expect(ignoredPass).not.toContain(':(glob)**/.env*') }) diff --git a/src/shared/quick-open-filter.ts b/src/shared/quick-open-filter.ts index 930645718..65e94c3e0 100644 --- a/src/shared/quick-open-filter.ts +++ b/src/shared/quick-open-filter.ts @@ -377,9 +377,16 @@ export function buildGitLsFilesArgsForQuickOpen( } const trailingPathspecs = excludeSpecs.length > 0 ? ['--', '.', ...excludeSpecs] : [] - // Why: newline output C-quotes tabs/newlines, which makes Quick Open return - // fake paths when rg is unavailable. NUL output preserves real Git paths. - const primary = ['-z', '--cached', '--others', '--exclude-standard', ...trailingPathspecs] - const ignoredPass = ['-z', '--others', '--ignored', '--exclude-standard', ...trailingPathspecs] + // Why: NUL preserves real Git paths; stage mode identifies gitlinks without + // lstat probes for ordinary tracked files. + const primary = ['-z', '-s', '--cached', '--others', '--exclude-standard', ...trailingPathspecs] + const ignoredPass = [ + '-z', + '-s', + '--others', + '--ignored', + '--exclude-standard', + ...trailingPathspecs + ] return { primary, ignoredPass } } diff --git a/src/shared/quick-open-readdir-walk.test.ts b/src/shared/quick-open-readdir-walk.test.ts new file mode 100644 index 000000000..1d4a441f6 --- /dev/null +++ b/src/shared/quick-open-readdir-walk.test.ts @@ -0,0 +1,262 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +const { lstatMock } = vi.hoisted(() => ({ + lstatMock: vi.fn() +})) + +vi.mock('fs/promises', async () => { + const actual = await vi.importActual('fs/promises') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() + lstatMock.mockImplementation(actual.lstat) + return { + ...actual, + lstat: lstatMock + } +}) + +import { mkdtemp, mkdir, rm, symlink, writeFile } from 'fs/promises' +import { tmpdir } from 'os' +import { dirname, join } from 'path' +import { + classifyQuickOpenGitEntry, + createQuickOpenReaddirBudget, + expandQuickOpenGitFilesWithNestedRepos, + isQuickOpenReaddirBudgetError, + listQuickOpenFilesWithReaddir, + parseQuickOpenGitLsFilesEntry +} from './quick-open-readdir-walk' + +const tempDirs: string[] = [] +const SHA1 = '0123456789abcdef0123456789abcdef01234567' +const SHA256 = `${SHA1}89abcdef0123456789abcdef` + +function staged(mode: string, path: string, sha = SHA1): string { + return `${mode} ${sha} 0\t${path}` +} + +async function makeTempRoot(): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-quick-open-readdir-')) + tempDirs.push(root) + return root +} + +async function writeRel(root: string, relPath: string, content = 'x'): Promise { + const absPath = join(root, ...relPath.split('/')) + await mkdir(dirname(absPath), { recursive: true }) + await writeFile(absPath, content) +} + +async function mkdirRel(root: string, relPath: string): Promise { + await mkdir(join(root, ...relPath.split('/')), { recursive: true }) +} + +async function makeNestedRepo(root: string, relPath: string, gitEntry: 'dir' | 'file' = 'dir') { + await mkdirRel(root, relPath) + const gitPath = join(root, ...relPath.split('/'), '.git') + await (gitEntry === 'dir' + ? mkdir(gitPath, { recursive: true }) + : writeFile(gitPath, 'gitdir: ../.git/worktrees/example')) +} + +afterEach(async () => { + vi.clearAllMocks() + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))) +}) + +describe('quick-open readdir walk', () => { + it('parses git ls-files stage output and bare untracked entries', () => { + expect(parseQuickOpenGitLsFilesEntry(staged('100644', 'src/index.ts'))).toEqual({ + path: 'src/index.ts', + isGitlink: false, + isUntrackedDir: false + }) + expect(parseQuickOpenGitLsFilesEntry(staged('160000', 'packages/app'))).toEqual({ + path: 'packages/app', + isGitlink: true, + isUntrackedDir: false + }) + expect(parseQuickOpenGitLsFilesEntry(staged('100755', 'bin/run', SHA256))).toEqual({ + path: 'bin/run', + isGitlink: false, + isUntrackedDir: false + }) + expect(parseQuickOpenGitLsFilesEntry('scratch.txt')).toEqual({ + path: 'scratch.txt', + isGitlink: false, + isUntrackedDir: false + }) + expect(parseQuickOpenGitLsFilesEntry('packages/lib/')).toEqual({ + path: 'packages/lib/', + isGitlink: false, + isUntrackedDir: true + }) + }) + + it('keeps ordinary git entries without lstat calls', async () => { + await expect( + expandQuickOpenGitFilesWithNestedRepos({ + rootPath: '/unused/root', + gitPaths: [ + staged('100644', 'README.md'), + staged('100755', 'bin/run', SHA256), + 'scratch.txt' + ] + }) + ).resolves.toEqual(['README.md', 'bin/run', 'scratch.txt']) + + expect(lstatMock).not.toHaveBeenCalled() + }) + + it('classifies nested repo placeholders without confusing extensionless files', async () => { + const root = await makeTempRoot() + await writeRel(root, 'Makefile') + await makeNestedRepo(root, 'packages/app') + await makeNestedRepo(root, 'packages/lib', 'file') + await mkdirRel(root, 'packages/unchecked') + + await expect(classifyQuickOpenGitEntry(root, staged('100644', 'Makefile'))).resolves.toEqual({ + kind: 'keep', + relPath: 'Makefile' + }) + await expect( + classifyQuickOpenGitEntry(root, staged('160000', 'packages/app')) + ).resolves.toEqual({ + kind: 'fill-nested-repo', + relPath: 'packages/app' + }) + await expect(classifyQuickOpenGitEntry(root, 'packages/lib/')).resolves.toEqual({ + kind: 'fill-nested-repo', + relPath: 'packages/lib' + }) + await expect(classifyQuickOpenGitEntry(root, 'packages/unchecked')).resolves.toEqual({ + kind: 'keep', + relPath: 'packages/unchecked' + }) + await expect(classifyQuickOpenGitEntry(root, 'packages/unchecked/')).resolves.toEqual({ + kind: 'drop-placeholder', + relPath: 'packages/unchecked' + }) + }) + + it('re-prefixes nested children and filters final workspace-relative paths', async () => { + const root = await makeTempRoot() + await writeRel(root, 'README.md') + await writeRel(root, 'Makefile') + await makeNestedRepo(root, 'packages/app') + await makeNestedRepo(root, 'packages/lib', 'file') + await mkdirRel(root, 'packages/empty') + await writeRel(root, 'packages/app/src/main.ts') + await writeRel(root, 'packages/app/node_modules/pkg/index.js') + await writeRel(root, 'packages/app/.git/config') + await writeRel(root, 'packages/app/linked-worktree/file.ts') + await writeRel(root, 'packages/lib/src/lib.ts') + + await expect( + expandQuickOpenGitFilesWithNestedRepos({ + rootPath: root, + gitPaths: [ + staged('100644', 'README.md'), + staged('100644', 'Makefile'), + staged('160000', 'packages/app'), + 'packages/lib/', + 'packages/empty/' + ], + excludePathPrefixes: ['packages/app/linked-worktree'] + }) + ).resolves.toEqual([ + 'README.md', + 'Makefile', + 'packages/app/src/main.ts', + 'packages/lib/src/lib.ts' + ]) + }) + + it('rejects on cap and shares one budget across nested subtrees', async () => { + const root = await makeTempRoot() + await makeNestedRepo(root, 'packages/app') + await makeNestedRepo(root, 'packages/lib') + await writeRel(root, 'packages/app/a.ts') + await writeRel(root, 'packages/app/b.ts') + await writeRel(root, 'packages/lib/c.ts') + + await expect( + expandQuickOpenGitFilesWithNestedRepos({ + rootPath: root, + gitPaths: [staged('160000', 'packages/app'), staged('160000', 'packages/lib')], + budget: createQuickOpenReaddirBudget({ maxFiles: 2 }) + }) + ).rejects.toThrow('File listing exceeded') + }) + + it('prunes excluded nested subtrees during traversal without consuming the budget', async () => { + const root = await makeTempRoot() + await makeNestedRepo(root, 'packages/app') + await writeRel(root, 'packages/app/keep.ts') + // A large excluded subtree inside the nested repo: if it were walked before + // being filtered, it would exhaust the tiny budget and reject. + for (let i = 0; i < 20; i += 1) { + await writeRel(root, `packages/app/excluded/file-${i}.ts`) + } + + await expect( + expandQuickOpenGitFilesWithNestedRepos({ + rootPath: root, + gitPaths: [staged('160000', 'packages/app')], + excludePathPrefixes: ['packages/app/excluded'], + budget: createQuickOpenReaddirBudget({ maxFiles: 5 }) + }) + ).resolves.toEqual(['packages/app/keep.ts']) + }) + + it('identifies budget errors so callers can translate only those to install-rg guidance', () => { + expect(isQuickOpenReaddirBudgetError(new Error('File listing timed out'))).toBe(true) + expect(isQuickOpenReaddirBudgetError(new Error('File listing exceeded 10000 files'))).toBe(true) + // Genuine git failures must keep their own message, not the install-rg toast. + expect(isQuickOpenReaddirBudgetError(new Error('git ls-files killed by SIGTERM'))).toBe(false) + expect(isQuickOpenReaddirBudgetError('File listing timed out')).toBe(false) + }) + + it('rejects on deadline instead of returning a partial list', async () => { + const root = await makeTempRoot() + await writeRel(root, 'src/index.ts') + + await expect( + listQuickOpenFilesWithReaddir(root, { + budget: { remainingFiles: 10, deadlineMs: Date.now() - 1_000 } + }) + ).rejects.toThrow('File listing timed out') + }) + + it('does not list symlinked files or follow symlinked directories', async () => { + const root = await makeTempRoot() + await writeRel(root, 'src/index.ts') + await writeRel(root, 'target/file.ts') + + try { + await symlink(join(root, 'src/index.ts'), join(root, 'src/link.ts')) + await symlink(join(root, 'target'), join(root, 'linked-dir'), 'dir') + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'EPERM') { + return + } + throw err + } + + const files = await listQuickOpenFilesWithReaddir(root) + expect(files).toEqual(expect.arrayContaining(['src/index.ts', 'target/file.ts'])) + expect(files).not.toContain('src/link.ts') + expect(files).not.toContain('linked-dir/file.ts') + }) + + it('fills nested repo paths containing spaces and glob metacharacters', async () => { + const root = await makeTempRoot() + await makeNestedRepo(root, 'packages/app [one] space') + await writeRel(root, 'packages/app [one] space/src/main.ts') + + await expect( + expandQuickOpenGitFilesWithNestedRepos({ + rootPath: root, + gitPaths: ['packages/app [one] space/'] + }) + ).resolves.toEqual(['packages/app [one] space/src/main.ts']) + }) +}) diff --git a/src/shared/quick-open-readdir-walk.ts b/src/shared/quick-open-readdir-walk.ts new file mode 100644 index 000000000..a05f5021c --- /dev/null +++ b/src/shared/quick-open-readdir-walk.ts @@ -0,0 +1,251 @@ +import { lstat, readdir } from 'fs/promises' +import { join, relative } from 'path' +import { + HIDDEN_DIR_BLOCKLIST, + shouldExcludeQuickOpenRelPath, + shouldIncludeQuickOpenPath +} from './quick-open-filter' + +export const QUICK_OPEN_READDIR_MAX_FILES = 10_000 +export const QUICK_OPEN_READDIR_TIMEOUT_MS = 10_000 + +export type QuickOpenReaddirBudget = { + remainingFiles: number + deadlineMs: number +} + +export type QuickOpenGitEntryKind = 'keep' | 'fill-nested-repo' | 'drop-placeholder' + +export type QuickOpenGitLsFilesEntry = { + path: string + isGitlink: boolean + isUntrackedDir: boolean +} + +const GIT_LS_FILES_STAGE_ENTRY = /^([0-7]{6}) [0-9a-f]{40,64} [0-3]\t/ + +export function parseQuickOpenGitLsFilesEntry(entry: string): QuickOpenGitLsFilesEntry { + const match = GIT_LS_FILES_STAGE_ENTRY.exec(entry) + if (match) { + return { + path: entry.slice(match[0].length), + isGitlink: match[1] === '160000', + isUntrackedDir: false + } + } + return { + path: entry, + isGitlink: false, + isUntrackedDir: entry.endsWith('/') + } +} + +export function createQuickOpenReaddirBudget( + opts: { maxFiles?: number; timeoutMs?: number; nowMs?: number } = {} +): QuickOpenReaddirBudget { + return { + remainingFiles: opts.maxFiles ?? QUICK_OPEN_READDIR_MAX_FILES, + deadlineMs: (opts.nowMs ?? Date.now()) + (opts.timeoutMs ?? QUICK_OPEN_READDIR_TIMEOUT_MS) + } +} + +const FILE_LISTING_TIMED_OUT = 'File listing timed out' +const FILE_LISTING_EXCEEDED_PREFIX = 'File listing exceeded' + +/** + * Why: the readdir walk can exhaust its cap/deadline even on the git path (a + * git monorepo parent with a huge nested repo). Callers translate only these + * budget errors into "install rg" guidance, leaving genuine git failures with + * their own messages. + */ +export function isQuickOpenReaddirBudgetError(error: unknown): boolean { + const message = error instanceof Error ? error.message : '' + return message === FILE_LISTING_TIMED_OUT || message.startsWith(FILE_LISTING_EXCEEDED_PREFIX) +} + +function assertWithinDeadline(budget: QuickOpenReaddirBudget): void { + if (Date.now() > budget.deadlineMs) { + throw new Error(FILE_LISTING_TIMED_OUT) + } +} + +function consumeFileBudget(budget: QuickOpenReaddirBudget): void { + if (budget.remainingFiles <= 0) { + throw new Error(`${FILE_LISTING_EXCEEDED_PREFIX} ${QUICK_OPEN_READDIR_MAX_FILES} files`) + } + budget.remainingFiles-- +} + +function shouldDescend(name: string): boolean { + return name !== 'node_modules' && !HIDDEN_DIR_BLOCKLIST.has(name) +} + +function toRelPath(rootPath: string, absPath: string): string { + // Why: path.relative returns backslashes on Windows, while Quick Open paths + // are always stored and matched with POSIX separators. + return relative(rootPath, absPath).replace(/\\/g, '/') +} + +function joinRootRel(rootPath: string, relPath: string): string { + return join(rootPath, ...relPath.split('/').filter(Boolean)) +} + +function normalizeGitEntry(entry: string): string { + return entry.replace(/\/+$/, '') +} + +// Translate workspace-root-relative exclude prefixes into prefixes relative to +// a nested repo at `nestedRelPath`, so the nested walk can prune them during +// traversal. Prefixes outside the nested repo are dropped (they cannot match). +function rebaseExcludePrefixesForNestedRepo( + excludePathPrefixes: readonly string[], + nestedRelPath: string +): string[] { + const base = `${nestedRelPath}/` + const rebased: string[] = [] + for (const prefix of excludePathPrefixes) { + if (prefix.startsWith(base)) { + rebased.push(prefix.slice(base.length)) + } + } + return rebased +} + +async function hasGitEntry(absPath: string): Promise { + try { + const stat = await lstat(join(absPath, '.git')) + return stat.isDirectory() || stat.isFile() + } catch { + return false + } +} + +export async function classifyQuickOpenGitEntry( + rootPath: string, + entry: string +): Promise<{ kind: QuickOpenGitEntryKind; relPath: string }> { + const parsed = parseQuickOpenGitLsFilesEntry(entry) + const relPath = normalizeGitEntry(parsed.path) + if (!relPath) { + return { kind: 'drop-placeholder', relPath } + } + + if (!parsed.isGitlink && !parsed.isUntrackedDir) { + return { kind: 'keep', relPath } + } + + let stat + try { + stat = await lstat(joinRootRel(rootPath, relPath)) + } catch { + return { kind: 'drop-placeholder', relPath } + } + + if (!stat.isDirectory()) { + return { kind: 'drop-placeholder', relPath } + } + + if (await hasGitEntry(joinRootRel(rootPath, relPath))) { + return { kind: 'fill-nested-repo', relPath } + } + + return { kind: 'drop-placeholder', relPath } +} + +export async function listQuickOpenFilesWithReaddir( + rootPath: string, + opts: { + excludePathPrefixes?: readonly string[] + budget?: QuickOpenReaddirBudget + } = {} +): Promise { + const files: string[] = [] + const budget = opts.budget ?? createQuickOpenReaddirBudget() + const excludePathPrefixes = opts.excludePathPrefixes ?? [] + + async function walk(dir: string): Promise { + assertWithinDeadline(budget) + + let entries + try { + entries = await readdir(dir, { withFileTypes: true }) + } catch { + // Why: permission denied on an individual subtree is common for broad + // roots. Skipping that subtree preserves the existing relay fallback. + return + } + + for (const entry of entries) { + assertWithinDeadline(budget) + + const name = entry.name + const absPath = join(dir, name) + const relPath = toRelPath(rootPath, absPath) + if (shouldExcludeQuickOpenRelPath(relPath, excludePathPrefixes)) { + continue + } + if (entry.isDirectory()) { + if (shouldDescend(name)) { + await walk(absPath) + } + continue + } + if (entry.isFile()) { + consumeFileBudget(budget) + files.push(relPath) + } + } + } + + await walk(rootPath) + return files +} + +export async function expandQuickOpenGitFilesWithNestedRepos(opts: { + rootPath: string + gitPaths: Iterable + excludePathPrefixes?: readonly string[] + budget?: QuickOpenReaddirBudget +}): Promise { + const files = new Set() + const excludePathPrefixes = opts.excludePathPrefixes ?? [] + const budget = opts.budget ?? createQuickOpenReaddirBudget() + + const addFinalPath = (relPath: string): void => { + if (!relPath) { + return + } + if (shouldExcludeQuickOpenRelPath(relPath, excludePathPrefixes)) { + return + } + if (shouldIncludeQuickOpenPath(relPath)) { + files.add(relPath) + } + } + + for (const rawPath of opts.gitPaths) { + assertWithinDeadline(budget) + + const { kind, relPath } = await classifyQuickOpenGitEntry(opts.rootPath, rawPath) + if (kind === 'keep') { + addFinalPath(relPath) + continue + } + if (kind === 'drop-placeholder') { + continue + } + + const nestedFiles = await listQuickOpenFilesWithReaddir(joinRootRel(opts.rootPath, relPath), { + // Why: exclude prefixes are workspace-root-relative; rebase them onto the + // nested repo so the walk prunes excluded subtrees during traversal + // instead of burning the shared budget and filtering them at the end. + excludePathPrefixes: rebaseExcludePrefixesForNestedRepo(excludePathPrefixes, relPath), + budget + }) + for (const nestedFile of nestedFiles) { + addFinalPath(`${relPath}/${nestedFile}`) + } + } + + return Array.from(files) +}