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

A repository's branches page answers under 50 ms with 200 branches #5666

Merged opened this

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

Merged

scout/forge:scout/3376-a-repository-s-branches-page-answers-und → main 8 commits fork 3 threads · 3 unresolved

  • Merged. scout/3376-a-repository-s-branches-page-answers-und was merged into main as b06f15d.

Review

Sign in to review this change.
4 files changed +328 −46
src/git/branches.ts +90 −3
@@ -14,10 +14,10 @@
1414 */
1515
1616 import { err, ok, type Result } from "../kernel/result.ts";
17 import { runGit, runGitCapture, type GitFailure } from "./env.ts";
17 import { runGit, runGitCapture, runGitRaw, type GitFailure } from "./env.ts";
1818 import { isSafeOid, isSafeRef } from "./guard.ts";
1919 import { resolveOid } from "./merge.ts";
20 import { listRefs } from "./read.ts";
20 import { LOG_FORMAT, listRefs, parseLogRecords, type Commit } from "./read.ts";
2121
2222 /**
2323 * A branch list stops comparing here. Each comparison is one `rev-list`
@@ -142,12 +142,14 @@ export interface BranchListing {
142142
143143 /**
144144 * Every branch, with its divergence from the default. One `for-each-ref`
145 * for the names and tips, then one `rev-list --left-right --count` per
145 * for the names and tips, then one `for-each-ref %(ahead-behind:)` for
146 * the counts, or, with `bulk: false` or an older git, one `rev-list` per
146147 * branch (bounded, and capped by `MAX_BRANCH_COMPARE`).
147148 */
148149 export async function listBranches(
149150 dir: string,
150151 defaultBranch: string,
152 opts: { readonly bulk?: boolean } = {},
151153 ): Promise<Result<BranchListing, GitFailure>> {
152154 const refs = await listRefs(dir, "refs/heads/");
153155 if (!refs.ok) return refs;
@@ -176,6 +178,18 @@ export async function listBranches(
176178 });
177179 }
178180
181 const counted =
182 opts.bulk === false ? undefined : await aheadBehindAll(dir, compare);
183 if (counted !== undefined) {
184 return ok({
185 branches: rows.map((r) => ({
186 ...r,
187 ...(counted.get(r.name) ?? { ahead: null, behind: null }),
188 })),
189 compared: true,
190 });
191 }
192
179193 const branches: BranchInfo[] = [];
180194 for (let i = 0; i < rows.length; i += COMPARE_CONCURRENCY) {
181195 const slice = rows.slice(i, i + COMPARE_CONCURRENCY);
@@ -192,6 +206,79 @@ export async function listBranches(
192206 return ok({ branches, compared: true });
193207 }
194208
209 /**
210 * Every branch's divergence from `base` in one `for-each-ref`, which asks
211 * git's own `%(ahead-behind:)` (2.41+). Undefined when this git lacks it,
212 * and the caller falls back to one `rev-list` per branch.
213 */
214 async function aheadBehindAll(
215 dir: string,
216 base: string,
217 ): Promise<Map<string, { ahead: number; behind: number }> | undefined> {
218 if (!isSafeOid(base)) return undefined;
219 const listed = await runGit(
220 [
221 "for-each-ref",
222 `--format=%(refname)%00%(ahead-behind:${base})`,
223 "--",
224 "refs/heads/",
225 ],
226 { cwd: dir },
227 );
228 return listed.ok ? parseAheadBehind(listed.value) : undefined;
229 }
230
231 /**
232 * The `refname NUL ahead SP behind` lines of `aheadBehindAll`. A malformed
233 * line gives up on the whole list (undefined), so the caller takes the slow
234 * per-branch path rather than show a wrong count.
235 */
236 export function parseAheadBehind(
237 text: string,
238 ): Map<string, { ahead: number; behind: number }> | undefined {
outdated

The malformed-line return undefined has no test. Cover it so the fallback is proven, not assumed.

239 const out = new Map<string, { ahead: number; behind: number }>();
240 if (text === "") return out;
241 for (const line of text.split("\n")) {
242 const [ref, pair] = line.split("\0");
243 // `%(ahead-behind:B)` prints "<ahead> <behind>" of the ref against B.
244 const [ahead, behind] = (pair ?? "").split(" ").map(Number);
245 if (!ref || !Number.isSafeInteger(ahead) || !Number.isSafeInteger(behind))
246 return undefined;
247 out.set(ref.slice("refs/heads/".length), {
248 ahead: ahead as number,
249 behind: behind as number,
250 });
251 }
252 return out;
253 }
254
255 /**
256 * The tip commit of each oid, in one `git log --no-walk`. Oids come from
257 * git itself; anything unsafe is skipped, never passed on.
258 */
259 export async function tipCommits(
260 dir: string,
261 oids: readonly string[],
262 ): Promise<Map<string, Commit>> {
263 const safe = [...new Set(oids)].filter(isSafeOid);
264 const out = new Map<string, Commit>();
265 if (safe.length === 0) return out;
266 const result = await runGitRaw(
267 [
268 "log",
269 "--no-walk=unsorted",
270 "-z",
271 `--format=${LOG_FORMAT}`,
272 ...safe,
273 "--",
274 ],
275 { cwd: dir },
276 );
277 if (!result.ok) return out;
278 for (const commit of parseLogRecords(result.value)) out.set(commit.oid, commit);
279 return out;
280 }
outdated

The 7-field stride parse is untested; add a test comparing against commitLog for body, parents and date.

281
195282 /**
196283 * How far `head` has drifted from `base`. `A...B` counts each side of the
197284 * merge base: the left number is what B is missing (behind), the right is
src/git/read.ts +28 −23
@@ -503,30 +503,13 @@ export async function committedAt(dir: string, oid: string): Promise<string | un
503503 return Number.isNaN(when.getTime()) ? undefined : when.toISOString();
504504 }
505505
506 export async function commitLog(
507 dir: string,
508 rev: string,
509 options: LogOptions = {},
510 ): Promise<Result<Commit[], GitFailure>> {
511 assertBatchSafe(rev);
512 const args = [
513 "log",
514 "-z",
515 "--format=%H%x00%P%x00%an%x00%ae%x00%aI%x00%s%x00%b",
516 `--max-count=${options.limit ?? 50}`,
517 `--skip=${options.skip ?? 0}`,
518 rev,
519 "--",
520 ];
521 if (options.path !== undefined) args.push(options.path);
522 const result = await runGitRaw(args, { cwd: dir });
523 if (!result.ok) return result;
506 /** The `git log -z` format `parseLogRecords` reads: seven NUL-separated fields. */
507 export const LOG_FORMAT = "%H%x00%P%x00%an%x00%ae%x00%aI%x00%s%x00%b";
524508
525 const text = new TextDecoder().decode(result.value);
509 /** Records of `git log -z --format=LOG_FORMAT`: groups of 7 NUL-separated fields. */
510 export function parseLogRecords(bytes: Uint8Array): Commit[] {
526511 const commits: Commit[] = [];
527 // -z terminates each record with NUL; fields inside are NUL-separated too,
528 // so records are groups of 7 fields.
529 const fields = text.split("\0");
512 const fields = new TextDecoder().decode(bytes).split("\0");
530513 for (let i = 0; i + 6 < fields.length; i += 7) {
531514 commits.push({
532515 oid: fields[i]!,
@@ -538,7 +521,29 @@ export async function commitLog(
538521 body: fields[i + 6]!.replace(/\n$/, ""),
539522 });
540523 }
541 return ok(commits);
524 return commits;
525 }
526
527 export async function commitLog(
528 dir: string,
529 rev: string,
530 options: LogOptions = {},
531 ): Promise<Result<Commit[], GitFailure>> {
532 assertBatchSafe(rev);
533 const args = [
534 "log",
535 "-z",
536 `--format=${LOG_FORMAT}`,
537 `--max-count=${options.limit ?? 50}`,
538 `--skip=${options.skip ?? 0}`,
539 rev,
540 "--",
541 ];
542 if (options.path !== undefined) args.push(options.path);
543 const result = await runGitRaw(args, { cwd: dir });
544 if (!result.ok) return result;
545
546 return ok(parseLogRecords(result.value));
542547 }
543548
544549 export interface PathListing {
src/web/pages/branches.ts +14 −20
@@ -18,8 +18,8 @@
1818 import { createBranch, deleteBranch } from "../../actions/branches.ts";
1919 import type { Repo } from "../../core/repos.ts";
2020 import { notePhase, takeSessionTime, timePhaseAsync } from "../../core/route_timing.ts";
21 import { listBranches, type BranchInfo } from "../../git/branches.ts";
22 import { commitLog, type Commit } from "../../git/read.ts";
21 import { listBranches, tipCommits, type BranchInfo } from "../../git/branches.ts";
22 import type { Commit } from "../../git/read.ts";
2323 import { repoDir } from "../../git/repos.ts";
2424 import type { Ctx } from "../../http/context.ts";
2525 import type { RepoScope } from "../scope.ts";
@@ -51,7 +51,6 @@ import { refSetFrom } from "./refs.ts";
5151 * cap, so a repository with a thousand branches still renders one page.
5252 */
5353 const TIP_COMMITS = 40;
54 const TIP_CONCURRENCY = 8;
5554
5655 export interface BranchRow {
5756 readonly name: string;
@@ -63,26 +62,19 @@ export interface BranchRow {
6362 readonly tip: Commit | undefined;
6463 }
6564
66 /** The tip commit of the first `TIP_COMMITS` rows, a few at a time. */
65 /** The tip commit of the first `TIP_COMMITS` rows, from one git call. */
6766 async function withTips(
6867 dir: string,
6968 rows: readonly Omit<BranchRow, "tip">[],
7069 ): Promise<BranchRow[]> {
71 const out: BranchRow[] = rows.map((r) => ({ ...r, tip: undefined }));
72 const wanted = out.slice(0, TIP_COMMITS);
73 for (let i = 0; i < wanted.length; i += TIP_CONCURRENCY) {
74 const slice = wanted.slice(i, i + TIP_CONCURRENCY);
75 const logs = await Promise.all(
76 slice.map((row) => commitLog(dir, row.oid, { limit: 1 })),
77 );
78 slice.forEach((row, j) => {
79 const log = logs[j];
80 const at = out.indexOf(row);
81 if (log?.ok === true && log.value[0] !== undefined && at !== -1)
82 out[at] = { ...row, tip: log.value[0] };
83 });
84 }
85 return out;
70 const tips = await tipCommits(
71 dir,
72 rows.slice(0, TIP_COMMITS).map((r) => r.oid),
73 );
74 return rows.map((r, i) => ({
75 ...r,
76 tip: i < TIP_COMMITS ? tips.get(r.oid) : undefined,
77 }));
8678 }
8779
8880 /** Every branch this scope can see, however it has to ask. */
@@ -112,7 +104,9 @@ async function branchRows(
112104 return { rows, compared: false };
113105 }
114106 const dir = repoDir(ctx.app.reposRoot, repo.ownerName, repo.name);
115 const listed = await listBranches(dir, repo.defaultBranch);
107 const listed = await timePhaseAsync(ctx.request, "compare", () =>
108 listBranches(dir, repo.defaultBranch),
109 );
116110 if (!listed.ok) return { rows: [], compared: false };
117111 const bare = listed.value.branches.map((b: BranchInfo) => ({
118112 name: b.name,
test/branches-many.test.ts added +196
@@ -0,0 +1,196 @@
1 /**
2 * A repository with 200 branches lists them in under 50 ms: ahead/behind and
3 * tips are one git call each, not one per branch.
4 */
5 import { afterAll, beforeAll, expect, test } from "bun:test";
6 import { mkdtemp, rm } from "node:fs/promises";
7 import { tmpdir } from "node:os";
8 import { join } from "node:path";
9 import { createApp, type App } from "../src/app.ts";
10 import { serve, type Servers } from "../src/http/server.ts";
11 import { divergence, listBranches, parseAheadBehind, tipCommits } from "../src/git/branches.ts";
12 import { commitLog } from "../src/git/read.ts";
13 import { repoDir } from "../src/git/repos.ts";
14 import { gitClientEnv } from "./helpers/git-client.ts";
15 import { seedAdmin } from "./helpers/admin.ts";
16 import { baselineMs, expectBudget } from "./helpers/budget.ts";
17
18 const env = gitClientEnv({
19 GIT_AUTHOR_NAME: "A",
20 GIT_AUTHOR_EMAIL: "[email protected]",
21 GIT_COMMITTER_NAME: "A",
22 GIT_COMMITTER_EMAIL: "[email protected]",
23 });
24
25 async function git(args: string[], cwd: string): Promise<void> {
26 const p = Bun.spawn(["git", ...args], { cwd, env, stdout: "ignore", stderr: "pipe" });
27 const [err, code] = await Promise.all([new Response(p.stderr).text(), p.exited]);
28 if (code !== 0) throw new Error(`git ${args.join(" ")}: ${err}`);
29 }
30
31 let dataDir = "", work = "";
32 let app: App, servers: Servers, bearer = "";
33
34 beforeAll(async () => {
35 dataDir = await mkdtemp(join(tmpdir(), "forge-bbr-"));
36 work = await mkdtemp(join(tmpdir(), "forge-bbrwork-"));
37 app = createApp({ dataDir, port: 0 });
38 servers = await serve(app);
39 const base = servers.url;
40 await seedAdmin(app, { username: "alice", email: "[email protected]", password: "correct-horse-battery" });
41 const minted = await fetch(`${base}/api/v1/tokens`, {
42 method: "POST",
43 headers: { "content-type": "application/json" },
44 body: JSON.stringify({ adminConfirm: true, username: "alice", password: "correct-horse-battery", name: "cli", scope: "admin" }),
45 });
46 const { token } = (await minted.json()) as { token: string };
47 bearer = token;
48 await fetch(`${base}/api/v1/repos`, {
49 method: "POST",
50 headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
51 body: JSON.stringify({ name: "app", description: "x" }),
52 });
53 await git(["clone", `${base.replace("http://", `http://alice:${token}@`)}/alice/app.git`, "app"], work);
54 const dir = join(work, "app");
55 await Bun.write(join(dir, "a.ts"), "export const a = 1;\n");
56 await Bun.write(join(dir, "README.md"), "# app\n");
57 await git(["add", "."], dir);
58 await git(["commit", "-m", "one"], dir);
59 await git(["push", "origin", "main"], dir);
60 // 199 branches (200 with main), each one commit off main; main then moves on, so every
61 // branch is also one behind.
62 let stream = "";
63 for (let i = 0; i < 199; i++) {
64 const name = `feat/b${String(i).padStart(3, "0")}`;
65 stream += `commit refs/heads/${name}\ncommitter A <[email protected]> ${1700000000 + i} +0000\ndata 4\nb${String(i).padStart(2, "0")}\nfrom refs/heads/main\n\n`;
66 }
67 const fi = Bun.spawn(["git", "fast-import", "--quiet"], { cwd: dir, env, stdin: new TextEncoder().encode(stream), stderr: "pipe" });
68 if ((await fi.exited) !== 0) throw new Error(await new Response(fi.stderr).text());
69 await Bun.write(join(dir, "b.ts"), "export const b = 2;\n");
70 await git(["add", "."], dir);
71 await git(["commit", "-m", "two"], dir);
72 await git(["push", "origin", "--all"], dir);
73 }, 60_000);
74
75 afterAll(async () => {
76 servers.stop();
77 app.close();
78 await rm(dataDir, { recursive: true, force: true });
79 await rm(work, { recursive: true, force: true });
80 });
81
82 const get = async (path: string): Promise<{ text: string; timing: string }> => {
83 const res = await fetch(`${servers.url}${path}`, { headers: { authorization: `Bearer ${bearer}` } });
84 const text = await res.text();
outdated

500 ms does not enforce the 50 ms the title promises; the next test guards the regression instead. Say so, or tighten the bound.

85 expect(res.status).toBe(200);
86 return { text, timing: res.headers.get("server-timing") ?? "" };
87 };
88
89 test("200 branches hold the 50 ms budget and name their phases", async () => {
90 await get("/alice/app/branches");
91 const { text, timing } = await get("/alice/app/branches");
92 const dur = (name: string) => Number(new RegExp(`(?:^|, )${name};dur=([0-9.]+)`).exec(timing)?.[1]);
93 // 50 ms absolute on a quiet machine (FORGE_TEST_QUIET=1); under load it is judged
94 // against a /healthz baseline from the same run, capped at 4x the budget.
95 expectBudget(dur("app"), { ms: 50, against: await baselineMs(servers.url) });
96 for (const phase of ["session", "refs", "compare", "render"]) expect(dur(phase)).toBeGreaterThanOrEqual(0);
97 expect(text).toContain("200 branches");
98 expect(text).toContain("feat/b198");
99 // The tip line comes from tipCommits: a shifted field would drop or garble it.
100 expect(text).toMatch(/· b\d+ · updated <time datetime="2023-11-14T[\s\S]{0,200}?by A</);
101 }, 30_000);
102
103 test("the one-call counts equal the per-branch rev-list counts", async () => {
104 const dir = repoDir(app.reposRoot, "alice", "app");
105 const listed = await listBranches(dir, "main");
106 if (!listed.ok) throw new Error("listBranches failed");
107 expect(listed.value.compared).toBe(true);
108 const main = listed.value.branches.find((b) => b.isDefault)!;
109 expect(listed.value.branches.length).toBe(200);
110 expect(main.name).toBe("main");
111 for (const b of listed.value.branches) {
112 const want = await divergence(dir, main.oid, b.oid);
113 expect({ name: b.name, ahead: b.ahead, behind: b.behind }).toEqual({ name: b.name, ...want });
114 }
115 expect(listed.value.branches[1]).toMatchObject({ name: "feat/b000", ahead: 1, behind: 1 });
116 }, 60_000);
117
118 test("the per-branch fallback gives the same rows, and the one-call path is faster", async () => {
119 const dir = repoDir(app.reposRoot, "alice", "app");
120 // Best of three each, so one stalled run on a loaded machine cannot flip it.
121 const best = async (opts: { bulk?: boolean }) => {
122 let fastest = Infinity, last = await listBranches(dir, "main", opts);
123 for (let i = 0; i < 3; i++) {
124 const t = performance.now();
125 last = await listBranches(dir, "main", opts);
126 fastest = Math.min(fastest, performance.now() - t);
127 }
128 return { last, fastest };
129 };
130 const { last: bulk, fastest: bulkMs } = await best({});
131 const { last: slow, fastest: slowMs } = await best({ bulk: false });
132 if (!bulk.ok || !slow.ok) throw new Error("listBranches failed");
133 expect(slow.value).toEqual(bulk.value);
134 expect(bulkMs).toBeLessThan(slowMs);
135 }, 60_000);
136
137 test("a malformed or unsupported ahead-behind output gives up so the slow path runs", () => {
138 expect(parseAheadBehind("refs/heads/a\u00001 2\nrefs/heads/b\u00000 0")).toEqual(
139 new Map([["a", { ahead: 1, behind: 2 }], ["b", { ahead: 0, behind: 0 }]]),
140 );
141 expect(parseAheadBehind("")).toEqual(new Map());
142 // An older git prints the format atom verbatim or empty; a torn line has no pair.
143 expect(parseAheadBehind("refs/heads/a\u0000")).toBeUndefined();
144 expect(parseAheadBehind("refs/heads/a\u0000%(ahead-behind:abc)")).toBeUndefined();
145 expect(parseAheadBehind("refs/heads/a\u00001 2\nrefs/heads/b")).toBeUndefined();
146 expect(parseAheadBehind("refs/heads/a\u00001 x")).toBeUndefined();
147 expect(parseAheadBehind("\u00001 2")).toBeUndefined();
148 });
149
150 async function head(cwd: string, rev = "HEAD"): Promise<string> {
151 const p = Bun.spawn(["git", "rev-parse", rev], { cwd, env, stdout: "pipe", stderr: "ignore" });
152 return (await new Response(p.stdout).text()).trim();
153 }
154
155 test("tipCommits reads every field commitLog does: body, parents, author, date", async () => {
156 const dir = join(work, "tips");
157 await git(["init", "-q", "-b", "main", dir], work);
158 const commit = (subject: string, body: string, author: string) => {
159 env.GIT_AUTHOR_NAME = author;
160 return git(
161 ["commit", "--allow-empty", "-m", subject, ...(body ? ["-m", body] : [])],
162 dir,
163 );
164 };
165 const dated = (date: string) => Object.assign(env, { GIT_AUTHOR_DATE: date, GIT_COMMITTER_DATE: date });
166 dated("2024-01-02T03:04:05+02:00");
167 await commit("root", "", "Root");
168 dated("2024-03-04T05:06:07+00:00");
169 await git(["switch", "-q", "-c", "side"], dir);
170 await commit("side work", "first line\n\nsecond paragraph\nthird line", "Side");
171 const sideTip = await head(dir);
172 await git(["switch", "-q", "main"], dir);
173 dated("2024-05-06T07:08:09-05:00");
174 await commit("main work", "", "Main");
175 dated("2024-06-07T08:09:10+00:00");
176 await git(["merge", "--no-ff", "-q", "-m", "merge side", "-m", "joins two histories", "side"], dir);
177 const mergeTip = await head(dir);
178 const rootTip = await head(dir, "HEAD~2");
179 const oids = [rootTip, sideTip, await head(dir, "HEAD^"), mergeTip];
180
181 const tips = await tipCommits(dir, oids);
182 expect(tips.size).toBe(4);
183 for (const oid of oids) {
184 const want = await commitLog(dir, oid, { limit: 1 });
185 if (!want.ok) throw new Error("commitLog failed");
186 expect(tips.get(oid)).toEqual(want.value[0]!);
187 }
188 expect(tips.get(mergeTip)!.parents).toHaveLength(2);
189 expect(tips.get(mergeTip)!.body).toBe("joins two histories");
190 expect(tips.get(sideTip)!.body).toBe("first line\n\nsecond paragraph\nthird line");
191 expect(tips.get(sideTip)!.author).toBe("Side");
192 expect(tips.get(sideTip)!.date).toBe("2024-03-04T05:06:07Z");
193 expect(tips.get(rootTip)!.parents).toEqual([]);
194 // An unsafe oid is dropped, never handed to git.
195 expect((await tipCommits(dir, ["--output=x", mergeTip])).size).toBe(1);
196 }, 60_000);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs