mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-22 02:54:31 -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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user