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

Forks copy a pin's refs/shots with its refs/builds #9271

Merged opened this

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

Merged

scout/forge:scout/9219-forks-copy-a-pin-s-refs-shots-with-its-r → main 4 commits fork

  • Merged. scout/9219-forks-copy-a-pin-s-refs-shots-with-its-r was merged into main as 53d4300.

Review

Sign in to review this change.
4 files changed +59 −6
docs/features/pin-screenshots.md +2 −1
@@ -12,6 +12,7 @@ A build pin can keep curated screenshots: PNG, JPEG or WebP pictures, in order,
1212 - Every write may carry `at`, the `tree` it was read from (the `at` query parameter on DELETE); a list changed since answers 409 `moved`. Two writers racing without `at` still cannot both win: the loser gets `moved`.
1313 - `GET /:owner/:name/builds/:label/shots/:file` serves the picture (immutable when `?v=` matches).
1414 - **Terminal.** **Builds** → `s` on a pin lists its screenshots; a writer has `p` pick from the forge's pictures of that commit, `c` caption, `u`/`d` move, `x` remove. Upload is the API's: a terminal has no file of yours to send.
15 - **Forks.** A fork starts with its parent's pins, covers and screenshots as they are at the fork (`refs/builds`, `refs/covers`, `refs/shots`). Pins and screenshots added upstream later are not synced. A fork whose pin refs cannot be copied is not made, and leaves nothing behind.
1516 - **Live.** Every change emits `build.shots` (`added`, `picked`, `moved`, `captioned`, `removed`), read by the repository's readers.
1617
17 Left out: a gallery page, the demo/arcade side of Jay Moran's port, copying `refs/shots` into forks, activity wording for `build.shots`.
18 Left out: a gallery page, the demo/arcade side of Jay Moran's port, activity wording for `build.shots`.
src/git/repos.ts +21 −1
@@ -77,10 +77,30 @@ export async function forkBareRepo(
7777 const reflog = await runGit(["config", "core.logAllRefUpdates", "true"], {
7878 cwd: forkDir,
7979 });
80 if (!reflog.ok) return reflog;
80 if (!reflog.ok) return await undo(forkDir, reflog);
81 // A bare clone carries heads and tags only. A pin's refs ride with it:
82 // the build, its cover and its curated screenshots.
83 const pins = await runGit(
84 [
85 "fetch",
86 "--no-tags",
87 parentDir,
88 "+refs/builds/*:refs/builds/*",
89 "+refs/covers/*:refs/covers/*",
90 "+refs/shots/*:refs/shots/*",
91 ],
92 { cwd: forkDir, timeoutMs: 120_000 },
93 );
94 if (!pins.ok) return await undo(forkDir, pins);
8195 return ok();
8296 }
8397
98 /** A fork half made is no fork: remove it, so a second try finds a free place. */
99 async function undo<E>(forkDir: string, failure: E): Promise<E> {
100 await rm(forkDir, { recursive: true, force: true });
101 return failure;
102 }
103
84104 /**
85105 * Make a fork self-contained: absorb every borrowed object into the fork's own
86106 * packs, then drop the alternates link. After this the parent can be deleted
src/spawn/preview-seed.ts +1 −1
@@ -401,7 +401,7 @@ async function seedForkFamily(app: App, admin: User, repo: Repo, first: string):
401401 files: [{ path: `${owner}.md`, content: `# ${owner}\n\nA change of its own.\n` }],
402402 });
403403 if (!commit.ok) return err(`the commit of ${owner}: ${JSON.stringify(commit.error)}`);
404 const pin = await pinBuild(app, person.value, fork.value, { label: "try-1", oid: commit.value.commitOid, note: `${owner}, first try` });
404 const pin = await pinBuild(app, person.value, fork.value, { label: `${owner}-try-1`, oid: commit.value.commitOid, note: `${owner}, first try` });
405405 if (!pin.ok) return err(`the pin of ${owner}: ${JSON.stringify(pin.error)}`);
406406 }
407407 const home = getRepo(app.db, admin.username, PREVIEW_REPO);
test/build-shots.test.ts +35 −3
@@ -7,7 +7,7 @@
77 * nothing.
88 */
99 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
10 import { mkdirSync, writeFileSync } from "node:fs";
10 import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
1111 import { mkdtemp, rm } from "node:fs/promises";
1212 import { tmpdir } from "node:os";
1313 import { join } from "node:path";
@@ -15,13 +15,14 @@ import { createApp, type App } from "../src/app.ts";
1515 import { deletePin, pinBuild } from "../src/actions/builds.ts";
1616 import { pickShots, uploadShot } from "../src/actions/build_shots.ts";
1717 import { addToCrew, createAgent } from "../src/actions/crew.ts";
18 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
18 import { commitToBranch, createRepo, forkRepo } from "../src/actions/repos.ts";
1919 import { createApp as insertApp, getAppByRepo } from "../src/core/apps.ts";
2020 import { recordLook, type Look } from "../src/core/looks.ts";
2121 import type { Repo } from "../src/core/repos.ts";
2222 import { createToken } from "../src/core/tokens.ts";
2323 import { createUser, getUserByName, type User } from "../src/core/users.ts";
24 import { repoDir } from "../src/git/repos.ts";
24 import { storeCover } from "../src/git/builds.ts";
25 import { forkBareRepo, repoDir } from "../src/git/repos.ts";
2526 import { readShots, SHOT_MAX } from "../src/git/shots.ts";
2627 import { serve, type Servers } from "../src/http/server.ts";
2728 import { eventVerb } from "../src/design/activity.ts";
@@ -359,6 +360,37 @@ describe("pin screenshots", () => {
359360 expect((await pinBuild(app, alice, repo, { label: "v1", oid })).ok).toBe(true);
360361 expect((await shots()).items).toEqual([]);
361362 });
363 test("a fork copies a pin's refs/builds, refs/covers and refs/shots", async () => {
364 expect((await storeCover(dir(), "v1", PNG(4))).ok).toBe(true);
365 const up = await uploadShot(app, alice, repo, "v1", PNG(5), "kept");
366 expect(up.ok).toBe(true);
367 const forked = await forkRepo(app, alice, repo, "karts-fork");
368 if (!forked.ok) throw new Error(String(forked.error));
369 const forkDir = repoDir(app.reposRoot, "alice", "karts-fork");
370 const refs = (d: string, p: string) =>
371 new TextDecoder()
372 .decode(Bun.spawnSync(["git", "for-each-ref", "--format=%(refname) %(objectname)", p], { cwd: d, env: gitClientEnv() }).stdout)
373 .trim();
374 for (const p of ["refs/builds/", "refs/covers/", "refs/shots/"]) {
375 expect(refs(dir(), p)).not.toBe("");
376 expect(refs(forkDir, p)).toBe(refs(dir(), p));
377 }
378 expect((await readShots(forkDir, "v1")).items.length).toBeGreaterThan(0);
379 });
380
381 test("a fork whose pin refs cannot be copied is removed, so a second try finds a free place", async () => {
382 const bad = join(dataDir, "bad-parent.git");
383 Bun.spawnSync(["git", "clone", "--bare", dir(), bad], { env: gitClientEnv() });
384 // A pin ref naming an object the parent does not hold: the fetch fails.
385 mkdirSync(join(bad, "refs", "shots"), { recursive: true });
386 writeFileSync(join(bad, "refs", "shots", "ghost"), "1234567890123456789012345678901234567890\n");
387 const target = join(dataDir, "bad-fork.git");
388 expect((await forkBareRepo(bad, target)).ok).toBe(false);
389 expect(await Bun.file(join(target, "HEAD")).exists()).toBe(false);
390 expect(existsSync(target)).toBe(false);
391 rmSync(join(bad, "refs", "shots", "ghost"));
392 expect((await forkBareRepo(bad, target)).ok).toBe(true);
393 });
362394 });
363395
364396 describe("activity wording for build events", () => {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs