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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/10531-a-mirrored-repo-s-page-names-its-upstrea → main 2 commits fork 3 threads · 3 unresolved
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, | |
| 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"; | ||
| 42 | 42 | import { NAME_THE_BRANCH } from "../../actions/branch_words.ts"; |
| 43 | 43 | import { pinRepo, syncRepoNow, unpinRepo } from "../../actions/origins.ts"; |
| 44 | 44 | import { isPinned } from "../../core/pins.ts"; |
| 45 | import { creditOf, creditText } from "../../core/credit.ts"; | |
| 45 | 46 | import { setDecisionsBy } from "../../actions/repos.ts"; |
| 46 | 47 | import { stopMirroring } from "../../actions/mirror_target.ts"; |
| 47 | 48 | import { getMirrorTarget, mirrorLine } from "../../core/migrate_mirror.ts"; |
| @@ -965,6 +966,18 @@ export function workspaceScreen( | ||
| 965 | 966 | await loadTab(made); |
| 966 | 967 | }; |
| 967 | 968 | |
| 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 | ||
| 968 | 981 | const reload = async (arriving = false): Promise<void> => { |
| 969 | 982 | const mine = ++gen; |
| 970 | 983 | readPin(); |
| @@ -979,6 +992,7 @@ export function workspaceScreen( | ||
| 979 | 992 | return; |
| 980 | 993 | } |
| 981 | 994 | void askUpstream(mine); |
| 995 | void askCredit(mine); | |
| 982 | 996 | // Coming back to a workspace we remember: the tree and every buffer are |
| 983 | 997 | // already here, edits included. Re-reading them would be slower *and* |
| 984 | 998 | // would throw away uncommitted work, so the only thing worth asking git |
| @@ -2605,6 +2619,7 @@ export function workspaceScreen( | ||
| 2605 | 2619 | const parts = [ |
| 2606 | 2620 | ref === "" ? "no ref" : ref, |
| 2607 | 2621 | held === undefined ? "" : mirrorLine(held), |
| 2622 | creditWords, | |
| 2608 | 2623 | dirty === 0 ? "" : `${dirty} modified`, |
| 2609 | 2624 | pending.size === 0 ? "" : `${plural(pending.size, "deletion")} pending`, |
| 2610 | 2625 | // 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"; | ||
| 80 | 80 | import { getAppByRepo } from "../../core/apps.ts"; |
| 81 | 81 | import { field, readForm } from "../form.ts"; |
| 82 | 82 | import { html, raw, when, type Html } from "../html.ts"; |
| 83 | import { creditLine } from "../credit_line.ts"; | |
| 83 | 84 | import { type CodeRows, rowsMarkup } from "../code_rows.ts"; |
| 84 | 85 | import { rowsOffThread } from "../code_rows_pool.ts"; |
| 85 | 86 | import { flashTo, fragment, page, redirect } from "../layout.ts"; |
| @@ -1418,10 +1419,13 @@ async function codeHome(ctx: Ctx, repo: Repo): Promise<Response> { | ||
| 1418 | 1419 | ? null |
| 1419 | 1420 | : upstreamOffer(ctx.app, ctx.auth.user, repo, parent, drift?.ahead); |
| 1420 | 1421 | 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 | ); | |
| 1421 | 1425 | const rendering = performance.now(); |
| 1422 | 1426 | const body = html`${scopeHeader(ctx, scope, "code", { |
| 1423 | 1427 | 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} | |
| 1425 | 1429 | ${offerUpstream(repo, parent, offer)} |
| 1426 | 1430 | ${latest === null |
| 1427 | 1431 | ? "" |
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 & 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 & 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 () => { | ||
| 52 | 52 | await rm(dataDir, { recursive: true, force: true }); |
| 53 | 53 | }); |
| 54 | 54 | |
| 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 () => { | |
| 56 | 56 | const res = await fetch(`${servers.url}/alice/r`, { headers: { cookie } }); |
| 57 | 57 | expect(res.status).toBe(200); |
| 58 | 58 | await res.arrayBuffer(); |
| 59 | 59 | 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"]); | |
| 61 | 61 | }); |
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 | }); |
Cold-cache git shortlog on the request path (10 s timeout). Principle: snappy first paint. Bound it or fill the cache off-path.