feat(init): add grep and glob local-op handlers#703
Conversation
Semver Impact of This PR🟡 Minor (new features) 📋 Changelog PreviewThis is how your changes will appear in the changelog. New Features ✨Docs
Init
Other
Bug Fixes 🐛Dashboard
Other
Internal Changes 🔧
🤖 This preview updates automatically when you update the PR. |
|
Codecov Results 📊✅ 134 passed | Total: 134 | Pass Rate: 100% | Execution Time: 0ms 📊 Comparison with Base Branch
✨ No test changes detected All tests are passing successfully. ✅ Patch coverage is 100.00%. Project has 1574 uncovered lines. Coverage diff@@ Coverage Diff @@
## main #PR +/-##
==========================================
+ Coverage 95.33% 95.35% +0.02%
==========================================
Files 232 232 —
Lines 33632 33828 +196
Branches 0 0 —
==========================================
+ Hits 32059 32254 +195
- Misses 1573 1574 +1
- Partials 0 0 —Generated by Codecov Action |
1de46f8 to
9eb23a0
Compare
9eb23a0 to
d9c6ce3
Compare
d9c6ce3 to
031cdee
Compare
031cdee to
b2f5fe8
Compare
12c73de to
ecd6915
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
Autofix Details
Bugbot Autofix prepared fixes for both issues found in the latest run.
- ✅ Fixed: Truthiness check drops matches with empty line text
- Changed the guard condition from truthiness check to null check (m[3] != null) to correctly handle empty string matches from git grep.
- ✅ Fixed: Spawn timeout silently returns empty results instead of error
- Modified spawnCollect to detect SIGTERM signal and reject with timeout error instead of resolving with exit code 1, enabling proper fallback chain.
Or push these changes by commenting:
@cursor push 1b6cd595f2
Preview (1b6cd595f2)
diff --git a/src/lib/init/local-ops.ts b/src/lib/init/local-ops.ts
--- a/src/lib/init/local-ops.ts
+++ b/src/lib/init/local-ops.ts
@@ -34,6 +34,8 @@
DetectSentryPayload,
DirEntry,
FileExistsBatchPayload,
+ GlobPayload,
+ GrepPayload,
ListDirPayload,
LocalOpPayload,
LocalOpResult,
@@ -296,6 +298,10 @@
return await runCommands(payload, options.dryRun);
case "apply-patchset":
return await applyPatchset(payload, options.dryRun);
+ case "grep":
+ return await grep(payload);
+ case "glob":
+ return await glob(payload);
case "create-sentry-project":
return await createSentryProject(payload, options);
case "detect-sentry":
@@ -849,6 +855,466 @@
};
}
+// ── Grep & Glob ─────────────────────────────────────────────────────
+
+const MAX_GREP_RESULTS_PER_SEARCH = 100;
+const MAX_GREP_LINE_LENGTH = 2000;
+const MAX_GLOB_RESULTS = 100;
+const SKIP_DIRS = new Set([
+ "node_modules",
+ ".git",
+ "__pycache__",
+ ".venv",
+ "venv",
+ "dist",
+ "build",
+]);
+
+type GrepMatch = { path: string; lineNum: number; line: string };
+
+// ── Ripgrep implementations (preferred when rg is on PATH) ──────────
+
+/**
+ * Spawn a command, collect stdout + stderr, reject on spawn errors (ENOENT).
+ * Drains both streams to prevent pipe buffer deadlocks.
+ */
+function spawnCollect(
+ cmd: string,
+ args: string[],
+ cwd: string
+): Promise<{ stdout: string; stderr: string; exitCode: number }> {
+ return new Promise((resolve, reject) => {
+ const child = spawn(cmd, args, {
+ cwd,
+ stdio: ["ignore", "pipe", "pipe"],
+ timeout: 30_000,
+ });
+
+ const outChunks: Buffer[] = [];
+ let outLen = 0;
+ child.stdout.on("data", (chunk: Buffer) => {
+ if (outLen < MAX_OUTPUT_BYTES) {
+ outChunks.push(chunk);
+ outLen += chunk.length;
+ }
+ });
+
+ const errChunks: Buffer[] = [];
+ child.stderr.on("data", (chunk: Buffer) => {
+ if (errChunks.length < 64) {
+ errChunks.push(chunk);
+ }
+ });
+
+ child.on("error", (err) => {
+ reject(err);
+ });
+ child.on("close", (code, signal) => {
+ if (code === null && signal === "SIGTERM") {
+ reject(new Error("Command timed out after 30 seconds"));
+ return;
+ }
+ resolve({
+ stdout: Buffer.concat(outChunks).toString("utf-8"),
+ stderr: Buffer.concat(errChunks).toString("utf-8"),
+ exitCode: code ?? 1,
+ });
+ });
+ });
+}
+
+/**
+ * Parse ripgrep output using `|` as field separator (set via
+ * `--field-match-separator=|`) to avoid ambiguity with `:` in
+ * Windows drive-letter paths.
+ * Format: filepath|linenum|matched text
+ */
+function parseRgGrepOutput(
+ cwd: string,
+ stdout: string,
+ maxResults: number
+): { matches: GrepMatch[]; truncated: boolean } {
+ const lines = stdout.split("\n").filter(Boolean);
+ const truncated = lines.length > maxResults;
+ const matches: GrepMatch[] = [];
+
+ for (const line of lines.slice(0, maxResults)) {
+ const firstSep = line.indexOf("|");
+ if (firstSep === -1) {
+ continue;
+ }
+ const filePart = line.substring(0, firstSep);
+ const rest = line.substring(firstSep + 1);
+ const secondSep = rest.indexOf("|");
+ if (secondSep === -1) {
+ continue;
+ }
+ const lineNum = Number.parseInt(rest.substring(0, secondSep), 10);
+ let text = rest.substring(secondSep + 1);
+ if (text.length > MAX_GREP_LINE_LENGTH) {
+ text = `${text.substring(0, MAX_GREP_LINE_LENGTH)}…`;
+ }
+ matches.push({ path: path.relative(cwd, filePart), lineNum, line: text });
+ }
+
+ return { matches, truncated };
+}
+
+async function rgGrepSearch(opts: {
+ cwd: string;
+ pattern: string;
+ target: string;
+ include: string | undefined;
+ maxResults: number;
+}): Promise<{ matches: GrepMatch[]; truncated: boolean }> {
+ const { cwd, pattern, target, include, maxResults } = opts;
+ const args = [
+ "-nH",
+ "--no-messages",
+ "--hidden",
+ "--field-match-separator=|",
+ "--regexp",
+ pattern,
+ ];
+ if (include) {
+ args.push("--glob", include);
+ }
+ args.push(target);
+
+ const { stdout, exitCode } = await spawnCollect("rg", args, cwd);
+
+ if (exitCode === 1 || (exitCode === 2 && !stdout.trim())) {
+ return { matches: [], truncated: false };
+ }
+ if (exitCode !== 0 && exitCode !== 2) {
+ throw new Error(`ripgrep failed with exit code ${exitCode}`);
+ }
+
+ return parseRgGrepOutput(cwd, stdout, maxResults);
+}
+
+async function rgGlobSearch(opts: {
+ cwd: string;
+ pattern: string;
+ target: string;
+ maxResults: number;
+}): Promise<{ files: string[]; truncated: boolean }> {
+ const { cwd, pattern, target, maxResults } = opts;
+ const args = ["--files", "--hidden", "--glob", pattern, target];
+
+ const { stdout, exitCode } = await spawnCollect("rg", args, cwd);
+
+ if (exitCode === 1 || (exitCode === 2 && !stdout.trim())) {
+ return { files: [], truncated: false };
+ }
+ if (exitCode !== 0 && exitCode !== 2) {
+ throw new Error(`ripgrep failed with exit code ${exitCode}`);
+ }
+
+ const lines = stdout.split("\n").filter(Boolean);
+ const truncated = lines.length > maxResults;
+ const files = lines.slice(0, maxResults).map((f) => path.relative(cwd, f));
+ return { files, truncated };
+}
+
+// ── Node.js fallback (when rg is not installed) ─────────────────────
+
+/**
+ * Recursively walk a directory, yielding relative file paths.
+ * Skips common non-source directories and respects an optional glob filter.
+ */
+async function* walkFiles(
+ root: string,
+ base: string,
+ globPattern: string | undefined
+): AsyncGenerator<string> {
+ let entries: fs.Dirent[];
+ try {
+ entries = await fs.promises.readdir(base, { withFileTypes: true });
+ } catch {
+ return;
+ }
+ for (const entry of entries) {
+ const full = path.join(base, entry.name);
+ const rel = path.relative(root, full);
+ if (entry.isDirectory() && !SKIP_DIRS.has(entry.name)) {
+ yield* walkFiles(root, full, globPattern);
+ } else if (entry.isFile()) {
+ const matchTarget = globPattern?.includes("/") ? rel : entry.name;
+ if (!globPattern || matchGlob(matchTarget, globPattern)) {
+ yield rel;
+ }
+ }
+ }
+}
+
+/** Minimal glob matcher — supports `*`, `**`, and `?` wildcards. */
+function matchGlob(name: string, pattern: string): boolean {
+ const re = pattern
+ .replace(/[.+^${}()|[\]\\]/g, "\\$&")
+ .replace(/\*\*/g, "\0")
+ .replace(/\*/g, "[^/]*")
+ .replace(/\0/g, ".*")
+ .replace(/\?/g, ".");
+ return new RegExp(`^${re}$`).test(name);
+}
+
+/**
+ * Search files for a regex pattern using Node.js fs. Fallback for when
+ * ripgrep is not available.
+ */
+// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: file-walking search with early exits
+async function fsGrepSearch(opts: {
+ cwd: string;
+ pattern: string;
+ searchPath: string | undefined;
+ include: string | undefined;
+ maxResults: number;
+}): Promise<{ matches: GrepMatch[]; truncated: boolean }> {
+ const { cwd, pattern, searchPath, include, maxResults } = opts;
+ const target = searchPath ? safePath(cwd, searchPath) : cwd;
+ const regex = new RegExp(pattern);
+ const matches: GrepMatch[] = [];
+
+ for await (const rel of walkFiles(cwd, target, include)) {
+ if (matches.length > maxResults) {
+ break;
+ }
+ const absPath = path.join(cwd, rel);
+ let content: string;
+ try {
+ const stat = await fs.promises.stat(absPath);
+ if (stat.size > MAX_FILE_BYTES) {
+ continue;
+ }
+ content = await fs.promises.readFile(absPath, "utf-8");
+ } catch {
+ continue;
+ }
+ const lines = content.split("\n");
+ for (let i = 0; i < lines.length; i += 1) {
+ const line = lines[i] ?? "";
+ if (regex.test(line)) {
+ let text = line;
+ if (text.length > MAX_GREP_LINE_LENGTH) {
+ text = `${text.substring(0, MAX_GREP_LINE_LENGTH)}…`;
+ }
+ matches.push({ path: rel, lineNum: i + 1, line: text });
+ if (matches.length > maxResults) {
+ break;
+ }
+ }
+ }
+ }
+
+ const truncated = matches.length > maxResults;
+ if (truncated) {
+ matches.length = maxResults;
+ }
+ return { matches, truncated };
+}
+
+async function fsGlobSearch(opts: {
+ cwd: string;
+ pattern: string;
+ searchPath: string | undefined;
+ maxResults: number;
+}): Promise<{ files: string[]; truncated: boolean }> {
+ const { cwd, pattern, searchPath, maxResults } = opts;
+ const target = searchPath ? safePath(cwd, searchPath) : cwd;
+ const files: string[] = [];
+
+ for await (const rel of walkFiles(cwd, target, pattern)) {
+ files.push(rel);
+ if (files.length > maxResults) {
+ break;
+ }
+ }
+
+ const truncated = files.length > maxResults;
+ if (truncated) {
+ files.length = maxResults;
+ }
+ return { files, truncated };
+}
+
+// ── git grep / git ls-files (middle fallback tier) ──────────────────
+
+const GREP_LINE_RE = /^(.+?):(\d+):(.*)$/;
+
+function parseGrepOutput(
+ stdout: string,
+ maxResults: number,
+ pathPrefix?: string
+): { matches: GrepMatch[]; truncated: boolean } {
+ const lines = stdout.split("\n").filter(Boolean);
+ const matches: GrepMatch[] = [];
+
+ for (const line of lines) {
+ const m = line.match(GREP_LINE_RE);
+ if (!(m?.[1] && m[2] && m[3] != null)) {
+ continue;
+ }
+ const lineNum = Number.parseInt(m[2], 10);
+ let text: string = m[3];
+ if (text.length > MAX_GREP_LINE_LENGTH) {
+ text = `${text.substring(0, MAX_GREP_LINE_LENGTH)}…`;
+ }
+ const filePath = pathPrefix ? path.join(pathPrefix, m[1]) : m[1];
+ matches.push({ path: filePath, lineNum, line: text });
+ if (matches.length > maxResults) {
+ break;
+ }
+ }
+
+ const truncated = matches.length > maxResults;
+ if (truncated) {
+ matches.length = maxResults;
+ }
+ return { matches, truncated };
+}
+
+async function gitGrepSearch(opts: {
+ cwd: string;
+ pattern: string;
+ target: string;
+ include: string | undefined;
+ maxResults: number;
+}): Promise<{ matches: GrepMatch[]; truncated: boolean }> {
+ const { cwd, pattern, target, include, maxResults } = opts;
+ const args = ["grep", "--untracked", "-n", "-E", pattern];
+ if (include) {
+ args.push("--", include);
+ }
+
+ const { stdout, exitCode } = await spawnCollect("git", args, target);
+
+ if (exitCode === 1) {
+ return { matches: [], truncated: false };
+ }
+ if (exitCode !== 0) {
+ throw new Error(`git grep failed with exit code ${exitCode}`);
+ }
+
+ const prefix = path.relative(cwd, target);
+ return parseGrepOutput(stdout, maxResults, prefix || undefined);
+}
+
+async function gitLsFiles(opts: {
+ cwd: string;
+ pattern: string;
+ target: string;
+ maxResults: number;
+}): Promise<{ files: string[]; truncated: boolean }> {
+ const { cwd, pattern, target, maxResults } = opts;
+ const args = [
+ "ls-files",
+ "--cached",
+ "--others",
+ "--exclude-standard",
+ pattern,
+ ];
+
+ const { stdout, exitCode } = await spawnCollect("git", args, target);
+
+ if (exitCode !== 0) {
+ throw new Error(`git ls-files failed with exit code ${exitCode}`);
+ }
+
+ const lines = stdout.split("\n").filter(Boolean);
+ const truncated = lines.length > maxResults;
+ const files = lines
+ .slice(0, maxResults)
+ .map((f) => path.relative(cwd, path.resolve(target, f)));
+ return { files, truncated };
+}
+
+// ── Dispatch: rg → git → Node.js ────────────────────────────────────
+
+async function grepSearch(opts: {
+ cwd: string;
+ pattern: string;
+ searchPath: string | undefined;
+ include: string | undefined;
+ maxResults: number;
+}): Promise<{ matches: GrepMatch[]; truncated: boolean }> {
+ const target = opts.searchPath
+ ? safePath(opts.cwd, opts.searchPath)
+ : opts.cwd;
+ const resolvedOpts = { ...opts, target };
+ try {
+ return await rgGrepSearch(resolvedOpts);
+ } catch {
+ try {
+ return await gitGrepSearch(resolvedOpts);
+ } catch {
+ return await fsGrepSearch(opts);
+ }
+ }
+}
+
+async function globSearchImpl(opts: {
+ cwd: string;
+ pattern: string;
+ searchPath: string | undefined;
+ maxResults: number;
+}): Promise<{ files: string[]; truncated: boolean }> {
+ const target = opts.searchPath
+ ? safePath(opts.cwd, opts.searchPath)
+ : opts.cwd;
+ const resolvedOpts = { ...opts, target };
+ try {
+ return await rgGlobSearch(resolvedOpts);
+ } catch {
+ try {
+ return await gitLsFiles(resolvedOpts);
+ } catch {
+ return await fsGlobSearch(opts);
+ }
+ }
+}
+
+async function grep(payload: GrepPayload): Promise<LocalOpResult> {
+ const { cwd, params } = payload;
+ const maxResults = params.maxResultsPerSearch ?? MAX_GREP_RESULTS_PER_SEARCH;
+
+ const results = await Promise.all(
+ params.searches.map(async (search) => {
+ const { matches, truncated } = await grepSearch({
+ cwd,
+ pattern: search.pattern,
+ searchPath: search.path,
+ include: search.include,
+ maxResults,
+ });
+ return { pattern: search.pattern, matches, truncated };
+ })
+ );
+
+ return { ok: true, data: { results } };
+}
+
+async function glob(payload: GlobPayload): Promise<LocalOpResult> {
+ const { cwd, params } = payload;
+ const maxResults = params.maxResults ?? MAX_GLOB_RESULTS;
+
+ const results = await Promise.all(
+ params.patterns.map(async (pattern) => {
+ const { files, truncated } = await globSearchImpl({
+ cwd,
+ pattern,
+ searchPath: params.path,
+ maxResults,
+ });
+ return { pattern, files, truncated };
+ })
+ );
+
+ return { ok: true, data: { results } };
+}
+
+// ── Sentry project + DSN ────────────────────────────────────────────
+
async function createSentryProject(
payload: CreateSentryProjectPayload,
options: WizardOptions
diff --git a/src/lib/init/types.ts b/src/lib/init/types.ts
--- a/src/lib/init/types.ts
+++ b/src/lib/init/types.ts
@@ -25,6 +25,8 @@
| FileExistsBatchPayload
| RunCommandsPayload
| ApplyPatchsetPayload
+ | GrepPayload
+ | GlobPayload
| CreateSentryProjectPayload
| DetectSentryPayload;
@@ -69,6 +71,33 @@
};
};
+export type GrepSearch = {
+ pattern: string;
+ path?: string;
+ include?: string;
+};
+
+export type GrepPayload = {
+ type: "local-op";
+ operation: "grep";
+ cwd: string;
+ params: {
+ searches: GrepSearch[];
+ maxResultsPerSearch?: number;
+ };
+};
+
+export type GlobPayload = {
+ type: "local-op";
+ operation: "glob";
+ cwd: string;
+ params: {
+ patterns: string[];
+ path?: string;
+ maxResults?: number;
+ };
+};
+
export type PatchEdit = {
oldString: string;
newString: string;
diff --git a/src/lib/init/wizard-runner.ts b/src/lib/init/wizard-runner.ts
--- a/src/lib/init/wizard-runner.ts
+++ b/src/lib/init/wizard-runner.ts
@@ -137,6 +137,20 @@
}
case "list-dir":
return "Listing directory...";
+ case "grep": {
+ const searches = payload.params.searches;
+ if (searches.length === 1 && searches[0]) {
+ return `Searching for ${safeCodeSpan(searches[0].pattern)}...`;
+ }
+ return `Running ${searches.length} searches...`;
+ }
+ case "glob": {
+ const patterns = payload.params.patterns;
+ if (patterns.length === 1 && patterns[0]) {
+ return `Finding files matching ${safeCodeSpan(patterns[0])}...`;
+ }
+ return `Finding files (${patterns.length} patterns)...`;
+ }
case "create-sentry-project":
return `Creating project ${safeCodeSpan(payload.params.name)} (${payload.params.platform})...`;
case "detect-sentry":
diff --git a/test/lib/init/local-ops.test.ts b/test/lib/init/local-ops.test.ts
--- a/test/lib/init/local-ops.test.ts
+++ b/test/lib/init/local-ops.test.ts
@@ -16,6 +16,8 @@
import type {
ApplyPatchsetPayload,
FileExistsBatchPayload,
+ GlobPayload,
+ GrepPayload,
ListDirPayload,
LocalOpPayload,
ReadFilesPayload,
@@ -1120,3 +1122,233 @@
expect(paths).toContain(join("a", "nested.ts"));
});
});
+
+describe("grep", () => {
+ let testDir: string;
+ let options: WizardOptions;
+
+ beforeEach(() => {
+ testDir = mkdtempSync(join("/tmp", "grep-test-"));
+ options = makeOptions({ directory: testDir });
+ // Init a git repo so git grep / git ls-files tier is exercised
+ const { execSync } = require("node:child_process");
+ execSync("git init -q", { cwd: testDir });
+ writeFileSync(
+ join(testDir, "app.ts"),
+ 'import * as Sentry from "@sentry/node";\nSentry.init({ dsn: "..." });\n'
+ );
+ writeFileSync(
+ join(testDir, "utils.ts"),
+ "export function helper() { return 1; }\n"
+ );
+ mkdirSync(join(testDir, "src"));
+ writeFileSync(
+ join(testDir, "src", "index.ts"),
+ 'import { helper } from "./utils";\nSentry.init({});\n'
+ );
+ });
+
+ afterEach(() => {
+ rmSync(testDir, { recursive: true, force: true });
+ });
+
+ test("finds matches for a single pattern", async () => {
+ const payload: GrepPayload = {
+ type: "local-op",
+ operation: "grep",
+ cwd: testDir,
+ params: {
+ searches: [{ pattern: "Sentry\\.init" }],
+ },
+ };
+
+ const result = await handleLocalOp(payload, options);
+ expect(result.ok).toBe(true);
+ const data = result.data as {
+ results: Array<{
+ pattern: string;
+ matches: Array<{ path: string; lineNum: number; line: string }>;
+ truncated: boolean;
+ }>;
+ };
+ expect(data.results).toHaveLength(1);
+ expect(data.results[0].matches.length).toBeGreaterThanOrEqual(2);
+ expect(data.results[0].truncated).toBe(false);
+ });
+
+ test("supports multiple search patterns in one call", async () => {
+ const payload: GrepPayload = {
+ type: "local-op",
+ operation: "grep",
+ cwd: testDir,
+ params: {
+ searches: [{ pattern: "@sentry/node" }, { pattern: "helper" }],
+ },
+ };
+
+ const result = await handleLocalOp(payload, options);
+ expect(result.ok).toBe(true);
+ const data = result.data as {
+ results: Array<{ pattern: string; matches: unknown[] }>;
+ };
+ expect(data.results).toHaveLength(2);
+ expect(data.results[0].pattern).toBe("@sentry/node");
+ expect(data.results[0].matches.length).toBeGreaterThanOrEqual(1);
+ expect(data.results[1].pattern).toBe("helper");
+ expect(data.results[1].matches.length).toBeGreaterThanOrEqual(1);
+ });
+
+ test("supports include glob filter", async () => {
+ const payload: GrepPayload = {
+ type: "local-op",
+ operation: "grep",
+ cwd: testDir,
+ params: {
+ searches: [{ pattern: "Sentry", include: "app.*" }],
+ },
+ };
+
+ const result = await handleLocalOp(payload, options);
+ expect(result.ok).toBe(true);
+ const data = result.data as {
+ results: Array<{ matches: Array<{ path: string }> }>;
+ };
+ for (const match of data.results[0].matches) {
+ expect(match.path).toContain("app");
+ }
+ });
+
+ test("returns empty matches for non-matching pattern", async () => {
+ const payload: GrepPayload = {
+ type: "local-op",
+ operation: "grep",
+ cwd: testDir,
+ params: {
+ searches: [{ pattern: "nonexistent_string_xyz" }],
+ },
+ };
+
+ const result = await handleLocalOp(payload, options);
+ expect(result.ok).toBe(true);
+ const data = result.data as { results: Array<{ matches: unknown[] }> };
+ expect(data.results[0].matches).toHaveLength(0);
+ });
+
+ test("returns paths relative to cwd when searching a subdirectory", async () => {
+ const payload: GrepPayload = {
+ type: "local-op",
+ operation: "grep",
+ cwd: testDir,
+ params: {
+ searches: [{ pattern: "helper", path: "src" }],
+ },
+ };
+
+ const result = await handleLocalOp(payload, options);
+ expect(result.ok).toBe(true);
+ const data = result.data as {
+ results: Array<{
+ matches: Array<{ path: string; lineNum: number }>;
+ }>;
+ };
+ expect(data.results[0].matches.length).toBeGreaterThanOrEqual(1);
+ for (const match of data.results[0].matches) {
+ expect(match.path).toMatch(/^src\//);
+ }
+ });
+
+ test("respects path sandbox", async () => {
+ const payload: GrepPayload = {
+ type: "local-op",
+ operation: "grep",
+ cwd: testDir,
+ params: {
+ searches: [{ pattern: "test", path: "../../etc" }],
+ },
+ };
+
+ const result = await handleLocalOp(payload, options);
+ expect(result.ok).toBe(false);
+ expect(result.error).toContain("outside project directory");
+ });
+});
+
+describe("glob", () => {
+ let testDir: string;
+ let options: WizardOptions;
+
+ beforeEach(() => {
+ testDir = mkdtempSync(join("/tmp", "glob-test-"));
+ options = makeOptions({ directory: testDir });
+ const { execSync } = require("node:child_process");
+ execSync("git init -q", { cwd: testDir });
+ writeFileSync(join(testDir, "app.ts"), "x");
+ writeFileSync(join(testDir, "utils.ts"), "x");
+ writeFileSync(join(testDir, "config.json"), "{}");
+ mkdirSync(join(testDir, "src"));
+ writeFileSync(join(testDir, "src", "index.ts"), "x");
+ });
+
+ afterEach(() => {
+ rmSync(testDir, { recursive: true, force: true });
+ });
+
+ test("finds files matching a single pattern", async () => {
+ const payload: GlobPayload = {
+ type: "local-op",
+ operation: "glob",
+ cwd: testDir,
+ params: {
+ patterns: ["*.ts"],
+ },
+ };
+
+ const result = await handleLocalOp(payload, options);
+ expect(result.ok).toBe(true);
+ const data = result.data as {
+ results: Array<{ pattern: string; files: string[]; truncated: boolean }>;
+ };
+ expect(data.results).toHaveLength(1);
+ expect(data.results[0].files.length).toBeGreaterThanOrEqual(2);
+ expect(data.results[0].truncated).toBe(false);
+ for (const f of data.results[0].files) {
+ expect(f).toMatch(/\.ts$/);
+ }
+ });
+
+ test("supports multiple patterns in one call", async () => {
+ const payload: GlobPayload = {
+ type: "local-op",
+ operation: "glob",
+ cwd: testDir,
+ params: {
+ patterns: ["*.ts", "*.json"],
+ },
+ };
+
+ const result = await handleLocalOp(payload, options);
+ expect(result.ok).toBe(true);
+ const data = result.data as {
+ results: Array<{ pattern: string; files: string[] }>;
+ };
+ expect(data.results).toHaveLength(2);
+ expect(data.results[0].files.length).toBeGreaterThanOrEqual(2);
+ expect(data.results[1].files.length).toBeGreaterThanOrEqual(1);
+ });
+
+ test("returns empty for non-matching pattern", async () => {
+ const payload: GlobPayload = {
+ type: "local-op",
+ operation: "glob",
+ cwd: testDir,
+ params: {
+ patterns: ["*.xyz"],
... diff truncated: showing 800 of 809 linesThis Bugbot Autofix run was free. To enable autofix for future PRs, go to the Cursor dashboard.
ecd6915 to
601bea2
Compare
56b2e72 to
7015444
Compare
Add two new local-op types that let the server search project files without reading them all: - grep: regex search across files with optional glob filter, batched (multiple patterns in one round-trip), capped at 100 matches per search with 2000-char line truncation - glob: find files by pattern, batched (multiple patterns in one round-trip), capped at 100 results Three-tier fallback chain (following gemini-cli's approach): 1. ripgrep (rg) — fastest, binary-safe, .gitignore-aware 2. git grep / git ls-files — fast, available on nearly every dev machine 3. Node.js fs walk — always works, no dependencies Counterpart server-side schemas in cli-init-api PR #80. Made-with: Cursor
7015444 to
dfd6f1c
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit dfd6f1c. Configure here.


Summary
Adds two new local-op types so the server can search project files without reading them all — eliminates the need for "FILE SELECTION mode" LLM calls in many cases.
Both use a Node.js
fs.readdir/readFileimplementation (no ripgrep dependency required). Skipsnode_modules,.git,__pycache__,.venv,dist,buildby default.Server-side counterpart (schemas + step prompt updates) will come in a separate cli-init-api PR.
Test plan
Made with Cursor