feat(grep): add gitignore toggle support (#270)

* 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 <vu.anh.nguyen@mgm-tp.com>
Co-authored-by: can1357 <me@can.ac>
This commit is contained in:
daandden
2026-03-03 20:44:58 +07:00
committed by GitHub
parent 9ac976a5b3
commit 9c245d9f8d
8 changed files with 120 additions and 6 deletions
+9 -3
View File
@@ -88,6 +88,8 @@ pub struct GrepOptions<'env> {
pub multiline: Option<bool>,
/// Include hidden files (default: true).
pub hidden: Option<bool>,
/// Respect .gitignore files (default: true).
pub gitignore: Option<bool>,
/// Enable shared filesystem scan cache (default: false).
pub cache: Option<bool>,
/// Maximum number of matches to return.
@@ -587,6 +589,7 @@ struct GrepConfig {
ignore_case: Option<bool>,
multiline: Option<bool>,
hidden: Option<bool>,
gitignore: Option<bool>,
cache: Option<bool>,
max_count: Option<u32>,
offset: Option<u32>,
@@ -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,
+9 -1
View File
@@ -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<void> {
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<void> {
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)
@@ -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<void> {
@@ -33,6 +34,7 @@ export default class Grep extends Command {
limit: flags.limit,
context: flags.context,
mode,
gitignore: !flags["no-gitignore"],
};
await initTheme();
@@ -3,6 +3,7 @@ Searches files using powerful regex matching built on ripgrep.
<instruction>
- 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
</instruction>
+6 -1
View File
@@ -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<typeof grepSchema, GrepToolDetails> {
_onUpdate?: AgentToolUpdateCallback<GrepToolDetails>,
_toolContext?: AgentToolContext,
): Promise<AgentToolResult<GrepToolDetails>> {
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<typeof grepSchema, GrepToolDetails> {
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<typeof grepSchema, GrepToolDetails> {
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}`);
}
+36
View File
@@ -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"));
+2
View File
@@ -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 */
+55 -1
View File
@@ -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({