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

A mirrored repo's page names its upstream, its licence and its original authors #10847

Merged opened this

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

Merged

scout/forge:scout/10531-a-mirrored-repo-s-page-names-its-upstrea → main 2 commits fork 3 threads · 3 unresolved

  • Merged. scout/10531-a-mirrored-repo-s-page-names-its-upstrea was merged into main as 43cda2c.

Review

Sign in to review this change.
8 files changed +510 −3
docs/features/mirror-credit.md added +12
@@ -0,0 +1,12 @@
1 # Mirror credit
2
3 A mirror's repo page carries one quiet sentence under its header: `Mirrored from <upstream> · <licence> · by <authors>`. A fork of a mirror says `Forked from a mirror of <upstream> · …`. A native repository carries none.
4
5 - Upstream: the origin's page (`repo.origin`, or what a detached mirror kept). It is a link only when it is an http(s) address; anything else is plain text.
6 - Licence: the root `LICENSE`/`COPYING`/`UNLICENSE` file at the viewed ref, named when it is a common licence (MIT, Apache-2.0, GPL, BSD…), else by file name; "no licence file" when absent.
7 - Authors: the three names behind the most commits among the newest 500 (`git log`), for the viewed ref whether it is a branch, a tag or a commit id. Bounded: git gets 1.5 s; past that the authors are left out (the page is not held) and nothing is kept, so the next view tries again.
8 - A fork shows the sentence found by walking `forkParentId` through repositories the viewer may read; a parent the viewer cannot read shows no line.
9
10 Two doors, one reading: `core/credit.ts` (`creditOf`, `creditText`) is shared by the web page (`src/web/credit_line.ts`, a `small muted` paragraph, no new class) and the terminal, where the workspace's status line (`src/tui/screens/workspace.ts`) carries the same sentence beside the ref.
11
12 Tests: `test/mirror-credit.test.ts`, `test/tui-mirror-credit.test.ts`.
src/core/credit.ts added +194
@@ -0,0 +1,194 @@
1 /**
2 * The credit a mirror carries: its upstream, its licence and its original
3 * authors (#10531). A fork of a mirror carries the same credit through its
4 * lineage. Plain data in, plain data out: the page draws it.
5 */
6
7 import type { Database } from "bun:sqlite";
8 import { runGit } from "../git/env.ts";
9 import { isSafeRef } from "../git/guard.ts";
10 import { repoDir } from "../git/repos.ts";
11 import type { TreeEntry } from "../git/read.ts";
12
13 import { detachedMirror } from "./origins.ts";
14 import { canRead, getRepoById, type Repo } from "./repos.ts";
15 import type { User } from "./users.ts";
16
17 export interface Upstream {
18 /** `owner/name` on the origin. */
19 readonly fullName: string;
20 /** The origin's page for it; may be empty. */
21 readonly htmlUrl: string;
22 /** True when the credit came up a fork chain, not from this repository's own origin. */
23 readonly viaFork: boolean;
24 }
25
26 const MAX_HOPS = 8;
27 const TOP_AUTHORS = 3;
28
29 /**
30 * The upstream this repository credits: its own origin when it is a mirror
31 * (attached or detached), else the nearest mirror up its fork chain that the
32 * viewer may read. Undefined for a native repository.
33 */
34 export function upstreamOf(
35 db: Database,
36 repo: Repo,
37 viewer: User | undefined,
38 ): Upstream | undefined {
39 let at: Repo | undefined = repo;
40 for (let hop = 0; at !== undefined && hop < MAX_HOPS; hop++) {
41 if (at.origin !== null) return { fullName: at.origin.fullName, htmlUrl: at.origin.htmlUrl, viaFork: at !== repo };
42 const frozen = at.detached ?? detachedMirror(db, at.id);
43 if (frozen !== undefined && frozen !== null) return { fullName: frozen.fullName, htmlUrl: frozen.htmlUrl, viaFork: at !== repo };
44 if (at.forkParentId === null) return undefined;
45 const parent: Repo | undefined = getRepoById(db, at.forkParentId);
46 if (parent === undefined || !canRead(db, parent, viewer)) return undefined;
47 at = parent;
48 }
49 return undefined;
50 }
51
52 const LICENCE_FILE = /^(licen[cs]e|copying|unlicense)(\.(md|txt|markdown|rst))?$/i;
53
54 /** The licence file at the root of a tree, or undefined. */
55 export function licenceEntry(entries: readonly TreeEntry[]): TreeEntry | undefined {
56 return entries.find((e) => e.type === "blob" && LICENCE_FILE.test(e.name));
57 }
58
59 const KNOWN: readonly (readonly [RegExp, string])[] = [
60 [/GNU AFFERO GENERAL PUBLIC LICENSE/i, "AGPL"],
61 [/GNU LESSER GENERAL PUBLIC LICENSE/i, "LGPL"],
62 [/GNU GENERAL PUBLIC LICENSE[\s\S]{0,80}Version 3/i, "GPL-3.0"],
63 [/GNU GENERAL PUBLIC LICENSE[\s\S]{0,80}Version 2/i, "GPL-2.0"],
64 [/Apache License[\s\S]{0,40}Version 2\.0/i, "Apache-2.0"],
65 [/Mozilla Public License[\s\S]{0,40}2\.0/i, "MPL-2.0"],
66 [/Permission is hereby granted, free of charge/i, "MIT"],
67 [/Permission to use, copy, modify, and\/or distribute/i, "ISC"],
68 [/Redistribution and use in source and binary forms[\s\S]{0,1200}Neither the name/i, "BSD-3-Clause"],
69 [/Redistribution and use in source and binary forms/i, "BSD-2-Clause"],
70 [/This is free and unencumbered software released into the public domain/i, "Unlicense"],
71 ];
72
73 /** A short name for licence text; the file's own name when it is not one we know. */
74 export function licenceName(text: string, fileName: string): string {
75 const head = text.slice(0, 4000);
76 for (const [pattern, name] of KNOWN) if (pattern.test(head)) return name;
77 return fileName;
78 }
79
80 const authorCache = new Map<string, readonly string[]>();
81 const CACHE_MAX = 200;
82 /** Only this many of the newest commits are counted: the cost is bounded, not the history's size. */
83 export const AUTHOR_WINDOW = 500;
84 const AUTHOR_TIMEOUT_MS = 1_500;
85
86 /**
87 * The names behind the most commits among the newest `AUTHOR_WINDOW`, most
88 * first. `rev` is a branch, tag or commit id; it is resolved to its commit,
89 * and the answer is kept by directory and commit. A git that is slow to answer
90 * costs the page its authors, never its first paint: a miss is not kept.
91 */
92 async function authorsAt(
93 dir: string,
outdated

Cold-cache git shortlog on the request path (10 s timeout). Principle: snappy first paint. Bound it or fill the cache off-path.

94 rev: string,
95 opts: { limit?: number; timeoutMs?: number; window?: number } = {},
96 ): Promise<readonly string[]> {
97 const limit = opts.limit ?? TOP_AUTHORS;
98 const timeoutMs = opts.timeoutMs ?? AUTHOR_TIMEOUT_MS;
99 const window = opts.window ?? AUTHOR_WINDOW;
100 if (!isSafeRef(rev)) return [];
101 const id = await runGit(["rev-parse", "--verify", "--quiet", `${rev}^{commit}`], { cwd: dir, timeoutMs });
102 if (!id.ok || !/^[0-9a-f]{40}([0-9a-f]{24})?$/.test(id.value)) return [];
103 const key = `${dir}:${id.value}:${window}`;
104 const kept = authorCache.get(key);
105 if (kept !== undefined) return kept.slice(0, limit);
106 const out = await runGit(
107 ["log", `--max-count=${window}`, "--no-merges", "--format=%aN", id.value, "--"],
108 { cwd: dir, timeoutMs },
109 );
110 if (!out.ok) return [];
111 const counts = new Map<string, number>();
112 for (const line of out.value.split("\n")) {
113 const name = line.trim().slice(0, 80);
114 if (name !== "") counts.set(name, (counts.get(name) ?? 0) + 1);
115 }
116 const names = [...counts.entries()].sort((a, b) => b[1] - a[1]).map(([n]) => n).slice(0, 5);
117 if (authorCache.size >= CACHE_MAX) authorCache.clear();
118 authorCache.set(key, names);
119 return names.slice(0, limit);
120 }
121
122 export async function topAuthors(
123 dir: string,
124 rev: string,
125 opts: { limit?: number; timeoutMs?: number; window?: number } = {},
126 ): Promise<readonly string[]> {
127 try {
128 return await authorsAt(dir, rev, opts);
129 } catch {
130 return []; // a directory that is gone, a git that will not start: no authors, same page
131 }
132 }
133
134 /** Empties the author cache; for tests. */
135 export function clearCreditCache(): void {
136 authorCache.clear();
137 }
138
139 /** What a credit needs of a repository's source: the root listing and one blob. */
140 export interface CreditSource {
141 tree(ref: string, path: string): Promise<{ ok: true; value: readonly TreeEntry[] } | { ok: false }>;
142 blob(
143 ref: string,
144 path: string,
145 ): Promise<{ ok: true; value: { kind: string; size?: number; body?: Uint8Array } } | { ok: false }>;
146 }
147
148 export interface Credit {
149 readonly upstream: Upstream;
150 readonly licence: string;
151 readonly authors: readonly string[];
152 }
153
154 const MAX_LICENCE_BYTES = 64 * 1024;
155
156 /** The credit a repository carries at `ref`, or undefined for a native one. Shared by both doors. */
157 export async function creditOf(
158 db: Database,
159 reposRoot: string,
160 repo: Repo,
161 viewer: User | undefined,
162 source: CreditSource,
163 ref: string,
164 ): Promise<Credit | undefined> {
165 const upstream = upstreamOf(db, repo, viewer);
166 if (upstream === undefined) return undefined;
167 const root = await source.tree(ref, "");
168 const file = root.ok ? licenceEntry(root.value) : undefined;
169 let licence = "no licence file";
170 if (file !== undefined) {
171 const blob = await source.blob(ref, file.name);
172 licence =
173 blob.ok && blob.value.kind === "text" && (blob.value.size ?? 0) <= MAX_LICENCE_BYTES && blob.value.body !== undefined
174 ? licenceName(new TextDecoder().decode(blob.value.body), file.name)
175 : file.name;
176 }
177 const authors = await topAuthors(repoDir(reposRoot, repo.ownerName, repo.name), ref);
178 return { upstream, licence, authors };
179 }
180
181 /** The words before the upstream's name: a sentence, not a label. */
182 export function creditLead(c: Credit): string {
183 return c.upstream.viaFork ? "Forked from a mirror of" : "Mirrored from";
184 }
185
186 /** The rest of the sentence after the upstream's name: " · MIT · by Zed, Quinn". */
187 export function creditRest(c: Credit): string {
188 return ` · ${c.licence}${c.authors.length > 0 ? ` · by ${c.authors.join(", ")}` : ""}`;
189 }
190
191 /** The whole line as plain text, for the terminal. */
192 export function creditText(c: Credit): string {
193 return `${creditLead(c)} ${c.upstream.fullName}${creditRest(c)}`;
194 }
src/tui/screens/workspace.ts +15
@@ -42,6 +42,7 @@ import { createBranch } from "../../actions/branches.ts";
4242 import { NAME_THE_BRANCH } from "../../actions/branch_words.ts";
4343 import { pinRepo, syncRepoNow, unpinRepo } from "../../actions/origins.ts";
4444 import { isPinned } from "../../core/pins.ts";
45 import { creditOf, creditText } from "../../core/credit.ts";
4546 import { setDecisionsBy } from "../../actions/repos.ts";
4647 import { stopMirroring } from "../../actions/mirror_target.ts";
4748 import { getMirrorTarget, mirrorLine } from "../../core/migrate_mirror.ts";
@@ -965,6 +966,18 @@ export function workspaceScreen(
965966 await loadTab(made);
966967 };
967968
969 /** A mirror's credit line (#10531), the same sentence the web page prints; empty until read. */
970 let creditWords = "";
971 const askCredit = async (mine: number): Promise<void> => {
972 if (repo === undefined || ref === "") return;
973 const credit = await creditOf(f.db, f.app.reposRoot, repo, f.user, src, ref);
974 if (stale(mine)) return;
975 const words = credit === undefined ? "" : creditText(credit);
976 if (words === creditWords) return;
977 creditWords = words;
978 ctx.invalidate();
979 };
980
968981 const reload = async (arriving = false): Promise<void> => {
969982 const mine = ++gen;
970983 readPin();
@@ -979,6 +992,7 @@ export function workspaceScreen(
979992 return;
980993 }
981994 void askUpstream(mine);
995 void askCredit(mine);
982996 // Coming back to a workspace we remember: the tree and every buffer are
983997 // already here, edits included. Re-reading them would be slower *and*
984998 // would throw away uncommitted work, so the only thing worth asking git
@@ -2605,6 +2619,7 @@ export function workspaceScreen(
26052619 const parts = [
26062620 ref === "" ? "no ref" : ref,
26072621 held === undefined ? "" : mirrorLine(held),
2622 creditWords,
26082623 dirty === 0 ? "" : `${dirty} modified`,
26092624 pending.size === 0 ? "" : `${plural(pending.size, "deletion")} pending`,
26102625 // Said on every frame, not only in the dialog: somebody typing into a
src/web/credit_line.ts added +24
@@ -0,0 +1,24 @@
1 /**
2 * The one quiet line a mirror (or a fork of one) carries under its header:
3 * where it was mirrored from, its licence and its original authors (#10531).
4 * The reading is `core/credit.ts`, shared with the terminal.
5 */
6
7 import { creditLead, creditOf, creditRest } from "../core/credit.ts";
8 import type { Repo } from "../core/repos.ts";
9 import type { RepoSource } from "../view/source.ts";
10 import type { Ctx } from "../http/context.ts";
11 import { html, type Html } from "./html.ts";
12
13 export async function creditLine(ctx: Ctx, repo: Repo, source: RepoSource, ref: string): Promise<Html> {
14 const credit = await creditOf(ctx.app.db, ctx.app.reposRoot, repo, ctx.auth.user, source, ref);
15 if (credit === undefined) return html``;
16 const { upstream } = credit;
17 const safe = /^https?:\/\//i.test(upstream.htmlUrl);
18 return html`<p class="small muted">
19 ${creditLead(credit)}
20 ${safe
21 ? html`<a href="${upstream.htmlUrl}" rel="noreferrer noopener">${upstream.fullName}</a>`
22 : html`<span class="mono">${upstream.fullName}</span>`}${creditRest(credit)}
23 </p>`;
24 }
src/web/pages/repo.ts +5 −1
@@ -80,6 +80,7 @@ import { declaresFeature } from "../../actions/features.ts";
8080 import { getAppByRepo } from "../../core/apps.ts";
8181 import { field, readForm } from "../form.ts";
8282 import { html, raw, when, type Html } from "../html.ts";
83 import { creditLine } from "../credit_line.ts";
8384 import { type CodeRows, rowsMarkup } from "../code_rows.ts";
8485 import { rowsOffThread } from "../code_rows_pool.ts";
8586 import { flashTo, fragment, page, redirect } from "../layout.ts";
@@ -1418,10 +1419,13 @@ async function codeHome(ctx: Ctx, repo: Repo): Promise<Response> {
14181419 ? null
14191420 : upstreamOffer(ctx.app, ctx.auth.user, repo, parent, drift?.ahead);
14201421 const declares = isApp && (await timePhaseAsync(ctx.request, "feature", () => declaresFeature(ctx.app, ctx.auth.user, repo)));
1422 const credit = await timePhaseAsync(ctx.request, "credit", () =>
1423 creditLine(ctx, repo, scope.source, ref),
1424 );
14211425 const rendering = performance.now();
14221426 const body = html`${scopeHeader(ctx, scope, "code", {
14231427 branchCount: refs.branches.length,
1424 })}${timePhase(ctx.request, "behind", () => behindLine(ctx, repo))}${originAheadLine(ctx, repo)}${archivedLine(ctx, repo)}${movedLine(ctx, repo)}${noApp}
1428 })}${credit}${timePhase(ctx.request, "behind", () => behindLine(ctx, repo))}${originAheadLine(ctx, repo)}${archivedLine(ctx, repo)}${movedLine(ctx, repo)}${noApp}
14251429 ${offerUpstream(repo, parent, offer)}
14261430 ${latest === null
14271431 ? ""
test/mirror-credit.test.ts added +166
@@ -0,0 +1,166 @@
1 /**
2 * A mirror's repo page names its upstream, its licence and its original
3 * authors in one line; a fork of the mirror says the same through its
4 * lineage; a native repo says nothing; every value is escaped (#10531).
5 */
6 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { createApp, type App } from "../src/app.ts";
11 import { AUTHOR_WINDOW, clearCreditCache, creditOf, creditText, licenceName, topAuthors, type CreditSource } from "../src/core/credit.ts";
12 import { runGit } from "../src/git/env.ts";
13 import { createOrigin, linkRepoToOrigin } from "../src/core/origins.ts";
14 import { createRepo, forkRepo, getRepoById, type Repo } from "../src/core/repos.ts";
15 import { createUser } from "../src/core/users.ts";
16 import { repoDir } from "../src/git/repos.ts";
17 import { commitFiles } from "../src/git/write.ts";
18 import { serve, type Servers } from "../src/http/server.ts";
19 import { quietLogs } from "./helpers/globals.ts";
20
21 quietLogs();
22
23 const MIT = "MIT License\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\n";
24
25 describe("the credit line of a mirror", () => {
26 let dataDir: string;
27 let app: App;
28 let servers: Servers;
29 let mirror: Repo;
30 let fork: Repo;
31 let bare: Repo;
32 let plain: Repo;
33 let frozen: Repo;
34 let orphan: Repo;
35
36 const put = async (repo: Repo, who: string, files: { path: string; content: string }[]) => {
37 const r = await commitFiles(repoDir(app.reposRoot, repo.ownerName, repo.name), {
38 branch: "main",
39 message: "work",
40 files,
41 author: { name: who, email: "[email protected]" },
42 });
43 expect(r.ok).toBe(true);
44 };
45 const page = async (repo: Repo) =>
46 await (await fetch(`${servers.url}/${repo.ownerName}/${repo.name}`)).text();
47 const line = (body: string) => /<p class="small muted">\s*(?:Mirrored|Forked) from[\s\S]*?<\/p>/.exec(body)?.[0] ?? "";
48
49 beforeAll(async () => {
50 dataDir = await mkdtemp(join(tmpdir(), "forge-credit-"));
51 app = createApp({ dataDir, port: 0, host: "forge.test" });
52 const made0 = await createUser(app.db, { origin: "seed", trusted: true, username: "ada", email: "[email protected]", adminIfFirst: true });
53 if (!made0.ok) throw new Error(String(made0.error));
54 const u = made0.value;
55 const mk = async (name: string) => {
56 const r = await createRepo(app.db, app.reposRoot, { ownerId: u.id, name, visibility: "public", description: "", defaultBranch: "main" });
57 if (!r.ok) throw new Error(r.error);
58 return r.value;
59 };
60 mirror = await mk("lib");
61 bare = await mk("bare");
62 plain = await mk("plain");
63 frozen = await mk("frozen");
64 const hidden = await mk("hidden");
65 await put(mirror, "Zed & Zeta", [{ path: "LICENSE", content: MIT }, { path: "a.txt", content: "1\n" }]);
66 await put(mirror, "Zed & Zeta", [{ path: "b.txt", content: "2\n" }]);
67 await put(mirror, "Quinn", [{ path: "c.txt", content: "3\n" }]);
68 await put(bare, "Quinn", [{ path: "a.txt", content: "1\n" }]);
69 await put(frozen, "Quinn", [{ path: "a.txt", content: "1\n" }]);
70 await put(hidden, "Quinn", [{ path: "a.txt", content: "1\n" }]);
71 await put(plain, "Quinn", [{ path: "a.txt", content: "1\n" }]);
72 const made = createOrigin(app.db, dataDir, { userId: u.id, kind: "forgejo", apiUrl: "https://up.example/api/v1", webUrl: "https://up.example", login: "up", token: "t0ken-t0ken" });
73 if (!made.ok) throw new Error(made.error);
74 const origin = made.value;
75 for (const [repo, remote] of [[mirror, "lib"], [bare, "bare"]] as const)
76 linkRepoToOrigin(app.db, repo.id, { originId: origin.id, fullName: `up/${remote}`, remoteId: remote, htmlUrl: `https://up.example/up/${remote}` });
77 linkRepoToOrigin(app.db, hidden.id, { originId: origin.id, fullName: "up/hidden", remoteId: "hidden", htmlUrl: "https://up.example/up/hidden" });
78 // Frozen: the origin went, the provenance stayed; its page is not a web address.
79 app.db.run(
80 "UPDATE repo SET origin_id = NULL, origin_full_name = 'up/frozen', origin_html_url = 'javascript:alert(1)' WHERE id = ?1",
81 [frozen.id],
82 );
83 const hiddenFork = await forkRepo(app.db, app.reposRoot, hidden, u.id, "hidden-fork");
84 if (!hiddenFork.ok) throw new Error(hiddenFork.error);
85 orphan = hiddenFork.value;
86 app.db.run("UPDATE repo SET visibility = 'private' WHERE id = ?1", [hidden.id]);
87 app.db.run("UPDATE repo SET visibility = 'public' WHERE id = ?1", [orphan.id]);
88 const forked = await forkRepo(app.db, app.reposRoot, mirror, u.id, "lib-mine");
89 if (!forked.ok) throw new Error(forked.error);
90 fork = forked.value;
91 servers = await serve(app);
92 });
93
94 afterAll(async () => {
95 await servers.stop();
96 app.close();
97 await rm(dataDir, { recursive: true, force: true, maxRetries: 20, retryDelay: 100 });
98 });
99
100 test("a mirror names its upstream, its licence and its top authors, escaped", async () => {
101 const credit = line(await page(mirror));
102 expect(credit).toContain('href="https://up.example/up/lib"');
103 expect(credit).toContain("up/lib");
104 expect(credit).toContain("MIT");
105 expect(credit).toContain("Zed &amp; Zeta, Quinn");
106 expect(credit).not.toContain(" & ");
107 });
108
109 test("a fork of the mirror shows the same credit through its lineage", async () => {
110 const credit = line(await page(fork));
111 expect(credit).toContain("up/lib");
112 expect(credit).toContain("MIT");
113 expect(credit).toContain("Zed &amp; Zeta");
114 });
115
116 test("a mirror without a licence file says so; a native repo shows no line", async () => {
117 expect(line(await page(bare))).toContain("no licence file");
118 expect(line(await page(plain))).toBe("");
119 });
120
121 test("licence text is named, and an unknown one keeps its file name", () => {
122 expect(licenceName("Apache License\nVersion 2.0, January 2004", "LICENSE")).toBe("Apache-2.0");
123 expect(licenceName("do what you like", "COPYING")).toBe("COPYING");
124 });
125
126 test("a detached mirror still names its frozen origin, and a non-web address is text, not a link", async () => {
127 const credit = line(await page(frozen));
128 expect(credit).toContain("Mirrored from");
129 expect(credit).toContain("up/frozen");
130 expect(credit).not.toContain("<a ");
131 expect(credit).not.toContain("javascript:");
132 });
133
134 test("a fork whose mirrored parent the viewer cannot read shows no line", async () => {
135 expect(line(await page(orphan))).toBe("");
136 });
137
138 test("a fork says so in a sentence", async () => {
139 expect(line(await page(fork))).toContain("Forked from a mirror of");
140 expect(line(await page(mirror))).toMatch(/Mirrored from\s+<a [^>]*>up\/lib<\/a> · MIT · by /);
141 });
142
143 test("authors come from a tag and from a bare commit id, not only a branch", async () => {
144 const dir = repoDir(app.reposRoot, mirror.ownerName, mirror.name);
145 const sha = await runGit(["rev-parse", "main"], { cwd: dir });
146 if (!sha.ok) throw new Error("rev-parse");
147 const tagged = await runGit(["tag", "v1", sha.value], { cwd: dir });
148 expect(tagged.ok).toBe(true);
149 const source: CreditSource = { tree: async () => ({ ok: false }), blob: async () => ({ ok: false }) };
150 for (const ref of ["v1", sha.value]) {
151 const credit = await creditOf(app.db, app.reposRoot, getRepoById(app.db, mirror.id)!, undefined, source, ref);
152 expect(creditText(credit!)).toContain("by Zed & Zeta, Quinn");
153 }
154 });
155
156 test("authors count only the newest commits and a git that cannot answer costs the authors, not the page", async () => {
157 clearCreditCache();
158 const dir = repoDir(app.reposRoot, mirror.ownerName, mirror.name);
159 expect(AUTHOR_WINDOW).toBeGreaterThan(0);
160 // Newest first: Quinn, then two by Zed & Zeta. A window of one sees only Quinn.
161 expect(await topAuthors(dir, "main", { window: 1 })).toEqual(["Quinn"]);
162 // A miss (no such directory) is empty, fast, and not kept: the real answer still comes.
163 expect(await topAuthors(join(dataDir, "nowhere"), "main")).toEqual([]);
164 expect((await topAuthors(dir, "main"))[0]).toBe("Zed & Zeta");
165 });
166 });
test/repo-timing.test.ts +2 −2
@@ -52,10 +52,10 @@ afterAll(async () => {
5252 await rm(dataDir, { recursive: true, force: true });
5353 });
5454
55 test("a repository home names session, appof, refs, tree, readme, following, wave, behind and render in order", async () => {
55 test("a repository home names session, appof, refs, tree, readme, following, wave, credit, behind and render in order", async () => {
5656 const res = await fetch(`${servers.url}/alice/r`, { headers: { cookie } });
5757 expect(res.status).toBe(200);
5858 await res.arrayBuffer();
5959 const names = requestTiming(res).split(", ").map((p) => p.split(";")[0]!);
60 expect(names).toEqual(["app", "session", "appof", "refs", "tree", "readme", "following", "wave", "behind", "render"]);
60 expect(names).toEqual(["app", "session", "appof", "refs", "tree", "readme", "following", "wave", "credit", "behind", "render"]);
6161 });
test/tui-mirror-credit.test.ts added +92
@@ -0,0 +1,92 @@
1 /**
2 * The terminal's repo screen carries the same credit sentence as the web page
3 * for a mirror — upstream, licence, authors — and nothing for a native repo (#10531).
4 */
5
6 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
11 import { createApp, type App as Forge } from "../src/app.ts";
12 import type { Repo } from "../src/core/repos.ts";
13 import type { User } from "../src/core/users.ts";
14 import { createForgeApp } from "../src/tui/forge.ts";
15 import { headlessTerminal } from "../src/tui/term/terminal.ts";
16 import { quietLogs } from "./helpers/globals.ts";
17 import { seedAdmin } from "./helpers/admin.ts";
18 import { settleApp, settleUntil } from "./helpers/tui.ts";
19
20 quietLogs();
21
22 describe("the workspace's status line credits a mirror", () => {
23 let dataDir: string;
24 let forge: Forge;
25 let alice: User;
26 let mirrored: Repo;
27
28 beforeAll(async () => {
29 dataDir = await mkdtemp(join(tmpdir(), "forge-tui-credit-"));
30 forge = createApp({ dataDir, port: 0 });
31 const a = await seedAdmin(forge, { username: "alice", email: "[email protected]", password: "a-long-passphrase" });
32 if (!a.ok) throw new Error("signup failed");
33 alice = a.value;
34 const mk = async (name: string): Promise<Repo> => {
35 const made = await createRepo(forge, alice, { name, visibility: "public", description: "" });
36 if (!made.ok) throw new Error("repo failed");
37 await commitToBranch(forge, alice, made.value, {
38 message: "seed",
39 files: [{ path: "README.md", content: "# x\n" }],
40 });
41 return made.value;
42 };
43 mirrored = await mk("lighthouse");
44 await mk("harbour");
45 forge.db.run(
46 "UPDATE repo SET origin_id = NULL, origin_full_name = 'up/lighthouse', origin_html_url = 'https://up.example/up/lighthouse' WHERE id = ?1",
47 [mirrored.id],
48 );
49 });
50
51 afterAll(async () => {
52 forge.close();
53 await rm(dataDir, { recursive: true, force: true });
54 });
55
56 async function workspace(user: User, name: string) {
57 const { app } = createForgeApp(
58 forge,
59 { sshUsername: user.username, remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user },
60 { terminal: headlessTerminal(120, 40), exitHooks: false },
61 );
62 app.start();
63 const press = async (...keys: string[]): Promise<void> => {
64 for (const key of keys)
65 app.dispatch({ type: "key", key, ctrl: false, alt: false, shift: false });
66 await settleApp(app);
67 };
68 await settleApp(app);
69 await press("/", ...name, "enter", "enter");
70 await settleUntil(app, `alice/${name}`);
71 return { app, press, text: () => app.screen.toText() };
72 }
73
74 test("a mirror's status line says where it was mirrored from, its licence and its authors", async () => {
75 const h = await workspace(alice, "lighthouse");
76 try {
77 await settleUntil(h.app, "Mirrored from up/lighthouse");
78 expect(h.text()).toContain("Mirrored from up/lighthouse · no licence file · by alice");
79 } finally {
80 h.app.stop();
81 }
82 });
83
84 test("a native repository's status line carries no credit", async () => {
85 const h = await workspace(alice, "harbour");
86 try {
87 expect(h.text()).not.toContain("Mirrored from");
88 } finally {
89 h.app.stop();
90 }
91 });
92 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs