diff --git a/src/db/repos.test.ts b/src/db/repos.test.ts index 6509d26..9c6de31 100644 --- a/src/db/repos.test.ts +++ b/src/db/repos.test.ts @@ -237,6 +237,18 @@ describe("repos", () => { expect(results[0]!.name).toBe("open-todos"); }); + it("treats punctuation in repo search queries as literal text", () => { + upsertRepo({ + path: "/tmp/repo-project-familiarization", + name: "repo-project-familiarization", + description: "repository familiarization", + }); + + expect(searchRepos("repo-project-familiarization").map((repo) => repo.name)) + .toEqual(["repo-project-familiarization"]); + expect(searchRepos("missing-repo-project-familiarization")).toEqual([]); + }); + it("sanitizes contaminated direct rows at list, get, FTS search, and unified search outputs", () => { const unsafe = `https://${["member", "phrase"].join(":")}@git.example.test/team/tool.git?query=marker#fragment`; db.query("INSERT INTO repos (path, name, remote_url) VALUES ('/tmp/unsafe', 'unsafeoutput', ?)").run(unsafe); @@ -400,6 +412,53 @@ describe("pull requests", () => { }); }); +describe("FTS query escaping", () => { + it("treats embedded double quotes as literal input across search surfaces", () => { + const repo = upsertRepo({ + path: "/tmp/quoted-search", + name: "quoted-search", + description: 'handle "quoted" repository search', + }); + bulkInsertCommits([ + { + repo_id: repo.id, + sha: "quoted-commit", + author_name: "Test", + author_email: "test@test.com", + date: "2026-01-01T00:00:00Z", + message: 'handle "quoted" commit search', + files_changed: 1, + insertions: 1, + deletions: 0, + }, + ]); + bulkInsertPullRequests([ + { + repo_id: repo.id, + number: 42, + title: 'handle "quoted" pull request search', + state: "open", + author: "test", + created_at: "2026-01-01", + updated_at: null, + merged_at: null, + closed_at: null, + url: "", + base_branch: "main", + head_branch: "quoted-search", + additions: 1, + deletions: 0, + changed_files: 1, + }, + ]); + + const query = 'handle "quoted"'; + expect(searchRepos(query).map((result) => result.name)).toEqual(["quoted-search"]); + expect(searchCommits(query).map((result) => result.sha)).toEqual(["quoted-commit"]); + expect(searchPullRequests(query).map((result) => result.number)).toEqual([42]); + }); +}); + describe("unified search", () => { it("should search across entities", () => { const repo = upsertRepo({ path: "/tmp/test-platform", name: "test-platform", description: "platform for testing" }); diff --git a/src/db/repos.ts b/src/db/repos.ts index e7cac00..b0cfe02 100644 --- a/src/db/repos.ts +++ b/src/db/repos.ts @@ -403,11 +403,20 @@ export function deleteRepo(id: number): boolean { return result.changes > 0; } +function buildFtsQuery(query: string): string { + return query + .trim() + .split(/\s+/) + .filter(Boolean) + .map((term) => `"${term.replaceAll('"', '""')}"`) + .join(" "); +} + export function searchRepos(query: string, limit = 20): Repo[] { const db = getDb(); const ids = db .query("SELECT rowid FROM fts_repos WHERE fts_repos MATCH ? LIMIT ?") - .all(query, limit) as { rowid: number }[]; + .all(buildFtsQuery(query), limit) as { rowid: number }[]; if (ids.length === 0) return []; const placeholders = ids.map(() => "?").join(","); return (db.query(`SELECT * FROM repos WHERE id IN (${placeholders})`).all(...ids.map((r) => r.rowid)) as Repo[]) @@ -460,7 +469,7 @@ export function searchCommits(query: string, limit = 20): Array; + `).all(buildFtsQuery(query), limit) as Array; } // ── Branches ── @@ -927,7 +936,7 @@ export function searchPullRequests(query: string, limit = 20): Array; + `).all(buildFtsQuery(query), limit) as Array; } // ── Unified Search ──