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:
Alisa
2026-01-27 17:19:13 -08:00
committed by GitHub
parent 18efe82ddc
commit adc8e11bb1
40 changed files with 1394 additions and 612 deletions
@@ -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);
});
+12 -22
View File
@@ -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');
+1 -1
View File
@@ -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);
});
});
});
+129
View File
@@ -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
);
+5 -1
View File
@@ -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;