feat(core): Download ripgrep at runtime, if enabled. (#7818)

This commit is contained in:
joshualitt
2025-09-08 14:44:56 -07:00
committed by GitHub
parent 097b5c734f
commit f0bbfe5f0a
17 changed files with 408 additions and 123 deletions
+108 -1
View File
@@ -25,6 +25,11 @@ import { ClearcutLogger } from '../telemetry/clearcut-logger/clearcut-logger.js'
import { ShellTool } from '../tools/shell.js';
import { ReadFileTool } from '../tools/read-file.js';
import { GrepTool } from '../tools/grep.js';
import { RipGrepTool, canUseRipgrep } from '../tools/ripGrep.js';
import { logRipgrepFallback } from '../telemetry/loggers.js';
import { RipgrepFallbackEvent } from '../telemetry/types.js';
import { ToolRegistry } from '../tools/tool-registry.js';
vi.mock('fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('fs')>();
@@ -56,7 +61,11 @@ vi.mock('../utils/memoryDiscovery.js', () => ({
// Mock individual tools if their constructors are complex or have side effects
vi.mock('../tools/ls');
vi.mock('../tools/read-file');
vi.mock('../tools/grep');
vi.mock('../tools/grep.js');
vi.mock('../tools/ripGrep.js', () => ({
canUseRipgrep: vi.fn(),
RipGrepTool: class MockRipGrepTool {},
}));
vi.mock('../tools/glob');
vi.mock('../tools/edit');
vi.mock('../tools/shell');
@@ -88,6 +97,15 @@ vi.mock('../telemetry/index.js', async (importOriginal) => {
};
});
vi.mock('../telemetry/loggers.js', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../telemetry/loggers.js')>();
return {
...actual,
logRipgrepFallback: vi.fn(),
};
});
vi.mock('../services/gitService.js', () => {
const GitServiceMock = vi.fn();
GitServiceMock.prototype.initialize = vi.fn();
@@ -666,4 +684,93 @@ describe('setApprovalMode with folder trust', () => {
expect(() => config.setApprovalMode(ApprovalMode.AUTO_EDIT)).not.toThrow();
expect(() => config.setApprovalMode(ApprovalMode.DEFAULT)).not.toThrow();
});
describe('registerCoreTools', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should register RipGrepTool when useRipgrep is true and it is available', async () => {
(canUseRipgrep as Mock).mockResolvedValue(true);
const config = new Config({ ...baseParams, useRipgrep: true });
await config.initialize();
const calls = (ToolRegistry.prototype.registerTool as Mock).mock.calls;
const wasRipGrepRegistered = calls.some(
(call) => call[0] instanceof vi.mocked(RipGrepTool),
);
const wasGrepRegistered = calls.some(
(call) => call[0] instanceof vi.mocked(GrepTool),
);
expect(wasRipGrepRegistered).toBe(true);
expect(wasGrepRegistered).toBe(false);
expect(logRipgrepFallback).not.toHaveBeenCalled();
});
it('should register GrepTool as a fallback when useRipgrep is true but it is not available', async () => {
(canUseRipgrep as Mock).mockResolvedValue(false);
const config = new Config({ ...baseParams, useRipgrep: true });
await config.initialize();
const calls = (ToolRegistry.prototype.registerTool as Mock).mock.calls;
const wasRipGrepRegistered = calls.some(
(call) => call[0] instanceof vi.mocked(RipGrepTool),
);
const wasGrepRegistered = calls.some(
(call) => call[0] instanceof vi.mocked(GrepTool),
);
expect(wasRipGrepRegistered).toBe(false);
expect(wasGrepRegistered).toBe(true);
expect(logRipgrepFallback).toHaveBeenCalledWith(
config,
expect.any(RipgrepFallbackEvent),
);
const event = (logRipgrepFallback as Mock).mock.calls[0][1];
expect(event.error).toBeUndefined();
});
it('should register GrepTool as a fallback when canUseRipgrep throws an error', async () => {
const error = new Error('ripGrep check failed');
(canUseRipgrep as Mock).mockRejectedValue(error);
const config = new Config({ ...baseParams, useRipgrep: true });
await config.initialize();
const calls = (ToolRegistry.prototype.registerTool as Mock).mock.calls;
const wasRipGrepRegistered = calls.some(
(call) => call[0] instanceof vi.mocked(RipGrepTool),
);
const wasGrepRegistered = calls.some(
(call) => call[0] instanceof vi.mocked(GrepTool),
);
expect(wasRipGrepRegistered).toBe(false);
expect(wasGrepRegistered).toBe(true);
expect(logRipgrepFallback).toHaveBeenCalledWith(
config,
expect.any(RipgrepFallbackEvent),
);
const event = (logRipgrepFallback as Mock).mock.calls[0][1];
expect(event.error).toBe(String(error));
});
it('should register GrepTool when useRipgrep is false', async () => {
const config = new Config({ ...baseParams, useRipgrep: false });
await config.initialize();
const calls = (ToolRegistry.prototype.registerTool as Mock).mock.calls;
const wasRipGrepRegistered = calls.some(
(call) => call[0] instanceof vi.mocked(RipGrepTool),
);
const wasGrepRegistered = calls.some(
(call) => call[0] instanceof vi.mocked(GrepTool),
);
expect(wasRipGrepRegistered).toBe(false);
expect(wasGrepRegistered).toBe(true);
expect(canUseRipgrep).not.toHaveBeenCalled();
expect(logRipgrepFallback).not.toHaveBeenCalled();
});
});
});
+24 -4
View File
@@ -20,7 +20,7 @@ import { ToolRegistry } from '../tools/tool-registry.js';
import { LSTool } from '../tools/ls.js';
import { ReadFileTool } from '../tools/read-file.js';
import { GrepTool } from '../tools/grep.js';
import { RipGrepTool } from '../tools/ripGrep.js';
import { canUseRipgrep, RipGrepTool } from '../tools/ripGrep.js';
import { GlobTool } from '../tools/glob.js';
import { EditTool } from '../tools/edit.js';
import { SmartEditTool } from '../tools/smart-edit.js';
@@ -50,8 +50,16 @@ import { IdeClient } from '../ide/ide-client.js';
import { ideContext } from '../ide/ideContext.js';
import type { FileSystemService } from '../services/fileSystemService.js';
import { StandardFileSystemService } from '../services/fileSystemService.js';
import { logCliConfiguration, logIdeConnection } from '../telemetry/loggers.js';
import { IdeConnectionEvent, IdeConnectionType } from '../telemetry/types.js';
import {
logCliConfiguration,
logIdeConnection,
logRipgrepFallback,
} from '../telemetry/loggers.js';
import {
IdeConnectionEvent,
IdeConnectionType,
RipgrepFallbackEvent,
} from '../telemetry/types.js';
import type { FallbackModelHandler } from '../fallback/types.js';
// Re-export OAuth config type
@@ -892,7 +900,19 @@ export class Config {
registerCoreTool(ReadFileTool, this);
if (this.getUseRipgrep()) {
registerCoreTool(RipGrepTool, this);
let useRipgrep = false;
let errorString: undefined | string = undefined;
try {
useRipgrep = await canUseRipgrep();
} catch (error: unknown) {
errorString = String(error);
}
if (useRipgrep) {
registerCoreTool(RipGrepTool, this);
} else {
logRipgrepFallback(this, new RipgrepFallbackEvent(errorString));
registerCoreTool(GrepTool, this);
}
} else {
registerCoreTool(GrepTool, this);
}
+5
View File
@@ -52,4 +52,9 @@ describe('Storage additional helpers', () => {
);
expect(Storage.getMcpOAuthTokensPath()).toBe(expected);
});
it('getGlobalBinDir returns ~/.gemini/tmp/bin', () => {
const expected = path.join(os.homedir(), '.gemini', 'tmp', 'bin');
expect(Storage.getGlobalBinDir()).toBe(expected);
});
});
+5
View File
@@ -13,6 +13,7 @@ export const GEMINI_DIR = '.gemini';
export const GOOGLE_ACCOUNTS_FILENAME = 'google_accounts.json';
export const OAUTH_FILE = 'oauth_creds.json';
const TMP_DIR_NAME = 'tmp';
const BIN_DIR_NAME = 'bin';
export class Storage {
private readonly targetDir: string;
@@ -57,6 +58,10 @@ export class Storage {
return path.join(Storage.getGlobalGeminiDir(), TMP_DIR_NAME);
}
static getGlobalBinDir(): string {
return path.join(Storage.getGlobalTempDir(), BIN_DIR_NAME);
}
getGeminiDir(): string {
return path.join(this.targetDir, GEMINI_DIR);
}
@@ -392,6 +392,24 @@ describe('ClearcutLogger', () => {
});
});
describe('logRipgrepFallbackEvent', () => {
it('logs an event with the proper name', () => {
const { logger } = setup();
// Spy on flushToClearcut to prevent it from clearing the queue
const flushSpy = vi
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.spyOn(logger!, 'flushToClearcut' as any)
.mockResolvedValue({ nextRequestWaitMs: 0 });
logger?.logRipgrepFallbackEvent();
const events = getEvents(logger!);
expect(events.length).toBe(1);
expect(events[0]).toHaveEventName(EventNames.RIPGREP_FALLBACK);
expect(flushSpy).toHaveBeenCalledOnce();
});
});
describe('enqueueLogEvent', () => {
it('should add events to the queue', () => {
const { logger } = setup();
@@ -44,6 +44,7 @@ export enum EventNames {
API_ERROR = 'api_error',
END_SESSION = 'end_session',
FLASH_FALLBACK = 'flash_fallback',
RIPGREP_FALLBACK = 'ripgrep_fallback',
LOOP_DETECTED = 'loop_detected',
NEXT_SPEAKER_CHECK = 'next_speaker_check',
SLASH_COMMAND = 'slash_command',
@@ -631,6 +632,13 @@ export class ClearcutLogger {
});
}
logRipgrepFallbackEvent(): void {
this.enqueueLogEvent(this.createLogEvent(EventNames.RIPGREP_FALLBACK, []));
this.flushToClearcut().catch((error) => {
console.debug('Error flushing to Clearcut:', error);
});
}
logLoopDetectedEvent(event: LoopDetectedEvent): void {
const data: EventValue[] = [
{
+1
View File
@@ -13,6 +13,7 @@ export const EVENT_API_ERROR = 'gemini_cli.api_error';
export const EVENT_API_RESPONSE = 'gemini_cli.api_response';
export const EVENT_CLI_CONFIG = 'gemini_cli.config';
export const EVENT_FLASH_FALLBACK = 'gemini_cli.flash_fallback';
export const EVENT_RIPGREP_FALLBACK = 'gemini_cli.ripgrep_fallback';
export const EVENT_NEXT_SPEAKER_CHECK = 'gemini_cli.next_speaker_check';
export const EVENT_SLASH_COMMAND = 'gemini_cli.slash_command';
export const EVENT_IDE_CONNECTION = 'gemini_cli.ide_connection';
@@ -30,6 +30,7 @@ import {
EVENT_FLASH_FALLBACK,
EVENT_MALFORMED_JSON_RESPONSE,
EVENT_FILE_OPERATION,
EVENT_RIPGREP_FALLBACK,
} from './constants.js';
import {
logApiRequest,
@@ -41,6 +42,7 @@ import {
logChatCompression,
logMalformedJsonResponse,
logFileOperation,
logRipgrepFallback,
} from './loggers.js';
import { ToolCallDecision } from './tool-call-decision.js';
import {
@@ -50,6 +52,7 @@ import {
ToolCallEvent,
UserPromptEvent,
FlashFallbackEvent,
RipgrepFallbackEvent,
MalformedJsonResponseEvent,
makeChatCompressionEvent,
FileOperationEvent,
@@ -453,6 +456,59 @@ describe('loggers', () => {
});
});
describe('logRipgrepFallback', () => {
const mockConfig = {
getSessionId: () => 'test-session-id',
getUsageStatisticsEnabled: () => true,
} as unknown as Config;
beforeEach(() => {
vi.spyOn(ClearcutLogger.prototype, 'logRipgrepFallbackEvent');
});
it('should log ripgrep fallback event', () => {
const event = new RipgrepFallbackEvent();
logRipgrepFallback(mockConfig, event);
expect(
ClearcutLogger.prototype.logRipgrepFallbackEvent,
).toHaveBeenCalled();
const emittedEvent = mockLogger.emit.mock.calls[0][0];
expect(emittedEvent.body).toBe('Switching to grep as fallback.');
expect(emittedEvent.attributes).toEqual(
expect.objectContaining({
'session.id': 'test-session-id',
'user.email': 'test-user@example.com',
'event.name': EVENT_RIPGREP_FALLBACK,
error: undefined,
}),
);
});
it('should log ripgrep fallback event with an error', () => {
const event = new RipgrepFallbackEvent('rg not found');
logRipgrepFallback(mockConfig, event);
expect(
ClearcutLogger.prototype.logRipgrepFallbackEvent,
).toHaveBeenCalled();
const emittedEvent = mockLogger.emit.mock.calls[0][0];
expect(emittedEvent.body).toBe('Switching to grep as fallback.');
expect(emittedEvent.attributes).toEqual(
expect.objectContaining({
'session.id': 'test-session-id',
'user.email': 'test-user@example.com',
'event.name': EVENT_RIPGREP_FALLBACK,
error: 'rg not found',
}),
);
});
});
describe('logToolCall', () => {
const cfg1 = {
getSessionId: () => 'test-session-id',
+24
View File
@@ -27,6 +27,7 @@ import {
EVENT_CONTENT_RETRY,
EVENT_CONTENT_RETRY_FAILURE,
EVENT_FILE_OPERATION,
EVENT_RIPGREP_FALLBACK,
} from './constants.js';
import type {
ApiErrorEvent,
@@ -48,6 +49,7 @@ import type {
InvalidChunkEvent,
ContentRetryEvent,
ContentRetryFailureEvent,
RipgrepFallbackEvent,
} from './types.js';
import {
recordApiErrorMetrics,
@@ -268,6 +270,28 @@ export function logFlashFallback(
logger.emit(logRecord);
}
export function logRipgrepFallback(
config: Config,
event: RipgrepFallbackEvent,
): void {
ClearcutLogger.getInstance(config)?.logRipgrepFallbackEvent();
if (!isTelemetrySdkInitialized()) return;
const attributes: LogAttributes = {
...getCommonAttributes(config),
...event,
'event.name': EVENT_RIPGREP_FALLBACK,
'event.timestamp': new Date().toISOString(),
};
const logger = logs.getLogger(SERVICE_NAME);
const logRecord: LogRecord = {
body: `Switching to grep as fallback.`,
attributes,
};
logger.emit(logRecord);
}
export function logApiError(config: Config, event: ApiErrorEvent): void {
const uiEvent = {
...event,
+10
View File
@@ -281,6 +281,16 @@ export class FlashFallbackEvent implements BaseTelemetryEvent {
}
}
export class RipgrepFallbackEvent implements BaseTelemetryEvent {
'event.name': 'ripgrep_fallback';
'event.timestamp': string;
constructor(public error?: string) {
this['event.name'] = 'ripgrep_fallback';
this['event.timestamp'] = new Date().toISOString();
}
}
export enum LoopType {
CONSECUTIVE_IDENTICAL_TOOL_CALLS = 'consecutive_identical_tool_calls',
CHANTING_IDENTICAL_SENTENCES = 'chanting_identical_sentences',
+75 -5
View File
@@ -4,9 +4,17 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import {
describe,
it,
expect,
beforeEach,
afterEach,
vi,
type Mock,
} from 'vitest';
import type { RipGrepToolParams } from './ripGrep.js';
import { RipGrepTool } from './ripGrep.js';
import { canUseRipgrep, RipGrepTool } from './ripGrep.js';
import path from 'node:path';
import fs from 'node:fs/promises';
import os, { EOL } from 'node:os';
@@ -14,10 +22,24 @@ import type { Config } from '../config/config.js';
import { createMockWorkspaceContext } from '../test-utils/mockWorkspaceContext.js';
import type { ChildProcess } from 'node:child_process';
import { spawn } from 'node:child_process';
import { downloadRipGrep } from '@joshua.litt/get-ripgrep';
import { fileExists } from '../utils/fileUtils.js';
// Mock @lvce-editor/ripgrep for testing
vi.mock('@lvce-editor/ripgrep', () => ({
rgPath: '/mock/rg/path',
// Mock dependencies for canUseRipgrep
vi.mock('@joshua.litt/get-ripgrep', () => ({
downloadRipGrep: vi.fn(),
}));
vi.mock('../utils/fileUtils.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../utils/fileUtils.js')>();
return {
...actual,
fileExists: vi.fn(),
};
});
vi.mock('../config/storage.js', () => ({
Storage: {
getGlobalBinDir: vi.fn().mockReturnValue('/mock/bin/dir'),
},
}));
// Mock child_process for ripgrep calls
@@ -27,6 +49,54 @@ vi.mock('child_process', () => ({
const mockSpawn = vi.mocked(spawn);
describe('canUseRipgrep', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should return true if ripgrep already exists', async () => {
(fileExists as Mock).mockResolvedValue(true);
const result = await canUseRipgrep();
expect(result).toBe(true);
expect(fileExists).toHaveBeenCalledWith(path.join('/mock/bin/dir', 'rg'));
expect(downloadRipGrep).not.toHaveBeenCalled();
});
it('should download ripgrep and return true if it does not exist initially', async () => {
(fileExists as Mock)
.mockResolvedValueOnce(false)
.mockResolvedValueOnce(true);
(downloadRipGrep as Mock).mockResolvedValue(undefined);
const result = await canUseRipgrep();
expect(result).toBe(true);
expect(fileExists).toHaveBeenCalledTimes(2);
expect(downloadRipGrep).toHaveBeenCalledWith('/mock/bin/dir');
});
it('should return false if download fails and file does not exist', async () => {
(fileExists as Mock).mockResolvedValue(false);
(downloadRipGrep as Mock).mockResolvedValue(undefined);
const result = await canUseRipgrep();
expect(result).toBe(false);
expect(fileExists).toHaveBeenCalledTimes(2);
expect(downloadRipGrep).toHaveBeenCalledWith('/mock/bin/dir');
});
it('should propagate errors from downloadRipGrep', async () => {
const error = new Error('Download failed');
(fileExists as Mock).mockResolvedValue(false);
(downloadRipGrep as Mock).mockRejectedValue(error);
await expect(canUseRipgrep()).rejects.toThrow(error);
expect(fileExists).toHaveBeenCalledTimes(1);
expect(downloadRipGrep).toHaveBeenCalledWith('/mock/bin/dir');
});
});
// Helper function to create mock spawn implementations
function createMockSpawn(
options: {
+20 -2
View File
@@ -8,16 +8,34 @@ import fs from 'node:fs';
import path from 'node:path';
import { EOL } from 'node:os';
import { spawn } from 'node:child_process';
import { rgPath } from '@lvce-editor/ripgrep';
import { downloadRipGrep } from '@joshua.litt/get-ripgrep';
import type { ToolInvocation, ToolResult } from './tools.js';
import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js';
import { SchemaValidator } from '../utils/schemaValidator.js';
import { makeRelative, shortenPath } from '../utils/paths.js';
import { getErrorMessage, isNodeError } from '../utils/errors.js';
import type { Config } from '../config/config.js';
import { fileExists } from '../utils/fileUtils.js';
import { Storage } from '../config/storage.js';
const DEFAULT_TOTAL_MAX_MATCHES = 20000;
function getRgPath() {
return path.join(Storage.getGlobalBinDir(), 'rg');
}
/**
* Checks if `rg` exists, if not then attempt to download it.
*/
export async function canUseRipgrep(): Promise<boolean> {
if (await fileExists(getRgPath())) {
return true;
}
await downloadRipGrep(Storage.getGlobalBinDir());
return await fileExists(getRgPath());
}
/**
* Parameters for the GrepTool
*/
@@ -293,7 +311,7 @@ class GrepToolInvocation extends BaseToolInvocation<
try {
const output = await new Promise<string>((resolve, reject) => {
const child = spawn(rgPath, rgArgs, {
const child = spawn(getRgPath(), rgArgs, {
windowsHide: true,
});
+20
View File
@@ -28,6 +28,7 @@ import {
processSingleFileContent,
detectBOM,
readFileWithEncoding,
fileExists,
} from './fileUtils.js';
import { StandardFileSystemService } from '../services/fileSystemService.js';
@@ -133,6 +134,25 @@ describe('fileUtils', () => {
});
});
describe('fileExists', () => {
it('should return true if the file exists', async () => {
const testFile = path.join(tempRootDir, 'exists.txt');
actualNodeFs.writeFileSync(testFile, 'content');
await expect(fileExists(testFile)).resolves.toBe(true);
});
it('should return false if the file does not exist', async () => {
const testFile = path.join(tempRootDir, 'does-not-exist.txt');
await expect(fileExists(testFile)).resolves.toBe(false);
});
it('should return true for a directory that exists', async () => {
const testDir = path.join(tempRootDir, 'exists-dir');
actualNodeFs.mkdirSync(testDir);
await expect(fileExists(testDir)).resolves.toBe(true);
});
});
describe('isBinaryFile', () => {
let filePathForBinaryTest: string;
+10
View File
@@ -5,6 +5,7 @@
*/
import fs from 'node:fs';
import fsPromises from 'node:fs/promises';
import path from 'node:path';
import type { PartUnion } from '@google/genai';
// eslint-disable-next-line import/no-internal-modules
@@ -467,3 +468,12 @@ export async function processSingleFileContent(
};
}
}
export async function fileExists(filePath: string): Promise<boolean> {
try {
await fsPromises.access(filePath, fs.constants.F_OK);
return true;
} catch (_: unknown) {
return false;
}
}