fix(core): Add .geminiignore support to SearchText tool (#13763)

Co-authored-by: Gaurav <39389231+gsquared94@users.noreply.github.com>
This commit is contained in:
Serghei
2025-12-22 06:25:26 +02:00
committed by GitHub
parent b923604602
commit 58fd00a3df
5 changed files with 183 additions and 0 deletions

View File

@@ -27,6 +27,10 @@ class MockConfig {
getDebugMode() { getDebugMode() {
return true; return true;
} }
getFileFilteringRespectGeminiIgnore() {
return true;
}
} }
describe('ripgrep-real-direct', () => { describe('ripgrep-real-direct', () => {

View File

@@ -252,6 +252,7 @@ describe('RipGrepTool', () => {
getTargetDir: () => tempRootDir, getTargetDir: () => tempRootDir,
getWorkspaceContext: () => createMockWorkspaceContext(tempRootDir), getWorkspaceContext: () => createMockWorkspaceContext(tempRootDir),
getDebugMode: () => false, getDebugMode: () => false,
getFileFilteringRespectGeminiIgnore: () => true,
} as unknown as Config; } as unknown as Config;
beforeEach(async () => { beforeEach(async () => {
@@ -735,6 +736,7 @@ describe('RipGrepTool', () => {
getWorkspaceContext: () => getWorkspaceContext: () =>
createMockWorkspaceContext(tempRootDir, [secondDir]), createMockWorkspaceContext(tempRootDir, [secondDir]),
getDebugMode: () => false, getDebugMode: () => false,
getFileFilteringRespectGeminiIgnore: () => true,
} as unknown as Config; } as unknown as Config;
// Setup specific mock for this test - multi-directory search for 'world' // Setup specific mock for this test - multi-directory search for 'world'
@@ -876,6 +878,7 @@ describe('RipGrepTool', () => {
getWorkspaceContext: () => getWorkspaceContext: () =>
createMockWorkspaceContext(tempRootDir, [secondDir]), createMockWorkspaceContext(tempRootDir, [secondDir]),
getDebugMode: () => false, getDebugMode: () => false,
getFileFilteringRespectGeminiIgnore: () => true,
} as unknown as Config; } as unknown as Config;
// Setup specific mock for this test - searching in 'sub' should only return matches from that directory // 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'); 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 () => { it('should handle context parameters', async () => {
mockSpawn.mockImplementationOnce( mockSpawn.mockImplementationOnce(
createMockSpawn({ createMockSpawn({
@@ -1761,6 +1838,7 @@ describe('RipGrepTool', () => {
}); });
}); });
}); });
afterAll(() => { afterAll(() => {
storageSpy.mockRestore(); storageSpy.mockRestore();
}); });

View File

@@ -23,6 +23,7 @@ import {
FileExclusions, FileExclusions,
COMMON_DIRECTORY_EXCLUDES, COMMON_DIRECTORY_EXCLUDES,
} from '../utils/ignorePatterns.js'; } from '../utils/ignorePatterns.js';
import { GeminiIgnoreParser } from '../utils/geminiIgnoreParser.js';
const DEFAULT_TOTAL_MAX_MATCHES = 20000; const DEFAULT_TOTAL_MAX_MATCHES = 20000;
@@ -189,6 +190,7 @@ class GrepToolInvocation extends BaseToolInvocation<
> { > {
constructor( constructor(
private readonly config: Config, private readonly config: Config,
private readonly geminiIgnoreParser: GeminiIgnoreParser,
params: RipGrepToolParams, params: RipGrepToolParams,
messageBus?: MessageBus, messageBus?: MessageBus,
_toolName?: string, _toolName?: string,
@@ -387,6 +389,14 @@ class GrepToolInvocation extends BaseToolInvocation<
excludes.forEach((exclude) => { excludes.forEach((exclude) => {
rgArgs.push('--glob', `!${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'); rgArgs.push('--threads', '4');
@@ -479,6 +489,7 @@ export class RipGrepTool extends BaseDeclarativeTool<
ToolResult ToolResult
> { > {
static readonly Name = GREP_TOOL_NAME; static readonly Name = GREP_TOOL_NAME;
private readonly geminiIgnoreParser: GeminiIgnoreParser;
constructor( constructor(
private readonly config: Config, private readonly config: Config,
@@ -544,6 +555,7 @@ export class RipGrepTool extends BaseDeclarativeTool<
false, // canUpdateOutput false, // canUpdateOutput
messageBus, messageBus,
); );
this.geminiIgnoreParser = new GeminiIgnoreParser(config.getTargetDir());
} }
/** /**
@@ -580,6 +592,7 @@ export class RipGrepTool extends BaseDeclarativeTool<
): ToolInvocation<RipGrepToolParams, ToolResult> { ): ToolInvocation<RipGrepToolParams, ToolResult> {
return new GrepToolInvocation( return new GrepToolInvocation(
this.config, this.config,
this.geminiIgnoreParser,
params, params,
messageBus, messageBus,
_toolName, _toolName,

View File

@@ -58,6 +58,25 @@ describe('GeminiIgnoreParser', () => {
false, 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', () => { describe('when .geminiignore does not exist', () => {
@@ -66,5 +85,50 @@ describe('GeminiIgnoreParser', () => {
expect(parser.getPatterns()).toEqual([]); expect(parser.getPatterns()).toEqual([]);
expect(parser.isIgnored('any_file.txt')).toBe(false); 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);
});
}); });
}); });

View File

@@ -11,6 +11,8 @@ import ignore from 'ignore';
export interface GeminiIgnoreFilter { export interface GeminiIgnoreFilter {
isIgnored(filePath: string): boolean; isIgnored(filePath: string): boolean;
getPatterns(): string[]; getPatterns(): string[];
getIgnoreFilePath(): string | null;
hasPatterns(): boolean;
} }
export class GeminiIgnoreParser implements GeminiIgnoreFilter { export class GeminiIgnoreParser implements GeminiIgnoreFilter {
@@ -78,4 +80,26 @@ export class GeminiIgnoreParser implements GeminiIgnoreFilter {
getPatterns(): string[] { getPatterns(): string[] {
return this.patterns; 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);
}
} }