From 00d6198efff962bfdb9472098a7e76a6795fc9ff Mon Sep 17 00:00:00 2001 From: breken-ai <312387581+breken-ai@users.noreply.github.com> Date: Fri, 25 Sep 2026 03:42:25 -0700 Subject: [PATCH] fix(grep): keep files without the pattern when grep -v is used grep -v prints the lines that do not match, but the SQL prefilter still asked the backend only for rows containing the pattern. A memory file that never mentions the pattern, whose every line grep -v should print, was dropped before the line-by-line inversion ran. Skip the content prefilter for an inverted match and keep only the path filter. --- src/shell/grep-core.ts | 12 ++++++++++++ tests/claude-code/grep-core.test.ts | 18 ++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/src/shell/grep-core.ts b/src/shell/grep-core.ts index 02709ba9d..ec1114649 100644 --- a/src/shell/grep-core.ts +++ b/src/shell/grep-core.ts @@ -623,6 +623,18 @@ export function extractRegexAlternationPrefilters(pattern: string): string[] | n } export function buildGrepSearchOptions(params: GrepMatchParams, targetPath: string): SearchOptions { + // `grep -v` wants the lines that do NOT match, so a row that never contains + // the pattern is exactly the one it must keep. Skip every content prefilter + // and let the line-by-line refinement do the inversion. + if (params.invertMatch) { + return { + pathFilter: buildPathFilter(targetPath), + contentScanOnly: true, + likeOp: process.env.HIVEMIND_GREP_LIKE === "case-sensitive" ? "LIKE" : "ILIKE", + escapedPattern: sqlLike(params.pattern), + }; + } + const hasRegexMeta = !params.fixedString && /[.*+?^${}()|[\]\\]/.test(params.pattern); const literalPrefilter = hasRegexMeta ? extractRegexLiteralPrefilter(params.pattern) : null; const alternationPrefilters = hasRegexMeta ? extractRegexAlternationPrefilters(params.pattern) : null; diff --git a/tests/claude-code/grep-core.test.ts b/tests/claude-code/grep-core.test.ts index 995ad1b5d..6c5f34de9 100644 --- a/tests/claude-code/grep-core.test.ts +++ b/tests/claude-code/grep-core.test.ts @@ -950,6 +950,24 @@ describe("grepBothTables", () => { expect(out).toContain("/summaries/a.md:trailing"); }); + it("grep -v keeps files that never contain the pattern", async () => { + // Fake backend that applies the ILIKE content filter the way the server does: + // with `summary::text ILIKE '%foo%'` in the SQL, only rows containing foo return. + const stored = [ + { path: "/summaries/a.md", content: "foo line\nkeep a" }, + { path: "/summaries/b.md", content: "only b" }, + ]; + const api = { + query: vi.fn(async (sql: string) => + sql.includes("ILIKE '%foo%'") + ? stored.filter((r) => r.content.toLowerCase().includes("foo")) + : stored), + } as any; + const out = await grepBothTables(api, "m", "s", { ...baseParams, invertMatch: true }, "/summaries"); + // `grep -v foo` prints every line without foo, including all of b.md. + expect(out).toEqual(["/summaries/a.md:keep a", "/summaries/b.md:only b"]); + }); + it("falls back to refined output when HIVEMIND_SEMANTIC_EMIT_ALL=false even with an embedding", async () => { const prev = process.env.HIVEMIND_SEMANTIC_EMIT_ALL; process.env.HIVEMIND_SEMANTIC_EMIT_ALL = "false";