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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6250-a-repository-s-forks-page-draws-the-tree → main 4 commits fork 1 thread · 1 unresolved
6 files
changed +214
−15
README.md +5
| @@ -2006,6 +2006,11 @@ rises. Order: commits ahead of the parent's default branch (most first), | ||
| 2006 | 2006 | then fewest behind, then the newest tip commit. Each row reads `N ahead · M |
| 2007 | 2007 | behind` and when the fork last moved; a fork that is ahead has a **Compare** |
| 2008 | 2008 | 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 | ||
| 2009 | 2014 | Forks with nothing ahead (or whose standing git cannot read) follow under |
| 2010 | 2015 | `N forks with nothing new`. At most 60 forks are ranked per view; past that |
| 2011 | 2016 | 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( | ||
| 626 | 626 | return listForks(db, repoId).filter((fork) => canRead(db, fork, viewer)); |
| 627 | 627 | } |
| 628 | 628 | |
| 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[] { | |
| 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 | ||
| 629 | 689 | export function getRepo( |
| 630 | 690 | db: Database, |
| 631 | 691 | ownerName: string, |
src/tui/screens/forks.ts +25 −11
| @@ -12,7 +12,7 @@ import { | ||
| 12 | 12 | MAX_RANKED_FORKS, |
| 13 | 13 | type ForkRanking, |
| 14 | 14 | } 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"; | |
| 16 | 16 | import type { Ctx, Screen } from "../app/app.ts"; |
| 17 | 17 | import { binding } from "../app/keys.ts"; |
| 18 | 18 | import { style } from "../term/style.ts"; |
| @@ -25,29 +25,43 @@ import { repoScreen } from "./workspace.ts"; | ||
| 25 | 25 | import { ListState, drawList, plural, type ListRow } from "./common.ts"; |
| 26 | 26 | |
| 27 | 27 | /** 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 | ]; | |
| 35 | 47 | 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) : "")), | |
| 38 | 50 | ]; |
| 39 | 51 | } |
| 40 | 52 | |
| 41 | 53 | export function forksScreen(f: Session, ctx: Ctx, repo: Repo): Screen { |
| 42 | 54 | let ranked: ForkRanking | undefined; |
| 55 | let tree: ForkNode[] = []; | |
| 43 | 56 | const state = new ListState(); |
| 44 | const rows = () => (ranked === undefined ? [] : forkRows(ranked)); | |
| 57 | const rows = () => (ranked === undefined ? [] : forkRows(ranked, tree)); | |
| 45 | 58 | return { |
| 46 | 59 | title: "Forks", |
| 47 | 60 | keys: [binding(["j", "k"], "move"), binding(["enter"], "open this fork"), binding(["esc"], "close")], |
| 48 | 61 | async load() { |
| 49 | 62 | const forks = listForks(f.db, repo.id).filter((r) => canRead(f.db, r, f.user)); |
| 50 | 63 | ranked = await rankedForksOf(f.app, repo, forks); |
| 64 | tree = forkTree(f.db, repo.id, f.user); | |
| 51 | 65 | ctx.invalidate(); |
| 52 | 66 | }, |
| 53 | 67 | render(area, s) { |
src/web/pages/repo.ts +25 −2
| @@ -38,6 +38,8 @@ import { pinRepo, syncRepoNow, unpinRepo } from "../../actions/origins.ts"; | ||
| 38 | 38 | import { |
| 39 | 39 | canAdmin, |
| 40 | 40 | forkParentFor, |
| 41 | forkTree, | |
| 42 | type ForkNode, | |
| 41 | 43 | visibleForks, |
| 42 | 44 | } from "../../core/repos.ts"; |
| 43 | 45 | import { isSafeRef, isSafeTreePath } from "../../git/guard.ts"; |
| @@ -131,6 +133,9 @@ export async function forksPage(ctx: Ctx): Promise<Response> { | ||
| 131 | 133 | ); |
| 132 | 134 | notePhase(ctx.request, "forks", performance.now() - t); |
| 133 | 135 | 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(); | |
| 134 | 139 | // The whole family, from the oldest ancestor this viewer may see: who |
| 135 | 140 | // follows whom, and where each stands against the one it follows. |
| 136 | 141 | const lineage = await lineageOf(ctx.app, repo, (r) => |
| @@ -151,7 +156,7 @@ export async function forksPage(ctx: Ctx): Promise<Response> { | ||
| 151 | 156 | title: "No forks yet", |
| 152 | 157 | 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.", |
| 153 | 158 | }) |
| 154 | : html`${forkRanking(ranked)} | |
| 159 | : html`${forkRanking(ranked, new Map(tree.map((n) => [n.repo.id, n])))} | |
| 155 | 160 | <h3 class="mt">The family, by who follows whom</h3> |
| 156 | 161 | ${lineageCard(lineage, repo.id)}`}`; |
| 157 | 162 | const res = page({ |
| @@ -167,9 +172,27 @@ export async function forksPage(ctx: Ctx): Promise<Response> { | ||
| 167 | 172 | * The forks with work to offer, best first, each linking to the compare |
| 168 | 173 | * view that proposes it upstream; then those with nothing new, counted. |
| 169 | 174 | */ |
| 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 | }); | |
| 171 | 193 | const line = (f: RankedFork, offer: boolean): Html => |
| 172 | 194 | row({ |
| 195 | children: (tree.get(f.fork.id)?.forks ?? []).map(below), | |
| 173 | 196 | lead: icon("fork"), |
| 174 | 197 | title: html`<a href="/${f.fork.ownerName}/${f.fork.name}" |
| 175 | 198 | >${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 () => { | ||
| 36 | 36 | const names = (res: Response): string[] => |
| 37 | 37 | (res.headers.get("server-timing") ?? "").split(", ").map((p) => p.split(";")[0]!).filter((n) => n !== "app"); |
| 38 | 38 | |
| 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 () => { | |
| 40 | 40 | const res = await fetch(`${servers.url}/alice/notes/forks`, { headers: { authorization: `Bearer ${bearer}` } }); |
| 41 | 41 | expect(res.status).toBe(200); |
| 42 | 42 | 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"]); | |
| 44 | 44 | }); |
| 45 | 45 | |
| 46 | 46 | test("a missing repository still names session and repository", async () => { |
new above
offline
Low (cost on a large family):
forkTreewalks every visible fork and its children. The page only draws children for the ≤60 ranked forks, and it already calledvisibleForksonce above, so the samecanReadpass runs twice. On a repository with thousands of forks, the second level'sIN (?,…)list also grows with the fork count. Fix in a follow-up: pass in the ranked forks (already capped atMAX_RANKED_FORKS) as the first level instead of callingvisibleForksagain. That bounds the bound-parameter count and halves thecanReadwork.