mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-15 08:31:14 -07:00
499 lines
17 KiB
TypeScript
499 lines
17 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
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;
|
|
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);
|
|
return fullPath;
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
testRootDir = await fs.mkdtemp(
|
|
path.join(os.tmpdir(), 'file-discovery-test-'),
|
|
);
|
|
projectRoot = path.join(testRootDir, 'project');
|
|
await fs.mkdir(projectRoot, { recursive: true });
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await fs.rm(testRootDir, { recursive: true, force: true });
|
|
});
|
|
|
|
describe('initialization', () => {
|
|
it('should initialize git ignore parser by default in a git repo', async () => {
|
|
await fs.mkdir(path.join(projectRoot, '.git'));
|
|
await createTestFile('.gitignore', 'node_modules/');
|
|
|
|
const service = new FileDiscoveryService(projectRoot);
|
|
// Let's check the effect of the parser instead of mocking it.
|
|
expect(service.shouldIgnoreFile('node_modules/foo.js')).toBe(true);
|
|
expect(service.shouldIgnoreFile('src/foo.js')).toBe(false);
|
|
});
|
|
|
|
it('should not load git repo patterns when not in a git repo', async () => {
|
|
// No .git directory
|
|
await createTestFile('.gitignore', 'node_modules/');
|
|
const service = new FileDiscoveryService(projectRoot);
|
|
|
|
// .gitignore is not loaded in non-git repos
|
|
expect(service.shouldIgnoreFile('node_modules/foo.js')).toBe(false);
|
|
});
|
|
|
|
it('should load .geminiignore patterns even when not in a git repo', async () => {
|
|
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(GEMINI_IGNORE_FILE_NAME, 'logs/');
|
|
});
|
|
|
|
it('should filter out git-ignored and gemini-ignored files by default', () => {
|
|
const files = [
|
|
'src/index.ts',
|
|
'node_modules/package/index.js',
|
|
'README.md',
|
|
'.git/config',
|
|
'dist/bundle.js',
|
|
'logs/latest.log',
|
|
].map((f) => path.join(projectRoot, f));
|
|
|
|
const service = new FileDiscoveryService(projectRoot);
|
|
|
|
expect(service.filterFiles(files)).toEqual(
|
|
['src/index.ts', 'README.md'].map((f) => path.join(projectRoot, f)),
|
|
);
|
|
});
|
|
|
|
it('should not filter files when respectGitIgnore is false', () => {
|
|
const files = [
|
|
'src/index.ts',
|
|
'node_modules/package/index.js',
|
|
'.git/config',
|
|
'logs/latest.log',
|
|
].map((f) => path.join(projectRoot, f));
|
|
|
|
const service = new FileDiscoveryService(projectRoot);
|
|
|
|
const filtered = service.filterFiles(files, {
|
|
respectGitIgnore: false,
|
|
respectGeminiIgnore: true, // still respect this one
|
|
});
|
|
|
|
expect(filtered).toEqual(
|
|
['src/index.ts', 'node_modules/package/index.js', '.git/config'].map(
|
|
(f) => path.join(projectRoot, f),
|
|
),
|
|
);
|
|
});
|
|
|
|
it('should not filter files when respectGeminiIgnore is false', () => {
|
|
const files = [
|
|
'src/index.ts',
|
|
'node_modules/package/index.js',
|
|
'logs/latest.log',
|
|
].map((f) => path.join(projectRoot, f));
|
|
|
|
const service = new FileDiscoveryService(projectRoot);
|
|
|
|
const filtered = service.filterFiles(files, {
|
|
respectGitIgnore: true,
|
|
respectGeminiIgnore: false,
|
|
});
|
|
|
|
expect(filtered).toEqual(
|
|
['src/index.ts', 'logs/latest.log'].map((f) =>
|
|
path.join(projectRoot, f),
|
|
),
|
|
);
|
|
});
|
|
|
|
it('should handle empty file list', () => {
|
|
const service = new FileDiscoveryService(projectRoot);
|
|
|
|
expect(service.filterFiles([])).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('filterFilesWithReport', () => {
|
|
beforeEach(async () => {
|
|
await fs.mkdir(path.join(projectRoot, '.git'));
|
|
await createTestFile('.gitignore', 'node_modules/');
|
|
await createTestFile(GEMINI_IGNORE_FILE_NAME, '*.log');
|
|
});
|
|
|
|
it('should return filtered paths and correct ignored count', () => {
|
|
const files = [
|
|
'src/index.ts',
|
|
'node_modules/package/index.js',
|
|
'debug.log',
|
|
'README.md',
|
|
].map((f) => path.join(projectRoot, f));
|
|
|
|
const service = new FileDiscoveryService(projectRoot);
|
|
const report = service.filterFilesWithReport(files);
|
|
|
|
expect(report.filteredPaths).toEqual(
|
|
['src/index.ts', 'README.md'].map((f) => path.join(projectRoot, f)),
|
|
);
|
|
expect(report.ignoredCount).toBe(2);
|
|
});
|
|
|
|
it('should handle no ignored files', () => {
|
|
const files = ['src/index.ts', 'README.md'].map((f) =>
|
|
path.join(projectRoot, f),
|
|
);
|
|
|
|
const service = new FileDiscoveryService(projectRoot);
|
|
const report = service.filterFilesWithReport(files);
|
|
|
|
expect(report.filteredPaths).toEqual(files);
|
|
expect(report.ignoredCount).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('shouldGitIgnoreFile & shouldGeminiIgnoreFile', () => {
|
|
beforeEach(async () => {
|
|
await fs.mkdir(path.join(projectRoot, '.git'));
|
|
await createTestFile('.gitignore', 'node_modules/');
|
|
await createTestFile(GEMINI_IGNORE_FILE_NAME, '*.log');
|
|
});
|
|
|
|
it('should return true for git-ignored files', () => {
|
|
const service = new FileDiscoveryService(projectRoot);
|
|
|
|
expect(
|
|
service.shouldIgnoreFile(
|
|
path.join(projectRoot, 'node_modules/package/index.js'),
|
|
),
|
|
).toBe(true);
|
|
});
|
|
|
|
it('should return false for non-git-ignored files', () => {
|
|
const service = new FileDiscoveryService(projectRoot);
|
|
|
|
expect(
|
|
service.shouldIgnoreFile(path.join(projectRoot, 'src/index.ts')),
|
|
).toBe(false);
|
|
});
|
|
|
|
it('should return true for gemini-ignored files', () => {
|
|
const service = new FileDiscoveryService(projectRoot);
|
|
|
|
expect(
|
|
service.shouldIgnoreFile(path.join(projectRoot, 'debug.log')),
|
|
).toBe(true);
|
|
});
|
|
|
|
it('should return false for non-gemini-ignored files', () => {
|
|
const service = new FileDiscoveryService(projectRoot);
|
|
|
|
expect(
|
|
service.shouldIgnoreFile(path.join(projectRoot, 'src/index.ts')),
|
|
).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('edge cases', () => {
|
|
it('should handle relative project root paths', async () => {
|
|
await fs.mkdir(path.join(projectRoot, '.git'));
|
|
await createTestFile('.gitignore', 'ignored.txt');
|
|
const service = new FileDiscoveryService(
|
|
path.relative(process.cwd(), projectRoot),
|
|
);
|
|
|
|
expect(
|
|
service.shouldIgnoreFile(path.join(projectRoot, 'ignored.txt')),
|
|
).toBe(true);
|
|
expect(
|
|
service.shouldIgnoreFile(path.join(projectRoot, 'not-ignored.txt')),
|
|
).toBe(false);
|
|
});
|
|
|
|
it('should handle filterFiles with undefined options', async () => {
|
|
await fs.mkdir(path.join(projectRoot, '.git'));
|
|
await createTestFile('.gitignore', 'ignored.txt');
|
|
const service = new FileDiscoveryService(projectRoot);
|
|
|
|
const files = ['src/index.ts', 'ignored.txt'].map((f) =>
|
|
path.join(projectRoot, f),
|
|
);
|
|
|
|
expect(service.filterFiles(files, undefined)).toEqual([
|
|
path.join(projectRoot, 'src/index.ts'),
|
|
]);
|
|
});
|
|
});
|
|
describe('precedence (.geminiignore over .gitignore)', () => {
|
|
beforeEach(async () => {
|
|
await fs.mkdir(path.join(projectRoot, '.git'));
|
|
});
|
|
|
|
it('should un-ignore a file in .geminiignore that is ignored in .gitignore', async () => {
|
|
await createTestFile('.gitignore', '*.txt');
|
|
await createTestFile(GEMINI_IGNORE_FILE_NAME, '!important.txt');
|
|
|
|
const service = new FileDiscoveryService(projectRoot);
|
|
const files = ['file.txt', 'important.txt'].map((f) =>
|
|
path.join(projectRoot, f),
|
|
);
|
|
|
|
const filtered = service.filterFiles(files);
|
|
expect(filtered).toEqual([path.join(projectRoot, 'important.txt')]);
|
|
});
|
|
|
|
it('should un-ignore a directory in .geminiignore that is ignored in .gitignore', async () => {
|
|
await createTestFile('.gitignore', 'logs/');
|
|
await createTestFile(GEMINI_IGNORE_FILE_NAME, '!logs/');
|
|
|
|
const service = new FileDiscoveryService(projectRoot);
|
|
const files = ['logs/app.log', 'other/app.log'].map((f) =>
|
|
path.join(projectRoot, f),
|
|
);
|
|
|
|
const filtered = service.filterFiles(files);
|
|
expect(filtered).toEqual(files);
|
|
});
|
|
|
|
it('should extend ignore rules in .geminiignore', async () => {
|
|
await createTestFile('.gitignore', '*.log');
|
|
await createTestFile(GEMINI_IGNORE_FILE_NAME, 'temp/');
|
|
|
|
const service = new FileDiscoveryService(projectRoot);
|
|
const files = ['app.log', 'temp/file.txt'].map((f) =>
|
|
path.join(projectRoot, f),
|
|
);
|
|
|
|
const filtered = service.filterFiles(files);
|
|
expect(filtered).toEqual([]);
|
|
});
|
|
|
|
it('should use .gitignore rules if respectGeminiIgnore is false', async () => {
|
|
await createTestFile('.gitignore', '*.txt');
|
|
await createTestFile(GEMINI_IGNORE_FILE_NAME, '!important.txt');
|
|
|
|
const service = new FileDiscoveryService(projectRoot);
|
|
const files = ['file.txt', 'important.txt'].map((f) =>
|
|
path.join(projectRoot, f),
|
|
);
|
|
|
|
const filtered = service.filterFiles(files, {
|
|
respectGitIgnore: true,
|
|
respectGeminiIgnore: false,
|
|
});
|
|
|
|
expect(filtered).toEqual([]);
|
|
});
|
|
|
|
it('should use .geminiignore rules if respectGitIgnore is false', async () => {
|
|
await createTestFile('.gitignore', '*.txt');
|
|
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) =>
|
|
path.join(projectRoot, f),
|
|
);
|
|
|
|
const filtered = service.filterFiles(files, {
|
|
respectGitIgnore: false,
|
|
respectGeminiIgnore: true,
|
|
});
|
|
|
|
// .gitignore is ignored, so *.txt is not applied.
|
|
// .geminiignore un-ignores important.txt (which wasn't ignored anyway)
|
|
// and ignores temp/
|
|
expect(filtered).toEqual(
|
|
['file.txt', 'important.txt'].map((f) => path.join(projectRoot, f)),
|
|
);
|
|
});
|
|
});
|
|
|
|
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'));
|
|
});
|
|
});
|
|
});
|