Public: anyone can read this; the crew can change it.
Update from parent merges when the fork has diverged #2138
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/4-update-from-parent-merges-when-the-fork → main 4 commits fork 1 thread · 1 unresolved
5 files
changed +97
−59
docs/APPS.md +4 −1
| @@ -849,7 +849,10 @@ Forks follow their parents. A fork's default branch is a copy of the | ||
| 849 | 849 | parent's until someone commits on it, and while that is so the parent |
| 850 | 850 | moving is the fork moving — fast-forward only, as pushes land, a mirror's |
| 851 | 851 | sync included. A fork with commits of its own on that branch is left as it |
| 852 | is; `Update from parent` on its page (or `POST …/sync`) says so. This is | |
| 852 | is by that following; `Update from parent` on its page (or `POST …/sync`) | |
| 853 | merges the parent in, as a merge commit authored by the person who asked, | |
| 854 | and refuses with 409 `{error: "conflict", files, message}` when both sides | |
| 855 | changed the same files (`docs/FOLLOWING.md`). This is | |
| 853 | 856 | what lets one person's fork stand between the crew and the outside world: |
| 854 | 857 | the crew proposes into it, the person merges there, and proposes the |
| 855 | 858 | result onward as one pull (see `docs/ORIGINS.md`, "a fork of a mirror"). |
docs/ORIGINS.md +3 −1
| @@ -525,7 +525,9 @@ GET /api/v1/user/origins/:id/mirror/:job/stream SSE: mirror.<stage>, ends o | ||
| 525 | 525 | GET /api/v1/user/origin-apps registered OAuth apps (no secrets) |
| 526 | 526 | POST /api/v1/user/origin-apps {host, clientId, clientSecret?} |
| 527 | 527 | DELETE /api/v1/user/origin-apps/:id |
| 528 | POST /api/v1/repos/:o/:n/sync sync this mirror now | |
| 528 | POST /api/v1/repos/:o/:n/sync sync this mirror now; on a fork, take the | |
| 529 | parent's updates (fast-forward or merge; | |
| 530 | 404 not_a_fork, 409 conflict + files + message) | |
| 529 | 531 | POST /api/v1/repos/:o/:n/origin/reconnect {originId} → repo JSON; re-attach a |
| 530 | 532 | detached mirror (admin token, repo |
| 531 | 533 | admin, own origin; else 404/422 code) |
src/actions/forks.ts −16
| @@ -8,7 +8,6 @@ | ||
| 8 | 8 | |
| 9 | 9 | import type { App } from "../app.ts"; |
| 10 | 10 | import { |
| 11 | fastForwardFork, | |
| 12 | 11 | forkRelation, |
| 13 | 12 | takeUpdates as take, |
| 14 | 13 | type ForkRelation, |
| @@ -30,25 +29,10 @@ import { requireWriter, type AccessError } from "./access.ts"; | ||
| 30 | 29 | |
| 31 | 30 | export type { ForkRelation, ForkSync, TakeError } from "../core/following.ts"; |
| 32 | 31 | |
| 33 | export type SyncForkError = AccessError | "not_a_fork" | "diverged"; | |
| 34 | ||
| 35 | 32 | /** The parent, while `actor` may read it (`forkParentFor`). */ |
| 36 | 33 | const parentOf = (app: App, fork: Repo, actor: User | undefined) => |
| 37 | 34 | forkParentFor(app.db, fork, actor); |
| 38 | 35 | |
| 39 | /** Bring a fork's default branch up to its parent's, if nothing was committed on it. */ | |
| 40 | export async function syncFork( | |
| 41 | app: App, | |
| 42 | actor: User | undefined, | |
| 43 | fork: Repo, | |
| 44 | ): Promise<Result<ForkSync, SyncForkError>> { | |
| 45 | const writer = requireWriter(app, actor, fork); | |
| 46 | if (!writer.ok) return writer; | |
| 47 | const parent = parentOf(app, fork, actor); | |
| 48 | if (parent === undefined) return err("not_a_fork"); | |
| 49 | return fastForwardFork(app, fork, parent, writer.value.id); | |
| 50 | } | |
| 51 | ||
| 52 | 36 | /** |
| 53 | 37 | * Where the fork stands against its parent, for anyone who may read the |
| 54 | 38 | * fork. A parent the reader may not see is still named — the relationship |
src/http/api.ts +36 −34
| @@ -57,7 +57,7 @@ import { receiveBeat, SIGNATURE_HEADER } from "../spawn/heartbeat.ts"; | ||
| 57 | 57 | import { reissueDoor, spawnDoor } from "../spawn/door.ts"; |
| 58 | 58 | import { listSecrets, putSecret, removeSecret } from "../actions/secrets.ts"; |
| 59 | 59 | import { proposeUpstream } from "../actions/upstream.ts"; |
| 60 | import { relationOf, syncFork, takeUpdates } from "../actions/forks.ts"; | |
| 60 | import { relationOf, takeUpdates } from "../actions/forks.ts"; | |
| 61 | 61 | import { convergeFork, convergeState } from "../crew/converge.ts"; |
| 62 | 62 | import { markReaders, mayDirectCrew } from "../crew/direct.ts"; |
| 63 | 63 | import { |
| @@ -377,6 +377,35 @@ import { | ||
| 377 | 377 | } from "../actions/reports.ts"; |
| 378 | 378 | import { BODY_MAX, REQUEST_MAX, TITLE_MAX } from "../core/reports.ts"; |
| 379 | 379 | |
| 380 | /** Take the parent's updates for `repo` and answer as the API does, for every route that asks. */ | |
| 381 | async function respondTake(ctx: Ctx, repo: Repo): Promise<Response> { | |
| 382 | const took = await takeUpdates(ctx.app, ctx.auth.user, repo); | |
| 383 | if (!took.ok) { | |
| 384 | const e = took.error; | |
| 385 | if (typeof e === "string") | |
| 386 | return e === "not_a_fork" | |
| 387 | ? apiError("not_a_fork", 404, "this repository follows nothing") | |
| 388 | : respondAccess(e, 403); | |
| 389 | if (e.kind === "conflict") | |
| 390 | return json( | |
| 391 | { | |
| 392 | error: "conflict", | |
| 393 | message: "both sides changed these files; the crew can converge", | |
| 394 | files: e.files, | |
| 395 | }, | |
| 396 | 409, | |
| 397 | ); | |
| 398 | return apiError( | |
| 399 | e.kind, | |
| 400 | 409, | |
| 401 | e.kind === "moved" | |
| 402 | ? "something moved while this was read; try again" | |
| 403 | : e.message, | |
| 404 | ); | |
| 405 | } | |
| 406 | return json(took.value); | |
| 407 | } | |
| 408 | ||
| 380 | 409 | /** The 401 a stranger gets where an account is needed but a report is not. */ |
| 381 | 410 | function reportInstead(ctx: Ctx, repo: Repo): Response { |
| 382 | 411 | const page = `${ctx.url.origin}/${repo.ownerName}/${repo.name}/report`; |
| @@ -2002,31 +2031,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 2002 | 2031 | if (denied) return denied; |
| 2003 | 2032 | const repo = loadRepo(ctx); |
| 2004 | 2033 | if (!repo) return notFound(); |
| 2005 | const took = await takeUpdates(ctx.app, ctx.auth.user, repo); | |
| 2006 | if (!took.ok) { | |
| 2007 | const e = took.error; | |
| 2008 | if (typeof e === "string") | |
| 2009 | return e === "not_a_fork" | |
| 2010 | ? apiError("not_a_fork", 404, "this repository follows nothing") | |
| 2011 | : respondAccess(e, 403); | |
| 2012 | if (e.kind === "conflict") | |
| 2013 | return json( | |
| 2014 | { | |
| 2015 | error: "conflict", | |
| 2016 | message: "both sides changed these files; the crew can converge", | |
| 2017 | files: e.files, | |
| 2018 | }, | |
| 2019 | 409, | |
| 2020 | ); | |
| 2021 | return apiError( | |
| 2022 | e.kind, | |
| 2023 | 409, | |
| 2024 | e.kind === "moved" | |
| 2025 | ? "something moved while this was read; try again" | |
| 2026 | : e.message, | |
| 2027 | ); | |
| 2028 | } | |
| 2029 | return json(took.value); | |
| 2034 | return respondTake(ctx, repo); | |
| 2030 | 2035 | }, |
| 2031 | 2036 | ); |
| 2032 | 2037 | |
| @@ -4038,8 +4043,10 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 4038 | 4043 | ); |
| 4039 | 4044 | }); |
| 4040 | 4045 | |
| 4041 | // Sync one mirror. This only pulls, so an ordinary repo writer may ask — | |
| 4042 | // it borrows nobody's identity and grants nobody anything. | |
| 4046 | // Sync one mirror, or let a fork take its parent's updates. A mirror only | |
| 4047 | // pulls, so any writer may ask. A fork fast-forwards, or writes a merge | |
| 4048 | // commit authored as the caller when it has work of its own and the merge | |
| 4049 | // is clean (409 conflict otherwise) — which is why it needs a writer. | |
| 4043 | 4050 | router.add("POST", "/api/v1/repos/:owner/:name/sync", async (ctx) => { |
| 4044 | 4051 | const denied = requireScope(ctx, "rw"); |
| 4045 | 4052 | if (denied) return denied; |
| @@ -4047,12 +4054,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 4047 | 4054 | if (!repo) return notFound(); |
| 4048 | 4055 | // A native fork follows its parent; a mirror its origin. |
| 4049 | 4056 | if (repo.origin === null && repo.forkParentId !== null) { |
| 4050 | const followed = await syncFork(ctx.app, ctx.auth.user, repo); | |
| 4051 | if (!followed.ok) | |
| 4052 | return followed.error === "diverged" || followed.error === "not_a_fork" | |
| 4053 | ? json({ error: followed.error }, 409) | |
| 4054 | : respondAccess(followed.error, 404); | |
| 4055 | return json(followed.value); | |
| 4057 | return respondTake(ctx, repo); | |
| 4056 | 4058 | } |
| 4057 | 4059 | const synced = await syncRepoNow(ctx.app, ctx.auth.user, repo); |
| 4058 | 4060 | if (!synced.ok) return respondAccess(synced.error, 404); |
test/forks.test.ts +54 −7
| @@ -9,9 +9,10 @@ import { | ||
| 9 | 9 | forkRepo, |
| 10 | 10 | updateRepoSettings, |
| 11 | 11 | } from "../src/actions/repos.ts"; |
| 12 | import { syncFork } from "../src/actions/forks.ts"; | |
| 12 | import { takeUpdates } from "../src/actions/forks.ts"; | |
| 13 | 13 | import { createApp, type App } from "../src/app.ts"; |
| 14 | 14 | import { getRepo, type Repo } from "../src/core/repos.ts"; |
| 15 | import { createToken } from "../src/core/tokens.ts"; | |
| 15 | 16 | import { createSession } from "../src/core/sessions.ts"; |
| 16 | 17 | import { createUser, type User } from "../src/core/users.ts"; |
| 17 | 18 | import { commitFiles } from "../src/git/write.ts"; |
| @@ -127,18 +128,64 @@ describe("forks", () => { | ||
| 127 | 128 | expect(await follows(carol, moved)).toBe(true); |
| 128 | 129 | |
| 129 | 130 | // Asked by hand, there is nothing left to do. |
| 130 | const again = must(await syncFork(app, users["carol"]!, carol)); | |
| 131 | const again = must(await takeUpdates(app, users["carol"]!, carol)); | |
| 131 | 132 | expect(again.updated).toBe(false); |
| 132 | 133 | expect(again.to).toBe(moved!); |
| 133 | 134 | |
| 135 | // The follower never merges: bob has work of his own and stays where he was. | |
| 134 | 136 | const bob = getRepo(app.db, "bob", "app")!; |
| 135 | const before = await tip(bob); | |
| 136 | const refused = await syncFork(app, users["bob"]!, bob); | |
| 137 | expect(refused.ok).toBe(false); | |
| 138 | if (!refused.ok) expect(refused.error).toBe("diverged"); | |
| 139 | expect(await tip(bob)).toBe(before); | |
| 137 | expect(await tip(bob)).not.toBe(moved); | |
| 140 | 138 | }); |
| 141 | 139 | |
| 140 | test("Update from parent merges a diverged fork cleanly, and refuses a conflicting one untouched", async () => { | |
| 141 | const parent = must( | |
| 142 | await createRepo(app, users["alice"]!, { name: "lib", visibility: "public" }), | |
| 143 | ); | |
| 144 | await commitAs("alice", parent, "README.md", "seed"); | |
| 145 | const clean = await forkAs("carol", parent); | |
| 146 | const conflicting = await forkAs("dan", parent); | |
| 147 | await commitAs("carol", clean, "MINE.md", "carol's own"); | |
| 148 | await commitAs("dan", conflicting, "README.md", "dan rewrote it"); | |
| 149 | await commitAs("alice", parent, "README.md", "alice rewrote it"); | |
| 150 | const parentTip = (await tip(parent))!; | |
| 151 | for (let turns = 0; turns < 200; turns++) | |
| 152 | await new Promise((resolve) => setImmediate(resolve)); // let the follower refuse | |
| 153 | ||
| 154 | const sync = (who: string, repo: Repo) => | |
| 155 | fetch(`${base}/api/v1/repos/${repo.ownerName}/${repo.name}/sync`, { | |
| 156 | method: "POST", | |
| 157 | headers: { | |
| 158 | authorization: `Bearer ${createToken(app.db, users[who]!.id, "t", "rw").token}`, | |
| 159 | }, | |
| 160 | }); | |
| 161 | const git = async (repo: Repo, ...args: string[]) => | |
| 162 | (await Bun.$`git -C ${dirOf(repo)} ${args}`.text()).trim(); | |
| 163 | ||
| 164 | const ownTip = (await tip(clean))!; | |
| 165 | const res = await sync("carol", clean); | |
| 166 | expect(res.status).toBe(200); | |
| 167 | const merged = (await res.json()) as { updated: boolean; merged: boolean; to: string }; | |
| 168 | expect(merged.updated).toBe(true); | |
| 169 | expect(merged.merged).toBe(true); | |
| 170 | const mergeOid = (await tip(clean))!; | |
| 171 | expect(merged.to).toBe(mergeOid); | |
| 172 | expect((await git(clean, "rev-list", "--parents", "-n1", mergeOid)).split(" ").slice(1).sort()).toEqual( | |
| 173 | [ownTip, parentTip].sort(), | |
| 174 | ); | |
| 175 | expect(await git(clean, "log", "-1", "--format=%an", mergeOid)).toBe("carol"); | |
| 176 | // Merged once, there is nothing left to take. | |
| 177 | const again = (await (await sync("carol", clean)).json()) as { updated: boolean }; | |
| 178 | expect(again.updated).toBe(false); | |
| 179 | ||
| 180 | const before = await tip(conflicting); | |
| 181 | const refused = await sync("dan", conflicting); | |
| 182 | expect(refused.status).toBe(409); | |
| 183 | const body = (await refused.json()) as { error: string; files: string[] }; | |
| 184 | expect(body.error).toBe("conflict"); | |
| 185 | expect(body.files).toEqual(["README.md"]); | |
| 186 | expect(await tip(conflicting)).toBe(before); | |
| 187 | }, 30_000); | |
| 188 | ||
| 142 | 189 | test("the About rail says how far a fork is from its parent, and offers an update only when one can be taken", async () => { |
| 143 | 190 | // Dan forks as upstream stands: level, nothing offered. |
| 144 | 191 | await forkAs("dan", upstream); |
new above
offline
Low: this assertion is weaker than the one it replaces.
not.toBe(moved)would still pass if the follower had moved bob somewhere wrong. Keepconst before = await tip(bob)from before the parent moved and asserttoBe(before), so the test still shows the follower leaves a diverged fork exactly where it was.