diff --git a/src/tools/glob/cli.test.ts b/src/tools/glob/cli.test.ts index bfab65d57..5d1ba0683 100644 --- a/src/tools/glob/cli.test.ts +++ b/src/tools/glob/cli.test.ts @@ -54,6 +54,15 @@ describe("buildRgArgs", () => { const args = buildRgArgs({ pattern: "**/*.tsx" }) expect(args).toContain("--glob=**/*.tsx") }) + + // Regression for #3726: broken/dangling symlinks should not surface as + // tool errors. --no-messages silences ripgrep's non-fatal stderr warnings + // so the downstream "exit code > 2 && stderr.trim()" gate sees a clean + // stream when only I/O warnings were emitted. + it("includes --no-messages so broken symlinks do not error the tool (#3726)", () => { + const args = buildRgArgs({ pattern: "*.ts" }) + expect(args).toContain("--no-messages") + }) }) describe("buildFindArgs", () => { diff --git a/src/tools/glob/cli.ts b/src/tools/glob/cli.ts index 9ba34c32a..271405a21 100644 --- a/src/tools/glob/cli.ts +++ b/src/tools/glob/cli.ts @@ -152,7 +152,10 @@ async function runRgFilesInternal( const stderr = await new Response(proc.stderr).text() const exitCode = await proc.exited - if (exitCode > 1 && stderr.trim()) { + // ripgrep exits 2 on non-fatal I/O errors (broken symlinks, permission + // denied) even when stdout still contains valid file matches; treat only + // exit codes >2 as fatal so partial results survive. See #3726. + if (exitCode > 2 && stderr.trim()) { return { files: [], totalFiles: 0, diff --git a/src/tools/glob/constants.ts b/src/tools/glob/constants.ts index 8284b3681..0e49431f4 100644 --- a/src/tools/glob/constants.ts +++ b/src/tools/glob/constants.ts @@ -9,4 +9,8 @@ export const RG_FILES_FLAGS = [ "--files", "--color=never", "--glob=!.git/*", + // Suppress stderr warnings on broken/dangling symlinks and similar + // non-fatal I/O issues so they don't tip the tool into the error branch. + // See #3726. + "--no-messages", ] as const diff --git a/src/tools/grep/cli.ts b/src/tools/grep/cli.ts index 4b9684c66..abef23804 100644 --- a/src/tools/grep/cli.ts +++ b/src/tools/grep/cli.ts @@ -197,7 +197,10 @@ async function runRgInternal(options: GrepOptions, resolvedCli?: ResolvedCli): P const truncated = stdout.length >= DEFAULT_MAX_OUTPUT_BYTES const outputToProcess = truncated ? stdout.substring(0, DEFAULT_MAX_OUTPUT_BYTES) : stdout - if (exitCode > 1 && stderr.trim()) { + // ripgrep exits 2 on non-fatal I/O errors (broken symlinks, permission + // denied) even when stdout still contains valid matches; treat only + // exit codes >2 as fatal so partial results survive. See #3726. + if (exitCode > 2 && stderr.trim()) { return { matches: [], totalMatches: 0, diff --git a/src/tools/grep/constants.ts b/src/tools/grep/constants.ts index f6c913303..5dc9f34f3 100644 --- a/src/tools/grep/constants.ts +++ b/src/tools/grep/constants.ts @@ -12,6 +12,10 @@ export const RG_SAFETY_FLAGS = [ "--no-heading", "--line-number", "--with-filename", + // Suppress stderr warnings on broken/dangling symlinks and similar + // non-fatal I/O issues so they don't tip the tool into the error branch. + // See #3726. + "--no-messages", ] as const export const GREP_SAFETY_FLAGS = ["-n", "-H", "--color=never"] as const