Public: anyone can read this; the crew can change it.
Forks copy a pin's refs/shots with its refs/builds #9271
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/9219-forks-copy-a-pin-s-refs-shots-with-its-r → main 4 commits fork
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, | ||
| 12 | 12 | - 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`. |
| 13 | 13 | - `GET /:owner/:name/builds/:label/shots/:file` serves the picture (immutable when `?v=` matches). |
| 14 | 14 | - **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. | |
| 15 | 16 | - **Live.** Every change emits `build.shots` (`added`, `picked`, `moved`, `captioned`, `removed`), read by the repository's readers. |
| 16 | 17 | |
| 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( | ||
| 77 | 77 | const reflog = await runGit(["config", "core.logAllRefUpdates", "true"], { |
| 78 | 78 | cwd: forkDir, |
| 79 | 79 | }); |
| 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); | |
| 81 | 95 | return ok(); |
| 82 | 96 | } |
| 83 | 97 | |
| 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 | ||
| 84 | 104 | /** |
| 85 | 105 | * Make a fork self-contained: absorb every borrowed object into the fork's own |
| 86 | 106 | * 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): | ||
| 401 | 401 | files: [{ path: `${owner}.md`, content: `# ${owner}\n\nA change of its own.\n` }], |
| 402 | 402 | }); |
| 403 | 403 | 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` }); | |
| 405 | 405 | if (!pin.ok) return err(`the pin of ${owner}: ${JSON.stringify(pin.error)}`); |
| 406 | 406 | } |
| 407 | 407 | const home = getRepo(app.db, admin.username, PREVIEW_REPO); |
test/build-shots.test.ts +35 −3
| @@ -7,7 +7,7 @@ | ||
| 7 | 7 | * nothing. |
| 8 | 8 | */ |
| 9 | 9 | 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"; | |
| 11 | 11 | import { mkdtemp, rm } from "node:fs/promises"; |
| 12 | 12 | import { tmpdir } from "node:os"; |
| 13 | 13 | import { join } from "node:path"; |
| @@ -15,13 +15,14 @@ import { createApp, type App } from "../src/app.ts"; | ||
| 15 | 15 | import { deletePin, pinBuild } from "../src/actions/builds.ts"; |
| 16 | 16 | import { pickShots, uploadShot } from "../src/actions/build_shots.ts"; |
| 17 | 17 | 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"; | |
| 19 | 19 | import { createApp as insertApp, getAppByRepo } from "../src/core/apps.ts"; |
| 20 | 20 | import { recordLook, type Look } from "../src/core/looks.ts"; |
| 21 | 21 | import type { Repo } from "../src/core/repos.ts"; |
| 22 | 22 | import { createToken } from "../src/core/tokens.ts"; |
| 23 | 23 | 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"; | |
| 25 | 26 | import { readShots, SHOT_MAX } from "../src/git/shots.ts"; |
| 26 | 27 | import { serve, type Servers } from "../src/http/server.ts"; |
| 27 | 28 | import { eventVerb } from "../src/design/activity.ts"; |
| @@ -359,6 +360,37 @@ describe("pin screenshots", () => { | ||
| 359 | 360 | expect((await pinBuild(app, alice, repo, { label: "v1", oid })).ok).toBe(true); |
| 360 | 361 | expect((await shots()).items).toEqual([]); |
| 361 | 362 | }); |
| 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 | }); | |
| 362 | 394 | }); |
| 363 | 395 | |
| 364 | 396 | describe("activity wording for build events", () => { |
new above
offline