feat: full implementation for .geminiignore in settings and respective tool calls (#3727)
This commit is contained in:
parent
76b935d598
commit
a01b1219a3
|
@ -43,6 +43,14 @@ vi.mock('@google/gemini-cli-core', async () => {
|
|||
fileCount: extensionPaths?.length || 0,
|
||||
}),
|
||||
),
|
||||
DEFAULT_MEMORY_FILE_FILTERING_OPTIONS: {
|
||||
respectGitIgnore: false,
|
||||
respectGeminiIgnore: true,
|
||||
},
|
||||
DEFAULT_FILE_FILTERING_OPTIONS: {
|
||||
respectGitIgnore: true,
|
||||
respectGeminiIgnore: true,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
|
@ -479,6 +487,10 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
|
|||
'/path/to/ext3/context1.md',
|
||||
'/path/to/ext3/context2.md',
|
||||
],
|
||||
{
|
||||
respectGitIgnore: false,
|
||||
respectGeminiIgnore: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
@ -15,8 +15,10 @@ import {
|
|||
ApprovalMode,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
DEFAULT_GEMINI_EMBEDDING_MODEL,
|
||||
DEFAULT_MEMORY_FILE_FILTERING_OPTIONS,
|
||||
FileDiscoveryService,
|
||||
TelemetryTarget,
|
||||
FileFilteringOptions,
|
||||
MCPServerConfig,
|
||||
IDE_SERVER_NAME,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
@ -219,12 +221,14 @@ export async function loadHierarchicalGeminiMemory(
|
|||
debugMode: boolean,
|
||||
fileService: FileDiscoveryService,
|
||||
extensionContextFilePaths: string[] = [],
|
||||
fileFilteringOptions?: FileFilteringOptions,
|
||||
): Promise<{ memoryContent: string; fileCount: number }> {
|
||||
if (debugMode) {
|
||||
logger.debug(
|
||||
`CLI: Delegating hierarchical memory load to server for CWD: ${currentWorkingDirectory}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Directly call the server function.
|
||||
// The server function will use its own homedir() for the global path.
|
||||
return loadServerHierarchicalMemory(
|
||||
|
@ -232,6 +236,7 @@ export async function loadHierarchicalGeminiMemory(
|
|||
debugMode,
|
||||
fileService,
|
||||
extensionContextFilePaths,
|
||||
fileFilteringOptions,
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -277,12 +282,19 @@ export async function loadCliConfig(
|
|||
);
|
||||
|
||||
const fileService = new FileDiscoveryService(process.cwd());
|
||||
|
||||
const fileFiltering = {
|
||||
...DEFAULT_MEMORY_FILE_FILTERING_OPTIONS,
|
||||
...settings.fileFiltering,
|
||||
};
|
||||
|
||||
// Call the (now wrapper) loadHierarchicalGeminiMemory which calls the server's version
|
||||
const { memoryContent, fileCount } = await loadHierarchicalGeminiMemory(
|
||||
process.cwd(),
|
||||
debugMode,
|
||||
fileService,
|
||||
extensionContextFilePaths,
|
||||
fileFiltering,
|
||||
);
|
||||
|
||||
let mcpServers = mergeMcpServers(settings, activeExtensions);
|
||||
|
@ -405,6 +417,7 @@ export async function loadCliConfig(
|
|||
// Git-aware file filtering settings
|
||||
fileFiltering: {
|
||||
respectGitIgnore: settings.fileFiltering?.respectGitIgnore,
|
||||
respectGeminiIgnore: settings.fileFiltering?.respectGeminiIgnore,
|
||||
enableRecursiveFileSearch:
|
||||
settings.fileFiltering?.enableRecursiveFileSearch,
|
||||
},
|
||||
|
|
|
@ -81,6 +81,7 @@ export interface Settings {
|
|||
// Git-aware file filtering settings
|
||||
fileFiltering?: {
|
||||
respectGitIgnore?: boolean;
|
||||
respectGeminiIgnore?: boolean;
|
||||
enableRecursiveFileSearch?: boolean;
|
||||
};
|
||||
|
||||
|
|
|
@ -252,7 +252,9 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
|||
config.getDebugMode(),
|
||||
config.getFileService(),
|
||||
config.getExtensionContextFilePaths(),
|
||||
config.getFileFilteringOptions(),
|
||||
);
|
||||
|
||||
config.setUserMemory(memoryContent);
|
||||
config.setGeminiMdFileCount(fileCount);
|
||||
setGeminiMdFileCount(fileCount);
|
||||
|
|
|
@ -21,6 +21,11 @@ const mockConfig = {
|
|||
isSandboxed: vi.fn(() => false),
|
||||
getFileService: vi.fn(),
|
||||
getFileFilteringRespectGitIgnore: vi.fn(() => true),
|
||||
getFileFilteringRespectGeminiIgnore: vi.fn(() => true),
|
||||
getFileFilteringOptions: vi.fn(() => ({
|
||||
respectGitIgnore: true,
|
||||
respectGeminiIgnore: true,
|
||||
})),
|
||||
getEnableRecursiveFileSearch: vi.fn(() => true),
|
||||
} as unknown as Config;
|
||||
|
||||
|
@ -171,7 +176,13 @@ describe('handleAtCommand', () => {
|
|||
125,
|
||||
);
|
||||
expect(mockReadManyFilesExecute).toHaveBeenCalledWith(
|
||||
{ paths: [filePath], respect_git_ignore: true },
|
||||
{
|
||||
paths: [filePath],
|
||||
file_filtering_options: {
|
||||
respect_git_ignore: true,
|
||||
respect_gemini_ignore: true,
|
||||
},
|
||||
},
|
||||
abortController.signal,
|
||||
);
|
||||
expect(mockAddItem).toHaveBeenCalledWith(
|
||||
|
@ -217,7 +228,13 @@ describe('handleAtCommand', () => {
|
|||
126,
|
||||
);
|
||||
expect(mockReadManyFilesExecute).toHaveBeenCalledWith(
|
||||
{ paths: [resolvedGlob], respect_git_ignore: true },
|
||||
{
|
||||
paths: [resolvedGlob],
|
||||
file_filtering_options: {
|
||||
respect_git_ignore: true,
|
||||
respect_gemini_ignore: true,
|
||||
},
|
||||
},
|
||||
abortController.signal,
|
||||
);
|
||||
expect(mockOnDebugMessage).toHaveBeenCalledWith(
|
||||
|
@ -318,7 +335,13 @@ describe('handleAtCommand', () => {
|
|||
signal: abortController.signal,
|
||||
});
|
||||
expect(mockReadManyFilesExecute).toHaveBeenCalledWith(
|
||||
{ paths: [unescapedPath], respect_git_ignore: true },
|
||||
{
|
||||
paths: [unescapedPath],
|
||||
file_filtering_options: {
|
||||
respect_git_ignore: true,
|
||||
respect_gemini_ignore: true,
|
||||
},
|
||||
},
|
||||
abortController.signal,
|
||||
);
|
||||
});
|
||||
|
@ -347,7 +370,13 @@ describe('handleAtCommand', () => {
|
|||
signal: abortController.signal,
|
||||
});
|
||||
expect(mockReadManyFilesExecute).toHaveBeenCalledWith(
|
||||
{ paths: [file1, file2], respect_git_ignore: true },
|
||||
{
|
||||
paths: [file1, file2],
|
||||
file_filtering_options: {
|
||||
respect_git_ignore: true,
|
||||
respect_gemini_ignore: true,
|
||||
},
|
||||
},
|
||||
abortController.signal,
|
||||
);
|
||||
expect(result.processedQuery).toEqual([
|
||||
|
@ -389,7 +418,13 @@ describe('handleAtCommand', () => {
|
|||
signal: abortController.signal,
|
||||
});
|
||||
expect(mockReadManyFilesExecute).toHaveBeenCalledWith(
|
||||
{ paths: [file1, file2], respect_git_ignore: true },
|
||||
{
|
||||
paths: [file1, file2],
|
||||
file_filtering_options: {
|
||||
respect_git_ignore: true,
|
||||
respect_gemini_ignore: true,
|
||||
},
|
||||
},
|
||||
abortController.signal,
|
||||
);
|
||||
expect(result.processedQuery).toEqual([
|
||||
|
@ -454,7 +489,13 @@ describe('handleAtCommand', () => {
|
|||
});
|
||||
|
||||
expect(mockReadManyFilesExecute).toHaveBeenCalledWith(
|
||||
{ paths: [file1, resolvedFile2], respect_git_ignore: true },
|
||||
{
|
||||
paths: [file1, resolvedFile2],
|
||||
file_filtering_options: {
|
||||
respect_git_ignore: true,
|
||||
respect_gemini_ignore: true,
|
||||
},
|
||||
},
|
||||
abortController.signal,
|
||||
);
|
||||
expect(result.processedQuery).toEqual([
|
||||
|
@ -556,7 +597,13 @@ describe('handleAtCommand', () => {
|
|||
// If the mock is simpler, it might use queryPath if stat(queryPath) succeeds.
|
||||
// The most important part is that *some* version of the path that leads to the content is used.
|
||||
// Let's assume it uses the path from the query if stat confirms it exists (even if different case on disk)
|
||||
{ paths: [queryPath], respect_git_ignore: true },
|
||||
{
|
||||
paths: [queryPath],
|
||||
file_filtering_options: {
|
||||
respect_git_ignore: true,
|
||||
respect_gemini_ignore: true,
|
||||
},
|
||||
},
|
||||
abortController.signal,
|
||||
);
|
||||
expect(mockAddItem).toHaveBeenCalledWith(
|
||||
|
@ -583,8 +630,18 @@ describe('handleAtCommand', () => {
|
|||
|
||||
// Mock the file discovery service to report this file as git-ignored
|
||||
mockFileDiscoveryService.shouldIgnoreFile.mockImplementation(
|
||||
(path: string, options?: { respectGitIgnore?: boolean }) =>
|
||||
path === gitIgnoredFile && options?.respectGitIgnore !== false,
|
||||
(
|
||||
path: string,
|
||||
options?: {
|
||||
respectGitIgnore?: boolean;
|
||||
respectGeminiIgnore?: boolean;
|
||||
},
|
||||
) => {
|
||||
if (path !== gitIgnoredFile) return false;
|
||||
if (options?.respectGitIgnore) return true;
|
||||
if (options?.respectGeminiIgnore) return false;
|
||||
return false;
|
||||
},
|
||||
);
|
||||
|
||||
const result = await handleAtCommand({
|
||||
|
@ -596,15 +653,24 @@ describe('handleAtCommand', () => {
|
|||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
// Should be called twice - once for git ignore check and once for gemini ignore check
|
||||
expect(mockFileDiscoveryService.shouldIgnoreFile).toHaveBeenCalledTimes(
|
||||
2,
|
||||
);
|
||||
expect(mockFileDiscoveryService.shouldIgnoreFile).toHaveBeenCalledWith(
|
||||
gitIgnoredFile,
|
||||
{ respectGitIgnore: true },
|
||||
{ respectGitIgnore: true, respectGeminiIgnore: false },
|
||||
);
|
||||
expect(mockFileDiscoveryService.shouldIgnoreFile).toHaveBeenCalledWith(
|
||||
gitIgnoredFile,
|
||||
{ respectGitIgnore: false, respectGeminiIgnore: true },
|
||||
);
|
||||
|
||||
expect(mockOnDebugMessage).toHaveBeenCalledWith(
|
||||
`Path ${gitIgnoredFile} is git-ignored and will be skipped.`,
|
||||
);
|
||||
expect(mockOnDebugMessage).toHaveBeenCalledWith(
|
||||
'Ignored 1 git-ignored files: node_modules/package.json',
|
||||
'Ignored 1 files:\nGit-ignored: node_modules/package.json',
|
||||
);
|
||||
expect(mockReadManyFilesExecute).not.toHaveBeenCalled();
|
||||
expect(result.processedQuery).toEqual([{ text: query }]);
|
||||
|
@ -616,7 +682,15 @@ describe('handleAtCommand', () => {
|
|||
const query = `@${validFile}`;
|
||||
const fileContent = 'console.log("Hello world");';
|
||||
|
||||
mockFileDiscoveryService.shouldIgnoreFile.mockReturnValue(false);
|
||||
mockFileDiscoveryService.shouldIgnoreFile.mockImplementation(
|
||||
(
|
||||
_path: string,
|
||||
_options?: {
|
||||
respectGitIgnore?: boolean;
|
||||
respectGeminiIgnore?: boolean;
|
||||
},
|
||||
) => false,
|
||||
);
|
||||
mockReadManyFilesExecute.mockResolvedValue({
|
||||
llmContent: [`--- ${validFile} ---\n\n${fileContent}\n\n`],
|
||||
returnDisplay: 'Read 1 file.',
|
||||
|
@ -631,12 +705,26 @@ describe('handleAtCommand', () => {
|
|||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
// Should be called twice - once for git ignore check and once for gemini ignore check
|
||||
expect(mockFileDiscoveryService.shouldIgnoreFile).toHaveBeenCalledTimes(
|
||||
2,
|
||||
);
|
||||
expect(mockFileDiscoveryService.shouldIgnoreFile).toHaveBeenCalledWith(
|
||||
validFile,
|
||||
{ respectGitIgnore: true },
|
||||
{ respectGitIgnore: true, respectGeminiIgnore: false },
|
||||
);
|
||||
expect(mockFileDiscoveryService.shouldIgnoreFile).toHaveBeenCalledWith(
|
||||
validFile,
|
||||
{ respectGitIgnore: false, respectGeminiIgnore: true },
|
||||
);
|
||||
expect(mockReadManyFilesExecute).toHaveBeenCalledWith(
|
||||
{ paths: [validFile], respect_git_ignore: true },
|
||||
{
|
||||
paths: [validFile],
|
||||
file_filtering_options: {
|
||||
respect_git_ignore: true,
|
||||
respect_gemini_ignore: true,
|
||||
},
|
||||
},
|
||||
abortController.signal,
|
||||
);
|
||||
expect(result.processedQuery).toEqual([
|
||||
|
@ -656,8 +744,21 @@ describe('handleAtCommand', () => {
|
|||
const fileContent = '# Project README';
|
||||
|
||||
mockFileDiscoveryService.shouldIgnoreFile.mockImplementation(
|
||||
(path: string, options?: { respectGitIgnore?: boolean }) =>
|
||||
path === gitIgnoredFile && options?.respectGitIgnore !== false,
|
||||
(
|
||||
path: string,
|
||||
options?: {
|
||||
respectGitIgnore?: boolean;
|
||||
respectGeminiIgnore?: boolean;
|
||||
},
|
||||
) => {
|
||||
if (path === gitIgnoredFile && options?.respectGitIgnore) {
|
||||
return true;
|
||||
}
|
||||
if (options?.respectGeminiIgnore) {
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
);
|
||||
mockReadManyFilesExecute.mockResolvedValue({
|
||||
llmContent: [`--- ${validFile} ---\n\n${fileContent}\n\n`],
|
||||
|
@ -673,22 +774,40 @@ describe('handleAtCommand', () => {
|
|||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
// Should be called twice for each file - once for git ignore check and once for gemini ignore check
|
||||
expect(mockFileDiscoveryService.shouldIgnoreFile).toHaveBeenCalledTimes(
|
||||
4,
|
||||
);
|
||||
expect(mockFileDiscoveryService.shouldIgnoreFile).toHaveBeenCalledWith(
|
||||
validFile,
|
||||
{ respectGitIgnore: true },
|
||||
{ respectGitIgnore: true, respectGeminiIgnore: false },
|
||||
);
|
||||
expect(mockFileDiscoveryService.shouldIgnoreFile).toHaveBeenCalledWith(
|
||||
validFile,
|
||||
{ respectGitIgnore: false, respectGeminiIgnore: true },
|
||||
);
|
||||
expect(mockFileDiscoveryService.shouldIgnoreFile).toHaveBeenCalledWith(
|
||||
gitIgnoredFile,
|
||||
{ respectGitIgnore: true },
|
||||
{ respectGitIgnore: true, respectGeminiIgnore: false },
|
||||
);
|
||||
expect(mockFileDiscoveryService.shouldIgnoreFile).toHaveBeenCalledWith(
|
||||
gitIgnoredFile,
|
||||
{ respectGitIgnore: false, respectGeminiIgnore: true },
|
||||
);
|
||||
expect(mockOnDebugMessage).toHaveBeenCalledWith(
|
||||
`Path ${gitIgnoredFile} is git-ignored and will be skipped.`,
|
||||
);
|
||||
expect(mockOnDebugMessage).toHaveBeenCalledWith(
|
||||
'Ignored 1 git-ignored files: .env',
|
||||
'Ignored 1 files:\nGit-ignored: .env',
|
||||
);
|
||||
expect(mockReadManyFilesExecute).toHaveBeenCalledWith(
|
||||
{ paths: [validFile], respect_git_ignore: true },
|
||||
{
|
||||
paths: [validFile],
|
||||
file_filtering_options: {
|
||||
respect_git_ignore: true,
|
||||
respect_gemini_ignore: true,
|
||||
},
|
||||
},
|
||||
abortController.signal,
|
||||
);
|
||||
expect(result.processedQuery).toEqual([
|
||||
|
@ -705,7 +824,16 @@ describe('handleAtCommand', () => {
|
|||
const gitFile = '.git/config';
|
||||
const query = `@${gitFile}`;
|
||||
|
||||
mockFileDiscoveryService.shouldIgnoreFile.mockReturnValue(true);
|
||||
// Mock to return true for git ignore check, false for gemini ignore check
|
||||
mockFileDiscoveryService.shouldIgnoreFile.mockImplementation(
|
||||
(
|
||||
_path: string,
|
||||
options?: {
|
||||
respectGitIgnore?: boolean;
|
||||
respectGeminiIgnore?: boolean;
|
||||
},
|
||||
) => options?.respectGitIgnore === true,
|
||||
);
|
||||
|
||||
const result = await handleAtCommand({
|
||||
query,
|
||||
|
@ -716,13 +844,24 @@ describe('handleAtCommand', () => {
|
|||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
// Should be called twice - once for git ignore check and once for gemini ignore check
|
||||
expect(mockFileDiscoveryService.shouldIgnoreFile).toHaveBeenCalledTimes(
|
||||
2,
|
||||
);
|
||||
expect(mockFileDiscoveryService.shouldIgnoreFile).toHaveBeenCalledWith(
|
||||
gitFile,
|
||||
{ respectGitIgnore: true },
|
||||
{ respectGitIgnore: true, respectGeminiIgnore: false },
|
||||
);
|
||||
expect(mockFileDiscoveryService.shouldIgnoreFile).toHaveBeenCalledWith(
|
||||
gitFile,
|
||||
{ respectGitIgnore: false, respectGeminiIgnore: true },
|
||||
);
|
||||
expect(mockOnDebugMessage).toHaveBeenCalledWith(
|
||||
`Path ${gitFile} is git-ignored and will be skipped.`,
|
||||
);
|
||||
expect(mockOnDebugMessage).toHaveBeenCalledWith(
|
||||
'Ignored 1 files:\nGit-ignored: .git/config',
|
||||
);
|
||||
expect(mockReadManyFilesExecute).not.toHaveBeenCalled();
|
||||
expect(result.processedQuery).toEqual([{ text: query }]);
|
||||
expect(result.shouldProceed).toBe(true);
|
||||
|
@ -759,4 +898,208 @@ describe('handleAtCommand', () => {
|
|||
expect(result.shouldProceed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gemini-ignore filtering', () => {
|
||||
it('should skip gemini-ignored files in @ commands', async () => {
|
||||
const geminiIgnoredFile = 'build/output.js';
|
||||
const query = `@${geminiIgnoredFile}`;
|
||||
|
||||
// Mock the file discovery service to report this file as gemini-ignored
|
||||
mockFileDiscoveryService.shouldIgnoreFile.mockImplementation(
|
||||
(
|
||||
path: string,
|
||||
options?: {
|
||||
respectGitIgnore?: boolean;
|
||||
respectGeminiIgnore?: boolean;
|
||||
},
|
||||
) => {
|
||||
if (path !== geminiIgnoredFile) return false;
|
||||
if (options?.respectGeminiIgnore) return true;
|
||||
if (options?.respectGitIgnore) return false;
|
||||
return false;
|
||||
},
|
||||
);
|
||||
|
||||
const result = await handleAtCommand({
|
||||
query,
|
||||
config: mockConfig,
|
||||
addItem: mockAddItem,
|
||||
onDebugMessage: mockOnDebugMessage,
|
||||
messageId: 204,
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
// Should be called twice - once for git ignore check and once for gemini ignore check
|
||||
expect(mockFileDiscoveryService.shouldIgnoreFile).toHaveBeenCalledTimes(
|
||||
2,
|
||||
);
|
||||
expect(mockFileDiscoveryService.shouldIgnoreFile).toHaveBeenCalledWith(
|
||||
geminiIgnoredFile,
|
||||
{ respectGitIgnore: true, respectGeminiIgnore: false },
|
||||
);
|
||||
expect(mockFileDiscoveryService.shouldIgnoreFile).toHaveBeenCalledWith(
|
||||
geminiIgnoredFile,
|
||||
{ respectGitIgnore: false, respectGeminiIgnore: true },
|
||||
);
|
||||
|
||||
expect(mockOnDebugMessage).toHaveBeenCalledWith(
|
||||
`Path ${geminiIgnoredFile} is gemini-ignored and will be skipped.`,
|
||||
);
|
||||
expect(mockOnDebugMessage).toHaveBeenCalledWith(
|
||||
'No valid file paths found in @ commands to read.',
|
||||
);
|
||||
expect(mockOnDebugMessage).toHaveBeenCalledWith(
|
||||
'Ignored 1 files:\nGemini-ignored: build/output.js',
|
||||
);
|
||||
expect(mockReadManyFilesExecute).not.toHaveBeenCalled();
|
||||
expect(result.processedQuery).toEqual([{ text: query }]);
|
||||
expect(result.shouldProceed).toBe(true);
|
||||
});
|
||||
|
||||
it('should process non-ignored files when .geminiignore is present', async () => {
|
||||
const validFile = 'src/index.ts';
|
||||
const query = `@${validFile}`;
|
||||
const fileContent = 'console.log("Hello world")';
|
||||
|
||||
mockFileDiscoveryService.shouldIgnoreFile.mockImplementation(
|
||||
(
|
||||
_path: string,
|
||||
_options?: {
|
||||
respectGitIgnore?: boolean;
|
||||
respectGeminiIgnore?: boolean;
|
||||
},
|
||||
) => false,
|
||||
);
|
||||
mockReadManyFilesExecute.mockResolvedValue({
|
||||
llmContent: [`--- ${validFile} ---\n\n${fileContent}\n\n`],
|
||||
returnDisplay: 'Read 1 file.',
|
||||
});
|
||||
|
||||
const result = await handleAtCommand({
|
||||
query,
|
||||
config: mockConfig,
|
||||
addItem: mockAddItem,
|
||||
onDebugMessage: mockOnDebugMessage,
|
||||
messageId: 205,
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
// Should be called twice - once for git ignore check and once for gemini ignore check
|
||||
expect(mockFileDiscoveryService.shouldIgnoreFile).toHaveBeenCalledTimes(
|
||||
2,
|
||||
);
|
||||
expect(mockFileDiscoveryService.shouldIgnoreFile).toHaveBeenCalledWith(
|
||||
validFile,
|
||||
{ respectGitIgnore: true, respectGeminiIgnore: false },
|
||||
);
|
||||
expect(mockFileDiscoveryService.shouldIgnoreFile).toHaveBeenCalledWith(
|
||||
validFile,
|
||||
{ respectGitIgnore: false, respectGeminiIgnore: true },
|
||||
);
|
||||
|
||||
expect(mockReadManyFilesExecute).toHaveBeenCalledWith(
|
||||
{
|
||||
paths: [validFile],
|
||||
file_filtering_options: {
|
||||
respect_git_ignore: true,
|
||||
respect_gemini_ignore: true,
|
||||
},
|
||||
},
|
||||
abortController.signal,
|
||||
);
|
||||
expect(result.processedQuery).toEqual([
|
||||
{ text: `@${validFile}` },
|
||||
{ text: '\n--- Content from referenced files ---' },
|
||||
{ text: `\nContent from @${validFile}:\n` },
|
||||
{ text: fileContent },
|
||||
{ text: '\n--- End of content ---' },
|
||||
]);
|
||||
expect(result.shouldProceed).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle mixed gemini-ignored and valid files', async () => {
|
||||
const validFile = 'src/main.ts';
|
||||
const geminiIgnoredFile = 'dist/bundle.js';
|
||||
const query = `@${validFile} @${geminiIgnoredFile}`;
|
||||
const fileContent = '// Main application entry';
|
||||
|
||||
mockFileDiscoveryService.shouldIgnoreFile.mockImplementation(
|
||||
(
|
||||
path: string,
|
||||
options?: {
|
||||
respectGitIgnore?: boolean;
|
||||
respectGeminiIgnore?: boolean;
|
||||
},
|
||||
) => {
|
||||
if (path === geminiIgnoredFile && options?.respectGeminiIgnore) {
|
||||
return true;
|
||||
}
|
||||
if (options?.respectGitIgnore) {
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
);
|
||||
mockReadManyFilesExecute.mockResolvedValue({
|
||||
llmContent: [`--- ${validFile} ---\n\n${fileContent}\n\n`],
|
||||
returnDisplay: 'Read 1 file.',
|
||||
});
|
||||
|
||||
const result = await handleAtCommand({
|
||||
query,
|
||||
config: mockConfig,
|
||||
addItem: mockAddItem,
|
||||
onDebugMessage: mockOnDebugMessage,
|
||||
messageId: 206,
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
// Should be called twice for each file - once for git ignore check and once for gemini ignore check
|
||||
expect(mockFileDiscoveryService.shouldIgnoreFile).toHaveBeenCalledTimes(
|
||||
4,
|
||||
);
|
||||
|
||||
// Verify both files were checked against both ignore types
|
||||
[validFile, geminiIgnoredFile].forEach((file) => {
|
||||
expect(mockFileDiscoveryService.shouldIgnoreFile).toHaveBeenCalledWith(
|
||||
file,
|
||||
{ respectGitIgnore: true, respectGeminiIgnore: false },
|
||||
);
|
||||
expect(mockFileDiscoveryService.shouldIgnoreFile).toHaveBeenCalledWith(
|
||||
file,
|
||||
{ respectGitIgnore: false, respectGeminiIgnore: true },
|
||||
);
|
||||
});
|
||||
|
||||
expect(mockOnDebugMessage).toHaveBeenCalledWith(
|
||||
`Path ${validFile} resolved to file: ${validFile}`,
|
||||
);
|
||||
expect(mockOnDebugMessage).toHaveBeenCalledWith(
|
||||
`Path ${geminiIgnoredFile} is gemini-ignored and will be skipped.`,
|
||||
);
|
||||
expect(mockOnDebugMessage).toHaveBeenCalledWith(
|
||||
'Ignored 1 files:\nGemini-ignored: dist/bundle.js',
|
||||
);
|
||||
|
||||
expect(mockReadManyFilesExecute).toHaveBeenCalledWith(
|
||||
{
|
||||
paths: [validFile],
|
||||
file_filtering_options: {
|
||||
respect_git_ignore: true,
|
||||
respect_gemini_ignore: true,
|
||||
},
|
||||
},
|
||||
abortController.signal,
|
||||
);
|
||||
|
||||
expect(result.processedQuery).toEqual([
|
||||
{ text: `@${validFile} @${geminiIgnoredFile}` },
|
||||
{ text: '\n--- Content from referenced files ---' },
|
||||
{ text: `\nContent from @${validFile}:\n` },
|
||||
{ text: fileContent },
|
||||
{ text: '\n--- End of content ---' },
|
||||
]);
|
||||
expect(result.shouldProceed).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -136,12 +136,17 @@ export async function handleAtCommand({
|
|||
|
||||
// Get centralized file discovery service
|
||||
const fileDiscovery = config.getFileService();
|
||||
const respectGitIgnore = config.getFileFilteringRespectGitIgnore();
|
||||
|
||||
const respectFileIgnore = config.getFileFilteringOptions();
|
||||
|
||||
const pathSpecsToRead: string[] = [];
|
||||
const atPathToResolvedSpecMap = new Map<string, string>();
|
||||
const contentLabelsForDisplay: string[] = [];
|
||||
const ignoredPaths: string[] = [];
|
||||
const ignoredByReason: Record<string, string[]> = {
|
||||
git: [],
|
||||
gemini: [],
|
||||
both: [],
|
||||
};
|
||||
|
||||
const toolRegistry = await config.getToolRegistry();
|
||||
const readManyFilesTool = toolRegistry.getTool('read_many_files');
|
||||
|
@ -182,10 +187,31 @@ export async function handleAtCommand({
|
|||
}
|
||||
|
||||
// Check if path should be ignored based on filtering options
|
||||
if (fileDiscovery.shouldIgnoreFile(pathName, { respectGitIgnore })) {
|
||||
const reason = respectGitIgnore ? 'git-ignored' : 'custom-ignored';
|
||||
onDebugMessage(`Path ${pathName} is ${reason} and will be skipped.`);
|
||||
ignoredPaths.push(pathName);
|
||||
|
||||
const gitIgnored =
|
||||
respectFileIgnore.respectGitIgnore &&
|
||||
fileDiscovery.shouldIgnoreFile(pathName, {
|
||||
respectGitIgnore: true,
|
||||
respectGeminiIgnore: false,
|
||||
});
|
||||
const geminiIgnored =
|
||||
respectFileIgnore.respectGeminiIgnore &&
|
||||
fileDiscovery.shouldIgnoreFile(pathName, {
|
||||
respectGitIgnore: false,
|
||||
respectGeminiIgnore: true,
|
||||
});
|
||||
|
||||
if (gitIgnored || geminiIgnored) {
|
||||
const reason =
|
||||
gitIgnored && geminiIgnored ? 'both' : gitIgnored ? 'git' : 'gemini';
|
||||
ignoredByReason[reason].push(pathName);
|
||||
const reasonText =
|
||||
reason === 'both'
|
||||
? 'ignored by both git and gemini'
|
||||
: reason === 'git'
|
||||
? 'git-ignored'
|
||||
: 'gemini-ignored';
|
||||
onDebugMessage(`Path ${pathName} is ${reasonText} and will be skipped.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
@ -319,11 +345,26 @@ export async function handleAtCommand({
|
|||
initialQueryText = initialQueryText.trim();
|
||||
|
||||
// Inform user about ignored paths
|
||||
if (ignoredPaths.length > 0) {
|
||||
const ignoreType = respectGitIgnore ? 'git-ignored' : 'custom-ignored';
|
||||
onDebugMessage(
|
||||
`Ignored ${ignoredPaths.length} ${ignoreType} files: ${ignoredPaths.join(', ')}`,
|
||||
);
|
||||
const totalIgnored =
|
||||
ignoredByReason.git.length +
|
||||
ignoredByReason.gemini.length +
|
||||
ignoredByReason.both.length;
|
||||
|
||||
if (totalIgnored > 0) {
|
||||
const messages = [];
|
||||
if (ignoredByReason.git.length) {
|
||||
messages.push(`Git-ignored: ${ignoredByReason.git.join(', ')}`);
|
||||
}
|
||||
if (ignoredByReason.gemini.length) {
|
||||
messages.push(`Gemini-ignored: ${ignoredByReason.gemini.join(', ')}`);
|
||||
}
|
||||
if (ignoredByReason.both.length) {
|
||||
messages.push(`Ignored by both: ${ignoredByReason.both.join(', ')}`);
|
||||
}
|
||||
|
||||
const message = `Ignored ${totalIgnored} files:\n${messages.join('\n')}`;
|
||||
console.log(message);
|
||||
onDebugMessage(message);
|
||||
}
|
||||
|
||||
// Fallback for lone "@" or completely invalid @-commands resulting in empty initialQueryText
|
||||
|
@ -347,7 +388,11 @@ export async function handleAtCommand({
|
|||
|
||||
const toolArgs = {
|
||||
paths: pathSpecsToRead,
|
||||
respect_git_ignore: respectGitIgnore, // Use configuration setting
|
||||
file_filtering_options: {
|
||||
respect_git_ignore: respectFileIgnore.respectGitIgnore,
|
||||
respect_gemini_ignore: respectFileIgnore.respectGeminiIgnore,
|
||||
},
|
||||
// Use configuration setting
|
||||
};
|
||||
let toolCallDisplay: IndividualToolCallDisplay;
|
||||
|
||||
|
|
|
@ -14,7 +14,10 @@ import { CommandContext, SlashCommand } from '../commands/types.js';
|
|||
import { Config, FileDiscoveryService } from '@google/gemini-cli-core';
|
||||
|
||||
interface MockConfig {
|
||||
getFileFilteringRespectGitIgnore: () => boolean;
|
||||
getFileFilteringOptions: () => {
|
||||
respectGitIgnore: boolean;
|
||||
respectGeminiIgnore: boolean;
|
||||
};
|
||||
getEnableRecursiveFileSearch: () => boolean;
|
||||
getFileService: () => FileDiscoveryService | null;
|
||||
}
|
||||
|
@ -118,12 +121,16 @@ describe('useCompletion git-aware filtering integration', () => {
|
|||
projectRoot: '',
|
||||
gitIgnoreFilter: null,
|
||||
geminiIgnoreFilter: null,
|
||||
isFileIgnored: vi.fn(),
|
||||
} as unknown as Mocked<FileDiscoveryService>;
|
||||
|
||||
mockConfig = {
|
||||
getFileFilteringRespectGitIgnore: vi.fn(() => true),
|
||||
getFileService: vi.fn().mockReturnValue(mockFileDiscoveryService),
|
||||
getFileFilteringOptions: vi.fn(() => ({
|
||||
respectGitIgnore: true,
|
||||
respectGeminiIgnore: true,
|
||||
})),
|
||||
getEnableRecursiveFileSearch: vi.fn(() => true),
|
||||
getFileService: vi.fn(() => mockFileDiscoveryService),
|
||||
};
|
||||
|
||||
vi.mocked(FileDiscoveryService).mockImplementation(
|
||||
|
@ -186,7 +193,7 @@ describe('useCompletion git-aware filtering integration', () => {
|
|||
{ name: '.env', isDirectory: () => false },
|
||||
] as unknown as Awaited<ReturnType<typeof fs.readdir>>);
|
||||
|
||||
// Mock git ignore service to ignore certain files
|
||||
// Mock ignore service to ignore certain files
|
||||
mockFileDiscoveryService.shouldGitIgnoreFile.mockImplementation(
|
||||
(path: string) =>
|
||||
path.includes('node_modules') ||
|
||||
|
@ -195,8 +202,17 @@ describe('useCompletion git-aware filtering integration', () => {
|
|||
);
|
||||
mockFileDiscoveryService.shouldIgnoreFile.mockImplementation(
|
||||
(path: string, options) => {
|
||||
if (options?.respectGitIgnore !== false) {
|
||||
return mockFileDiscoveryService.shouldGitIgnoreFile(path);
|
||||
if (
|
||||
options?.respectGitIgnore &&
|
||||
mockFileDiscoveryService.shouldGitIgnoreFile(path)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
options?.respectGeminiIgnore &&
|
||||
mockFileDiscoveryService.shouldGeminiIgnoreFile
|
||||
) {
|
||||
return mockFileDiscoveryService.shouldGeminiIgnoreFile(path);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
@ -231,38 +247,54 @@ describe('useCompletion git-aware filtering integration', () => {
|
|||
it('should handle recursive search with git-aware filtering', async () => {
|
||||
// Mock the recursive file search scenario
|
||||
vi.mocked(fs.readdir).mockImplementation(
|
||||
async (dirPath: string | Buffer | URL) => {
|
||||
if (dirPath === testCwd) {
|
||||
return [
|
||||
{ name: 'src', isDirectory: () => true },
|
||||
{ name: 'node_modules', isDirectory: () => true },
|
||||
{ name: 'temp', isDirectory: () => true },
|
||||
] as Array<{ name: string; isDirectory: () => boolean }>;
|
||||
async (
|
||||
dirPath: string | Buffer | URL,
|
||||
options?: { withFileTypes?: boolean },
|
||||
) => {
|
||||
const path = dirPath.toString();
|
||||
if (options?.withFileTypes) {
|
||||
if (path === testCwd) {
|
||||
return [
|
||||
{ name: 'data', isDirectory: () => true },
|
||||
{ name: 'dist', isDirectory: () => true },
|
||||
{ name: 'node_modules', isDirectory: () => true },
|
||||
{ name: 'README.md', isDirectory: () => false },
|
||||
{ name: '.env', isDirectory: () => false },
|
||||
] as unknown as Awaited<ReturnType<typeof fs.readdir>>;
|
||||
}
|
||||
if (path.endsWith('/src')) {
|
||||
return [
|
||||
{ name: 'index.ts', isDirectory: () => false },
|
||||
{ name: 'components', isDirectory: () => true },
|
||||
] as unknown as Awaited<ReturnType<typeof fs.readdir>>;
|
||||
}
|
||||
if (path.endsWith('/temp')) {
|
||||
return [
|
||||
{ name: 'temp.log', isDirectory: () => false },
|
||||
] as unknown as Awaited<ReturnType<typeof fs.readdir>>;
|
||||
}
|
||||
}
|
||||
if (dirPath.endsWith('/src')) {
|
||||
return [
|
||||
{ name: 'index.ts', isDirectory: () => false },
|
||||
{ name: 'components', isDirectory: () => true },
|
||||
] as Array<{ name: string; isDirectory: () => boolean }>;
|
||||
}
|
||||
if (dirPath.endsWith('/temp')) {
|
||||
return [{ name: 'temp.log', isDirectory: () => false }] as Array<{
|
||||
name: string;
|
||||
isDirectory: () => boolean;
|
||||
}>;
|
||||
}
|
||||
return [] as Array<{ name: string; isDirectory: () => boolean }>;
|
||||
return [];
|
||||
},
|
||||
);
|
||||
|
||||
// Mock git ignore service
|
||||
// Mock ignore service
|
||||
mockFileDiscoveryService.shouldGitIgnoreFile.mockImplementation(
|
||||
(path: string) => path.includes('node_modules') || path.includes('temp'),
|
||||
);
|
||||
mockFileDiscoveryService.shouldIgnoreFile.mockImplementation(
|
||||
(path: string, options) => {
|
||||
if (options?.respectGitIgnore !== false) {
|
||||
return mockFileDiscoveryService.shouldGitIgnoreFile(path);
|
||||
if (
|
||||
options?.respectGitIgnore &&
|
||||
mockFileDiscoveryService.shouldGitIgnoreFile(path)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
options?.respectGeminiIgnore &&
|
||||
mockFileDiscoveryService.shouldGeminiIgnoreFile
|
||||
) {
|
||||
return mockFileDiscoveryService.shouldGeminiIgnoreFile(path);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
@ -405,9 +437,12 @@ describe('useCompletion git-aware filtering integration', () => {
|
|||
);
|
||||
mockFileDiscoveryService.shouldIgnoreFile.mockImplementation(
|
||||
(path: string, options) => {
|
||||
if (options?.respectGitIgnore !== false) {
|
||||
if (options?.respectGitIgnore) {
|
||||
return mockFileDiscoveryService.shouldGitIgnoreFile(path);
|
||||
}
|
||||
if (options?.respectGeminiIgnore) {
|
||||
return mockFileDiscoveryService.shouldGeminiIgnoreFile(path);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
);
|
||||
|
|
|
@ -55,6 +55,10 @@ describe('useCompletion', () => {
|
|||
getFileFilteringRespectGitIgnore: vi.fn(() => true),
|
||||
getFileService: vi.fn().mockReturnValue(mockFileDiscoveryService),
|
||||
getEnableRecursiveFileSearch: vi.fn(() => true),
|
||||
getFileFilteringOptions: vi.fn(() => ({
|
||||
respectGitIgnore: true,
|
||||
respectGeminiIgnore: true,
|
||||
})),
|
||||
} as unknown as Mocked<Config>;
|
||||
|
||||
mockCommandContext = {} as CommandContext;
|
||||
|
|
|
@ -15,6 +15,7 @@ import {
|
|||
getErrorMessage,
|
||||
Config,
|
||||
FileDiscoveryService,
|
||||
DEFAULT_FILE_FILTERING_OPTIONS,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
MAX_SUGGESTIONS_TO_SHOW,
|
||||
|
@ -415,10 +416,8 @@ export function useCompletion(
|
|||
const fileDiscoveryService = config ? config.getFileService() : null;
|
||||
const enableRecursiveSearch =
|
||||
config?.getEnableRecursiveFileSearch() ?? true;
|
||||
const filterOptions = {
|
||||
respectGitIgnore: config?.getFileFilteringRespectGitIgnore() ?? true,
|
||||
respectGeminiIgnore: true,
|
||||
};
|
||||
const filterOptions =
|
||||
config?.getFileFilteringOptions() ?? DEFAULT_FILE_FILTERING_OPTIONS;
|
||||
|
||||
try {
|
||||
// If there's no slash, or it's the root, do a recursive search from cwd
|
||||
|
|
|
@ -331,6 +331,7 @@ describe('Server Config (config.ts)', () => {
|
|||
config.getDebugMode(),
|
||||
config.getFileService(),
|
||||
config.getExtensionContextFilePaths(),
|
||||
config.getFileFilteringOptions(),
|
||||
);
|
||||
|
||||
expect(config.getUserMemory()).toBe(mockMemoryData.memoryContent);
|
||||
|
|
|
@ -76,7 +76,20 @@ export interface GeminiCLIExtension {
|
|||
version: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export interface FileFilteringOptions {
|
||||
respectGitIgnore: boolean;
|
||||
respectGeminiIgnore: boolean;
|
||||
}
|
||||
// For memory files
|
||||
export const DEFAULT_MEMORY_FILE_FILTERING_OPTIONS: FileFilteringOptions = {
|
||||
respectGitIgnore: false,
|
||||
respectGeminiIgnore: true,
|
||||
};
|
||||
// For all other files
|
||||
export const DEFAULT_FILE_FILTERING_OPTIONS: FileFilteringOptions = {
|
||||
respectGitIgnore: true,
|
||||
respectGeminiIgnore: true,
|
||||
};
|
||||
export class MCPServerConfig {
|
||||
constructor(
|
||||
// For stdio transport
|
||||
|
@ -137,6 +150,7 @@ export interface ConfigParameters {
|
|||
usageStatisticsEnabled?: boolean;
|
||||
fileFiltering?: {
|
||||
respectGitIgnore?: boolean;
|
||||
respectGeminiIgnore?: boolean;
|
||||
enableRecursiveFileSearch?: boolean;
|
||||
};
|
||||
checkpointing?: boolean;
|
||||
|
@ -182,6 +196,7 @@ export class Config {
|
|||
private geminiClient!: GeminiClient;
|
||||
private readonly fileFiltering: {
|
||||
respectGitIgnore: boolean;
|
||||
respectGeminiIgnore: boolean;
|
||||
enableRecursiveFileSearch: boolean;
|
||||
};
|
||||
private fileDiscoveryService: FileDiscoveryService | null = null;
|
||||
|
@ -239,6 +254,7 @@ export class Config {
|
|||
|
||||
this.fileFiltering = {
|
||||
respectGitIgnore: params.fileFiltering?.respectGitIgnore ?? true,
|
||||
respectGeminiIgnore: params.fileFiltering?.respectGeminiIgnore ?? true,
|
||||
enableRecursiveFileSearch:
|
||||
params.fileFiltering?.enableRecursiveFileSearch ?? true,
|
||||
};
|
||||
|
@ -473,6 +489,16 @@ export class Config {
|
|||
getFileFilteringRespectGitIgnore(): boolean {
|
||||
return this.fileFiltering.respectGitIgnore;
|
||||
}
|
||||
getFileFilteringRespectGeminiIgnore(): boolean {
|
||||
return this.fileFiltering.respectGeminiIgnore;
|
||||
}
|
||||
|
||||
getFileFilteringOptions(): FileFilteringOptions {
|
||||
return {
|
||||
respectGitIgnore: this.fileFiltering.respectGitIgnore,
|
||||
respectGeminiIgnore: this.fileFiltering.respectGeminiIgnore,
|
||||
};
|
||||
}
|
||||
|
||||
getCheckpointingEnabled(): boolean {
|
||||
return this.checkpointing;
|
||||
|
@ -549,6 +575,7 @@ export class Config {
|
|||
this.getDebugMode(),
|
||||
this.getFileService(),
|
||||
this.getExtensionContextFilePaths(),
|
||||
this.getFileFilteringOptions(),
|
||||
);
|
||||
|
||||
this.setUserMemory(memoryContent);
|
||||
|
|
|
@ -10,7 +10,7 @@ import { BaseTool, Icon, ToolResult } from './tools.js';
|
|||
import { Type } from '@google/genai';
|
||||
import { SchemaValidator } from '../utils/schemaValidator.js';
|
||||
import { makeRelative, shortenPath } from '../utils/paths.js';
|
||||
import { Config } from '../config/config.js';
|
||||
import { Config, DEFAULT_FILE_FILTERING_OPTIONS } from '../config/config.js';
|
||||
import { isWithinRoot } from '../utils/fileUtils.js';
|
||||
|
||||
/**
|
||||
|
@ -28,9 +28,12 @@ export interface LSToolParams {
|
|||
ignore?: string[];
|
||||
|
||||
/**
|
||||
* Whether to respect .gitignore patterns (optional, defaults to true)
|
||||
* Whether to respect .gitignore and .geminiignore patterns (optional, defaults to true)
|
||||
*/
|
||||
respect_git_ignore?: boolean;
|
||||
file_filtering_options?: {
|
||||
respect_git_ignore?: boolean;
|
||||
respect_gemini_ignore?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -89,10 +92,22 @@ export class LSTool extends BaseTool<LSToolParams, ToolResult> {
|
|||
},
|
||||
type: Type.ARRAY,
|
||||
},
|
||||
respect_git_ignore: {
|
||||
file_filtering_options: {
|
||||
description:
|
||||
'Optional: Whether to respect .gitignore patterns when listing files. Only available in git repositories. Defaults to true.',
|
||||
type: Type.BOOLEAN,
|
||||
'Optional: Whether to respect ignore patterns from .gitignore or .geminiignore',
|
||||
type: Type.OBJECT,
|
||||
properties: {
|
||||
respect_git_ignore: {
|
||||
description:
|
||||
'Optional: Whether to respect .gitignore patterns when listing files. Only available in git repositories. Defaults to true.',
|
||||
type: Type.BOOLEAN,
|
||||
},
|
||||
respect_gemini_ignore: {
|
||||
description:
|
||||
'Optional: Whether to respect .geminiignore patterns when listing files. Defaults to true.',
|
||||
type: Type.BOOLEAN,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['path'],
|
||||
|
@ -199,14 +214,25 @@ export class LSTool extends BaseTool<LSToolParams, ToolResult> {
|
|||
|
||||
const files = fs.readdirSync(params.path);
|
||||
|
||||
const defaultFileIgnores =
|
||||
this.config.getFileFilteringOptions() ?? DEFAULT_FILE_FILTERING_OPTIONS;
|
||||
|
||||
const fileFilteringOptions = {
|
||||
respectGitIgnore:
|
||||
params.file_filtering_options?.respect_git_ignore ??
|
||||
defaultFileIgnores.respectGitIgnore,
|
||||
respectGeminiIgnore:
|
||||
params.file_filtering_options?.respect_gemini_ignore ??
|
||||
defaultFileIgnores.respectGeminiIgnore,
|
||||
};
|
||||
|
||||
// Get centralized file discovery service
|
||||
const respectGitIgnore =
|
||||
params.respect_git_ignore ??
|
||||
this.config.getFileFilteringRespectGitIgnore();
|
||||
|
||||
const fileDiscovery = this.config.getFileService();
|
||||
|
||||
const entries: FileEntry[] = [];
|
||||
let gitIgnoredCount = 0;
|
||||
let geminiIgnoredCount = 0;
|
||||
|
||||
if (files.length === 0) {
|
||||
// Changed error message to be more neutral for LLM
|
||||
|
@ -227,14 +253,21 @@ export class LSTool extends BaseTool<LSToolParams, ToolResult> {
|
|||
fullPath,
|
||||
);
|
||||
|
||||
// Check if this file should be git-ignored (only in git repositories)
|
||||
// Check if this file should be ignored based on git or gemini ignore rules
|
||||
if (
|
||||
respectGitIgnore &&
|
||||
fileFilteringOptions.respectGitIgnore &&
|
||||
fileDiscovery.shouldGitIgnoreFile(relativePath)
|
||||
) {
|
||||
gitIgnoredCount++;
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
fileFilteringOptions.respectGeminiIgnore &&
|
||||
fileDiscovery.shouldGeminiIgnoreFile(relativePath)
|
||||
) {
|
||||
geminiIgnoredCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const stats = fs.statSync(fullPath);
|
||||
|
@ -265,13 +298,21 @@ export class LSTool extends BaseTool<LSToolParams, ToolResult> {
|
|||
.join('\n');
|
||||
|
||||
let resultMessage = `Directory listing for ${params.path}:\n${directoryContent}`;
|
||||
const ignoredMessages = [];
|
||||
if (gitIgnoredCount > 0) {
|
||||
resultMessage += `\n\n(${gitIgnoredCount} items were git-ignored)`;
|
||||
ignoredMessages.push(`${gitIgnoredCount} git-ignored`);
|
||||
}
|
||||
if (geminiIgnoredCount > 0) {
|
||||
ignoredMessages.push(`${geminiIgnoredCount} gemini-ignored`);
|
||||
}
|
||||
|
||||
if (ignoredMessages.length > 0) {
|
||||
resultMessage += `\n\n(${ignoredMessages.join(', ')})`;
|
||||
}
|
||||
|
||||
let displayMessage = `Listed ${entries.length} item(s).`;
|
||||
if (gitIgnoredCount > 0) {
|
||||
displayMessage += ` (${gitIgnoredCount} git-ignored)`;
|
||||
if (ignoredMessages.length > 0) {
|
||||
displayMessage += ` (${ignoredMessages.join(', ')})`;
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
|
@ -58,10 +58,13 @@ describe('ReadManyFilesTool', () => {
|
|||
const fileService = new FileDiscoveryService(tempRootDir);
|
||||
const mockConfig = {
|
||||
getFileService: () => fileService,
|
||||
getFileFilteringRespectGitIgnore: () => true,
|
||||
|
||||
getFileFilteringOptions: () => ({
|
||||
respectGitIgnore: true,
|
||||
respectGeminiIgnore: true,
|
||||
}),
|
||||
getTargetDir: () => tempRootDir,
|
||||
} as Partial<Config> as Config;
|
||||
|
||||
tool = new ReadManyFilesTool(mockConfig);
|
||||
|
||||
mockReadFileFn = mockControl.mockReadFile;
|
||||
|
|
|
@ -17,7 +17,7 @@ import {
|
|||
getSpecificMimeType,
|
||||
} from '../utils/fileUtils.js';
|
||||
import { PartListUnion, Schema, Type } from '@google/genai';
|
||||
import { Config } from '../config/config.js';
|
||||
import { Config, DEFAULT_FILE_FILTERING_OPTIONS } from '../config/config.js';
|
||||
import {
|
||||
recordFileOperationMetric,
|
||||
FileOperation,
|
||||
|
@ -62,9 +62,12 @@ export interface ReadManyFilesParams {
|
|||
useDefaultExcludes?: boolean;
|
||||
|
||||
/**
|
||||
* Optional. Whether to respect .gitignore patterns. Defaults to true.
|
||||
* Whether to respect .gitignore and .geminiignore patterns (optional, defaults to true)
|
||||
*/
|
||||
respect_git_ignore?: boolean;
|
||||
file_filtering_options?: {
|
||||
respect_git_ignore?: boolean;
|
||||
respect_gemini_ignore?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -173,11 +176,22 @@ export class ReadManyFilesTool extends BaseTool<
|
|||
'Optional. Whether to apply a list of default exclusion patterns (e.g., node_modules, .git, binary files). Defaults to true.',
|
||||
default: true,
|
||||
},
|
||||
respect_git_ignore: {
|
||||
type: Type.BOOLEAN,
|
||||
file_filtering_options: {
|
||||
description:
|
||||
'Optional. Whether to respect .gitignore patterns when discovering files. Only available in git repositories. Defaults to true.',
|
||||
default: true,
|
||||
'Whether to respect ignore patterns from .gitignore or .geminiignore',
|
||||
type: Type.OBJECT,
|
||||
properties: {
|
||||
respect_git_ignore: {
|
||||
description:
|
||||
'Optional: Whether to respect .gitignore patterns when listing files. Only available in git repositories. Defaults to true.',
|
||||
type: Type.BOOLEAN,
|
||||
},
|
||||
respect_gemini_ignore: {
|
||||
description:
|
||||
'Optional: Whether to respect .geminiignore patterns when listing files. Defaults to true.',
|
||||
type: Type.BOOLEAN,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['paths'],
|
||||
|
@ -257,12 +271,19 @@ Use this tool when the user's query implies needing the content of several files
|
|||
include = [],
|
||||
exclude = [],
|
||||
useDefaultExcludes = true,
|
||||
respect_git_ignore = true,
|
||||
} = params;
|
||||
|
||||
const respectGitIgnore =
|
||||
respect_git_ignore ?? this.config.getFileFilteringRespectGitIgnore();
|
||||
const defaultFileIgnores =
|
||||
this.config.getFileFilteringOptions() ?? DEFAULT_FILE_FILTERING_OPTIONS;
|
||||
|
||||
const fileFilteringOptions = {
|
||||
respectGitIgnore:
|
||||
params.file_filtering_options?.respect_git_ignore ??
|
||||
defaultFileIgnores.respectGitIgnore, // Use the property from the returned object
|
||||
respectGeminiIgnore:
|
||||
params.file_filtering_options?.respect_gemini_ignore ??
|
||||
defaultFileIgnores.respectGeminiIgnore, // Use the property from the returned object
|
||||
};
|
||||
// Get centralized file discovery service
|
||||
const fileDiscovery = this.config.getFileService();
|
||||
|
||||
|
@ -272,8 +293,8 @@ Use this tool when the user's query implies needing the content of several files
|
|||
const contentParts: PartListUnion = [];
|
||||
|
||||
const effectiveExcludes = useDefaultExcludes
|
||||
? [...DEFAULT_EXCLUDES, ...exclude, ...this.geminiIgnorePatterns]
|
||||
: [...exclude, ...this.geminiIgnorePatterns];
|
||||
? [...DEFAULT_EXCLUDES, ...exclude]
|
||||
: [...exclude];
|
||||
|
||||
const searchPatterns = [...inputPatterns, ...include];
|
||||
if (searchPatterns.length === 0) {
|
||||
|
@ -294,18 +315,36 @@ Use this tool when the user's query implies needing the content of several files
|
|||
signal,
|
||||
});
|
||||
|
||||
const filteredEntries = respectGitIgnore
|
||||
const gitFilteredEntries = fileFilteringOptions.respectGitIgnore
|
||||
? fileDiscovery
|
||||
.filterFiles(
|
||||
entries.map((p) => path.relative(this.config.getTargetDir(), p)),
|
||||
{
|
||||
respectGitIgnore,
|
||||
respectGitIgnore: true,
|
||||
respectGeminiIgnore: false,
|
||||
},
|
||||
)
|
||||
.map((p) => path.resolve(this.config.getTargetDir(), p))
|
||||
: entries;
|
||||
|
||||
// Apply gemini ignore filtering if enabled
|
||||
const finalFilteredEntries = fileFilteringOptions.respectGeminiIgnore
|
||||
? fileDiscovery
|
||||
.filterFiles(
|
||||
gitFilteredEntries.map((p) =>
|
||||
path.relative(this.config.getTargetDir(), p),
|
||||
),
|
||||
{
|
||||
respectGitIgnore: false,
|
||||
respectGeminiIgnore: true,
|
||||
},
|
||||
)
|
||||
.map((p) => path.resolve(this.config.getTargetDir(), p))
|
||||
: gitFilteredEntries;
|
||||
|
||||
let gitIgnoredCount = 0;
|
||||
let geminiIgnoredCount = 0;
|
||||
|
||||
for (const absoluteFilePath of entries) {
|
||||
// Security check: ensure the glob library didn't return something outside targetDir.
|
||||
if (!absoluteFilePath.startsWith(this.config.getTargetDir())) {
|
||||
|
@ -317,11 +356,23 @@ Use this tool when the user's query implies needing the content of several files
|
|||
}
|
||||
|
||||
// Check if this file was filtered out by git ignore
|
||||
if (respectGitIgnore && !filteredEntries.includes(absoluteFilePath)) {
|
||||
if (
|
||||
fileFilteringOptions.respectGitIgnore &&
|
||||
!gitFilteredEntries.includes(absoluteFilePath)
|
||||
) {
|
||||
gitIgnoredCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if this file was filtered out by gemini ignore
|
||||
if (
|
||||
fileFilteringOptions.respectGeminiIgnore &&
|
||||
!finalFilteredEntries.includes(absoluteFilePath)
|
||||
) {
|
||||
geminiIgnoredCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
filesToConsider.add(absoluteFilePath);
|
||||
}
|
||||
|
||||
|
@ -329,7 +380,15 @@ Use this tool when the user's query implies needing the content of several files
|
|||
if (gitIgnoredCount > 0) {
|
||||
skippedFiles.push({
|
||||
path: `${gitIgnoredCount} file(s)`,
|
||||
reason: 'ignored',
|
||||
reason: 'git ignored',
|
||||
});
|
||||
}
|
||||
|
||||
// Add info about gemini-ignored files if any were filtered
|
||||
if (geminiIgnoredCount > 0) {
|
||||
skippedFiles.push({
|
||||
path: `${geminiIgnoredCount} file(s)`,
|
||||
reason: 'gemini ignored',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
|
@ -145,4 +145,43 @@ describe('bfsFileSearch', () => {
|
|||
});
|
||||
expect(result).toEqual(['/test/subdir1/file1.txt']);
|
||||
});
|
||||
|
||||
it('should respect .geminiignore files', async () => {
|
||||
const mockFs = vi.mocked(fsPromises);
|
||||
const mockGitUtils = vi.mocked(gitUtils);
|
||||
|
||||
mockGitUtils.isGitRepository.mockReturnValue(false);
|
||||
|
||||
const mockReaddir = mockFs.readdir as unknown as ReaddirWithFileTypes;
|
||||
vi.mocked(mockReaddir).mockImplementation(async (dir) => {
|
||||
if (dir === '/test') {
|
||||
return [
|
||||
createMockDirent('.geminiignore', true),
|
||||
createMockDirent('subdir1', false),
|
||||
createMockDirent('subdir2', false),
|
||||
];
|
||||
}
|
||||
if (dir === '/test/subdir1') {
|
||||
return [createMockDirent('file1.txt', true)];
|
||||
}
|
||||
if (dir === '/test/subdir2') {
|
||||
return [createMockDirent('file1.txt', true)];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
vi.mocked(fs).readFileSync.mockReturnValue('subdir2');
|
||||
|
||||
const fileService = new FileDiscoveryService('/test');
|
||||
const result = await bfsFileSearch('/test', {
|
||||
fileName: 'file1.txt',
|
||||
fileService,
|
||||
fileFilteringOptions: {
|
||||
respectGitIgnore: true,
|
||||
respectGeminiIgnore: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual(['/test/subdir1/file1.txt']);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -8,7 +8,7 @@ import * as fs from 'fs/promises';
|
|||
import * as path from 'path';
|
||||
import { Dirent } from 'fs';
|
||||
import { FileDiscoveryService } from '../services/fileDiscoveryService.js';
|
||||
|
||||
import { FileFilteringOptions } from '../config/config.js';
|
||||
// Simple console logger for now.
|
||||
// TODO: Integrate with a more robust server-side logger.
|
||||
const logger = {
|
||||
|
@ -22,6 +22,7 @@ interface BfsFileSearchOptions {
|
|||
maxDirs?: number;
|
||||
debug?: boolean;
|
||||
fileService?: FileDiscoveryService;
|
||||
fileFilteringOptions?: FileFilteringOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -69,7 +70,13 @@ export async function bfsFileSearch(
|
|||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(currentDir, entry.name);
|
||||
if (fileService?.shouldGitIgnoreFile(fullPath)) {
|
||||
if (
|
||||
fileService?.shouldIgnoreFile(fullPath, {
|
||||
respectGitIgnore: options.fileFilteringOptions?.respectGitIgnore,
|
||||
respectGeminiIgnore:
|
||||
options.fileFilteringOptions?.respectGeminiIgnore,
|
||||
})
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
|
@ -307,6 +307,7 @@ describe('getFolderStructure gitignore', () => {
|
|||
createDirent('file1.txt', 'file'),
|
||||
createDirent('node_modules', 'dir'),
|
||||
createDirent('ignored.txt', 'file'),
|
||||
createDirent('gem_ignored.txt', 'file'),
|
||||
createDirent('.gemini', 'dir'),
|
||||
] as any;
|
||||
}
|
||||
|
@ -327,6 +328,9 @@ describe('getFolderStructure gitignore', () => {
|
|||
if (path === '/test/project/.gitignore') {
|
||||
return 'ignored.txt\nnode_modules/\n.gemini/\n!/.gemini/config.yaml';
|
||||
}
|
||||
if (path === '/test/project/.geminiignore') {
|
||||
return 'gem_ignored.txt\nnode_modules/\n.gemini/\n!/.gemini/config.yaml';
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
|
@ -347,10 +351,37 @@ describe('getFolderStructure gitignore', () => {
|
|||
const fileService = new FileDiscoveryService('/test/project');
|
||||
const structure = await getFolderStructure('/test/project', {
|
||||
fileService,
|
||||
respectGitIgnore: false,
|
||||
fileFilteringOptions: {
|
||||
respectGeminiIgnore: false,
|
||||
respectGitIgnore: false,
|
||||
},
|
||||
});
|
||||
expect(structure).toContain('ignored.txt');
|
||||
// node_modules is still ignored by default
|
||||
expect(structure).toContain('node_modules/...');
|
||||
});
|
||||
|
||||
it('should ignore files and folders specified in .geminiignore', async () => {
|
||||
const fileService = new FileDiscoveryService('/test/project');
|
||||
const structure = await getFolderStructure('/test/project', {
|
||||
fileService,
|
||||
});
|
||||
expect(structure).not.toContain('gem_ignored.txt');
|
||||
expect(structure).toContain('node_modules/...');
|
||||
expect(structure).not.toContain('logs.json');
|
||||
});
|
||||
|
||||
it('should not ignore files if respectGeminiIgnore is false', async () => {
|
||||
const fileService = new FileDiscoveryService('/test/project');
|
||||
const structure = await getFolderStructure('/test/project', {
|
||||
fileService,
|
||||
fileFilteringOptions: {
|
||||
respectGeminiIgnore: false,
|
||||
respectGitIgnore: true, // Explicitly disable gemini ignore only
|
||||
},
|
||||
});
|
||||
expect(structure).toContain('gem_ignored.txt');
|
||||
// node_modules is still ignored by default
|
||||
expect(structure).toContain('node_modules/...');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -9,6 +9,8 @@ import { Dirent } from 'fs';
|
|||
import * as path from 'path';
|
||||
import { getErrorMessage, isNodeError } from './errors.js';
|
||||
import { FileDiscoveryService } from '../services/fileDiscoveryService.js';
|
||||
import { FileFilteringOptions } from '../config/config.js';
|
||||
import { DEFAULT_FILE_FILTERING_OPTIONS } from '../config/config.js';
|
||||
|
||||
const MAX_ITEMS = 200;
|
||||
const TRUNCATION_INDICATOR = '...';
|
||||
|
@ -26,16 +28,16 @@ interface FolderStructureOptions {
|
|||
fileIncludePattern?: RegExp;
|
||||
/** For filtering files. */
|
||||
fileService?: FileDiscoveryService;
|
||||
/** Whether to use .gitignore patterns. */
|
||||
respectGitIgnore?: boolean;
|
||||
/** File filtering ignore options. */
|
||||
fileFilteringOptions?: FileFilteringOptions;
|
||||
}
|
||||
|
||||
// Define a type for the merged options where fileIncludePattern remains optional
|
||||
type MergedFolderStructureOptions = Required<
|
||||
Omit<FolderStructureOptions, 'fileIncludePattern' | 'fileService'>
|
||||
> & {
|
||||
fileIncludePattern?: RegExp;
|
||||
fileService?: FileDiscoveryService;
|
||||
fileFilteringOptions?: FileFilteringOptions;
|
||||
};
|
||||
|
||||
/** Represents the full, unfiltered information about a folder and its contents. */
|
||||
|
@ -126,8 +128,13 @@ async function readFullStructure(
|
|||
}
|
||||
const fileName = entry.name;
|
||||
const filePath = path.join(currentPath, fileName);
|
||||
if (options.respectGitIgnore && options.fileService) {
|
||||
if (options.fileService.shouldGitIgnoreFile(filePath)) {
|
||||
if (options.fileService) {
|
||||
const shouldIgnore =
|
||||
(options.fileFilteringOptions.respectGitIgnore &&
|
||||
options.fileService.shouldGitIgnoreFile(filePath)) ||
|
||||
(options.fileFilteringOptions.respectGeminiIgnore &&
|
||||
options.fileService.shouldGeminiIgnoreFile(filePath));
|
||||
if (shouldIgnore) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
@ -160,14 +167,16 @@ async function readFullStructure(
|
|||
const subFolderName = entry.name;
|
||||
const subFolderPath = path.join(currentPath, subFolderName);
|
||||
|
||||
let isIgnoredByGit = false;
|
||||
if (options.respectGitIgnore && options.fileService) {
|
||||
if (options.fileService.shouldGitIgnoreFile(subFolderPath)) {
|
||||
isIgnoredByGit = true;
|
||||
}
|
||||
let isIgnored = false;
|
||||
if (options.fileService) {
|
||||
isIgnored =
|
||||
(options.fileFilteringOptions.respectGitIgnore &&
|
||||
options.fileService.shouldGitIgnoreFile(subFolderPath)) ||
|
||||
(options.fileFilteringOptions.respectGeminiIgnore &&
|
||||
options.fileService.shouldGeminiIgnoreFile(subFolderPath));
|
||||
}
|
||||
|
||||
if (options.ignoredFolders.has(subFolderName) || isIgnoredByGit) {
|
||||
if (options.ignoredFolders.has(subFolderName) || isIgnored) {
|
||||
const ignoredSubFolder: FullFolderInfo = {
|
||||
name: subFolderName,
|
||||
path: subFolderPath,
|
||||
|
@ -295,7 +304,8 @@ export async function getFolderStructure(
|
|||
ignoredFolders: options?.ignoredFolders ?? DEFAULT_IGNORED_FOLDERS,
|
||||
fileIncludePattern: options?.fileIncludePattern,
|
||||
fileService: options?.fileService,
|
||||
respectGitIgnore: options?.respectGitIgnore ?? true,
|
||||
fileFilteringOptions:
|
||||
options?.fileFilteringOptions ?? DEFAULT_FILE_FILTERING_OPTIONS,
|
||||
};
|
||||
|
||||
try {
|
||||
|
|
|
@ -15,6 +15,10 @@ import {
|
|||
} from '../tools/memoryTool.js';
|
||||
import { FileDiscoveryService } from '../services/fileDiscoveryService.js';
|
||||
import { processImports } from './memoryImportProcessor.js';
|
||||
import {
|
||||
DEFAULT_MEMORY_FILE_FILTERING_OPTIONS,
|
||||
FileFilteringOptions,
|
||||
} from '../config/config.js';
|
||||
|
||||
// Simple console logger, similar to the one previously in CLI's config.ts
|
||||
// TODO: Integrate with a more robust server-side logger if available/appropriate.
|
||||
|
@ -85,6 +89,7 @@ async function getGeminiMdFilePathsInternal(
|
|||
debugMode: boolean,
|
||||
fileService: FileDiscoveryService,
|
||||
extensionContextFilePaths: string[] = [],
|
||||
fileFilteringOptions: FileFilteringOptions,
|
||||
): Promise<string[]> {
|
||||
const allPaths = new Set<string>();
|
||||
const geminiMdFilenames = getAllGeminiMdFilenames();
|
||||
|
@ -181,11 +186,18 @@ async function getGeminiMdFilePathsInternal(
|
|||
}
|
||||
upwardPaths.forEach((p) => allPaths.add(p));
|
||||
|
||||
// Merge options with memory defaults, with options taking precedence
|
||||
const mergedOptions = {
|
||||
...DEFAULT_MEMORY_FILE_FILTERING_OPTIONS,
|
||||
...fileFilteringOptions,
|
||||
};
|
||||
|
||||
const downwardPaths = await bfsFileSearch(resolvedCwd, {
|
||||
fileName: geminiMdFilename,
|
||||
maxDirs: MAX_DIRECTORIES_TO_SCAN_FOR_MEMORY,
|
||||
debug: debugMode,
|
||||
fileService,
|
||||
fileFilteringOptions: mergedOptions, // Pass merged options as fileFilter
|
||||
});
|
||||
downwardPaths.sort(); // Sort for consistent ordering, though hierarchy might be more complex
|
||||
if (debugMode && downwardPaths.length > 0)
|
||||
|
@ -282,11 +294,13 @@ export async function loadServerHierarchicalMemory(
|
|||
debugMode: boolean,
|
||||
fileService: FileDiscoveryService,
|
||||
extensionContextFilePaths: string[] = [],
|
||||
fileFilteringOptions?: FileFilteringOptions,
|
||||
): Promise<{ memoryContent: string; fileCount: number }> {
|
||||
if (debugMode)
|
||||
logger.debug(
|
||||
`Loading server hierarchical memory for CWD: ${currentWorkingDirectory}`,
|
||||
);
|
||||
|
||||
// For the server, homedir() refers to the server process's home.
|
||||
// This is consistent with how MemoryTool already finds the global path.
|
||||
const userHomePath = homedir();
|
||||
|
@ -296,6 +310,7 @@ export async function loadServerHierarchicalMemory(
|
|||
debugMode,
|
||||
fileService,
|
||||
extensionContextFilePaths,
|
||||
fileFilteringOptions || DEFAULT_MEMORY_FILE_FILTERING_OPTIONS,
|
||||
);
|
||||
if (filePaths.length === 0) {
|
||||
if (debugMode) logger.debug('No GEMINI.md files found in hierarchy.');
|
||||
|
|
Loading…
Reference in New Issue