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:
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user