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

A repository's forks page draws the tree: each fork's own forks beneath it, two levels deep #6296

Merged opened this

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

Merged

scout/forge:scout/6250-a-repository-s-forks-page-draws-the-tree → main 4 commits fork 1 thread · 1 unresolved

  • Merged. scout/6250-a-repository-s-forks-page-draws-the-tree was merged into main as 9b79df9.

Review

Sign in to review this change.
6 files changed +214 −15
README.md +5
@@ -2006,6 +2006,11 @@ rises. Order: commits ahead of the parent's default branch (most first),
20062006 then fewest behind, then the newest tip commit. Each row reads `N ahead · M
20072007 behind` and when the fork last moved; a fork that is ahead has a **Compare**
20082008 button (on the page) to its `/pulls/new`, which proposes it to the parent.
2009 Each fork's own forks are listed indented beneath it (one more level, on the
2010 page and in the terminal alike); the forks below those are a count, `and N more
2011 forks below`, linking on the page to that fork's forks page. A private fork is
2012 never named or counted to someone who cannot read it.
2013
20092014 Forks with nothing ahead (or whose standing git cannot read) follow under
20102015 `N forks with nothing new`. At most 60 forks are ranked per view; past that
20112016 the page says `Ranking the first 60 of N forks`. A fork's standing is cached
src/core/repos.ts +60
@@ -626,6 +626,66 @@ export function visibleForks(
626626 return listForks(db, repoId).filter((fork) => canRead(db, fork, viewer));
627627 }
628628
629 /** A fork the viewer may see, the visible forks beneath it, and how many lie one level further. */
630 export interface ForkNode {
631 readonly repo: Repo;
632 readonly forks: readonly ForkNode[];
633 /** Visible forks of this one, counted but not listed; only the second level carries it. */
634 readonly deeper: number;
635 }
636
637 const forksOfMany = (db: Database, ids: readonly string[]): Repo[] =>
638 ids.length === 0
639 ? []
640 : db
641 .query<RepoRow, string[]>(
642 `${SELECT} WHERE repo.fork_parent_id IN (${ids.map(() => "?").join(",")}) ORDER BY repo.id`,
643 )
644 .all(...ids)
645 .map(toRepo);
646
647 /** The visible forks of these parents, grouped by parent id: one query. */
648 function visibleForksByParent(
649 db: Database,
650 parents: readonly Repo[],
651 viewer: User | undefined,
652 ): Map<string, Repo[]> {
653 const byParent = new Map<string, Repo[]>();
654 for (const fork of forksOfMany(db, parents.map((p) => p.id))) {
655 if (fork.forkParentId === null || !canRead(db, fork, viewer)) continue;
656 const siblings = byParent.get(fork.forkParentId) ?? [];
657 siblings.push(fork);
658 byParent.set(fork.forkParentId, siblings);
659 }
660 return byParent;
661 }
662
663 /**
664 * The visible forks of a repository, each with its own visible forks, and
665 * for those a count of the visible forks one level further: three queries
666 * at most, one per level. A fork the viewer cannot read hides itself and
667 * everything under it.
668 */
669 export function forkTree(
670 db: Database,
671 repoId: string,
672 viewer: User | undefined,
673 ): ForkNode[] {

Low (cost on a large family): forkTree walks every visible fork and its children. The page only draws children for the ≤60 ranked forks, and it already called visibleForks once above, so the same canRead pass runs twice. On a repository with thousands of forks, the second level's IN (?,…) list also grows with the fork count. Fix in a follow-up: pass in the ranked forks (already capped at MAX_RANKED_FORKS) as the first level instead of calling visibleForks again. That bounds the bound-parameter count and halves the canRead work.

674 const first = visibleForks(db, repoId, viewer);
675 const secondBy = visibleForksByParent(db, first, viewer);
676 const second = [...secondBy.values()].flat();
677 const thirdBy = visibleForksByParent(db, second, viewer);
678 return first.map((repo) => ({
679 repo,
680 deeper: 0,
681 forks: (secondBy.get(repo.id) ?? []).map((child) => ({
682 repo: child,
683 forks: [],
684 deeper: thirdBy.get(child.id)?.length ?? 0,
685 })),
686 }));
687 }
688
629689 export function getRepo(
630690 db: Database,
631691 ownerName: string,
src/tui/screens/forks.ts +25 −11
@@ -12,7 +12,7 @@ import {
1212 MAX_RANKED_FORKS,
1313 type ForkRanking,
1414 } from "../../actions/forks.ts";
15 import { canRead, listForks, type Repo } from "../../core/repos.ts";
15 import { canRead, forkTree, listForks, type ForkNode, type Repo } from "../../core/repos.ts";
1616 import type { Ctx, Screen } from "../app/app.ts";
1717 import { binding } from "../app/keys.ts";
1818 import { style } from "../term/style.ts";
@@ -25,29 +25,43 @@ import { repoScreen } from "./workspace.ts";
2525 import { ListState, drawList, plural, type ListRow } from "./common.ts";
2626
2727 /** The rows the screen draws, in order: pure so a test reads them without a terminal. */
28 export function forkRows(ranked: ForkRanking): { repo: Repo; name: string; words: string; heading: string }[] {
29 const row = (f: ForkRanking["offering"][number], heading: string) => ({
30 repo: f.fork,
31 name: `${f.fork.ownerName}/${f.fork.name}`,
32 words: driftWords(f.drift),
33 heading,
34 });
28 export function forkRows(
29 ranked: ForkRanking,
30 tree: readonly ForkNode[] = [],
31 ): { repo: Repo; name: string; words: string; heading: string }[] {
32 const below = new Map(tree.map((n) => [n.repo.id, n.forks]));
33 const row = (f: ForkRanking["offering"][number], heading: string) => [
34 {
35 repo: f.fork,
36 name: `${f.fork.ownerName}/${f.fork.name}`,
37 words: driftWords(f.drift),
38 heading,
39 },
40 ...(below.get(f.fork.id) ?? []).map((n) => ({
41 repo: n.repo,
42 name: ` ${n.repo.ownerName}/${n.repo.name}`,
43 words: n.deeper === 0 ? "" : `and ${n.deeper} more fork${n.deeper === 1 ? "" : "s"} below`,
44 heading: "",
45 })),
46 ];
3547 return [
36 ...ranked.offering.map((f) => row(f, "")),
37 ...ranked.nothingNew.map((f, i) => row(f, i === 0 ? nothingNewHeading(ranked.nothingNew.length) : "")),
48 ...ranked.offering.flatMap((f) => row(f, "")),
49 ...ranked.nothingNew.flatMap((f, i) => row(f, i === 0 ? nothingNewHeading(ranked.nothingNew.length) : "")),
3850 ];
3951 }
4052
4153 export function forksScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
4254 let ranked: ForkRanking | undefined;
55 let tree: ForkNode[] = [];
4356 const state = new ListState();
44 const rows = () => (ranked === undefined ? [] : forkRows(ranked));
57 const rows = () => (ranked === undefined ? [] : forkRows(ranked, tree));
4558 return {
4659 title: "Forks",
4760 keys: [binding(["j", "k"], "move"), binding(["enter"], "open this fork"), binding(["esc"], "close")],
4861 async load() {
4962 const forks = listForks(f.db, repo.id).filter((r) => canRead(f.db, r, f.user));
5063 ranked = await rankedForksOf(f.app, repo, forks);
64 tree = forkTree(f.db, repo.id, f.user);
5165 ctx.invalidate();
5266 },
5367 render(area, s) {
src/web/pages/repo.ts +25 −2
@@ -38,6 +38,8 @@ import { pinRepo, syncRepoNow, unpinRepo } from "../../actions/origins.ts";
3838 import {
3939 canAdmin,
4040 forkParentFor,
41 forkTree,
42 type ForkNode,
4143 visibleForks,
4244 } from "../../core/repos.ts";
4345 import { isSafeRef, isSafeTreePath } from "../../git/guard.ts";
@@ -131,6 +133,9 @@ export async function forksPage(ctx: Ctx): Promise<Response> {
131133 );
132134 notePhase(ctx.request, "forks", performance.now() - t);
133135 t = performance.now();
136 const tree = forkTree(ctx.app.db, repo.id, ctx.auth.user);
137 notePhase(ctx.request, "fork-tree", performance.now() - t);
138 t = performance.now();
134139 // The whole family, from the oldest ancestor this viewer may see: who
135140 // follows whom, and where each stands against the one it follows.
136141 const lineage = await lineageOf(ctx.app, repo, (r) =>
@@ -151,7 +156,7 @@ export async function forksPage(ctx: Ctx): Promise<Response> {
151156 title: "No forks yet",
152157 note: "A fork is a copy under another name that can propose changes back here, and follows this one until it has work of its own.",
153158 })
154 : html`${forkRanking(ranked)}
159 : html`${forkRanking(ranked, new Map(tree.map((n) => [n.repo.id, n])))}
155160 <h3 class="mt">The family, by who follows whom</h3>
156161 ${lineageCard(lineage, repo.id)}`}`;
157162 const res = page({
@@ -167,9 +172,27 @@ export async function forksPage(ctx: Ctx): Promise<Response> {
167172 * The forks with work to offer, best first, each linking to the compare
168173 * view that proposes it upstream; then those with nothing new, counted.
169174 */
170 function forkRanking(ranked: ForkRanking): Html {
175 function forkRanking(
176 ranked: ForkRanking,
177 tree: ReadonlyMap<string, ForkNode>,
178 ): Html {
179 const below = (n: ForkNode): Html =>
180 row({
181 lead: icon("fork"),
182 title: html`<a href="/${n.repo.ownerName}/${n.repo.name}"
183 >${n.repo.ownerName}/${n.repo.name}</a
184 >`,
185 ...(n.deeper === 0
186 ? {}
187 : {
188 meta: html`<a href="/${n.repo.ownerName}/${n.repo.name}/forks"
189 >and ${n.deeper} more fork${n.deeper === 1 ? "" : "s"} below</a
190 >`,
191 }),
192 });
171193 const line = (f: RankedFork, offer: boolean): Html =>
172194 row({
195 children: (tree.get(f.fork.id)?.forks ?? []).map(below),
173196 lead: icon("fork"),
174197 title: html`<a href="/${f.fork.ownerName}/${f.fork.name}"
175198 >${f.fork.ownerName}/${f.fork.name}</a
test/fork-tree.test.ts added +97
@@ -0,0 +1,97 @@
1 import { afterAll, beforeAll, describe, 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 { createRepo, forkRepo } from "../src/actions/repos.ts";
6 import { forkTree, getRepo } from "../src/core/repos.ts";
7 import { createApp, type App } from "../src/app.ts";
8 import { createUser, type User } from "../src/core/users.ts";
9 import { serve, type Servers } from "../src/http/server.ts";
10 import { createForgeApp } from "../src/tui/forge.ts";
11 import { forksScreen } from "../src/tui/screens/forks.ts";
12 import { headlessTerminal } from "../src/tui/term/terminal.ts";
13 import { settleApp } from "./helpers/tui.ts";
14 import { promote } from "./helpers/members.ts";
15
16 const must = <T>(r: { ok: true; value: T } | { ok: false; error: unknown }): T => {
17 if (!r.ok) throw new Error(JSON.stringify(r.error));
18 return r.value;
19 };
20
21 describe("the forks page draws the tree", () => {
22 let dataDir: string;
23 let app: App;
24 let servers: Servers;
25 const users: Record<string, User> = {};
26
27 beforeAll(async () => {
28 dataDir = await mkdtemp(join(tmpdir(), "forge-fork-tree-"));
29 app = createApp({ dataDir, port: 0 });
30 servers = await serve(app);
31 for (const name of ["alice", "bob", "carol", "dan", "erin", "fay"]) {
32 users[name] = must(
33 await createUser(app.db, {
34 origin: "seed",
35 trusted: true,
36 username: name,
37 email: `${name}@f.dev`,
38 password: "a-good-passphrase",
39 adminIfFirst: name === "alice",
40 }),
41 );
42 promote(app.db, users[name]!);
43 }
44 const upstream = must(await createRepo(app, users["alice"]!, { name: "app", visibility: "public" }));
45 const bob = must(await forkRepo(app, users["bob"]!, upstream));
46 must(await forkRepo(app, users["carol"]!, upstream));
47 const dan = must(await forkRepo(app, users["dan"]!, bob));
48 must(await forkRepo(app, users["erin"]!, dan));
49 must(await forkRepo(app, users["fay"]!, dan, "secret", { visibility: "private" }));
50 }, 60_000);
51
52 afterAll(async () => {
53 servers.stop();
54 app.close();
55 await rm(dataDir, { recursive: true, force: true });
56 });
57
58 test("a fork of a fork is nested under its parent; deeper forks are a count", async () => {
59 const text = await (await fetch(`${servers.url}/alice/app/forks`, { headers: { cookie: "forge_mode=dev" } })).text();
60 const at = (s: string) => text.indexOf(s);
61 for (const who of ["bob", "carol", "dan"]) expect(at(`href="/${who}/app"`)).toBeGreaterThan(-1);
62 const ul = at('<ul class="row-children">');
63 expect(ul).toBeGreaterThan(-1);
64 expect(at('href="/dan/app"')).toBeGreaterThan(ul);
65 expect(text).toContain('href="/dan/app/forks"');
66 expect(text).toContain("and 1 more fork below");
67 });
68
69 test("a private fork is never named or counted to someone who cannot read it", async () => {
70 const text = await (await fetch(`${servers.url}/alice/app/forks`, { headers: { cookie: "forge_mode=dev" } })).text();
71 expect(text).not.toContain("fay/secret");
72 expect(text).not.toContain("and 2 more");
73 const alice = getRepo(app.db, "alice", "app")!;
74 const dan = forkTree(app.db, alice.id, undefined).find((n) => n.repo.ownerName === "bob")!.forks[0]!;
75 expect(dan.deeper).toBe(1);
76 const seenByFay = forkTree(app.db, alice.id, users["fay"]!).find((n) => n.repo.ownerName === "bob")!.forks[0]!;
77 expect(seenByFay.deeper).toBe(2);
78 });
79
80 test("the terminal's forks screen indents the second level and counts what is below", async () => {
81 const terminal = headlessTerminal(120, 30);
82 const { app: tui, session } = createForgeApp(
83 app,
84 { sshUsername: "carol", remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user: users["carol"]! },
85 { terminal, exitHooks: false },
86 );
87 tui.start();
88 tui.ctx.push(forksScreen(session, tui.ctx, getRepo(app.db, "alice", "app")!));
89 await settleApp(tui);
90 const text = tui.screen.toText();
91 tui.stop?.();
92 expect(text).toMatch(/\n\S* ?bob\/app/);
93 expect(text).toMatch(/\n {4}dan\/app/);
94 expect(text).toContain("and 1 more fork below");
95 expect(text).not.toContain("fay/secret");
96 });
97 });
test/forks-timing.test.ts +2 −2
@@ -36,11 +36,11 @@ afterAll(async () => {
3636 const names = (res: Response): string[] =>
3737 (res.headers.get("server-timing") ?? "").split(", ").map((p) => p.split(";")[0]!).filter((n) => n !== "app");
3838
39 test("the forks page names session, repository, forks, lineage, drift and render, in order", async () => {
39 test("the forks page names session, repository, forks, fork-tree, lineage, drift and render, in order", async () => {
4040 const res = await fetch(`${servers.url}/alice/notes/forks`, { headers: { authorization: `Bearer ${bearer}` } });
4141 expect(res.status).toBe(200);
4242 await res.text();
43 expect(names(res)).toEqual(["session", "repository", "forks", "lineage", "drift", "render"]);
43 expect(names(res)).toEqual(["session", "repository", "forks", "fork-tree", "lineage", "drift", "render"]);
4444 });
4545
4646 test("a missing repository still names session and repository", async () => {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs