diff --git a/src/shell/grep-core.ts b/src/shell/grep-core.ts index 02709ba9..ec111464 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 995ad1b5..6c5f34de 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";