diff options
Diffstat (limited to 'packages/core/src/tools')
| -rw-r--r-- | packages/core/src/tools/glob.test.ts | 18 | ||||
| -rw-r--r-- | packages/core/src/tools/glob.ts | 64 | ||||
| -rw-r--r-- | packages/core/src/tools/ls.ts | 54 | ||||
| -rw-r--r-- | packages/core/src/tools/read-many-files.test.ts | 16 | ||||
| -rw-r--r-- | packages/core/src/tools/read-many-files.ts | 61 | ||||
| -rw-r--r-- | packages/core/src/tools/shell.ts | 27 |
6 files changed, 220 insertions, 20 deletions
diff --git a/packages/core/src/tools/glob.test.ts b/packages/core/src/tools/glob.test.ts index d42e5b1c..b630a0d8 100644 --- a/packages/core/src/tools/glob.test.ts +++ b/packages/core/src/tools/glob.test.ts @@ -11,16 +11,30 @@ import path from 'path'; import fs from 'fs/promises'; import os from 'os'; import { describe, it, expect, beforeEach, afterEach } from 'vitest'; // Removed vi +import { FileDiscoveryService } from '../services/fileDiscoveryService.js'; +import { Config } from '../config/config.js'; describe('GlobTool', () => { let tempRootDir: string; // This will be the rootDirectory for the GlobTool instance let globTool: GlobTool; const abortSignal = new AbortController().signal; + // Mock config for testing + const mockConfig = { + getFileService: async () => { + const service = new FileDiscoveryService(tempRootDir); + await service.initialize({ respectGitIgnore: true }); + return service; + }, + getFileFilteringRespectGitIgnore: () => true, + getFileFilteringCustomIgnorePatterns: () => [], + getFileFilteringAllowBuildArtifacts: () => false, + } as Partial<Config> as Config; + beforeEach(async () => { // Create a unique root directory for each test run tempRootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'glob-tool-root-')); - globTool = new GlobTool(tempRootDir); + globTool = new GlobTool(tempRootDir, mockConfig); // Create some test files and directories within this root // Top-level files @@ -214,7 +228,7 @@ describe('GlobTool', () => { it("should return error if search path resolves outside the tool's root directory", () => { // Create a globTool instance specifically for this test, with a deeper root const deeperRootDir = path.join(tempRootDir, 'sub'); - const specificGlobTool = new GlobTool(deeperRootDir); + const specificGlobTool = new GlobTool(deeperRootDir, mockConfig); // const params: GlobToolParams = { pattern: '*.txt', path: '..' }; // This line is unused and will be removed. // This should be fine as tempRootDir is still within the original tempRootDir (the parent of deeperRootDir) // Let's try to go further up. diff --git a/packages/core/src/tools/glob.ts b/packages/core/src/tools/glob.ts index 86aef44f..d4b479eb 100644 --- a/packages/core/src/tools/glob.ts +++ b/packages/core/src/tools/glob.ts @@ -10,6 +10,7 @@ import fg from 'fast-glob'; import { SchemaValidator } from '../utils/schemaValidator.js'; import { BaseTool, ToolResult } from './tools.js'; import { shortenPath, makeRelative } from '../utils/paths.js'; +import { Config } from '../config/config.js'; /** * Parameters for the GlobTool @@ -29,6 +30,11 @@ export interface GlobToolParams { * Whether the search should be case-sensitive (optional, defaults to false) */ case_sensitive?: boolean; + + /** + * Whether to respect .gitignore patterns (optional, defaults to true) + */ + respect_git_ignore?: boolean; } /** @@ -40,7 +46,10 @@ export class GlobTool extends BaseTool<GlobToolParams, ToolResult> { * Creates a new instance of the GlobLogic * @param rootDirectory Root directory to ground this tool in. */ - constructor(private rootDirectory: string) { + constructor( + private rootDirectory: string, + private config: Config, + ) { super( GlobTool.Name, 'FindFiles', @@ -62,6 +71,11 @@ export class GlobTool extends BaseTool<GlobToolParams, ToolResult> { 'Optional: Whether the search should be case-sensitive. Defaults to false.', type: 'boolean', }, + respect_git_ignore: { + description: + 'Optional: Whether to respect .gitignore patterns when finding files. Only available in git repositories. Defaults to true.', + type: 'boolean', + }, }, required: ['pattern'], type: 'object', @@ -167,6 +181,12 @@ export class GlobTool extends BaseTool<GlobToolParams, ToolResult> { params.path || '.', ); + // Get centralized file discovery service + const respectGitIgnore = + params.respect_git_ignore ?? + this.config.getFileFilteringRespectGitIgnore(); + const fileDiscovery = await this.config.getFileService(); + const entries = await fg(params.pattern, { cwd: searchDirAbsolute, absolute: true, @@ -179,25 +199,57 @@ export class GlobTool extends BaseTool<GlobToolParams, ToolResult> { suppressErrors: true, }); - if (!entries || entries.length === 0) { + // Apply git-aware filtering if enabled and in git repository + let filteredEntries = entries; + let gitIgnoredCount = 0; + + if (respectGitIgnore && fileDiscovery.isGitRepository()) { + const allPaths = entries.map((entry) => entry.path); + const relativePaths = allPaths.map((p) => + path.relative(this.rootDirectory, p), + ); + const filteredRelativePaths = fileDiscovery.filterFiles(relativePaths, { + respectGitIgnore, + }); + const filteredAbsolutePaths = new Set( + filteredRelativePaths.map((p) => path.resolve(this.rootDirectory, p)), + ); + + filteredEntries = entries.filter((entry) => + filteredAbsolutePaths.has(entry.path), + ); + gitIgnoredCount = entries.length - filteredEntries.length; + } + + if (!filteredEntries || filteredEntries.length === 0) { + let message = `No files found matching pattern "${params.pattern}" within ${searchDirAbsolute}.`; + if (gitIgnoredCount > 0) { + message += ` (${gitIgnoredCount} files were git-ignored)`; + } return { - llmContent: `No files found matching pattern "${params.pattern}" within ${searchDirAbsolute}.`, + llmContent: message, returnDisplay: `No files found`, }; } - entries.sort((a, b) => { + filteredEntries.sort((a, b) => { const mtimeA = a.stats?.mtime?.getTime() ?? 0; const mtimeB = b.stats?.mtime?.getTime() ?? 0; return mtimeB - mtimeA; }); - const sortedAbsolutePaths = entries.map((entry) => entry.path); + const sortedAbsolutePaths = filteredEntries.map((entry) => entry.path); const fileListDescription = sortedAbsolutePaths.join('\n'); const fileCount = sortedAbsolutePaths.length; + let resultMessage = `Found ${fileCount} file(s) matching "${params.pattern}" within ${searchDirAbsolute}`; + if (gitIgnoredCount > 0) { + resultMessage += ` (${gitIgnoredCount} additional files were git-ignored)`; + } + resultMessage += `, sorted by modification time (newest first):\n${fileListDescription}`; + return { - llmContent: `Found ${fileCount} file(s) matching "${params.pattern}" within ${searchDirAbsolute}, sorted by modification time (newest first):\n${fileListDescription}`, + llmContent: resultMessage, returnDisplay: `Found ${fileCount} matching file(s)`, }; } catch (error) { diff --git a/packages/core/src/tools/ls.ts b/packages/core/src/tools/ls.ts index fea95187..56a016aa 100644 --- a/packages/core/src/tools/ls.ts +++ b/packages/core/src/tools/ls.ts @@ -9,6 +9,7 @@ import path from 'path'; import { BaseTool, ToolResult } from './tools.js'; import { SchemaValidator } from '../utils/schemaValidator.js'; import { makeRelative, shortenPath } from '../utils/paths.js'; +import { Config } from '../config/config.js'; /** * Parameters for the LS tool @@ -20,9 +21,14 @@ export interface LSToolParams { path: string; /** - * List of glob patterns to ignore + * Array of glob patterns to ignore (optional) */ ignore?: string[]; + + /** + * Whether to respect .gitignore patterns (optional, defaults to true) + */ + respect_git_ignore?: boolean; } /** @@ -65,7 +71,10 @@ export class LSTool extends BaseTool<LSToolParams, ToolResult> { * Creates a new instance of the LSLogic * @param rootDirectory Root directory to ground this tool in. All operations will be restricted to this directory. */ - constructor(private rootDirectory: string) { + constructor( + private rootDirectory: string, + private config: Config, + ) { super( LSTool.Name, 'ReadFolder', @@ -84,6 +93,11 @@ export class LSTool extends BaseTool<LSToolParams, ToolResult> { }, type: 'array', }, + respect_git_ignore: { + description: + 'Optional: Whether to respect .gitignore patterns when listing files. Only available in git repositories. Defaults to true.', + type: 'boolean', + }, }, required: ['path'], type: 'object', @@ -214,7 +228,16 @@ export class LSTool extends BaseTool<LSToolParams, ToolResult> { } const files = fs.readdirSync(params.path); + + // Get centralized file discovery service + const respectGitIgnore = + params.respect_git_ignore ?? + this.config.getFileFilteringRespectGitIgnore(); + const fileDiscovery = await this.config.getFileService(); + const entries: FileEntry[] = []; + let gitIgnoredCount = 0; + if (files.length === 0) { // Changed error message to be more neutral for LLM return { @@ -229,6 +252,18 @@ export class LSTool extends BaseTool<LSToolParams, ToolResult> { } const fullPath = path.join(params.path, file); + const relativePath = path.relative(this.rootDirectory, fullPath); + + // Check if this file should be git-ignored (only in git repositories) + if ( + respectGitIgnore && + fileDiscovery.isGitRepository() && + fileDiscovery.shouldIgnoreFile(relativePath) + ) { + gitIgnoredCount++; + continue; + } + try { const stats = fs.statSync(fullPath); const isDir = stats.isDirectory(); @@ -257,10 +292,19 @@ export class LSTool extends BaseTool<LSToolParams, ToolResult> { .map((entry) => `${entry.isDirectory ? '[DIR] ' : ''}${entry.name}`) .join('\n'); + let resultMessage = `Directory listing for ${params.path}:\n${directoryContent}`; + if (gitIgnoredCount > 0) { + resultMessage += `\n\n(${gitIgnoredCount} items were git-ignored)`; + } + + let displayMessage = `Listed ${entries.length} item(s).`; + if (gitIgnoredCount > 0) { + displayMessage += ` (${gitIgnoredCount} git-ignored)`; + } + return { - llmContent: `Directory listing for ${params.path}:\n${directoryContent}`, - // Simplified display, CLI wrapper can enhance - returnDisplay: `Listed ${entries.length} item(s).`, + llmContent: resultMessage, + returnDisplay: displayMessage, }; } catch (error) { const errorMsg = `Error listing directory: ${error instanceof Error ? error.message : String(error)}`; diff --git a/packages/core/src/tools/read-many-files.test.ts b/packages/core/src/tools/read-many-files.test.ts index 5c6d94fa..f4ecc9d0 100644 --- a/packages/core/src/tools/read-many-files.test.ts +++ b/packages/core/src/tools/read-many-files.test.ts @@ -9,6 +9,8 @@ import type { Mock } from 'vitest'; import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { mockControl } from '../__mocks__/fs/promises.js'; import { ReadManyFilesTool } from './read-many-files.js'; +import { FileDiscoveryService } from '../services/fileDiscoveryService.js'; +import { Config } from '../config/config.js'; import path from 'path'; import fs from 'fs'; // Actual fs for setup import os from 'os'; @@ -19,6 +21,18 @@ describe('ReadManyFilesTool', () => { let tempDirOutsideRoot: string; let mockReadFileFn: Mock; + // Mock config for testing + const mockConfig = { + getFileService: async () => { + const service = new FileDiscoveryService(tempRootDir); + await service.initialize({ respectGitIgnore: true }); + return service; + }, + getFileFilteringRespectGitIgnore: () => true, + getFileFilteringCustomIgnorePatterns: () => [], + getFileFilteringAllowBuildArtifacts: () => false, + } as Partial<Config> as Config; + beforeEach(async () => { tempRootDir = fs.mkdtempSync( path.join(os.tmpdir(), 'read-many-files-root-'), @@ -26,7 +40,7 @@ describe('ReadManyFilesTool', () => { tempDirOutsideRoot = fs.mkdtempSync( path.join(os.tmpdir(), 'read-many-files-external-'), ); - tool = new ReadManyFilesTool(tempRootDir); + tool = new ReadManyFilesTool(tempRootDir, mockConfig); mockReadFileFn = mockControl.mockReadFile; mockReadFileFn.mockReset(); diff --git a/packages/core/src/tools/read-many-files.ts b/packages/core/src/tools/read-many-files.ts index 4ba09ef0..30f70c91 100644 --- a/packages/core/src/tools/read-many-files.ts +++ b/packages/core/src/tools/read-many-files.ts @@ -16,6 +16,7 @@ import { DEFAULT_ENCODING, } from '../utils/fileUtils.js'; import { PartListUnion } from '@google/genai'; +import { Config } from '../config/config.js'; /** * Parameters for the ReadManyFilesTool. @@ -54,6 +55,11 @@ export interface ReadManyFilesParams { * Optional. Apply default exclusion patterns. Defaults to true. */ useDefaultExcludes?: boolean; + + /** + * Optional. Whether to respect .gitignore patterns. Defaults to true. + */ + respect_git_ignore?: boolean; } /** @@ -119,7 +125,10 @@ export class ReadManyFilesTool extends BaseTool< * @param targetDir The absolute root directory within which this tool is allowed to operate. * All paths provided in `params` will be resolved relative to this directory. */ - constructor(readonly targetDir: string) { + constructor( + readonly targetDir: string, + private config: Config, + ) { const parameterSchema: Record<string, unknown> = { type: 'object', properties: { @@ -155,6 +164,12 @@ 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: 'boolean', + description: + 'Optional. Whether to respect .gitignore patterns when discovering files. Only available in git repositories. Defaults to true.', + default: true, + }, }, required: ['paths'], }; @@ -254,8 +269,15 @@ 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(); + + // Get centralized file discovery service + const fileDiscovery = await this.config.getFileService(); + const toolBaseDir = this.targetDir; const filesToConsider = new Set<string>(); const skippedFiles: Array<{ path: string; reason: string }> = []; @@ -290,9 +312,22 @@ Use this tool when the user's query implies needing the content of several files caseSensitiveMatch: false, }); + // Apply git-aware filtering if enabled and in git repository + const filteredEntries = + respectGitIgnore && fileDiscovery.isGitRepository() + ? fileDiscovery + .filterFiles( + entries.map((p) => path.relative(toolBaseDir, p)), + { + respectGitIgnore, + }, + ) + .map((p) => path.resolve(toolBaseDir, p)) + : entries; + + let gitIgnoredCount = 0; for (const absoluteFilePath of entries) { // Security check: ensure the glob library didn't return something outside targetDir. - // This should be guaranteed by `cwd` and the library's sandboxing, but an extra check is good practice. if (!absoluteFilePath.startsWith(toolBaseDir)) { skippedFiles.push({ path: absoluteFilePath, @@ -300,8 +335,30 @@ Use this tool when the user's query implies needing the content of several files }); continue; } + + // Check if this file was filtered out by git ignore + if ( + respectGitIgnore && + fileDiscovery.isGitRepository() && + !filteredEntries.includes(absoluteFilePath) + ) { + gitIgnoredCount++; + continue; + } + filesToConsider.add(absoluteFilePath); } + + // Add info about git-ignored files if any were filtered + if (gitIgnoredCount > 0) { + const reason = respectGitIgnore + ? 'git-ignored' + : 'filtered by custom ignore patterns'; + skippedFiles.push({ + path: `${gitIgnoredCount} file(s)`, + reason, + }); + } } catch (error) { return { llmContent: `Error during file search: ${getErrorMessage(error)}`, diff --git a/packages/core/src/tools/shell.ts b/packages/core/src/tools/shell.ts index 53e2bbf3..2117366a 100644 --- a/packages/core/src/tools/shell.ts +++ b/packages/core/src/tools/shell.ts @@ -35,10 +35,29 @@ export class ShellTool extends BaseTool<ShellToolParams, ToolResult> { constructor(private readonly config: Config) { const toolDisplayName = 'Shell'; - const descriptionUrl = new URL('shell.md', import.meta.url); - const toolDescription = fs.readFileSync(descriptionUrl, 'utf-8'); - const schemaUrl = new URL('shell.json', import.meta.url); - const toolParameterSchema = JSON.parse(fs.readFileSync(schemaUrl, 'utf-8')); + + let toolDescription: string; + let toolParameterSchema: Record<string, unknown>; + + try { + const descriptionUrl = new URL('shell.md', import.meta.url); + toolDescription = fs.readFileSync(descriptionUrl, 'utf-8'); + const schemaUrl = new URL('shell.json', import.meta.url); + toolParameterSchema = JSON.parse(fs.readFileSync(schemaUrl, 'utf-8')); + } catch { + // Fallback with minimal descriptions for tests when file reading fails + toolDescription = 'Execute shell commands'; + toolParameterSchema = { + type: 'object', + properties: { + command: { type: 'string', description: 'Command to execute' }, + description: { type: 'string', description: 'Command description' }, + directory: { type: 'string', description: 'Working directory' }, + }, + required: ['command'], + }; + } + super( ShellTool.Name, toolDisplayName, |
