From 9c245d9f8dfffbdeb05c65feabb8a25fd40f7c42 Mon Sep 17 00:00:00 2001 From: daandden <64765666+daandden@users.noreply.github.com> Date: Tue, 3 Mar 2026 20:44:58 +0700 Subject: [PATCH] feat(grep): add gitignore toggle support (#270) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: map existing codebase * feat(grep): add gitignore toggle support Thread gitignore option through coding-agent schema, TS native bindings, and Rust grep scan paths. Add native and tool tests for default behavior and gitignore:false behavior. Fixes #267 * chore: remove tracked .planning artifacts * style(rust): format grep scan call * feat(grep): add gitignore toggle for ignored files Fixes #267 --------- Co-authored-by: Vũ Anh Nguyễn Co-authored-by: can1357 --- crates/pi-natives/src/grep.rs | 12 +++- packages/coding-agent/src/cli/grep-cli.ts | 10 +++- packages/coding-agent/src/commands/grep.ts | 2 + .../coding-agent/src/prompts/tools/grep.md | 1 + packages/coding-agent/src/tools/grep.ts | 7 ++- packages/coding-agent/test/tools.test.ts | 36 ++++++++++++ packages/natives/src/grep/types.ts | 2 + packages/natives/test/native.test.ts | 56 ++++++++++++++++++- 8 files changed, 120 insertions(+), 6 deletions(-) diff --git a/crates/pi-natives/src/grep.rs b/crates/pi-natives/src/grep.rs index 5f6177d8c..17389fc82 100644 --- a/crates/pi-natives/src/grep.rs +++ b/crates/pi-natives/src/grep.rs @@ -88,6 +88,8 @@ pub struct GrepOptions<'env> { pub multiline: Option, /// Include hidden files (default: true). pub hidden: Option, + /// Respect .gitignore files (default: true). + pub gitignore: Option, /// Enable shared filesystem scan cache (default: false). pub cache: Option, /// Maximum number of matches to return. @@ -587,6 +589,7 @@ struct GrepConfig { ignore_case: Option, multiline: Option, hidden: Option, + gitignore: Option, cache: Option, max_count: Option, offset: Option, @@ -938,6 +941,7 @@ fn grep_sync( let max_count = options.max_count.map(u64::from); let offset = options.offset.unwrap_or(0) as u64; let include_hidden = options.hidden.unwrap_or(true); + let use_gitignore = options.gitignore.unwrap_or(true); let use_cache = options.cache.unwrap_or(false); let glob_set = glob_util::try_compile_glob(options.glob.as_deref(), true)?; let type_filter = resolve_type_filter(options.type_filter.as_deref()); @@ -1021,16 +1025,16 @@ fn grep_sync( } let entries = if use_cache { - let scan = fs_cache::get_or_scan(&search_path, include_hidden, true, &ct)?; + let scan = fs_cache::get_or_scan(&search_path, include_hidden, use_gitignore, &ct)?; let mut entries = collect_files(&search_path, &scan.entries, glob_set.as_ref(), type_filter.as_ref()); if entries.is_empty() && scan.cache_age_ms >= fs_cache::empty_recheck_ms() { - let fresh = fs_cache::force_rescan(&search_path, include_hidden, true, true, &ct)?; + let fresh = fs_cache::force_rescan(&search_path, include_hidden, use_gitignore, true, &ct)?; entries = collect_files(&search_path, &fresh, glob_set.as_ref(), type_filter.as_ref()); } entries } else { - let fresh = fs_cache::force_rescan(&search_path, include_hidden, true, false, &ct)?; + let fresh = fs_cache::force_rescan(&search_path, include_hidden, use_gitignore, false, &ct)?; collect_files(&search_path, &fresh, glob_set.as_ref(), type_filter.as_ref()) }; // Check cancellation before heavy work @@ -1222,6 +1226,7 @@ pub fn grep( ignore_case, multiline, hidden, + gitignore, cache, max_count, offset, @@ -1242,6 +1247,7 @@ pub fn grep( ignore_case, multiline, hidden, + gitignore, cache, max_count, offset, diff --git a/packages/coding-agent/src/cli/grep-cli.ts b/packages/coding-agent/src/cli/grep-cli.ts index f7118c66a..dc1d2e02e 100644 --- a/packages/coding-agent/src/cli/grep-cli.ts +++ b/packages/coding-agent/src/cli/grep-cli.ts @@ -15,6 +15,7 @@ export interface GrepCommandArgs { limit: number; context: number; mode: "content" | "filesWithMatches" | "count"; + gitignore: boolean; } /** @@ -32,6 +33,7 @@ export function parseGrepArgs(args: string[]): GrepCommandArgs | undefined { limit: 20, context: 2, mode: "content", + gitignore: true, }; const positional: string[] = []; @@ -48,6 +50,8 @@ export function parseGrepArgs(args: string[]): GrepCommandArgs | undefined { result.mode = "filesWithMatches"; } else if (arg === "--count" || arg === "-c") { result.mode = "count"; + } else if (arg === "--no-gitignore") { + result.gitignore = false; } else if (!arg.startsWith("-")) { positional.push(arg); } @@ -72,7 +76,9 @@ export async function runGrepCommand(cmd: GrepCommandArgs): Promise { const searchPath = path.resolve(cmd.path); console.log(chalk.dim(`Searching in: ${searchPath}`)); console.log(chalk.dim(`Pattern: ${cmd.pattern}`)); - console.log(chalk.dim(`Mode: ${cmd.mode}, Limit: ${cmd.limit}, Context: ${cmd.context}`)); + console.log( + chalk.dim(`Mode: ${cmd.mode}, Limit: ${cmd.limit}, Context: ${cmd.context}, Gitignore: ${cmd.gitignore}`), + ); console.log(""); @@ -85,6 +91,7 @@ export async function runGrepCommand(cmd: GrepCommandArgs): Promise { maxCount: cmd.limit, context: cmd.mode === "content" ? cmd.context : undefined, hidden: true, + gitignore: cmd.gitignore, }); console.log(chalk.green(`Total matches: ${result.totalMatches}`)); @@ -140,6 +147,7 @@ ${chalk.bold("Options:")} -f, --files Output file names only -c, --count Output match counts per file -h, --help Show this help + --no-gitignore Include files excluded by .gitignore ${chalk.bold("Environment:")} PI_GREP_WORKERS=0 Disable worker pool (use single-threaded mode) diff --git a/packages/coding-agent/src/commands/grep.ts b/packages/coding-agent/src/commands/grep.ts index 0b9465601..6a0bfb911 100644 --- a/packages/coding-agent/src/commands/grep.ts +++ b/packages/coding-agent/src/commands/grep.ts @@ -19,6 +19,7 @@ export default class Grep extends Command { context: Flags.integer({ char: "C", description: "Context lines", default: 2 }), files: Flags.boolean({ char: "f", description: "Output file names only" }), count: Flags.boolean({ char: "c", description: "Output match counts per file" }), + "no-gitignore": Flags.boolean({ description: "Include files excluded by .gitignore" }), }; async run(): Promise { @@ -33,6 +34,7 @@ export default class Grep extends Command { limit: flags.limit, context: flags.context, mode, + gitignore: !flags["no-gitignore"], }; await initTheme(); diff --git a/packages/coding-agent/src/prompts/tools/grep.md b/packages/coding-agent/src/prompts/tools/grep.md index e1175efeb..d5fdb241e 100644 --- a/packages/coding-agent/src/prompts/tools/grep.md +++ b/packages/coding-agent/src/prompts/tools/grep.md @@ -3,6 +3,7 @@ Searches files using powerful regex matching built on ripgrep. - Supports full regex syntax (e.g., `log.*Error`, `function\\s+\\w+`); literal braces need escaping (`interface\\{\\}` for `interface{}` in Go) - Filter files with `glob` (e.g., `*.js`, `**/*.tsx`) or `type` (e.g., `js`, `py`, `rust`) +- Respects `.gitignore` by default; set `gitignore: false` to include ignored files - For cross-line patterns like `struct \\{[\\s\\S]*?field`, set `multiline: true` if needed - If the pattern contains a literal `\n`, multiline defaults to true diff --git a/packages/coding-agent/src/tools/grep.ts b/packages/coding-agent/src/tools/grep.ts index 04c229220..fc2729551 100644 --- a/packages/coding-agent/src/tools/grep.ts +++ b/packages/coding-agent/src/tools/grep.ts @@ -30,6 +30,7 @@ const grepSchema = Type.Object({ pre: Type.Optional(Type.Number({ description: "Lines of context before matches" })), post: Type.Optional(Type.Number({ description: "Lines of context after matches" })), multiline: Type.Optional(Type.Boolean({ description: "Enable multiline matching" })), + gitignore: Type.Optional(Type.Boolean({ description: "Respect .gitignore files during search (default: true)" })), limit: Type.Optional(Type.Number({ description: "Limit output to first N matches (default: 20)" })), offset: Type.Optional(Type.Number({ description: "Skip first N entries before applying limit (default: 0)" })), }); @@ -77,7 +78,7 @@ export class GrepTool implements AgentTool { _onUpdate?: AgentToolUpdateCallback, _toolContext?: AgentToolContext, ): Promise> { - const { pattern, path: searchDir, glob, type, i, pre, post, multiline, limit, offset } = params; + const { pattern, path: searchDir, glob, type, i, gitignore, pre, post, multiline, limit, offset } = params; return untilAborted(signal, async () => { const normalizedPattern = pattern.trim(); @@ -101,6 +102,7 @@ export class GrepTool implements AgentTool { const normalizedContextBefore = pre ?? defaultContextBefore; const normalizedContextAfter = post ?? defaultContextAfter; const ignoreCase = i ?? false; + const useGitignore = gitignore ?? true; const patternHasNewline = normalizedPattern.includes("\n") || normalizedPattern.includes("\\n"); const effectiveMultiline = multiline ?? patternHasNewline; @@ -162,6 +164,7 @@ export class GrepTool implements AgentTool { ignoreCase, multiline: effectiveMultiline, hidden: true, + gitignore: useGitignore, cache: false, maxCount: internalLimit, offset: normalizedOffset > 0 ? normalizedOffset : undefined, @@ -368,6 +371,7 @@ interface GrepRenderArgs { glob?: string; type?: string; i?: boolean; + gitignore?: boolean; pre?: number; post?: number; multiline?: boolean; @@ -385,6 +389,7 @@ export const grepToolRenderer = { if (args.glob) meta.push(`glob:${args.glob}`); if (args.type) meta.push(`type:${args.type}`); if (args.i) meta.push("case:insensitive"); + if (args.gitignore === false) meta.push("gitignore:false"); if (args.pre !== undefined && args.pre > 0) { meta.push(`pre:${args.pre}`); } diff --git a/packages/coding-agent/test/tools.test.ts b/packages/coding-agent/test/tools.test.ts index 6d6cf35b9..0c0700144 100644 --- a/packages/coding-agent/test/tools.test.ts +++ b/packages/coding-agent/test/tools.test.ts @@ -627,6 +627,42 @@ function b() { expect(result.details?.fileCount).toBeGreaterThanOrEqual(2); }); + it("should respect .gitignore by default", async () => { + const scenarioDir = path.join(testDir, "grep-gitignore-default"); + fs.mkdirSync(path.join(scenarioDir, ".git"), { recursive: true }); + fs.writeFileSync(path.join(scenarioDir, ".gitignore"), "ignored.txt\n"); + fs.writeFileSync(path.join(scenarioDir, "ignored.txt"), "needle ignored\n"); + fs.writeFileSync(path.join(scenarioDir, "kept.txt"), "needle kept\n"); + + const result = await grepTool.execute("test-call-15-gitignore-default", { + pattern: "needle", + path: scenarioDir, + }); + + const output = getTextOutput(result); + expect(output).toContain("kept.txt"); + expect(output).not.toContain("ignored.txt"); + expect(result.details?.fileCount).toBe(1); + expect(result.details?.matchCount).toBe(1); + }); + + it("should include ignored files when gitignore is false", async () => { + const scenarioDir = path.join(testDir, "grep-gitignore-off"); + fs.mkdirSync(path.join(scenarioDir, ".git"), { recursive: true }); + fs.writeFileSync(path.join(scenarioDir, ".gitignore"), "ignored.txt\n"); + fs.writeFileSync(path.join(scenarioDir, "ignored.txt"), "needle ignored\n"); + + const result = await grepTool.execute("test-call-16-gitignore-off", { + pattern: "needle", + path: scenarioDir, + gitignore: false, + }); + + const output = getTextOutput(result); + expect(output).toContain("ignored.txt"); + expect(result.details?.fileCount).toBe(1); + expect(result.details?.matchCount).toBe(1); + }); it("should apply default limit of 20 when limit is not provided", async () => { const lines = Array.from({ length: 60 }, (_, i) => `needle ${i + 1}`); fs.writeFileSync(path.join(testDir, "default-limit.txt"), lines.join("\n")); diff --git a/packages/natives/src/grep/types.ts b/packages/natives/src/grep/types.ts index 63807fd3c..9a8fe865e 100644 --- a/packages/natives/src/grep/types.ts +++ b/packages/natives/src/grep/types.ts @@ -20,6 +20,8 @@ export interface GrepOptions extends Cancellable { multiline?: boolean; /** Include hidden files (default: true) */ hidden?: boolean; + /** Respect .gitignore files (default: true) */ + gitignore?: boolean; /** Enable shared filesystem scan cache (default: false). */ cache?: boolean; /** Maximum number of matches to return */ diff --git a/packages/natives/test/native.test.ts b/packages/natives/test/native.test.ts index ded85b63b..333469e30 100644 --- a/packages/natives/test/native.test.ts +++ b/packages/natives/test/native.test.ts @@ -106,8 +106,62 @@ describe("pi-natives", () => { expect(result.totalMatches).toBe(0); expect(result.filesWithMatches).toBe(0); }); - }); + it("should respect .gitignore by default and allow opting out", async () => { + const scopedDir = path.join(testDir, "grep-gitignore-case"); + await fs.mkdir(scopedDir, { recursive: true }); + await fs.mkdir(path.join(scopedDir, ".git"), { recursive: true }); + await fs.writeFile(path.join(scopedDir, ".gitignore"), "ignored.ts\n"); + await fs.writeFile(path.join(scopedDir, "ignored.ts"), 'export const ignoredToken = "IGNORE_ME_TOKEN";\n'); + + const defaultResult = await grep({ + pattern: "IGNORE_ME_TOKEN", + path: scopedDir, + }); + + expect(defaultResult.totalMatches).toBe(0); + expect(defaultResult.filesWithMatches).toBe(0); + + const includeIgnoredResult = await grep({ + pattern: "IGNORE_ME_TOKEN", + path: scopedDir, + gitignore: false, + }); + + expect(includeIgnoredResult.totalMatches).toBe(1); + expect(includeIgnoredResult.matches.some(match => match.path.endsWith("ignored.ts"))).toBe(true); + }); + + it("should keep hidden filtering when gitignore is disabled", async () => { + const scopedDir = path.join(testDir, "grep-hidden-gitignore-case"); + await fs.mkdir(scopedDir, { recursive: true }); + await fs.mkdir(path.join(scopedDir, ".git"), { recursive: true }); + await fs.writeFile(path.join(scopedDir, ".gitignore"), ".hidden-ignored.ts\n"); + await fs.writeFile( + path.join(scopedDir, ".hidden-ignored.ts"), + 'export const hiddenIgnoredToken = "HIDDEN_IGNORE_TOKEN";\n', + ); + + const hiddenExcluded = await grep({ + pattern: "HIDDEN_IGNORE_TOKEN", + path: scopedDir, + gitignore: false, + hidden: false, + }); + + expect(hiddenExcluded.totalMatches).toBe(0); + + const hiddenIncluded = await grep({ + pattern: "HIDDEN_IGNORE_TOKEN", + path: scopedDir, + gitignore: false, + hidden: true, + }); + + expect(hiddenIncluded.totalMatches).toBe(1); + expect(hiddenIncluded.matches.some(match => match.path.endsWith(".hidden-ignored.ts"))).toBe(true); + }); + }); describe("fuzzyFind", () => { it("should match abbreviated fuzzy queries across separators", async () => { const result = await fuzzyFind({