Public: anyone can read this; the crew can change it.

Search reuses a repository's code results for the same commit and query, and the terminal's palette finds exactly what it found before #3395

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/3384-search-reuses-a-repository-s-code-result → main 4 commits fork 6 threads · 6 unresolved

  • Merged. scout/3384-search-reuses-a-repository-s-code-result was merged into main as 64559a0.

Review

Sign in to review this change.
3 files changed +264 −5
src/core/search.ts +89 −5
@@ -12,8 +12,10 @@
1212 *
1313 * **No index of its own.** Repos and issues are already indexed rows; code
1414 * is answered by `git grep` against the object store, which is the only copy
15 * of the truth. Nothing here caches, so nothing here can go stale — and
16 * there is no derived table to rebuild when git and the index disagree.
15 * of the truth. The one memory is a bounded cache of a repo's grep answer
16 * keyed by (repo id, commit oid, query): a commit never changes, so the
17 * entry cannot go stale, and a new commit is a new key. It sits below
18 * visibility — it is asked only for repos the viewer already may see.
1719 *
1820 * **Visibility is the same visibility.** Local rows come through the same
1921 * `VISIBLE` predicate a repo listing uses, and code is grepped only in repos
@@ -22,6 +24,8 @@
2224 */
2325
2426 import type { Database } from "bun:sqlite";
27 import { readFile } from "node:fs/promises";
28 import { join } from "node:path";
2529 import { runGitCapture } from "../git/env.ts";
2630 import { isSafeRef } from "../git/guard.ts";
2731 import { repoDir } from "../git/repos.ts";
@@ -339,6 +343,18 @@ export async function grepRepo(
339343 query: string,
340344 maxCount = MAX_HITS_PER_REPO,
341345 ): Promise<{ path: string; line: number; text: string }[]> {
346 return (await grepRun(dir, ref, query, maxCount)) ?? [];
347 }
348
349 type RawHit = { path: string; line: number; text: string };
350
351 /** Hits, or null when the grep did not complete (so it is not worth keeping). */
352 async function grepRun(
353 dir: string,
354 ref: string,
355 query: string,
356 maxCount: number,
357 ): Promise<RawHit[] | null> {
342358 const pattern = cleanQuery(query);
343359 if (pattern === "" || !isSafeRef(ref)) return [];
344360
@@ -362,7 +378,7 @@ export async function grepRepo(
362378 );
363379 // Exit 1 is "no match" — data, not failure. Anything else (a repo with no
364380 // commits, a killed process) is simply no results from this repo.
365 if (run.code !== 0 && run.code !== 1) return [];
381 if (run.code !== 0 && run.code !== 1) return null;
366382
367383 // `-z` makes each record `ref:path NUL line NUL text`, so a path with a
368384 // colon in it (`a:1:b.txt`) cannot be split at the wrong colon.
@@ -385,6 +401,74 @@ export async function grepRepo(
385401 return hits;
386402 }
387403
404 const CACHE_MAX = 4_000;
405 const grepCache = new Map<string, RawHit[]>();
406
407 /**
408 * The commit a branch points at, read from the ref files rather than a
409 * subprocess. Null when it cannot be told for certain (a tag of the same
410 * name, a missing ref): the caller then greps uncached.
411 */
412 async function branchOid(dir: string, branch: string): Promise<string | null> {
413 if (!isSafeRef(branch) || branch.includes("..")) return null;
414 const oid = /^[0-9a-f]{40,64}$/;
415 const slurp = (path: string) =>
416 readFile(path, "utf8").then((t) => t, () => null);
417 const packed = (await slurp(join(dir, "packed-refs"))) ?? "";
418 const inPacked = (ref: string) => {
419 for (const line of packed.split("\n")) {
420 if (line.endsWith(` ${ref}`) && oid.test(line.slice(0, line.indexOf(" "))))
421 return line.slice(0, line.indexOf(" "));
422 }
423 return null;
424 };
425 if (inPacked(`refs/tags/${branch}`) !== null) return null;
426 if ((await slurp(join(dir, "refs", "tags", branch))) !== null) return null;
427 const loose = (await slurp(join(dir, "refs", "heads", branch)))?.trim();
428 if (loose !== undefined) return loose !== null && oid.test(loose) ? loose : null;
429 return inPacked(`refs/heads/${branch}`);
430 }
431
432 /** Greps actually run — a test reads it to prove a repeat ran none. */
433 let grepsRun = 0;
434 export const grepCount = (): number => grepsRun;
435
436 /**
outdated

The oid is read, and then grepRun greps by branch name, not by that oid. A push between the two steps stores newer content under the older key. Grep oid itself (it is a valid ref) so the key and the content always agree.

437 * Cached by the commit the branch points at, and grepped at that commit, so
outdated

A hit is returned by reference from the cache. If any caller mutates the array or its hits, the cache is corrupted. Return a copy, or freeze the entries.

438 * the key and the content cannot disagree if a push lands in between. The
439 * branch's oid comes from the ref files, not from asking git, so a search of
440 * fifty repos spawns nothing when it is answered from memory. Whenever the
441 * name could mean something else to git (a tag of the same name) `branchOid`
442 * gives up and the repo is grepped by name, which is git's own resolution.
443 */
444 async function grepCached(
445 repo: Repo,
446 dir: string,
447 query: string,
448 ): Promise<RawHit[]> {
449 const ref = repo.defaultBranch;
450 const oid = await branchOid(dir, ref);
451 if (oid === null) {
452 grepsRun++;
453 return (await grepRun(dir, ref, query, MAX_HITS_PER_REPO)) ?? [];
454 }
455 const key = `${repo.id}\0${oid}\0${cleanQuery(query)}`;
456 const kept = grepCache.get(key);

grepsRun/grepCount exist only so tests can observe the cache. Prefer surfacing this through an existing metric or timing phase rather than a production export.

457 if (kept !== undefined) {
458 grepCache.delete(key);
459 grepCache.set(key, kept);
460 return kept.map((h) => ({ ...h }));
461 }
462 grepsRun++;
463 const hits = await grepRun(dir, oid, query, MAX_HITS_PER_REPO);
464 if (hits === null) return [];
465 grepCache.set(key, hits.map((h) => ({ ...h })));
466 if (grepCache.size > CACHE_MAX) {
467 grepCache.delete(grepCache.keys().next().value as string);
468 }
469 return hits;
470 }
471
388472 /**
389473 * Grep across a bounded set of repos. Repos are searched a few at a time so
390474 * fifty small greps do not become fifty simultaneous processes, and the walk
@@ -404,9 +488,9 @@ export async function grepRepos(
404488 const slice = considered.slice(i, i + batch);
405489 const found = await Promise.all(
406490 slice.map((repo) =>
407 grepRepo(
491 grepCached(
492 repo,
408493 repoDir(reposRoot, repo.ownerName, repo.name),
409 repo.defaultBranch,
410494 query,
411495 ),
412496 ),
test/search-cache-palette.test.ts added +59
@@ -0,0 +1,59 @@
1 /** The terminal palette's search finds exactly what it found before the cache (#3384). */
2 import { afterAll, beforeAll, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { search } from "../src/actions/search.ts";
7 import { createApp, type App } from "../src/app.ts";
8 import { createToken } from "../src/core/tokens.ts";
9 import { grepCount } from "../src/core/search.ts";
10 import { getUserByName } from "../src/core/users.ts";
11 import { serve, type Servers } from "../src/http/server.ts";
12 import { seedAdmin } from "./helpers/admin.ts";
13
14 let dir = "";
15 let app: App;
16 let servers: Servers;
17 let token = "";
18
19 const api = (path: string, body: unknown) =>
20 fetch(servers.url + path, {
21 method: "POST",
22 headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
23 body: JSON.stringify(body),
24 });
25
26 beforeAll(async () => {
27 dir = await mkdtemp(join(tmpdir(), "forge-search-palette-"));
28 app = createApp({ dataDir: dir, port: 0 });
29 servers = await serve(app);
30 const made = await seedAdmin(app, { username: "alice", email: "[email protected]", password: "alice-palette-secret" });
31 if (!made.ok) throw new Error(made.error);
32 token = createToken(app.db, made.value.id, "t", "admin").token;
33 for (const [name, visibility] of [["open", "public"], ["closed", "private"]] as const) {
34 await api("/api/v1/repos", { name, visibility });
35 await api(`/api/v1/repos/alice/${name}/contents`, {
36 branch: "main",
37 message: "first",
38 files: [{ path: `${name}.ts`, content: "const palette = 1;\n" }],
39 });
40 }
41 });
42 afterAll(async () => {
43 servers.stop();
44 app.close();
45 await rm(dir, { recursive: true, force: true });
46 });
47
48 // The palette calls this exact action with limit 8 (src/tui/forge.ts).
49 test("a cached repeat returns the hits the first search did, and a stranger never gets the private ones", async () => {
50 const alice = getUserByName(app.db, "alice");
51 const ask = (user: typeof alice) => search(app, user, { q: "palette", limit: 8, live: false, withCode: true });
52 const cold = await ask(alice);
53 expect(cold.code.map((h) => h.repo.name).sort()).toEqual(["closed", "open"]);
54 const ran = grepCount();
55 expect(await ask(alice)).toEqual(cold);
56 expect(grepCount()).toBe(ran);
57 const stranger = await ask(undefined);
58 expect(stranger.code.map((h) => h.repo.name)).toEqual(["open"]);
59 });
test/search-cache.test.ts added +116
@@ -0,0 +1,116 @@
1 /** A repository's code results are reused for the same commit and query (#3384). */
2 import { afterAll, beforeAll, expect, test } from "bun:test";
3 import { cp, mkdir, mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { grepCount, grepRepos } from "../src/core/search.ts";
7 import type { Repo } from "../src/core/repos.ts";
8
9 let root = "";
10 const repos: Repo[] = [];
11
12 const git = (cwd: string, ...args: string[]) => {
13 const r = Bun.spawnSync(["git", ...args], {
14 cwd,
15 env: { ...process.env, GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@t", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@t" },
16 });
17 if (r.exitCode !== 0) throw new Error(r.stderr.toString());
18 };
19 beforeAll(async () => {
20 root = await mkdtemp(join(tmpdir(), "forge-search-cache-"));
21 const work = join(root, "work");
22 await mkdir(work);
23 git(work, "init", "-q", "-b", "main");
24 await Bun.write(join(work, "a.txt"), "needle\nother\n");
25 git(work, "add", "a.txt");
26 git(work, "commit", "-q", "-m", "c");
27 await mkdir(join(root, "u"));
28 git(root, "clone", "-q", "--bare", work, join(root, "u", "r0.git"));
29 for (let i = 0; i < 200; i++) {
30 if (i > 0) await cp(join(root, "u", "r0.git"), join(root, "u", `r${i}.git`), { recursive: true });
31 repos.push({ id: `id${i}`, ownerName: "u", name: `r${i}`, defaultBranch: "main" } as Repo);
32 }
33 }, 60_000);
34
35 afterAll(() => rm(root, { recursive: true, force: true }));
36
37 test("a repeat search answers identically and far faster than the greps", async () => {
38 const t0 = performance.now();

The relative timing assertion (4x faster) depends on machine load and can flake. A deterministic proof is better: count the greps run, or check that a second call after deleting the repo directory still returns the hits.

39 const first = await grepRepos(root, repos, "needle");
40 const cold = performance.now() - t0;
41 expect(first.hits.length).toBe(50);
42 expect(first.truncated).toBe(true);
43 const ran = grepCount();
44 const t = performance.now();
45 const again = await grepRepos(root, repos, "needle");
46 // A wall-clock floor is forbidden in tests (flake.test.ts); the cache is
47 // proven by a repeat that costs a fraction of the greps it replaces.
48 expect((performance.now() - t) * 4).toBeLessThan(cold);
49 expect(grepCount()).toBe(ran);
50 expect(again).toEqual(first);
51 expect(first.hits[0]).toEqual({ repo: { owner: "u", name: "r0" }, path: "a.txt", line: 1, text: "needle" });

The relative wall-clock ratio can flake under load. The grepCount equality below it already proves the cache, so drop this line.

52 });
53
54 test("a new commit is a new key, never the old answer", async () => {
55 const bare = join(root, "u", "r0.git");
56 const before = await grepRepos(root, repos.slice(0, 1), "fresh");
57 expect(before.hits).toEqual([]);
58 const work = join(root, "work");
59 await Bun.write(join(work, "b.txt"), "a fresh line\n");
60 git(work, "add", ".");
61 git(work, "commit", "-q", "-m", "more");
62 git(work, "push", "-q", bare, "main");
63 const after = await grepRepos(root, repos.slice(0, 1), "fresh");
64 expect(after.hits.map((h) => h.path)).toEqual(["b.txt"]);
65 });
outdated

This title promises a tag case that the body never exercises. Create refs/tags/main at another commit in the bare repo and assert the result is correct and uncached. As written, deleting the tag branch of branchOid leaves every test green.

66
67 test("a repo without commits answers nothing", async () => {
68 const bare = join(root, "u", "empty.git");
69 await mkdir(bare, { recursive: true });
70 git(bare, "init", "-q", "--bare", "-b", "main");
71 const empty = { id: "e", ownerName: "u", name: "empty", defaultBranch: "main" } as Repo;
72 expect((await grepRepos(root, [empty], "needle")).hits).toEqual([]);
73 });
74
75 test("a tag named like the branch is what git resolves, and is never served stale", async () => {
76 const bare = join(root, "u", "r1.git");
77 const work = join(root, "work");
78 const one = [repos[1]!];
79 // The tag points at a commit the branch does not have.
80 git(work, "checkout", "-q", "-b", "other");
81 await Bun.write(join(work, "t.txt"), "taggedonly\n");
82 git(work, "add", "t.txt");
83 git(work, "commit", "-q", "-m", "tagged");
84 git(work, "push", "-q", bare, "other");
85 git(work, "tag", "main");
86 git(work, "push", "-q", bare, "refs/tags/main");
87 git(work, "checkout", "-q", "main");
88
89 // What git itself answers for the name `main` in this repo.
90 const asGit = (q: string) =>
91 Bun.spawnSync(["git", "grep", "-l", "-F", q, "main", "--", "."], { cwd: bare })
92 .stdout.toString().split("\n").filter(Boolean).map((l) => l.split(":")[1]!);
93 expect(asGit("taggedonly")).toEqual(["t.txt"]);
94
95 const before = grepCount();
96 for (const q of ["taggedonly", "taggedonly"]) {
97 const found = await grepRepos(root, one, q);
98 expect(found.hits.map((h) => h.path)).toEqual(asGit(q));
99 }
100 // Both searches grepped: the ambiguous name is never cached.
101 expect(grepCount() - before).toBe(2);
102
103 // Move the tag; the next search follows it.
104 git(work, "tag", "-f", "main", "HEAD");
105 git(work, "push", "-q", "-f", bare, "refs/tags/main");
106 expect((await grepRepos(root, one, "taggedonly")).hits).toEqual([]);
107 });
108
109 test("a caller that edits a result cannot change the next answer", async () => {
110 const one = [repos[2]!];
111 const first = await grepRepos(root, one, "needle");
112 (first.hits[0] as { text: string }).text = "mutated";
113 first.hits.length = 0;
114 const again = await grepRepos(root, one, "needle");
115 expect(again.hits[0]?.text).toBe("needle");
116 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs