Skip to content

g1t/apps/web/app/lib/branches.server.ts

122 lines6,007 bytesCodeBlame
1/**
2 * A repository's branches as its pages show them: each one's head commit,
3 * how far it has moved from the default branch, the pull request open on
4 * it with its checks, and its preview. The overview shows the newest few;
5 * the Branches page shows them all.
6 */
7import type { Branch, Commit, Pull, RepoPath, Viewer } from "@g1t/contracts";
8
9import type { ActiveBranch } from "../components/branches";
10import { bounded, drift } from "./branches";
11import { immutable } from "./immutable.server";
12import { repos } from "./services.server";
13
14/**
15 * How deep each history is read, in turn, to find where a branch and the
16 * default branch meet: most branches meet it within the first; a branch
17 * left long ago needs the default branch's history further back; one far
18 * from both reads both deeply. Past the last, the counts are not shown.
19 */
20const DEPTHS: ReadonlyArray<{ branch: number; main: number }> = [
21 { branch: 40, main: 120 },
22 { branch: 40, main: 1000 },
23 { branch: 1000, main: 1000 },
24];
25/** Branches counted at once. */
26const COUNTING = 8;
27
28type Preview = { branch?: string | null; number?: number | null; url: string };
29/** What never changes for a branch head and a default branch head. */
30type Measured = { commit: ActiveBranch["commit"]; drift: ActiveBranch["drift"] };
31
32const summary = (commit: Commit | undefined): ActiveBranch["commit"] =>
33 commit ? { hash: commit.hash, message: commit.message.split("\n")[0] ?? "", author: commit.author.name, at: commit.authoredAt } : null;
34
35/**
36 * The branches other than the default, at most `read` of them read (those
37 * with an open pull request first), newest commit first. `main` is the
38 * default branch's head commit with its first line.
39 */
40export async function readBranches(
41 path: RepoPath,
42 viewer: Viewer,
43 input: { defaultBranch: string; branches: Branch[]; pulls: Pull[]; previews: Preview[] },
44 read: number,
45): Promise<{ main: string; total: number; shown: ActiveBranch[]; head: ActiveBranch["commit"] }> {
46 const soft = <T,>(promise: Promise<T>): Promise<T | null> => promise.catch(() => null);
47 const main = input.defaultBranch;
48 const pullOn = new Map(input.pulls.filter((pull) => pull.branch).map((pull) => [pull.branch as string, pull]));
49 const others = input.branches.filter((branch) => branch.name !== main);
50 const reading = [...others.filter((b) => pullOn.has(b.name)), ...others.filter((b) => !pullOn.has(b.name))].slice(0, read);
51 // By commit hash, not name: history from a commit never changes, so
52 // repos keeps it (services/repos/src/store.rs) and only new heads cost a walk.
53 const mainHead = input.branches.find((branch) => branch.name === main)?.hash ?? null;
54 const log = async (hash: string, depth: number): Promise<Commit[] | null> => {
55 const found = await soft(repos.log(path, viewer, hash, depth));
56 return found?.ok && found.value.length > 0 ? found.value : null;
57 };
58 // The default branch's history, read once per depth for every branch,
59 // and not read deeper when a shallower read already reached its start.
60 const mainLogs = new Map<number, Promise<Commit[] | null>>();
61 const mainLog = (depth: number): Promise<Commit[] | null> => {
62 if (!mainHead) return Promise.resolve(null);
63 let kept = mainLogs.get(depth);
64 if (!kept) {
65 const shallower = Math.max(0, ...[...mainLogs.keys()].filter((read) => read < depth));
66 const before = mainLogs.get(shallower);
67 kept = before
68 ? before.then((read) => (read && read.length < shallower ? read : log(mainHead, depth)))
69 : log(mainHead, depth);
70 mainLogs.set(depth, kept);
71 }
72 return kept;
73 };
74 // Reads deeper only while the two histories have not met. Null when a
75 // read failed, so a failure is not kept as the answer.
76 const measure = async (head: string, main: string): Promise<Measured | null> => {
77 let branch: Commit[] | null = null;
78 let readTo = 0;
79 for (const depth of DEPTHS) {
80 // Not read again when no deeper, or when it already reached the start.
81 const again: boolean = branch == null || (depth.branch > readTo && branch.length >= readTo);
82 const [read, mainRead]: [Commit[] | null, Commit[] | null] = await Promise.all([
83 again ? log(head, depth.branch) : branch,
84 mainLog(depth.main),
85 ]);
86 if (!read || !mainRead) return null;
87 if (again) readTo = depth.branch;
88 branch = read;
89 const counted = drift(head, main, [...mainRead, ...read]);
90 if (counted) return { commit: summary(read[0]), drift: counted };
91 }
92 return { commit: summary(branch?.[0]), drift: null };
93 };
94 // Kept by the pair of heads: neither history can change, so neither can
95 // the answer. Without one, the head commit alone.
96 const measureOrHead = async (branch: Branch): Promise<Measured> => {
97 const kept =
98 branch.hash && mainHead
99 ? await immutable(`branch-drift:${path.namespace}/${path.name}:${mainHead}:${branch.hash}`, () => measure(branch.hash, mainHead))
100 : null;
101 if (kept) return kept;
102 const head = await log(branch.hash || branch.name, 1);
103 return { commit: summary(head?.[0]), drift: null };
104 };
105 const [mainTop, measured] = await Promise.all([
106 mainHead ? log(mainHead, 1) : Promise.resolve(null),
107 bounded(reading, COUNTING, measureOrHead),
108 ]);
109 const shown = reading
110 .map((branch, index): ActiveBranch => {
111 const pull = pullOn.get(branch.name);
112 return {
113 name: branch.name,
114 commit: measured[index]?.commit ?? null,
115 drift: measured[index]?.drift ?? null,
116 pull: pull ? { number: pull.number, title: pull.title, checkStatus: pull.checkStatus, draft: pull.status === "draft" } : null,
117 preview: input.previews.find((app) => app.branch === branch.name || (pull != null && app.number === pull.number))?.url ?? null,
118 };
119 })
120 .sort((a, b) => Date.parse(b.commit?.at ?? "0") - Date.parse(a.commit?.at ?? "0"));
121 return { main, total: others.length, shown, head: summary(mainTop?.[0]) };
122}