Public: anyone can read this; the crew can change it.
Say how far behind a fork is, next to Update from parent #17
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/1-say-how-far-behind-a-fork-is-next-to-upd → main 2 commits fork
5 files
changed +161
−44
src/core/repos.ts +27
| @@ -372,6 +372,33 @@ export function listForks(db: Database, repoId: string): Repo[] { | ||
| 372 | 372 | .map(toRepo); |
| 373 | 373 | } |
| 374 | 374 | |
| 375 | /** | |
| 376 | * The repository this one was forked from, while that parent is still here | |
| 377 | * and the viewer may read it. A fork's lineage — the parent's name, and how | |
| 378 | * far the fork has drifted from it — is a fact about the parent, so a fork | |
| 379 | * that has been opened up never speaks for a parent that has not. | |
| 380 | */ | |
| 381 | export function forkParentFor( | |
| 382 | db: Database, | |
| 383 | repo: Repo, | |
| 384 | viewer: User | undefined, | |
| 385 | ): Repo | undefined { | |
| 386 | const parent = | |
| 387 | repo.forkParentId === null ? undefined : getRepoById(db, repo.forkParentId); | |
| 388 | return parent !== undefined && canRead(db, parent, viewer) | |
| 389 | ? parent | |
| 390 | : undefined; | |
| 391 | } | |
| 392 | ||
| 393 | /** The forks of this repository the viewer may see — the list, and its count. */ | |
| 394 | export function visibleForks( | |
| 395 | db: Database, | |
| 396 | repoId: string, | |
| 397 | viewer: User | undefined, | |
| 398 | ): Repo[] { | |
| 399 | return listForks(db, repoId).filter((fork) => canRead(db, fork, viewer)); | |
| 400 | } | |
| 401 | ||
| 375 | 402 | export function getRepo( |
| 376 | 403 | db: Database, |
| 377 | 404 | ownerName: string, |
src/http/api.ts +2 −6
| @@ -111,9 +111,9 @@ import { | ||
| 111 | 111 | canRead, |
| 112 | 112 | countsForRepos, |
| 113 | 113 | getRepo, |
| 114 | listForks, | |
| 115 | 114 | listReposByOwner, |
| 116 | 115 | listReposVisibleTo, |
| 116 | visibleForks, | |
| 117 | 117 | type Repo, |
| 118 | 118 | type RepoCounts, |
| 119 | 119 | getRepoById, |
| @@ -1095,11 +1095,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 1095 | 1095 | router.add("GET", "/api/v1/repos/:owner/:name/forks", (ctx) => { |
| 1096 | 1096 | const repo = loadRepo(ctx); |
| 1097 | 1097 | if (!repo) return notFound(); |
| 1098 | return json( | |
| 1099 | listForks(ctx.app.db, repo.id).filter((f) => | |
| 1100 | canRead(ctx.app.db, f, ctx.auth.user), | |
| 1101 | ), | |
| 1102 | ); | |
| 1098 | return json(visibleForks(ctx.app.db, repo.id, ctx.auth.user)); | |
| 1103 | 1099 | }); |
| 1104 | 1100 | |
| 1105 | 1101 | router.add("POST", "/api/v1/repos/:owner/:name/forks", async (ctx) => { |
src/web/chrome.ts +6 −4
| @@ -10,7 +10,7 @@ | ||
| 10 | 10 | |
| 11 | 11 | import { getAppByRepo } from "../core/apps.ts"; |
| 12 | 12 | import { edgeLoginUrl } from "../http/edge.ts"; |
| 13 | import { canAdmin, canRead, getRepo, getRepoById } from "../core/repos.ts"; | |
| 13 | import { canAdmin, canRead, forkParentFor, getRepo } from "../core/repos.ts"; | |
| 14 | 14 | import type { Repo } from "../core/repos.ts"; |
| 15 | 15 | import type { Ctx } from "../http/context.ts"; |
| 16 | 16 | import { html, raw, when, type Html } from "./html.ts"; |
| @@ -141,10 +141,12 @@ export function scopeHeader( | ||
| 141 | 141 | const repo = scope.repo; |
| 142 | 142 | const facts = scope.facts; |
| 143 | 143 | const admin = repo !== undefined && canAdmin(ctx.app.db, repo, ctx.auth.user); |
| 144 | // "forked from …" names another repository, so it says nothing to somebody | |
| 145 | // who cannot read that one — same rule as the About rail's Fork-of row. | |
| 144 | 146 | const parent = |
| 145 | repo?.forkParentId != null | |
| 146 | ? getRepoById(ctx.app.db, repo.forkParentId) | |
| 147 | : undefined; | |
| 147 | repo === undefined | |
| 148 | ? undefined | |
| 149 | : forkParentFor(ctx.app.db, repo, ctx.auth.user); | |
| 148 | 150 | |
| 149 | 151 | const isApp = |
| 150 | 152 | repo !== undefined && getAppByRepo(ctx.app.db, repo.id) !== undefined; |
src/web/pages/repo.ts +16 −18
| @@ -14,10 +14,9 @@ import { forkDrift, syncFork, type ForkDrift } from "../../actions/forks.ts"; | ||
| 14 | 14 | import { pinRepo, syncRepoNow, unpinRepo } from "../../actions/origins.ts"; |
| 15 | 15 | import { |
| 16 | 16 | canAdmin, |
| 17 | canRead, | |
| 18 | 17 | canWrite, |
| 19 | getRepoById, | |
| 20 | listForks, | |
| 18 | forkParentFor, | |
| 19 | visibleForks, | |
| 21 | 20 | } from "../../core/repos.ts"; |
| 22 | 21 | import { isSafeRef, isSafeTreePath } from "../../git/guard.ts"; |
| 23 | 22 | import { blobForRender, type TreeEntry } from "../../git/read.ts"; |
| @@ -79,12 +78,11 @@ export { pathCrumbs, refMenu, refSet, refSetFrom } from "./refs.ts"; | ||
| 79 | 78 | |
| 80 | 79 | // ---- fork ---- |
| 81 | 80 | |
| 82 | /** The repository this one was forked from, while that parent is still here. */ | |
| 83 | function forkParent(ctx: Ctx, repo: Repo): Repo | undefined { | |
| 84 | return repo.forkParentId === null | |
| 85 | ? undefined | |
| 86 | : getRepoById(ctx.app.db, repo.forkParentId); | |
| 87 | } | |
| 81 | /** The parent and the forks this viewer may see, from the request. */ | |
| 82 | const forkParent = (ctx: Ctx, repo: Repo): Repo | undefined => | |
| 83 | forkParentFor(ctx.app.db, repo, ctx.auth.user); | |
| 84 | const forksFor = (ctx: Ctx, repo: Repo): Repo[] => | |
| 85 | visibleForks(ctx.app.db, repo.id, ctx.auth.user); | |
| 88 | 86 | |
| 89 | 87 | /** |
| 90 | 88 | * How far the fork has slipped from its parent, in a phrase. A fork with |
| @@ -120,9 +118,7 @@ function updatable(drift: ForkDrift | undefined): boolean { | ||
| 120 | 118 | export function forksPage(ctx: Ctx): Response { |
| 121 | 119 | const repo = loadRepo(ctx); |
| 122 | 120 | if (!repo) return notFoundPage(ctx); |
| 123 | const forks = listForks(ctx.app.db, repo.id).filter((fork) => | |
| 124 | canRead(ctx.app.db, fork, ctx.auth.user), | |
| 125 | ); | |
| 121 | const forks = forksFor(ctx, repo); | |
| 126 | 122 | const body = html`${repoHeader(ctx, repo, "code")} |
| 127 | 123 | <div class="page-head mt"> |
| 128 | 124 | <h2>${forks.length} fork${forks.length === 1 ? "" : "s"}</h2> |
| @@ -153,7 +149,7 @@ export function forksPage(ctx: Ctx): Response { | ||
| 153 | 149 | function forkBar(ctx: Ctx, repo: Repo): Html { |
| 154 | 150 | const canFork = |
| 155 | 151 | ctx.auth.user !== undefined && ctx.auth.user.id !== repo.ownerId; |
| 156 | const forkCount = listForks(ctx.app.db, repo.id).length; | |
| 152 | const forkCount = forksFor(ctx, repo).length; | |
| 157 | 153 | if (!canFork && forkCount === 0) return html``; |
| 158 | 154 | return html`<div class="fork-bar"> |
| 159 | 155 | ${when( |
| @@ -897,13 +893,15 @@ export async function repoHome(ctx: Ctx): Promise<Response> { | ||
| 897 | 893 | : knownRef(refs, repo.defaultBranch) |
| 898 | 894 | ? repo.defaultBranch |
| 899 | 895 | : refs.branches[0]!; |
| 900 | const entries = await scope.source.tree(ref, ""); | |
| 901 | const tree = entries.ok ? entries.value : []; | |
| 902 | 896 | // A fork's rail says how far its default branch is from its parent's; the |
| 903 | // count is git's, so it is read here rather than inside the card. | |
| 897 | // count is git's, so it is read here rather than inside the card — beside | |
| 898 | // the tree read, not after it, because neither waits on the other. | |
| 904 | 899 | const parent = forkParent(ctx, repo); |
| 905 | const drift = | |
| 906 | parent === undefined ? undefined : await forkDrift(ctx.app, repo, parent); | |
| 900 | const [entries, drift] = await Promise.all([ | |
| 901 | scope.source.tree(ref, ""), | |
| 902 | parent === undefined ? undefined : forkDrift(ctx.app, repo, parent), | |
| 903 | ]); | |
| 904 | const tree = entries.ok ? entries.value : []; | |
| 907 | 905 | |
| 908 | 906 | const body = html`${scopeHeader(ctx, scope, "code", { |
| 909 | 907 | branchCount: refs.branches.length, |
test/forks.test.ts +110 −16
| @@ -80,6 +80,26 @@ describe("forks", () => { | ||
| 80 | 80 | .then((j) => (j as { token: string }).token); |
| 81 | 81 | } |
| 82 | 82 | |
| 83 | /** Sign in through the web form; the cookie is what a browser would carry. */ | |
| 84 | async function signIn(username: string): Promise<string> { | |
| 85 | const res = await fetch(`${base}/signin`, { | |
| 86 | method: "POST", | |
| 87 | body: new URLSearchParams({ username, password: "fork-me-please" }), | |
| 88 | redirect: "manual", | |
| 89 | }); | |
| 90 | return (res.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 91 | } | |
| 92 | ||
| 93 | /** The HTML of a page as somebody — or, with no cookie, as a stranger. */ | |
| 94 | async function pageAs(path: string, cookie?: string): Promise<string> { | |
| 95 | const res = await fetch( | |
| 96 | `${base}${path}`, | |
| 97 | cookie === undefined ? {} : { headers: { cookie } }, | |
| 98 | ); | |
| 99 | expect(res.status).toBe(200); | |
| 100 | return await res.text(); | |
| 101 | } | |
| 102 | ||
| 83 | 103 | beforeAll(async () => { |
| 84 | 104 | dataDir = await mkdtemp(join(tmpdir(), "forge-fork-")); |
| 85 | 105 | work = await mkdtemp(join(tmpdir(), "forge-fork-work-")); |
| @@ -251,19 +271,6 @@ describe("forks", () => { | ||
| 251 | 271 | authorization: `Bearer ${token}`, |
| 252 | 272 | "content-type": "application/json", |
| 253 | 273 | }); |
| 254 | async function signIn(username: string): Promise<string> { | |
| 255 | const res = await fetch(`${base}/signin`, { | |
| 256 | method: "POST", | |
| 257 | body: new URLSearchParams({ username, password: "fork-me-please" }), | |
| 258 | redirect: "manual", | |
| 259 | }); | |
| 260 | return (res.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 261 | } | |
| 262 | async function rail(path: string, cookie: string): Promise<string> { | |
| 263 | const res = await fetch(`${base}${path}`, { headers: { cookie } }); | |
| 264 | expect(res.status).toBe(200); | |
| 265 | return await res.text(); | |
| 266 | } | |
| 267 | 274 | |
| 268 | 275 | // Dan forks alice/app as it stands, so his copy starts level with it: |
| 269 | 276 | // nothing to take, and nothing offered. |
| @@ -278,7 +285,7 @@ describe("forks", () => { | ||
| 278 | 285 | }) |
| 279 | 286 | ).status, |
| 280 | 287 | ).toBe(201); |
| 281 | const current = await rail("/dan/app", danCookie); | |
| 288 | const current = await pageAs("/dan/app", danCookie); | |
| 282 | 289 | expect(current).toContain("up to date"); |
| 283 | 290 | expect(current).not.toContain("Update from parent"); |
| 284 | 291 | |
| @@ -301,13 +308,13 @@ describe("forks", () => { | ||
| 301 | 308 | work, |
| 302 | 309 | ); |
| 303 | 310 | |
| 304 | const behind = await rail("/dan/app", danCookie); | |
| 311 | const behind = await pageAs("/dan/app", danCookie); | |
| 305 | 312 | expect(behind).toContain("1 commit behind"); |
| 306 | 313 | expect(behind).toContain("Update from parent"); |
| 307 | 314 | |
| 308 | 315 | // Bob committed on his fork and alice moved on twice since: an update |
| 309 | 316 | // would be refused, so the page says why instead of offering one. |
| 310 | const diverged = await rail("/bob/app", await signIn("bob")); | |
| 317 | const diverged = await pageAs("/bob/app", await signIn("bob")); | |
| 311 | 318 | expect(diverged).toContain("1 commit of its own"); |
| 312 | 319 | expect(diverged).toContain("2 behind"); |
| 313 | 320 | expect(diverged).not.toContain("Update from parent"); |
| @@ -359,6 +366,93 @@ describe("forks", () => { | ||
| 359 | 366 | ); |
| 360 | 367 | }); |
| 361 | 368 | |
| 369 | test("a fork does not name a parent the viewer cannot read", async () => { | |
| 370 | const asAlice = { | |
| 371 | authorization: `Bearer ${aliceToken}`, | |
| 372 | "content-type": "application/json", | |
| 373 | }; | |
| 374 | // A private repo, forked by its own owner and then opened: the copy is | |
| 375 | // public, the parent is not. The lineage row is the one place a public | |
| 376 | // page could name a repository the visitor cannot see — and the drift | |
| 377 | // beside it would say how busy that repository has been. | |
| 378 | await fetch(`${base}/api/v1/repos`, { | |
| 379 | method: "POST", | |
| 380 | headers: asAlice, | |
| 381 | body: JSON.stringify({ name: "vault", visibility: "private" }), | |
| 382 | }); | |
| 383 | const remote = `${base.replace("http://", `http://alice:${aliceToken}@`)}/alice/vault.git`; | |
| 384 | await gitOk(["clone", remote, "vault"], work); | |
| 385 | const dir = join(work, "vault"); | |
| 386 | await Bun.write(join(dir, "README.md"), "# vault\n"); | |
| 387 | await gitOk(["add", "."], dir); | |
| 388 | await gitOk(["commit", "-m", "vault seed"], dir); | |
| 389 | await gitOk(["push", "origin", "main"], dir); | |
| 390 | expect( | |
| 391 | ( | |
| 392 | await fetch(`${base}/api/v1/repos/alice/vault/forks`, { | |
| 393 | method: "POST", | |
| 394 | headers: asAlice, | |
| 395 | body: JSON.stringify({ name: "vault-open" }), | |
| 396 | }) | |
| 397 | ).status, | |
| 398 | ).toBe(201); | |
| 399 | expect( | |
| 400 | ( | |
| 401 | await fetch(`${base}/api/v1/repos/alice/vault-open`, { | |
| 402 | method: "PATCH", | |
| 403 | headers: asAlice, | |
| 404 | body: JSON.stringify({ visibility: "public" }), | |
| 405 | }) | |
| 406 | ).status, | |
| 407 | ).toBe(200); | |
| 408 | ||
| 409 | const stranger = await pageAs("/alice/vault-open"); | |
| 410 | expect(stranger).not.toContain("Fork of"); | |
| 411 | expect(stranger).not.toContain('href="/alice/vault"'); | |
| 412 | expect(stranger).not.toContain("up to date"); | |
| 413 | ||
| 414 | // Alice reads the same page and the row is all there. | |
| 415 | const owner = await pageAs("/alice/vault-open", await signIn("alice")); | |
| 416 | expect(owner).toContain("Fork of"); | |
| 417 | expect(owner).toContain('href="/alice/vault"'); | |
| 418 | expect(owner).toContain("up to date"); | |
| 419 | }); | |
| 420 | ||
| 421 | test("a fork the viewer cannot read is not counted on the parent", async () => { | |
| 422 | const asAlice = { | |
| 423 | authorization: `Bearer ${aliceToken}`, | |
| 424 | "content-type": "application/json", | |
| 425 | }; | |
| 426 | expect( | |
| 427 | ( | |
| 428 | await fetch(`${base}/api/v1/repos/alice/app/forks`, { | |
| 429 | method: "POST", | |
| 430 | headers: asAlice, | |
| 431 | body: JSON.stringify({ name: "app-quiet" }), | |
| 432 | }) | |
| 433 | ).status, | |
| 434 | ).toBe(201); | |
| 435 | expect( | |
| 436 | ( | |
| 437 | await fetch(`${base}/api/v1/repos/alice/app-quiet`, { | |
| 438 | method: "PATCH", | |
| 439 | headers: asAlice, | |
| 440 | body: JSON.stringify({ visibility: "private" }), | |
| 441 | }) | |
| 442 | ).status, | |
| 443 | ).toBe(200); | |
| 444 | ||
| 445 | // The count is a link to the list, so it counts what the list would show. | |
| 446 | const stranger = await pageAs("/alice/app"); | |
| 447 | expect(stranger).toContain("3 forks"); | |
| 448 | expect(await pageAs("/alice/app/forks")).not.toContain( | |
| 449 | 'href="/alice/app-quiet"', | |
| 450 | ); | |
| 451 | expect(await pageAs("/alice/app", await signIn("alice"))).toContain( | |
| 452 | "4 forks", | |
| 453 | ); | |
| 454 | }); | |
| 455 | ||
| 362 | 456 | test("deleting the parent detaches the fork, which survives standalone", async () => { |
| 363 | 457 | const forkDir = repoDir(join(dataDir, "repos"), "bob", "app"); |
| 364 | 458 | const parent = getRepo(app.db, "alice", "app")!; |
new above
offline