feat: full implementation for .geminiignore in settings and respective tool calls (#3727)

This commit is contained in:
Pyush Sinha
2025-07-20 00:55:33 -07:00
committed by GitHub
parent 76b935d598
commit a01b1219a3
19 changed files with 803 additions and 116 deletions

View File

@@ -331,6 +331,7 @@ describe('Server Config (config.ts)', () => {
config.getDebugMode(),
config.getFileService(),
config.getExtensionContextFilePaths(),
config.getFileFilteringOptions(),
);
expect(config.getUserMemory()).toBe(mockMemoryData.memoryContent);

View File

@@ -76,7 +76,20 @@ export interface GeminiCLIExtension {
version: string;
isActive: boolean;
}
export interface FileFilteringOptions {
respectGitIgnore: boolean;
respectGeminiIgnore: boolean;
}
// For memory files
export const DEFAULT_MEMORY_FILE_FILTERING_OPTIONS: FileFilteringOptions = {
respectGitIgnore: false,
respectGeminiIgnore: true,
};
// For all other files
export const DEFAULT_FILE_FILTERING_OPTIONS: FileFilteringOptions = {
respectGitIgnore: true,
respectGeminiIgnore: true,
};
export class MCPServerConfig {
constructor(
// For stdio transport
@@ -137,6 +150,7 @@ export interface ConfigParameters {
usageStatisticsEnabled?: boolean;
fileFiltering?: {
respectGitIgnore?: boolean;
respectGeminiIgnore?: boolean;
enableRecursiveFileSearch?: boolean;
};
checkpointing?: boolean;
@@ -182,6 +196,7 @@ export class Config {
private geminiClient!: GeminiClient;
private readonly fileFiltering: {
respectGitIgnore: boolean;
respectGeminiIgnore: boolean;
enableRecursiveFileSearch: boolean;
};
private fileDiscoveryService: FileDiscoveryService | null = null;
@@ -239,6 +254,7 @@ export class Config {
this.fileFiltering = {
respectGitIgnore: params.fileFiltering?.respectGitIgnore ?? true,
respectGeminiIgnore: params.fileFiltering?.respectGeminiIgnore ?? true,
enableRecursiveFileSearch:
params.fileFiltering?.enableRecursiveFileSearch ?? true,
};
@@ -473,6 +489,16 @@ export class Config {
getFileFilteringRespectGitIgnore(): boolean {
return this.fileFiltering.respectGitIgnore;
}
getFileFilteringRespectGeminiIgnore(): boolean {
return this.fileFiltering.respectGeminiIgnore;
}
getFileFilteringOptions(): FileFilteringOptions {
return {
respectGitIgnore: this.fileFiltering.respectGitIgnore,
respectGeminiIgnore: this.fileFiltering.respectGeminiIgnore,
};
}
getCheckpointingEnabled(): boolean {
return this.checkpointing;
@@ -549,6 +575,7 @@ export class Config {
this.getDebugMode(),
this.getFileService(),
this.getExtensionContextFilePaths(),
this.getFileFilteringOptions(),
);
this.setUserMemory(memoryContent);

View File

@@ -10,7 +10,7 @@ import { BaseTool, Icon, ToolResult } from './tools.js';
import { Type } from '@google/genai';
import { SchemaValidator } from '../utils/schemaValidator.js';
import { makeRelative, shortenPath } from '../utils/paths.js';
import { Config } from '../config/config.js';
import { Config, DEFAULT_FILE_FILTERING_OPTIONS } from '../config/config.js';
import { isWithinRoot } from '../utils/fileUtils.js';
/**
@@ -28,9 +28,12 @@ export interface LSToolParams {
ignore?: string[];
/**
* Whether to respect .gitignore patterns (optional, defaults to true)
* Whether to respect .gitignore and .geminiignore patterns (optional, defaults to true)
*/
respect_git_ignore?: boolean;
file_filtering_options?: {
respect_git_ignore?: boolean;
respect_gemini_ignore?: boolean;
};
}
/**
@@ -89,10 +92,22 @@ export class LSTool extends BaseTool<LSToolParams, ToolResult> {
},
type: Type.ARRAY,
},
respect_git_ignore: {
file_filtering_options: {
description:
'Optional: Whether to respect .gitignore patterns when listing files. Only available in git repositories. Defaults to true.',
type: Type.BOOLEAN,
'Optional: Whether to respect ignore patterns from .gitignore or .geminiignore',
type: Type.OBJECT,
properties: {
respect_git_ignore: {
description:
'Optional: Whether to respect .gitignore patterns when listing files. Only available in git repositories. Defaults to true.',
type: Type.BOOLEAN,
},
respect_gemini_ignore: {
description:
'Optional: Whether to respect .geminiignore patterns when listing files. Defaults to true.',
type: Type.BOOLEAN,
},
},
},
},
required: ['path'],
@@ -199,14 +214,25 @@ export class LSTool extends BaseTool<LSToolParams, ToolResult> {
const files = fs.readdirSync(params.path);
const defaultFileIgnores =
this.config.getFileFilteringOptions() ?? DEFAULT_FILE_FILTERING_OPTIONS;
const fileFilteringOptions = {
respectGitIgnore:
params.file_filtering_options?.respect_git_ignore ??
defaultFileIgnores.respectGitIgnore,
respectGeminiIgnore:
params.file_filtering_options?.respect_gemini_ignore ??
defaultFileIgnores.respectGeminiIgnore,
};
// Get centralized file discovery service
const respectGitIgnore =
params.respect_git_ignore ??
this.config.getFileFilteringRespectGitIgnore();
const fileDiscovery = this.config.getFileService();
const entries: FileEntry[] = [];
let gitIgnoredCount = 0;
let geminiIgnoredCount = 0;
if (files.length === 0) {
// Changed error message to be more neutral for LLM
@@ -227,14 +253,21 @@ export class LSTool extends BaseTool<LSToolParams, ToolResult> {
fullPath,
);
// Check if this file should be git-ignored (only in git repositories)
// Check if this file should be ignored based on git or gemini ignore rules
if (
respectGitIgnore &&
fileFilteringOptions.respectGitIgnore &&
fileDiscovery.shouldGitIgnoreFile(relativePath)
) {
gitIgnoredCount++;
continue;
}
if (
fileFilteringOptions.respectGeminiIgnore &&
fileDiscovery.shouldGeminiIgnoreFile(relativePath)
) {
geminiIgnoredCount++;
continue;
}
try {
const stats = fs.statSync(fullPath);
@@ -265,13 +298,21 @@ export class LSTool extends BaseTool<LSToolParams, ToolResult> {
.join('\n');
let resultMessage = `Directory listing for ${params.path}:\n${directoryContent}`;
const ignoredMessages = [];
if (gitIgnoredCount > 0) {
resultMessage += `\n\n(${gitIgnoredCount} items were git-ignored)`;
ignoredMessages.push(`${gitIgnoredCount} git-ignored`);
}
if (geminiIgnoredCount > 0) {
ignoredMessages.push(`${geminiIgnoredCount} gemini-ignored`);
}
if (ignoredMessages.length > 0) {
resultMessage += `\n\n(${ignoredMessages.join(', ')})`;
}
let displayMessage = `Listed ${entries.length} item(s).`;
if (gitIgnoredCount > 0) {
displayMessage += ` (${gitIgnoredCount} git-ignored)`;
if (ignoredMessages.length > 0) {
displayMessage += ` (${ignoredMessages.join(', ')})`;
}
return {

View File

@@ -58,10 +58,13 @@ describe('ReadManyFilesTool', () => {
const fileService = new FileDiscoveryService(tempRootDir);
const mockConfig = {
getFileService: () => fileService,
getFileFilteringRespectGitIgnore: () => true,
getFileFilteringOptions: () => ({
respectGitIgnore: true,
respectGeminiIgnore: true,
}),
getTargetDir: () => tempRootDir,
} as Partial<Config> as Config;
tool = new ReadManyFilesTool(mockConfig);
mockReadFileFn = mockControl.mockReadFile;

View File

@@ -17,7 +17,7 @@ import {
getSpecificMimeType,
} from '../utils/fileUtils.js';
import { PartListUnion, Schema, Type } from '@google/genai';
import { Config } from '../config/config.js';
import { Config, DEFAULT_FILE_FILTERING_OPTIONS } from '../config/config.js';
import {
recordFileOperationMetric,
FileOperation,
@@ -62,9 +62,12 @@ export interface ReadManyFilesParams {
useDefaultExcludes?: boolean;
/**
* Optional. Whether to respect .gitignore patterns. Defaults to true.
* Whether to respect .gitignore and .geminiignore patterns (optional, defaults to true)
*/
respect_git_ignore?: boolean;
file_filtering_options?: {
respect_git_ignore?: boolean;
respect_gemini_ignore?: boolean;
};
}
/**
@@ -173,11 +176,22 @@ export class ReadManyFilesTool extends BaseTool<
'Optional. Whether to apply a list of default exclusion patterns (e.g., node_modules, .git, binary files). Defaults to true.',
default: true,
},
respect_git_ignore: {
type: Type.BOOLEAN,
file_filtering_options: {
description:
'Optional. Whether to respect .gitignore patterns when discovering files. Only available in git repositories. Defaults to true.',
default: true,
'Whether to respect ignore patterns from .gitignore or .geminiignore',
type: Type.OBJECT,
properties: {
respect_git_ignore: {
description:
'Optional: Whether to respect .gitignore patterns when listing files. Only available in git repositories. Defaults to true.',
type: Type.BOOLEAN,
},
respect_gemini_ignore: {
description:
'Optional: Whether to respect .geminiignore patterns when listing files. Defaults to true.',
type: Type.BOOLEAN,
},
},
},
},
required: ['paths'],
@@ -257,12 +271,19 @@ Use this tool when the user's query implies needing the content of several files
include = [],
exclude = [],
useDefaultExcludes = true,
respect_git_ignore = true,
} = params;
const respectGitIgnore =
respect_git_ignore ?? this.config.getFileFilteringRespectGitIgnore();
const defaultFileIgnores =
this.config.getFileFilteringOptions() ?? DEFAULT_FILE_FILTERING_OPTIONS;
const fileFilteringOptions = {
respectGitIgnore:
params.file_filtering_options?.respect_git_ignore ??
defaultFileIgnores.respectGitIgnore, // Use the property from the returned object
respectGeminiIgnore:
params.file_filtering_options?.respect_gemini_ignore ??
defaultFileIgnores.respectGeminiIgnore, // Use the property from the returned object
};
// Get centralized file discovery service
const fileDiscovery = this.config.getFileService();
@@ -272,8 +293,8 @@ Use this tool when the user's query implies needing the content of several files
const contentParts: PartListUnion = [];
const effectiveExcludes = useDefaultExcludes
? [...DEFAULT_EXCLUDES, ...exclude, ...this.geminiIgnorePatterns]
: [...exclude, ...this.geminiIgnorePatterns];
? [...DEFAULT_EXCLUDES, ...exclude]
: [...exclude];
const searchPatterns = [...inputPatterns, ...include];
if (searchPatterns.length === 0) {
@@ -294,18 +315,36 @@ Use this tool when the user's query implies needing the content of several files
signal,
});
const filteredEntries = respectGitIgnore
const gitFilteredEntries = fileFilteringOptions.respectGitIgnore
? fileDiscovery
.filterFiles(
entries.map((p) => path.relative(this.config.getTargetDir(), p)),
{
respectGitIgnore,
respectGitIgnore: true,
respectGeminiIgnore: false,
},
)
.map((p) => path.resolve(this.config.getTargetDir(), p))
: entries;
// Apply gemini ignore filtering if enabled
const finalFilteredEntries = fileFilteringOptions.respectGeminiIgnore
? fileDiscovery
.filterFiles(
gitFilteredEntries.map((p) =>
path.relative(this.config.getTargetDir(), p),
),
{
respectGitIgnore: false,
respectGeminiIgnore: true,
},
)
.map((p) => path.resolve(this.config.getTargetDir(), p))
: gitFilteredEntries;
let gitIgnoredCount = 0;
let geminiIgnoredCount = 0;
for (const absoluteFilePath of entries) {
// Security check: ensure the glob library didn't return something outside targetDir.
if (!absoluteFilePath.startsWith(this.config.getTargetDir())) {
@@ -317,11 +356,23 @@ Use this tool when the user's query implies needing the content of several files
}
// Check if this file was filtered out by git ignore
if (respectGitIgnore && !filteredEntries.includes(absoluteFilePath)) {
if (
fileFilteringOptions.respectGitIgnore &&
!gitFilteredEntries.includes(absoluteFilePath)
) {
gitIgnoredCount++;
continue;
}
// Check if this file was filtered out by gemini ignore
if (
fileFilteringOptions.respectGeminiIgnore &&
!finalFilteredEntries.includes(absoluteFilePath)
) {
geminiIgnoredCount++;
continue;
}
filesToConsider.add(absoluteFilePath);
}
@@ -329,7 +380,15 @@ Use this tool when the user's query implies needing the content of several files
if (gitIgnoredCount > 0) {
skippedFiles.push({
path: `${gitIgnoredCount} file(s)`,
reason: 'ignored',
reason: 'git ignored',
});
}
// Add info about gemini-ignored files if any were filtered
if (geminiIgnoredCount > 0) {
skippedFiles.push({
path: `${geminiIgnoredCount} file(s)`,
reason: 'gemini ignored',
});
}
} catch (error) {

View File

@@ -145,4 +145,43 @@ describe('bfsFileSearch', () => {
});
expect(result).toEqual(['/test/subdir1/file1.txt']);
});
it('should respect .geminiignore files', async () => {
const mockFs = vi.mocked(fsPromises);
const mockGitUtils = vi.mocked(gitUtils);
mockGitUtils.isGitRepository.mockReturnValue(false);
const mockReaddir = mockFs.readdir as unknown as ReaddirWithFileTypes;
vi.mocked(mockReaddir).mockImplementation(async (dir) => {
if (dir === '/test') {
return [
createMockDirent('.geminiignore', true),
createMockDirent('subdir1', false),
createMockDirent('subdir2', false),
];
}
if (dir === '/test/subdir1') {
return [createMockDirent('file1.txt', true)];
}
if (dir === '/test/subdir2') {
return [createMockDirent('file1.txt', true)];
}
return [];
});
vi.mocked(fs).readFileSync.mockReturnValue('subdir2');
const fileService = new FileDiscoveryService('/test');
const result = await bfsFileSearch('/test', {
fileName: 'file1.txt',
fileService,
fileFilteringOptions: {
respectGitIgnore: true,
respectGeminiIgnore: true,
},
});
expect(result).toEqual(['/test/subdir1/file1.txt']);
});
});

View File

@@ -8,7 +8,7 @@ import * as fs from 'fs/promises';
import * as path from 'path';
import { Dirent } from 'fs';
import { FileDiscoveryService } from '../services/fileDiscoveryService.js';
import { FileFilteringOptions } from '../config/config.js';
// Simple console logger for now.
// TODO: Integrate with a more robust server-side logger.
const logger = {
@@ -22,6 +22,7 @@ interface BfsFileSearchOptions {
maxDirs?: number;
debug?: boolean;
fileService?: FileDiscoveryService;
fileFilteringOptions?: FileFilteringOptions;
}
/**
@@ -69,7 +70,13 @@ export async function bfsFileSearch(
for (const entry of entries) {
const fullPath = path.join(currentDir, entry.name);
if (fileService?.shouldGitIgnoreFile(fullPath)) {
if (
fileService?.shouldIgnoreFile(fullPath, {
respectGitIgnore: options.fileFilteringOptions?.respectGitIgnore,
respectGeminiIgnore:
options.fileFilteringOptions?.respectGeminiIgnore,
})
) {
continue;
}

View File

@@ -307,6 +307,7 @@ describe('getFolderStructure gitignore', () => {
createDirent('file1.txt', 'file'),
createDirent('node_modules', 'dir'),
createDirent('ignored.txt', 'file'),
createDirent('gem_ignored.txt', 'file'),
createDirent('.gemini', 'dir'),
] as any;
}
@@ -327,6 +328,9 @@ describe('getFolderStructure gitignore', () => {
if (path === '/test/project/.gitignore') {
return 'ignored.txt\nnode_modules/\n.gemini/\n!/.gemini/config.yaml';
}
if (path === '/test/project/.geminiignore') {
return 'gem_ignored.txt\nnode_modules/\n.gemini/\n!/.gemini/config.yaml';
}
return '';
});
@@ -347,10 +351,37 @@ describe('getFolderStructure gitignore', () => {
const fileService = new FileDiscoveryService('/test/project');
const structure = await getFolderStructure('/test/project', {
fileService,
respectGitIgnore: false,
fileFilteringOptions: {
respectGeminiIgnore: false,
respectGitIgnore: false,
},
});
expect(structure).toContain('ignored.txt');
// node_modules is still ignored by default
expect(structure).toContain('node_modules/...');
});
it('should ignore files and folders specified in .geminiignore', async () => {
const fileService = new FileDiscoveryService('/test/project');
const structure = await getFolderStructure('/test/project', {
fileService,
});
expect(structure).not.toContain('gem_ignored.txt');
expect(structure).toContain('node_modules/...');
expect(structure).not.toContain('logs.json');
});
it('should not ignore files if respectGeminiIgnore is false', async () => {
const fileService = new FileDiscoveryService('/test/project');
const structure = await getFolderStructure('/test/project', {
fileService,
fileFilteringOptions: {
respectGeminiIgnore: false,
respectGitIgnore: true, // Explicitly disable gemini ignore only
},
});
expect(structure).toContain('gem_ignored.txt');
// node_modules is still ignored by default
expect(structure).toContain('node_modules/...');
});
});

View File

@@ -9,6 +9,8 @@ import { Dirent } from 'fs';
import * as path from 'path';
import { getErrorMessage, isNodeError } from './errors.js';
import { FileDiscoveryService } from '../services/fileDiscoveryService.js';
import { FileFilteringOptions } from '../config/config.js';
import { DEFAULT_FILE_FILTERING_OPTIONS } from '../config/config.js';
const MAX_ITEMS = 200;
const TRUNCATION_INDICATOR = '...';
@@ -26,16 +28,16 @@ interface FolderStructureOptions {
fileIncludePattern?: RegExp;
/** For filtering files. */
fileService?: FileDiscoveryService;
/** Whether to use .gitignore patterns. */
respectGitIgnore?: boolean;
/** File filtering ignore options. */
fileFilteringOptions?: FileFilteringOptions;
}
// Define a type for the merged options where fileIncludePattern remains optional
type MergedFolderStructureOptions = Required<
Omit<FolderStructureOptions, 'fileIncludePattern' | 'fileService'>
> & {
fileIncludePattern?: RegExp;
fileService?: FileDiscoveryService;
fileFilteringOptions?: FileFilteringOptions;
};
/** Represents the full, unfiltered information about a folder and its contents. */
@@ -126,8 +128,13 @@ async function readFullStructure(
}
const fileName = entry.name;
const filePath = path.join(currentPath, fileName);
if (options.respectGitIgnore && options.fileService) {
if (options.fileService.shouldGitIgnoreFile(filePath)) {
if (options.fileService) {
const shouldIgnore =
(options.fileFilteringOptions.respectGitIgnore &&
options.fileService.shouldGitIgnoreFile(filePath)) ||
(options.fileFilteringOptions.respectGeminiIgnore &&
options.fileService.shouldGeminiIgnoreFile(filePath));
if (shouldIgnore) {
continue;
}
}
@@ -160,14 +167,16 @@ async function readFullStructure(
const subFolderName = entry.name;
const subFolderPath = path.join(currentPath, subFolderName);
let isIgnoredByGit = false;
if (options.respectGitIgnore && options.fileService) {
if (options.fileService.shouldGitIgnoreFile(subFolderPath)) {
isIgnoredByGit = true;
}
let isIgnored = false;
if (options.fileService) {
isIgnored =
(options.fileFilteringOptions.respectGitIgnore &&
options.fileService.shouldGitIgnoreFile(subFolderPath)) ||
(options.fileFilteringOptions.respectGeminiIgnore &&
options.fileService.shouldGeminiIgnoreFile(subFolderPath));
}
if (options.ignoredFolders.has(subFolderName) || isIgnoredByGit) {
if (options.ignoredFolders.has(subFolderName) || isIgnored) {
const ignoredSubFolder: FullFolderInfo = {
name: subFolderName,
path: subFolderPath,
@@ -295,7 +304,8 @@ export async function getFolderStructure(
ignoredFolders: options?.ignoredFolders ?? DEFAULT_IGNORED_FOLDERS,
fileIncludePattern: options?.fileIncludePattern,
fileService: options?.fileService,
respectGitIgnore: options?.respectGitIgnore ?? true,
fileFilteringOptions:
options?.fileFilteringOptions ?? DEFAULT_FILE_FILTERING_OPTIONS,
};
try {

View File

@@ -15,6 +15,10 @@ import {
} from '../tools/memoryTool.js';
import { FileDiscoveryService } from '../services/fileDiscoveryService.js';
import { processImports } from './memoryImportProcessor.js';
import {
DEFAULT_MEMORY_FILE_FILTERING_OPTIONS,
FileFilteringOptions,
} from '../config/config.js';
// Simple console logger, similar to the one previously in CLI's config.ts
// TODO: Integrate with a more robust server-side logger if available/appropriate.
@@ -85,6 +89,7 @@ async function getGeminiMdFilePathsInternal(
debugMode: boolean,
fileService: FileDiscoveryService,
extensionContextFilePaths: string[] = [],
fileFilteringOptions: FileFilteringOptions,
): Promise<string[]> {
const allPaths = new Set<string>();
const geminiMdFilenames = getAllGeminiMdFilenames();
@@ -181,11 +186,18 @@ async function getGeminiMdFilePathsInternal(
}
upwardPaths.forEach((p) => allPaths.add(p));
// Merge options with memory defaults, with options taking precedence
const mergedOptions = {
...DEFAULT_MEMORY_FILE_FILTERING_OPTIONS,
...fileFilteringOptions,
};
const downwardPaths = await bfsFileSearch(resolvedCwd, {
fileName: geminiMdFilename,
maxDirs: MAX_DIRECTORIES_TO_SCAN_FOR_MEMORY,
debug: debugMode,
fileService,
fileFilteringOptions: mergedOptions, // Pass merged options as fileFilter
});
downwardPaths.sort(); // Sort for consistent ordering, though hierarchy might be more complex
if (debugMode && downwardPaths.length > 0)
@@ -282,11 +294,13 @@ export async function loadServerHierarchicalMemory(
debugMode: boolean,
fileService: FileDiscoveryService,
extensionContextFilePaths: string[] = [],
fileFilteringOptions?: FileFilteringOptions,
): Promise<{ memoryContent: string; fileCount: number }> {
if (debugMode)
logger.debug(
`Loading server hierarchical memory for CWD: ${currentWorkingDirectory}`,
);
// For the server, homedir() refers to the server process's home.
// This is consistent with how MemoryTool already finds the global path.
const userHomePath = homedir();
@@ -296,6 +310,7 @@ export async function loadServerHierarchicalMemory(
debugMode,
fileService,
extensionContextFilePaths,
fileFilteringOptions || DEFAULT_MEMORY_FILE_FILTERING_OPTIONS,
);
if (filePaths.length === 0) {
if (debugMode) logger.debug('No GEMINI.md files found in hierarchy.');