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 }, + { respectGitIgnore: true, respectGeminiIgnore: false }, + ); + expect(mockFileDiscoveryService.shouldIgnoreFile).toHaveBeenCalledWith( + validFile, + { 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 }, + { respectGitIgnore: true, respectGeminiIgnore: false }, + ); + expect(mockFileDiscoveryService.shouldIgnoreFile).toHaveBeenCalledWith( + validFile, + { 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 }, + { respectGitIgnore: true, respectGeminiIgnore: false }, + ); + expect(mockFileDiscoveryService.shouldIgnoreFile).toHaveBeenCalledWith( + gitFile, + { 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(); const contentLabelsForDisplay: string[] = []; - const ignoredPaths: string[] = []; + const ignoredByReason: Record = { + 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; 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>); - // 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 }>; + 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>; + } + if (path.endsWith('/src')) { + return [ + { name: 'index.ts', isDirectory: () => false }, + { name: 'components', isDirectory: () => true }, + ] as unknown as Awaited>; + } + if (path.endsWith('/temp')) { + return [ + { name: 'temp.log', isDirectory: () => false }, + ] as unknown as Awaited>; + } } - if (dirPath.endsWith('/src')) { - return [ - { name: 'index.ts', isDirectory: () => false }, - { name: 'components', isDirectory: () => true }, - ] as Array<{ name: string; isDirectory: () => boolean }>; - } - 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; 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 { }, 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 { 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 { 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 { .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 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 > & { 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 { const allPaths = new Set(); 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.');