From adc8e11bb10c94adda645400e27fa7c5e7723276 Mon Sep 17 00:00:00 2001 From: Alisa <62909685+alisa-alisa@users.noreply.github.com> Date: Tue, 27 Jan 2026 17:19:13 -0800 Subject: [PATCH] Add support for an additional exclusion file besides .gitignore and .geminiignore (#16487) Co-authored-by: Adam Weidman --- docs/cli/settings.md | 17 +- docs/get-started/configuration.md | 8 + integration-tests/ripgrep-real.test.ts | 8 + packages/a2a-server/src/config/config.test.ts | 181 ++++++++------ packages/a2a-server/src/config/config.ts | 13 +- packages/a2a-server/src/config/settings.ts | 2 + packages/cli/src/config/config.test.ts | 5 + .../cli/src/config/settingsSchema.test.ts | 8 + packages/cli/src/config/settingsSchema.ts | 12 + .../src/ui/hooks/atCommandProcessor.test.ts | 7 +- .../cli/src/ui/hooks/useAtCompletion.test.ts | 17 +- packages/cli/src/ui/hooks/useAtCompletion.ts | 14 +- packages/core/src/config/config.test.ts | 50 ++++ packages/core/src/config/config.ts | 15 +- packages/core/src/config/constants.ts | 6 + packages/core/src/index.ts | 1 + .../src/services/fileDiscoveryService.test.ts | 187 ++++++++++++++- .../core/src/services/fileDiscoveryService.ts | 113 ++++++++- packages/core/src/tools/glob.test.ts | 9 +- packages/core/src/tools/ls.test.ts | 6 +- packages/core/src/tools/read-file.test.ts | 54 ++++- packages/core/src/tools/read-file.ts | 14 +- .../core/src/tools/read-many-files.test.ts | 5 +- packages/core/src/tools/ripGrep.test.ts | 92 ++++++- packages/core/src/tools/ripGrep.ts | 39 ++- packages/core/src/utils/bfsFileSearch.test.ts | 7 +- .../core/src/utils/filesearch/crawler.test.ts | 165 ++++++------- .../src/utils/filesearch/fileSearch.test.ts | 227 ++++++++++++------ .../core/src/utils/filesearch/fileSearch.ts | 14 +- .../core/src/utils/filesearch/ignore.test.ts | 62 +++-- packages/core/src/utils/filesearch/ignore.ts | 34 +-- .../core/src/utils/geminiIgnoreParser.test.ts | 134 ----------- packages/core/src/utils/geminiIgnoreParser.ts | 105 -------- .../core/src/utils/getFolderStructure.test.ts | 7 +- packages/core/src/utils/gitIgnoreParser.ts | 2 +- .../core/src/utils/ignoreFileParser.test.ts | 219 +++++++++++++++++ packages/core/src/utils/ignoreFileParser.ts | 129 ++++++++++ .../core/src/utils/memoryDiscovery.test.ts | 2 + packages/core/src/utils/tool-utils.test.ts | 6 +- schemas/settings.schema.json | 10 + 40 files changed, 1394 insertions(+), 612 deletions(-) delete mode 100644 packages/core/src/utils/geminiIgnoreParser.test.ts delete mode 100644 packages/core/src/utils/geminiIgnoreParser.ts create mode 100644 packages/core/src/utils/ignoreFileParser.test.ts create mode 100644 packages/core/src/utils/ignoreFileParser.ts diff --git a/docs/cli/settings.md b/docs/cli/settings.md index bd0b682bc8..b5421b581c 100644 --- a/docs/cli/settings.md +++ b/docs/cli/settings.md @@ -80,14 +80,15 @@ they appear in the UI. ### Context -| UI Label | Setting | Description | Default | -| ------------------------------------ | ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | ------- | -| Memory Discovery Max Dirs | `context.discoveryMaxDirs` | Maximum number of directories to search for memory. | `200` | -| Load Memory From Include Directories | `context.loadMemoryFromIncludeDirectories` | Controls how /memory refresh loads GEMINI.md files. When true, include directories are scanned; when false, only the current directory is used. | `false` | -| Respect .gitignore | `context.fileFiltering.respectGitIgnore` | Respect .gitignore files when searching. | `true` | -| Respect .geminiignore | `context.fileFiltering.respectGeminiIgnore` | Respect .geminiignore files when searching. | `true` | -| Enable Recursive File Search | `context.fileFiltering.enableRecursiveFileSearch` | Enable recursive file search functionality when completing @ references in the prompt. | `true` | -| Enable Fuzzy Search | `context.fileFiltering.enableFuzzySearch` | Enable fuzzy search when searching for files. | `true` | +| UI Label | Setting | Description | Default | +| ------------------------------------ | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | +| Memory Discovery Max Dirs | `context.discoveryMaxDirs` | Maximum number of directories to search for memory. | `200` | +| Load Memory From Include Directories | `context.loadMemoryFromIncludeDirectories` | Controls how /memory refresh loads GEMINI.md files. When true, include directories are scanned; when false, only the current directory is used. | `false` | +| Respect .gitignore | `context.fileFiltering.respectGitIgnore` | Respect .gitignore files when searching. | `true` | +| Respect .geminiignore | `context.fileFiltering.respectGeminiIgnore` | Respect .geminiignore files when searching. | `true` | +| Enable Recursive File Search | `context.fileFiltering.enableRecursiveFileSearch` | Enable recursive file search functionality when completing @ references in the prompt. | `true` | +| Enable Fuzzy Search | `context.fileFiltering.enableFuzzySearch` | Enable fuzzy search when searching for files. | `true` | +| Custom Ignore File Paths | `context.fileFiltering.customIgnoreFilePaths` | Additional ignore file paths to respect. These files take precedence over .geminiignore and .gitignore. Files earlier in the array take precedence over files later in the array, e.g. the first file takes precedence over the second one. | `[]` | ### Tools diff --git a/docs/get-started/configuration.md b/docs/get-started/configuration.md index f6e966ad0e..3a1e385cf7 100644 --- a/docs/get-started/configuration.md +++ b/docs/get-started/configuration.md @@ -616,6 +616,14 @@ their corresponding top-level category object in your `settings.json` file. - **Default:** `true` - **Requires restart:** Yes +- **`context.fileFiltering.customIgnoreFilePaths`** (array): + - **Description:** Additional ignore file paths to respect. These files take + precedence over .geminiignore and .gitignore. Files earlier in the array + take precedence over files later in the array, e.g. the first file takes + precedence over the second one. + - **Default:** `[]` + - **Requires restart:** Yes + #### `tools` - **`tools.sandbox`** (boolean | string): diff --git a/integration-tests/ripgrep-real.test.ts b/integration-tests/ripgrep-real.test.ts index 9c83e32270..fee76aedc1 100644 --- a/integration-tests/ripgrep-real.test.ts +++ b/integration-tests/ripgrep-real.test.ts @@ -32,6 +32,14 @@ class MockConfig { return true; } + getFileFilteringOptions() { + return { + respectGitIgnore: true, + respectGeminiIgnore: true, + customIgnoreFilePaths: [], + }; + } + validatePathAccess() { return null; } diff --git a/packages/a2a-server/src/config/config.test.ts b/packages/a2a-server/src/config/config.test.ts index 6ca5b049e5..06be9581a5 100644 --- a/packages/a2a-server/src/config/config.test.ts +++ b/packages/a2a-server/src/config/config.test.ts @@ -5,106 +5,127 @@ */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as path from 'node:path'; import { loadConfig } from './config.js'; -import type { ExtensionLoader } from '@google/gemini-cli-core'; import type { Settings } from './settings.js'; +import { + type ExtensionLoader, + FileDiscoveryService, +} from '@google/gemini-cli-core'; -const { - mockLoadServerHierarchicalMemory, - mockConfigConstructor, - mockVerifyGitAvailability, -} = vi.hoisted(() => ({ - mockLoadServerHierarchicalMemory: vi.fn().mockResolvedValue({ - memoryContent: '', - fileCount: 0, - filePaths: [], - }), - mockConfigConstructor: vi.fn(), - mockVerifyGitAvailability: vi.fn(), -})); +// Mock dependencies +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + Config: vi.fn().mockImplementation((params) => ({ + initialize: vi.fn(), + refreshAuth: vi.fn(), + ...params, // Expose params for assertion + })), + loadServerHierarchicalMemory: vi + .fn() + .mockResolvedValue({ memoryContent: '', fileCount: 0, filePaths: [] }), + startupProfiler: { + flush: vi.fn(), + }, + FileDiscoveryService: vi.fn(), + }; +}); -vi.mock('@google/gemini-cli-core', async () => ({ - Config: class MockConfig { - constructor(params: unknown) { - mockConfigConstructor(params); - } - initialize = vi.fn(); - refreshAuth = vi.fn(); - }, - loadServerHierarchicalMemory: mockLoadServerHierarchicalMemory, - startupProfiler: { - flush: vi.fn(), - }, - FileDiscoveryService: vi.fn(), - ApprovalMode: { DEFAULT: 'default', YOLO: 'yolo' }, - AuthType: { - LOGIN_WITH_GOOGLE: 'login_with_google', - USE_GEMINI: 'use_gemini', - }, - GEMINI_DIR: '.gemini', - DEFAULT_GEMINI_EMBEDDING_MODEL: 'models/embedding-001', - DEFAULT_GEMINI_MODEL: 'models/gemini-1.5-flash', - PREVIEW_GEMINI_MODEL: 'models/gemini-1.5-pro-latest', - homedir: () => '/tmp', - GitService: { - verifyGitAvailability: mockVerifyGitAvailability, +vi.mock('../utils/logger.js', () => ({ + logger: { + info: vi.fn(), + error: vi.fn(), }, })); describe('loadConfig', () => { - const mockSettings = { - checkpointing: { enabled: true }, - }; - const mockExtensionLoader = { - start: vi.fn(), - getExtensions: vi.fn().mockReturnValue([]), - } as unknown as ExtensionLoader; + const mockSettings = {} as Settings; + const mockExtensionLoader = {} as ExtensionLoader; + const taskId = 'test-task-id'; beforeEach(() => { - vi.resetAllMocks(); + vi.clearAllMocks(); process.env['GEMINI_API_KEY'] = 'test-key'; - // Reset the mock return value just in case - mockLoadServerHierarchicalMemory.mockResolvedValue({ - memoryContent: '', - fileCount: 0, - filePaths: [], - }); }); afterEach(() => { + delete process.env['CUSTOM_IGNORE_FILE_PATHS']; delete process.env['GEMINI_API_KEY']; - delete process.env['CHECKPOINTING']; }); - it('should disable checkpointing if git is not installed', async () => { - mockVerifyGitAvailability.mockResolvedValue(false); - - await loadConfig( - mockSettings as unknown as Settings, - mockExtensionLoader, - 'test-task', - ); - - expect(mockConfigConstructor).toHaveBeenCalledWith( - expect.objectContaining({ - checkpointing: false, - }), - ); + it('should set customIgnoreFilePaths when CUSTOM_IGNORE_FILE_PATHS env var is present', async () => { + const testPath = '/tmp/ignore'; + process.env['CUSTOM_IGNORE_FILE_PATHS'] = testPath; + const config = await loadConfig(mockSettings, mockExtensionLoader, taskId); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((config as any).fileFiltering.customIgnoreFilePaths).toEqual([ + testPath, + ]); }); - it('should enable checkpointing if git is installed', async () => { - mockVerifyGitAvailability.mockResolvedValue(true); + it('should set customIgnoreFilePaths when settings.fileFiltering.customIgnoreFilePaths is present', async () => { + const testPath = '/settings/ignore'; + const settings: Settings = { + fileFiltering: { + customIgnoreFilePaths: [testPath], + }, + }; + const config = await loadConfig(settings, mockExtensionLoader, taskId); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((config as any).fileFiltering.customIgnoreFilePaths).toEqual([ + testPath, + ]); + }); - await loadConfig( - mockSettings as unknown as Settings, - mockExtensionLoader, - 'test-task', - ); + it('should merge customIgnoreFilePaths from settings and env var', async () => { + const envPath = '/env/ignore'; + const settingsPath = '/settings/ignore'; + process.env['CUSTOM_IGNORE_FILE_PATHS'] = envPath; + const settings: Settings = { + fileFiltering: { + customIgnoreFilePaths: [settingsPath], + }, + }; + const config = await loadConfig(settings, mockExtensionLoader, taskId); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((config as any).fileFiltering.customIgnoreFilePaths).toEqual([ + settingsPath, + envPath, + ]); + }); - expect(mockConfigConstructor).toHaveBeenCalledWith( - expect.objectContaining({ - checkpointing: true, - }), - ); + it('should split CUSTOM_IGNORE_FILE_PATHS using system delimiter', async () => { + const paths = ['/path/one', '/path/two']; + process.env['CUSTOM_IGNORE_FILE_PATHS'] = paths.join(path.delimiter); + const config = await loadConfig(mockSettings, mockExtensionLoader, taskId); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((config as any).fileFiltering.customIgnoreFilePaths).toEqual(paths); + }); + + it('should have empty customIgnoreFilePaths when both are missing', async () => { + const config = await loadConfig(mockSettings, mockExtensionLoader, taskId); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((config as any).fileFiltering.customIgnoreFilePaths).toEqual([]); + }); + + it('should initialize FileDiscoveryService with correct options', async () => { + const testPath = '/tmp/ignore'; + process.env['CUSTOM_IGNORE_FILE_PATHS'] = testPath; + const settings: Settings = { + fileFiltering: { + respectGitIgnore: false, + }, + }; + + await loadConfig(settings, mockExtensionLoader, taskId); + + expect(FileDiscoveryService).toHaveBeenCalledWith(expect.any(String), { + respectGitIgnore: false, + respectGeminiIgnore: undefined, + customIgnoreFilePaths: [testPath], + }); }); }); diff --git a/packages/a2a-server/src/config/config.ts b/packages/a2a-server/src/config/config.ts index 732d6e2f84..12ab87439a 100644 --- a/packages/a2a-server/src/config/config.ts +++ b/packages/a2a-server/src/config/config.ts @@ -86,8 +86,15 @@ export async function loadConfig( // Git-aware file filtering settings fileFiltering: { respectGitIgnore: settings.fileFiltering?.respectGitIgnore, + respectGeminiIgnore: settings.fileFiltering?.respectGeminiIgnore, enableRecursiveFileSearch: settings.fileFiltering?.enableRecursiveFileSearch, + customIgnoreFilePaths: [ + ...(settings.fileFiltering?.customIgnoreFilePaths || []), + ...(process.env['CUSTOM_IGNORE_FILE_PATHS'] + ? process.env['CUSTOM_IGNORE_FILE_PATHS'].split(path.delimiter) + : []), + ], }, ideMode: false, folderTrust, @@ -100,7 +107,11 @@ export async function loadConfig( ptyInfo: 'auto', }; - const fileService = new FileDiscoveryService(workspaceDir); + const fileService = new FileDiscoveryService(workspaceDir, { + respectGitIgnore: configParams?.fileFiltering?.respectGitIgnore, + respectGeminiIgnore: configParams?.fileFiltering?.respectGeminiIgnore, + customIgnoreFilePaths: configParams?.fileFiltering?.customIgnoreFilePaths, + }); const { memoryContent, fileCount, filePaths } = await loadServerHierarchicalMemory( workspaceDir, diff --git a/packages/a2a-server/src/config/settings.ts b/packages/a2a-server/src/config/settings.ts index 7040a80d4e..f57e177681 100644 --- a/packages/a2a-server/src/config/settings.ts +++ b/packages/a2a-server/src/config/settings.ts @@ -38,7 +38,9 @@ export interface Settings { // Git-aware file filtering settings fileFiltering?: { respectGitIgnore?: boolean; + respectGeminiIgnore?: boolean; enableRecursiveFileSearch?: boolean; + customIgnoreFilePaths?: string[]; }; } diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index c8b9dcfb87..23688ec66a 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -126,10 +126,12 @@ vi.mock('@google/gemini-cli-core', async () => { DEFAULT_MEMORY_FILE_FILTERING_OPTIONS: { respectGitIgnore: false, respectGeminiIgnore: true, + customIgnoreFilePaths: [], }, DEFAULT_FILE_FILTERING_OPTIONS: { respectGitIgnore: true, respectGeminiIgnore: true, + customIgnoreFilePaths: [], }, createPolicyEngineConfig: vi.fn(async () => ({ rules: [], @@ -704,6 +706,9 @@ describe('loadCliConfig', () => { expect(config.getFileFilteringRespectGeminiIgnore()).toBe( DEFAULT_FILE_FILTERING_OPTIONS.respectGeminiIgnore, ); + expect(config.getCustomIgnoreFilePaths()).toEqual( + DEFAULT_FILE_FILTERING_OPTIONS.customIgnoreFilePaths, + ); expect(config.getApprovalMode()).toBe(ApprovalMode.DEFAULT); }); diff --git a/packages/cli/src/config/settingsSchema.test.ts b/packages/cli/src/config/settingsSchema.test.ts index 6aef68fc2e..6e55082edb 100644 --- a/packages/cli/src/config/settingsSchema.test.ts +++ b/packages/cli/src/config/settingsSchema.test.ts @@ -107,6 +107,14 @@ describe('SettingsSchema', () => { getSettingsSchema().context.properties.fileFiltering.properties ?.enableRecursiveFileSearch, ).toBeDefined(); + expect( + getSettingsSchema().context.properties.fileFiltering.properties + ?.customIgnoreFilePaths, + ).toBeDefined(); + expect( + getSettingsSchema().context.properties.fileFiltering.properties + ?.customIgnoreFilePaths.type, + ).toBe('array'); }); it('should have unique categories', () => { diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 3a25283249..6d108602df 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -932,6 +932,18 @@ const SETTINGS_SCHEMA = { description: 'Enable fuzzy search when searching for files.', showInDialog: true, }, + customIgnoreFilePaths: { + type: 'array', + label: 'Custom Ignore File Paths', + category: 'Context', + requiresRestart: true, + default: [] as string[], + description: + 'Additional ignore file paths to respect. These files take precedence over .geminiignore and .gitignore. Files earlier in the array take precedence over files later in the array, e.g. the first file takes precedence over the second one.', + showInDialog: true, + items: { type: 'string' }, + mergeStrategy: MergeStrategy.UNION, + }, }, }, }, diff --git a/packages/cli/src/ui/hooks/atCommandProcessor.test.ts b/packages/cli/src/ui/hooks/atCommandProcessor.test.ts index 509ce0e4b3..c7fb584477 100644 --- a/packages/cli/src/ui/hooks/atCommandProcessor.test.ts +++ b/packages/cli/src/ui/hooks/atCommandProcessor.test.ts @@ -15,6 +15,7 @@ import { StandardFileSystemService, ToolRegistry, COMMON_IGNORE_PATTERNS, + GEMINI_IGNORE_FILE_NAME, // DEFAULT_FILE_EXCLUDES, } from '@google/gemini-cli-core'; import * as core from '@google/gemini-cli-core'; @@ -628,7 +629,7 @@ describe('handleAtCommand', () => { describe('gemini-ignore filtering', () => { it('should skip gemini-ignored files in @ commands', async () => { await createTestFile( - path.join(testRootDir, '.geminiignore'), + path.join(testRootDir, GEMINI_IGNORE_FILE_NAME), 'build/output.js', ); const geminiIgnoredFile = await createTestFile( @@ -659,7 +660,7 @@ describe('handleAtCommand', () => { }); it('should process non-ignored files when .geminiignore is present', async () => { await createTestFile( - path.join(testRootDir, '.geminiignore'), + path.join(testRootDir, GEMINI_IGNORE_FILE_NAME), 'build/output.js', ); const validFile = await createTestFile( @@ -690,7 +691,7 @@ describe('handleAtCommand', () => { it('should handle mixed gemini-ignored and valid files', async () => { await createTestFile( - path.join(testRootDir, '.geminiignore'), + path.join(testRootDir, GEMINI_IGNORE_FILE_NAME), 'dist/bundle.js', ); const validFile = await createTestFile( diff --git a/packages/cli/src/ui/hooks/useAtCompletion.test.ts b/packages/cli/src/ui/hooks/useAtCompletion.test.ts index b05e9ff63b..7c41b7593d 100644 --- a/packages/cli/src/ui/hooks/useAtCompletion.test.ts +++ b/packages/cli/src/ui/hooks/useAtCompletion.test.ts @@ -10,7 +10,10 @@ import { renderHook } from '../../test-utils/render.js'; import { waitFor } from '../../test-utils/async.js'; import { useAtCompletion } from './useAtCompletion.js'; import type { Config, FileSearch } from '@google/gemini-cli-core'; -import { FileSearchFactory } from '@google/gemini-cli-core'; +import { + FileSearchFactory, + FileDiscoveryService, +} from '@google/gemini-cli-core'; import type { FileSystemStructure } from '@google/gemini-cli-test-utils'; import { createTmpDir, cleanupTmpDir } from '@google/gemini-cli-test-utils'; import type { Suggestion } from '../components/SuggestionsDisplay.js'; @@ -148,8 +151,10 @@ describe('useAtCompletion', () => { const fileSearch = FileSearchFactory.create({ projectRoot: testRootDir, ignoreDirs: [], - useGitignore: false, - useGeminiignore: false, + fileDiscoveryService: new FileDiscoveryService(testRootDir, { + respectGitIgnore: false, + respectGeminiIgnore: false, + }), cache: false, cacheTtl: 0, enableRecursiveFileSearch: true, @@ -271,8 +276,10 @@ describe('useAtCompletion', () => { const realFileSearch = FileSearchFactory.create({ projectRoot: testRootDir, ignoreDirs: [], - useGitignore: true, - useGeminiignore: true, + fileDiscoveryService: new FileDiscoveryService(testRootDir, { + respectGitIgnore: true, + respectGeminiIgnore: true, + }), cache: false, cacheTtl: 0, enableRecursiveFileSearch: true, diff --git a/packages/cli/src/ui/hooks/useAtCompletion.ts b/packages/cli/src/ui/hooks/useAtCompletion.ts index bea591d261..6b07691719 100644 --- a/packages/cli/src/ui/hooks/useAtCompletion.ts +++ b/packages/cli/src/ui/hooks/useAtCompletion.ts @@ -7,7 +7,11 @@ import { useEffect, useReducer, useRef } from 'react'; import { setTimeout as setTimeoutPromise } from 'node:timers/promises'; import type { Config, FileSearch } from '@google/gemini-cli-core'; -import { FileSearchFactory, escapePath } from '@google/gemini-cli-core'; +import { + FileSearchFactory, + escapePath, + FileDiscoveryService, +} from '@google/gemini-cli-core'; import type { Suggestion } from '../components/SuggestionsDisplay.js'; import { MAX_SUGGESTIONS_TO_SHOW } from '../components/SuggestionsDisplay.js'; import { CommandKind } from '../commands/types.js'; @@ -250,10 +254,10 @@ export function useAtCompletion(props: UseAtCompletionProps): void { const searcher = FileSearchFactory.create({ projectRoot: cwd, ignoreDirs: [], - useGitignore: - config?.getFileFilteringOptions()?.respectGitIgnore ?? true, - useGeminiignore: - config?.getFileFilteringOptions()?.respectGeminiIgnore ?? true, + fileDiscoveryService: new FileDiscoveryService( + cwd, + config?.getFileFilteringOptions(), + ), cache: true, cacheTtl: 30, // 30 seconds enableRecursiveFileSearch: diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index c5062c6d64..b6308974fb 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -13,6 +13,7 @@ import { debugLogger } from '../utils/debugLogger.js'; import { ApprovalMode } from '../policy/types.js'; import type { HookDefinition } from '../hooks/types.js'; import { HookType, HookEventName } from '../hooks/types.js'; +import { FileDiscoveryService } from '../services/fileDiscoveryService.js'; import * as path from 'node:path'; import * as fs from 'node:fs'; import { setGeminiMdFilename as mockSetGeminiMdFilename } from '../tools/memoryTool.js'; @@ -138,6 +139,8 @@ vi.mock('../services/gitService.js', () => { return { GitService: GitServiceMock }; }); +vi.mock('../services/fileDiscoveryService.js'); + vi.mock('../ide/ide-client.js', () => ({ IdeClient: { getInstance: vi.fn().mockResolvedValue({ @@ -623,6 +626,30 @@ describe('Server Config (config.ts)', () => { expect(config.getFileFilteringRespectGitIgnore()).toBe(false); }); + it('should set customIgnoreFilePaths from params', () => { + const params: ConfigParameters = { + ...baseParams, + fileFiltering: { + customIgnoreFilePaths: ['/path/to/ignore/file'], + }, + }; + const config = new Config(params); + expect(config.getCustomIgnoreFilePaths()).toStrictEqual([ + '/path/to/ignore/file', + ]); + }); + + it('should set customIgnoreFilePaths to empty array if not provided', () => { + const params: ConfigParameters = { + ...baseParams, + fileFiltering: { + respectGitIgnore: true, + }, + }; + const config = new Config(params); + expect(config.getCustomIgnoreFilePaths()).toStrictEqual([]); + }); + it('should initialize WorkspaceContext with includeDirectories', () => { const includeDirectories = ['dir1', 'dir2']; const paramsWithIncludeDirs: ConfigParameters = { @@ -699,6 +726,29 @@ describe('Server Config (config.ts)', () => { expect(fileService).toBeDefined(); }); + it('should pass file filtering options to FileDiscoveryService', () => { + const configParams = { + ...baseParams, + fileFiltering: { + respectGitIgnore: false, + respectGeminiIgnore: false, + customIgnoreFilePaths: ['.myignore'], + }, + }; + + const config = new Config(configParams); + config.getFileService(); + + expect(FileDiscoveryService).toHaveBeenCalledWith( + path.resolve(TARGET_DIR), + { + respectGitIgnore: false, + respectGeminiIgnore: false, + customIgnoreFilePaths: ['.myignore'], + }, + ); + }); + describe('Usage Statistics', () => { it('defaults usage statistics to enabled if not specified', () => { const config = new Config({ diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index d8905235c9..d432086155 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -328,6 +328,7 @@ export interface ConfigParameters { enableFuzzySearch?: boolean; maxFileCount?: number; searchTimeout?: number; + customIgnoreFilePaths?: string[]; }; checkpointing?: boolean; proxy?: string; @@ -465,6 +466,7 @@ export class Config { enableFuzzySearch: boolean; maxFileCount: number; searchTimeout: number; + customIgnoreFilePaths: string[]; }; private fileDiscoveryService: FileDiscoveryService | null = null; private gitService: GitService | undefined = undefined; @@ -631,6 +633,7 @@ export class Config { params.fileFiltering?.searchTimeout ?? DEFAULT_FILE_FILTERING_OPTIONS.searchTimeout ?? 5000, + customIgnoreFilePaths: params.fileFiltering?.customIgnoreFilePaths ?? [], }; this.checkpointing = params.checkpointing ?? false; this.proxy = params.proxy; @@ -1528,16 +1531,22 @@ export class Config { getFileFilteringRespectGitIgnore(): boolean { return this.fileFiltering.respectGitIgnore; } + getFileFilteringRespectGeminiIgnore(): boolean { return this.fileFiltering.respectGeminiIgnore; } + getCustomIgnoreFilePaths(): string[] { + return this.fileFiltering.customIgnoreFilePaths; + } + getFileFilteringOptions(): FileFilteringOptions { return { respectGitIgnore: this.fileFiltering.respectGitIgnore, respectGeminiIgnore: this.fileFiltering.respectGeminiIgnore, maxFileCount: this.fileFiltering.maxFileCount, searchTimeout: this.fileFiltering.searchTimeout, + customIgnoreFilePaths: this.fileFiltering.customIgnoreFilePaths, }; } @@ -1574,7 +1583,11 @@ export class Config { getFileService(): FileDiscoveryService { if (!this.fileDiscoveryService) { - this.fileDiscoveryService = new FileDiscoveryService(this.targetDir); + this.fileDiscoveryService = new FileDiscoveryService(this.targetDir, { + respectGitIgnore: this.fileFiltering.respectGitIgnore, + respectGeminiIgnore: this.fileFiltering.respectGeminiIgnore, + customIgnoreFilePaths: this.fileFiltering.customIgnoreFilePaths, + }); } return this.fileDiscoveryService; } diff --git a/packages/core/src/config/constants.ts b/packages/core/src/config/constants.ts index 9f3047a84f..d8fcb6885a 100644 --- a/packages/core/src/config/constants.ts +++ b/packages/core/src/config/constants.ts @@ -9,6 +9,7 @@ export interface FileFilteringOptions { respectGeminiIgnore: boolean; maxFileCount?: number; searchTimeout?: number; + customIgnoreFilePaths: string[]; } // For memory files @@ -17,6 +18,7 @@ export const DEFAULT_MEMORY_FILE_FILTERING_OPTIONS: FileFilteringOptions = { respectGeminiIgnore: true, maxFileCount: 20000, searchTimeout: 5000, + customIgnoreFilePaths: [], }; // For all other files @@ -25,4 +27,8 @@ export const DEFAULT_FILE_FILTERING_OPTIONS: FileFilteringOptions = { respectGeminiIgnore: true, maxFileCount: 20000, searchTimeout: 5000, + customIgnoreFilePaths: [], }; + +// Generic exclusion file name +export const GEMINI_IGNORE_FILE_NAME = '.geminiignore'; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index adc828f8ec..85d5004c4c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -8,6 +8,7 @@ export * from './config/config.js'; export * from './config/defaultModelConfigs.js'; export * from './config/models.js'; +export * from './config/constants.js'; export * from './output/types.js'; export * from './output/json-formatter.js'; export * from './output/stream-json-formatter.js'; diff --git a/packages/core/src/services/fileDiscoveryService.test.ts b/packages/core/src/services/fileDiscoveryService.test.ts index 173e114583..7fbdcdead8 100644 --- a/packages/core/src/services/fileDiscoveryService.test.ts +++ b/packages/core/src/services/fileDiscoveryService.test.ts @@ -4,11 +4,12 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import * as fs from 'node:fs/promises'; import * as os from 'node:os'; import * as path from 'node:path'; import { FileDiscoveryService } from './fileDiscoveryService.js'; +import { GEMINI_IGNORE_FILE_NAME } from '../config/constants.js'; describe('FileDiscoveryService', () => { let testRootDir: string; @@ -54,19 +55,66 @@ describe('FileDiscoveryService', () => { }); it('should load .geminiignore patterns even when not in a git repo', async () => { - await createTestFile('.geminiignore', 'secrets.txt'); + await createTestFile(GEMINI_IGNORE_FILE_NAME, 'secrets.txt'); const service = new FileDiscoveryService(projectRoot); expect(service.shouldIgnoreFile('secrets.txt')).toBe(true); expect(service.shouldIgnoreFile('src/index.js')).toBe(false); }); + + it('should call applyFilterFilesOptions in constructor', () => { + const resolveSpy = vi.spyOn( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + FileDiscoveryService.prototype as any, + 'applyFilterFilesOptions', + ); + const options = { respectGitIgnore: false }; + new FileDiscoveryService(projectRoot, options); + expect(resolveSpy).toHaveBeenCalledWith(options); + }); + + it('should correctly resolve options passed to constructor', () => { + const options = { + respectGitIgnore: false, + respectGeminiIgnore: false, + customIgnoreFilePaths: ['custom/.ignore'], + }; + const service = new FileDiscoveryService(projectRoot, options); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const defaults = (service as any).defaultFilterFileOptions; + + expect(defaults.respectGitIgnore).toBe(false); + expect(defaults.respectGeminiIgnore).toBe(false); + expect(defaults.customIgnoreFilePaths).toStrictEqual(['custom/.ignore']); + }); + + it('should use defaults when options are not provided', () => { + const service = new FileDiscoveryService(projectRoot); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const defaults = (service as any).defaultFilterFileOptions; + + expect(defaults.respectGitIgnore).toBe(true); + expect(defaults.respectGeminiIgnore).toBe(true); + expect(defaults.customIgnoreFilePaths).toStrictEqual([]); + }); + + it('should partially override defaults', () => { + const service = new FileDiscoveryService(projectRoot, { + respectGitIgnore: false, + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const defaults = (service as any).defaultFilterFileOptions; + + expect(defaults.respectGitIgnore).toBe(false); + expect(defaults.respectGeminiIgnore).toBe(true); + }); }); describe('filterFiles', () => { beforeEach(async () => { await fs.mkdir(path.join(projectRoot, '.git')); await createTestFile('.gitignore', 'node_modules/\n.git/\ndist'); - await createTestFile('.geminiignore', 'logs/'); + await createTestFile(GEMINI_IGNORE_FILE_NAME, 'logs/'); }); it('should filter out git-ignored and gemini-ignored files by default', () => { @@ -140,7 +188,7 @@ describe('FileDiscoveryService', () => { beforeEach(async () => { await fs.mkdir(path.join(projectRoot, '.git')); await createTestFile('.gitignore', 'node_modules/'); - await createTestFile('.geminiignore', '*.log'); + await createTestFile(GEMINI_IGNORE_FILE_NAME, '*.log'); }); it('should return filtered paths and correct ignored count', () => { @@ -177,7 +225,7 @@ describe('FileDiscoveryService', () => { beforeEach(async () => { await fs.mkdir(path.join(projectRoot, '.git')); await createTestFile('.gitignore', 'node_modules/'); - await createTestFile('.geminiignore', '*.log'); + await createTestFile(GEMINI_IGNORE_FILE_NAME, '*.log'); }); it('should return true for git-ignored files', () => { @@ -252,7 +300,7 @@ describe('FileDiscoveryService', () => { it('should un-ignore a file in .geminiignore that is ignored in .gitignore', async () => { await createTestFile('.gitignore', '*.txt'); - await createTestFile('.geminiignore', '!important.txt'); + await createTestFile(GEMINI_IGNORE_FILE_NAME, '!important.txt'); const service = new FileDiscoveryService(projectRoot); const files = ['file.txt', 'important.txt'].map((f) => @@ -265,7 +313,7 @@ describe('FileDiscoveryService', () => { it('should un-ignore a directory in .geminiignore that is ignored in .gitignore', async () => { await createTestFile('.gitignore', 'logs/'); - await createTestFile('.geminiignore', '!logs/'); + await createTestFile(GEMINI_IGNORE_FILE_NAME, '!logs/'); const service = new FileDiscoveryService(projectRoot); const files = ['logs/app.log', 'other/app.log'].map((f) => @@ -278,7 +326,7 @@ describe('FileDiscoveryService', () => { it('should extend ignore rules in .geminiignore', async () => { await createTestFile('.gitignore', '*.log'); - await createTestFile('.geminiignore', 'temp/'); + await createTestFile(GEMINI_IGNORE_FILE_NAME, 'temp/'); const service = new FileDiscoveryService(projectRoot); const files = ['app.log', 'temp/file.txt'].map((f) => @@ -291,7 +339,7 @@ describe('FileDiscoveryService', () => { it('should use .gitignore rules if respectGeminiIgnore is false', async () => { await createTestFile('.gitignore', '*.txt'); - await createTestFile('.geminiignore', '!important.txt'); + await createTestFile(GEMINI_IGNORE_FILE_NAME, '!important.txt'); const service = new FileDiscoveryService(projectRoot); const files = ['file.txt', 'important.txt'].map((f) => @@ -308,7 +356,7 @@ describe('FileDiscoveryService', () => { it('should use .geminiignore rules if respectGitIgnore is false', async () => { await createTestFile('.gitignore', '*.txt'); - await createTestFile('.geminiignore', '!important.txt\ntemp/'); + await createTestFile(GEMINI_IGNORE_FILE_NAME, '!important.txt\ntemp/'); const service = new FileDiscoveryService(projectRoot); const files = ['file.txt', 'important.txt', 'temp/file.js'].map((f) => @@ -328,4 +376,123 @@ describe('FileDiscoveryService', () => { ); }); }); + + describe('custom ignore file', () => { + it('should respect patterns from a custom ignore file', async () => { + const customIgnoreName = '.customignore'; + await createTestFile(customIgnoreName, '*.secret'); + + const service = new FileDiscoveryService(projectRoot, { + customIgnoreFilePaths: [customIgnoreName], + }); + + const files = ['file.txt', 'file.secret'].map((f) => + path.join(projectRoot, f), + ); + + const filtered = service.filterFiles(files); + expect(filtered).toEqual([path.join(projectRoot, 'file.txt')]); + }); + + it('should prioritize custom ignore patterns over .geminiignore patterns in git repo', async () => { + await fs.mkdir(path.join(projectRoot, '.git')); + await createTestFile('.gitignore', 'node_modules/'); + await createTestFile(GEMINI_IGNORE_FILE_NAME, '*.log'); + + const customIgnoreName = '.customignore'; + // .geminiignore ignores *.log, custom un-ignores debug.log + await createTestFile(customIgnoreName, '!debug.log'); + + const service = new FileDiscoveryService(projectRoot, { + customIgnoreFilePaths: [customIgnoreName], + }); + + const files = ['debug.log', 'error.log'].map((f) => + path.join(projectRoot, f), + ); + + const filtered = service.filterFiles(files); + expect(filtered).toEqual([path.join(projectRoot, 'debug.log')]); + }); + + it('should prioritize custom ignore patterns over .geminiignore patterns in non-git repo', async () => { + // No .git directory created + await createTestFile(GEMINI_IGNORE_FILE_NAME, 'secret.txt'); + + const customIgnoreName = '.customignore'; + // .geminiignore ignores secret.txt, custom un-ignores it + await createTestFile(customIgnoreName, '!secret.txt'); + + const service = new FileDiscoveryService(projectRoot, { + customIgnoreFilePaths: [customIgnoreName], + }); + + const files = ['secret.txt'].map((f) => path.join(projectRoot, f)); + + const filtered = service.filterFiles(files); + expect(filtered).toEqual([path.join(projectRoot, 'secret.txt')]); + }); + }); + + describe('getIgnoreFilePaths & getAllIgnoreFilePaths', () => { + beforeEach(async () => { + await fs.mkdir(path.join(projectRoot, '.git')); + await createTestFile('.gitignore', '*.log'); + await createTestFile(GEMINI_IGNORE_FILE_NAME, '*.tmp'); + await createTestFile('.customignore', '*.secret'); + }); + + it('should return .geminiignore path by default', () => { + const service = new FileDiscoveryService(projectRoot); + const paths = service.getIgnoreFilePaths(); + expect(paths).toEqual([path.join(projectRoot, GEMINI_IGNORE_FILE_NAME)]); + }); + + it('should not return .geminiignore path if respectGeminiIgnore is false', () => { + const service = new FileDiscoveryService(projectRoot, { + respectGeminiIgnore: false, + }); + const paths = service.getIgnoreFilePaths(); + expect(paths).toEqual([]); + }); + + it('should return custom ignore file paths', () => { + const service = new FileDiscoveryService(projectRoot, { + customIgnoreFilePaths: ['.customignore'], + }); + const paths = service.getIgnoreFilePaths(); + expect(paths).toContain(path.join(projectRoot, GEMINI_IGNORE_FILE_NAME)); + expect(paths).toContain(path.join(projectRoot, '.customignore')); + }); + + it('should return all ignore paths including .gitignore', () => { + const service = new FileDiscoveryService(projectRoot); + const paths = service.getAllIgnoreFilePaths(); + expect(paths).toContain(path.join(projectRoot, GEMINI_IGNORE_FILE_NAME)); + expect(paths).toContain(path.join(projectRoot, '.gitignore')); + }); + + it('should not return .gitignore if respectGitIgnore is false', () => { + const service = new FileDiscoveryService(projectRoot, { + respectGitIgnore: false, + }); + const paths = service.getAllIgnoreFilePaths(); + expect(paths).toContain(path.join(projectRoot, GEMINI_IGNORE_FILE_NAME)); + expect(paths).not.toContain(path.join(projectRoot, '.gitignore')); + }); + + it('should not return .gitignore if it does not exist', async () => { + await fs.rm(path.join(projectRoot, '.gitignore')); + const service = new FileDiscoveryService(projectRoot); + const paths = service.getAllIgnoreFilePaths(); + expect(paths).not.toContain(path.join(projectRoot, '.gitignore')); + expect(paths).toContain(path.join(projectRoot, GEMINI_IGNORE_FILE_NAME)); + }); + + it('should ensure .gitignore is the first file in the list', () => { + const service = new FileDiscoveryService(projectRoot); + const paths = service.getAllIgnoreFilePaths(); + expect(paths[0]).toBe(path.join(projectRoot, '.gitignore')); + }); + }); }); diff --git a/packages/core/src/services/fileDiscoveryService.ts b/packages/core/src/services/fileDiscoveryService.ts index 4ad2eb7552..44a28c1ff2 100644 --- a/packages/core/src/services/fileDiscoveryService.ts +++ b/packages/core/src/services/fileDiscoveryService.ts @@ -5,15 +5,18 @@ */ import type { GitIgnoreFilter } from '../utils/gitIgnoreParser.js'; -import type { GeminiIgnoreFilter } from '../utils/geminiIgnoreParser.js'; +import type { IgnoreFileFilter } from '../utils/ignoreFileParser.js'; import { GitIgnoreParser } from '../utils/gitIgnoreParser.js'; -import { GeminiIgnoreParser } from '../utils/geminiIgnoreParser.js'; +import { IgnoreFileParser } from '../utils/ignoreFileParser.js'; import { isGitRepository } from '../utils/gitUtils.js'; +import { GEMINI_IGNORE_FILE_NAME } from '../config/constants.js'; +import fs from 'node:fs'; import * as path from 'node:path'; export interface FilterFilesOptions { respectGitIgnore?: boolean; respectGeminiIgnore?: boolean; + customIgnoreFilePaths?: string[]; } export interface FilterReport { @@ -23,32 +26,83 @@ export interface FilterReport { export class FileDiscoveryService { private gitIgnoreFilter: GitIgnoreFilter | null = null; - private geminiIgnoreFilter: GeminiIgnoreFilter | null = null; - private combinedIgnoreFilter: GitIgnoreFilter | null = null; + private geminiIgnoreFilter: IgnoreFileFilter | null = null; + private customIgnoreFilter: IgnoreFileFilter | null = null; + private combinedIgnoreFilter: GitIgnoreFilter | IgnoreFileFilter | null = + null; + private defaultFilterFileOptions: FilterFilesOptions = { + respectGitIgnore: true, + respectGeminiIgnore: true, + customIgnoreFilePaths: [], + }; private projectRoot: string; - constructor(projectRoot: string) { + constructor(projectRoot: string, options?: FilterFilesOptions) { this.projectRoot = path.resolve(projectRoot); + this.applyFilterFilesOptions(options); if (isGitRepository(this.projectRoot)) { this.gitIgnoreFilter = new GitIgnoreParser(this.projectRoot); } - this.geminiIgnoreFilter = new GeminiIgnoreParser(this.projectRoot); + this.geminiIgnoreFilter = new IgnoreFileParser( + this.projectRoot, + GEMINI_IGNORE_FILE_NAME, + ); + if (this.defaultFilterFileOptions.customIgnoreFilePaths?.length) { + this.customIgnoreFilter = new IgnoreFileParser( + this.projectRoot, + this.defaultFilterFileOptions.customIgnoreFilePaths, + ); + } if (this.gitIgnoreFilter) { const geminiPatterns = this.geminiIgnoreFilter.getPatterns(); - // Create combined parser: .gitignore + .geminiignore + const customPatterns = this.customIgnoreFilter + ? this.customIgnoreFilter.getPatterns() + : []; + // Create combined parser: .gitignore + .geminiignore + custom ignore this.combinedIgnoreFilter = new GitIgnoreParser( this.projectRoot, - geminiPatterns, + // customPatterns should go the last to ensure overwriting of geminiPatterns + [...geminiPatterns, ...customPatterns], + ); + } else { + // Create combined parser when not git repo + const geminiPatterns = this.geminiIgnoreFilter.getPatterns(); + const customPatterns = this.customIgnoreFilter + ? this.customIgnoreFilter.getPatterns() + : []; + this.combinedIgnoreFilter = new IgnoreFileParser( + this.projectRoot, + [...geminiPatterns, ...customPatterns], + true, ); } } + private applyFilterFilesOptions(options?: FilterFilesOptions): void { + if (!options) return; + + if (options.respectGitIgnore !== undefined) { + this.defaultFilterFileOptions.respectGitIgnore = options.respectGitIgnore; + } + if (options.respectGeminiIgnore !== undefined) { + this.defaultFilterFileOptions.respectGeminiIgnore = + options.respectGeminiIgnore; + } + if (options.customIgnoreFilePaths) { + this.defaultFilterFileOptions.customIgnoreFilePaths = + options.customIgnoreFilePaths; + } + } + /** - * Filters a list of file paths based on git ignore rules + * Filters a list of file paths based on ignore rules */ filterFiles(filePaths: string[], options: FilterFilesOptions = {}): string[] { - const { respectGitIgnore = true, respectGeminiIgnore = true } = options; + const { + respectGitIgnore = this.defaultFilterFileOptions.respectGitIgnore, + respectGeminiIgnore = this.defaultFilterFileOptions.respectGeminiIgnore, + } = options; return filePaths.filter((filePath) => { if ( respectGitIgnore && @@ -58,6 +112,11 @@ export class FileDiscoveryService { return !this.combinedIgnoreFilter.isIgnored(filePath); } + // Always respect custom ignore filter if provided + if (this.customIgnoreFilter?.isIgnored(filePath)) { + return false; + } + if (respectGitIgnore && this.gitIgnoreFilter?.isIgnored(filePath)) { return false; } @@ -97,4 +156,38 @@ export class FileDiscoveryService { ): boolean { return this.filterFiles([filePath], options).length === 0; } + + /** + * Returns the list of ignore files being used (e.g. .geminiignore) excluding .gitignore. + */ + getIgnoreFilePaths(): string[] { + const paths: string[] = []; + if ( + this.geminiIgnoreFilter && + this.defaultFilterFileOptions.respectGeminiIgnore + ) { + paths.push(...this.geminiIgnoreFilter.getIgnoreFilePaths()); + } + if (this.customIgnoreFilter) { + paths.push(...this.customIgnoreFilter.getIgnoreFilePaths()); + } + return paths; + } + + /** + * Returns all ignore files including .gitignore if applicable. + */ + getAllIgnoreFilePaths(): string[] { + const paths: string[] = []; + if ( + this.gitIgnoreFilter && + this.defaultFilterFileOptions.respectGitIgnore + ) { + const gitIgnorePath = path.join(this.projectRoot, '.gitignore'); + if (fs.existsSync(gitIgnorePath)) { + paths.push(gitIgnorePath); + } + } + return paths.concat(this.getIgnoreFilePaths()); + } } diff --git a/packages/core/src/tools/glob.test.ts b/packages/core/src/tools/glob.test.ts index e96b3e7650..2aa4d52c7e 100644 --- a/packages/core/src/tools/glob.test.ts +++ b/packages/core/src/tools/glob.test.ts @@ -18,7 +18,10 @@ import { createMockWorkspaceContext } from '../test-utils/mockWorkspaceContext.j import { ToolErrorType } from './tool-error.js'; import * as glob from 'glob'; import { createMockMessageBus } from '../test-utils/mock-message-bus.js'; -import { DEFAULT_FILE_FILTERING_OPTIONS } from '../config/constants.js'; +import { + DEFAULT_FILE_FILTERING_OPTIONS, + GEMINI_IGNORE_FILE_NAME, +} from '../config/constants.js'; vi.mock('glob', { spy: true }); @@ -385,7 +388,7 @@ describe('GlobTool', () => { it('should respect .geminiignore files by default', async () => { await fs.writeFile( - path.join(tempRootDir, '.geminiignore'), + path.join(tempRootDir, GEMINI_IGNORE_FILE_NAME), 'gemini-ignored_test.txt', ); await fs.writeFile( @@ -423,7 +426,7 @@ describe('GlobTool', () => { it('should not respect .geminiignore when respect_gemini_ignore is false', async () => { await fs.writeFile( - path.join(tempRootDir, '.geminiignore'), + path.join(tempRootDir, GEMINI_IGNORE_FILE_NAME), 'gemini-ignored_test.txt', ); await fs.writeFile( diff --git a/packages/core/src/tools/ls.test.ts b/packages/core/src/tools/ls.test.ts index e358d24186..4bc57b8d32 100644 --- a/packages/core/src/tools/ls.test.ts +++ b/packages/core/src/tools/ls.test.ts @@ -15,6 +15,7 @@ import { FileDiscoveryService } from '../services/fileDiscoveryService.js'; import { ToolErrorType } from './tool-error.js'; import { WorkspaceContext } from '../utils/workspaceContext.js'; import { createMockMessageBus } from '../test-utils/mock-message-bus.js'; +import { GEMINI_IGNORE_FILE_NAME } from '../config/constants.js'; describe('LSTool', () => { let lsTool: LSTool; @@ -182,7 +183,10 @@ describe('LSTool', () => { it('should respect geminiignore patterns', async () => { await fs.writeFile(path.join(tempRootDir, 'file1.txt'), 'content1'); await fs.writeFile(path.join(tempRootDir, 'file2.log'), 'content1'); - await fs.writeFile(path.join(tempRootDir, '.geminiignore'), '*.log'); + await fs.writeFile( + path.join(tempRootDir, GEMINI_IGNORE_FILE_NAME), + '*.log', + ); const invocation = lsTool.build({ dir_path: tempRootDir }); const result = await invocation.execute(abortSignal); diff --git a/packages/core/src/tools/read-file.test.ts b/packages/core/src/tools/read-file.test.ts index 275b8fb25c..15071f2620 100644 --- a/packages/core/src/tools/read-file.test.ts +++ b/packages/core/src/tools/read-file.test.ts @@ -19,6 +19,7 @@ import { StandardFileSystemService } from '../services/fileSystemService.js'; import { createMockWorkspaceContext } from '../test-utils/mockWorkspaceContext.js'; import { WorkspaceContext } from '../utils/workspaceContext.js'; import { createMockMessageBus } from '../test-utils/mock-message-bus.js'; +import { GEMINI_IGNORE_FILE_NAME } from '../config/constants.js'; vi.mock('../telemetry/loggers.js', () => ({ logFileOperation: vi.fn(), @@ -438,7 +439,7 @@ describe('ReadFileTool', () => { describe('with .geminiignore', () => { beforeEach(async () => { await fsp.writeFile( - path.join(tempRootDir, '.geminiignore'), + path.join(tempRootDir, GEMINI_IGNORE_FILE_NAME), ['foo.*', 'ignored/'].join('\n'), ); const mockConfigInstance = { @@ -509,6 +510,57 @@ describe('ReadFileTool', () => { const invocation = tool.build(params); expect(typeof invocation).not.toBe('string'); }); + + it('should allow reading ignored files if respectGeminiIgnore is false', async () => { + const ignoredFilePath = path.join(tempRootDir, 'foo.bar'); + await fsp.writeFile(ignoredFilePath, 'content', 'utf-8'); + + const configNoIgnore = { + getFileService: () => new FileDiscoveryService(tempRootDir), + getFileSystemService: () => new StandardFileSystemService(), + getTargetDir: () => tempRootDir, + getWorkspaceContext: () => new WorkspaceContext(tempRootDir), + getFileFilteringOptions: () => ({ + respectGitIgnore: true, + respectGeminiIgnore: false, + }), + storage: { + getProjectTempDir: () => path.join(tempRootDir, '.temp'), + }, + isInteractive: () => false, + isPathAllowed(this: Config, absolutePath: string): boolean { + const workspaceContext = this.getWorkspaceContext(); + if (workspaceContext.isPathWithinWorkspace(absolutePath)) { + return true; + } + + const projectTempDir = this.storage.getProjectTempDir(); + return isSubpath(path.resolve(projectTempDir), absolutePath); + }, + validatePathAccess( + this: Config, + absolutePath: string, + ): string | null { + if (this.isPathAllowed(absolutePath)) { + return null; + } + + const workspaceDirs = this.getWorkspaceContext().getDirectories(); + const projectTempDir = this.storage.getProjectTempDir(); + return `Path not in workspace: Attempted path "${absolutePath}" resolves outside the allowed workspace directories: ${workspaceDirs.join(', ')} or the project temp directory: ${projectTempDir}`; + }, + } as unknown as Config; + + const toolNoIgnore = new ReadFileTool( + configNoIgnore, + createMockMessageBus(), + ); + const params: ReadFileToolParams = { + file_path: ignoredFilePath, + }; + const invocation = toolNoIgnore.build(params); + expect(typeof invocation).not.toBe('string'); + }); }); }); }); diff --git a/packages/core/src/tools/read-file.ts b/packages/core/src/tools/read-file.ts index d7a3684b51..2fa5772187 100644 --- a/packages/core/src/tools/read-file.ts +++ b/packages/core/src/tools/read-file.ts @@ -22,6 +22,7 @@ import { getProgrammingLanguage } from '../telemetry/telemetry-utils.js'; import { logFileOperation } from '../telemetry/loggers.js'; import { FileOperationEvent } from '../telemetry/types.js'; import { READ_FILE_TOOL_NAME } from './tool-names.js'; +import { FileDiscoveryService } from '../services/fileDiscoveryService.js'; /** * Parameters for the ReadFile tool @@ -159,6 +160,7 @@ export class ReadFileTool extends BaseDeclarativeTool< ToolResult > { static readonly Name = READ_FILE_TOOL_NAME; + private readonly fileDiscoveryService: FileDiscoveryService; constructor( private config: Config, @@ -193,6 +195,10 @@ export class ReadFileTool extends BaseDeclarativeTool< true, false, ); + this.fileDiscoveryService = new FileDiscoveryService( + config.getTargetDir(), + config.getFileFilteringOptions(), + ); } protected override validateToolParamValues( @@ -219,9 +225,13 @@ export class ReadFileTool extends BaseDeclarativeTool< return 'Limit must be a positive number'; } - const fileService = this.config.getFileService(); const fileFilteringOptions = this.config.getFileFilteringOptions(); - if (fileService.shouldIgnoreFile(resolvedPath, fileFilteringOptions)) { + if ( + this.fileDiscoveryService.shouldIgnoreFile( + resolvedPath, + fileFilteringOptions, + ) + ) { return `File path '${resolvedPath}' is ignored by configured ignore patterns.`; } diff --git a/packages/core/src/tools/read-many-files.test.ts b/packages/core/src/tools/read-many-files.test.ts index 6b2c13fdee..f340424a35 100644 --- a/packages/core/src/tools/read-many-files.test.ts +++ b/packages/core/src/tools/read-many-files.test.ts @@ -23,6 +23,7 @@ import { } from '../utils/ignorePatterns.js'; import * as glob from 'glob'; import { createMockMessageBus } from '../test-utils/mock-message-bus.js'; +import { GEMINI_IGNORE_FILE_NAME } from '../config/constants.js'; vi.mock('glob', { spy: true }); @@ -70,7 +71,7 @@ describe('ReadManyFilesTool', () => { tempDirOutsideRoot = fs.realpathSync( fs.mkdtempSync(path.join(os.tmpdir(), 'read-many-files-external-')), ); - fs.writeFileSync(path.join(tempRootDir, '.geminiignore'), 'foo.*'); + fs.writeFileSync(path.join(tempRootDir, GEMINI_IGNORE_FILE_NAME), 'foo.*'); const fileService = new FileDiscoveryService(tempRootDir); const mockConfig = { getFileService: () => fileService, @@ -79,6 +80,7 @@ describe('ReadManyFilesTool', () => { getFileFilteringOptions: () => ({ respectGitIgnore: true, respectGeminiIgnore: true, + customIgnoreFilePaths: [], }), getTargetDir: () => tempRootDir, getWorkspaceDirs: () => [tempRootDir], @@ -516,6 +518,7 @@ describe('ReadManyFilesTool', () => { getFileFilteringOptions: () => ({ respectGitIgnore: true, respectGeminiIgnore: true, + customIgnoreFilePaths: [], }), getWorkspaceContext: () => new WorkspaceContext(tempDir1, [tempDir2]), getTargetDir: () => tempDir1, diff --git a/packages/core/src/tools/ripGrep.test.ts b/packages/core/src/tools/ripGrep.test.ts index e1f610d3e3..944a320fa4 100644 --- a/packages/core/src/tools/ripGrep.test.ts +++ b/packages/core/src/tools/ripGrep.test.ts @@ -21,6 +21,7 @@ import fs from 'node:fs/promises'; import os from 'node:os'; import type { Config } from '../config/config.js'; import { Storage } from '../config/storage.js'; +import { GEMINI_IGNORE_FILE_NAME } from '../config/constants.js'; import { createMockWorkspaceContext } from '../test-utils/mockWorkspaceContext.js'; import type { ChildProcess } from 'node:child_process'; import { spawn } from 'node:child_process'; @@ -247,7 +248,17 @@ describe('RipGrepTool', () => { let ripgrepBinaryPath: string; let grepTool: RipGrepTool; const abortSignal = new AbortController().signal; - let mockConfig: Config; + + let mockConfig = { + getTargetDir: () => tempRootDir, + getWorkspaceContext: () => createMockWorkspaceContext(tempRootDir), + getDebugMode: () => false, + getFileFilteringRespectGeminiIgnore: () => true, + getFileFilteringOptions: () => ({ + respectGitIgnore: true, + respectGeminiIgnore: true, + }), + } as unknown as Config; beforeEach(async () => { downloadRipGrepMock.mockReset(); @@ -267,6 +278,10 @@ describe('RipGrepTool', () => { getWorkspaceContext: () => createMockWorkspaceContext(tempRootDir), getDebugMode: () => false, getFileFilteringRespectGeminiIgnore: () => true, + getFileFilteringOptions: () => ({ + respectGitIgnore: true, + respectGeminiIgnore: true, + }), storage: { getProjectTempDir: vi.fn().mockReturnValue('/tmp/project'), }, @@ -668,6 +683,57 @@ describe('RipGrepTool', () => { expect(result.returnDisplay).toContain('(limited)'); }, 10000); + it('should filter out files based on FileDiscoveryService even if ripgrep returns them', async () => { + // Create .geminiignore to ignore 'ignored.txt' + await fs.writeFile( + path.join(tempRootDir, GEMINI_IGNORE_FILE_NAME), + 'ignored.txt', + ); + + // Re-initialize tool so FileDiscoveryService loads the new .geminiignore + const toolWithIgnore = new RipGrepTool( + mockConfig, + createMockMessageBus(), + ); + + // Mock ripgrep returning both an ignored file and an allowed file + mockSpawn.mockImplementationOnce( + createMockSpawn({ + outputData: + JSON.stringify({ + type: 'match', + data: { + path: { text: 'ignored.txt' }, + line_number: 1, + lines: { text: 'should be ignored\n' }, + }, + }) + + '\n' + + JSON.stringify({ + type: 'match', + data: { + path: { text: 'allowed.txt' }, + line_number: 1, + lines: { text: 'should be kept\n' }, + }, + }) + + '\n', + exitCode: 0, + }), + ); + + const params: RipGrepToolParams = { pattern: 'should' }; + const invocation = toolWithIgnore.build(params); + const result = await invocation.execute(abortSignal); + + // Verify ignored file is filtered out + expect(result.llmContent).toContain('allowed.txt'); + expect(result.llmContent).toContain('should be kept'); + expect(result.llmContent).not.toContain('ignored.txt'); + expect(result.llmContent).not.toContain('should be ignored'); + expect(result.returnDisplay).toContain('Found 1 match'); + }); + it('should handle regex special characters correctly', async () => { // Setup specific mock for this test - regex pattern 'foo.*bar' should match 'const foo = "bar";' mockSpawn.mockImplementationOnce( @@ -779,6 +845,10 @@ describe('RipGrepTool', () => { createMockWorkspaceContext(tempRootDir, [secondDir]), getDebugMode: () => false, getFileFilteringRespectGeminiIgnore: () => true, + getFileFilteringOptions: () => ({ + respectGitIgnore: true, + respectGeminiIgnore: true, + }), storage: { getProjectTempDir: vi.fn().mockReturnValue('/tmp/project'), }, @@ -887,6 +957,10 @@ describe('RipGrepTool', () => { createMockWorkspaceContext(tempRootDir, [secondDir]), getDebugMode: () => false, getFileFilteringRespectGeminiIgnore: () => true, + getFileFilteringOptions: () => ({ + respectGitIgnore: true, + respectGeminiIgnore: true, + }), storage: { getProjectTempDir: vi.fn().mockReturnValue('/tmp/project'), }, @@ -1404,13 +1478,17 @@ describe('RipGrepTool', () => { }); it('should add .geminiignore when enabled and patterns exist', async () => { - const geminiIgnorePath = path.join(tempRootDir, '.geminiignore'); + const geminiIgnorePath = path.join(tempRootDir, GEMINI_IGNORE_FILE_NAME); await fs.writeFile(geminiIgnorePath, 'ignored.log'); const configWithGeminiIgnore = { getTargetDir: () => tempRootDir, getWorkspaceContext: () => createMockWorkspaceContext(tempRootDir), getDebugMode: () => false, getFileFilteringRespectGeminiIgnore: () => true, + getFileFilteringOptions: () => ({ + respectGitIgnore: true, + respectGeminiIgnore: true, + }), storage: { getProjectTempDir: vi.fn().mockReturnValue('/tmp/project'), }, @@ -1465,13 +1543,17 @@ describe('RipGrepTool', () => { }); it('should skip .geminiignore when disabled', async () => { - const geminiIgnorePath = path.join(tempRootDir, '.geminiignore'); + const geminiIgnorePath = path.join(tempRootDir, GEMINI_IGNORE_FILE_NAME); await fs.writeFile(geminiIgnorePath, 'ignored.log'); const configWithoutGeminiIgnore = { getTargetDir: () => tempRootDir, getWorkspaceContext: () => createMockWorkspaceContext(tempRootDir), getDebugMode: () => false, getFileFilteringRespectGeminiIgnore: () => false, + getFileFilteringOptions: () => ({ + respectGitIgnore: true, + respectGeminiIgnore: false, + }), storage: { getProjectTempDir: vi.fn().mockReturnValue('/tmp/project'), }, @@ -1618,6 +1700,10 @@ describe('RipGrepTool', () => { getWorkspaceContext: () => createMockWorkspaceContext(tempRootDir, ['/another/dir']), getDebugMode: () => false, + getFileFilteringOptions: () => ({ + respectGitIgnore: true, + respectGeminiIgnore: true, + }), storage: { getProjectTempDir: vi.fn().mockReturnValue('/tmp/project'), }, diff --git a/packages/core/src/tools/ripGrep.ts b/packages/core/src/tools/ripGrep.ts index 4752edf9b9..9b9b5e60aa 100644 --- a/packages/core/src/tools/ripGrep.ts +++ b/packages/core/src/tools/ripGrep.ts @@ -23,7 +23,7 @@ import { FileExclusions, COMMON_DIRECTORY_EXCLUDES, } from '../utils/ignorePatterns.js'; -import { GeminiIgnoreParser } from '../utils/geminiIgnoreParser.js'; +import { FileDiscoveryService } from '../services/fileDiscoveryService.js'; import { execStreaming } from '../utils/shell-utils.js'; import { DEFAULT_TOTAL_MAX_MATCHES, @@ -148,7 +148,7 @@ class GrepToolInvocation extends BaseToolInvocation< > { constructor( private readonly config: Config, - private readonly geminiIgnoreParser: GeminiIgnoreParser, + private readonly fileDiscoveryService: FileDiscoveryService, params: RipGrepToolParams, messageBus: MessageBus, _toolName?: string, @@ -243,6 +243,21 @@ class GrepToolInvocation extends BaseToolInvocation< signal.removeEventListener('abort', onAbort); } + if (!this.params.no_ignore) { + const uniqueFiles = Array.from( + new Set(allMatches.map((m) => m.filePath)), + ); + const absoluteFilePaths = uniqueFiles.map((f) => + path.resolve(searchDirAbs, f), + ); + const allowedFiles = + this.fileDiscoveryService.filterFiles(absoluteFilePaths); + const allowedSet = new Set(allowedFiles); + allMatches = allMatches.filter((m) => + allowedSet.has(path.resolve(searchDirAbs, m.filePath)), + ); + } + const searchLocationDescription = `in path "${searchDirDisplay}"`; if (allMatches.length === 0) { const noMatchMsg = `No matches found for pattern "${this.params.pattern}" ${searchLocationDescription}${this.params.include ? ` (filter: "${this.params.include}")` : ''}.`; @@ -361,12 +376,11 @@ class GrepToolInvocation extends BaseToolInvocation< rgArgs.push('--glob', `!${exclude}`); }); - if (this.config.getFileFilteringRespectGeminiIgnore()) { - // Add .geminiignore support (ripgrep natively handles .gitignore) - const geminiIgnorePath = this.geminiIgnoreParser.getIgnoreFilePath(); - if (geminiIgnorePath) { - rgArgs.push('--ignore-file', geminiIgnorePath); - } + // Add .geminiignore and custom ignore files support (if provided/mandated) + // (ripgrep natively handles .gitignore) + const geminiIgnorePaths = this.fileDiscoveryService.getIgnoreFilePaths(); + for (const ignorePath of geminiIgnorePaths) { + rgArgs.push('--ignore-file', ignorePath); } } @@ -472,7 +486,7 @@ export class RipGrepTool extends BaseDeclarativeTool< ToolResult > { static readonly Name = GREP_TOOL_NAME; - private readonly geminiIgnoreParser: GeminiIgnoreParser; + private readonly fileDiscoveryService: FileDiscoveryService; constructor( private readonly config: Config, @@ -538,7 +552,10 @@ export class RipGrepTool extends BaseDeclarativeTool< true, // isOutputMarkdown false, // canUpdateOutput ); - this.geminiIgnoreParser = new GeminiIgnoreParser(config.getTargetDir()); + this.fileDiscoveryService = new FileDiscoveryService( + config.getTargetDir(), + config.getFileFilteringOptions(), + ); } /** @@ -591,7 +608,7 @@ export class RipGrepTool extends BaseDeclarativeTool< ): ToolInvocation { return new GrepToolInvocation( this.config, - this.geminiIgnoreParser, + this.fileDiscoveryService, params, messageBus ?? this.messageBus, _toolName, diff --git a/packages/core/src/utils/bfsFileSearch.test.ts b/packages/core/src/utils/bfsFileSearch.test.ts index c194090fb6..22e4ed6795 100644 --- a/packages/core/src/utils/bfsFileSearch.test.ts +++ b/packages/core/src/utils/bfsFileSearch.test.ts @@ -10,6 +10,7 @@ import * as path from 'node:path'; import * as os from 'node:os'; import { bfsFileSearch, bfsFileSearchSync } from './bfsFileSearch.js'; import { FileDiscoveryService } from '../services/fileDiscoveryService.js'; +import { GEMINI_IGNORE_FILE_NAME } from 'src/config/constants.js'; describe('bfsFileSearch', () => { let testRootDir: string; @@ -131,6 +132,7 @@ describe('bfsFileSearch', () => { fileFilteringOptions: { respectGitIgnore: true, respectGeminiIgnore: true, + customIgnoreFilePaths: [], }, }); @@ -138,7 +140,7 @@ describe('bfsFileSearch', () => { }); it('should ignore geminiignored files', async () => { - await createTestFile('node_modules/', 'project', '.geminiignore'); + await createTestFile('node_modules/', 'project', GEMINI_IGNORE_FILE_NAME); await createTestFile('content', 'project', 'node_modules', 'target.txt'); const targetFilePath = await createTestFile( 'content', @@ -154,6 +156,7 @@ describe('bfsFileSearch', () => { fileFilteringOptions: { respectGitIgnore: false, respectGeminiIgnore: true, + customIgnoreFilePaths: [], }, }); @@ -183,6 +186,7 @@ describe('bfsFileSearch', () => { fileFilteringOptions: { respectGitIgnore: false, respectGeminiIgnore: false, + customIgnoreFilePaths: [], }, }); @@ -316,6 +320,7 @@ describe('bfsFileSearchSync', () => { fileFilteringOptions: { respectGitIgnore: true, respectGeminiIgnore: true, + customIgnoreFilePaths: [], }, }); diff --git a/packages/core/src/utils/filesearch/crawler.test.ts b/packages/core/src/utils/filesearch/crawler.test.ts index bf1ccea209..192c0274b8 100644 --- a/packages/core/src/utils/filesearch/crawler.test.ts +++ b/packages/core/src/utils/filesearch/crawler.test.ts @@ -12,6 +12,8 @@ import { crawl } from './crawler.js'; import { createTmpDir, cleanupTmpDir } from '@google/gemini-cli-test-utils'; import type { Ignore } from './ignore.js'; import { loadIgnoreRules } from './ignore.js'; +import { GEMINI_IGNORE_FILE_NAME } from '../../config/constants.js'; +import { FileDiscoveryService } from '../../services/fileDiscoveryService.js'; describe('crawler', () => { let tmpDir: string; @@ -24,17 +26,16 @@ describe('crawler', () => { it('should use .geminiignore rules', async () => { tmpDir = await createTmpDir({ - '.geminiignore': 'dist/', + [GEMINI_IGNORE_FILE_NAME]: 'dist/', dist: ['ignored.js'], src: ['not-ignored.js'], }); - const ignore = loadIgnoreRules({ - projectRoot: tmpDir, - useGitignore: false, - useGeminiignore: true, - ignoreDirs: [], + const service = new FileDiscoveryService(tmpDir, { + respectGitIgnore: false, + respectGeminiIgnore: true, }); + const ignore = loadIgnoreRules(service, []); const results = await crawl({ crawlDirectory: tmpDir, @@ -48,7 +49,7 @@ describe('crawler', () => { expect.arrayContaining([ '.', 'src/', - '.geminiignore', + GEMINI_IGNORE_FILE_NAME, 'src/not-ignored.js', ]), ); @@ -56,19 +57,19 @@ describe('crawler', () => { it('should combine .gitignore and .geminiignore rules', async () => { tmpDir = await createTmpDir({ + '.git': {}, '.gitignore': 'dist/', - '.geminiignore': 'build/', + [GEMINI_IGNORE_FILE_NAME]: 'build/', dist: ['ignored-by-git.js'], build: ['ignored-by-gemini.js'], src: ['not-ignored.js'], }); - const ignore = loadIgnoreRules({ - projectRoot: tmpDir, - useGitignore: true, - useGeminiignore: true, - ignoreDirs: [], + const service = new FileDiscoveryService(tmpDir, { + respectGitIgnore: true, + respectGeminiIgnore: true, }); + const ignore = loadIgnoreRules(service, []); const results = await crawl({ crawlDirectory: tmpDir, @@ -82,7 +83,7 @@ describe('crawler', () => { expect.arrayContaining([ '.', 'src/', - '.geminiignore', + GEMINI_IGNORE_FILE_NAME, '.gitignore', 'src/not-ignored.js', ]), @@ -95,12 +96,11 @@ describe('crawler', () => { src: ['main.js'], }); - const ignore = loadIgnoreRules({ - projectRoot: tmpDir, - useGitignore: false, - useGeminiignore: false, - ignoreDirs: ['logs'], + const service = new FileDiscoveryService(tmpDir, { + respectGitIgnore: false, + respectGeminiIgnore: false, }); + const ignore = loadIgnoreRules(service, ['logs']); const results = await crawl({ crawlDirectory: tmpDir, @@ -117,6 +117,7 @@ describe('crawler', () => { it('should handle negated directories', async () => { tmpDir = await createTmpDir({ + '.git': {}, '.gitignore': ['build/**', '!build/public', '!build/public/**'].join( '\n', ), @@ -127,12 +128,11 @@ describe('crawler', () => { src: ['main.js'], }); - const ignore = loadIgnoreRules({ - projectRoot: tmpDir, - useGitignore: true, - useGeminiignore: false, - ignoreDirs: [], + const service = new FileDiscoveryService(tmpDir, { + respectGitIgnore: true, + respectGeminiIgnore: false, }); + const ignore = loadIgnoreRules(service, []); const results = await crawl({ crawlDirectory: tmpDir, @@ -157,17 +157,17 @@ describe('crawler', () => { it('should handle root-level file negation', async () => { tmpDir = await createTmpDir({ + '.git': {}, '.gitignore': ['*.mk', '!Foo.mk'].join('\n'), 'bar.mk': '', 'Foo.mk': '', }); - const ignore = loadIgnoreRules({ - projectRoot: tmpDir, - useGitignore: true, - useGeminiignore: false, - ignoreDirs: [], + const service = new FileDiscoveryService(tmpDir, { + respectGitIgnore: true, + respectGeminiIgnore: false, }); + const ignore = loadIgnoreRules(service, []); const results = await crawl({ crawlDirectory: tmpDir, @@ -184,6 +184,7 @@ describe('crawler', () => { it('should handle directory negation with glob', async () => { tmpDir = await createTmpDir({ + '.git': {}, '.gitignore': [ 'third_party/**', '!third_party/foo', @@ -200,12 +201,11 @@ describe('crawler', () => { }, }); - const ignore = loadIgnoreRules({ - projectRoot: tmpDir, - useGitignore: true, - useGeminiignore: false, - ignoreDirs: [], + const service = new FileDiscoveryService(tmpDir, { + respectGitIgnore: true, + respectGeminiIgnore: false, }); + const ignore = loadIgnoreRules(service, []); const results = await crawl({ crawlDirectory: tmpDir, @@ -229,17 +229,17 @@ describe('crawler', () => { it('should correctly handle negated patterns in .gitignore', async () => { tmpDir = await createTmpDir({ + '.git': {}, '.gitignore': ['dist/**', '!dist/keep.js'].join('\n'), dist: ['ignore.js', 'keep.js'], src: ['main.js'], }); - const ignore = loadIgnoreRules({ - projectRoot: tmpDir, - useGitignore: true, - useGeminiignore: false, - ignoreDirs: [], + const service = new FileDiscoveryService(tmpDir, { + respectGitIgnore: true, + respectGeminiIgnore: false, }); + const ignore = loadIgnoreRules(service, []); const results = await crawl({ crawlDirectory: tmpDir, @@ -266,12 +266,11 @@ describe('crawler', () => { src: ['file1.js'], }); - const ignore = loadIgnoreRules({ - projectRoot: tmpDir, - useGitignore: true, - useGeminiignore: true, - ignoreDirs: [], + const service = new FileDiscoveryService(tmpDir, { + respectGitIgnore: true, + respectGeminiIgnore: true, }); + const ignore = loadIgnoreRules(service, []); const results = await crawl({ crawlDirectory: tmpDir, @@ -287,16 +286,16 @@ describe('crawler', () => { it('should handle empty or commented-only ignore files', async () => { tmpDir = await createTmpDir({ + '.git': {}, '.gitignore': '# This is a comment\n\n \n', src: ['main.js'], }); - const ignore = loadIgnoreRules({ - projectRoot: tmpDir, - useGitignore: true, - useGeminiignore: false, - ignoreDirs: [], + const service = new FileDiscoveryService(tmpDir, { + respectGitIgnore: true, + respectGeminiIgnore: false, }); + const ignore = loadIgnoreRules(service, []); const results = await crawl({ crawlDirectory: tmpDir, @@ -317,12 +316,11 @@ describe('crawler', () => { src: ['main.js'], }); - const ignore = loadIgnoreRules({ - projectRoot: tmpDir, - useGitignore: false, - useGeminiignore: false, - ignoreDirs: [], + const service = new FileDiscoveryService(tmpDir, { + respectGitIgnore: false, + respectGeminiIgnore: false, }); + const ignore = loadIgnoreRules(service, []); const results = await crawl({ crawlDirectory: tmpDir, @@ -349,12 +347,11 @@ describe('crawler', () => { it('should hit the cache for subsequent crawls', async () => { tmpDir = await createTmpDir({ 'file1.js': '' }); - const ignore = loadIgnoreRules({ - projectRoot: tmpDir, - useGitignore: false, - useGeminiignore: false, - ignoreDirs: [], + const service = new FileDiscoveryService(tmpDir, { + respectGitIgnore: false, + respectGeminiIgnore: false, }); + const ignore = loadIgnoreRules(service, []); const options = { crawlDirectory: tmpDir, cwd: tmpDir, @@ -382,17 +379,19 @@ describe('crawler', () => { it('should miss the cache when ignore rules change', async () => { tmpDir = await createTmpDir({ + '.git': {}, '.gitignore': 'a.txt', 'a.txt': '', 'b.txt': '', }); const getIgnore = () => - loadIgnoreRules({ - projectRoot: tmpDir, - useGitignore: true, - useGeminiignore: false, - ignoreDirs: [], - }); + loadIgnoreRules( + new FileDiscoveryService(tmpDir, { + respectGitIgnore: true, + respectGeminiIgnore: false, + }), + [], + ); const getOptions = (ignore: Ignore) => ({ crawlDirectory: tmpDir, cwd: tmpDir, @@ -421,12 +420,11 @@ describe('crawler', () => { it('should miss the cache after TTL expires', async () => { tmpDir = await createTmpDir({ 'file1.js': '' }); - const ignore = loadIgnoreRules({ - projectRoot: tmpDir, - useGitignore: false, - useGeminiignore: false, - ignoreDirs: [], + const service = new FileDiscoveryService(tmpDir, { + respectGitIgnore: false, + respectGeminiIgnore: false, }); + const ignore = loadIgnoreRules(service, []); const options = { crawlDirectory: tmpDir, cwd: tmpDir, @@ -452,12 +450,11 @@ describe('crawler', () => { it('should miss the cache when maxDepth changes', async () => { tmpDir = await createTmpDir({ 'file1.js': '' }); - const ignore = loadIgnoreRules({ - projectRoot: tmpDir, - useGitignore: false, - useGeminiignore: false, - ignoreDirs: [], + const service = new FileDiscoveryService(tmpDir, { + respectGitIgnore: false, + respectGeminiIgnore: false, }); + const ignore = loadIgnoreRules(service, []); const getOptions = (maxDepth?: number) => ({ crawlDirectory: tmpDir, cwd: tmpDir, @@ -504,12 +501,11 @@ describe('crawler', () => { }); const getCrawlResults = async (maxDepth?: number) => { - const ignore = loadIgnoreRules({ - projectRoot: tmpDir, - useGitignore: false, - useGeminiignore: false, - ignoreDirs: [], + const service = new FileDiscoveryService(tmpDir, { + respectGitIgnore: false, + respectGeminiIgnore: false, }); + const ignore = loadIgnoreRules(service, []); const paths = await crawl({ crawlDirectory: tmpDir, cwd: tmpDir, @@ -580,12 +576,11 @@ describe('crawler', () => { 'file3.js': '', }); - const ignore = loadIgnoreRules({ - projectRoot: tmpDir, - useGitignore: false, - useGeminiignore: false, - ignoreDirs: [], + const service = new FileDiscoveryService(tmpDir, { + respectGitIgnore: false, + respectGeminiIgnore: false, }); + const ignore = loadIgnoreRules(service, []); const paths = await crawl({ crawlDirectory: tmpDir, diff --git a/packages/core/src/utils/filesearch/fileSearch.test.ts b/packages/core/src/utils/filesearch/fileSearch.test.ts index 6566b8394d..3c2506cb13 100644 --- a/packages/core/src/utils/filesearch/fileSearch.test.ts +++ b/packages/core/src/utils/filesearch/fileSearch.test.ts @@ -8,6 +8,8 @@ import { describe, it, expect, afterEach, vi } from 'vitest'; import { FileSearchFactory, AbortError, filter } from './fileSearch.js'; import { createTmpDir, cleanupTmpDir } from '@google/gemini-cli-test-utils'; import * as crawler from './crawler.js'; +import { GEMINI_IGNORE_FILE_NAME } from '../../config/constants.js'; +import { FileDiscoveryService } from '../../services/fileDiscoveryService.js'; describe('FileSearch', () => { let tmpDir: string; @@ -20,41 +22,17 @@ describe('FileSearch', () => { it('should use .geminiignore rules', async () => { tmpDir = await createTmpDir({ - '.geminiignore': 'dist/', + [GEMINI_IGNORE_FILE_NAME]: 'dist/', dist: ['ignored.js'], src: ['not-ignored.js'], }); const fileSearch = FileSearchFactory.create({ projectRoot: tmpDir, - useGitignore: false, - useGeminiignore: true, - ignoreDirs: [], - cache: false, - cacheTtl: 0, - enableRecursiveFileSearch: true, - enableFuzzySearch: true, - }); - - await fileSearch.initialize(); - const results = await fileSearch.search(''); - - expect(results).toEqual(['src/', '.geminiignore', 'src/not-ignored.js']); - }); - - it('should combine .gitignore and .geminiignore rules', async () => { - tmpDir = await createTmpDir({ - '.gitignore': 'dist/', - '.geminiignore': 'build/', - dist: ['ignored-by-git.js'], - build: ['ignored-by-gemini.js'], - src: ['not-ignored.js'], - }); - - const fileSearch = FileSearchFactory.create({ - projectRoot: tmpDir, - useGitignore: true, - useGeminiignore: true, + fileDiscoveryService: new FileDiscoveryService(tmpDir, { + respectGitIgnore: false, + respectGeminiIgnore: true, + }), ignoreDirs: [], cache: false, cacheTtl: 0, @@ -67,7 +45,40 @@ describe('FileSearch', () => { expect(results).toEqual([ 'src/', - '.geminiignore', + GEMINI_IGNORE_FILE_NAME, + 'src/not-ignored.js', + ]); + }); + + it('should combine .gitignore and .geminiignore rules', async () => { + tmpDir = await createTmpDir({ + '.git': {}, + '.gitignore': 'dist/', + [GEMINI_IGNORE_FILE_NAME]: 'build/', + dist: ['ignored-by-git.js'], + build: ['ignored-by-gemini.js'], + src: ['not-ignored.js'], + }); + + const fileSearch = FileSearchFactory.create({ + projectRoot: tmpDir, + fileDiscoveryService: new FileDiscoveryService(tmpDir, { + respectGitIgnore: true, + respectGeminiIgnore: true, + }), + ignoreDirs: [], + cache: false, + cacheTtl: 0, + enableRecursiveFileSearch: true, + enableFuzzySearch: true, + }); + + await fileSearch.initialize(); + const results = await fileSearch.search(''); + + expect(results).toEqual([ + 'src/', + GEMINI_IGNORE_FILE_NAME, '.gitignore', 'src/not-ignored.js', ]); @@ -81,8 +92,10 @@ describe('FileSearch', () => { const fileSearch = FileSearchFactory.create({ projectRoot: tmpDir, - useGitignore: false, - useGeminiignore: false, + fileDiscoveryService: new FileDiscoveryService(tmpDir, { + respectGitIgnore: false, + respectGeminiIgnore: false, + }), ignoreDirs: ['logs'], cache: false, cacheTtl: 0, @@ -98,6 +111,7 @@ describe('FileSearch', () => { it('should handle negated directories', async () => { tmpDir = await createTmpDir({ + '.git': {}, '.gitignore': ['build/**', '!build/public', '!build/public/**'].join( '\n', ), @@ -110,8 +124,10 @@ describe('FileSearch', () => { const fileSearch = FileSearchFactory.create({ projectRoot: tmpDir, - useGitignore: true, - useGeminiignore: false, + fileDiscoveryService: new FileDiscoveryService(tmpDir, { + respectGitIgnore: true, + respectGeminiIgnore: false, + }), ignoreDirs: [], cache: false, cacheTtl: 0, @@ -143,8 +159,10 @@ describe('FileSearch', () => { const fileSearch = FileSearchFactory.create({ projectRoot: tmpDir, - useGitignore: false, - useGeminiignore: false, + fileDiscoveryService: new FileDiscoveryService(tmpDir, { + respectGitIgnore: false, + respectGeminiIgnore: false, + }), ignoreDirs: [], cache: false, cacheTtl: 0, @@ -160,6 +178,7 @@ describe('FileSearch', () => { it('should handle root-level file negation', async () => { tmpDir = await createTmpDir({ + '.git': {}, '.gitignore': ['*.mk', '!Foo.mk'].join('\n'), 'bar.mk': '', 'Foo.mk': '', @@ -167,8 +186,10 @@ describe('FileSearch', () => { const fileSearch = FileSearchFactory.create({ projectRoot: tmpDir, - useGitignore: true, - useGeminiignore: false, + fileDiscoveryService: new FileDiscoveryService(tmpDir, { + respectGitIgnore: true, + respectGeminiIgnore: false, + }), ignoreDirs: [], cache: false, cacheTtl: 0, @@ -184,6 +205,7 @@ describe('FileSearch', () => { it('should handle directory negation with glob', async () => { tmpDir = await createTmpDir({ + '.git': {}, '.gitignore': [ 'third_party/**', '!third_party/foo', @@ -202,8 +224,10 @@ describe('FileSearch', () => { const fileSearch = FileSearchFactory.create({ projectRoot: tmpDir, - useGitignore: true, - useGeminiignore: false, + fileDiscoveryService: new FileDiscoveryService(tmpDir, { + respectGitIgnore: true, + respectGeminiIgnore: false, + }), ignoreDirs: [], cache: false, cacheTtl: 0, @@ -225,6 +249,7 @@ describe('FileSearch', () => { it('should correctly handle negated patterns in .gitignore', async () => { tmpDir = await createTmpDir({ + '.git': {}, '.gitignore': ['dist/**', '!dist/keep.js'].join('\n'), dist: ['ignore.js', 'keep.js'], src: ['main.js'], @@ -232,8 +257,10 @@ describe('FileSearch', () => { const fileSearch = FileSearchFactory.create({ projectRoot: tmpDir, - useGitignore: true, - useGeminiignore: false, + fileDiscoveryService: new FileDiscoveryService(tmpDir, { + respectGitIgnore: true, + respectGeminiIgnore: false, + }), ignoreDirs: [], cache: false, cacheTtl: 0, @@ -262,8 +289,10 @@ describe('FileSearch', () => { const fileSearch = FileSearchFactory.create({ projectRoot: tmpDir, - useGitignore: true, - useGeminiignore: true, + fileDiscoveryService: new FileDiscoveryService(tmpDir, { + respectGitIgnore: true, + respectGeminiIgnore: true, + }), ignoreDirs: [], cache: false, cacheTtl: 0, @@ -289,8 +318,10 @@ describe('FileSearch', () => { const fileSearch = FileSearchFactory.create({ projectRoot: tmpDir, - useGitignore: false, - useGeminiignore: false, + fileDiscoveryService: new FileDiscoveryService(tmpDir, { + respectGitIgnore: false, + respectGeminiIgnore: false, + }), ignoreDirs: [], cache: false, cacheTtl: 0, @@ -315,8 +346,10 @@ describe('FileSearch', () => { const fileSearch = FileSearchFactory.create({ projectRoot: tmpDir, - useGitignore: false, - useGeminiignore: false, + fileDiscoveryService: new FileDiscoveryService(tmpDir, { + respectGitIgnore: false, + respectGeminiIgnore: false, + }), ignoreDirs: [], cache: false, cacheTtl: 0, @@ -341,8 +374,10 @@ describe('FileSearch', () => { const fileSearch = FileSearchFactory.create({ projectRoot: tmpDir, - useGitignore: false, - useGeminiignore: false, + fileDiscoveryService: new FileDiscoveryService(tmpDir, { + respectGitIgnore: false, + respectGeminiIgnore: false, + }), ignoreDirs: [], cache: false, cacheTtl: 0, @@ -367,8 +402,10 @@ describe('FileSearch', () => { const fileSearch = FileSearchFactory.create({ projectRoot: tmpDir, - useGitignore: false, - useGeminiignore: false, + fileDiscoveryService: new FileDiscoveryService(tmpDir, { + respectGitIgnore: false, + respectGeminiIgnore: false, + }), ignoreDirs: [], cache: false, cacheTtl: 0, @@ -391,8 +428,10 @@ describe('FileSearch', () => { const fileSearch = FileSearchFactory.create({ projectRoot: tmpDir, - useGitignore: false, - useGeminiignore: false, + fileDiscoveryService: new FileDiscoveryService(tmpDir, { + respectGitIgnore: false, + respectGeminiIgnore: false, + }), ignoreDirs: [], cache: false, cacheTtl: 0, @@ -422,8 +461,10 @@ describe('FileSearch', () => { tmpDir = await createTmpDir({}); const fileSearch = FileSearchFactory.create({ projectRoot: tmpDir, - useGitignore: false, - useGeminiignore: false, + fileDiscoveryService: new FileDiscoveryService(tmpDir, { + respectGitIgnore: false, + respectGeminiIgnore: false, + }), ignoreDirs: [], cache: false, cacheTtl: 0, @@ -438,14 +479,17 @@ describe('FileSearch', () => { it('should handle empty or commented-only ignore files', async () => { tmpDir = await createTmpDir({ + '.git': {}, '.gitignore': '# This is a comment\n\n \n', src: ['main.js'], }); const fileSearch = FileSearchFactory.create({ projectRoot: tmpDir, - useGitignore: true, - useGeminiignore: false, + fileDiscoveryService: new FileDiscoveryService(tmpDir, { + respectGitIgnore: true, + respectGeminiIgnore: false, + }), ignoreDirs: [], cache: false, cacheTtl: 0, @@ -467,8 +511,10 @@ describe('FileSearch', () => { const fileSearch = FileSearchFactory.create({ projectRoot: tmpDir, - useGitignore: false, // Explicitly disable .gitignore to isolate this rule - useGeminiignore: false, + fileDiscoveryService: new FileDiscoveryService(tmpDir, { + respectGitIgnore: false, // Explicitly disable .gitignore to isolate this rule + respectGeminiIgnore: false, + }), ignoreDirs: [], cache: false, cacheTtl: 0, @@ -491,8 +537,10 @@ describe('FileSearch', () => { const fileSearch = FileSearchFactory.create({ projectRoot: tmpDir, - useGitignore: false, - useGeminiignore: false, + fileDiscoveryService: new FileDiscoveryService(tmpDir, { + respectGitIgnore: false, + respectGeminiIgnore: false, + }), ignoreDirs: [], cache: false, cacheTtl: 0, @@ -518,8 +566,10 @@ describe('FileSearch', () => { const fileSearch = FileSearchFactory.create({ projectRoot: tmpDir, - useGitignore: false, - useGeminiignore: false, + fileDiscoveryService: new FileDiscoveryService(tmpDir, { + respectGitIgnore: false, + respectGeminiIgnore: false, + }), ignoreDirs: [], cache: false, cacheTtl: 0, @@ -555,8 +605,10 @@ describe('FileSearch', () => { const fileSearch = FileSearchFactory.create({ projectRoot: tmpDir, - useGitignore: false, - useGeminiignore: false, + fileDiscoveryService: new FileDiscoveryService(tmpDir, { + respectGitIgnore: false, + respectGeminiIgnore: false, + }), ignoreDirs: [], cache: true, // Enable caching for this test cacheTtl: 0, @@ -595,8 +647,10 @@ describe('FileSearch', () => { const fileSearch = FileSearchFactory.create({ projectRoot: tmpDir, - useGitignore: false, - useGeminiignore: false, + fileDiscoveryService: new FileDiscoveryService(tmpDir, { + respectGitIgnore: false, + respectGeminiIgnore: false, + }), ignoreDirs: [], cache: false, cacheTtl: 0, @@ -639,8 +693,10 @@ describe('FileSearch', () => { const fileSearch = FileSearchFactory.create({ projectRoot: tmpDir, - useGitignore: false, - useGeminiignore: false, + fileDiscoveryService: new FileDiscoveryService(tmpDir, { + respectGitIgnore: false, + respectGeminiIgnore: false, + }), ignoreDirs: [], cache: true, // Ensure caching is enabled cacheTtl: 10000, @@ -677,8 +733,10 @@ describe('FileSearch', () => { const fileSearch = FileSearchFactory.create({ projectRoot: tmpDir, - useGitignore: false, - useGeminiignore: false, + fileDiscoveryService: new FileDiscoveryService(tmpDir, { + respectGitIgnore: false, + respectGeminiIgnore: false, + }), ignoreDirs: [], cache: false, cacheTtl: 0, @@ -707,8 +765,10 @@ describe('FileSearch', () => { const fileSearch = FileSearchFactory.create({ projectRoot: tmpDir, - useGitignore: false, - useGeminiignore: false, + fileDiscoveryService: new FileDiscoveryService(tmpDir, { + respectGitIgnore: false, + respectGeminiIgnore: false, + }), ignoreDirs: [], cache: false, cacheTtl: 0, @@ -732,8 +792,10 @@ describe('FileSearch', () => { const fileSearch = FileSearchFactory.create({ projectRoot: tmpDir, - useGitignore: false, - useGeminiignore: false, + fileDiscoveryService: new FileDiscoveryService(tmpDir, { + respectGitIgnore: false, + respectGeminiIgnore: false, + }), ignoreDirs: [], cache: false, cacheTtl: 0, @@ -757,8 +819,10 @@ describe('FileSearch', () => { const fileSearch = FileSearchFactory.create({ projectRoot: tmpDir, - useGitignore: false, - useGeminiignore: false, + fileDiscoveryService: new FileDiscoveryService(tmpDir, { + respectGitIgnore: false, + respectGeminiIgnore: false, + }), ignoreDirs: [], cache: false, cacheTtl: 0, @@ -773,6 +837,7 @@ describe('FileSearch', () => { it('should respect ignore rules', async () => { tmpDir = await createTmpDir({ + '.git': {}, '.gitignore': '*.js', 'file1.js': '', 'file2.ts': '', @@ -780,8 +845,10 @@ describe('FileSearch', () => { const fileSearch = FileSearchFactory.create({ projectRoot: tmpDir, - useGitignore: true, - useGeminiignore: false, + fileDiscoveryService: new FileDiscoveryService(tmpDir, { + respectGitIgnore: true, + respectGeminiIgnore: false, + }), ignoreDirs: [], cache: false, cacheTtl: 0, diff --git a/packages/core/src/utils/filesearch/fileSearch.ts b/packages/core/src/utils/filesearch/fileSearch.ts index 3c829d6846..6aedaf7276 100644 --- a/packages/core/src/utils/filesearch/fileSearch.ts +++ b/packages/core/src/utils/filesearch/fileSearch.ts @@ -13,12 +13,12 @@ import { crawl } from './crawler.js'; import type { FzfResultItem } from 'fzf'; import { AsyncFzf } from 'fzf'; import { unescapePath } from '../paths.js'; +import type { FileDiscoveryService } from '../../services/fileDiscoveryService.js'; export interface FileSearchOptions { projectRoot: string; ignoreDirs: string[]; - useGitignore: boolean; - useGeminiignore: boolean; + fileDiscoveryService: FileDiscoveryService; cache: boolean; cacheTtl: number; enableRecursiveFileSearch: boolean; @@ -101,7 +101,10 @@ class RecursiveFileSearch implements FileSearch { constructor(private readonly options: FileSearchOptions) {} async initialize(): Promise { - this.ignore = loadIgnoreRules(this.options); + this.ignore = loadIgnoreRules( + this.options.fileDiscoveryService, + this.options.ignoreDirs, + ); this.allFiles = await crawl({ crawlDirectory: this.options.projectRoot, @@ -200,7 +203,10 @@ class DirectoryFileSearch implements FileSearch { constructor(private readonly options: FileSearchOptions) {} async initialize(): Promise { - this.ignore = loadIgnoreRules(this.options); + this.ignore = loadIgnoreRules( + this.options.fileDiscoveryService, + this.options.ignoreDirs, + ); } async search( diff --git a/packages/core/src/utils/filesearch/ignore.test.ts b/packages/core/src/utils/filesearch/ignore.test.ts index f65ecd72c4..04db54a737 100644 --- a/packages/core/src/utils/filesearch/ignore.test.ts +++ b/packages/core/src/utils/filesearch/ignore.test.ts @@ -7,6 +7,8 @@ import { describe, it, expect, afterEach } from 'vitest'; import { Ignore, loadIgnoreRules } from './ignore.js'; import { createTmpDir, cleanupTmpDir } from '@google/gemini-cli-test-utils'; +import { GEMINI_IGNORE_FILE_NAME } from '../../config/constants.js'; +import { FileDiscoveryService } from '../../services/fileDiscoveryService.js'; describe('Ignore', () => { describe('getDirectoryFilter', () => { @@ -76,14 +78,14 @@ describe('loadIgnoreRules', () => { it('should load rules from .gitignore', async () => { tmpDir = await createTmpDir({ + '.git': {}, '.gitignore': '*.log', }); - const ignore = loadIgnoreRules({ - projectRoot: tmpDir, - useGitignore: true, - useGeminiignore: false, - ignoreDirs: [], + const service = new FileDiscoveryService(tmpDir, { + respectGitIgnore: true, + respectGeminiIgnore: false, }); + const ignore = loadIgnoreRules(service, []); const fileFilter = ignore.getFileFilter(); expect(fileFilter('test.log')).toBe(true); expect(fileFilter('test.txt')).toBe(false); @@ -91,14 +93,13 @@ describe('loadIgnoreRules', () => { it('should load rules from .geminiignore', async () => { tmpDir = await createTmpDir({ - '.geminiignore': '*.log', + [GEMINI_IGNORE_FILE_NAME]: '*.log', }); - const ignore = loadIgnoreRules({ - projectRoot: tmpDir, - useGitignore: false, - useGeminiignore: true, - ignoreDirs: [], + const service = new FileDiscoveryService(tmpDir, { + respectGitIgnore: false, + respectGeminiIgnore: true, }); + const ignore = loadIgnoreRules(service, []); const fileFilter = ignore.getFileFilter(); expect(fileFilter('test.log')).toBe(true); expect(fileFilter('test.txt')).toBe(false); @@ -106,15 +107,15 @@ describe('loadIgnoreRules', () => { it('should combine rules from .gitignore and .geminiignore', async () => { tmpDir = await createTmpDir({ + '.git': {}, '.gitignore': '*.log', - '.geminiignore': '*.txt', + [GEMINI_IGNORE_FILE_NAME]: '*.txt', }); - const ignore = loadIgnoreRules({ - projectRoot: tmpDir, - useGitignore: true, - useGeminiignore: true, - ignoreDirs: [], + const service = new FileDiscoveryService(tmpDir, { + respectGitIgnore: true, + respectGeminiIgnore: true, }); + const ignore = loadIgnoreRules(service, []); const fileFilter = ignore.getFileFilter(); expect(fileFilter('test.log')).toBe(true); expect(fileFilter('test.txt')).toBe(true); @@ -123,12 +124,11 @@ describe('loadIgnoreRules', () => { it('should add ignoreDirs', async () => { tmpDir = await createTmpDir({}); - const ignore = loadIgnoreRules({ - projectRoot: tmpDir, - useGitignore: false, - useGeminiignore: false, - ignoreDirs: ['logs/'], + const service = new FileDiscoveryService(tmpDir, { + respectGitIgnore: false, + respectGeminiIgnore: false, }); + const ignore = loadIgnoreRules(service, ['logs/']); const dirFilter = ignore.getDirectoryFilter(); expect(dirFilter('logs/')).toBe(true); expect(dirFilter('src/')).toBe(false); @@ -136,24 +136,22 @@ describe('loadIgnoreRules', () => { it('should handle missing ignore files gracefully', async () => { tmpDir = await createTmpDir({}); - const ignore = loadIgnoreRules({ - projectRoot: tmpDir, - useGitignore: true, - useGeminiignore: true, - ignoreDirs: [], + const service = new FileDiscoveryService(tmpDir, { + respectGitIgnore: true, + respectGeminiIgnore: true, }); + const ignore = loadIgnoreRules(service, []); const fileFilter = ignore.getFileFilter(); expect(fileFilter('anyfile.txt')).toBe(false); }); it('should always add .git to the ignore list', async () => { tmpDir = await createTmpDir({}); - const ignore = loadIgnoreRules({ - projectRoot: tmpDir, - useGitignore: false, - useGeminiignore: false, - ignoreDirs: [], + const service = new FileDiscoveryService(tmpDir, { + respectGitIgnore: false, + respectGeminiIgnore: false, }); + const ignore = loadIgnoreRules(service, []); const dirFilter = ignore.getDirectoryFilter(); expect(dirFilter('.git/')).toBe(true); }); diff --git a/packages/core/src/utils/filesearch/ignore.ts b/packages/core/src/utils/filesearch/ignore.ts index a39066f582..b8b2635c19 100644 --- a/packages/core/src/utils/filesearch/ignore.ts +++ b/packages/core/src/utils/filesearch/ignore.ts @@ -5,38 +5,28 @@ */ import fs from 'node:fs'; -import path from 'node:path'; import ignore from 'ignore'; import picomatch from 'picomatch'; +import type { FileDiscoveryService } from '../../services/fileDiscoveryService.js'; const hasFileExtension = picomatch('**/*[*.]*'); -export interface LoadIgnoreRulesOptions { - projectRoot: string; - useGitignore: boolean; - useGeminiignore: boolean; - ignoreDirs: string[]; -} - -export function loadIgnoreRules(options: LoadIgnoreRulesOptions): Ignore { +export function loadIgnoreRules( + service: FileDiscoveryService, + ignoreDirs: string[] = [], +): Ignore { const ignorer = new Ignore(); - if (options.useGitignore) { - const gitignorePath = path.join(options.projectRoot, '.gitignore'); - if (fs.existsSync(gitignorePath)) { - ignorer.add(fs.readFileSync(gitignorePath, 'utf8')); + const ignoreFiles = service.getAllIgnoreFilePaths(); + + for (const filePath of ignoreFiles) { + if (fs.existsSync(filePath)) { + ignorer.add(fs.readFileSync(filePath, 'utf8')); } } - if (options.useGeminiignore) { - const geminiignorePath = path.join(options.projectRoot, '.geminiignore'); - if (fs.existsSync(geminiignorePath)) { - ignorer.add(fs.readFileSync(geminiignorePath, 'utf8')); - } - } - - const ignoreDirs = ['.git', ...options.ignoreDirs]; + const allIgnoreDirs = ['.git', ...ignoreDirs]; ignorer.add( - ignoreDirs.map((dir) => { + allIgnoreDirs.map((dir) => { if (dir.endsWith('/')) { return dir; } diff --git a/packages/core/src/utils/geminiIgnoreParser.test.ts b/packages/core/src/utils/geminiIgnoreParser.test.ts deleted file mode 100644 index d113626d68..0000000000 --- a/packages/core/src/utils/geminiIgnoreParser.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { GeminiIgnoreParser } from './geminiIgnoreParser.js'; -import * as fs from 'node:fs/promises'; -import * as path from 'node:path'; -import * as os from 'node:os'; - -describe('GeminiIgnoreParser', () => { - let projectRoot: string; - - async function createTestFile(filePath: string, content = '') { - const fullPath = path.join(projectRoot, filePath); - await fs.mkdir(path.dirname(fullPath), { recursive: true }); - await fs.writeFile(fullPath, content); - } - - beforeEach(async () => { - projectRoot = await fs.mkdtemp( - path.join(os.tmpdir(), 'geminiignore-test-'), - ); - }); - - afterEach(async () => { - await fs.rm(projectRoot, { recursive: true, force: true }); - vi.restoreAllMocks(); - }); - - describe('when .geminiignore exists', () => { - beforeEach(async () => { - await createTestFile( - '.geminiignore', - 'ignored.txt\n# A comment\n/ignored_dir/\n', - ); - await createTestFile('ignored.txt', 'ignored'); - await createTestFile('not_ignored.txt', 'not ignored'); - await createTestFile( - path.join('ignored_dir', 'file.txt'), - 'in ignored dir', - ); - await createTestFile( - path.join('subdir', 'not_ignored.txt'), - 'not ignored', - ); - }); - - it('should ignore files specified in .geminiignore', () => { - const parser = new GeminiIgnoreParser(projectRoot); - expect(parser.getPatterns()).toEqual(['ignored.txt', '/ignored_dir/']); - expect(parser.isIgnored('ignored.txt')).toBe(true); - expect(parser.isIgnored('not_ignored.txt')).toBe(false); - expect(parser.isIgnored(path.join('ignored_dir', 'file.txt'))).toBe(true); - expect(parser.isIgnored(path.join('subdir', 'not_ignored.txt'))).toBe( - false, - ); - }); - - it('should return ignore file path when patterns exist', () => { - const parser = new GeminiIgnoreParser(projectRoot); - expect(parser.getIgnoreFilePath()).toBe( - path.join(projectRoot, '.geminiignore'), - ); - }); - - it('should return true for hasPatterns when patterns exist', () => { - const parser = new GeminiIgnoreParser(projectRoot); - expect(parser.hasPatterns()).toBe(true); - }); - - it('should return false for hasPatterns when .geminiignore is deleted', async () => { - const parser = new GeminiIgnoreParser(projectRoot); - await fs.rm(path.join(projectRoot, '.geminiignore')); - expect(parser.hasPatterns()).toBe(false); - expect(parser.getIgnoreFilePath()).toBeNull(); - }); - }); - - describe('when .geminiignore does not exist', () => { - it('should not load any patterns and not ignore any files', () => { - const parser = new GeminiIgnoreParser(projectRoot); - expect(parser.getPatterns()).toEqual([]); - expect(parser.isIgnored('any_file.txt')).toBe(false); - }); - - it('should return null for getIgnoreFilePath when no patterns exist', () => { - const parser = new GeminiIgnoreParser(projectRoot); - expect(parser.getIgnoreFilePath()).toBeNull(); - }); - - it('should return false for hasPatterns when no patterns exist', () => { - const parser = new GeminiIgnoreParser(projectRoot); - expect(parser.hasPatterns()).toBe(false); - }); - }); - - describe('when .geminiignore is empty', () => { - beforeEach(async () => { - await createTestFile('.geminiignore', ''); - }); - - it('should return null for getIgnoreFilePath', () => { - const parser = new GeminiIgnoreParser(projectRoot); - expect(parser.getIgnoreFilePath()).toBeNull(); - }); - - it('should return false for hasPatterns', () => { - const parser = new GeminiIgnoreParser(projectRoot); - expect(parser.hasPatterns()).toBe(false); - }); - }); - - describe('when .geminiignore only has comments', () => { - beforeEach(async () => { - await createTestFile( - '.geminiignore', - '# This is a comment\n# Another comment\n', - ); - }); - - it('should return null for getIgnoreFilePath', () => { - const parser = new GeminiIgnoreParser(projectRoot); - expect(parser.getIgnoreFilePath()).toBeNull(); - }); - - it('should return false for hasPatterns', () => { - const parser = new GeminiIgnoreParser(projectRoot); - expect(parser.hasPatterns()).toBe(false); - }); - }); -}); diff --git a/packages/core/src/utils/geminiIgnoreParser.ts b/packages/core/src/utils/geminiIgnoreParser.ts deleted file mode 100644 index 23217d9d70..0000000000 --- a/packages/core/src/utils/geminiIgnoreParser.ts +++ /dev/null @@ -1,105 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import * as fs from 'node:fs'; -import * as path from 'node:path'; -import ignore from 'ignore'; - -export interface GeminiIgnoreFilter { - isIgnored(filePath: string): boolean; - getPatterns(): string[]; - getIgnoreFilePath(): string | null; - hasPatterns(): boolean; -} - -export class GeminiIgnoreParser implements GeminiIgnoreFilter { - private projectRoot: string; - private patterns: string[] = []; - private ig = ignore(); - - constructor(projectRoot: string) { - this.projectRoot = path.resolve(projectRoot); - this.loadPatterns(); - } - - private loadPatterns(): void { - const patternsFilePath = path.join(this.projectRoot, '.geminiignore'); - let content: string; - try { - content = fs.readFileSync(patternsFilePath, 'utf-8'); - } catch (_error) { - // ignore file not found - return; - } - - this.patterns = (content ?? '') - .split('\n') - .map((p) => p.trim()) - .filter((p) => p !== '' && !p.startsWith('#')); - - this.ig.add(this.patterns); - } - - isIgnored(filePath: string): boolean { - if (this.patterns.length === 0) { - return false; - } - - if (!filePath || typeof filePath !== 'string') { - return false; - } - - if ( - filePath.startsWith('\\') || - filePath === '/' || - filePath.includes('\0') - ) { - return false; - } - - const resolved = path.resolve(this.projectRoot, filePath); - const relativePath = path.relative(this.projectRoot, resolved); - - if (relativePath === '' || relativePath.startsWith('..')) { - return false; - } - - // Even in windows, Ignore expects forward slashes. - const normalizedPath = relativePath.replace(/\\/g, '/'); - - if (normalizedPath.startsWith('/') || normalizedPath === '') { - return false; - } - - return this.ig.ignores(normalizedPath); - } - - getPatterns(): string[] { - return this.patterns; - } - - /** - * Returns the path to .geminiignore file if it exists and has patterns. - * Useful for tools like ripgrep that support --ignore-file flag. - */ - getIgnoreFilePath(): string | null { - if (!this.hasPatterns()) { - return null; - } - return path.join(this.projectRoot, '.geminiignore'); - } - - /** - * Returns true if .geminiignore exists and has patterns. - */ - hasPatterns(): boolean { - if (this.patterns.length === 0) { - return false; - } - const ignoreFilePath = path.join(this.projectRoot, '.geminiignore'); - return fs.existsSync(ignoreFilePath); - } -} diff --git a/packages/core/src/utils/getFolderStructure.test.ts b/packages/core/src/utils/getFolderStructure.test.ts index 5b8e18c089..e6c0c88cdc 100644 --- a/packages/core/src/utils/getFolderStructure.test.ts +++ b/packages/core/src/utils/getFolderStructure.test.ts @@ -12,6 +12,7 @@ import { getFolderStructure } from './getFolderStructure.js'; import { FileDiscoveryService } from '../services/fileDiscoveryService.js'; import * as path from 'node:path'; import { GEMINI_DIR } from './paths.js'; +import { GEMINI_IGNORE_FILE_NAME } from 'src/config/constants.js'; describe('getFolderStructure', () => { let testRootDir: string; @@ -285,6 +286,7 @@ ${testRootDir}${path.sep} fileFilteringOptions: { respectGeminiIgnore: false, respectGitIgnore: false, + customIgnoreFilePaths: [], }, }); @@ -296,7 +298,7 @@ ${testRootDir}${path.sep} describe('with geminiignore', () => { it('should ignore geminiignore files by default', async () => { await fsPromises.writeFile( - nodePath.join(testRootDir, '.geminiignore'), + nodePath.join(testRootDir, GEMINI_IGNORE_FILE_NAME), 'ignored.txt\nnode_modules/\n.gemini/\n!/.gemini/config.yaml', ); await createTestFile('file1.txt'); @@ -316,7 +318,7 @@ ${testRootDir}${path.sep} it('should not ignore files if respectGeminiIgnore is false', async () => { await fsPromises.writeFile( - nodePath.join(testRootDir, '.geminiignore'), + nodePath.join(testRootDir, GEMINI_IGNORE_FILE_NAME), 'ignored.txt\nnode_modules/\n.gemini/\n!/.gemini/config.yaml', ); await createTestFile('file1.txt'); @@ -331,6 +333,7 @@ ${testRootDir}${path.sep} fileFilteringOptions: { respectGeminiIgnore: false, respectGitIgnore: true, // Explicitly disable gemini ignore only + customIgnoreFilePaths: [], }, }); expect(structure).toContain('ignored.txt'); diff --git a/packages/core/src/utils/gitIgnoreParser.ts b/packages/core/src/utils/gitIgnoreParser.ts index cca0ca3bac..7677c60ced 100644 --- a/packages/core/src/utils/gitIgnoreParser.ts +++ b/packages/core/src/utils/gitIgnoreParser.ts @@ -175,7 +175,7 @@ export class GitIgnoreParser implements GitIgnoreFilter { const normalizedRelativeDir = relativeDir.replace(/\\/g, '/'); const igPlusExtras = ignore() .add(ig) - .add(this.processedExtraPatterns); + .add(this.processedExtraPatterns); // takes priority over ig patterns if (igPlusExtras.ignores(normalizedRelativeDir)) { // This directory is ignored by an ancestor's .gitignore. // According to git behavior, we don't need to process this diff --git a/packages/core/src/utils/ignoreFileParser.test.ts b/packages/core/src/utils/ignoreFileParser.test.ts new file mode 100644 index 0000000000..528ad1e8ef --- /dev/null +++ b/packages/core/src/utils/ignoreFileParser.test.ts @@ -0,0 +1,219 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { IgnoreFileParser } from './ignoreFileParser.js'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { GEMINI_IGNORE_FILE_NAME } from '../config/constants.js'; + +describe('GeminiIgnoreParser', () => { + let projectRoot: string; + + async function createTestFile(filePath: string, content = '') { + const fullPath = path.join(projectRoot, filePath); + await fs.mkdir(path.dirname(fullPath), { recursive: true }); + await fs.writeFile(fullPath, content); + } + + beforeEach(async () => { + projectRoot = await fs.mkdtemp( + path.join(os.tmpdir(), 'geminiignore-test-'), + ); + }); + + afterEach(async () => { + await fs.rm(projectRoot, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + describe('when .geminiignore exists', () => { + beforeEach(async () => { + await createTestFile( + GEMINI_IGNORE_FILE_NAME, + 'ignored.txt\n# A comment\n/ignored_dir/\n', + ); + await createTestFile('ignored.txt', 'ignored'); + await createTestFile('not_ignored.txt', 'not ignored'); + await createTestFile( + path.join('ignored_dir', 'file.txt'), + 'in ignored dir', + ); + await createTestFile( + path.join('subdir', 'not_ignored.txt'), + 'not ignored', + ); + }); + + it('should ignore files specified in .geminiignore', () => { + const parser = new IgnoreFileParser(projectRoot, GEMINI_IGNORE_FILE_NAME); + expect(parser.getPatterns()).toEqual(['ignored.txt', '/ignored_dir/']); + expect(parser.isIgnored('ignored.txt')).toBe(true); + expect(parser.isIgnored('not_ignored.txt')).toBe(false); + expect(parser.isIgnored(path.join('ignored_dir', 'file.txt'))).toBe(true); + expect(parser.isIgnored(path.join('subdir', 'not_ignored.txt'))).toBe( + false, + ); + }); + + it('should return ignore file path when patterns exist', () => { + const parser = new IgnoreFileParser(projectRoot, GEMINI_IGNORE_FILE_NAME); + expect(parser.getIgnoreFilePaths()).toEqual([ + path.join(projectRoot, GEMINI_IGNORE_FILE_NAME), + ]); + }); + + it('should return true for hasPatterns when patterns exist', () => { + const parser = new IgnoreFileParser(projectRoot, GEMINI_IGNORE_FILE_NAME); + expect(parser.hasPatterns()).toBe(true); + }); + + it('should maintain patterns in memory when .geminiignore is deleted', async () => { + const parser = new IgnoreFileParser(projectRoot, GEMINI_IGNORE_FILE_NAME); + await fs.rm(path.join(projectRoot, GEMINI_IGNORE_FILE_NAME)); + expect(parser.hasPatterns()).toBe(true); + expect(parser.getIgnoreFilePaths()).toEqual([]); + }); + }); + + describe('when .geminiignore does not exist', () => { + it('should not load any patterns and not ignore any files', () => { + const parser = new IgnoreFileParser(projectRoot, GEMINI_IGNORE_FILE_NAME); + expect(parser.getPatterns()).toEqual([]); + expect(parser.isIgnored('any_file.txt')).toBe(false); + }); + + it('should return empty array for getIgnoreFilePaths when no patterns exist', () => { + const parser = new IgnoreFileParser(projectRoot, GEMINI_IGNORE_FILE_NAME); + expect(parser.getIgnoreFilePaths()).toEqual([]); + }); + + it('should return false for hasPatterns when no patterns exist', () => { + const parser = new IgnoreFileParser(projectRoot, GEMINI_IGNORE_FILE_NAME); + expect(parser.hasPatterns()).toBe(false); + }); + }); + + describe('when .geminiignore is empty', () => { + beforeEach(async () => { + await createTestFile(GEMINI_IGNORE_FILE_NAME, ''); + }); + + it('should return file path for getIgnoreFilePaths', () => { + const parser = new IgnoreFileParser(projectRoot, GEMINI_IGNORE_FILE_NAME); + expect(parser.getIgnoreFilePaths()).toEqual([ + path.join(projectRoot, GEMINI_IGNORE_FILE_NAME), + ]); + }); + + it('should return false for hasPatterns', () => { + const parser = new IgnoreFileParser(projectRoot, GEMINI_IGNORE_FILE_NAME); + expect(parser.hasPatterns()).toBe(false); + }); + }); + + describe('when .geminiignore only has comments', () => { + beforeEach(async () => { + await createTestFile( + GEMINI_IGNORE_FILE_NAME, + '# This is a comment\n# Another comment\n', + ); + }); + + it('should return file path for getIgnoreFilePaths', () => { + const parser = new IgnoreFileParser(projectRoot, GEMINI_IGNORE_FILE_NAME); + expect(parser.getIgnoreFilePaths()).toEqual([ + path.join(projectRoot, GEMINI_IGNORE_FILE_NAME), + ]); + }); + + it('should return false for hasPatterns', () => { + const parser = new IgnoreFileParser(projectRoot, GEMINI_IGNORE_FILE_NAME); + expect(parser.hasPatterns()).toBe(false); + }); + }); + + describe('when multiple ignore files are provided', () => { + const primaryFile = 'primary.ignore'; + const secondaryFile = 'secondary.ignore'; + + beforeEach(async () => { + await createTestFile(primaryFile, '# Primary\n!important.txt\n'); + await createTestFile(secondaryFile, '# Secondary\n*.txt\n'); + await createTestFile('important.txt', 'important'); + await createTestFile('other.txt', 'other'); + }); + + it('should combine patterns from all files', () => { + const parser = new IgnoreFileParser(projectRoot, [ + primaryFile, + secondaryFile, + ]); + expect(parser.isIgnored('other.txt')).toBe(true); + }); + + it('should respect priority (first file overrides second)', () => { + const parser = new IgnoreFileParser(projectRoot, [ + primaryFile, + secondaryFile, + ]); + expect(parser.isIgnored('important.txt')).toBe(false); + }); + + it('should return all existing file paths in reverse order', () => { + const parser = new IgnoreFileParser(projectRoot, [ + 'nonexistent.ignore', + primaryFile, + secondaryFile, + ]); + expect(parser.getIgnoreFilePaths()).toEqual([ + path.join(projectRoot, secondaryFile), + path.join(projectRoot, primaryFile), + ]); + }); + }); + + describe('when patterns are passed directly', () => { + it('should ignore files matching the passed patterns', () => { + const parser = new IgnoreFileParser(projectRoot, ['*.log'], true); + expect(parser.isIgnored('debug.log')).toBe(true); + expect(parser.isIgnored('src/index.ts')).toBe(false); + }); + + it('should handle multiple patterns', () => { + const parser = new IgnoreFileParser( + projectRoot, + ['*.log', 'temp/'], + true, + ); + expect(parser.isIgnored('debug.log')).toBe(true); + expect(parser.isIgnored('temp/file.txt')).toBe(true); + expect(parser.isIgnored('src/index.ts')).toBe(false); + }); + + it('should respect precedence (later patterns override earlier ones)', () => { + const parser = new IgnoreFileParser( + projectRoot, + ['*.txt', '!important.txt'], + true, + ); + expect(parser.isIgnored('file.txt')).toBe(true); + expect(parser.isIgnored('important.txt')).toBe(false); + }); + + it('should return empty array for getIgnoreFilePaths', () => { + const parser = new IgnoreFileParser(projectRoot, ['*.log'], true); + expect(parser.getIgnoreFilePaths()).toEqual([]); + }); + + it('should return patterns via getPatterns', () => { + const patterns = ['*.log', '!debug.log']; + const parser = new IgnoreFileParser(projectRoot, patterns, true); + expect(parser.getPatterns()).toEqual(patterns); + }); + }); +}); diff --git a/packages/core/src/utils/ignoreFileParser.ts b/packages/core/src/utils/ignoreFileParser.ts new file mode 100644 index 0000000000..3fbb3f45d8 --- /dev/null +++ b/packages/core/src/utils/ignoreFileParser.ts @@ -0,0 +1,129 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import ignore from 'ignore'; +import { debugLogger } from './debugLogger.js'; + +export interface IgnoreFileFilter { + isIgnored(filePath: string): boolean; + getPatterns(): string[]; + getIgnoreFilePaths(): string[]; + hasPatterns(): boolean; +} + +/** + * An ignore file parser that reads the ignore files from the project root. + */ +export class IgnoreFileParser implements IgnoreFileFilter { + private projectRoot: string; + private patterns: string[] = []; + private ig = ignore(); + private readonly fileNames: string[]; + + constructor( + projectRoot: string, + // The order matters: files listed earlier have higher priority. + // It can be a single file name/pattern or an array of file names/patterns. + input: string | string[], + isPatterns = false, + ) { + this.projectRoot = path.resolve(projectRoot); + if (isPatterns) { + this.fileNames = []; + const patterns = Array.isArray(input) ? input : [input]; + this.patterns.push(...patterns); + this.ig.add(patterns); + } else { + this.fileNames = Array.isArray(input) ? input : [input]; + this.loadPatternsFromFiles(); + } + } + + private loadPatternsFromFiles(): void { + // Iterate in reverse order so that the first file in the list is processed last. + // This gives the first file the highest priority, as patterns added later override earlier ones. + for (const fileName of [...this.fileNames].reverse()) { + const patterns = this.parseIgnoreFile(fileName); + this.patterns.push(...patterns); + this.ig.add(patterns); + } + } + + private parseIgnoreFile(fileName: string): string[] { + const patternsFilePath = path.join(this.projectRoot, fileName); + let content: string; + try { + content = fs.readFileSync(patternsFilePath, 'utf-8'); + } catch (_error) { + debugLogger.debug( + `Ignore file not found: ${patternsFilePath}, continue without it.`, + ); + return []; + } + + debugLogger.debug(`Loading ignore patterns from: ${patternsFilePath}`); + + return (content ?? '') + .split('\n') + .map((p) => p.trim()) + .filter((p) => p !== '' && !p.startsWith('#')); + } + + isIgnored(filePath: string): boolean { + if (this.patterns.length === 0) { + return false; + } + + if (!filePath || typeof filePath !== 'string') { + return false; + } + + if ( + filePath.startsWith('\\') || + filePath === '/' || + filePath.includes('\0') + ) { + return false; + } + + const resolved = path.resolve(this.projectRoot, filePath); + const relativePath = path.relative(this.projectRoot, resolved); + + if (relativePath === '' || relativePath.startsWith('..')) { + return false; + } + + // Even in windows, Ignore expects forward slashes. + const normalizedPath = relativePath.replace(/\\/g, '/'); + + if (normalizedPath.startsWith('/') || normalizedPath === '') { + return false; + } + + return this.ig.ignores(normalizedPath); + } + + getPatterns(): string[] { + return this.patterns; + } + + getIgnoreFilePaths(): string[] { + return this.fileNames + .slice() + .reverse() + .map((fileName) => path.join(this.projectRoot, fileName)) + .filter((filePath) => fs.existsSync(filePath)); + } + + /** + * Returns true if at least one ignore file exists and has patterns. + */ + hasPatterns(): boolean { + return this.patterns.length > 0; + } +} diff --git a/packages/core/src/utils/memoryDiscovery.test.ts b/packages/core/src/utils/memoryDiscovery.test.ts index 101cf5ad85..18a1438357 100644 --- a/packages/core/src/utils/memoryDiscovery.test.ts +++ b/packages/core/src/utils/memoryDiscovery.test.ts @@ -436,6 +436,7 @@ Subdir memory { respectGitIgnore: true, respectGeminiIgnore: true, + customIgnoreFilePaths: [], }, 200, // maxDirs parameter ); @@ -472,6 +473,7 @@ My code memory { respectGitIgnore: true, respectGeminiIgnore: true, + customIgnoreFilePaths: [], }, 1, // maxDirs ); diff --git a/packages/core/src/utils/tool-utils.test.ts b/packages/core/src/utils/tool-utils.test.ts index 822d0c2999..0a36615005 100644 --- a/packages/core/src/utils/tool-utils.test.ts +++ b/packages/core/src/utils/tool-utils.test.ts @@ -84,7 +84,11 @@ describe('doesToolInvocationMatch', () => { }); describe('for non-shell tools', () => { - const readFileTool = new ReadFileTool({} as Config, createMockMessageBus()); + const mockConfig = { + getTargetDir: () => '/tmp', + getFileFilteringOptions: () => ({}), + } as unknown as Config; + const readFileTool = new ReadFileTool(mockConfig, createMockMessageBus()); const invocation = { params: { file: 'test.txt' }, } as AnyToolInvocation; diff --git a/schemas/settings.schema.json b/schemas/settings.schema.json index 49a49504cc..e6ef72115a 100644 --- a/schemas/settings.schema.json +++ b/schemas/settings.schema.json @@ -1030,6 +1030,16 @@ "markdownDescription": "Enable fuzzy search when searching for files.\n\n- Category: `Context`\n- Requires restart: `yes`\n- Default: `true`", "default": true, "type": "boolean" + }, + "customIgnoreFilePaths": { + "title": "Custom Ignore File Paths", + "description": "Additional ignore file paths to respect. These files take precedence over .geminiignore and .gitignore. Files earlier in the array take precedence over files later in the array, e.g. the first file takes precedence over the second one.", + "markdownDescription": "Additional ignore file paths to respect. These files take precedence over .geminiignore and .gitignore. Files earlier in the array take precedence over files later in the array, e.g. the first file takes precedence over the second one.\n\n- Category: `Context`\n- Requires restart: `yes`\n- Default: `[]`", + "default": [], + "type": "array", + "items": { + "type": "string" + } } }, "additionalProperties": false