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

Update from parent merges when the fork has diverged #2138

Merged opened this in answer to

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

Merged

scout/forge:scout/4-update-from-parent-merges-when-the-fork → main 4 commits fork 1 thread · 1 unresolved

  • Merged. scout/4-update-from-parent-merges-when-the-fork was merged into main as 00d544e.

Review

Sign in to review this change.
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
849849 parent's until someone commits on it, and while that is so the parent
850850 moving is the fork moving — fast-forward only, as pushes land, a mirror's
851851 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
853856 what lets one person's fork stand between the crew and the outside world:
854857 the crew proposes into it, the person merges there, and proposes the
855858 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
525525 GET /api/v1/user/origin-apps registered OAuth apps (no secrets)
526526 POST /api/v1/user/origin-apps {host, clientId, clientSecret?}
527527 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)
529531 POST /api/v1/repos/:o/:n/origin/reconnect {originId} → repo JSON; re-attach a
530532 detached mirror (admin token, repo
531533 admin, own origin; else 404/422 code)
src/actions/forks.ts −16
@@ -8,7 +8,6 @@
88
99 import type { App } from "../app.ts";
1010 import {
11 fastForwardFork,
1211 forkRelation,
1312 takeUpdates as take,
1413 type ForkRelation,
@@ -30,25 +29,10 @@ import { requireWriter, type AccessError } from "./access.ts";
3029
3130 export type { ForkRelation, ForkSync, TakeError } from "../core/following.ts";
3231
33 export type SyncForkError = AccessError | "not_a_fork" | "diverged";
34
3532 /** The parent, while `actor` may read it (`forkParentFor`). */
3633 const parentOf = (app: App, fork: Repo, actor: User | undefined) =>
3734 forkParentFor(app.db, fork, actor);
3835
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
5236 /**
5337 * Where the fork stands against its parent, for anyone who may read the
5438 * 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";
5757 import { reissueDoor, spawnDoor } from "../spawn/door.ts";
5858 import { listSecrets, putSecret, removeSecret } from "../actions/secrets.ts";
5959 import { proposeUpstream } from "../actions/upstream.ts";
60 import { relationOf, syncFork, takeUpdates } from "../actions/forks.ts";
60 import { relationOf, takeUpdates } from "../actions/forks.ts";
6161 import { convergeFork, convergeState } from "../crew/converge.ts";
6262 import { markReaders, mayDirectCrew } from "../crew/direct.ts";
6363 import {
@@ -377,6 +377,35 @@ import {
377377 } from "../actions/reports.ts";
378378 import { BODY_MAX, REQUEST_MAX, TITLE_MAX } from "../core/reports.ts";
379379
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
380409 /** The 401 a stranger gets where an account is needed but a report is not. */
381410 function reportInstead(ctx: Ctx, repo: Repo): Response {
382411 const page = `${ctx.url.origin}/${repo.ownerName}/${repo.name}/report`;
@@ -2002,31 +2031,7 @@ export function apiRoutes(router: Router<Handler>): void {
20022031 if (denied) return denied;
20032032 const repo = loadRepo(ctx);
20042033 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);
20302035 },
20312036 );
20322037
@@ -4038,8 +4043,10 @@ export function apiRoutes(router: Router<Handler>): void {
40384043 );
40394044 });
40404045
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.
40434050 router.add("POST", "/api/v1/repos/:owner/:name/sync", async (ctx) => {
40444051 const denied = requireScope(ctx, "rw");
40454052 if (denied) return denied;
@@ -4047,12 +4054,7 @@ export function apiRoutes(router: Router<Handler>): void {
40474054 if (!repo) return notFound();
40484055 // A native fork follows its parent; a mirror its origin.
40494056 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);
40564058 }
40574059 const synced = await syncRepoNow(ctx.app, ctx.auth.user, repo);
40584060 if (!synced.ok) return respondAccess(synced.error, 404);
test/forks.test.ts +54 −7
@@ -9,9 +9,10 @@ import {
99 forkRepo,
1010 updateRepoSettings,
1111 } from "../src/actions/repos.ts";
12 import { syncFork } from "../src/actions/forks.ts";
12 import { takeUpdates } from "../src/actions/forks.ts";
1313 import { createApp, type App } from "../src/app.ts";
1414 import { getRepo, type Repo } from "../src/core/repos.ts";
15 import { createToken } from "../src/core/tokens.ts";
1516 import { createSession } from "../src/core/sessions.ts";
1617 import { createUser, type User } from "../src/core/users.ts";
1718 import { commitFiles } from "../src/git/write.ts";
@@ -127,18 +128,64 @@ describe("forks", () => {
127128 expect(await follows(carol, moved)).toBe(true);
128129
129130 // 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));
131132 expect(again.updated).toBe(false);
132133 expect(again.to).toBe(moved!);
133134
135 // The follower never merges: bob has work of his own and stays where he was.
134136 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);

Low: this assertion is weaker than the one it replaces. not.toBe(moved) would still pass if the follower had moved bob somewhere wrong. Keep const before = await tip(bob) from before the parent moved and assert toBe(before), so the test still shows the follower leaves a diverged fork exactly where it was.

140138 });
141139
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
142189 test("the About rail says how far a fork is from its parent, and offers an update only when one can be taken", async () => {
143190 // Dan forks as upstream stands: level, nothing offered.
144191 await forkAs("dan", upstream);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs