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

Files page: checks phase under 3 ms and no git spawns for branch tips on a hit #6169

Merged opened this

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

Merged

scout/forge:scout/6153-files-page-checks-phase-under-3-ms-and-n → main 4 commits fork 5 threads · 5 unresolved

  • Merged. scout/6153-files-page-checks-phase-under-3-ms-and-n was merged into main as a1421e5.

Review

Sign in to review this change.
5 files changed +279 −16
src/core/pulls.ts +11 −3
@@ -12,6 +12,7 @@ import { sealedByNumber } from "./sealed.ts";
1212 import type { Database } from "bun:sqlite";
1313 import { diffRange, type Diff } from "../git/diff.ts";
1414 import { isSafeRef } from "../git/guard.ts";
15 import { refTip } from "../git/read.ts";
1516 import { readTips } from "../git/tips.ts";
1617 import {
1718 aheadCount,
@@ -761,12 +762,19 @@ export async function pullStatus(
761762 pull: Pull,
762763 gate?: ReviewGate,
763764 ): Promise<PullStatus> {
764 const baseOid =
765 (await resolveOid(dir, `refs/heads/${pull.baseBranch}`)) ?? pull.baseOid;
766765 const headRef = pull.crossRepo
767766 ? `refs/pull/${pull.number}/head`
768767 : `refs/heads/${pull.headBranch}`;
769 const headOid = (await resolveOid(dir, headRef)) ?? pull.headOid;
768 // The tips come from the ref files (no process); git is asked only when
769 // they cannot be read that way.
770 const tip = async (ref: string): Promise<string | undefined> =>
771 (await refTip(dir, ref)) ?? (await resolveOid(dir, ref));
772 const [liveBase, liveHead] = await Promise.all([
773 tip(`refs/heads/${pull.baseBranch}`),
outdated

This is the behaviour the issue is about. Add a test that runs pullStatus with git unavailable (empty PATH / failing git) and still gets the live tips; today the suite passes with the old rev-parse path restored.

774 tip(headRef),
775 ]);
776 const baseOid = liveBase ?? pull.baseOid;
777 const headOid = liveHead ?? pull.headOid;
770778 const reviews = gate === undefined ? null : reviewGate(gate, pull, headOid);
771779 const { ahead, mergeable: clean, conflicts } = await mergeReading(
772780 dir,
src/git/read.ts +28
@@ -8,6 +8,7 @@
88 import { defect } from "../kernel/error.ts";
99 import { err, ok, type Result } from "../kernel/result.ts";
1010 import { gitEnv, runGit, runGitRaw, type GitFailure } from "./env.ts";
11 import { isSafeRef } from "./guard.ts";
1112
1213 export type ObjectType = "blob" | "tree" | "commit" | "tag";
1314
@@ -556,3 +557,30 @@ export async function listPaths(
556557 if (all.at(-1) === "") all.pop();
557558 return ok({ paths: all.slice(0, cap), truncated: all.length > cap });
558559 }
560
561 /**
562 * The commit a ref names, read from the ref files themselves: no process.
563 * A loose ref wins over `packed-refs`, as in git. Anything this does not
564 * fully understand (a symbolic ref, a missing file, an odd line) answers
565 * undefined and the caller asks git.
566 */
567 export async function refTip(dir: string, ref: string): Promise<string | undefined> {

Good guard order (safe ref, refs/ prefix, no ..). Add test cases for a symbolic ref and a garbled loose file so the fallback to git is proven.

568 if (!isSafeRef(ref) || !ref.startsWith("refs/") || ref.includes("..")) return undefined;
569 const oid = /^[0-9a-f]{40}([0-9a-f]{24})?$/;
570 try {
571 const loose = (await Bun.file(`${dir}/${ref}`).text()).trim();
572 return oid.test(loose) ? loose : undefined;
573 } catch {
574 // not loose: it may be packed
575 }
576 try {
577 const packed = await Bun.file(`${dir}/packed-refs`).text();
578 for (const line of packed.split("\n")) {
579 if (line.endsWith(` ${ref}`) && oid.test(line.slice(0, line.length - ref.length - 1)))
580 return line.slice(0, line.length - ref.length - 1);
581 }
582 } catch {
583 // no packed-refs
584 }
585 return undefined;
586 }
src/web/pages/pull.ts +29 −13
@@ -54,8 +54,7 @@ import {
5454 } from "../../core/reviews.ts";
5555 import { getUsersByIds } from "../../core/users.ts";
5656 import { canWrite, getRepoById, type Repo } from "../../core/repos.ts";
57 import { resolveOid } from "../../git/merge.ts";
58 import { commitLog } from "../../git/read.ts";
57 import { commitLog, readerFor } from "../../git/read.ts";
5958 import { wallOf } from "../../contract/policy.ts";
6059 import type { Ctx } from "../../http/context.ts";
6160 import {
@@ -303,6 +302,28 @@ export function checksLine(
303302 </p>`;
304303 }
305304
305 /** Whether a head declares a fast tier: a commit's files never change. */
306 const fastByHead = new Map<string, boolean>();
307 export async function declaresFast(dir: string, head: string): Promise<boolean> {
308 const known = fastByHead.get(head);
309 if (known !== undefined) return known;
310 const [forgeJson, packageJson] = await Promise.all([
311 readFileAt(dir, head, "forge.json"),
312 readFileAt(dir, head, "package.json"),
313 ]);
314 const declared = parseCheck(forgeJson, packageJson);
outdated

A head with neither file is never cached, so every view of such a pull re-reads from git, which is the cost this pull is removing. Cache the negative once the commit is known to exist, for example by checking the tree is readable, rather than by whether a file came back.

315 const fast = declared.ok && declared.value?.fast != null;
316 // Nothing read: remember that only for a commit known to exist (its tree
outdated

A false from a failed or partial read is cached for the process lifetime. Skip caching when both files came back empty/undefined, and test it.

317 // reads); an unreadable one may simply not have landed yet.
318 if (forgeJson === undefined && packageJson === undefined) {
319 const tree = await readerFor(dir).object(`${head}^{tree}`);
320 if (!tree.ok) return fast;
321 }
322 if (fastByHead.size >= 500) fastByHead.delete(fastByHead.keys().next().value!);
323 fastByHead.set(head, fast);
324 return fast;
325 }
326
306327 /**
307328 * The tiers' words for a pull whose check declares a fast tier (#1198);
308329 * nothing when it does not, or when no check has run on this head.
@@ -314,12 +335,7 @@ async function tierOf(
314335 ): Promise<string> {
315336 const run = check.run;
316337 if (run === null || run.kind !== "check") return "";
317 const [forgeJson, packageJson] = await Promise.all([
318 readFileAt(dir, head, "forge.json"),
319 readFileAt(dir, head, "package.json"),
320 ]);
321 const declared = parseCheck(forgeJson, packageJson);
322 if (!declared.ok || declared.value?.fast == null) return "";
338 if (!(await declaresFast(dir, head))) return "";
323339 const state: TierState | undefined =
324340 check.state === "ok"
325341 ? "green"
@@ -843,11 +859,11 @@ export async function pullFilesPage(ctx: Ctx): Promise<Response> {
843859 const check = tick("checks", () => checkSummary(ctx.app.db, pull.id, status.headOid));
844860 const isApp = getAppByRepo(ctx.app.db, repo.id) !== undefined;
845861 const tier = await lap("checks", () => tierOf(dir, status.headOid, check));
846 const stale = await lap("checks", async () =>
862 // `status.baseOid` is the live base tip already: no second reading of it.
863 const stale =
847864 pull.state === "open" && pull.mergedAt === null
848 ? checkStale(check.run, await resolveOid(dir, `refs/heads/${pull.baseBranch}`))
849 : false,
850 );
865 ? checkStale(check.run, status.baseOid)
866 : false;
851867 const threads = await lap("reviews", () => pullThreads(ctx.app, pull));
852868 const threadViews = threadStateViews(ctx, repo, threads);
853869 const unresolved = threads.filter((t) => t.resolvedAt === null).length;
@@ -1395,7 +1411,7 @@ export async function pullChecksLineFor(
13951411 const tier = await tierOf(dir, status.headOid, check);
13961412 const stale =
13971413 pull.state === "open" && pull.mergedAt === null
1398 ? checkStale(check.run, await resolveOid(dir, `refs/heads/${pull.baseBranch}`))
1414 ? checkStale(check.run, status.baseOid)
13991415 : false;
14001416 return pullChecksLine(ctx, repo, pull, status, check, isApp, tier, stale);
14011417 }
test/pull-tips.test.ts added +153
@@ -0,0 +1,153 @@
1 import { expect, test } from "bun:test";
2 import { mkdtemp, rm } from "node:fs/promises";
3 import { tmpdir } from "node:os";
4 import { join } from "node:path";
5 import { pullStatus, type Pull } from "../src/core/pulls.ts";
6 import { declaresFast } from "../src/web/pages/pull.ts";
7 import { runGit } from "../src/git/env.ts";
8 import { gitClientEnv } from "./helpers/git-client.ts";
9
10 const env = gitClientEnv({
11 GIT_AUTHOR_NAME: "X",
12 GIT_AUTHOR_EMAIL: "[email protected]",
13 GIT_COMMITTER_NAME: "X",
14 GIT_COMMITTER_EMAIL: "[email protected]",
15 });
16
17 async function git(cwd: string, ...args: string[]): Promise<string> {
18 const p = Bun.spawn(["git", ...args], { cwd, env, stdout: "pipe", stderr: "pipe" });
19 const out = await new Response(p.stdout).text();
20 expect(await p.exited).toBe(0);
21 return out.trim();
22 }
23
24 const pullAt = (base: string, head: string, o: Partial<Pull> = {}): Pull =>
25 ({
26 number: 1,
27 baseBranch: "main",
28 headBranch: "feature",
29 baseOid: base,
30 headOid: head,
31 crossRepo: false,
32 ...o,
33 }) as Pull;
34
35 async function repo(): Promise<{ root: string; gd: string; one: string; two: string }> {
36 const root = await mkdtemp(join(tmpdir(), "pulltips-"));
37 await git(root, "init", "-q", "-b", "main");
38 await git(root, "commit", "-q", "--allow-empty", "-m", "one");
39 const one = await git(root, "rev-parse", "HEAD");
40 await git(root, "checkout", "-q", "-b", "feature");
41 await Bun.write(join(root, "f.txt"), "x\n");
42 await git(root, "add", "f.txt");
43 await git(root, "commit", "-q", "-m", "two");
44 const two = await git(root, "rev-parse", "HEAD");
45 return { root, gd: join(root, ".git"), one, two };
46 }
47
48 test("pullStatus answers the live tips: loose, packed, and through git for a symbolic or garbled ref", async () => {
49 const { root, gd, one, two } = await repo();
50 try {
51 // The snapshot on the pull is stale on purpose: the answer must be live.
52 const stale = pullAt("0".repeat(40), "1".repeat(40));
53 let s = await pullStatus(gd, stale);
54 expect([s.baseOid, s.headOid, s.ahead, s.mergeable]).toEqual([one, two, 1, true]);
55
56 await git(root, "pack-refs", "--all");
57 s = await pullStatus(gd, stale);
58 expect([s.baseOid, s.headOid]).toEqual([one, two]);
59
60 // A symbolic loose ref is not for us to read: git resolves it.
61 await Bun.write(join(gd, "refs/heads/feature"), "ref: refs/heads/main\n");
62 s = await pullStatus(gd, stale);
63 expect(s.headOid).toBe(one);
64
65 // A garbled one is not an oid: git refuses it, the pull's snapshot stands.
66 await Bun.write(join(gd, "refs/heads/feature"), "garbage\n");
67 s = await pullStatus(gd, pullAt(one, two));
68 expect(s.headOid).toBe(two);
69 } finally {
70 await rm(root, { recursive: true, force: true });
71 }
72 });
73
74 test("pullStatus spawns nothing on a hit: live tips with no git on the PATH", async () => {
75 const { root, gd, one, two } = await repo();
76 const nogit = await mkdtemp(join(tmpdir(), "pulltips-nogit-"));
77 const path = process.env.PATH;
78 const without = async (work: () => Promise<void>): Promise<void> => {
79 process.env.PATH = nogit;
80 try {
81 await work();
82 } finally {
83 process.env.PATH = path;
84 }
85 };
86 try {
87 const stale = pullAt("0".repeat(40), "1".repeat(40));
88 await pullStatus(gd, stale); // the merge reading is remembered by oid
89 // Control: with no git, a spawn fails, so a spawn below would be seen.
90 await without(async () => {
91 const probe = await runGit(["rev-parse", "HEAD"], { cwd: root }).then(
92 (r) => r.ok,
93 () => false,
94 );
95 expect(probe).toBe(false);
96 });
97 await without(async () => {
98 const s = await pullStatus(gd, stale);
99 expect([s.baseOid, s.headOid]).toEqual([one, two]);
100 });
101 await git(root, "pack-refs", "--all");
102 await pullStatus(gd, stale);
103 await without(async () => {
104 const s = await pullStatus(gd, stale);
105 expect([s.baseOid, s.headOid]).toEqual([one, two]);
106 });
107 } finally {
108 process.env.PATH = path;
109 await rm(root, { recursive: true, force: true });
110 await rm(nogit, { recursive: true, force: true });
111 }
112 });
113
114 test("declaresFast remembers a commit with neither file, once its tree reads", async () => {
115 const { root, gd, two } = await repo();
116 const elsewhere = await mkdtemp(join(tmpdir(), "pulltips-empty-"));
117 try {
118 await git(elsewhere, "init", "-q", "--bare");
119 expect(await declaresFast(gd, two)).toBe(false);
120 // Remembered: a repository that cannot supply the commit is not asked.
121 expect(await declaresFast(elsewhere, two)).toBe(false);
122 // An unreadable commit is not remembered as false.
123 const ghost = "a".repeat(40);
124 expect(await declaresFast(elsewhere, ghost)).toBe(false);
125 expect(await declaresFast(gd, ghost)).toBe(false);
126 } finally {
127 await rm(root, { recursive: true, force: true });
128 await rm(elsewhere, { recursive: true, force: true });
129 }
130 });
131
132 test("declaresFast is stable across calls and not poisoned by a failed first read", async () => {
133 const { root, gd } = await repo();
134 try {
135 await Bun.write(
136 join(root, "forge.json"),
137 JSON.stringify({ check: { run: "a", fast: "b" } }),
138 );
139 await git(root, "add", "forge.json");
140 await git(root, "commit", "-q", "-m", "check");
141 const head = await git(root, "rev-parse", "HEAD");
142 const empty = await mkdtemp(join(tmpdir(), "pulltips-empty-"));
143 await git(empty, "init", "-q", "--bare");
144 // The commit cannot be read yet (a repository without it): false, and not remembered.
145 expect(await declaresFast(empty, head)).toBe(false);
146 expect(await declaresFast(gd, head)).toBe(true);
147 // Remembered once read: the same answer with nowhere to read it from.
148 expect(await declaresFast(empty, head)).toBe(true);
149 expect(await declaresFast(gd, head)).toBe(true);
150 } finally {
151 await rm(root, { recursive: true, force: true });
152 }
153 });
test/ref-tip.test.ts added +58
@@ -0,0 +1,58 @@
1 import { expect, test } from "bun:test";
2 import { mkdtemp, rm } from "node:fs/promises";
3 import { tmpdir } from "node:os";
4 import { join } from "node:path";
5 import { refTip } from "../src/git/read.ts";
6 import { gitClientEnv } from "./helpers/git-client.ts";
7
8 const env = gitClientEnv({
9 GIT_AUTHOR_NAME: "X",
10 GIT_AUTHOR_EMAIL: "[email protected]",
11 GIT_COMMITTER_NAME: "X",
12 GIT_COMMITTER_EMAIL: "[email protected]",
13 });
14
15 async function git(cwd: string, ...args: string[]): Promise<string> {
16 const p = Bun.spawn(["git", ...args], { cwd, env, stdout: "pipe", stderr: "pipe" });
17 const out = await new Response(p.stdout).text();
18 expect(await p.exited).toBe(0);
19 return out.trim();
20 }
21

Only refTip is covered. Add coverage that fails without the pullStatus tip reading and without the declaresFast cache.

22 test("refTip reads loose and packed refs without git, and refuses what it cannot read", async () => {
23 const dir = await mkdtemp(join(tmpdir(), "reftip-"));
24 try {
25 await git(dir, "init", "-q", "-b", "main");
26 const gd = join(dir, ".git");
27 await git(dir, "commit", "-q", "--allow-empty", "-m", "one");
28 const one = await git(dir, "rev-parse", "HEAD");
29 await git(dir, "branch", "feature");
30 expect(await refTip(gd, "refs/heads/main")).toBe(one);
31 await git(dir, "pack-refs", "--all");
32 expect(await refTip(gd, "refs/heads/feature")).toBe(one);
33 await git(dir, "commit", "-q", "--allow-empty", "-m", "two");
34 const two = await git(dir, "rev-parse", "HEAD");
35 // a loose ref shadows the packed one
36 expect(await refTip(gd, "refs/heads/main")).toBe(two);
37 expect(await refTip(gd, "refs/heads/nope")).toBeUndefined();
38 expect(await refTip(gd, "refs/heads/../../x")).toBeUndefined();
39 expect(await refTip(gd, "HEAD")).toBeUndefined();
40 } finally {
41 await rm(dir, { recursive: true, force: true });
42 }
43 });
44
45 test("refTip answers undefined for a symbolic or garbled loose ref, so the caller asks git", async () => {
46 const dir = await mkdtemp(join(tmpdir(), "reftip-"));
47 try {
48 await git(dir, "init", "-q", "-b", "main");
49 await git(dir, "commit", "-q", "--allow-empty", "-m", "one");
50 const gd = join(dir, ".git");
51 await Bun.write(join(gd, "refs/heads/sym"), "ref: refs/heads/main\n");
52 await Bun.write(join(gd, "refs/heads/junk"), "not an oid\n");
53 expect(await refTip(gd, "refs/heads/sym")).toBeUndefined();
54 expect(await refTip(gd, "refs/heads/junk")).toBeUndefined();
55 } finally {
56 await rm(dir, { recursive: true, force: true });
57 }
58 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs