summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--packages/cli/src/config/config.test.ts12
-rw-r--r--packages/cli/src/config/config.ts13
-rw-r--r--packages/cli/src/config/settings.ts1
-rw-r--r--packages/cli/src/ui/App.tsx2
-rw-r--r--packages/cli/src/ui/hooks/atCommandProcessor.test.ts387
-rw-r--r--packages/cli/src/ui/hooks/atCommandProcessor.ts69
-rw-r--r--packages/cli/src/ui/hooks/useCompletion.integration.test.ts95
-rw-r--r--packages/cli/src/ui/hooks/useCompletion.test.ts4
-rw-r--r--packages/cli/src/ui/hooks/useCompletion.ts7
-rw-r--r--packages/core/src/config/config.test.ts1
-rw-r--r--packages/core/src/config/config.ts29
-rw-r--r--packages/core/src/tools/ls.ts69
-rw-r--r--packages/core/src/tools/read-many-files.test.ts7
-rw-r--r--packages/core/src/tools/read-many-files.ts91
-rw-r--r--packages/core/src/utils/bfsFileSearch.test.ts39
-rw-r--r--packages/core/src/utils/bfsFileSearch.ts11
-rw-r--r--packages/core/src/utils/getFolderStructure.test.ts33
-rw-r--r--packages/core/src/utils/getFolderStructure.ts34
-rw-r--r--packages/core/src/utils/memoryDiscovery.ts15
19 files changed, 803 insertions, 116 deletions
diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts
index cc0f112a..0c0761cc 100644
--- a/packages/cli/src/config/config.test.ts
+++ b/packages/cli/src/config/config.test.ts
@@ -43,6 +43,14 @@ vi.mock('@google/gemini-cli-core', async () => {
fileCount: extensionPaths?.length || 0,
}),
),
+ DEFAULT_MEMORY_FILE_FILTERING_OPTIONS: {
+ respectGitIgnore: false,
+ respectGeminiIgnore: true,
+ },
+ DEFAULT_FILE_FILTERING_OPTIONS: {
+ respectGitIgnore: true,
+ respectGeminiIgnore: true,
+ },
};
});
@@ -479,6 +487,10 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
'/path/to/ext3/context1.md',
'/path/to/ext3/context2.md',
],
+ {
+ respectGitIgnore: false,
+ respectGeminiIgnore: true,
+ },
);
});
diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts
index 2d33daa3..fd4907d0 100644
--- a/packages/cli/src/config/config.ts
+++ b/packages/cli/src/config/config.ts
@@ -15,8 +15,10 @@ import {
ApprovalMode,
DEFAULT_GEMINI_MODEL,
DEFAULT_GEMINI_EMBEDDING_MODEL,
+ DEFAULT_MEMORY_FILE_FILTERING_OPTIONS,
FileDiscoveryService,
TelemetryTarget,
+ FileFilteringOptions,
MCPServerConfig,
IDE_SERVER_NAME,
} from '@google/gemini-cli-core';
@@ -219,12 +221,14 @@ export async function loadHierarchicalGeminiMemory(
debugMode: boolean,
fileService: FileDiscoveryService,
extensionContextFilePaths: string[] = [],
+ fileFilteringOptions?: FileFilteringOptions,
): Promise<{ memoryContent: string; fileCount: number }> {
if (debugMode) {
logger.debug(
`CLI: Delegating hierarchical memory load to server for CWD: ${currentWorkingDirectory}`,
);
}
+
// Directly call the server function.
// The server function will use its own homedir() for the global path.
return loadServerHierarchicalMemory(
@@ -232,6 +236,7 @@ export async function loadHierarchicalGeminiMemory(
debugMode,
fileService,
extensionContextFilePaths,
+ fileFilteringOptions,
);
}
@@ -277,12 +282,19 @@ export async function loadCliConfig(
);
const fileService = new FileDiscoveryService(process.cwd());
+
+ const fileFiltering = {
+ ...DEFAULT_MEMORY_FILE_FILTERING_OPTIONS,
+ ...settings.fileFiltering,
+ };
+
// Call the (now wrapper) loadHierarchicalGeminiMemory which calls the server's version
const { memoryContent, fileCount } = await loadHierarchicalGeminiMemory(
process.cwd(),
debugMode,
fileService,
extensionContextFilePaths,
+ fileFiltering,
);
let mcpServers = mergeMcpServers(settings, activeExtensions);
@@ -405,6 +417,7 @@ export async function loadCliConfig(
// Git-aware file filtering settings
fileFiltering: {
respectGitIgnore: settings.fileFiltering?.respectGitIgnore,
+ respectGeminiIgnore: settings.fileFiltering?.respectGeminiIgnore,
enableRecursiveFileSearch:
settings.fileFiltering?.enableRecursiveFileSearch,
},
diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts
index 24b9e9e6..3cbfe22d 100644
--- a/packages/cli/src/config/settings.ts
+++ b/packages/cli/src/config/settings.ts
@@ -81,6 +81,7 @@ export interface Settings {
// Git-aware file filtering settings
fileFiltering?: {
respectGitIgnore?: boolean;
+ respectGeminiIgnore?: boolean;
enableRecursiveFileSearch?: boolean;
};
diff --git a/packages/cli/src/ui/App.tsx b/packages/cli/src/ui/App.tsx
index 39a1f14c..027665f1 100644
--- a/packages/cli/src/ui/App.tsx
+++ b/packages/cli/src/ui/App.tsx
@@ -252,7 +252,9 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
config.getDebugMode(),
config.getFileService(),
config.getExtensionContextFilePaths(),
+ config.getFileFilteringOptions(),
);
+
config.setUserMemory(memoryContent);
config.setGeminiMdFileCount(fileCount);
setGeminiMdFileCount(fileCount);
diff --git a/packages/cli/src/ui/hooks/atCommandProcessor.test.ts b/packages/cli/src/ui/hooks/atCommandProcessor.test.ts
index efe15c64..6e272b24 100644
--- a/packages/cli/src/ui/hooks/atCommandProcessor.test.ts
+++ b/packages/cli/src/ui/hooks/atCommandProcessor.test.ts
@@ -21,6 +21,11 @@ const mockConfig = {
isSandboxed: vi.fn(() => false),
getFileService: vi.fn(),
getFileFilteringRespectGitIgnore: vi.fn(() => true),
+ getFileFilteringRespectGeminiIgnore: vi.fn(() => true),
+ getFileFilteringOptions: vi.fn(() => ({
+ respectGitIgnore: true,
+ respectGeminiIgnore: true,
+ })),
getEnableRecursiveFileSearch: vi.fn(() => true),
} as unknown as Config;
@@ -171,7 +176,13 @@ describe('handleAtCommand', () => {
125,
);
expect(mockReadManyFilesExecute).toHaveBeenCalledWith(
- { paths: [filePath], respect_git_ignore: true },
+ {
+ paths: [filePath],
+ file_filtering_options: {
+ respect_git_ignore: true,
+ respect_gemini_ignore: true,
+ },
+ },
abortController.signal,
);
expect(mockAddItem).toHaveBeenCalledWith(
@@ -217,7 +228,13 @@ describe('handleAtCommand', () => {
126,
);
expect(mockReadManyFilesExecute).toHaveBeenCalledWith(
- { paths: [resolvedGlob], respect_git_ignore: true },
+ {
+ paths: [resolvedGlob],
+ file_filtering_options: {
+ respect_git_ignore: true,
+ respect_gemini_ignore: true,
+ },
+ },
abortController.signal,
);
expect(mockOnDebugMessage).toHaveBeenCalledWith(
@@ -318,7 +335,13 @@ describe('handleAtCommand', () => {
signal: abortController.signal,
});
expect(mockReadManyFilesExecute).toHaveBeenCalledWith(
- { paths: [unescapedPath], respect_git_ignore: true },
+ {
+ paths: [unescapedPath],
+ file_filtering_options: {
+ respect_git_ignore: true,
+ respect_gemini_ignore: true,
+ },
+ },
abortController.signal,
);
});
@@ -347,7 +370,13 @@ describe('handleAtCommand', () => {
signal: abortController.signal,
});
expect(mockReadManyFilesExecute).toHaveBeenCalledWith(
- { paths: [file1, file2], respect_git_ignore: true },
+ {
+ paths: [file1, file2],
+ file_filtering_options: {
+ respect_git_ignore: true,
+ respect_gemini_ignore: true,
+ },
+ },
abortController.signal,
);
expect(result.processedQuery).toEqual([
@@ -389,7 +418,13 @@ describe('handleAtCommand', () => {
signal: abortController.signal,
});
expect(mockReadManyFilesExecute).toHaveBeenCalledWith(
- { paths: [file1, file2], respect_git_ignore: true },
+ {
+ paths: [file1, file2],
+ file_filtering_options: {
+ respect_git_ignore: true,
+ respect_gemini_ignore: true,
+ },
+ },
abortController.signal,
);
expect(result.processedQuery).toEqual([
@@ -454,7 +489,13 @@ describe('handleAtCommand', () => {
});
expect(mockReadManyFilesExecute).toHaveBeenCalledWith(
- { paths: [file1, resolvedFile2], respect_git_ignore: true },
+ {
+ paths: [file1, resolvedFile2],
+ file_filtering_options: {
+ respect_git_ignore: true,
+ respect_gemini_ignore: true,
+ },
+ },
abortController.signal,
);
expect(result.processedQuery).toEqual([
@@ -556,7 +597,13 @@ describe('handleAtCommand', () => {
// If the mock is simpler, it might use queryPath if stat(queryPath) succeeds.
// The most important part is that *some* version of the path that leads to the content is used.
// Let's assume it uses the path from the query if stat confirms it exists (even if different case on disk)
- { paths: [queryPath], respect_git_ignore: true },
+ {
+ paths: [queryPath],
+ file_filtering_options: {
+ respect_git_ignore: true,
+ respect_gemini_ignore: true,
+ },
+ },
abortController.signal,
);
expect(mockAddItem).toHaveBeenCalledWith(
@@ -583,8 +630,18 @@ describe('handleAtCommand', () => {
// Mock the file discovery service to report this file as git-ignored
mockFileDiscoveryService.shouldIgnoreFile.mockImplementation(
- (path: string, options?: { respectGitIgnore?: boolean }) =>
- path === gitIgnoredFile && options?.respectGitIgnore !== false,
+ (
+ path: string,
+ options?: {
+ respectGitIgnore?: boolean;
+ respectGeminiIgnore?: boolean;
+ },
+ ) => {
+ if (path !== gitIgnoredFile) return false;
+ if (options?.respectGitIgnore) return true;
+ if (options?.respectGeminiIgnore) return false;
+ return false;
+ },
);
const result = await handleAtCommand({
@@ -596,15 +653,24 @@ describe('handleAtCommand', () => {
signal: abortController.signal,
});
+ // Should be called twice - once for git ignore check and once for gemini ignore check
+ expect(mockFileDiscoveryService.shouldIgnoreFile).toHaveBeenCalledTimes(
+ 2,
+ );
expect(mockFileDiscoveryService.shouldIgnoreFile).toHaveBeenCalledWith(
gitIgnoredFile,
- { respectGitIgnore: true },
+ { respectGitIgnore: true, respectGeminiIgnore: false },
);
+ expect(mockFileDiscoveryService.shouldIgnoreFile).toHaveBeenCalledWith(
+ gitIgnoredFile,
+ { respectGitIgnore: false, respectGeminiIgnore: true },
+ );
+
expect(mockOnDebugMessage).toHaveBeenCalledWith(
`Path ${gitIgnoredFile} is git-ignored and will be skipped.`,
);
expect(mockOnDebugMessage).toHaveBeenCalledWith(
- 'Ignored 1 git-ignored files: node_modules/package.json',
+ 'Ignored 1 files:\nGit-ignored: node_modules/package.json',
);
expect(mockReadManyFilesExecute).not.toHaveBeenCalled();
expect(result.processedQuery).toEqual([{ text: query }]);
@@ -616,7 +682,15 @@ describe('handleAtCommand', () => {
const query = `@${validFile}`;
const fileContent = 'console.log("Hello world");';
- mockFileDiscoveryService.shouldIgnoreFile.mockReturnValue(false);
+ mockFileDiscoveryService.shouldIgnoreFile.mockImplementation(
+ (
+ _path: string,
+ _options?: {
+ respectGitIgnore?: boolean;
+ respectGeminiIgnore?: boolean;
+ },
+ ) => false,
+ );
mockReadManyFilesExecute.mockResolvedValue({
llmContent: [`--- ${validFile} ---\n\n${fileContent}\n\n`],
returnDisplay: 'Read 1 file.',
@@ -631,12 +705,26 @@ describe('handleAtCommand', () => {
signal: abortController.signal,
});
+ // Should be called twice - once for git ignore check and once for gemini ignore check
+ expect(mockFileDiscoveryService.shouldIgnoreFile).toHaveBeenCalledTimes(
+ 2,
+ );
+ expect(mockFileDiscoveryService.shouldIgnoreFile).toHaveBeenCalledWith(
+ validFile,
+ { respectGitIgnore: true, respectGeminiIgnore: false },
+ );
expect(mockFileDiscoveryService.shouldIgnoreFile).toHaveBeenCalledWith(
validFile,
- { respectGitIgnore: true },
+ { respectGitIgnore: false, respectGeminiIgnore: true },
);
expect(mockReadManyFilesExecute).toHaveBeenCalledWith(
- { paths: [validFile], respect_git_ignore: true },
+ {
+ paths: [validFile],
+ file_filtering_options: {
+ respect_git_ignore: true,
+ respect_gemini_ignore: true,
+ },
+ },
abortController.signal,
);
expect(result.processedQuery).toEqual([
@@ -656,8 +744,21 @@ describe('handleAtCommand', () => {
const fileContent = '# Project README';
mockFileDiscoveryService.shouldIgnoreFile.mockImplementation(
- (path: string, options?: { respectGitIgnore?: boolean }) =>
- path === gitIgnoredFile && options?.respectGitIgnore !== false,
+ (
+ path: string,
+ options?: {
+ respectGitIgnore?: boolean;
+ respectGeminiIgnore?: boolean;
+ },
+ ) => {
+ if (path === gitIgnoredFile && options?.respectGitIgnore) {
+ return true;
+ }
+ if (options?.respectGeminiIgnore) {
+ return false;
+ }
+ return false;
+ },
);
mockReadManyFilesExecute.mockResolvedValue({
llmContent: [`--- ${validFile} ---\n\n${fileContent}\n\n`],
@@ -673,22 +774,40 @@ describe('handleAtCommand', () => {
signal: abortController.signal,
});
+ // Should be called twice for each file - once for git ignore check and once for gemini ignore check
+ expect(mockFileDiscoveryService.shouldIgnoreFile).toHaveBeenCalledTimes(
+ 4,
+ );
+ expect(mockFileDiscoveryService.shouldIgnoreFile).toHaveBeenCalledWith(
+ validFile,
+ { respectGitIgnore: true, respectGeminiIgnore: false },
+ );
expect(mockFileDiscoveryService.shouldIgnoreFile).toHaveBeenCalledWith(
validFile,
- { respectGitIgnore: true },
+ { respectGitIgnore: false, respectGeminiIgnore: true },
);
expect(mockFileDiscoveryService.shouldIgnoreFile).toHaveBeenCalledWith(
gitIgnoredFile,
- { respectGitIgnore: true },
+ { respectGitIgnore: true, respectGeminiIgnore: false },
+ );
+ expect(mockFileDiscoveryService.shouldIgnoreFile).toHaveBeenCalledWith(
+ gitIgnoredFile,
+ { respectGitIgnore: false, respectGeminiIgnore: true },
);
expect(mockOnDebugMessage).toHaveBeenCalledWith(
`Path ${gitIgnoredFile} is git-ignored and will be skipped.`,
);
expect(mockOnDebugMessage).toHaveBeenCalledWith(
- 'Ignored 1 git-ignored files: .env',
+ 'Ignored 1 files:\nGit-ignored: .env',
);
expect(mockReadManyFilesExecute).toHaveBeenCalledWith(
- { paths: [validFile], respect_git_ignore: true },
+ {
+ paths: [validFile],
+ file_filtering_options: {
+ respect_git_ignore: true,
+ respect_gemini_ignore: true,
+ },
+ },
abortController.signal,
);
expect(result.processedQuery).toEqual([
@@ -705,7 +824,16 @@ describe('handleAtCommand', () => {
const gitFile = '.git/config';
const query = `@${gitFile}`;
- mockFileDiscoveryService.shouldIgnoreFile.mockReturnValue(true);
+ // Mock to return true for git ignore check, false for gemini ignore check
+ mockFileDiscoveryService.shouldIgnoreFile.mockImplementation(
+ (
+ _path: string,
+ options?: {
+ respectGitIgnore?: boolean;
+ respectGeminiIgnore?: boolean;
+ },
+ ) => options?.respectGitIgnore === true,
+ );
const result = await handleAtCommand({
query,
@@ -716,13 +844,24 @@ describe('handleAtCommand', () => {
signal: abortController.signal,
});
+ // Should be called twice - once for git ignore check and once for gemini ignore check
+ expect(mockFileDiscoveryService.shouldIgnoreFile).toHaveBeenCalledTimes(
+ 2,
+ );
+ expect(mockFileDiscoveryService.shouldIgnoreFile).toHaveBeenCalledWith(
+ gitFile,
+ { respectGitIgnore: true, respectGeminiIgnore: false },
+ );
expect(mockFileDiscoveryService.shouldIgnoreFile).toHaveBeenCalledWith(
gitFile,
- { respectGitIgnore: true },
+ { respectGitIgnore: false, respectGeminiIgnore: true },
);
expect(mockOnDebugMessage).toHaveBeenCalledWith(
`Path ${gitFile} is git-ignored and will be skipped.`,
);
+ expect(mockOnDebugMessage).toHaveBeenCalledWith(
+ 'Ignored 1 files:\nGit-ignored: .git/config',
+ );
expect(mockReadManyFilesExecute).not.toHaveBeenCalled();
expect(result.processedQuery).toEqual([{ text: query }]);
expect(result.shouldProceed).toBe(true);
@@ -759,4 +898,208 @@ describe('handleAtCommand', () => {
expect(result.shouldProceed).toBe(true);
});
});
+
+ describe('gemini-ignore filtering', () => {
+ it('should skip gemini-ignored files in @ commands', async () => {
+ const geminiIgnoredFile = 'build/output.js';
+ const query = `@${geminiIgnoredFile}`;
+
+ // Mock the file discovery service to report this file as gemini-ignored
+ mockFileDiscoveryService.shouldIgnoreFile.mockImplementation(
+ (
+ path: string,
+ options?: {
+ respectGitIgnore?: boolean;
+ respectGeminiIgnore?: boolean;
+ },
+ ) => {
+ if (path !== geminiIgnoredFile) return false;
+ if (options?.respectGeminiIgnore) return true;
+ if (options?.respectGitIgnore) return false;
+ return false;
+ },
+ );
+
+ const result = await handleAtCommand({
+ query,
+ config: mockConfig,
+ addItem: mockAddItem,
+ onDebugMessage: mockOnDebugMessage,
+ messageId: 204,
+ signal: abortController.signal,
+ });
+
+ // Should be called twice - once for git ignore check and once for gemini ignore check
+ expect(mockFileDiscoveryService.shouldIgnoreFile).toHaveBeenCalledTimes(
+ 2,
+ );
+ expect(mockFileDiscoveryService.shouldIgnoreFile).toHaveBeenCalledWith(
+ geminiIgnoredFile,
+ { respectGitIgnore: true, respectGeminiIgnore: false },
+ );
+ expect(mockFileDiscoveryService.shouldIgnoreFile).toHaveBeenCalledWith(
+ geminiIgnoredFile,
+ { respectGitIgnore: false, respectGeminiIgnore: true },
+ );
+
+ expect(mockOnDebugMessage).toHaveBeenCalledWith(
+ `Path ${geminiIgnoredFile} is gemini-ignored and will be skipped.`,
+ );
+ expect(mockOnDebugMessage).toHaveBeenCalledWith(
+ 'No valid file paths found in @ commands to read.',
+ );
+ expect(mockOnDebugMessage).toHaveBeenCalledWith(
+ 'Ignored 1 files:\nGemini-ignored: build/output.js',
+ );
+ expect(mockReadManyFilesExecute).not.toHaveBeenCalled();
+ expect(result.processedQuery).toEqual([{ text: query }]);
+ expect(result.shouldProceed).toBe(true);
+ });
+
+ it('should process non-ignored files when .geminiignore is present', async () => {
+ const validFile = 'src/index.ts';
+ const query = `@${validFile}`;
+ const fileContent = 'console.log("Hello world")';
+
+ mockFileDiscoveryService.shouldIgnoreFile.mockImplementation(
+ (
+ _path: string,
+ _options?: {
+ respectGitIgnore?: boolean;
+ respectGeminiIgnore?: boolean;
+ },
+ ) => false,
+ );
+ mockReadManyFilesExecute.mockResolvedValue({
+ llmContent: [`--- ${validFile} ---\n\n${fileContent}\n\n`],
+ returnDisplay: 'Read 1 file.',
+ });
+
+ const result = await handleAtCommand({
+ query,
+ config: mockConfig,
+ addItem: mockAddItem,
+ onDebugMessage: mockOnDebugMessage,
+ messageId: 205,
+ signal: abortController.signal,
+ });
+
+ // Should be called twice - once for git ignore check and once for gemini ignore check
+ expect(mockFileDiscoveryService.shouldIgnoreFile).toHaveBeenCalledTimes(
+ 2,
+ );
+ expect(mockFileDiscoveryService.shouldIgnoreFile).toHaveBeenCalledWith(
+ validFile,
+ { respectGitIgnore: true, respectGeminiIgnore: false },
+ );
+ expect(mockFileDiscoveryService.shouldIgnoreFile).toHaveBeenCalledWith(
+ validFile,
+ { respectGitIgnore: false, respectGeminiIgnore: true },
+ );
+
+ expect(mockReadManyFilesExecute).toHaveBeenCalledWith(
+ {
+ paths: [validFile],
+ file_filtering_options: {
+ respect_git_ignore: true,
+ respect_gemini_ignore: true,
+ },
+ },
+ abortController.signal,
+ );
+ expect(result.processedQuery).toEqual([
+ { text: `@${validFile}` },
+ { text: '\n--- Content from referenced files ---' },
+ { text: `\nContent from @${validFile}:\n` },
+ { text: fileContent },
+ { text: '\n--- End of content ---' },
+ ]);
+ expect(result.shouldProceed).toBe(true);
+ });
+
+ it('should handle mixed gemini-ignored and valid files', async () => {
+ const validFile = 'src/main.ts';
+ const geminiIgnoredFile = 'dist/bundle.js';
+ const query = `@${validFile} @${geminiIgnoredFile}`;
+ const fileContent = '// Main application entry';
+
+ mockFileDiscoveryService.shouldIgnoreFile.mockImplementation(
+ (
+ path: string,
+ options?: {
+ respectGitIgnore?: boolean;
+ respectGeminiIgnore?: boolean;
+ },
+ ) => {
+ if (path === geminiIgnoredFile && options?.respectGeminiIgnore) {
+ return true;
+ }
+ if (options?.respectGitIgnore) {
+ return false;
+ }
+ return false;
+ },
+ );
+ mockReadManyFilesExecute.mockResolvedValue({
+ llmContent: [`--- ${validFile} ---\n\n${fileContent}\n\n`],
+ returnDisplay: 'Read 1 file.',
+ });
+
+ const result = await handleAtCommand({
+ query,
+ config: mockConfig,
+ addItem: mockAddItem,
+ onDebugMessage: mockOnDebugMessage,
+ messageId: 206,
+ signal: abortController.signal,
+ });
+
+ // Should be called twice for each file - once for git ignore check and once for gemini ignore check
+ expect(mockFileDiscoveryService.shouldIgnoreFile).toHaveBeenCalledTimes(
+ 4,
+ );
+
+ // Verify both files were checked against both ignore types
+ [validFile, geminiIgnoredFile].forEach((file) => {
+ expect(mockFileDiscoveryService.shouldIgnoreFile).toHaveBeenCalledWith(
+ file,
+ { respectGitIgnore: true, respectGeminiIgnore: false },
+ );
+ expect(mockFileDiscoveryService.shouldIgnoreFile).toHaveBeenCalledWith(
+ file,
+ { respectGitIgnore: false, respectGeminiIgnore: true },
+ );
+ });
+
+ expect(mockOnDebugMessage).toHaveBeenCalledWith(
+ `Path ${validFile} resolved to file: ${validFile}`,
+ );
+ expect(mockOnDebugMessage).toHaveBeenCalledWith(
+ `Path ${geminiIgnoredFile} is gemini-ignored and will be skipped.`,
+ );
+ expect(mockOnDebugMessage).toHaveBeenCalledWith(
+ 'Ignored 1 files:\nGemini-ignored: dist/bundle.js',
+ );
+
+ expect(mockReadManyFilesExecute).toHaveBeenCalledWith(
+ {
+ paths: [validFile],
+ file_filtering_options: {
+ respect_git_ignore: true,
+ respect_gemini_ignore: true,
+ },
+ },
+ abortController.signal,
+ );
+
+ expect(result.processedQuery).toEqual([
+ { text: `@${validFile} @${geminiIgnoredFile}` },
+ { text: '\n--- Content from referenced files ---' },
+ { text: `\nContent from @${validFile}:\n` },
+ { text: fileContent },
+ { text: '\n--- End of content ---' },
+ ]);
+ expect(result.shouldProceed).toBe(true);
+ });
+ });
});
diff --git a/packages/cli/src/ui/hooks/atCommandProcessor.ts b/packages/cli/src/ui/hooks/atCommandProcessor.ts
index 7fe68f10..983abc62 100644
--- a/packages/cli/src/ui/hooks/atCommandProcessor.ts
+++ b/packages/cli/src/ui/hooks/atCommandProcessor.ts
@@ -136,12 +136,17 @@ export async function handleAtCommand({
// Get centralized file discovery service
const fileDiscovery = config.getFileService();
- const respectGitIgnore = config.getFileFilteringRespectGitIgnore();
+
+ const respectFileIgnore = config.getFileFilteringOptions();
const pathSpecsToRead: string[] = [];
const atPathToResolvedSpecMap = new Map<string, string>();
const contentLabelsForDisplay: string[] = [];
- const ignoredPaths: string[] = [];
+ const ignoredByReason: Record<string, string[]> = {
+ git: [],
+ gemini: [],
+ both: [],
+ };
const toolRegistry = await config.getToolRegistry();
const readManyFilesTool = toolRegistry.getTool('read_many_files');
@@ -182,10 +187,31 @@ export async function handleAtCommand({
}
// Check if path should be ignored based on filtering options
- if (fileDiscovery.shouldIgnoreFile(pathName, { respectGitIgnore })) {
- const reason = respectGitIgnore ? 'git-ignored' : 'custom-ignored';
- onDebugMessage(`Path ${pathName} is ${reason} and will be skipped.`);
- ignoredPaths.push(pathName);
+
+ const gitIgnored =
+ respectFileIgnore.respectGitIgnore &&
+ fileDiscovery.shouldIgnoreFile(pathName, {
+ respectGitIgnore: true,
+ respectGeminiIgnore: false,
+ });
+ const geminiIgnored =
+ respectFileIgnore.respectGeminiIgnore &&
+ fileDiscovery.shouldIgnoreFile(pathName, {
+ respectGitIgnore: false,
+ respectGeminiIgnore: true,
+ });
+
+ if (gitIgnored || geminiIgnored) {
+ const reason =
+ gitIgnored && geminiIgnored ? 'both' : gitIgnored ? 'git' : 'gemini';
+ ignoredByReason[reason].push(pathName);
+ const reasonText =
+ reason === 'both'
+ ? 'ignored by both git and gemini'
+ : reason === 'git'
+ ? 'git-ignored'
+ : 'gemini-ignored';
+ onDebugMessage(`Path ${pathName} is ${reasonText} and will be skipped.`);
continue;
}
@@ -319,11 +345,26 @@ export async function handleAtCommand({
initialQueryText = initialQueryText.trim();
// Inform user about ignored paths
- if (ignoredPaths.length > 0) {
- const ignoreType = respectGitIgnore ? 'git-ignored' : 'custom-ignored';
- onDebugMessage(
- `Ignored ${ignoredPaths.length} ${ignoreType} files: ${ignoredPaths.join(', ')}`,
- );
+ const totalIgnored =
+ ignoredByReason.git.length +
+ ignoredByReason.gemini.length +
+ ignoredByReason.both.length;
+
+ if (totalIgnored > 0) {
+ const messages = [];
+ if (ignoredByReason.git.length) {
+ messages.push(`Git-ignored: ${ignoredByReason.git.join(', ')}`);
+ }
+ if (ignoredByReason.gemini.length) {
+ messages.push(`Gemini-ignored: ${ignoredByReason.gemini.join(', ')}`);
+ }
+ if (ignoredByReason.both.length) {
+ messages.push(`Ignored by both: ${ignoredByReason.both.join(', ')}`);
+ }
+
+ const message = `Ignored ${totalIgnored} files:\n${messages.join('\n')}`;
+ console.log(message);
+ onDebugMessage(message);
}
// Fallback for lone "@" or completely invalid @-commands resulting in empty initialQueryText
@@ -347,7 +388,11 @@ export async function handleAtCommand({
const toolArgs = {
paths: pathSpecsToRead,
- respect_git_ignore: respectGitIgnore, // Use configuration setting
+ file_filtering_options: {
+ respect_git_ignore: respectFileIgnore.respectGitIgnore,
+ respect_gemini_ignore: respectFileIgnore.respectGeminiIgnore,
+ },
+ // Use configuration setting
};
let toolCallDisplay: IndividualToolCallDisplay;
diff --git a/packages/cli/src/ui/hooks/useCompletion.integration.test.ts b/packages/cli/src/ui/hooks/useCompletion.integration.test.ts
index 37075e3c..f6f0944b 100644
--- a/packages/cli/src/ui/hooks/useCompletion.integration.test.ts
+++ b/packages/cli/src/ui/hooks/useCompletion.integration.test.ts
@@ -14,7 +14,10 @@ import { CommandContext, SlashCommand } from '../commands/types.js';
import { Config, FileDiscoveryService } from '@google/gemini-cli-core';
interface MockConfig {
- getFileFilteringRespectGitIgnore: () => boolean;
+ getFileFilteringOptions: () => {
+ respectGitIgnore: boolean;
+ respectGeminiIgnore: boolean;
+ };
getEnableRecursiveFileSearch: () => boolean;
getFileService: () => FileDiscoveryService | null;
}
@@ -118,12 +121,16 @@ describe('useCompletion git-aware filtering integration', () => {
projectRoot: '',
gitIgnoreFilter: null,
geminiIgnoreFilter: null,
+ isFileIgnored: vi.fn(),
} as unknown as Mocked<FileDiscoveryService>;
mockConfig = {
- getFileFilteringRespectGitIgnore: vi.fn(() => true),
- getFileService: vi.fn().mockReturnValue(mockFileDiscoveryService),
+ getFileFilteringOptions: vi.fn(() => ({
+ respectGitIgnore: true,
+ respectGeminiIgnore: true,
+ })),
getEnableRecursiveFileSearch: vi.fn(() => true),
+ getFileService: vi.fn(() => mockFileDiscoveryService),
};
vi.mocked(FileDiscoveryService).mockImplementation(
@@ -186,7 +193,7 @@ describe('useCompletion git-aware filtering integration', () => {
{ name: '.env', isDirectory: () => false },
] as unknown as Awaited<ReturnType<typeof fs.readdir>>);
- // Mock git ignore service to ignore certain files
+ // Mock ignore service to ignore certain files
mockFileDiscoveryService.shouldGitIgnoreFile.mockImplementation(
(path: string) =>
path.includes('node_modules') ||
@@ -195,8 +202,17 @@ describe('useCompletion git-aware filtering integration', () => {
);
mockFileDiscoveryService.shouldIgnoreFile.mockImplementation(
(path: string, options) => {
- if (options?.respectGitIgnore !== false) {
- return mockFileDiscoveryService.shouldGitIgnoreFile(path);
+ if (
+ options?.respectGitIgnore &&
+ mockFileDiscoveryService.shouldGitIgnoreFile(path)
+ ) {
+ return true;
+ }
+ if (
+ options?.respectGeminiIgnore &&
+ mockFileDiscoveryService.shouldGeminiIgnoreFile
+ ) {
+ return mockFileDiscoveryService.shouldGeminiIgnoreFile(path);
}
return false;
},
@@ -231,38 +247,54 @@ describe('useCompletion git-aware filtering integration', () => {
it('should handle recursive search with git-aware filtering', async () => {
// Mock the recursive file search scenario
vi.mocked(fs.readdir).mockImplementation(
- async (dirPath: string | Buffer | URL) => {
- if (dirPath === testCwd) {
- return [
- { name: 'src', isDirectory: () => true },
- { name: 'node_modules', isDirectory: () => true },
- { name: 'temp', isDirectory: () => true },
- ] as Array<{ name: string; isDirectory: () => boolean }>;
- }
- if (dirPath.endsWith('/src')) {
- return [
- { name: 'index.ts', isDirectory: () => false },
- { name: 'components', isDirectory: () => true },
- ] as Array<{ name: string; isDirectory: () => boolean }>;
+ async (
+ dirPath: string | Buffer | URL,
+ options?: { withFileTypes?: boolean },
+ ) => {
+ const path = dirPath.toString();
+ if (options?.withFileTypes) {
+ if (path === testCwd) {
+ return [
+ { name: 'data', isDirectory: () => true },
+ { name: 'dist', isDirectory: () => true },
+ { name: 'node_modules', isDirectory: () => true },
+ { name: 'README.md', isDirectory: () => false },
+ { name: '.env', isDirectory: () => false },
+ ] as unknown as Awaited<ReturnType<typeof fs.readdir>>;
+ }
+ if (path.endsWith('/src')) {
+ return [
+ { name: 'index.ts', isDirectory: () => false },
+ { name: 'components', isDirectory: () => true },
+ ] as unknown as Awaited<ReturnType<typeof fs.readdir>>;
+ }
+ if (path.endsWith('/temp')) {
+ return [
+ { name: 'temp.log', isDirectory: () => false },
+ ] as unknown as Awaited<ReturnType<typeof fs.readdir>>;
+ }
}
- if (dirPath.endsWith('/temp')) {
- return [{ name: 'temp.log', isDirectory: () => false }] as Array<{
- name: string;
- isDirectory: () => boolean;
- }>;
- }
- return [] as Array<{ name: string; isDirectory: () => boolean }>;
+ return [];
},
);
- // Mock git ignore service
+ // Mock ignore service
mockFileDiscoveryService.shouldGitIgnoreFile.mockImplementation(
(path: string) => path.includes('node_modules') || path.includes('temp'),
);
mockFileDiscoveryService.shouldIgnoreFile.mockImplementation(
(path: string, options) => {
- if (options?.respectGitIgnore !== false) {
- return mockFileDiscoveryService.shouldGitIgnoreFile(path);
+ if (
+ options?.respectGitIgnore &&
+ mockFileDiscoveryService.shouldGitIgnoreFile(path)
+ ) {
+ return true;
+ }
+ if (
+ options?.respectGeminiIgnore &&
+ mockFileDiscoveryService.shouldGeminiIgnoreFile
+ ) {
+ return mockFileDiscoveryService.shouldGeminiIgnoreFile(path);
}
return false;
},
@@ -405,9 +437,12 @@ describe('useCompletion git-aware filtering integration', () => {
);
mockFileDiscoveryService.shouldIgnoreFile.mockImplementation(
(path: string, options) => {
- if (options?.respectGitIgnore !== false) {
+ if (options?.respectGitIgnore) {
return mockFileDiscoveryService.shouldGitIgnoreFile(path);
}
+ if (options?.respectGeminiIgnore) {
+ return mockFileDiscoveryService.shouldGeminiIgnoreFile(path);
+ }
return false;
},
);
diff --git a/packages/cli/src/ui/hooks/useCompletion.test.ts b/packages/cli/src/ui/hooks/useCompletion.test.ts
index 267bce13..f4227c1a 100644
--- a/packages/cli/src/ui/hooks/useCompletion.test.ts
+++ b/packages/cli/src/ui/hooks/useCompletion.test.ts
@@ -55,6 +55,10 @@ describe('useCompletion', () => {
getFileFilteringRespectGitIgnore: vi.fn(() => true),
getFileService: vi.fn().mockReturnValue(mockFileDiscoveryService),
getEnableRecursiveFileSearch: vi.fn(() => true),
+ getFileFilteringOptions: vi.fn(() => ({
+ respectGitIgnore: true,
+ respectGeminiIgnore: true,
+ })),
} as unknown as Mocked<Config>;
mockCommandContext = {} as CommandContext;
diff --git a/packages/cli/src/ui/hooks/useCompletion.ts b/packages/cli/src/ui/hooks/useCompletion.ts
index 81acc992..69d8bfb9 100644
--- a/packages/cli/src/ui/hooks/useCompletion.ts
+++ b/packages/cli/src/ui/hooks/useCompletion.ts
@@ -15,6 +15,7 @@ import {
getErrorMessage,
Config,
FileDiscoveryService,
+ DEFAULT_FILE_FILTERING_OPTIONS,
} from '@google/gemini-cli-core';
import {
MAX_SUGGESTIONS_TO_SHOW,
@@ -415,10 +416,8 @@ export function useCompletion(
const fileDiscoveryService = config ? config.getFileService() : null;
const enableRecursiveSearch =
config?.getEnableRecursiveFileSearch() ?? true;
- const filterOptions = {
- respectGitIgnore: config?.getFileFilteringRespectGitIgnore() ?? true,
- respectGeminiIgnore: true,
- };
+ const filterOptions =
+ config?.getFileFilteringOptions() ?? DEFAULT_FILE_FILTERING_OPTIONS;
try {
// If there's no slash, or it's the root, do a recursive search from cwd
diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts
index e34880a6..44300a83 100644
--- a/packages/core/src/config/config.test.ts
+++ b/packages/core/src/config/config.test.ts
@@ -331,6 +331,7 @@ describe('Server Config (config.ts)', () => {
config.getDebugMode(),
config.getFileService(),
config.getExtensionContextFilePaths(),
+ config.getFileFilteringOptions(),
);
expect(config.getUserMemory()).toBe(mockMemoryData.memoryContent);
diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts
index f81b3e32..9528f648 100644
--- a/packages/core/src/config/config.ts
+++ b/packages/core/src/config/config.ts
@@ -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);
diff --git a/packages/core/src/tools/ls.ts b/packages/core/src/tools/ls.ts
index fc4f06dd..68a69101 100644
--- a/packages/core/src/tools/ls.ts
+++ b/packages/core/src/tools/ls.ts
@@ -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 {
diff --git a/packages/core/src/tools/read-many-files.test.ts b/packages/core/src/tools/read-many-files.test.ts
index 3bb824cd..adad6efc 100644
--- a/packages/core/src/tools/read-many-files.test.ts
+++ b/packages/core/src/tools/read-many-files.test.ts
@@ -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;
diff --git a/packages/core/src/tools/read-many-files.ts b/packages/core/src/tools/read-many-files.ts
index 1c01ee9f..7c3be6e3 100644
--- a/packages/core/src/tools/read-many-files.ts
+++ b/packages/core/src/tools/read-many-files.ts
@@ -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) {
diff --git a/packages/core/src/utils/bfsFileSearch.test.ts b/packages/core/src/utils/bfsFileSearch.test.ts
index 83e9b0b9..3ce452de 100644
--- a/packages/core/src/utils/bfsFileSearch.test.ts
+++ b/packages/core/src/utils/bfsFileSearch.test.ts
@@ -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']);
+ });
});
diff --git a/packages/core/src/utils/bfsFileSearch.ts b/packages/core/src/utils/bfsFileSearch.ts
index e552f520..790521e0 100644
--- a/packages/core/src/utils/bfsFileSearch.ts
+++ b/packages/core/src/utils/bfsFileSearch.ts
@@ -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;
}
diff --git a/packages/core/src/utils/getFolderStructure.test.ts b/packages/core/src/utils/getFolderStructure.test.ts
index 3d7c125e..b6354745 100644
--- a/packages/core/src/utils/getFolderStructure.test.ts
+++ b/packages/core/src/utils/getFolderStructure.test.ts
@@ -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/...');
+ });
});
diff --git a/packages/core/src/utils/getFolderStructure.ts b/packages/core/src/utils/getFolderStructure.ts
index 6798a147..15588a4b 100644
--- a/packages/core/src/utils/getFolderStructure.ts
+++ b/packages/core/src/utils/getFolderStructure.ts
@@ -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 {
diff --git a/packages/core/src/utils/memoryDiscovery.ts b/packages/core/src/utils/memoryDiscovery.ts
index ab240ea8..33231823 100644
--- a/packages/core/src/utils/memoryDiscovery.ts
+++ b/packages/core/src/utils/memoryDiscovery.ts
@@ -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.');