From 58fd00a3dfca304c4f8fc95201eafd60de5aa44d Mon Sep 17 00:00:00 2001 From: Serghei Date: Mon, 22 Dec 2025 06:25:26 +0200 Subject: [PATCH] fix(core): Add `.geminiignore` support to SearchText tool (#13763) Co-authored-by: Gaurav <39389231+gsquared94@users.noreply.github.com> --- integration-tests/ripgrep-real.test.ts | 4 + packages/core/src/tools/ripGrep.test.ts | 78 +++++++++++++++++++ packages/core/src/tools/ripGrep.ts | 13 ++++ .../core/src/utils/geminiIgnoreParser.test.ts | 64 +++++++++++++++ packages/core/src/utils/geminiIgnoreParser.ts | 24 ++++++ 5 files changed, 183 insertions(+) diff --git a/integration-tests/ripgrep-real.test.ts b/integration-tests/ripgrep-real.test.ts index bc106466b2..ac8a3eb169 100644 --- a/integration-tests/ripgrep-real.test.ts +++ b/integration-tests/ripgrep-real.test.ts @@ -27,6 +27,10 @@ class MockConfig { getDebugMode() { return true; } + + getFileFilteringRespectGeminiIgnore() { + return true; + } } describe('ripgrep-real-direct', () => { diff --git a/packages/core/src/tools/ripGrep.test.ts b/packages/core/src/tools/ripGrep.test.ts index 24b2de35bb..0f978313ed 100644 --- a/packages/core/src/tools/ripGrep.test.ts +++ b/packages/core/src/tools/ripGrep.test.ts @@ -252,6 +252,7 @@ describe('RipGrepTool', () => { getTargetDir: () => tempRootDir, getWorkspaceContext: () => createMockWorkspaceContext(tempRootDir), getDebugMode: () => false, + getFileFilteringRespectGeminiIgnore: () => true, } as unknown as Config; beforeEach(async () => { @@ -735,6 +736,7 @@ describe('RipGrepTool', () => { getWorkspaceContext: () => createMockWorkspaceContext(tempRootDir, [secondDir]), getDebugMode: () => false, + getFileFilteringRespectGeminiIgnore: () => true, } as unknown as Config; // Setup specific mock for this test - multi-directory search for 'world' @@ -876,6 +878,7 @@ describe('RipGrepTool', () => { getWorkspaceContext: () => createMockWorkspaceContext(tempRootDir, [secondDir]), getDebugMode: () => false, + getFileFilteringRespectGeminiIgnore: () => true, } as unknown as Config; // Setup specific mock for this test - searching in 'sub' should only return matches from that directory @@ -1644,6 +1647,80 @@ describe('RipGrepTool', () => { expect(result.llmContent).toContain('L1: secret log entry'); }); + it('should add .geminiignore when enabled and patterns exist', async () => { + const geminiIgnorePath = path.join(tempRootDir, '.geminiignore'); + await fs.writeFile(geminiIgnorePath, 'ignored.log'); + const configWithGeminiIgnore = { + getTargetDir: () => tempRootDir, + getWorkspaceContext: () => createMockWorkspaceContext(tempRootDir), + getDebugMode: () => false, + getFileFilteringRespectGeminiIgnore: () => true, + } as unknown as Config; + const geminiIgnoreTool = new RipGrepTool(configWithGeminiIgnore); + + mockSpawn.mockImplementationOnce( + createMockSpawn({ + outputData: + JSON.stringify({ + type: 'match', + data: { + path: { text: 'ignored.log' }, + line_number: 1, + lines: { text: 'secret log entry\n' }, + }, + }) + '\n', + exitCode: 0, + }), + ); + + const params: RipGrepToolParams = { pattern: 'secret' }; + const invocation = geminiIgnoreTool.build(params); + await invocation.execute(abortSignal); + + expect(mockSpawn).toHaveBeenLastCalledWith( + expect.anything(), + expect.arrayContaining(['--ignore-file', geminiIgnorePath]), + expect.anything(), + ); + }); + + it('should skip .geminiignore when disabled', async () => { + const geminiIgnorePath = path.join(tempRootDir, '.geminiignore'); + await fs.writeFile(geminiIgnorePath, 'ignored.log'); + const configWithoutGeminiIgnore = { + getTargetDir: () => tempRootDir, + getWorkspaceContext: () => createMockWorkspaceContext(tempRootDir), + getDebugMode: () => false, + getFileFilteringRespectGeminiIgnore: () => false, + } as unknown as Config; + const geminiIgnoreTool = new RipGrepTool(configWithoutGeminiIgnore); + + mockSpawn.mockImplementationOnce( + createMockSpawn({ + outputData: + JSON.stringify({ + type: 'match', + data: { + path: { text: 'ignored.log' }, + line_number: 1, + lines: { text: 'secret log entry\n' }, + }, + }) + '\n', + exitCode: 0, + }), + ); + + const params: RipGrepToolParams = { pattern: 'secret' }; + const invocation = geminiIgnoreTool.build(params); + await invocation.execute(abortSignal); + + expect(mockSpawn).toHaveBeenLastCalledWith( + expect.anything(), + expect.not.arrayContaining(['--ignore-file', geminiIgnorePath]), + expect.anything(), + ); + }); + it('should handle context parameters', async () => { mockSpawn.mockImplementationOnce( createMockSpawn({ @@ -1761,6 +1838,7 @@ describe('RipGrepTool', () => { }); }); }); + afterAll(() => { storageSpy.mockRestore(); }); diff --git a/packages/core/src/tools/ripGrep.ts b/packages/core/src/tools/ripGrep.ts index 3b232d0107..49a9398c16 100644 --- a/packages/core/src/tools/ripGrep.ts +++ b/packages/core/src/tools/ripGrep.ts @@ -23,6 +23,7 @@ import { FileExclusions, COMMON_DIRECTORY_EXCLUDES, } from '../utils/ignorePatterns.js'; +import { GeminiIgnoreParser } from '../utils/geminiIgnoreParser.js'; const DEFAULT_TOTAL_MAX_MATCHES = 20000; @@ -189,6 +190,7 @@ class GrepToolInvocation extends BaseToolInvocation< > { constructor( private readonly config: Config, + private readonly geminiIgnoreParser: GeminiIgnoreParser, params: RipGrepToolParams, messageBus?: MessageBus, _toolName?: string, @@ -387,6 +389,14 @@ class GrepToolInvocation extends BaseToolInvocation< excludes.forEach((exclude) => { rgArgs.push('--glob', `!${exclude}`); }); + + if (this.config.getFileFilteringRespectGeminiIgnore()) { + // Add .geminiignore support (ripgrep natively handles .gitignore) + const geminiIgnorePath = this.geminiIgnoreParser.getIgnoreFilePath(); + if (geminiIgnorePath) { + rgArgs.push('--ignore-file', geminiIgnorePath); + } + } } rgArgs.push('--threads', '4'); @@ -479,6 +489,7 @@ export class RipGrepTool extends BaseDeclarativeTool< ToolResult > { static readonly Name = GREP_TOOL_NAME; + private readonly geminiIgnoreParser: GeminiIgnoreParser; constructor( private readonly config: Config, @@ -544,6 +555,7 @@ export class RipGrepTool extends BaseDeclarativeTool< false, // canUpdateOutput messageBus, ); + this.geminiIgnoreParser = new GeminiIgnoreParser(config.getTargetDir()); } /** @@ -580,6 +592,7 @@ export class RipGrepTool extends BaseDeclarativeTool< ): ToolInvocation { return new GrepToolInvocation( this.config, + this.geminiIgnoreParser, params, messageBus, _toolName, diff --git a/packages/core/src/utils/geminiIgnoreParser.test.ts b/packages/core/src/utils/geminiIgnoreParser.test.ts index bf85cd8c69..d113626d68 100644 --- a/packages/core/src/utils/geminiIgnoreParser.test.ts +++ b/packages/core/src/utils/geminiIgnoreParser.test.ts @@ -58,6 +58,25 @@ describe('GeminiIgnoreParser', () => { false, ); }); + + it('should return ignore file path when patterns exist', () => { + const parser = new GeminiIgnoreParser(projectRoot); + expect(parser.getIgnoreFilePath()).toBe( + path.join(projectRoot, '.geminiignore'), + ); + }); + + it('should return true for hasPatterns when patterns exist', () => { + const parser = new GeminiIgnoreParser(projectRoot); + expect(parser.hasPatterns()).toBe(true); + }); + + it('should return false for hasPatterns when .geminiignore is deleted', async () => { + const parser = new GeminiIgnoreParser(projectRoot); + await fs.rm(path.join(projectRoot, '.geminiignore')); + expect(parser.hasPatterns()).toBe(false); + expect(parser.getIgnoreFilePath()).toBeNull(); + }); }); describe('when .geminiignore does not exist', () => { @@ -66,5 +85,50 @@ describe('GeminiIgnoreParser', () => { expect(parser.getPatterns()).toEqual([]); expect(parser.isIgnored('any_file.txt')).toBe(false); }); + + it('should return null for getIgnoreFilePath when no patterns exist', () => { + const parser = new GeminiIgnoreParser(projectRoot); + expect(parser.getIgnoreFilePath()).toBeNull(); + }); + + it('should return false for hasPatterns when no patterns exist', () => { + const parser = new GeminiIgnoreParser(projectRoot); + expect(parser.hasPatterns()).toBe(false); + }); + }); + + describe('when .geminiignore is empty', () => { + beforeEach(async () => { + await createTestFile('.geminiignore', ''); + }); + + it('should return null for getIgnoreFilePath', () => { + const parser = new GeminiIgnoreParser(projectRoot); + expect(parser.getIgnoreFilePath()).toBeNull(); + }); + + it('should return false for hasPatterns', () => { + const parser = new GeminiIgnoreParser(projectRoot); + expect(parser.hasPatterns()).toBe(false); + }); + }); + + describe('when .geminiignore only has comments', () => { + beforeEach(async () => { + await createTestFile( + '.geminiignore', + '# This is a comment\n# Another comment\n', + ); + }); + + it('should return null for getIgnoreFilePath', () => { + const parser = new GeminiIgnoreParser(projectRoot); + expect(parser.getIgnoreFilePath()).toBeNull(); + }); + + it('should return false for hasPatterns', () => { + const parser = new GeminiIgnoreParser(projectRoot); + expect(parser.hasPatterns()).toBe(false); + }); }); }); diff --git a/packages/core/src/utils/geminiIgnoreParser.ts b/packages/core/src/utils/geminiIgnoreParser.ts index 8518923de4..23217d9d70 100644 --- a/packages/core/src/utils/geminiIgnoreParser.ts +++ b/packages/core/src/utils/geminiIgnoreParser.ts @@ -11,6 +11,8 @@ import ignore from 'ignore'; export interface GeminiIgnoreFilter { isIgnored(filePath: string): boolean; getPatterns(): string[]; + getIgnoreFilePath(): string | null; + hasPatterns(): boolean; } export class GeminiIgnoreParser implements GeminiIgnoreFilter { @@ -78,4 +80,26 @@ export class GeminiIgnoreParser implements GeminiIgnoreFilter { getPatterns(): string[] { return this.patterns; } + + /** + * Returns the path to .geminiignore file if it exists and has patterns. + * Useful for tools like ripgrep that support --ignore-file flag. + */ + getIgnoreFilePath(): string | null { + if (!this.hasPatterns()) { + return null; + } + return path.join(this.projectRoot, '.geminiignore'); + } + + /** + * Returns true if .geminiignore exists and has patterns. + */ + hasPatterns(): boolean { + if (this.patterns.length === 0) { + return false; + } + const ignoreFilePath = path.join(this.projectRoot, '.geminiignore'); + return fs.existsSync(ignoreFilePath); + } }