Public: anyone can read this; the crew can change it.
A repository's forks page lists the forks with the most work to offer first: commits ahead, then fewest behind #5970
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/5960-a-repository-s-forks-page-lists-the-fork → main 3 commits fork 3 threads · 3 unresolved
8 files
changed +480
−6
README.md +14
| @@ -1917,6 +1917,20 @@ in the web settings first" (the address is set on the settings page). A check fe | ||
| 1917 | 1917 | the fetched head, the check fails (`squatted`), says so on the row, opens |
| 1918 | 1918 | no pull and leaves the branch as it was. |
| 1919 | 1919 | |
| 1920 | ## Ranked forks | |
| 1921 | ||
| 1922 | `/:owner/:name/forks` and the terminal's forks screen (`f` in a repository) | |
| 1923 | list a repository's direct forks the same way, so the best of a fork tree | |
| 1924 | rises. Order: commits ahead of the parent's default branch (most first), | |
| 1925 | then fewest behind, then the newest tip commit. Each row reads `N ahead · M | |
| 1926 | behind` and when the fork last moved; a fork that is ahead has a **Compare** | |
| 1927 | button (on the page) to its `/pulls/new`, which proposes it to the parent. | |
| 1928 | Forks with nothing ahead (or whose standing git cannot read) follow under | |
| 1929 | `N forks with nothing new`. At most 60 forks are ranked per view; past that | |
| 1930 | the page says `Ranking the first 60 of N forks`. A fork's standing is cached | |
| 1931 | by the pair of tips (`actions/forks.ts`), so a view of forks that have not | |
| 1932 | moved costs one `git log` per fork. | |
| 1933 | ||
| 1920 | 1934 | ## Shape |
| 1921 | 1935 | |
| 1922 | 1936 |
src/actions/forks.ts +120
| @@ -22,6 +22,8 @@ import { | ||
| 22 | 22 | import { commitIdentity } from "../core/commit_identity.ts"; |
| 23 | 23 | import type { User } from "../core/users.ts"; |
| 24 | 24 | import { divergence } from "../git/branches.ts"; |
| 25 | import { runGit } from "../git/env.ts"; | |
| 26 | import { isSafeRef } from "../git/guard.ts"; | |
| 25 | 27 | import { resolveOid } from "../git/merge.ts"; |
| 26 | 28 | import { repoDir } from "../git/repos.ts"; |
| 27 | 29 | import { err, type Result } from "../kernel/result.ts"; |
| @@ -104,3 +106,121 @@ export async function forkDrift( | ||
| 104 | 106 | return { ahead: null, behind: null }; |
| 105 | 107 | return divergence(forkDir, base, head); |
| 106 | 108 | } |
| 109 | ||
| 110 | // ---- ranking ---- | |
| 111 | ||
| 112 | export interface RankedFork { | |
| 113 | readonly fork: Repo; | |
| 114 | readonly drift: ForkDrift; | |
| 115 | /** When the fork's default branch last moved (ISO), or null. */ | |
| 116 | readonly pushedAt: string | null; | |
| 117 | } | |
| 118 | ||
| 119 | /** Forks ranked in one page are bounded; each costs a git call. */ | |
| 120 | export const MAX_RANKED_FORKS = 60; | |
| 121 | ||
| 122 | /** | |
| 123 | * Most work to offer first: commits ahead, then fewest behind, then the | |
| 124 | * newest push. A fork whose drift is unknown has nothing to offer and sorts | |
| 125 | * with the ones that are level. | |
| 126 | */ | |
| 127 | export function rankForks(forks: readonly RankedFork[]): { | |
| 128 | offering: RankedFork[]; | |
| 129 | nothingNew: RankedFork[]; | |
| 130 | } { | |
| 131 | const newest = (f: RankedFork) => Date.parse(f.pushedAt ?? "") || 0; | |
| 132 | const order = (a: RankedFork, b: RankedFork) => | |
| 133 | (b.drift.ahead ?? 0) - (a.drift.ahead ?? 0) || | |
| 134 | (a.drift.behind ?? Infinity) - (b.drift.behind ?? Infinity) || | |
| 135 | newest(b) - newest(a) || | |
| 136 | a.fork.id.localeCompare(b.fork.id); | |
| 137 | const sorted = [...forks].sort(order); | |
| 138 | return { | |
| 139 | offering: sorted.filter((f) => (f.drift.ahead ?? 0) > 0), | |
| 140 | nothingNew: sorted.filter((f) => (f.drift.ahead ?? 0) === 0), | |
| 141 | }; | |
| 142 | } | |
| 143 | ||
| 144 | /** What a fork's standing is worth saying: the words every surface uses. */ | |
| 145 | export const driftWords = (d: ForkDrift): string => | |
| 146 | d.ahead === null || d.behind === null | |
| 147 | ? "standing unknown" | |
| 148 | : `${d.ahead} ahead · ${d.behind} behind`; | |
| 149 | ||
| 150 | export const nothingNewHeading = (n: number): string => | |
| 151 | `${n} fork${n === 1 ? "" : "s"} with nothing new`; | |
| 152 | ||
| 153 | /** | |
| 154 | * A fork's standing is a pure function of the two tips, so it is kept by | |
| 155 | * them: a view whose forks have not moved costs one `git log` each and no | |
| 156 | * `rev-list`. Bounded; a full cache is emptied rather than tracked. | |
| 157 | */ | |
| 158 | const standing = new Map<string, ForkDrift>(); | |
| 159 | const STANDING_MAX = 2_000; | |
| 160 | ||
| 161 | async function tipAndTime( | |
| 162 | dir: string, | |
| 163 | branch: string, | |
| 164 | ): Promise<{ oid: string; pushedAt: string | null } | undefined> { | |
| 165 | if (!isSafeRef(branch)) return undefined; | |
| 166 | const out = await runGit( | |
| 167 | ["log", "-1", "--format=%H %cI", `refs/heads/${branch}`], | |
| 168 | { cwd: dir }, | |
| 169 | ); | |
| 170 | if (!out.ok) return undefined; | |
| 171 | const [oid, at] = out.value.trim().split(" "); | |
| 172 | return oid === undefined || oid === "" | |
| 173 | ? undefined | |
| 174 | : { oid, pushedAt: at ?? null }; | |
| 175 | } | |
| 176 | ||
| 177 | /** Each fork's drift from `parent` and its last push, read from git. */ | |
| 178 | export async function driftOfForks( | |
| 179 | app: App, | |
| 180 | parent: Repo, | |
| 181 | forks: readonly Repo[], | |
| 182 | ): Promise<RankedFork[]> { | |
| 183 | const base = await resolveOid( | |
| 184 | repoDir(app.reposRoot, parent.ownerName, parent.name), | |
| 185 | `refs/heads/${parent.defaultBranch}`, | |
| 186 | ); | |
| 187 | return Promise.all( | |
| 188 | forks.slice(0, MAX_RANKED_FORKS).map(async (fork) => { | |
| 189 | const dir = repoDir(app.reposRoot, fork.ownerName, fork.name); | |
| 190 | const tip = await tipAndTime(dir, fork.defaultBranch); | |
| 191 | if (tip === undefined || base === undefined) | |
| 192 | return { fork, drift: { ahead: null, behind: null }, pushedAt: null }; | |
| 193 | const key = `${base}:${tip.oid}`; | |
| 194 | let drift = standing.get(key); | |
| 195 | if (drift === undefined) { | |
| 196 | drift = await divergence(dir, base, tip.oid); | |
| 197 | if (standing.size >= STANDING_MAX) standing.clear(); | |
| 198 | standing.set(key, drift); | |
| 199 | } | |
| 200 | return { fork, drift, pushedAt: tip.pushedAt }; | |
| 201 | }), | |
| 202 | ); | |
| 203 | } | |
| 204 | ||
| 205 | export interface ForkRanking { | |
| 206 | /** Every fork the viewer may read, ranked or not. */ | |
| 207 | readonly total: number; | |
| 208 | readonly offering: RankedFork[]; | |
| 209 | readonly nothingNew: RankedFork[]; | |
| 210 | /** True when forks past `MAX_RANKED_FORKS` were left out of the ranking. */ | |
| 211 | readonly truncated: boolean; | |
| 212 | } | |
| 213 | ||
| 214 | /** The ranked forks of `repo` among those `mayRead`: one answer for every surface. */ | |
| 215 | export async function rankedForksOf( | |
| 216 | app: App, | |
| 217 | repo: Repo, | |
| 218 | forks: readonly Repo[], | |
| 219 | ): Promise<ForkRanking> { | |
| 220 | const ranked = rankForks(await driftOfForks(app, repo, forks)); | |
| 221 | return { | |
| 222 | total: forks.length, | |
| 223 | ...ranked, | |
| 224 | truncated: forks.length > MAX_RANKED_FORKS, | |
| 225 | }; | |
| 226 | } |
src/tui/screens/forks.ts added +101
| @@ -0,0 +1,101 @@ | ||
| 1 | /** | |
| 2 | * A repository's forks, ranked: the same order and words as the forks page | |
| 3 | * (`actions/forks.ts` decides both), so the best of the tree rises here too. | |
| 4 | * Forks that are ahead come first, with the one that has the most to offer on | |
| 5 | * top; those with nothing new follow under their count. | |
| 6 | */ | |
| 7 | ||
| 8 | import { | |
| 9 | driftWords, | |
| 10 | nothingNewHeading, | |
| 11 | rankedForksOf, | |
| 12 | MAX_RANKED_FORKS, | |
| 13 | type ForkRanking, | |
| 14 | } from "../../actions/forks.ts"; | |
| 15 | import { canRead, listForks, type Repo } from "../../core/repos.ts"; | |
| 16 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 17 | import { binding } from "../app/keys.ts"; | |
| 18 | import { style } from "../term/style.ts"; | |
| 19 | import { truncate } from "../term/width.ts"; | |
| 20 | import { fill, len, split } from "../ui/layout.ts"; | |
| 21 | import { emptyState } from "../ui/widgets.ts"; | |
| 22 | import type { Session } from "../session.ts"; | |
| 23 | import { local } from "./where.ts"; | |
| 24 | import { repoScreen } from "./workspace.ts"; | |
| 25 | import { ListState, drawList, plural, type ListRow } from "./common.ts"; | |
| 26 | ||
| 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 | }); | |
| 35 | return [ | |
| 36 | ...ranked.offering.map((f) => row(f, "")), | |
| 37 | ...ranked.nothingNew.map((f, i) => row(f, i === 0 ? nothingNewHeading(ranked.nothingNew.length) : "")), | |
| 38 | ]; | |
| 39 | } | |
| 40 | ||
| 41 | export function forksScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | |
| 42 | let ranked: ForkRanking | undefined; | |
| 43 | const state = new ListState(); | |
| 44 | const rows = () => (ranked === undefined ? [] : forkRows(ranked)); | |
| 45 | return { | |
| 46 | title: "Forks", | |
| 47 | keys: [binding(["j", "k"], "move"), binding(["enter"], "open this fork"), binding(["esc"], "close")], | |
| 48 | async load() { | |
| 49 | const forks = listForks(f.db, repo.id).filter((r) => canRead(f.db, r, f.user)); | |
| 50 | ranked = await rankedForksOf(f.app, repo, forks); | |
outdated
binding(enter, open this fork) has no handler in key(); implement it or remove it. |
||
| 51 | ctx.invalidate(); | |
| 52 | }, | |
| 53 | render(area, s) { | |
| 54 | const c = ctx.theme.c; | |
| 55 | const [head, body] = split(area, "v", [len(1), fill(1)]); | |
| 56 | const list = rows(); | |
| 57 | s.text( | |
| 58 | head!.x, | |
| 59 | head!.y, | |
| 60 | truncate( | |
| 61 | `${repo.ownerName}/${repo.name} · ${plural(ranked?.total ?? 0, "fork")}${ranked?.truncated ? ` · ranking the first ${MAX_RANKED_FORKS}` : ""}`, | |
| 62 | head!.w, | |
| 63 | ), | |
| 64 | style(c.mutedFg, c.bg), | |
| 65 | head!.x + head!.w, | |
| 66 | ); | |
| 67 | if (list.length === 0) { | |
| 68 | emptyState(s, body!, c, { | |
| 69 | title: ranked === undefined ? "Reading…" : "No forks yet", | |
| 70 | body: "", | |
| 71 | }); | |
| 72 | return; | |
| 73 | } | |
| 74 | drawList( | |
| 75 | s, | |
| 76 | body!, | |
| 77 | c, | |
| 78 | state, | |
| 79 | list.map((r): ListRow => ({ | |
| 80 | title: r.name, | |
| 81 | secondary: r.heading, | |
| 82 | meta: r.words, | |
| 83 | })), | |
| 84 | ); | |
| 85 | }, | |
| 86 | key(e) { | |
| 87 | const list = rows(); | |
| 88 | if (state.key(e, list.length)) return true; | |
| 89 | if (e.key === "enter") { | |
| 90 | const row = list[state.index]; | |
| 91 | if (row !== undefined) ctx.push(repoScreen(f, ctx, local(f, row.repo))); | |
| 92 | return true; | |
| 93 | } | |
| 94 | if (e.key === "esc" || e.key === "q") { | |
| 95 | ctx.pop(); | |
| 96 | return true; | |
| 97 | } | |
| 98 | return false; | |
| 99 | }, | |
| 100 | }; | |
| 101 | } |
src/tui/screens/workspace.ts +6
| @@ -125,6 +125,7 @@ import { issuesScreen } from "./issues.ts"; | ||
| 125 | 125 | import { liveBindings, liveKey } from "./mirror.ts"; |
| 126 | 126 | import { forkScreen } from "./newrepo.ts"; |
| 127 | 127 | import { pullScreen } from "./pull.ts"; |
| 128 | import { forksScreen } from "./forks.ts"; | |
| 128 | 129 | import { branchesScreen } from "./refs.ts"; |
| 129 | 130 | import { wavesScreen } from "./waves.ts"; |
| 130 | 131 | import { |
| @@ -1297,6 +1298,7 @@ export function workspaceScreen( | ||
| 1297 | 1298 | disabled: !editable(), |
| 1298 | 1299 | }), |
| 1299 | 1300 | binding(["b", "B"], "switch ref / new branch"), |
| 1301 | ...(repo === undefined ? [] : [binding(["f"], "forks, most work first")]), | |
| 1300 | 1302 | binding(["t", "ctrl+p"], "find file"), |
| 1301 | 1303 | binding(["y"], "copy path"), |
| 1302 | 1304 | binding(["i", "p", "C"], "issues / pulls / commits"), |
| @@ -1594,6 +1596,10 @@ export function workspaceScreen( | ||
| 1594 | 1596 | ); |
| 1595 | 1597 | return true; |
| 1596 | 1598 | } |
| 1599 | if (plain && e.key === "f" && repo !== undefined) { | |
| 1600 | ctx.push(forksScreen(f, ctx, repo)); | |
| 1601 | return true; | |
| 1602 | } | |
| 1597 | 1603 | if (plain && e.key === "B") { |
| 1598 | 1604 | newBranch(); |
| 1599 | 1605 | return true; |
src/web/pages/repo.ts +56 −2
| @@ -13,7 +13,17 @@ import { NOT_A_MEMBER } from "../../core/roles.ts"; | ||
| 13 | 13 | import { storageLimitMessage } from "../../core/storage.ts"; |
| 14 | 14 | import { canRead, listForks, type Repo } from "../../core/repos.ts"; |
| 15 | 15 | import { FORKS_PER_DAY, forkRepo, repositoryLimitMessage } from "../../actions/repos.ts"; |
| 16 | import { forkDrift, takeUpdates, type ForkDrift } from "../../actions/forks.ts"; | |
| 16 | import { | |
| 17 | type RankedFork, | |
| 18 | forkDrift, | |
| 19 | driftWords, | |
| 20 | nothingNewHeading, | |
| 21 | rankedForksOf, | |
| 22 | MAX_RANKED_FORKS, | |
| 23 | type ForkRanking, | |
| 24 | takeUpdates, | |
| 25 | type ForkDrift, | |
| 26 | } from "../../actions/forks.ts"; | |
| 17 | 27 | import { lineageOf } from "../../core/following.ts"; |
| 18 | 28 | import { |
| 19 | 29 | followingCard, |
| @@ -124,6 +134,9 @@ export async function forksPage(ctx: Ctx): Promise<Response> { | ||
| 124 | 134 | ); |
| 125 | 135 | notePhase(ctx.request, "lineage", performance.now() - t); |
| 126 | 136 | t = performance.now(); |
drift phase runs up to 120 git calls per view; cache or bound it. |
||
| 137 | const ranked = await rankedForksOf(ctx.app, repo, forks); | |
| 138 | notePhase(ctx.request, "drift", performance.now() - t); | |
| 139 | t = performance.now(); | |
| 127 | 140 | const body = html`${repoHeader(ctx, repo, "forks")} |
| 128 | 141 | <div class="page-head mt"> |
| 129 | 142 | <h2>${forks.length} fork${forks.length === 1 ? "" : "s"}</h2> |
| @@ -134,7 +147,9 @@ export async function forksPage(ctx: Ctx): Promise<Response> { | ||
| 134 | 147 | title: "No forks yet", |
| 135 | 148 | 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.", |
| 136 | 149 | }) |
| 137 | : lineageCard(lineage, repo.id)}`; | |
| 150 | : html`${forkRanking(ranked)} | |
| 151 | <h3 class="mt">The family, by who follows whom</h3> | |
| 152 | ${lineageCard(lineage, repo.id)}`}`; | |
| 138 | 153 | const res = page({ |
| 139 | 154 | ctx, |
| 140 | 155 | title: `Forks · ${repo.ownerName}/${repo.name}`, |
| @@ -144,6 +159,45 @@ export async function forksPage(ctx: Ctx): Promise<Response> { | ||
| 144 | 159 | return res; |
| 145 | 160 | } |
| 146 | 161 | |
| 162 | /** | |
| 163 | * The forks with work to offer, best first, each linking to the compare | |
| 164 | * view that proposes it upstream; then those with nothing new, counted. | |
| 165 | */ | |
| 166 | function forkRanking(ranked: ForkRanking): Html { | |
| 167 | const line = (f: RankedFork, offer: boolean): Html => | |
| 168 | row({ | |
| 169 | lead: icon("fork"), | |
| 170 | title: html`<a href="/${f.fork.ownerName}/${f.fork.name}" | |
| 171 | >${f.fork.ownerName}/${f.fork.name}</a | |
| 172 | >`, | |
| 173 | meta: html`${driftWords(f.drift)}${f.pushedAt === null | |
| 174 | ? html`` | |
| 175 | : html` · pushed ${time(f.pushedAt)}`}`, | |
| 176 | ...(offer | |
| 177 | ? { | |
| 178 | trail: btn("Compare", { | |
| 179 | href: `/${f.fork.ownerName}/${f.fork.name}/pulls/new`, | |
| 180 | size: "sm", | |
| 181 | icon: "merge", | |
| 182 | }), | |
| 183 | } | |
| 184 | : {}), | |
| 185 | }); | |
| 186 | const { offering, nothingNew } = ranked; | |
| 187 | return html`${ranked.truncated | |
| 188 | ? html`<p class="muted small"> | |
| 189 | Ranking the first ${MAX_RANKED_FORKS} of ${ranked.total} forks. | |
| 190 | </p>` | |
| 191 | : html``} | |
| 192 | ${offering.length === 0 | |
| 193 | ? html`` | |
| 194 | : listCard(offering.map((f) => line(f, true)))} | |
| 195 | ${nothingNew.length === 0 | |
| 196 | ? html`` | |
| 197 | : html`<h3 class="mt">${nothingNewHeading(nothingNew.length)}</h3> | |
| 198 | ${listCard(nothingNew.map((f) => line(f, false)))}`}`; | |
| 199 | } | |
| 200 | ||
| 147 | 201 | /** The "forked from …" lineage line and a Fork button, for the repo home. */ |
| 148 | 202 | function forkBar(ctx: Ctx, repo: Repo): Html { |
| 149 | 203 | const canFork = |
test/following.test.ts +3 −2
| @@ -733,9 +733,10 @@ describe("a fork that follows its parent", () => { | ||
| 733 | 733 | ), |
| 734 | 734 | ); |
| 735 | 735 | expect(page).not.toMatch(/class="[^"]*\blineage(-children|-here)?\b/); |
| 736 | const bobAt = page.indexOf('href="/bob/game"'); | |
| 736 | const family = page.indexOf("The family"); | |
| 737 | const bobAt = page.indexOf('href="/bob/game"', family); | |
| 737 | 738 | const nested = page.indexOf("row-children", bobAt); |
| 738 | const fayAt = page.indexOf('href="/fay/game-fay"'); | |
| 739 | const fayAt = page.indexOf('href="/fay/game-fay"', family); | |
| 739 | 740 | expect(bobAt).toBeGreaterThan(-1); |
| 740 | 741 | expect(nested).toBeGreaterThan(bobAt); |
| 741 | 742 | expect(fayAt).toBeGreaterThan(nested); |
test/forks-ranking.test.ts added +178
| @@ -0,0 +1,178 @@ | ||
| 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 { commitToBranch, createRepo, forkRepo } from "../src/actions/repos.ts"; | |
| 6 | import { | |
| 7 | MAX_RANKED_FORKS, | |
| 8 | rankedForksOf, | |
| 9 | rankForks, | |
| 10 | type RankedFork, | |
| 11 | } from "../src/actions/forks.ts"; | |
| 12 | import { createSession } from "../src/core/sessions.ts"; | |
| 13 | import { getRepo, listForks } from "../src/core/repos.ts"; | |
| 14 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 15 | import { forksScreen } from "../src/tui/screens/forks.ts"; | |
| 16 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 17 | import { settleApp } from "./helpers/tui.ts"; | |
| 18 | import { createApp, type App } from "../src/app.ts"; | |
| 19 | import type { Repo } from "../src/core/repos.ts"; | |
| 20 | import { createUser, type User } from "../src/core/users.ts"; | |
| 21 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 22 | import { promote } from "./helpers/members.ts"; | |
| 23 | ||
| 24 | const must = <T>(r: { ok: true; value: T } | { ok: false; error: unknown }): T => { | |
| 25 | if (!r.ok) throw new Error(JSON.stringify(r.error)); | |
| 26 | return r.value; | |
| 27 | }; | |
| 28 | ||
| 29 | describe("rankForks", () => { | |
| 30 | const f = (id: string, ahead: number | null, behind: number | null, pushedAt: string | null) => | |
| 31 | ({ fork: { id } as Repo, drift: { ahead, behind }, pushedAt }) as RankedFork; | |
| 32 | test("ahead desc, then behind asc, then newest push; level forks apart", () => { | |
| 33 | const { offering, nothingNew } = rankForks([ | |
| 34 | f("d", 0, 0, "2026-01-05T00:00:00Z"), | |
| 35 | f("b", 2, 3, "2026-01-01T00:00:00Z"), | |
| 36 | f("c", 2, 1, "2026-01-01T00:00:00Z"), | |
| 37 | f("a", 3, 9, "2026-01-01T00:00:00Z"), | |
| 38 | f("e", 2, 1, "2026-02-01T00:00:00Z"), | |
| 39 | f("n", null, null, null), | |
| 40 | ]); | |
| 41 | expect(offering.map((x) => x.fork.id)).toEqual(["a", "e", "c", "b"]); | |
| 42 | expect(nothingNew.map((x) => x.fork.id)).toEqual(["d", "n"]); | |
| 43 | }); | |
| 44 | }); | |
| 45 | ||
| 46 | describe("the forks page ranks", () => { | |
| 47 | let dataDir: string; | |
| 48 | let app: App; | |
| 49 | let servers: Servers; | |
| 50 | let upstream: Repo; | |
| 51 | const users: Record<string, User> = {}; | |
| 52 | ||
| 53 | beforeAll(async () => { | |
| 54 | dataDir = await mkdtemp(join(tmpdir(), "forge-fork-rank-")); | |
| 55 | app = createApp({ dataDir, port: 0 }); | |
| 56 | servers = await serve(app); | |
| 57 | for (const name of ["alice", "bob", "carol", "dan", "erin"]) { | |
| 58 | users[name] = must( | |
| 59 | await createUser(app.db, { | |
| 60 | origin: "seed", | |
| 61 | trusted: true, | |
| 62 | username: name, | |
| 63 | email: `${name}@f.dev`, | |
| 64 | password: "a-good-passphrase", | |
| 65 | adminIfFirst: name === "alice", | |
| 66 | }), | |
| 67 | ); | |
| 68 | promote(app.db, users[name]!); | |
| 69 | } | |
| 70 | upstream = must(await createRepo(app, users["alice"]!, { name: "app", visibility: "public" })); | |
| 71 | const commit = async (who: string, repo: Repo, n: number) => { | |
| 72 | for (let i = 0; i < n; i++) | |
| 73 | must( | |
| 74 | await commitToBranch(app, users[who]!, repo, { | |
| 75 | message: `${who} ${i} ${repo.name}`, | |
| 76 | files: [{ path: `${who}-${repo.name}-${i}.txt`, content: "x\n" }], | |
| 77 | }), | |
| 78 | ); | |
| 79 | }; | |
| 80 | await commit("alice", upstream, 1); | |
| 81 | const bob = must(await forkRepo(app, users["bob"]!, upstream)); | |
| 82 | const carol = must(await forkRepo(app, users["carol"]!, upstream)); | |
| 83 | const dan = must(await forkRepo(app, users["dan"]!, upstream)); | |
| 84 | must(await forkRepo(app, users["erin"]!, upstream)); | |
| 85 | await commit("bob", bob, 1); | |
| 86 | await commit("carol", carol, 2); | |
| 87 | await commit("dan", dan, 3); | |
| 88 | await commit("alice", upstream, 1); // everyone now 1 behind | |
| 89 | }, 60_000); | |
| 90 | ||
| 91 | afterAll(async () => { | |
| 92 | servers.stop(); | |
| 93 | app.close(); | |
| 94 | await rm(dataDir, { recursive: true, force: true }); | |
| 95 | }); | |
| 96 | ||
| 97 | test("most ahead first, links to compare, level forks last under their count", async () => { | |
| 98 | const res = await fetch(`${servers.url}/alice/app/forks`, { | |
| 99 | headers: { cookie: "forge_mode=dev" }, | |
| 100 | }); | |
| 101 | const text = await res.text(); | |
| 102 | const at = (s: string) => text.indexOf(s); | |
| 103 | expect(text).toContain("3 ahead · 1 behind"); | |
| 104 | expect(text).toContain("2 ahead · 1 behind"); | |
| 105 | expect(text).toContain("1 ahead · 1 behind"); | |
| 106 | expect(text).toContain("0 ahead · 0 behind"); | |
| 107 | expect(text).toContain("1 fork with nothing new"); | |
| 108 | expect(at("/carol/app")).toBeGreaterThan(-1); | |
| 109 | // dan is furthest ahead; carol next; bob last of those with work. | |
| 110 | expect(at('href="/dan/app"')).toBeLessThan(at('href="/carol/app"')); | |
| 111 | expect(at('href="/carol/app"')).toBeLessThan(at('href="/bob/app"')); | |
| 112 | expect(at('href="/bob/app"')).toBeLessThan(at("1 fork with nothing new")); | |
| 113 | expect(at("1 fork with nothing new")).toBeLessThan(at('href="/erin/app"')); | |
| 114 | expect(text).toContain('href="/bob/app/pulls/new"'); | |
| 115 | expect(text).not.toContain('href="/erin/app/pulls/new"'); | |
| 116 | }); | |
| 117 | ||
| 118 | test("Compare opens the fork's own proposal page, aimed at the parent and not at the fork", async () => { | |
| 119 | const cookie = `forge_session=${createSession(app.db, users["carol"]!.id).token}; forge_mode=dev`; | |
| 120 | const res = await fetch(`${servers.url}/carol/app/pulls/new`, { headers: { cookie } }); | |
| 121 | expect(res.status).toBe(200); | |
| 122 | const text = await res.text(); | |
| 123 | expect(text).toContain('name="baseRepo" value="parent"'); | |
| 124 | expect(text).toContain("alice/app"); | |
| 125 | expect(text).toContain("Proposing"); | |
| 126 | }); | |
| 127 | ||
| 128 | test("past the bound the ranking says so, and the count stays whole", async () => { | |
| 129 | const bob = getRepo(app.db, "bob", "app")!; | |
| 130 | const forks = Array.from({ length: MAX_RANKED_FORKS + 1 }, () => bob); | |
| 131 | const ranked = await rankedForksOf(app, upstream, forks); | |
| 132 | expect(ranked.total).toBe(MAX_RANKED_FORKS + 1); | |
| 133 | expect(ranked.truncated).toBe(true); | |
| 134 | expect(ranked.offering.length).toBe(MAX_RANKED_FORKS); | |
| 135 | const again = await rankedForksOf(app, upstream, forks); | |
| 136 | expect(again.offering.map((f) => f.drift)).toEqual(ranked.offering.map((f) => f.drift)); | |
| 137 | }); | |
| 138 | ||
| 139 | test("the terminal's forks screen shows the same order and words", async () => { | |
| 140 | const terminal = headlessTerminal(120, 30); | |
| 141 | const { app: tui, session } = createForgeApp( | |
| 142 | app, | |
| 143 | { sshUsername: "alice", remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user: users["alice"]! }, | |
| 144 | { terminal, exitHooks: false }, | |
| 145 | ); | |
| 146 | tui.start(); | |
| 147 | tui.ctx.push(forksScreen(session, tui.ctx, getRepo(app.db, "alice", "app")!)); | |
| 148 | await settleApp(tui); | |
| 149 | const text = tui.screen.toText(); | |
| 150 | tui.stop?.(); | |
| 151 | expect(listForks(app.db, upstream.id).length).toBe(4); | |
| 152 | const at = (s: string) => text.indexOf(s); | |
| 153 | expect(at("dan/app")).toBeGreaterThan(-1); | |
| 154 | expect(at("dan/app")).toBeLessThan(at("carol/app")); | |
| 155 | expect(at("carol/app")).toBeLessThan(at("bob/app")); | |
| 156 | expect(at("bob/app")).toBeLessThan(at("erin/app")); | |
| 157 | expect(text).toContain("3 ahead · 1 behind"); | |
| 158 | expect(text).toContain("1 fork with nothing new"); | |
| 159 | }); | |
| 160 | ||
| 161 | test("enter on a fork opens that fork's workspace", async () => { | |
| 162 | const terminal = headlessTerminal(120, 30); | |
| 163 | const { app: tui, session } = createForgeApp( | |
| 164 | app, | |
| 165 | { sshUsername: "alice", remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user: users["alice"]! }, | |
| 166 | { terminal, exitHooks: false }, | |
| 167 | ); | |
| 168 | tui.start(); | |
| 169 | tui.ctx.push(forksScreen(session, tui.ctx, getRepo(app.db, "alice", "app")!)); | |
| 170 | await settleApp(tui); | |
| 171 | terminal.feed("\r"); | |
| 172 | await settleApp(tui); | |
| 173 | const text = tui.screen.toText(); | |
| 174 | tui.stop?.(); | |
| 175 | expect(text).toContain("dan/app"); | |
| 176 | expect(text).not.toContain("nothing new"); | |
| 177 | }); | |
| 178 | }); |
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 and render, in order", async () => { | |
| 39 | test("the forks page names session, repository, forks, 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", "render"]); | |
| 43 | expect(names(res)).toEqual(["session", "repository", "forks", "lineage", "drift", "render"]); | |
| 44 | 44 | }); |
| 45 | 45 | |
| 46 | 46 | test("a missing repository still names session and repository", async () => { |
new above
offline
Truncation at 60 is silent; surface it on the page.