mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-08-24 10:14:19 -05:00
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:
@@ -160,4 +160,3 @@ For advanced provider setup, use the built-in provider manager:
|
||||
~~~powershell
|
||||
oc-provider
|
||||
~~~
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user