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() {
return true;
}
getFileFilteringRespectGeminiIgnore() {
return true;
}
}
describe('ripgrep-real-direct', () => {

View File

@@ -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();
});

View File

@@ -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<RipGrepToolParams, ToolResult> {
return new GrepToolInvocation(
this.config,
this.geminiIgnoreParser,
params,
messageBus,
_toolName,

View File

@@ -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);
});
});
});

View File

@@ -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);
}
}