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

Say how far behind a fork is, next to Update from parent #17

Merged opened this in answer to

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

Merged

mason/forge:mason/1-say-how-far-behind-a-fork-is-next-to-upd → main 2 commits fork

  • Merged. mason/1-say-how-far-behind-a-fork-is-next-to-upd was merged into main as 4f665c5.

Review

Sign in to review this change.
5 files changed +161 −44
src/core/repos.ts +27
@@ -372,6 +372,33 @@ export function listForks(db: Database, repoId: string): Repo[] {
372372 .map(toRepo);
373373 }
374374
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
375402 export function getRepo(
376403 db: Database,
377404 ownerName: string,
src/http/api.ts +2 −6
@@ -111,9 +111,9 @@ import {
111111 canRead,
112112 countsForRepos,
113113 getRepo,
114 listForks,
115114 listReposByOwner,
116115 listReposVisibleTo,
116 visibleForks,
117117 type Repo,
118118 type RepoCounts,
119119 getRepoById,
@@ -1095,11 +1095,7 @@ export function apiRoutes(router: Router<Handler>): void {
10951095 router.add("GET", "/api/v1/repos/:owner/:name/forks", (ctx) => {
10961096 const repo = loadRepo(ctx);
10971097 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));
11031099 });
11041100
11051101 router.add("POST", "/api/v1/repos/:owner/:name/forks", async (ctx) => {
src/web/chrome.ts +6 −4
@@ -10,7 +10,7 @@
1010
1111 import { getAppByRepo } from "../core/apps.ts";
1212 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";
1414 import type { Repo } from "../core/repos.ts";
1515 import type { Ctx } from "../http/context.ts";
1616 import { html, raw, when, type Html } from "./html.ts";
@@ -141,10 +141,12 @@ export function scopeHeader(
141141 const repo = scope.repo;
142142 const facts = scope.facts;
143143 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.
144146 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);
148150
149151 const isApp =
150152 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";
1414 import { pinRepo, syncRepoNow, unpinRepo } from "../../actions/origins.ts";
1515 import {
1616 canAdmin,
17 canRead,
1817 canWrite,
19 getRepoById,
20 listForks,
18 forkParentFor,
19 visibleForks,
2120 } from "../../core/repos.ts";
2221 import { isSafeRef, isSafeTreePath } from "../../git/guard.ts";
2322 import { blobForRender, type TreeEntry } from "../../git/read.ts";
@@ -79,12 +78,11 @@ export { pathCrumbs, refMenu, refSet, refSetFrom } from "./refs.ts";
7978
8079 // ---- fork ----
8180
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);
8886
8987 /**
9088 * 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 {
120118 export function forksPage(ctx: Ctx): Response {
121119 const repo = loadRepo(ctx);
122120 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);
126122 const body = html`${repoHeader(ctx, repo, "code")}
127123 <div class="page-head mt">
128124 <h2>${forks.length} fork${forks.length === 1 ? "" : "s"}</h2>
@@ -153,7 +149,7 @@ export function forksPage(ctx: Ctx): Response {
153149 function forkBar(ctx: Ctx, repo: Repo): Html {
154150 const canFork =
155151 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;
157153 if (!canFork && forkCount === 0) return html``;
158154 return html`<div class="fork-bar">
159155 ${when(
@@ -897,13 +893,15 @@ export async function repoHome(ctx: Ctx): Promise<Response> {
897893 : knownRef(refs, repo.defaultBranch)
898894 ? repo.defaultBranch
899895 : refs.branches[0]!;
900 const entries = await scope.source.tree(ref, "");
901 const tree = entries.ok ? entries.value : [];
902896 // 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.
904899 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 : [];
907905
908906 const body = html`${scopeHeader(ctx, scope, "code", {
909907 branchCount: refs.branches.length,
test/forks.test.ts +110 −16
@@ -80,6 +80,26 @@ describe("forks", () => {
8080 .then((j) => (j as { token: string }).token);
8181 }
8282
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
83103 beforeAll(async () => {
84104 dataDir = await mkdtemp(join(tmpdir(), "forge-fork-"));
85105 work = await mkdtemp(join(tmpdir(), "forge-fork-work-"));
@@ -251,19 +271,6 @@ describe("forks", () => {
251271 authorization: `Bearer ${token}`,
252272 "content-type": "application/json",
253273 });
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 }
267274
268275 // Dan forks alice/app as it stands, so his copy starts level with it:
269276 // nothing to take, and nothing offered.
@@ -278,7 +285,7 @@ describe("forks", () => {
278285 })
279286 ).status,
280287 ).toBe(201);
281 const current = await rail("/dan/app", danCookie);
288 const current = await pageAs("/dan/app", danCookie);
282289 expect(current).toContain("up to date");
283290 expect(current).not.toContain("Update from parent");
284291
@@ -301,13 +308,13 @@ describe("forks", () => {
301308 work,
302309 );
303310
304 const behind = await rail("/dan/app", danCookie);
311 const behind = await pageAs("/dan/app", danCookie);
305312 expect(behind).toContain("1 commit behind");
306313 expect(behind).toContain("Update from parent");
307314
308315 // Bob committed on his fork and alice moved on twice since: an update
309316 // 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"));
311318 expect(diverged).toContain("1 commit of its own");
312319 expect(diverged).toContain("2 behind");
313320 expect(diverged).not.toContain("Update from parent");
@@ -359,6 +366,93 @@ describe("forks", () => {
359366 );
360367 });
361368
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
362456 test("deleting the parent detaches the fork, which survives standalone", async () => {
363457 const forkDir = repoDir(join(dataDir, "repos"), "bob", "app");
364458 const parent = getRepo(app.db, "alice", "app")!;

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs