fix: add git-based fallback for file search on Linux (#257)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Matheus Nogueira
2026-04-01 21:05:18 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 11c65ab3a7
commit 3c36da80a8
6 changed files with 656 additions and 2 deletions
+119 -2
View File
@@ -1,8 +1,9 @@
import { describe, expect, it, vi, beforeEach } from 'vitest'
const { spawnMock, resolveAuthorizedPathMock } = vi.hoisted(() => ({
const { spawnMock, resolveAuthorizedPathMock, checkRgAvailableMock } = vi.hoisted(() => ({
spawnMock: vi.fn(),
resolveAuthorizedPathMock: vi.fn()
resolveAuthorizedPathMock: vi.fn(),
checkRgAvailableMock: vi.fn()
}))
vi.mock('child_process', () => ({
@@ -13,6 +14,10 @@ vi.mock('./filesystem-auth', () => ({
resolveAuthorizedPath: resolveAuthorizedPathMock
}))
vi.mock('./rg-availability', () => ({
checkRgAvailable: checkRgAvailableMock
}))
import { listQuickOpenFiles } from './filesystem-list-files'
import { EventEmitter } from 'events'
import type { Store } from '../persistence'
@@ -36,6 +41,7 @@ describe('filesystem-list-files', () => {
beforeEach(() => {
vi.clearAllMocks()
resolveAuthorizedPathMock.mockImplementation(async (path) => path)
checkRgAvailableMock.mockResolvedValue(true)
})
it('merges normal files and env files and filters correctly', async () => {
@@ -104,4 +110,115 @@ describe('filesystem-list-files', () => {
expect(result).toEqual(['valid.ts'])
})
describe('git ls-files fallback', () => {
it('falls back to git ls-files when rg is not available', async () => {
checkRgAvailableMock.mockResolvedValue(false)
let callIndex = 0
const gitP1 = createMockProcess()
const gitP2 = createMockProcess()
spawnMock.mockImplementation((cmd: string) => {
if (cmd === 'git') {
callIndex++
return callIndex === 1 ? gitP1 : gitP2
}
return createMockProcess()
})
const storeMock = {} as unknown as Store
const promise = listQuickOpenFiles('/mock/root', storeMock)
setTimeout(() => {
;(gitP1.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\n')
;(gitP1.stdout as unknown as EventEmitter).emit('data', 'package.json\n')
;(gitP1.stdout as unknown as EventEmitter).emit('data', 'node_modules/dep/index.js\n')
gitP1.emit('close')
;(gitP2.stdout as unknown as EventEmitter).emit('data', '.env.local\n')
gitP2.emit('close')
}, 10)
const result = await promise
// Verify rg was never called
const rgCalls = spawnMock.mock.calls.filter((call) => call[0] === 'rg')
expect(rgCalls.length).toBe(0)
// Verify git ls-files was called
const gitCalls = spawnMock.mock.calls.filter((call) => call[0] === 'git')
expect(gitCalls.length).toBe(2)
expect(gitCalls[0][1]).toContain('ls-files')
// Should include valid files and filter node_modules
expect(result).toContain('src/index.ts')
expect(result).toContain('package.json')
expect(result).toContain('.env.local')
expect(result).not.toContain('node_modules/dep/index.js')
})
it('git fallback applies hidden dir blocklist', async () => {
checkRgAvailableMock.mockResolvedValue(false)
const gitP1 = createMockProcess()
const gitP2 = createMockProcess()
let callIndex = 0
spawnMock.mockImplementation((cmd: string) => {
if (cmd === 'git') {
callIndex++
return callIndex === 1 ? gitP1 : gitP2
}
return createMockProcess()
})
const storeMock = {} as unknown as Store
const promise = listQuickOpenFiles('/mock/root', storeMock)
setTimeout(() => {
;(gitP1.stdout as unknown as EventEmitter).emit('data', '.next/cache/1.js\n')
;(gitP1.stdout as unknown as EventEmitter).emit('data', '.vscode/settings.json\n')
;(gitP1.stdout as unknown as EventEmitter).emit('data', '.github/workflows/ci.yml\n')
;(gitP1.stdout as unknown as EventEmitter).emit('data', 'valid.ts\n')
gitP1.emit('close')
gitP2.emit('close')
}, 10)
const result = await promise
expect(result).toEqual(['.github/workflows/ci.yml', 'valid.ts'])
})
it('does not fall back to git when rg is available', async () => {
checkRgAvailableMock.mockResolvedValue(true)
const p1 = createMockProcess()
const p2 = createMockProcess()
spawnMock.mockImplementation((_cmd, args: string[]) => {
if (args.includes('**/.env*')) {
return p2
}
return p1
})
const storeMock = {} as unknown as Store
const promise = listQuickOpenFiles('/mock/root', storeMock)
setTimeout(() => {
;(p1.stdout as unknown as EventEmitter).emit('data', '/mock/root/file.ts\n')
p1.emit('close')
p2.emit('close')
}, 10)
const result = await promise
expect(result).toEqual(['file.ts'])
// git should never have been called
const gitCalls = spawnMock.mock.calls.filter((call) => call[0] === 'git')
expect(gitCalls.length).toBe(0)
})
})
})
+92
View File
@@ -2,6 +2,7 @@ import { spawn } from 'child_process'
import { relative, sep } from 'path'
import type { Store } from '../persistence'
import { resolveAuthorizedPath } from './filesystem-auth'
import { checkRgAvailable } from './rg-availability'
// Why: We use --hidden to surface dotfiles users commonly edit (e.g. .env,
// .github workflows, .eslintrc) but must still exclude non-editable hidden
@@ -46,6 +47,15 @@ function shouldIncludeQuickOpenPath(path: string): boolean {
export async function listQuickOpenFiles(rootPath: string, store: Store): Promise<string[]> {
const authorizedRootPath = await resolveAuthorizedPath(rootPath, store)
// Why: checking rg availability upfront avoids a race condition where
// spawn('rg') emits 'close' before 'error' on some platforms, causing
// the handler to resolve with empty results before the git fallback
// can run. The result is cached after the first check.
const rgAvailable = await checkRgAvailable()
if (!rgAvailable) {
return listFilesWithGit(authorizedRootPath)
}
// Why: We try fast string slicing first (O(1) per file), but fall back to
// path.relative() if the rg output doesn't start with the expected prefix.
// This handles edge cases where symlinks, bind mounts, Windows junctions,
@@ -168,3 +178,85 @@ export async function listQuickOpenFiles(rootPath: string, store: Store): Promis
return Array.from(files)
}
/**
* 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 specifically
* surfaces .env* files that are typically gitignored but users frequently need in
* quick-open (mirrors the second rg call with --no-ignore-vcs).
*/
function listFilesWithGit(rootPath: string): Promise<string[]> {
const files = new Set<string>()
const runGitLsFiles = (args: string[]): Promise<void> => {
return new Promise((resolve) => {
let buf = ''
let done = false
const finish = (): void => {
if (done) {
return
}
done = true
clearTimeout(timer)
resolve()
}
const processLine = (line: string): void => {
if (line.charCodeAt(line.length - 1) === 13 /* \r */) {
line = line.substring(0, line.length - 1)
}
if (!line) {
return
}
if (shouldIncludeQuickOpenPath(line)) {
files.add(line)
}
}
// 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 = spawn('git', ['ls-files', ...args], {
cwd: rootPath,
stdio: ['ignore', 'pipe', 'pipe']
})
child.stdout.setEncoding('utf-8')
child.stdout.on('data', (chunk: string) => {
buf += chunk
let start = 0
let newlineIdx = buf.indexOf('\n', start)
while (newlineIdx !== -1) {
processLine(buf.substring(start, newlineIdx))
start = newlineIdx + 1
newlineIdx = buf.indexOf('\n', start)
}
buf = start < buf.length ? buf.substring(start) : ''
})
child.stderr.on('data', () => {
/* drain */
})
child.once('error', () => {
finish()
})
child.once('close', () => {
if (buf) {
processLine(buf)
}
finish()
})
const timer = setTimeout(() => child.kill(), 10000)
})
}
return Promise.all([
// Why: --cached lists tracked files, --others lists untracked files,
// --exclude-standard respects .gitignore. Together this mirrors
// rg --files --hidden (which respects gitignore by default).
runGitLsFiles(['--cached', '--others', '--exclude-standard']),
// Why: surfaces .env* files that are typically gitignored. --others
// without --exclude-standard lists all untracked files; the pathspec
// restricts output to .env* only. Mirrors the rg --no-ignore-vcs call.
runGitLsFiles(['--others', '--', '**/.env*'])
]).then(() => Array.from(files))
}
+185
View File
@@ -0,0 +1,185 @@
import { describe, expect, it, vi, beforeEach } from 'vitest'
const { spawnMock } = vi.hoisted(() => ({
spawnMock: vi.fn()
}))
vi.mock('child_process', () => ({
spawn: spawnMock
}))
import { searchWithGitGrep } from './filesystem-search-git'
import { EventEmitter } from 'events'
import type { ChildProcess } from 'child_process'
function createMockProcess(): ChildProcess {
const p = new EventEmitter() as unknown as ChildProcess
;(p as unknown as Record<string, unknown>).stdout = new EventEmitter()
;(
(p as unknown as Record<string, unknown>).stdout as EventEmitter & {
setEncoding: () => void
}
).setEncoding = vi.fn()
;(p as unknown as Record<string, unknown>).stderr = new EventEmitter()
;(p as unknown as Record<string, unknown>).kill = vi.fn()
return p
}
describe('filesystem-search-git', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('parses git grep output and finds matches', async () => {
const proc = createMockProcess()
spawnMock.mockReturnValue(proc)
const promise = searchWithGitGrep('/mock/root', { query: 'hello', rootPath: '/mock/root' }, 100)
setTimeout(() => {
;(proc.stdout as unknown as EventEmitter).emit(
'data',
'src/index.ts\x005: console.log("hello world")\n'
)
;(proc.stdout as unknown as EventEmitter).emit('data', 'src/main.ts\x0012: return "hello"\n')
proc.emit('close')
}, 10)
const result = await promise
expect(result.files).toHaveLength(2)
expect(result.totalMatches).toBe(2)
expect(result.truncated).toBe(false)
expect(result.files[0].relativePath).toBe('src/index.ts')
expect(result.files[0].matches[0]).toEqual({
line: 5,
column: 16,
matchLength: 5,
lineContent: ' console.log("hello world")'
})
expect(result.files[1].relativePath).toBe('src/main.ts')
expect(result.files[1].matches[0].line).toBe(12)
})
it('finds multiple matches per line', async () => {
const proc = createMockProcess()
spawnMock.mockReturnValue(proc)
const promise = searchWithGitGrep('/mock/root', { query: 'ab', rootPath: '/mock/root' }, 100)
setTimeout(() => {
;(proc.stdout as unknown as EventEmitter).emit('data', 'file.txt\x001:ab cd ab ef ab\n')
proc.emit('close')
}, 10)
const result = await promise
expect(result.files).toHaveLength(1)
expect(result.totalMatches).toBe(3)
expect(result.files[0].matches).toEqual([
{ line: 1, column: 1, matchLength: 2, lineContent: 'ab cd ab ef ab' },
{ line: 1, column: 7, matchLength: 2, lineContent: 'ab cd ab ef ab' },
{ line: 1, column: 13, matchLength: 2, lineContent: 'ab cd ab ef ab' }
])
})
it('respects maxResults and sets truncated', async () => {
const proc = createMockProcess()
spawnMock.mockReturnValue(proc)
const promise = searchWithGitGrep('/mock/root', { query: 'x', rootPath: '/mock/root' }, 2)
setTimeout(() => {
;(proc.stdout as unknown as EventEmitter).emit(
'data',
'a.ts\x001:x\n' + 'b.ts\x001:x\n' + 'c.ts\x001:x\n'
)
proc.emit('close')
}, 10)
const result = await promise
expect(result.totalMatches).toBe(2)
expect(result.truncated).toBe(true)
})
it('passes correct flags for case-insensitive fixed-string search', async () => {
const proc = createMockProcess()
spawnMock.mockReturnValue(proc)
const promise = searchWithGitGrep(
'/mock/root',
{ query: 'test', rootPath: '/mock/root', caseSensitive: false, useRegex: false },
100
)
setTimeout(() => proc.emit('close'), 10)
await promise
const gitArgs = spawnMock.mock.calls[0][1] as string[]
expect(gitArgs).toContain('-i')
expect(gitArgs).toContain('--fixed-strings')
expect(gitArgs).not.toContain('--extended-regexp')
})
it('passes correct flags for regex whole-word search', async () => {
const proc = createMockProcess()
spawnMock.mockReturnValue(proc)
const promise = searchWithGitGrep(
'/mock/root',
{
query: 'foo',
rootPath: '/mock/root',
caseSensitive: true,
useRegex: true,
wholeWord: true
},
100
)
setTimeout(() => proc.emit('close'), 10)
await promise
const gitArgs = spawnMock.mock.calls[0][1] as string[]
expect(gitArgs).toContain('-w')
expect(gitArgs).toContain('--extended-regexp')
expect(gitArgs).not.toContain('-i')
expect(gitArgs).not.toContain('--fixed-strings')
})
it('returns empty result when git grep spawn fails', async () => {
const proc = createMockProcess()
spawnMock.mockReturnValue(proc)
const promise = searchWithGitGrep('/mock/root', { query: 'test', rootPath: '/mock/root' }, 100)
setTimeout(() => proc.emit('error', new Error('spawn git ENOENT')), 10)
const result = await promise
expect(result.files).toHaveLength(0)
expect(result.totalMatches).toBe(0)
})
it('skips lines without null separator', async () => {
const proc = createMockProcess()
spawnMock.mockReturnValue(proc)
const promise = searchWithGitGrep('/mock/root', { query: 'ok', rootPath: '/mock/root' }, 100)
setTimeout(() => {
// A line without \0 should be skipped (e.g. git header output)
;(proc.stdout as unknown as EventEmitter).emit('data', 'no-null-here:1:ok\n')
;(proc.stdout as unknown as EventEmitter).emit('data', 'valid.ts\x003:ok\n')
proc.emit('close')
}, 10)
const result = await promise
expect(result.files).toHaveLength(1)
expect(result.files[0].relativePath).toBe('valid.ts')
})
})
+220
View File
@@ -0,0 +1,220 @@
import { spawn } from 'child_process'
import { join } from 'path'
import type { SearchOptions, SearchResult, SearchFileResult } from '../../shared/types'
const SEARCH_TIMEOUT_MS = 15000
function normalizeRelativePath(path: string): string {
return path.replace(/[\\/]+/g, '/').replace(/^\/+/, '')
}
// Why: esbuild's parser chokes on regex literals containing brace/bracket
// character classes, so we escape special chars with a simple loop instead.
const REGEX_SPECIAL = '.*+?^${}()|[]\\'
function escapeRegexSource(str: string): string {
let out = ''
for (let i = 0; i < str.length; i++) {
out += REGEX_SPECIAL.includes(str[i]) ? `\\${str[i]}` : str[i]
}
return out
}
/**
* Convert a user-facing glob pattern into a git pathspec.
*
* Why: rg globs like `*.ts` match at any directory depth, but a bare git
* pathspec `*.ts` only matches in the repo root. Wrapping with `:(glob)` and
* prepending `** /` for patterns without a path separator replicates rg's
* recursive-by-default behaviour.
*/
function toGitGlobPathspec(glob: string, exclude?: boolean): string {
const needsRecursive = !glob.includes('/')
const pattern = needsRecursive ? `**/${glob}` : glob
return exclude ? `:(exclude,glob)${pattern}` : `:(glob)${pattern}`
}
/**
* Fallback text search using git grep. Used when rg is not available.
*
* Why: On Linux, rg may not be installed or may not be in PATH when the app
* is launched from a desktop entry (which inherits a minimal system PATH).
* git grep is always available since this is a git-focused app.
*/
export function searchWithGitGrep(
rootPath: string,
args: SearchOptions,
maxResults: number
): Promise<SearchResult> {
return new Promise((resolve) => {
// Why: --untracked searches untracked (but not ignored) files in addition
// to tracked ones, matching rg's default behaviour of respecting gitignore.
// Why: -I skips binary files (mirrors rg's default). --null uses \0 as
// the filename delimiter so filenames with colons parse unambiguously.
// --no-recurse-submodules is needed because users may have
// submodule.recurse=true in their git config, which conflicts with
// --untracked and would cause git grep to fail.
const gitArgs: string[] = [
'-c',
'submodule.recurse=false',
'grep',
'-n',
'-I',
'--null',
'--no-color',
'--untracked'
]
if (!args.caseSensitive) {
gitArgs.push('-i')
}
if (args.wholeWord) {
gitArgs.push('-w')
}
if (!args.useRegex) {
gitArgs.push('--fixed-strings')
} else {
gitArgs.push('--extended-regexp')
}
gitArgs.push('-e', args.query, '--')
let hasPathspecs = false
if (args.includePattern) {
for (const pat of args.includePattern
.split(',')
.map((s) => s.trim())
.filter(Boolean)) {
gitArgs.push(toGitGlobPathspec(pat))
hasPathspecs = true
}
}
if (args.excludePattern) {
for (const pat of args.excludePattern
.split(',')
.map((s) => s.trim())
.filter(Boolean)) {
gitArgs.push(toGitGlobPathspec(pat, true))
hasPathspecs = true
}
}
// Why: when no include patterns are given, git grep needs a pathspec to
// search the working tree. '.' means "everything under cwd".
if (!hasPathspecs) {
gitArgs.push('.')
}
const fileMap = new Map<string, SearchFileResult>()
let totalMatches = 0
let truncated = false
let stdoutBuffer = ''
let done = false
// Build a JS regex to locate all submatch positions within each matched
// line. git grep only reports the first match per line; we need byte
// offsets and lengths for every occurrence to populate SearchMatch[].
let pattern = args.useRegex ? args.query : escapeRegexSource(args.query)
if (args.wholeWord) {
pattern = `\\b${pattern}\\b`
}
const matchRegex = new RegExp(pattern, `g${args.caseSensitive ? '' : 'i'}`)
const resolveOnce = (): void => {
if (done) {
return
}
done = true
clearTimeout(killTimeout)
resolve({
files: Array.from(fileMap.values()),
totalMatches,
truncated
})
}
const processLine = (line: string): void => {
if (!line || totalMatches >= maxResults) {
return
}
// Why: with --null -n the output format is filename\0linenum:content.
// The null byte separates the filename unambiguously (colons in
// filenames would otherwise break parsing).
const nullIdx = line.indexOf('\0')
if (nullIdx === -1) {
return
}
const relPath = normalizeRelativePath(line.substring(0, nullIdx))
const rest = line.substring(nullIdx + 1)
const colonIdx = rest.indexOf(':')
if (colonIdx === -1) {
return
}
const lineNum = parseInt(rest.substring(0, colonIdx), 10)
if (isNaN(lineNum)) {
return
}
const lineContent = rest.substring(colonIdx + 1).replace(/\n$/, '')
const absPath = join(rootPath, relPath)
let fileResult = fileMap.get(absPath)
if (!fileResult) {
fileResult = { filePath: absPath, relativePath: relPath, matches: [] }
fileMap.set(absPath, fileResult)
}
// Find all match positions within the line
matchRegex.lastIndex = 0
let m: RegExpExecArray | null
while ((m = matchRegex.exec(lineContent)) !== null) {
fileResult.matches.push({
line: lineNum,
column: m.index + 1,
matchLength: m[0].length,
lineContent
})
totalMatches++
if (totalMatches >= maxResults) {
truncated = true
child.kill()
break
}
// Prevent infinite loop on zero-length regex matches
if (m[0].length === 0) {
matchRegex.lastIndex++
}
}
}
const child = spawn('git', gitArgs, {
cwd: rootPath,
stdio: ['ignore', 'pipe', 'pipe']
})
child.stdout.setEncoding('utf-8')
child.stdout.on('data', (chunk: string) => {
stdoutBuffer += chunk
const lines = stdoutBuffer.split('\n')
stdoutBuffer = lines.pop() ?? ''
for (const l of lines) {
processLine(l)
}
})
child.stderr.on('data', () => {
/* drain */
})
child.once('error', () => {
resolveOnce()
})
child.once('close', () => {
if (stdoutBuffer) {
processLine(stdoutBuffer)
}
resolveOnce()
})
const killTimeout = setTimeout(() => {
truncated = true
child.kill()
}, SEARCH_TIMEOUT_MS)
})
}
+11
View File
@@ -35,6 +35,8 @@ import {
} from './filesystem-auth'
import { listQuickOpenFiles } from './filesystem-list-files'
import { registerFilesystemMutationHandlers } from './filesystem-mutations'
import { searchWithGitGrep } from './filesystem-search-git'
import { checkRgAvailable } from './rg-availability'
const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB
const DEFAULT_SEARCH_MAX_RESULTS = 2000
@@ -180,6 +182,15 @@ export function registerFilesystemHandlers(store: Store): void {
)
const searchKey = `${event.sender.id}:${rootPath}`
// Why: checking rg availability upfront avoids a race condition where
// spawn('rg') emits 'close' before 'error' on some platforms, causing
// the handler to resolve with empty results before the git-grep
// fallback can run. The result is cached after the first check.
const rgAvailable = await checkRgAvailable()
if (!rgAvailable) {
return searchWithGitGrep(rootPath, args, maxResults)
}
return new Promise((resolvePromise) => {
const rgArgs: string[] = [
'--json',
+29
View File
@@ -0,0 +1,29 @@
import { spawn } from 'child_process'
// Why: when rg is not installed, spawn('rg', ...) emits both 'error' and
// 'close' events but their ordering is non-deterministic across Node versions
// and platforms. If 'close' fires first the handler resolves with empty
// results before the 'error' handler can trigger the git-grep fallback.
// Checking rg availability once upfront (cached) avoids the race entirely.
let rgAvailableCache: boolean | null = null
export function checkRgAvailable(): Promise<boolean> {
if (rgAvailableCache !== null) {
return Promise.resolve(rgAvailableCache)
}
return new Promise((resolve) => {
const child = spawn('rg', ['--version'], { stdio: 'ignore' })
child.once('error', () => {
rgAvailableCache = false
resolve(false)
})
child.once('close', (code) => {
if (rgAvailableCache !== null) {
// error handler already resolved
return
}
rgAvailableCache = code === 0
resolve(rgAvailableCache)
})
})
}