diff --git a/packages/cli/src/ui/hooks/atCommandProcessor.test.ts b/packages/cli/src/ui/hooks/atCommandProcessor.test.ts index 6e272b24..de05667e 100644 --- a/packages/cli/src/ui/hooks/atCommandProcessor.test.ts +++ b/packages/cli/src/ui/hooks/atCommandProcessor.test.ts @@ -5,117 +5,74 @@ */ import { describe, it, expect, vi, beforeEach, afterEach, Mock } from 'vitest'; -import type { Mocked } from 'vitest'; import { handleAtCommand } from './atCommandProcessor.js'; -import { Config, FileDiscoveryService } from '@google/gemini-cli-core'; +import { + Config, + FileDiscoveryService, + GlobTool, + ReadManyFilesTool, + ToolRegistry, +} from '@google/gemini-cli-core'; +import * as os from 'os'; import { ToolCallStatus } from '../types.js'; import { UseHistoryManagerReturn } from './useHistoryManager.js'; import * as fsPromises from 'fs/promises'; -import type { Stats } from 'fs'; - -const mockGetToolRegistry = vi.fn(); -const mockGetTargetDir = vi.fn(); -const mockConfig = { - getToolRegistry: mockGetToolRegistry, - getTargetDir: mockGetTargetDir, - 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; - -const mockReadManyFilesExecute = vi.fn(); -const mockReadManyFilesTool = { - name: 'read_many_files', - displayName: 'Read Many Files', - description: 'Reads multiple files.', - execute: mockReadManyFilesExecute, - getDescription: vi.fn((params) => `Read files: ${params.paths.join(', ')}`), -}; - -const mockGlobExecute = vi.fn(); -const mockGlobTool = { - name: 'glob', - displayName: 'Glob Tool', - execute: mockGlobExecute, - getDescription: vi.fn(() => 'Glob tool description'), -}; - -const mockAddItem: Mock = vi.fn(); -const mockOnDebugMessage: Mock<(message: string) => void> = vi.fn(); - -vi.mock('fs/promises', async () => { - const actual = await vi.importActual('fs/promises'); - return { - ...actual, - stat: vi.fn(), - }; -}); - -vi.mock('@google/gemini-cli-core', async () => { - const actual = await vi.importActual('@google/gemini-cli-core'); - return { - ...actual, - FileDiscoveryService: vi.fn(), - }; -}); +import * as path from 'path'; describe('handleAtCommand', () => { + let testRootDir: string; + let mockConfig: Config; + + const mockAddItem: Mock = vi.fn(); + const mockOnDebugMessage: Mock<(message: string) => void> = vi.fn(); + let abortController: AbortController; - let mockFileDiscoveryService: Mocked; - beforeEach(() => { + async function createTestFile(fullPath: string, fileContents: string) { + await fsPromises.mkdir(path.dirname(fullPath), { recursive: true }); + await fsPromises.writeFile(fullPath, fileContents); + return path.resolve(testRootDir, fullPath); + } + + beforeEach(async () => { vi.resetAllMocks(); - abortController = new AbortController(); - mockGetTargetDir.mockReturnValue('/test/dir'); - mockGetToolRegistry.mockReturnValue({ - getTool: vi.fn((toolName: string) => { - if (toolName === 'read_many_files') return mockReadManyFilesTool; - if (toolName === 'glob') return mockGlobTool; - return undefined; - }), - }); - vi.mocked(fsPromises.stat).mockResolvedValue({ - isDirectory: () => false, - } as Stats); - mockReadManyFilesExecute.mockResolvedValue({ - llmContent: '', - returnDisplay: '', - }); - mockGlobExecute.mockResolvedValue({ - llmContent: 'No files found', - returnDisplay: '', - }); - // Mock FileDiscoveryService - mockFileDiscoveryService = { - initialize: vi.fn(), - shouldIgnoreFile: vi.fn(() => false), - filterFiles: vi.fn((files) => files), - getIgnoreInfo: vi.fn(() => ({ gitIgnored: [] })), - isGitRepository: vi.fn(() => true), - }; - vi.mocked(FileDiscoveryService).mockImplementation( - () => mockFileDiscoveryService, + testRootDir = await fsPromises.mkdtemp( + path.join(os.tmpdir(), 'folder-structure-test-'), ); - // Mock getFileService to return the mocked FileDiscoveryService - mockConfig.getFileService = vi - .fn() - .mockReturnValue(mockFileDiscoveryService); + abortController = new AbortController(); + + const getToolRegistry = vi.fn(); + + mockConfig = { + getToolRegistry, + getTargetDir: () => testRootDir, + isSandboxed: () => false, + getFileService: () => new FileDiscoveryService(testRootDir), + getFileFilteringRespectGitIgnore: () => true, + getFileFilteringRespectGeminiIgnore: () => true, + getFileFilteringOptions: () => ({ + respectGitIgnore: true, + respectGeminiIgnore: true, + }), + getEnableRecursiveFileSearch: vi.fn(() => true), + } as unknown as Config; + + const registry = new ToolRegistry(mockConfig); + registry.registerTool(new ReadManyFilesTool(mockConfig)); + registry.registerTool(new GlobTool(mockConfig)); + getToolRegistry.mockReturnValue(registry); }); - afterEach(() => { + afterEach(async () => { abortController.abort(); + await fsPromises.rm(testRootDir, { recursive: true, force: true }); }); it('should pass through query if no @ command is present', async () => { const query = 'regular user query'; + const result = await handleAtCommand({ query, config: mockConfig, @@ -124,17 +81,20 @@ describe('handleAtCommand', () => { messageId: 123, signal: abortController.signal, }); + + expect(result).toEqual({ + processedQuery: [{ text: query }], + shouldProceed: true, + }); expect(mockAddItem).toHaveBeenCalledWith( { type: 'user', text: query }, 123, ); - expect(result.processedQuery).toEqual([{ text: query }]); - expect(result.shouldProceed).toBe(true); - expect(mockReadManyFilesExecute).not.toHaveBeenCalled(); }); it('should pass through original query if only a lone @ symbol is present', async () => { const queryWithSpaces = ' @ '; + const result = await handleAtCommand({ query: queryWithSpaces, config: mockConfig, @@ -143,25 +103,27 @@ describe('handleAtCommand', () => { messageId: 124, signal: abortController.signal, }); + + expect(result).toEqual({ + processedQuery: [{ text: queryWithSpaces }], + shouldProceed: true, + }); expect(mockAddItem).toHaveBeenCalledWith( { type: 'user', text: queryWithSpaces }, 124, ); - expect(result.processedQuery).toEqual([{ text: queryWithSpaces }]); - expect(result.shouldProceed).toBe(true); expect(mockOnDebugMessage).toHaveBeenCalledWith( 'Lone @ detected, will be treated as text in the modified query.', ); }); it('should process a valid text file path', async () => { - const filePath = 'path/to/file.txt'; - const query = `@${filePath}`; const fileContent = 'This is the file content.'; - mockReadManyFilesExecute.mockResolvedValue({ - llmContent: [`--- ${filePath} ---\n\n${fileContent}\n\n`], - returnDisplay: 'Read 1 file.', - }); + const filePath = await createTestFile( + path.join(testRootDir, 'path', 'to', 'file.txt'), + fileContent, + ); + const query = `@${filePath}`; const result = await handleAtCommand({ query, @@ -171,20 +133,21 @@ describe('handleAtCommand', () => { messageId: 125, signal: abortController.signal, }); + + expect(result).toEqual({ + processedQuery: [ + { text: `@${filePath}` }, + { text: '\n--- Content from referenced files ---' }, + { text: `\nContent from @${filePath}:\n` }, + { text: fileContent }, + { text: '\n--- End of content ---' }, + ], + shouldProceed: true, + }); expect(mockAddItem).toHaveBeenCalledWith( { type: 'user', text: query }, 125, ); - expect(mockReadManyFilesExecute).toHaveBeenCalledWith( - { - paths: [filePath], - file_filtering_options: { - respect_git_ignore: true, - respect_gemini_ignore: true, - }, - }, - abortController.signal, - ); expect(mockAddItem).toHaveBeenCalledWith( expect.objectContaining({ type: 'tool_group', @@ -192,28 +155,17 @@ describe('handleAtCommand', () => { }), 125, ); - expect(result.processedQuery).toEqual([ - { text: `@${filePath}` }, - { text: '\n--- Content from referenced files ---' }, - { text: `\nContent from @${filePath}:\n` }, - { text: fileContent }, - { text: '\n--- End of content ---' }, - ]); - expect(result.shouldProceed).toBe(true); }); it('should process a valid directory path and convert to glob', async () => { - const dirPath = 'path/to/dir'; + const fileContent = 'This is the file content.'; + const filePath = await createTestFile( + path.join(testRootDir, 'path', 'to', 'file.txt'), + fileContent, + ); + const dirPath = path.dirname(filePath); const query = `@${dirPath}`; const resolvedGlob = `${dirPath}/**`; - const fileContent = 'Directory content.'; - vi.mocked(fsPromises.stat).mockResolvedValue({ - isDirectory: () => true, - } as Stats); - mockReadManyFilesExecute.mockResolvedValue({ - llmContent: [`--- ${resolvedGlob} ---\n\n${fileContent}\n\n`], - returnDisplay: 'Read directory contents.', - }); const result = await handleAtCommand({ query, @@ -223,76 +175,35 @@ describe('handleAtCommand', () => { messageId: 126, signal: abortController.signal, }); + + expect(result).toEqual({ + processedQuery: [ + { text: `@${resolvedGlob}` }, + { text: '\n--- Content from referenced files ---' }, + { text: `\nContent from @${filePath}:\n` }, + { text: fileContent }, + { text: '\n--- End of content ---' }, + ], + shouldProceed: true, + }); expect(mockAddItem).toHaveBeenCalledWith( { type: 'user', text: query }, 126, ); - expect(mockReadManyFilesExecute).toHaveBeenCalledWith( - { - paths: [resolvedGlob], - file_filtering_options: { - respect_git_ignore: true, - respect_gemini_ignore: true, - }, - }, - abortController.signal, - ); expect(mockOnDebugMessage).toHaveBeenCalledWith( `Path ${dirPath} resolved to directory, using glob: ${resolvedGlob}`, ); - expect(result.processedQuery).toEqual([ - { text: `@${resolvedGlob}` }, - { text: '\n--- Content from referenced files ---' }, - { text: `\nContent from @${resolvedGlob}:\n` }, - { text: fileContent }, - { text: '\n--- End of content ---' }, - ]); - expect(result.shouldProceed).toBe(true); - }); - - it('should process a valid image file path (as text content for now)', async () => { - const imagePath = 'path/to/image.png'; - const query = `@${imagePath}`; - // For @-commands, read_many_files is expected to return text or structured text. - // If it were to return actual image Part, the test and handling would be different. - // Current implementation of read_many_files for images returns base64 in text. - const imageFileTextContent = '[base64 image data for path/to/image.png]'; - const imagePart = { - mimeType: 'image/png', - inlineData: imageFileTextContent, - }; - mockReadManyFilesExecute.mockResolvedValue({ - llmContent: [imagePart], - returnDisplay: 'Read 1 image.', - }); - - const result = await handleAtCommand({ - query, - config: mockConfig, - addItem: mockAddItem, - onDebugMessage: mockOnDebugMessage, - messageId: 127, - signal: abortController.signal, - }); - expect(result.processedQuery).toEqual([ - { text: `@${imagePath}` }, - { text: '\n--- Content from referenced files ---' }, - imagePart, - { text: '\n--- End of content ---' }, - ]); - expect(result.shouldProceed).toBe(true); }); it('should handle query with text before and after @command', async () => { + const fileContent = 'Markdown content.'; + const filePath = await createTestFile( + path.join(testRootDir, 'doc.md'), + fileContent, + ); const textBefore = 'Explain this: '; - const filePath = 'doc.md'; const textAfter = ' in detail.'; const query = `${textBefore}@${filePath}${textAfter}`; - const fileContent = 'Markdown content.'; - mockReadManyFilesExecute.mockResolvedValue({ - llmContent: [`--- ${filePath} ---\n\n${fileContent}\n\n`], - returnDisplay: 'Read 1 doc.', - }); const result = await handleAtCommand({ query, @@ -302,64 +213,76 @@ describe('handleAtCommand', () => { messageId: 128, signal: abortController.signal, }); + + expect(result).toEqual({ + processedQuery: [ + { text: `${textBefore}@${filePath}${textAfter}` }, + { text: '\n--- Content from referenced files ---' }, + { text: `\nContent from @${filePath}:\n` }, + { text: fileContent }, + { text: '\n--- End of content ---' }, + ], + shouldProceed: true, + }); expect(mockAddItem).toHaveBeenCalledWith( { type: 'user', text: query }, 128, ); - expect(result.processedQuery).toEqual([ - { text: `${textBefore}@${filePath}${textAfter}` }, - { text: '\n--- Content from referenced files ---' }, - { text: `\nContent from @${filePath}:\n` }, - { text: fileContent }, - { text: '\n--- End of content ---' }, - ]); - expect(result.shouldProceed).toBe(true); }); it('should correctly unescape paths with escaped spaces', async () => { - const rawPath = 'path/to/my\\ file.txt'; - const unescapedPath = 'path/to/my file.txt'; - const query = `@${rawPath}`; - const fileContent = 'Content of file with space.'; - mockReadManyFilesExecute.mockResolvedValue({ - llmContent: [`--- ${unescapedPath} ---\n\n${fileContent}\n\n`], - returnDisplay: 'Read 1 file.', - }); + const fileContent = 'This is the file content.'; + const filePath = await createTestFile( + path.join(testRootDir, 'path', 'to', 'my file.txt'), + fileContent, + ); + const escapedpath = path.join(testRootDir, 'path', 'to', 'my\\ file.txt'); + const query = `@${escapedpath}`; - await handleAtCommand({ + const result = await handleAtCommand({ query, config: mockConfig, addItem: mockAddItem, onDebugMessage: mockOnDebugMessage, - messageId: 129, + messageId: 125, signal: abortController.signal, }); - expect(mockReadManyFilesExecute).toHaveBeenCalledWith( - { - paths: [unescapedPath], - file_filtering_options: { - respect_git_ignore: true, - respect_gemini_ignore: true, - }, - }, - abortController.signal, + + expect(result).toEqual({ + processedQuery: [ + { text: `@${filePath}` }, + { text: '\n--- Content from referenced files ---' }, + { text: `\nContent from @${filePath}:\n` }, + { text: fileContent }, + { text: '\n--- End of content ---' }, + ], + shouldProceed: true, + }); + expect(mockAddItem).toHaveBeenCalledWith( + { type: 'user', text: query }, + 125, + ); + expect(mockAddItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'tool_group', + tools: [expect.objectContaining({ status: ToolCallStatus.Success })], + }), + 125, ); }); it('should handle multiple @file references', async () => { - const file1 = 'file1.txt'; const content1 = 'Content file1'; - const file2 = 'file2.md'; + const file1Path = await createTestFile( + path.join(testRootDir, 'file1.txt'), + content1, + ); const content2 = 'Content file2'; - const query = `@${file1} @${file2}`; - - mockReadManyFilesExecute.mockResolvedValue({ - llmContent: [ - `--- ${file1} ---\n\n${content1}\n\n`, - `--- ${file2} ---\n\n${content2}\n\n`, - ], - returnDisplay: 'Read 2 files.', - }); + const file2Path = await createTestFile( + path.join(testRootDir, 'file2.md'), + content2, + ); + const query = `@${file1Path} @${file2Path}`; const result = await handleAtCommand({ query, @@ -369,45 +292,36 @@ describe('handleAtCommand', () => { messageId: 130, signal: abortController.signal, }); - expect(mockReadManyFilesExecute).toHaveBeenCalledWith( - { - paths: [file1, file2], - file_filtering_options: { - respect_git_ignore: true, - respect_gemini_ignore: true, - }, - }, - abortController.signal, - ); - expect(result.processedQuery).toEqual([ - { text: `@${file1} @${file2}` }, - { text: '\n--- Content from referenced files ---' }, - { text: `\nContent from @${file1}:\n` }, - { text: content1 }, - { text: `\nContent from @${file2}:\n` }, - { text: content2 }, - { text: '\n--- End of content ---' }, - ]); - expect(result.shouldProceed).toBe(true); + + expect(result).toEqual({ + processedQuery: [ + { text: query }, + { text: '\n--- Content from referenced files ---' }, + { text: `\nContent from @${file1Path}:\n` }, + { text: content1 }, + { text: `\nContent from @${file2Path}:\n` }, + { text: content2 }, + { text: '\n--- End of content ---' }, + ], + shouldProceed: true, + }); }); it('should handle multiple @file references with interleaved text', async () => { const text1 = 'Check '; - const file1 = 'f1.txt'; const content1 = 'C1'; + const file1Path = await createTestFile( + path.join(testRootDir, 'f1.txt'), + content1, + ); const text2 = ' and '; - const file2 = 'f2.md'; const content2 = 'C2'; + const file2Path = await createTestFile( + path.join(testRootDir, 'f2.md'), + content2, + ); const text3 = ' please.'; - const query = `${text1}@${file1}${text2}@${file2}${text3}`; - - mockReadManyFilesExecute.mockResolvedValue({ - llmContent: [ - `--- ${file1} ---\n\n${content1}\n\n`, - `--- ${file2} ---\n\n${content2}\n\n`, - ], - returnDisplay: 'Read 2 files.', - }); + const query = `${text1}@${file1Path}${text2}@${file2Path}${text3}`; const result = await handleAtCommand({ query, @@ -417,67 +331,34 @@ describe('handleAtCommand', () => { messageId: 131, signal: abortController.signal, }); - expect(mockReadManyFilesExecute).toHaveBeenCalledWith( - { - paths: [file1, file2], - file_filtering_options: { - respect_git_ignore: true, - respect_gemini_ignore: true, - }, - }, - abortController.signal, - ); - expect(result.processedQuery).toEqual([ - { text: `${text1}@${file1}${text2}@${file2}${text3}` }, - { text: '\n--- Content from referenced files ---' }, - { text: `\nContent from @${file1}:\n` }, - { text: content1 }, - { text: `\nContent from @${file2}:\n` }, - { text: content2 }, - { text: '\n--- End of content ---' }, - ]); - expect(result.shouldProceed).toBe(true); + + expect(result).toEqual({ + processedQuery: [ + { text: query }, + { text: '\n--- Content from referenced files ---' }, + { text: `\nContent from @${file1Path}:\n` }, + { text: content1 }, + { text: `\nContent from @${file2Path}:\n` }, + { text: content2 }, + { text: '\n--- End of content ---' }, + ], + shouldProceed: true, + }); }); it('should handle a mix of valid, invalid, and lone @ references', async () => { - const file1 = 'valid1.txt'; const content1 = 'Valid content 1'; + const file1Path = await createTestFile( + path.join(testRootDir, 'valid1.txt'), + content1, + ); const invalidFile = 'nonexistent.txt'; - const query = `Look at @${file1} then @${invalidFile} and also just @ symbol, then @valid2.glob`; - const file2Glob = 'valid2.glob'; - const resolvedFile2 = 'resolved/valid2.actual'; const content2 = 'Globbed content'; - - // Mock fs.stat for file1 (valid) - vi.mocked(fsPromises.stat).mockImplementation(async (p) => { - if (p.toString().endsWith(file1)) - return { isDirectory: () => false } as Stats; - if (p.toString().endsWith(invalidFile)) - throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); - // For valid2.glob, stat will fail, triggering glob - if (p.toString().endsWith(file2Glob)) - throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); - return { isDirectory: () => false } as Stats; // Default - }); - - // Mock glob to find resolvedFile2 for valid2.glob - mockGlobExecute.mockImplementation(async (params) => { - if (params.pattern.includes('valid2.glob')) { - return { - llmContent: `Found files:\n${mockGetTargetDir()}/${resolvedFile2}`, - returnDisplay: 'Found 1 file', - }; - } - return { llmContent: 'No files found', returnDisplay: '' }; - }); - - mockReadManyFilesExecute.mockResolvedValue({ - llmContent: [ - `--- ${file1} ---\n\n${content1}\n\n`, - `--- ${resolvedFile2} ---\n\n${content2}\n\n`, - ], - returnDisplay: 'Read 2 files.', - }); + const file2Path = await createTestFile( + path.join(testRootDir, 'resolved', 'valid2.actual'), + content2, + ); + const query = `Look at @${file1Path} then @${invalidFile} and also just @ symbol, then @${file2Path}`; const result = await handleAtCommand({ query, @@ -488,29 +369,20 @@ describe('handleAtCommand', () => { signal: abortController.signal, }); - expect(mockReadManyFilesExecute).toHaveBeenCalledWith( - { - paths: [file1, resolvedFile2], - file_filtering_options: { - respect_git_ignore: true, - respect_gemini_ignore: true, + expect(result).toEqual({ + processedQuery: [ + { + text: `Look at @${file1Path} then @${invalidFile} and also just @ symbol, then @${file2Path}`, }, - }, - abortController.signal, - ); - expect(result.processedQuery).toEqual([ - // Original query has @nonexistent.txt and @, but resolved has @resolved/valid2.actual - { - text: `Look at @${file1} then @${invalidFile} and also just @ symbol, then @${resolvedFile2}`, - }, - { text: '\n--- Content from referenced files ---' }, - { text: `\nContent from @${file1}:\n` }, - { text: content1 }, - { text: `\nContent from @${resolvedFile2}:\n` }, - { text: content2 }, - { text: '\n--- End of content ---' }, - ]); - expect(result.shouldProceed).toBe(true); + { text: '\n--- Content from referenced files ---' }, + { text: `\nContent from @${file2Path}:\n` }, + { text: content2 }, + { text: `\nContent from @${file1Path}:\n` }, + { text: content1 }, + { text: '\n--- End of content ---' }, + ], + shouldProceed: true, + }); expect(mockOnDebugMessage).toHaveBeenCalledWith( `Path ${invalidFile} not found directly, attempting glob search.`, ); @@ -524,13 +396,6 @@ describe('handleAtCommand', () => { it('should return original query if all @paths are invalid or lone @', async () => { const query = 'Check @nonexistent.txt and @ also'; - vi.mocked(fsPromises.stat).mockRejectedValue( - Object.assign(new Error('ENOENT'), { code: 'ENOENT' }), - ); - mockGlobExecute.mockResolvedValue({ - llmContent: 'No files found', - returnDisplay: '', - }); const result = await handleAtCommand({ query, @@ -540,109 +405,31 @@ describe('handleAtCommand', () => { messageId: 133, signal: abortController.signal, }); - expect(mockReadManyFilesExecute).not.toHaveBeenCalled(); - // The modified query string will be "Check @nonexistent.txt and @ also" because no paths were resolved for reading. - expect(result.processedQuery).toEqual([ - { text: 'Check @nonexistent.txt and @ also' }, - ]); - expect(result.shouldProceed).toBe(true); - }); - - it('should process a file path case-insensitively', async () => { - // const actualFilePath = 'path/to/MyFile.txt'; // Unused, path in llmContent should match queryPath - const queryPath = 'path/to/myfile.txt'; // Different case - const query = `@${queryPath}`; - const fileContent = 'This is the case-insensitive file content.'; - - // Mock fs.stat to "find" MyFile.txt when looking for myfile.txt - // This simulates a case-insensitive file system or resolution - vi.mocked(fsPromises.stat).mockImplementation(async (p) => { - if (p.toString().toLowerCase().endsWith('myfile.txt')) { - return { - isDirectory: () => false, - // You might need to add other Stats properties if your code uses them - } as Stats; - } - throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + expect(result).toEqual({ + processedQuery: [{ text: 'Check @nonexistent.txt and @ also' }], + shouldProceed: true, }); - - mockReadManyFilesExecute.mockResolvedValue({ - llmContent: [`--- ${queryPath} ---\n\n${fileContent}\n\n`], - returnDisplay: 'Read 1 file.', - }); - - const result = await handleAtCommand({ - query, - config: mockConfig, - addItem: mockAddItem, - onDebugMessage: mockOnDebugMessage, - messageId: 134, // New messageId - signal: abortController.signal, - }); - - expect(mockAddItem).toHaveBeenCalledWith( - { type: 'user', text: query }, - 134, - ); - // The atCommandProcessor resolves the path before calling read_many_files. - // We expect it to be called with the path that fs.stat "found". - // In a real case-insensitive FS, stat(myfile.txt) might return info for MyFile.txt. - // The key is that *a* valid path that points to the content is used. - expect(mockReadManyFilesExecute).toHaveBeenCalledWith( - // Depending on how path resolution and fs.stat mock interact, - // this could be queryPath or actualFilePath. - // For this test, we'll assume the processor uses the path that stat "succeeded" with. - // If the underlying fs/stat is truly case-insensitive, it might resolve to actualFilePath. - // 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], - file_filtering_options: { - respect_git_ignore: true, - respect_gemini_ignore: true, - }, - }, - abortController.signal, - ); - expect(mockAddItem).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'tool_group', - tools: [expect.objectContaining({ status: ToolCallStatus.Success })], - }), - 134, - ); - expect(result.processedQuery).toEqual([ - { text: `@${queryPath}` }, // Query uses the input path - { text: '\n--- Content from referenced files ---' }, - { text: `\nContent from @${queryPath}:\n` }, // Content display also uses input path - { text: fileContent }, - { text: '\n--- End of content ---' }, - ]); - expect(result.shouldProceed).toBe(true); }); describe('git-aware filtering', () => { - it('should skip git-ignored files in @ commands', async () => { - const gitIgnoredFile = 'node_modules/package.json'; - const query = `@${gitIgnoredFile}`; + beforeEach(async () => { + await fsPromises.mkdir(path.join(testRootDir, '.git'), { + recursive: true, + }); + }); - // Mock the file discovery service to report this file as git-ignored - mockFileDiscoveryService.shouldIgnoreFile.mockImplementation( - ( - path: string, - options?: { - respectGitIgnore?: boolean; - respectGeminiIgnore?: boolean; - }, - ) => { - if (path !== gitIgnoredFile) return false; - if (options?.respectGitIgnore) return true; - if (options?.respectGeminiIgnore) return false; - return false; - }, + it('should skip git-ignored files in @ commands', async () => { + await createTestFile( + path.join(testRootDir, '.gitignore'), + 'node_modules/package.json', ); + const gitIgnoredFile = await createTestFile( + path.join(testRootDir, 'node_modules', 'package.json'), + 'the file contents', + ); + + const query = `@${gitIgnoredFile}`; const result = await handleAtCommand({ query, @@ -653,48 +440,29 @@ 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, respectGeminiIgnore: false }, - ); - expect(mockFileDiscoveryService.shouldIgnoreFile).toHaveBeenCalledWith( - gitIgnoredFile, - { respectGitIgnore: false, respectGeminiIgnore: true }, - ); - + expect(result).toEqual({ + processedQuery: [{ text: query }], + shouldProceed: true, + }); expect(mockOnDebugMessage).toHaveBeenCalledWith( `Path ${gitIgnoredFile} is git-ignored and will be skipped.`, ); expect(mockOnDebugMessage).toHaveBeenCalledWith( - 'Ignored 1 files:\nGit-ignored: node_modules/package.json', + `Ignored 1 files:\nGit-ignored: ${gitIgnoredFile}`, ); - expect(mockReadManyFilesExecute).not.toHaveBeenCalled(); - expect(result.processedQuery).toEqual([{ text: query }]); - expect(result.shouldProceed).toBe(true); }); it('should process non-git-ignored files normally', 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, + await createTestFile( + path.join(testRootDir, '.gitignore'), + 'node_modules/package.json', ); - mockReadManyFilesExecute.mockResolvedValue({ - llmContent: [`--- ${validFile} ---\n\n${fileContent}\n\n`], - returnDisplay: 'Read 1 file.', - }); + + const validFile = await createTestFile( + path.join(testRootDir, 'src', 'index.ts'), + 'console.log("Hello world");', + ); + const query = `@${validFile}`; const result = await handleAtCommand({ query, @@ -705,65 +473,29 @@ 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, 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); + expect(result).toEqual({ + processedQuery: [ + { text: `@${validFile}` }, + { text: '\n--- Content from referenced files ---' }, + { text: `\nContent from @${validFile}:\n` }, + { text: 'console.log("Hello world");' }, + { text: '\n--- End of content ---' }, + ], + shouldProceed: true, + }); }); it('should handle mixed git-ignored and valid files', async () => { - const validFile = 'README.md'; - const gitIgnoredFile = '.env'; - const query = `@${validFile} @${gitIgnoredFile}`; - const fileContent = '# Project README'; - - mockFileDiscoveryService.shouldIgnoreFile.mockImplementation( - ( - path: string, - options?: { - respectGitIgnore?: boolean; - respectGeminiIgnore?: boolean; - }, - ) => { - if (path === gitIgnoredFile && options?.respectGitIgnore) { - return true; - } - if (options?.respectGeminiIgnore) { - return false; - } - return false; - }, + await createTestFile(path.join(testRootDir, '.gitignore'), '.env'); + const validFile = await createTestFile( + path.join(testRootDir, 'README.md'), + '# Project README', ); - mockReadManyFilesExecute.mockResolvedValue({ - llmContent: [`--- ${validFile} ---\n\n${fileContent}\n\n`], - returnDisplay: 'Read 1 file.', - }); + const gitIgnoredFile = await createTestFile( + path.join(testRootDir, '.env'), + 'SECRET=123', + ); + const query = `@${validFile} @${gitIgnoredFile}`; const result = await handleAtCommand({ query, @@ -774,66 +506,30 @@ 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, respectGeminiIgnore: false }, - ); - expect(mockFileDiscoveryService.shouldIgnoreFile).toHaveBeenCalledWith( - validFile, - { respectGitIgnore: false, respectGeminiIgnore: true }, - ); - expect(mockFileDiscoveryService.shouldIgnoreFile).toHaveBeenCalledWith( - gitIgnoredFile, - { respectGitIgnore: true, respectGeminiIgnore: false }, - ); - expect(mockFileDiscoveryService.shouldIgnoreFile).toHaveBeenCalledWith( - gitIgnoredFile, - { respectGitIgnore: false, respectGeminiIgnore: true }, - ); + expect(result).toEqual({ + processedQuery: [ + { text: `@${validFile} @${gitIgnoredFile}` }, + { text: '\n--- Content from referenced files ---' }, + { text: `\nContent from @${validFile}:\n` }, + { text: '# Project README' }, + { text: '\n--- End of content ---' }, + ], + shouldProceed: true, + }); expect(mockOnDebugMessage).toHaveBeenCalledWith( `Path ${gitIgnoredFile} is git-ignored and will be skipped.`, ); expect(mockOnDebugMessage).toHaveBeenCalledWith( - 'Ignored 1 files:\nGit-ignored: .env', + `Ignored 1 files:\nGit-ignored: ${gitIgnoredFile}`, ); - expect(mockReadManyFilesExecute).toHaveBeenCalledWith( - { - paths: [validFile], - file_filtering_options: { - respect_git_ignore: true, - respect_gemini_ignore: true, - }, - }, - abortController.signal, - ); - expect(result.processedQuery).toEqual([ - { text: `@${validFile} @${gitIgnoredFile}` }, - { 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 always ignore .git directory files', async () => { - const gitFile = '.git/config'; - const query = `@${gitFile}`; - - // 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 gitFile = await createTestFile( + path.join(testRootDir, '.git', 'config'), + '[core]\n\trepositoryformatversion = 0\n', ); + const query = `@${gitFile}`; const result = await handleAtCommand({ query, @@ -844,27 +540,16 @@ 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, respectGeminiIgnore: false }, - ); - expect(mockFileDiscoveryService.shouldIgnoreFile).toHaveBeenCalledWith( - gitFile, - { respectGitIgnore: false, respectGeminiIgnore: true }, - ); + expect(result).toEqual({ + processedQuery: [{ text: query }], + shouldProceed: true, + }); expect(mockOnDebugMessage).toHaveBeenCalledWith( `Path ${gitFile} is git-ignored and will be skipped.`, ); expect(mockOnDebugMessage).toHaveBeenCalledWith( - 'Ignored 1 files:\nGit-ignored: .git/config', + `Ignored 1 files:\nGit-ignored: ${gitFile}`, ); - expect(mockReadManyFilesExecute).not.toHaveBeenCalled(); - expect(result.processedQuery).toEqual([{ text: query }]); - expect(result.shouldProceed).toBe(true); }); }); @@ -877,10 +562,6 @@ describe('handleAtCommand', () => { const invalidFile = 'nonexistent.txt'; const query = `@${invalidFile}`; - vi.mocked(fsPromises.stat).mockRejectedValue( - Object.assign(new Error('ENOENT'), { code: 'ENOENT' }), - ); - const result = await handleAtCommand({ query, config: mockConfig, @@ -890,7 +571,6 @@ describe('handleAtCommand', () => { signal: abortController.signal, }); - expect(mockGlobExecute).not.toHaveBeenCalled(); expect(mockOnDebugMessage).toHaveBeenCalledWith( `Glob tool not found. Path ${invalidFile} will be skipped.`, ); @@ -901,24 +581,15 @@ describe('handleAtCommand', () => { 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; - }, + await createTestFile( + path.join(testRootDir, '.geminiignore'), + 'build/output.js', ); + const geminiIgnoredFile = await createTestFile( + path.join(testRootDir, 'build', 'output.js'), + 'console.log("Hello");', + ); + const query = `@${geminiIgnoredFile}`; const result = await handleAtCommand({ query, @@ -929,177 +600,90 @@ 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( - geminiIgnoredFile, - { respectGitIgnore: true, respectGeminiIgnore: false }, - ); - expect(mockFileDiscoveryService.shouldIgnoreFile).toHaveBeenCalledWith( - geminiIgnoredFile, - { respectGitIgnore: false, respectGeminiIgnore: true }, - ); - + expect(result).toEqual({ + processedQuery: [{ text: query }], + shouldProceed: 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.', + `Ignored 1 files:\nGemini-ignored: ${geminiIgnoredFile}`, ); - 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 () => { + await createTestFile( + path.join(testRootDir, '.geminiignore'), + 'build/output.js', + ); + const validFile = await createTestFile( + path.join(testRootDir, 'src', 'index.ts'), + 'console.log("Hello world");', + ); + const query = `@${validFile}`; + + const result = await handleAtCommand({ + query, + config: mockConfig, + addItem: mockAddItem, + onDebugMessage: mockOnDebugMessage, + messageId: 205, + signal: abortController.signal, }); - 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([ + expect(result).toEqual({ + processedQuery: [ { text: `@${validFile}` }, { text: '\n--- Content from referenced files ---' }, { text: `\nContent from @${validFile}:\n` }, - { text: fileContent }, + { text: 'console.log("Hello world");' }, { text: '\n--- End of content ---' }, - ]); - expect(result.shouldProceed).toBe(true); + ], + shouldProceed: true, + }); + }); + + it('should handle mixed gemini-ignored and valid files', async () => { + await createTestFile( + path.join(testRootDir, '.geminiignore'), + 'dist/bundle.js', + ); + const validFile = await createTestFile( + path.join(testRootDir, 'src', 'main.ts'), + '// Main application entry', + ); + const geminiIgnoredFile = await createTestFile( + path.join(testRootDir, 'dist', 'bundle.js'), + 'console.log("bundle");', + ); + const query = `@${validFile} @${geminiIgnoredFile}`; + + const result = await handleAtCommand({ + query, + config: mockConfig, + addItem: mockAddItem, + onDebugMessage: mockOnDebugMessage, + messageId: 206, + signal: abortController.signal, }); - 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([ + expect(result).toEqual({ + processedQuery: [ { text: `@${validFile} @${geminiIgnoredFile}` }, { text: '\n--- Content from referenced files ---' }, { text: `\nContent from @${validFile}:\n` }, - { text: fileContent }, + { text: '// Main application entry' }, { text: '\n--- End of content ---' }, - ]); - expect(result.shouldProceed).toBe(true); + ], + shouldProceed: true, }); + expect(mockOnDebugMessage).toHaveBeenCalledWith( + `Path ${geminiIgnoredFile} is gemini-ignored and will be skipped.`, + ); + expect(mockOnDebugMessage).toHaveBeenCalledWith( + `Ignored 1 files:\nGemini-ignored: ${geminiIgnoredFile}`, + ); }); + // }); }); diff --git a/packages/cli/src/ui/hooks/atCommandProcessor.ts b/packages/cli/src/ui/hooks/atCommandProcessor.ts index 983abc62..237d983f 100644 --- a/packages/cli/src/ui/hooks/atCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/atCommandProcessor.ts @@ -222,14 +222,13 @@ export async function handleAtCommand({ const absolutePath = path.resolve(config.getTargetDir(), pathName); const stats = await fs.stat(absolutePath); if (stats.isDirectory()) { - currentPathSpec = pathName.endsWith('/') - ? `${pathName}**` - : `${pathName}/**`; + currentPathSpec = + pathName + (pathName.endsWith(path.sep) ? `**` : `/**`); onDebugMessage( `Path ${pathName} resolved to directory, using glob: ${currentPathSpec}`, ); } else { - onDebugMessage(`Path ${pathName} resolved to file: ${currentPathSpec}`); + onDebugMessage(`Path ${pathName} resolved to file: ${absolutePath}`); } resolvedSuccessfully = true; } catch (error) { @@ -240,7 +239,10 @@ export async function handleAtCommand({ ); try { const globResult = await globTool.execute( - { pattern: `**/*${pathName}*`, path: config.getTargetDir() }, + { + pattern: `**/*${pathName}*`, + path: config.getTargetDir(), + }, signal, ); if ( diff --git a/packages/core/src/tools/read-many-files.ts b/packages/core/src/tools/read-many-files.ts index 7ea6c722..fb160a79 100644 --- a/packages/core/src/tools/read-many-files.ts +++ b/packages/core/src/tools/read-many-files.ts @@ -128,8 +128,6 @@ export class ReadManyFilesTool extends BaseTool< > { static readonly Name: string = 'read_many_files'; - private readonly geminiIgnorePatterns: string[] = []; - constructor(private config: Config) { const parameterSchema: Schema = { type: Type.OBJECT, @@ -213,9 +211,6 @@ Use this tool when the user's query implies needing the content of several files Icon.FileSearch, parameterSchema, ); - this.geminiIgnorePatterns = config - .getFileService() - .getGeminiIgnorePatterns(); } validateParams(params: ReadManyFilesParams): string | null { @@ -233,17 +228,19 @@ Use this tool when the user's query implies needing the content of several files // Determine the final list of exclusion patterns exactly as in execute method const paramExcludes = params.exclude || []; const paramUseDefaultExcludes = params.useDefaultExcludes !== false; - + const geminiIgnorePatterns = this.config + .getFileService() + .getGeminiIgnorePatterns(); const finalExclusionPatternsForDescription: string[] = paramUseDefaultExcludes - ? [...DEFAULT_EXCLUDES, ...paramExcludes, ...this.geminiIgnorePatterns] - : [...paramExcludes, ...this.geminiIgnorePatterns]; + ? [...DEFAULT_EXCLUDES, ...paramExcludes, ...geminiIgnorePatterns] + : [...paramExcludes, ...geminiIgnorePatterns]; let excludeDesc = `Excluding: ${finalExclusionPatternsForDescription.length > 0 ? `patterns like \`${finalExclusionPatternsForDescription.slice(0, 2).join('`, `')}${finalExclusionPatternsForDescription.length > 2 ? '...`' : '`'}` : 'none specified'}`; // Add a note if .geminiignore patterns contributed to the final list of exclusions - if (this.geminiIgnorePatterns.length > 0) { - const geminiPatternsInEffect = this.geminiIgnorePatterns.filter((p) => + if (geminiIgnorePatterns.length > 0) { + const geminiPatternsInEffect = geminiIgnorePatterns.filter((p) => finalExclusionPatternsForDescription.includes(p), ).length; if (geminiPatternsInEffect > 0) { @@ -305,15 +302,18 @@ Use this tool when the user's query implies needing the content of several files } try { - const entries = await glob(searchPatterns, { - cwd: this.config.getTargetDir(), - ignore: effectiveExcludes, - nodir: true, - dot: true, - absolute: true, - nocase: true, - signal, - }); + const entries = await glob( + searchPatterns.map((p) => p.replace(/\\/g, '/')), + { + cwd: this.config.getTargetDir(), + ignore: effectiveExcludes, + nodir: true, + dot: true, + absolute: true, + nocase: true, + signal, + }, + ); const gitFilteredEntries = fileFilteringOptions.respectGitIgnore ? fileDiscovery diff --git a/packages/core/src/utils/getFolderStructure.test.ts b/packages/core/src/utils/getFolderStructure.test.ts index 8694fe20..3f1b4534 100644 --- a/packages/core/src/utils/getFolderStructure.test.ts +++ b/packages/core/src/utils/getFolderStructure.test.ts @@ -4,42 +4,37 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import fsPromises from 'fs/promises'; import * as nodePath from 'path'; import * as os from 'os'; import { getFolderStructure } from './getFolderStructure.js'; -import * as gitUtils from './gitUtils.js'; import { FileDiscoveryService } from '../services/fileDiscoveryService.js'; import * as path from 'path'; -vi.mock('./gitUtils.js'); - describe('getFolderStructure', () => { let testRootDir: string; - const createEmptyDir = async (...pathSegments: string[]) => { + async function createEmptyDir(...pathSegments: string[]) { const fullPath = path.join(testRootDir, ...pathSegments); await fsPromises.mkdir(fullPath, { recursive: true }); - }; + } - const createTestFile = async (...pathSegments: string[]) => { + async function createTestFile(...pathSegments: string[]) { const fullPath = path.join(testRootDir, ...pathSegments); await fsPromises.mkdir(path.dirname(fullPath), { recursive: true }); await fsPromises.writeFile(fullPath, ''); return fullPath; - }; + } beforeEach(async () => { testRootDir = await fsPromises.mkdtemp( path.join(os.tmpdir(), 'folder-structure-test-'), ); - vi.resetAllMocks(); }); afterEach(async () => { await fsPromises.rm(testRootDir, { recursive: true, force: true }); - vi.restoreAllMocks(); }); it('should return basic folder structure', async () => { @@ -246,8 +241,10 @@ ${testRootDir}${path.sep} }); describe('with gitignore', () => { - beforeEach(() => { - vi.mocked(gitUtils.isGitRepository).mockReturnValue(true); + beforeEach(async () => { + await fsPromises.mkdir(path.join(testRootDir, '.git'), { + recursive: true, + }); }); it('should ignore files and folders specified in .gitignore', async () => {