mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-06-13 04:48:09 -07:00
Add support for an additional exclusion file besides .gitignore and .geminiignore (#16487)
Co-authored-by: Adam Weidman <adamfweidman@google.com>
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -32,6 +32,14 @@ class MockConfig {
|
||||
return true;
|
||||
}
|
||||
|
||||
getFileFilteringOptions() {
|
||||
return {
|
||||
respectGitIgnore: true,
|
||||
respectGeminiIgnore: true,
|
||||
customIgnoreFilePaths: [],
|
||||
};
|
||||
}
|
||||
|
||||
validatePathAccess() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -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<typeof import('@google/gemini-cli-core')>();
|
||||
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],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -38,7 +38,9 @@ export interface Settings {
|
||||
// Git-aware file filtering settings
|
||||
fileFiltering?: {
|
||||
respectGitIgnore?: boolean;
|
||||
respectGeminiIgnore?: boolean;
|
||||
enableRecursiveFileSearch?: boolean;
|
||||
customIgnoreFilePaths?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'),
|
||||
},
|
||||
|
||||
@@ -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<RipGrepToolParams, ToolResult> {
|
||||
return new GrepToolInvocation(
|
||||
this.config,
|
||||
this.geminiIgnoreParser,
|
||||
this.fileDiscoveryService,
|
||||
params,
|
||||
messageBus ?? this.messageBus,
|
||||
_toolName,
|
||||
|
||||
@@ -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: [],
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
this.ignore = loadIgnoreRules(this.options);
|
||||
this.ignore = loadIgnoreRules(
|
||||
this.options.fileDiscoveryService,
|
||||
this.options.ignoreDirs,
|
||||
);
|
||||
}
|
||||
|
||||
async search(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user