fix: avoid file suggestion OOM on large repos (#1074)

* fix: avoid file suggestion OOM on large repos

* fix: handle ignore scope and abort semantics in file suggestions

* test: stabilize rebased branch CI verification

* test: make proxy env cleanup windows-safe

* test: preload file suggestions module in setup

* fix: keep file suggestions lazy on startup

* chore: address final review nits
This commit is contained in:
Tortes
2026-06-12 23:04:46 +08:00
committed by GitHub
parent 89d05317b6
commit 8f92346cf4
5 changed files with 871 additions and 98 deletions
-1
View File
@@ -160,4 +160,3 @@ For advanced provider setup, use the built-in provider manager:
~~~powershell
oc-provider
~~~
+392
View File
@@ -0,0 +1,392 @@
import {
afterEach,
beforeAll,
expect,
mock,
test,
} from 'bun:test'
import { EventEmitter } from 'node:events'
import * as path from 'node:path'
type FakeChildProcess = EventEmitter & {
stdout: EventEmitter
stderr: EventEmitter
}
type LoadModuleOptions = {
spawnScenario?: (child: FakeChildProcess, signal?: AbortSignal) => void
ripGrepStreamImpl?: (
args: string[],
target: string,
abortSignal: AbortSignal,
onLines: (lines: string[]) => void,
) => Promise<void>
}
let actualCrossSpawnModule:
| typeof import('cross-spawn')
| undefined
let actualRipgrepModule: typeof import('../utils/ripgrep.js') | undefined
let actualMarkdownConfigLoaderModule:
| typeof import('../utils/markdownConfigLoader.js')
| undefined
let defaultFileSuggestionsModule:
| Awaited<ReturnType<typeof loadFileSuggestionsModule>>
| undefined
afterEach(() => {
mock.restore()
})
beforeAll(
async () => {
defaultFileSuggestionsModule = await loadFileSuggestionsModule()
},
{ timeout: 15_000 },
)
function createAbortError(message = 'aborted'): Error {
const error = new Error(message)
error.name = 'AbortError'
return error
}
function createFakeChildProcess(): FakeChildProcess {
const child = new EventEmitter() as FakeChildProcess
child.stdout = new EventEmitter()
child.stderr = new EventEmitter()
return child
}
function createIgnoreModuleMock() {
return {
default: () => {
const patterns: string[] = []
const api = {
add(input: string) {
patterns.push(
...input
.split(/\r?\n/)
.map(line => line.trim())
.filter(line => line.length > 0 && !line.startsWith('#')),
)
return api
},
ignores(filePath: string) {
const normalized = filePath.replaceAll('\\', '/')
if (normalized.split('/').includes('..')) {
throw new Error('path should be a `path.relative()`d string')
}
return patterns.some(pattern => {
const normalizedPattern = pattern.replaceAll('\\', '/')
if (normalizedPattern.endsWith('/')) {
return normalized.startsWith(normalizedPattern)
}
return normalized === normalizedPattern
})
},
}
return api
},
}
}
function isGitCommand(command: string): boolean {
const basename = path.basename(command).toLowerCase()
return basename === 'git.exe' || basename === 'git'
}
function installFileSuggestionsDependencyMocks(options: LoadModuleOptions = {}): void {
const realSpawn =
actualCrossSpawnModule!.spawn ??
(actualCrossSpawnModule!.default as typeof actualCrossSpawnModule.spawn)
mock.module('cross-spawn', () => ({
...actualCrossSpawnModule!,
default: (
command: string,
args: string[],
spawnOptions: { signal?: AbortSignal },
) => {
if (!options.spawnScenario || !isGitCommand(command)) {
return realSpawn(command, args, spawnOptions)
}
const child = createFakeChildProcess()
options.spawnScenario?.(child, spawnOptions.signal)
return child
},
spawn: (
command: string,
args: string[],
spawnOptions: { signal?: AbortSignal },
) => {
if (!options.spawnScenario || !isGitCommand(command)) {
return realSpawn(command, args, spawnOptions)
}
const child = createFakeChildProcess()
options.spawnScenario?.(child, spawnOptions.signal)
return child
},
}))
mock.module('../native-ts/file-index/index.js', () => ({
CHUNK_MS: 4,
FileIndex: class FileIndex {
loadFromFileListAsync(): { done: Promise<void> } {
return { done: Promise.resolve() }
}
search(): Array<{ path: string; score: number }> {
return []
}
},
yieldToEventLoop: async () => {},
}))
mock.module('../utils/ripgrep.js', () => ({
...actualRipgrepModule!,
ripGrepStream:
options.ripGrepStreamImpl ??
(async () => {
return undefined
}),
}))
}
async function loadFileSuggestionsModule(options: LoadModuleOptions = {}) {
actualCrossSpawnModule ??= await import('cross-spawn')
actualRipgrepModule ??= await import('../utils/ripgrep.js')
actualMarkdownConfigLoaderModule ??= await import(
'../utils/markdownConfigLoader.js'
)
installFileSuggestionsDependencyMocks(options)
const nonce = `${Date.now()}-${Math.random()}`
const module = await import(`./fileSuggestions.ts?ts=${nonce}`)
mock.restore()
return module
}
async function getDefaultFileSuggestionsModule() {
return defaultFileSuggestionsModule ?? loadFileSuggestionsModule()
}
test('normalizeFileSuggestionPath strips leading current-directory prefixes', async () => {
const fileSuggestions = await getDefaultFileSuggestionsModule()
expect(fileSuggestions.normalizeFileSuggestionPath('./src/index.ts')).toBe(
'src/index.ts',
)
expect(fileSuggestions.normalizeFileSuggestionPath('.\\src\\index.ts')).toBe(
'src\\index.ts',
)
expect(fileSuggestions.normalizeFileSuggestionPath('src/index.ts')).toBe(
'src/index.ts',
)
})
test('shouldExcludeFileSuggestionPath excludes common generated directories', async () => {
const fileSuggestions = await getDefaultFileSuggestionsModule()
expect(
fileSuggestions.shouldExcludeFileSuggestionPath(
'node_modules/react/index.js',
),
).toBe(true)
expect(
fileSuggestions.shouldExcludeFileSuggestionPath('wandb/run-1/output.log'),
).toBe(true)
expect(
fileSuggestions.shouldExcludeFileSuggestionPath(
'src/node_modules-helper.ts',
),
).toBe(false)
expect(
fileSuggestions.shouldExcludeFileSuggestionPath('src/components/'),
).toBe(false)
})
test('filterCandidatePathsForSuggestions filters generated directories and caps file count', async () => {
const fileSuggestions = await getDefaultFileSuggestionsModule()
const result = fileSuggestions.filterCandidatePathsForSuggestions(
[
'./src/index.ts',
'node_modules/pkg/index.js',
'wandb/latest-run.log',
'src/app.ts',
'src/extra.ts',
],
2,
)
expect(result.files).toEqual(['src/index.ts', 'src/app.ts'])
expect(result.truncated).toBe(true)
})
test('filterCandidatePathsForSuggestions keeps parent-relative paths when matcher throws on ..', async () => {
const fileSuggestions = await getDefaultFileSuggestionsModule()
const result = fileSuggestions.filterCandidatePathsForSuggestions(
['../bar.ts'],
10,
{
ignores(filePath: string) {
if (filePath.includes('..')) {
throw new Error('path should be a `path.relative()`d string')
}
return filePath.startsWith('foo/')
},
},
)
expect(result.files).toEqual(['../bar.ts'])
expect(result.truncated).toBe(false)
})
test('createFileSuggestionIgnoreMatcher scopes ignore patterns to their roots for subdirectory cwd', async () => {
const fileSuggestions = await getDefaultFileSuggestionsModule()
const repoRoot = path.resolve('virtual-repo')
const cwd = path.join(repoRoot, 'packages', 'app')
const matcher = fileSuggestions.createFileSuggestionIgnoreMatcher(cwd, [
{
root: repoRoot,
patterns: ['top-level.ts', 'shared-ignore/'].join('\n'),
},
{
root: cwd,
patterns: ['local-ignore.ts', 'local-generated/'].join('\n'),
},
])
const result = fileSuggestions.filterCandidatePathsForSuggestions(
[
'../sibling.ts',
'../../top-level.ts',
'../../shared-ignore/file.ts',
'local-ignore.ts',
'local-generated/file.ts',
'keep.ts',
],
10,
matcher,
)
expect(result.files).toEqual(['../sibling.ts', 'keep.ts'])
expect(result.truncated).toBe(false)
})
test('collectGitPaths reports external abort before output as non-success', async () => {
const fileSuggestions = await loadFileSuggestionsModule({
spawnScenario: (child, signal) => {
signal?.addEventListener(
'abort',
() => {
queueMicrotask(() => {
child.emit('error', createAbortError())
child.emit('close', 1)
})
},
{ once: true },
)
},
})
const controller = new AbortController()
const promise = fileSuggestions.collectGitPathsForTesting(['ls-files'], {
repoRoot: process.cwd(),
cwd: process.cwd(),
abortSignal: controller.signal,
maxFiles: 10,
})
controller.abort()
await expect(promise).resolves.toMatchObject({
files: [],
truncated: false,
code: 1,
})
})
test('collectGitPaths reports external abort after partial output as non-success', async () => {
const fileSuggestions = await loadFileSuggestionsModule({
spawnScenario: (child, signal) => {
queueMicrotask(() => {
child.stdout.emit('data', Buffer.from('tracked/a.ts\n'))
})
signal?.addEventListener(
'abort',
() => {
queueMicrotask(() => {
child.emit('error', createAbortError())
child.emit('close', 1)
})
},
{ once: true },
)
},
})
const controller = new AbortController()
const promise = fileSuggestions.collectGitPathsForTesting(['ls-files'], {
repoRoot: process.cwd(),
cwd: process.cwd(),
abortSignal: controller.signal,
maxFiles: 10,
})
await Promise.resolve()
controller.abort()
await expect(promise).resolves.toMatchObject({
truncated: false,
code: 1,
})
})
test('collectRipgrepPaths rejects external abort before output', async () => {
const fileSuggestions = await loadFileSuggestionsModule({
ripGrepStreamImpl: async (_args, _target, abortSignal) => {
await new Promise((_, reject) => {
abortSignal.addEventListener(
'abort',
() => reject(createAbortError()),
{ once: true },
)
})
},
})
const controller = new AbortController()
const promise = fileSuggestions.collectRipgrepPathsForTesting(
['--files'],
'.',
controller.signal,
10,
)
controller.abort()
await expect(promise).rejects.toMatchObject({ name: 'AbortError' })
})
test('collectRipgrepPaths rejects external abort after partial output', async () => {
const fileSuggestions = await loadFileSuggestionsModule({
ripGrepStreamImpl: async (_args, _target, abortSignal, onLines) => {
onLines(['partial.ts'])
await new Promise((_, reject) => {
abortSignal.addEventListener(
'abort',
() => reject(createAbortError()),
{ once: true },
)
})
},
})
const controller = new AbortController()
const promise = fileSuggestions.collectRipgrepPathsForTesting(
['--files'],
'.',
controller.signal,
10,
)
await Promise.resolve()
controller.abort()
await expect(promise).rejects.toMatchObject({ name: 'AbortError' })
})
+441 -77
View File
@@ -1,4 +1,5 @@
import { statSync } from 'fs'
import { spawn } from 'cross-spawn'
import ignore from 'ignore'
import * as path from 'path'
import {
@@ -18,7 +19,6 @@ import { createCombinedAbortSignal } from '../utils/combinedAbortSignal.js'
import { getCwd } from '../utils/cwd.js'
import { logForDebugging } from '../utils/debug.js'
import { errorMessage } from '../utils/errors.js'
import { execFileNoThrowWithCwd } from '../utils/execFileNoThrow.js'
import { getFsImplementation } from '../utils/fsOperations.js'
import { findGitRoot, gitExe } from '../utils/git.js'
import {
@@ -27,7 +27,7 @@ import {
} from '../utils/hooks.js'
import { logError } from '../utils/log.js'
import { expandPath } from '../utils/path.js'
import { ripGrep } from '../utils/ripgrep.js'
import { ripGrepStream } from '../utils/ripgrep.js'
import { getInitialSettings } from '../utils/settings/settings.js'
import { createSignal } from '../utils/signal.js'
@@ -59,8 +59,8 @@ let cachedConfigFiles: string[] = []
// recompute ~270k path.dirname() calls on each merge
let cachedTrackedDirs: string[] = []
// Cache for .ignore/.rgignore patterns (keyed by repoRoot:cwd)
let ignorePatternsCache: ReturnType<typeof ignore> | null = null
// Cache for .ignore/.rgignore matchers (keyed by repoRoot:cwd)
let ignorePatternsCache: FileSuggestionIgnoreMatcher | null = null
let ignorePatternsCacheKey: string | null = null
// Throttle state for background refresh. .git/index mtime triggers an
@@ -78,6 +78,351 @@ let lastGitIndexMtime: number | null = null
let loadedTrackedSignature: string | null = null
let loadedMergedSignature: string | null = null
type FileSuggestionIgnoreMatcher = {
ignores(filePath: string): boolean
}
type FileSuggestionIgnoreSource = {
root: string
patterns: string
}
const MAX_FILE_SUGGESTION_FILES = 200_000
const FILE_SUGGESTION_EXCLUDED_DIRS = new Set([
'.git',
'.hg',
'.svn',
'node_modules',
'.pnpm-store',
'.yarn',
'.next',
'.nuxt',
'.svelte-kit',
'.turbo',
'dist',
'build',
'out',
'output',
'coverage',
'.cache',
'__pycache__',
'.pytest_cache',
'.mypy_cache',
'.ruff_cache',
'.tox',
'venv',
'.venv',
'__pypackages__',
'site-packages',
'wandb',
'lightning_logs',
'mlruns',
])
export function normalizeFileSuggestionPath(filePath: string): string {
if (filePath.startsWith('./') || filePath.startsWith('.\\')) {
return filePath.slice(2)
}
return filePath
}
export function shouldExcludeFileSuggestionPath(filePath: string): boolean {
const normalizedPath = normalizeFileSuggestionPath(filePath)
const isDirectory = /[\\/]$/.test(normalizedPath)
const normalized = normalizedPath
.replaceAll('\\', '/')
.replace(/\/$/, '')
.toLowerCase()
const parts = normalized.split('/').filter(Boolean)
const lastDirIndex = isDirectory ? parts.length : parts.length - 1
for (let i = 0; i < lastDirIndex; i++) {
if (FILE_SUGGESTION_EXCLUDED_DIRS.has(parts[i]!)) {
return true
}
}
return false
}
export function filterCandidatePathsForSuggestions(
filePaths: string[],
maxFiles = MAX_FILE_SUGGESTION_FILES,
ignorePatterns?: FileSuggestionIgnoreMatcher | null,
): { files: string[]; truncated: boolean } {
const files: string[] = []
for (const rawPath of filePaths) {
if (
tryAddFileSuggestionPath(rawPath, files, maxFiles, ignorePatterns)
) {
return { files, truncated: true }
}
}
return { files, truncated: false }
}
function tryAddFileSuggestionPath(
rawPath: string,
out: string[],
maxFiles: number,
ignorePatterns?: FileSuggestionIgnoreMatcher | null,
): boolean {
if (!rawPath) return false
const normalizedPath = normalizeFileSuggestionPath(rawPath)
if (!normalizedPath) return false
if (shouldIgnoreFileSuggestionPath(normalizedPath, ignorePatterns)) {
return false
}
if (shouldExcludeFileSuggestionPath(normalizedPath)) return false
out.push(normalizedPath)
return out.length >= maxFiles
}
function shouldIgnoreFileSuggestionPath(
filePath: string,
ignorePatterns?: FileSuggestionIgnoreMatcher | null,
): boolean {
if (!ignorePatterns) return false
try {
return ignorePatterns.ignores(filePath)
} catch {
return false
}
}
function isPathWithinIgnoreRoot(relativePath: string): boolean {
return (
relativePath !== '..' &&
!relativePath.startsWith(`..${path.sep}`) &&
!path.isAbsolute(relativePath)
)
}
function normalizePathForIgnoreMatcher(filePath: string): string {
return normalizeFileSuggestionPath(filePath).replaceAll('\\', '/')
}
function createFileSuggestionIgnoreMatcherFromSources(
cwd: string,
sources: FileSuggestionIgnoreSource[],
): FileSuggestionIgnoreMatcher | null {
const rootedMatchers = sources.flatMap(source => {
if (!source.patterns) return []
const matcher = ignore()
matcher.add(source.patterns)
return [{ root: source.root, matcher }]
})
if (rootedMatchers.length === 0) {
return null
}
return {
ignores(filePath: string): boolean {
const normalizedPath = normalizePathForIgnoreMatcher(filePath)
if (!normalizedPath) return false
const absolutePath = path.resolve(cwd, normalizedPath)
for (const { root, matcher } of rootedMatchers) {
const relativePath = path.relative(root, absolutePath)
if (!isPathWithinIgnoreRoot(relativePath)) continue
if (matcher.ignores(normalizePathForIgnoreMatcher(relativePath))) {
return true
}
}
return false
},
}
}
export function createFileSuggestionIgnoreMatcher(
cwd: string,
sources: FileSuggestionIgnoreSource[],
): FileSuggestionIgnoreMatcher | null {
return createFileSuggestionIgnoreMatcherFromSources(cwd, sources)
}
function normalizeGitPath(
file: string,
repoRoot: string,
originalCwd: string,
): string {
if (originalCwd === repoRoot) {
return file
}
return path.relative(originalCwd, path.join(repoRoot, file))
}
async function collectGitPaths(
gitArgs: string[],
params: {
repoRoot: string
cwd: string
abortSignal: AbortSignal
maxFiles: number
ignorePatterns?: FileSuggestionIgnoreMatcher | null
},
): Promise<{ files: string[]; truncated: boolean; code: number; stderr: string }> {
const { repoRoot, cwd, abortSignal, maxFiles, ignorePatterns } = params
const controller = new AbortController()
let abortedExternally = abortSignal.aborted
const forwardAbort = () => {
abortedExternally = true
controller.abort()
}
if (abortSignal.aborted) {
controller.abort()
} else {
abortSignal.addEventListener('abort', forwardAbort, { once: true })
}
try {
return await new Promise(resolve => {
const child = spawn(gitExe(), gitArgs, {
cwd: repoRoot,
shell: false,
signal: controller.signal,
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
})
const files: string[] = []
let remainder = ''
let stderr = ''
let truncated = false
let settled = false
const finish = (result: {
files: string[]
truncated: boolean
code: number
stderr: string
}) => {
if (settled) return
settled = true
resolve(result)
}
const processLine = (line: string) => {
const normalizedPath = normalizeGitPath(line, repoRoot, cwd)
if (
tryAddFileSuggestionPath(
normalizedPath,
files,
maxFiles,
ignorePatterns,
)
) {
truncated = true
controller.abort()
}
}
child.stdout?.on('data', (chunk: Buffer) => {
const data = remainder + chunk.toString('utf8')
const lines = data.split('\n')
remainder = lines.pop() ?? ''
for (const rawLine of lines) {
const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine
if (!line) continue
processLine(line)
if (truncated) break
}
})
child.stderr?.on('data', (chunk: Buffer) => {
if (stderr.length >= 8192) return
stderr += chunk.toString('utf8').slice(0, 8192 - stderr.length)
})
child.on('error', error => {
if (truncated) {
finish({ files, truncated: true, code: 0, stderr })
return
}
if (abortedExternally) {
const message = error instanceof Error ? error.message : String(error)
finish({
files,
truncated: false,
code: 1,
stderr: stderr || message || 'aborted',
})
return
}
const message = error instanceof Error ? error.message : String(error)
finish({ files, truncated, code: 1, stderr: stderr || message })
})
child.on('close', code => {
if (!truncated && !abortedExternally && remainder) {
const line = remainder.endsWith('\r')
? remainder.slice(0, -1)
: remainder
if (line) {
processLine(line)
}
}
if (truncated) {
finish({ files, truncated: true, code: 0, stderr })
return
}
if (abortedExternally) {
finish({ files, truncated: false, code: 1, stderr: stderr || 'aborted' })
return
}
finish({ files, truncated: false, code: code ?? 1, stderr })
})
})
} finally {
abortSignal.removeEventListener('abort', forwardAbort)
}
}
async function collectRipgrepPaths(
args: string[],
target: string,
abortSignal: AbortSignal,
maxFiles: number,
): Promise<{ files: string[]; truncated: boolean }> {
const controller = new AbortController()
let abortedExternally = abortSignal.aborted
const forwardAbort = () => {
abortedExternally = true
controller.abort()
}
if (abortSignal.aborted) {
controller.abort()
} else {
abortSignal.addEventListener('abort', forwardAbort, { once: true })
}
const files: string[] = []
let truncated = false
try {
await ripGrepStream(args, target, controller.signal, lines => {
for (const line of lines) {
if (tryAddFileSuggestionPath(line, files, maxFiles)) {
truncated = true
controller.abort()
break
}
}
})
} catch (error) {
if (!(truncated && !abortedExternally)) {
throw error
}
} finally {
abortSignal.removeEventListener('abort', forwardAbort)
}
return { files, truncated }
}
/**
* Clear all file suggestion caches.
* Call this when resuming a session to ensure fresh file discovery.
@@ -150,20 +495,6 @@ function getGitIndexMtime(): number | null {
/**
* Normalize git paths relative to originalCwd
*/
function normalizeGitPaths(
files: string[],
repoRoot: string,
originalCwd: string,
): string[] {
if (originalCwd === repoRoot) {
return files
}
return files.map(f => {
const absolutePath = path.join(repoRoot, f)
return path.relative(originalCwd, absolutePath)
})
}
/**
* Merge already-normalized untracked files into the cache
*/
@@ -203,7 +534,7 @@ async function mergeUntrackedIntoNormalizedCache(
async function loadRipgrepIgnorePatterns(
repoRoot: string,
cwd: string,
): Promise<ReturnType<typeof ignore> | null> {
): Promise<FileSuggestionIgnoreMatcher | null> {
const cacheKey = `${repoRoot}:${cwd}`
// Return cached result if available
@@ -215,8 +546,7 @@ async function loadRipgrepIgnorePatterns(
const ignoreFiles = ['.ignore', '.rgignore']
const directories = [...new Set([repoRoot, cwd])]
const ig = ignore()
let hasPatterns = false
const sources: FileSuggestionIgnoreSource[] = []
const paths = directories.flatMap(dir =>
ignoreFiles.map(f => path.join(dir, f)),
@@ -226,18 +556,42 @@ async function loadRipgrepIgnorePatterns(
)
for (const [i, content] of contents.entries()) {
if (content === null) continue
ig.add(content)
hasPatterns = true
logForDebugging(`[FileIndex] loaded ignore patterns from ${paths[i]}`)
sources.push({
root: path.dirname(paths[i]!),
patterns: content,
})
}
const result = hasPatterns ? ig : null
const result = createFileSuggestionIgnoreMatcherFromSources(cwd, sources)
ignorePatternsCache = result
ignorePatternsCacheKey = cacheKey
return result
}
export async function collectGitPathsForTesting(
gitArgs: string[],
params: {
repoRoot: string
cwd: string
abortSignal: AbortSignal
maxFiles: number
ignorePatterns?: FileSuggestionIgnoreMatcher | null
},
): Promise<{ files: string[]; truncated: boolean; code: number; stderr: string }> {
return collectGitPaths(gitArgs, params)
}
export async function collectRipgrepPathsForTesting(
args: string[],
target: string,
abortSignal: AbortSignal,
maxFiles: number,
): Promise<{ files: string[]; truncated: boolean }> {
return collectRipgrepPaths(args, target, abortSignal, maxFiles)
}
/**
* Get files using git ls-files (much faster than ripgrep for git repos)
* Returns tracked files immediately, fetches untracked in background
@@ -262,14 +616,20 @@ async function getFilesUsingGit(
try {
const cwd = getCwd()
const ignorePatterns = await loadRipgrepIgnorePatterns(repoRoot, cwd)
// Get tracked files (fast - reads from git index)
// Run from repoRoot so paths are relative to repo root, not CWD
const lsFilesStart = Date.now()
const trackedResult = await execFileNoThrowWithCwd(
gitExe(),
const trackedResult = await collectGitPaths(
['-c', 'core.quotepath=false', 'ls-files', '--recurse-submodules'],
{ timeout: 5000, abortSignal, cwd: repoRoot },
{
repoRoot,
cwd,
abortSignal,
maxFiles: MAX_FILE_SUGGESTION_FILES,
ignorePatterns,
},
)
logForDebugging(
`[FileIndex] git ls-files (tracked) took ${Date.now() - lsFilesStart}ms`,
@@ -282,20 +642,7 @@ async function getFilesUsingGit(
return null
}
const trackedFiles = trackedResult.stdout.trim().split('\n').filter(Boolean)
// Normalize paths relative to the current working directory
let normalizedTracked = normalizeGitPaths(trackedFiles, repoRoot, cwd)
// Apply .ignore/.rgignore patterns if present (faster than falling back to ripgrep)
const ignorePatterns = await loadRipgrepIgnorePatterns(repoRoot, cwd)
if (ignorePatterns) {
const beforeCount = normalizedTracked.length
normalizedTracked = ignorePatterns.filter(normalizedTracked)
logForDebugging(
`[FileIndex] applied ignore patterns: ${beforeCount} -> ${normalizedTracked.length} files`,
)
}
const normalizedTracked = trackedResult.files
// Cache tracked files for later merge with untracked
cachedTrackedFiles = normalizedTracked
@@ -304,6 +651,11 @@ async function getFilesUsingGit(
logForDebugging(
`[FileIndex] git ls-files: ${normalizedTracked.length} tracked files in ${duration}ms`,
)
if (trackedResult.truncated) {
logForDebugging(
`[FileIndex] capped tracked file index at ${normalizedTracked.length} files to avoid excessive memory use`,
)
}
logEvent('tengu_file_suggestions_git_ls_files', {
file_count: normalizedTracked.length,
@@ -313,7 +665,7 @@ async function getFilesUsingGit(
})
// Start background fetch for untracked files (don't await)
if (!untrackedFetchPromise) {
if (!untrackedFetchPromise && !trackedResult.truncated) {
const untrackedArgs = respectGitignore
? [
'-c',
@@ -325,43 +677,39 @@ async function getFilesUsingGit(
: ['-c', 'core.quotepath=false', 'ls-files', '--others']
const generation = cacheGeneration
untrackedFetchPromise = execFileNoThrowWithCwd(gitExe(), untrackedArgs, {
timeout: 10000,
cwd: repoRoot,
const remainingCapacity = Math.max(
0,
MAX_FILE_SUGGESTION_FILES - normalizedTracked.length,
)
if (remainingCapacity === 0) {
return normalizedTracked
}
const { signal: untrackedAbortSignal, cleanup: cleanupUntrackedFetch } =
createCombinedAbortSignal(undefined, {
timeoutMs: 10_000,
})
untrackedFetchPromise = collectGitPaths(untrackedArgs, {
repoRoot,
cwd,
abortSignal: untrackedAbortSignal,
maxFiles: remainingCapacity,
ignorePatterns,
})
.then(async untrackedResult => {
if (generation !== cacheGeneration) {
return // Cache was cleared; don't merge stale untracked files
}
if (untrackedResult.code === 0) {
const rawUntrackedFiles = untrackedResult.stdout
.trim()
.split('\n')
.filter(Boolean)
// Normalize paths BEFORE applying ignore patterns (consistent with tracked files)
let normalizedUntracked = normalizeGitPaths(
rawUntrackedFiles,
repoRoot,
cwd,
)
// Apply .ignore/.rgignore patterns to normalized untracked files
const ignorePatterns = await loadRipgrepIgnorePatterns(
repoRoot,
cwd,
)
if (ignorePatterns && normalizedUntracked.length > 0) {
const beforeCount = normalizedUntracked.length
normalizedUntracked = ignorePatterns.filter(normalizedUntracked)
logForDebugging(
`[FileIndex] applied ignore patterns to untracked: ${beforeCount} -> ${normalizedUntracked.length} files`,
)
}
const normalizedUntracked = untrackedResult.files
logForDebugging(
`[FileIndex] background untracked fetch: ${normalizedUntracked.length} files`,
)
if (untrackedResult.truncated) {
logForDebugging(
`[FileIndex] capped untracked file index at ${normalizedUntracked.length} files to avoid excessive memory use`,
)
}
// Pass already-normalized files directly to merge function
void mergeUntrackedIntoNormalizedCache(normalizedUntracked)
}
@@ -372,6 +720,7 @@ async function getFilesUsingGit(
)
})
.finally(() => {
cleanupUntrackedFetch()
untrackedFetchPromise = null
})
}
@@ -500,13 +849,22 @@ async function getProjectFiles(
rgArgs.push('--no-ignore-vcs')
}
const files = await ripGrep(rgArgs, '.', abortSignal)
const relativePaths = files.map(f => path.relative(getCwd(), f))
const { files: relativePaths, truncated } = await collectRipgrepPaths(
rgArgs,
'.',
abortSignal,
MAX_FILE_SUGGESTION_FILES,
)
const duration = Date.now() - startTime
logForDebugging(
`[FileIndex] ripgrep: ${relativePaths.length} files in ${duration}ms`,
)
if (truncated) {
logForDebugging(
`[FileIndex] capped ripgrep file index at ${relativePaths.length} files to avoid excessive memory use`,
)
}
logEvent('tengu_file_suggestions_ripgrep', {
file_count: relativePaths.length,
@@ -700,12 +1058,14 @@ async function getTopLevelPaths(): Promise<string[]> {
try {
const entries = await fs.readdir(cwd)
return entries.map(entry => {
const fullPath = path.join(cwd, entry.name)
const relativePath = path.relative(cwd, fullPath)
// Add trailing separator for directories
return entry.isDirectory() ? relativePath + path.sep : relativePath
})
return entries
.map(entry => {
const fullPath = path.join(cwd, entry.name)
const relativePath = path.relative(cwd, fullPath)
// Add trailing separator for directories
return entry.isDirectory() ? relativePath + path.sep : relativePath
})
.filter(p => !shouldExcludeFileSuggestionPath(p))
} catch (error) {
logError(error as Error)
return []
@@ -747,12 +1107,16 @@ export async function generateFileSuggestions(
const startTime = Date.now()
try {
const hadIndex = fileIndex !== null
// Kick a background refresh. The index is progressively queryable —
// searches during build return partial results from ready chunks, and
// the typeahead callback (setOnIndexBuildComplete) re-fires the search
// when the build finishes to upgrade partial → full.
const wasBuilding = fileListRefreshPromise !== null
startBackgroundCacheRefresh()
if (!hadIndex && fileListRefreshPromise) {
await fileListRefreshPromise
}
// Handle both './' and '.\'
let normalizedPath = partialPath
+3 -18
View File
@@ -27,7 +27,7 @@ import { getDirectoryCompletions, getPathCompletions, isPathLikeToken } from '..
import { getShellHistoryCompletion } from '../utils/suggestions/shellHistoryCompletion.js';
import { getSlackChannelSuggestions, hasSlackMcpServer } from '../utils/suggestions/slackChannelSuggestions.js';
import { TEAM_LEAD_NAME } from '../utils/swarm/constants.js';
import { applyFileSuggestion, findLongestCommonPrefix, onIndexBuildComplete, startBackgroundCacheRefresh } from './fileSuggestions.js';
import { applyFileSuggestion, findLongestCommonPrefix, onIndexBuildComplete } from './fileSuggestions.js';
import { generateUnifiedSuggestions } from './unifiedSuggestions.js';
// Unicode-aware character class for file path tokens:
@@ -477,24 +477,9 @@ export function useTypeahead({
setMaxColumnWidth(undefined); // No fixed width for file suggestions
}, [mcpResources, setSuggestionsState, setSuggestionType, setMaxColumnWidth, agents]);
// Pre-warm the file index on mount so the first @-mention doesn't block.
// The build runs in background with ~4ms event-loop yields, so it doesn't
// delay first render — it just races the user's first @ keystroke.
//
// If the user types before the build finishes, they get partial results
// from the ready chunks; when the build completes, re-fire the last
// search so partial upgrades to full. Clears the token ref so the same
// query isn't discarded as stale.
//
// Skipped under NODE_ENV=test: REPL-mounting tests would spawn git ls-files
// against the real CI workspace (270k+ files on Windows runners), and the
// background build outlives the test — its setImmediate chain leaks into
// subsequent tests in the shard. The subscriber still registers so
// fileSuggestions tests that trigger a refresh directly work correctly.
// When a lazy file-index build completes, re-run the latest search so
// partial results upgrade to the full corpus.
useEffect(() => {
if (process.env.NODE_ENV !== 'test') {
startBackgroundCacheRefresh();
}
return onIndexBuildComplete(() => {
const token = latestSearchTokenRef.current;
if (token !== null) {
+35 -2
View File
@@ -10,12 +10,34 @@ import {
type FetchType = typeof globalThis.fetch
const originalFetch = globalThis.fetch
const PROXY_ENV_KEYS = [
'HTTP_PROXY',
'HTTPS_PROXY',
'ALL_PROXY',
'http_proxy',
'https_proxy',
'all_proxy',
] as const
const originalEnv = {
HTTP_PROXY: process.env.HTTP_PROXY,
HTTPS_PROXY: process.env.HTTPS_PROXY,
ALL_PROXY: process.env.ALL_PROXY,
http_proxy: process.env.http_proxy,
https_proxy: process.env.https_proxy,
all_proxy: process.env.all_proxy,
}
function restoreEnv(key: 'HTTP_PROXY' | 'HTTPS_PROXY', value: string | undefined): void {
function restoreEnv(
key:
| 'HTTP_PROXY'
| 'HTTPS_PROXY'
| 'ALL_PROXY'
| 'http_proxy'
| 'https_proxy'
| 'all_proxy',
value: string | undefined,
): void {
if (value === undefined) {
delete process.env[key]
} else {
@@ -23,18 +45,29 @@ function restoreEnv(key: 'HTTP_PROXY' | 'HTTPS_PROXY', value: string | undefined
}
}
function clearProxyEnv(): void {
for (const key of PROXY_ENV_KEYS) {
delete process.env[key]
}
}
beforeEach(async () => {
await acquireSharedMutationLock('fetchWithProxyRetry.test.ts')
clearProxyEnv()
process.env.HTTP_PROXY = 'http://127.0.0.1:15236'
delete process.env.HTTPS_PROXY
_resetKeepAliveForTesting()
})
afterEach(() => {
try {
globalThis.fetch = originalFetch
clearProxyEnv()
restoreEnv('HTTP_PROXY', originalEnv.HTTP_PROXY)
restoreEnv('HTTPS_PROXY', originalEnv.HTTPS_PROXY)
restoreEnv('ALL_PROXY', originalEnv.ALL_PROXY)
restoreEnv('http_proxy', originalEnv.http_proxy)
restoreEnv('https_proxy', originalEnv.https_proxy)
restoreEnv('all_proxy', originalEnv.all_proxy)
_resetKeepAliveForTesting()
} finally {
releaseSharedMutationLock()