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 <help@stably.ai>
This commit is contained in:
parent
b776506809
commit
f8e8d5d149
|
|
@ -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<void> {
|
||||
const absPath = join(root, ...relPath.split('/'))
|
||||
await mkdir(dirname(absPath), { recursive: true })
|
||||
await writeFile(absPath, content)
|
||||
}
|
||||
|
||||
async function initRepo(repoPath: string): Promise<void> {
|
||||
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([])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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<boolean> {
|
||||
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<typeof setTimeout>
|
||||
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<string[]> {
|
||||
if (!(await isInsideGitWorkTree(rootPath, localGitOptions))) {
|
||||
return listQuickOpenFilesWithReaddir(rootPath, {
|
||||
excludePathPrefixes,
|
||||
budget: createQuickOpenReaddirBudget()
|
||||
})
|
||||
}
|
||||
|
||||
const gitPaths = new Set<string>()
|
||||
const { primary, ignoredPass } = buildGitLsFilesArgsForQuickOpen(excludePathPrefixes)
|
||||
const children: {
|
||||
child: ChildProcess
|
||||
isDone: () => boolean
|
||||
reject: (error: Error) => void
|
||||
}[] = []
|
||||
|
||||
const runGitLsFiles = (args: string[]): Promise<void> => {
|
||||
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<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
|
||||
})
|
||||
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
|
||||
})
|
||||
}
|
||||
|
|
@ -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<string, unknown>).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)
|
||||
|
|
|
|||
|
|
@ -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<string[]> {
|
||||
const files = new Set<string>()
|
||||
const { primary, ignoredPass } = buildGitLsFilesArgsForQuickOpen(excludePathPrefixes)
|
||||
|
||||
const runGitLsFiles = (args: string[]): Promise<void> => {
|
||||
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<typeof setTimeout>
|
||||
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)
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string[]> {
|
||||
const files = new Set<string>()
|
||||
const gitPaths = new Set<string>()
|
||||
const { primary, ignoredPass } = buildGitLsFilesArgsForQuickOpen(excludePathPrefixes)
|
||||
const children: {
|
||||
child: ReturnType<typeof spawn>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>).stdout = new EventEmitter()
|
||||
|
|
@ -29,11 +39,27 @@ function createMockProcess(): ChildProcess {
|
|||
return p
|
||||
}
|
||||
|
||||
async function makeTempRoot(): Promise<string> {
|
||||
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<void> {
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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<string[]> {
|
||||
const files: string[] = []
|
||||
const deadline = Date.now() + TIMEOUT_MS
|
||||
let hitLimit = false
|
||||
|
||||
async function walk(dir: string): Promise<void> {
|
||||
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()
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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*')
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<typeof import('fs/promises')>('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<string> {
|
||||
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<void> {
|
||||
const absPath = join(root, ...relPath.split('/'))
|
||||
await mkdir(dirname(absPath), { recursive: true })
|
||||
await writeFile(absPath, content)
|
||||
}
|
||||
|
||||
async function mkdirRel(root: string, relPath: string): Promise<void> {
|
||||
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'])
|
||||
})
|
||||
})
|
||||
|
|
@ -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<boolean> {
|
||||
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<string[]> {
|
||||
const files: string[] = []
|
||||
const budget = opts.budget ?? createQuickOpenReaddirBudget()
|
||||
const excludePathPrefixes = opts.excludePathPrefixes ?? []
|
||||
|
||||
async function walk(dir: string): Promise<void> {
|
||||
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<string>
|
||||
excludePathPrefixes?: readonly string[]
|
||||
budget?: QuickOpenReaddirBudget
|
||||
}): Promise<string[]> {
|
||||
const files = new Set<string>()
|
||||
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)
|
||||
}
|
||||
Loading…
Reference in New Issue