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

Compat lab: sync shapes, idempotent resumable sync and divergence, two real forges (limit of #9451) #9563

Merged opened this

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

Merged

mason/forge:mason/9560-compat-lab-sync-shapes-idempotent-resuma → main 10 commits fork 2 threads · 2 unresolved

  • Merged. mason/9560-compat-lab-sync-shapes-idempotent-resuma was merged into main as f9920b1.

Review

Sign in to review this change.
12 files changed +805 −14
docs/features/forge-origin.md +12
@@ -44,6 +44,18 @@ The preview of the pull was not exercised: the write-through is proven by `test/
4444
4545 Any of them can be mirrored here, and a mirror follows the one there. Sending work back from a repository someone else owns takes a token that can write there: `contribute` forks it there and opens a pull, and a read-only connection is refused in words. Test: `test/origin-repo-groups.test.ts`.
4646
47 ## The sync contract (`src/git/sync_link.ts`)
48
49 One contract written for every shape a network takes: a follower, a fork, a mirror, two forges syncing both ways, a forge with several upstreams, and a diamond. Today only one shape has a door: a fork following its parent on the same forge. Nothing yet calls it between two forges (no API, MCP, web or terminal door syncs a repository with one on another forge); the other shapes are exercised by tests alone. A link is judged (`judgeLink`: synced, behind, ahead or diverged, with the counts and the one action that reconciles it: fast-forward, offer, merge, or keep both) and synced (`syncLink`).
50
51 - **Idempotent.** A tip already held is a no-op, by whichever route it comes; a diamond lands once.
52 - **Resumable.** Their tip arrives by a fetch into a staging ref, which git writes whole or not at all, and our branch moves by compare-and-swap. A sync cut off at any point leaves the branch where it was; the next try finishes.
53 - **Divergence is never silent and never moved.** The branch stays; their tip is staged at the stage ref; both sides compute the same words and the same action.
54
55 **Where you meet it.** A fork's standing against its parent now names the one action that reconciles it (`none`, `fast-forward`, `offer`, `merge`, `keep-both`) and says it in words: in the `following` API read (`action`, `words`), on the fork's home card, in the terminal's forks screen (`Forks` → the follows row), and in MCP `following`. Taking the parent's work (`fastForwardFork`, which every push to the parent triggers) goes through `syncLink`, so it is compare-and-swap, stages the parent's tip at `refs/following/<branch>`, and refuses a divergence. `source` is always a repository path computed from rows, never text a person typed. Syncs into the same repository's stage ref take turns, so a push's follow-on and a person's click at once never race on that ref (test: "syncs at once into one stage ref take turns").
56
57 `test/sync-shapes.test.ts` walks each shape with real git between bare repositories on one machine. `scripts/forge-compat-lab/shapes.ts` holds the same shapes as lab cells between two real forges, but **they have not run yet**: they run only when asked for (`LAB_ONLY=shapes scripts/forge-compat-lab.sh`) and are not proven until a docker host has walked them. Not shown either: that a sync at one door reaches a web viewer live (the card reads the standing on each load; nothing pushes it). Not yet wired: the flows-view health of each link, the schedule probe, and the crew taking the action on autonomous repositories (the rest of #9451).
58
4759 ## A downstream forge as a peer (#9451)
4860
4961 A child forge's proposal (`child/<name>/…` head, proposal token) used to wait for a person to approve the exact head even when that forge was trusted. Now a writer trusts the pair *(forge, `child:<name>`)* in the same trusted-sources list as an origin's account, on the same page (`/settings/trusted`) and in the same terminal screen: write `child:kid` as the account. The forge is the host of the origin the proposal token was minted for; the name is the one the writer chose when minting it. Its pulls then run their checks and fold in like a trusted origin's (`foldsIn`, `src/core/trusted_sources.ts`).
scripts/forge-compat-lab/driver.ts +12
@@ -6,6 +6,7 @@
66 */
77 import { spawnSync } from "node:child_process";
88 import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
9 import { runShapes } from "./shapes.ts";
910
1011 type Seeded = { cookie: string; ro: string; rw: string; admin: string; revokable: { id: string; token: string } };
1112 type Seed = Record<"ann" | "rick" | "boss" | "ona" | "pat" | "uma" | "dee", Seeded>;
@@ -872,6 +873,17 @@ const main = async (): Promise<void> => {
872873 }
873874 for (const [r, s] of [["forge-new", "forge-old"], ["forge-old", "forge-new"], ["forge-new2", "forge-new"]] as const)
874875 if (wanted(`${r}:${s}`)) await runPair(r, s, work[s] ?? null);
876 // Opt-in until the cells have run once on a docker host (docs/features/forge-origin.md).
877 if (only.includes("shapes"))
878 await runShapes({
879 a: { base: addr("forge-new"), user: "ann", rw: seeds["forge-new"]!.ann.rw },
880 b: { base: addr("forge-new2"), user: "ann", rw: seeds["forge-new2"]!.ann.rw },
881 api,
882 git,
883 gitEnv,
884 cell,
885 must,
886 });
875887 await results();
876888 console.log(`${cells.filter((c) => c.verdict === "pass").length} pass, ${cells.filter((c) => c.verdict === "fail").length} fail, ${cells.filter((c) => c.verdict === "absent").length} absent`);
877889 };
scripts/forge-compat-lab/shapes.ts added +199
@@ -0,0 +1,199 @@
1 /**
2 * The sync shapes between two real forges (#9560): a follower, a fork, a
3 * mirror, both ways, several upstreams, a diamond; each walks the sync
4 * contract in src/git/sync_link.ts over real HTTP and real git, and divergence
5 * is shown with the one action that reconciles it. The same shapes run
6 * in-process in test/sync-shapes.test.ts; here the two sides are two forges.
7 * Public repositories, so the fetch needs no credential; pushes use the token.
8 *
9 * UNRUN: these cells have not yet run on a docker host. They run only when
10 * asked for (`LAB_ONLY=shapes`), and the docs say so, until they have.
11 */
12 import { mkdirSync, writeFileSync } from "node:fs";
13 import type { LinkSync } from "../../src/git/sync_link.ts";
14
15 type Json = Record<string, any>; // eslint-disable-line @typescript-eslint/no-explicit-any
16 export interface ShapeDeps {
17 /** Two forges and the owner's seed on each. */
18 readonly a: { base: string; user: string; rw: string };
19 readonly b: { base: string; user: string; rw: string };
20 readonly api: (base: string, path: string, auth: string, init?: { method?: string; body?: unknown }) => Promise<{ status: number; data: Json; text: string }>;
21 readonly git: (args: string[], env: NodeJS.ProcessEnv, cwd?: string) => string;
22 readonly gitEnv: (user: string, token: string) => NodeJS.ProcessEnv;
23 readonly cell: (pair: string, area: string, check: string, run: () => Promise<string | void>) => Promise<void>;
24 readonly must: (ok: unknown, said: string) => void;
25 }
26
27 /**
28 * The contract as the lab image carries it. The driver is mounted at /lab, apart
29 * from the repository, so no relative path reaches src/ at run time; the image
30 * built from the same ref has it at /app/src. The specifier is a variable so
31 * the type-check in the repository does not look for /app, and the cast gives
outdated

A path held in a variable only so tsc does not look for it, plus a cast. The cells it feeds have never run. Run them on a docker host, or say in the docs that they are unproven.

32 * the import the repository's own types.
33 */
34 const SYNC_LINK: string = "/app/src/git/sync_link.ts";
35
36 export const runShapes = async (d: ShapeDeps): Promise<void> => {
37 const { syncLink } = (await import(SYNC_LINK)) as typeof import("../../src/git/sync_link.ts");
38 const pair = "shapes";
39 const stamp = Date.now().toString(36);
40 const dir = `/tmp/shapes-${stamp}`;
41 mkdirSync(dir, { recursive: true });
42 let n = 0;
43 type Site = typeof d.a;
44 /** A public repository on a forge, with one commit, and a working clone of it. */
45 const make = async (site: Site, name: string): Promise<{ site: Site; url: string; work: string }> => {
46 const made = await d.api(site.base, "/api/v1/repos", `Bearer ${site.rw}`, { method: "POST", body: { name, visibility: "public" } });
47 d.must(made.status === 201 || made.status === 200, `create ${name}: ${made.status} ${made.text.slice(0, 100)}`);
48 const work = `${dir}/${name}`;
49 const env = d.gitEnv(site.user, site.rw);
50 d.git(["init", "-q", "-b", "main", work], env);
51 d.git(["remote", "add", "origin", `${site.base}/${site.user}/${name}.git`], env, work);
52 return { site, url: `${site.base}/${site.user}/${name}.git`, work };
53 };
54 type Repo = Awaited<ReturnType<typeof make>>;
55 const commit = (r: Repo, file: string, body: string): string => {
56 const env = d.gitEnv(r.site.user, r.site.rw);
57 writeFileSync(`${r.work}/${file}`, body);
58 d.git(["add", "."], env, r.work);
59 d.git(["commit", "-q", "-m", `${file}`], env, r.work);
60 d.git(["push", "-q", "origin", "HEAD:refs/heads/main"], env, r.work);
61 return d.git(["rev-parse", "HEAD"], env, r.work);
62 };
63 /** The repository's tip, read through the forge. */
64 const tip = (r: Repo): string => d.git(["ls-remote", r.url, "refs/heads/main"], d.gitEnv(r.site.user, r.site.rw)).split(/\s/)[0]!;
65 /** A repository on `site` that starts as a copy of `from` (the shape's fork, follower or mirror). */
66 const copy = (site: Site, name: string, from: Repo): Promise<Repo> =>
67 make(site, name).then((r) => {
68 const env = d.gitEnv(site.user, site.rw);
69 d.git(["fetch", "-q", from.url, "refs/heads/main"], env, r.work);
70 d.git(["checkout", "-q", "-B", "main", "FETCH_HEAD"], env, r.work);
71 d.git(["push", "-q", "origin", "HEAD:refs/heads/main"], env, r.work);
72 return r;
73 });
74 /** The working clone at the forge's tip, so a sync starts from what the forge holds. */
75 const refresh = (r: Repo): void => {
76 const env = d.gitEnv(r.site.user, r.site.rw);
77 d.git(["fetch", "-q", "origin", "refs/heads/main"], env, r.work);
78 d.git(["checkout", "-q", "-B", "main", "FETCH_HEAD"], env, r.work);
79 };
80 /**
81 * One sync of `me` from `from`: fetched into the working clone, moved when a
82 * fast-forward, then pushed to the forge. Returns what the contract said.
83 */
84 const sync = async (me: Repo, from: Repo, stage = `refs/sync/${from.site.base.replace(/\W/g, "")}`): Promise<LinkSync> => {
85 const env = d.gitEnv(me.site.user, me.site.rw);
86 refresh(me);
87 const result = await syncLink(me.work, "refs/heads/main", from.url, "refs/heads/main", stage);
88 d.must(result.ok, `sync ${from.url} -> ${me.url}: ${result.ok ? "" : result.error}`);
89 if (!result.ok) throw new Error("unreachable");
90 if (result.value.moved) {
91 d.git(["reset", "-q", "--hard", "refs/heads/main"], env, me.work);
92 d.git(["push", "-q", "origin", "HEAD:refs/heads/main"], env, me.work);
93 }
94 return result.value;
95 };
96 const id = (): string => `${stamp}-${n++}`;
97
98 await d.cell(pair, "follower", "a repository follows another across forges: taken once, then nothing", async () => {
99 const up = await make(d.a, `up-${id()}`);
100 commit(up, "a", "1\n");
101 const me = await copy(d.b, `follow-${id()}`, up);
102 const t = commit(up, "b", "2\n");
103 const first = await sync(me, up);
104 d.must(first.moved && first.to === t && first.judgement.action === "fast-forward", `first ${JSON.stringify(first.judgement)}`);
105 const again = await sync(me, up);
106 d.must(!again.moved && again.judgement.state === "synced" && tip(me) === t, "second sync was not a no-op");
107 return first.judgement.words;
108 });
109
110 await d.cell(pair, "fork", "a fork of a fork follows down the chain", async () => {
111 const root = await make(d.a, `root-${id()}`);
112 commit(root, "a", "1\n");
113 const org = await copy(d.b, `org-${id()}`, root);
114 const person = await copy(d.a, `person-${id()}`, org);
115 const t = commit(root, "b", "2\n");
116 await sync(org, root);
117 await sync(person, org);
118 d.must(tip(person) === t, "the change did not reach the bottom");
119 });
120
121 await d.cell(pair, "mirror", "a mirror follows and never leads", async () => {
122 const origin = await make(d.a, `origin-${id()}`);
123 commit(origin, "a", "1\n");
124 const mirror = await copy(d.b, `mirror-${id()}`, origin);
125 for (let i = 0; i < 3; i++) commit(origin, `f${i}`, "x\n");
126 const r = await sync(mirror, origin);
127 d.must(r.judgement.behind === 3 && tip(mirror) === tip(origin), `mirror ${JSON.stringify(r.judgement)}`);
128 const back = await sync(origin, mirror);
129 d.must(!back.moved, "the mirror moved its origin");
130 });
131
132 await d.cell(pair, "both ways", "each side's work reaches the other and a loop is not made", async () => {
133 const x = await make(d.a, `ping-${id()}`);
134 commit(x, "base", "1\n");
135 const y = await copy(d.b, `pong-${id()}`, x);
136 const tx = commit(x, "fromA", "1\n");
137 await sync(y, x);
138 const ty = commit(y, "fromB", "1\n");
139 await sync(x, y);
140 d.must(tip(y) === ty && tip(x) === ty && tx !== ty, "the two did not converge");
141 for (let i = 0; i < 3; i++) d.must(!(await sync(y, x)).moved && !(await sync(x, y)).moved, "a settled link moved");
142 });
143
144 await d.cell(pair, "divergence", "both sides see the same divergence and the one action that reconciles it", async () => {
145 const x = await make(d.a, `div-${id()}`);
146 commit(x, "base", "1\n");
147 const y = await copy(d.b, `div-${id()}`, x);
148 commit(x, "c", "A\n");
149 commit(y, "c", "B\n");
150 const fromY = await sync(y, x);
151 const fromX = await sync(x, y);
152 d.must(fromY.judgement.state === "diverged" && fromX.judgement.state === "diverged", "not seen as diverged");
153 d.must(fromY.judgement.action === "keep-both" && fromX.judgement.action === "keep-both", "no reconciling action");
154 d.must(!fromY.moved && !fromX.moved, "divergence moved a branch");
155 d.must(fromY.judgement.conflicts.join() === "c", `conflicts ${fromY.judgement.conflicts}`);
156 return fromY.judgement.words;
157 });
158
159 await d.cell(pair, "several upstreams", "two upstreams: the first lands, the second is reported with its action", async () => {
160 const base = await make(d.a, `base-${id()}`);
161 commit(base, "a", "1\n");
162 const u1 = await copy(d.a, `u1-${id()}`, base);
163 const u2 = await copy(d.b, `u2-${id()}`, base);
164 const me = await copy(d.b, `multi-${id()}`, base);
165 const t1 = commit(u1, "one", "1\n");
166 commit(u2, "two", "2\n");
167 d.must((await sync(me, u1, "refs/sync/u1")).to === t1, "first upstream not taken");
168 const second = await sync(me, u2, "refs/sync/u2");
169 d.must(!second.moved && second.judgement.action === "merge", `second ${JSON.stringify(second.judgement)}`);
170 d.must(tip(me) === t1, "the diverged upstream moved the branch");
171 });
172
173 await d.cell(pair, "diamond", "the same change by two routes lands once", async () => {
174 const top = await make(d.a, `top-${id()}`);
175 commit(top, "a", "1\n");
176 const left = await copy(d.a, `left-${id()}`, top);
177 const right = await copy(d.b, `right-${id()}`, top);
178 const bottom = await copy(d.b, `bottom-${id()}`, top);
179 const t = commit(top, "b", "2\n");
180 await sync(left, top);
181 await sync(right, top);
182 const viaLeft = await sync(bottom, left, "refs/sync/left");
183 const viaRight = await sync(bottom, right, "refs/sync/right");
184 d.must(viaLeft.moved && !viaRight.moved && viaRight.judgement.state === "synced", "landed twice or not at all");
185 d.must(tip(bottom) === t, "bottom is not at the change");
186 });
187
188 await d.cell(pair, "resumable", "a sync cut off by a forge going away finishes on the next try", async () => {
189 const up = await make(d.a, `cut-${id()}`);
190 commit(up, "a", "1\n");
191 const me = await copy(d.b, `cutme-${id()}`, up);
192 const before = tip(me);
193 const t = commit(up, "b", "2\n");
194 refresh(me);
195 const cut = await syncLink(me.work, "refs/heads/main", `${d.a.base}/${d.a.user}/does-not-exist.git`, "refs/heads/main", "refs/sync/cut");
196 d.must(!cut.ok && tip(me) === before, "a failed sync moved the branch");
197 d.must((await sync(me, up)).to === t, "the next try did not finish");
198 });
199 };
src/core/following.ts +31 −8
@@ -31,13 +31,13 @@ import {
3131 import {
3232 fetchInto,
3333 merge,
34 mergeBase,
3534 mergeable,
3635 resolveOid,
3736 writeRef,
3837 type MergeIdentity,
3938 } from "../git/merge.ts";
4039 import { runGit } from "../git/env.ts";
40 import { describeLink, syncLink, type Reconcile } from "../git/sync_link.ts";
4141 import { readTipOrAbsent } from "../git/tip.ts";
4242 import { readTips } from "../git/tips.ts";
4343 import { repoDir } from "../git/repos.ts";
@@ -152,6 +152,20 @@ export function follow(forge: Following): () => void {
152152 * the comparison is memoized on that pair of tips, so a relationship is
153153 * recomputed only when either side moves; the database never holds it.
154154 */
155 const reconcileOf = (
156 state: RelationState,
157 behind: number,
158 ahead: number,
159 clean: boolean,
160 conflicts: readonly string[],
161 ): { action: Reconcile; words: string } => {
162 // Two histories with no common commit count as nothing either way, and are not in sync.
163 if (state === "diverged" && behind === 0 && ahead === 0)
164 return { action: "keep-both", words: "The two sides share no history; keep both and choose which one to follow." };
165 const { action, words } = describeLink(behind, ahead, clean, conflicts);
166 return { action, words };
167 };
168
155169 export type RelationState = "synced" | "behind" | "ahead" | "diverged";
156170
157171 export interface ForkRelation {
@@ -167,6 +181,9 @@ export interface ForkRelation {
167181 readonly conflicts: readonly string[];
168182 readonly forkTip: string;
169183 readonly parentTip: string;
184 /** The one action that reconciles the two, and the words for it: the sync contract's (`describeLink`). */
185 readonly action: Reconcile;
186 readonly words: string;
170187 }
171188
172189 export async function forkRelation(
@@ -202,6 +219,7 @@ export async function forkRelation(
202219 conflicts,
203220 forkTip: forkTip ?? "",
204221 parentTip: parentTip ?? "",
222 ...reconcileOf(state, behind, ahead, clean, conflicts),
205223 });
206224 // An empty side has nothing to follow or to differ by.
207225 if (parentTip === undefined || forkTip === undefined || parentTip === forkTip)
@@ -513,17 +531,22 @@ export async function fastForwardFork(
513531 const from = await resolveOid(forkDir, ref);
514532 if (to === undefined || from === to)
515533 return ok({ updated: false, from: from ?? "", to: to ?? "" });
516 // The parent's objects reach the fork through its alternates, so the
517 // ancestry question is answerable before anything is fetched.
518 if (from !== undefined && (await mergeBase(forkDir, from, to)) !== from)
519 return err("diverged");
520 const tip = await fetchInto(
534 // The contract (`syncLink`): their tip is staged whole, and the fork's
535 // branch moves only by a fast-forward, compare-and-swap. Both paths are
536 // computed here from repository rows, never from text a person typed.
537 const synced = await syncLink(
521538 forkDir,
539 ref,
522540 parentDir,
523541 `refs/heads/${parent.defaultBranch}`,
542 `refs/following/${parent.defaultBranch}`,
524543 );
525 if (tip === undefined || !(await writeRef(forkDir, ref, tip)))
526 return ok({ updated: false, from: from ?? "", to });
544 if (!synced.ok) return ok({ updated: false, from: from ?? "", to });
545 if (!synced.value.moved)
546 return synced.value.judgement.state === "synced"
547 ? ok({ updated: false, from: from ?? "", to })
548 : err("diverged");
549 const tip = synced.value.to;
527550 forge.bus.append({
528551 kind: "push",
529552 repoId: fork.id,
src/http/api/following.ts +3
@@ -82,6 +82,9 @@ export function followingRoutes(router: Router<Handler>): void {
8282 ahead: relation.ahead,
8383 clean: relation.clean,
8484 conflicts: relation.conflicts,
85 // The sync contract's one reconciling action and the words for it (src/git/sync_link.ts).
86 action: relation.action,
87 words: relation.words,
8588 forkTip: relation.forkTip,
8689 parentTip: relation.parentTip,
8790 parentForge: parentForgeFor(ctx.app.config),
src/http/mcp/tools/following.ts +3 −1
@@ -8,7 +8,7 @@ import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts"
88 export const tool: Tool = {
99 name: "following",
1010 doc:
11 "`{ repo }`: for a fork `{ parent, state, behind, ahead, clean, conflicts, parentForge, converge, chain, severedAt, upstream }`, where it stands against what it follows (`state` in step, behind, ahead or diverged; `behind`/`ahead` commits; `clean` whether a merge would apply; `conflicts` the files that clash; `converge` what the crew is doing about a conflict, else `null`; `parentForge`, `chain`, `severedAt` and `upstream` the forge-to-forge link, `null` when there is none); for a repository that is no fork but a spawned forge, only `{ chain, severedAt, upstream }`; refused `not_a_fork` when it follows nothing, `not_found` for a repository the caller may not see",
11 "`{ repo }`: for a fork `{ parent, state, behind, ahead, clean, conflicts, action, words, parentForge, converge, chain, severedAt, upstream }`, where it stands against what it follows (`state` in step, behind, ahead or diverged; `behind`/`ahead` commits; `clean` whether a merge would apply; `conflicts` the files that clash; `action` the one thing that reconciles it (none, fast-forward, offer, merge, keep-both) and `words` it said plainly; `converge` what the crew is doing about a conflict, else `null`; `parentForge`, `chain`, `severedAt` and `upstream` the forge-to-forge link, `null` when there is none); for a repository that is no fork but a spawned forge, only `{ chain, severedAt, upstream }`; refused `not_a_fork` when it follows nothing, `not_found` for a repository the caller may not see",
1212 description:
1313 "Where a fork stands against what it follows: in step, behind, ahead, diverged.",
1414 inputSchema: {
@@ -40,6 +40,8 @@ export const tool: Tool = {
4040 ahead: relation.ahead,
4141 clean: relation.clean,
4242 conflicts: relation.conflicts,
43 action: relation.action,
44 words: relation.words,
4345 parentForge: parentForgeFor(ctx.app.config),
4446 converge: converge?.kind ?? null,
4547 });
src/tui/screens/forks.ts +2 −2
@@ -94,8 +94,8 @@ export function followingRows(
9494 : relation.state === "synced"
9595 ? "in step"
9696 : relation.state === "diverged"
97 ? `diverged · ${counts}${relation.clean ? " · merges cleanly" : ""}`
98 : `${relation.state} · ${counts}`;
97 ? `diverged · ${counts}${relation.clean ? " · merges cleanly" : ""} · ${relation.action}`
98 : `${relation.state} · ${counts}${relation.action === "none" ? "" : ` · ${relation.action}`}`;
9999 return [
100100 {
101101 repo: parent,
src/web/pages/following.ts +2
@@ -368,6 +368,8 @@ export function followingCard(
368368 prose.push(block.sentence);
369369 rows.push(...block.links);
370370 }
371 if (parent.detached === null && relation.action !== "none")
372 prose.push(html`<p class="muted small">${relation.words}</p>`);
371373 if (foot !== null) prose.push(html`<p class="muted small">${foot}</p>`);
372374 if (canAdmin(ctx.app.db, repo, ctx.auth.user) && writer)
373375 prose.push(modeForm(base, following.mode, "How this fork follows its parent"));
test/fork-reconcile.test.ts added +120
@@ -0,0 +1,120 @@
1 /**
2 * The sync contract at the doors a person and an agent use (#9560): a fork's
3 * standing against its parent names the one action that reconciles it (API,
4 * web card, terminal row), and taking the parent's work goes through
5 * `syncLink`, staging the parent's tip and refusing a divergence.
6 *
7 * Nothing here sleeps: a parent commit that must not be followed is written
8 * with git directly (no push event, so no follow-on), and one that is
9 * followed is awaited through the event the follow-on emits.
10 */
11 import { afterAll, beforeAll, expect, test } from "bun:test";
12 import { mkdtemp, rm } from "node:fs/promises";
13 import { tmpdir } from "node:os";
14 import { join } from "node:path";
15 import { createApp, type App } from "../src/app.ts";
16 import { commitToBranch, createRepo, forkRepo } from "../src/actions/repos.ts";
17 import { fastForwardFork, forkRelation } from "../src/core/following.ts";
18 import { getRepo, type Repo } from "../src/core/repos.ts";
19 import { createSession } from "../src/core/sessions.ts";
20 import { createUser, type User } from "../src/core/users.ts";
21 import { runGit } from "../src/git/env.ts";
22 import { repoDir } from "../src/git/repos.ts";
23 import { serve, type Servers } from "../src/http/server.ts";
24 import { followingRows } from "../src/tui/screens/forks.ts";
25 import { quietLogs } from "./helpers/globals.ts";
26
27 quietLogs();
28
29 let dataDir = "";
30 let app: App;
31 let servers: Servers;
32 let alice: User;
33 let cookie = "";
34 let parent: Repo;
35 let fork: Repo;
36 const cells = process.env["FORGE_CELLS"];
37
38 const ok = <T>(r: { ok: true; value: T } | { ok: false; error: unknown }): T => {
39 if (!r.ok) throw new Error(JSON.stringify(r.error));
40 return r.value;
41 };
42 const commit = async (repo: Repo, path: string, content: string) =>
43 ok(await commitToBranch(app, alice, repo, { message: path, files: [{ path, content }] }));
44 /** A commit (same tree, new message) on `repo`'s main written with git alone: no push event, so nothing follows it. */
45 const quietCommit = async (repo: Repo, file: string): Promise<string> => {
46 const dir = repoDir(app.reposRoot, repo.ownerName, repo.name);
47 const g = async (...args: string[]) => {
48 const r = await runGit(["-c", "user.name=t", "-c", "[email protected]", ...args], { cwd: dir });
49 if (!r.ok) throw new Error(r.error.stderr);
50 return r.value.trim();
51 };
52 const base = await g("rev-parse", "refs/heads/main");
53 const made = await g("commit-tree", `${base}^{tree}`, "-p", base, "-m", file);
54 await g("update-ref", "refs/heads/main", made, base);
55 return made;
56 };
57 /** Resolves with the next event of `kind` on `repoId`. */
58 const next = (kind: string, repoId: string): Promise<void> =>
59 new Promise((resolve) => {
60 const off = app.bus.subscribe((e) => {
61 if (e.kind === kind && e.repoId === repoId) {
62 off();
63 resolve();
64 }
65 });
66 });
67 const read = async (): Promise<{ action: string; words: string; state: string }> =>
68 (await (await fetch(`${servers.url}/api/v1/repos/alice/kid/following`, { headers: { cookie } })).json()) as never;
69
70 beforeAll(async () => {
71 process.env["FORGE_CELLS"] = "off";
72 dataDir = await mkdtemp(join(tmpdir(), "forge-fork-reconcile-"));
73 app = createApp({ dataDir, port: 0 });
74 servers = await serve(app);
75 alice = ok(await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true }));
76 cookie = `forge_session=${createSession(app.db, alice.id).token}`;
77 parent = ok(await createRepo(app, alice, { name: "root", visibility: "public" }));
78 await commit(parent, "a.txt", "1\n");
79 fork = ok(await forkRepo(app, alice, parent, "kid"));
80 });
81
82 afterAll(async () => {
83 await servers.stop();
84 app.close();
85 if (cells === undefined) delete process.env["FORGE_CELLS"];
86 else process.env["FORGE_CELLS"] = cells;
87 await rm(dataDir, { recursive: true, force: true });
88 });
89
90 test("every standing names its action, at the API, the terminal row and the web card", async () => {
91 expect(await read()).toMatchObject({ state: "synced", action: "none" });
92 await quietCommit(parent, "b.txt");
93 const behind = await forkRelation(app, getRepo(app.db, "alice", "kid")!);
94 expect(behind).toMatchObject({ state: "behind", action: "fast-forward" });
95 expect(followingRows(behind, undefined)[0]!.words).toContain("fast-forward");
96 const page = await (await fetch(`${servers.url}/alice/kid`, { headers: { cookie } })).text();
97 expect(page).toContain("take them in, nothing of ours is lost");
98 });
99
100 test("taking the parent's work stages its tip, moves once, and refuses a divergence", async () => {
101 fork = getRepo(app.db, "alice", "kid")!;
102 const forkDir = repoDir(app.reposRoot, "alice", "kid");
103 const [first, again] = await Promise.all([
104 fastForwardFork(app, fork, parent, alice.id),
105 fastForwardFork(app, fork, parent, alice.id),
106 ]).then((rs) => rs.map(ok));
107 // Taken at once, they take turns: one moves, the other finds it done.
108 expect([first!.updated, again!.updated].sort()).toEqual([false, true]);
109 const moved = first!.updated ? first! : again!;
110 const staged = await runGit(["rev-parse", "refs/following/main"], { cwd: forkDir });
111 expect(staged.ok && staged.value.trim()).toBe(moved.to);
112 expect(ok(await fastForwardFork(app, fork, parent, alice.id)).updated).toBe(false);
113 await commit(fork, "mine.txt", "x\n");
114 // The parent's push starts the follow-on; it finds the fork diverged and says so.
115 const told = next("fork.diverged", fork.id);
116 await commit(parent, "c.txt", "3\n");
117 await told;
118 expect(await fastForwardFork(app, fork, parent, alice.id)).toEqual({ ok: false, error: "diverged" });
119 expect(await read()).toMatchObject({ state: "diverged", action: "merge" });
120 });
test/sync-shapes.test.ts added +253
@@ -0,0 +1,253 @@
1 /**
2 * The sync contract (src/git/sync_link.ts) walked through every shape a
3 * network takes, with real git between real repositories: a follower, a
4 * fork, a mirror, two forges syncing both ways, several upstreams, and a
5 * diamond. Each shape is also a cell in scripts/forge-compat-lab.sh.
6 */
7 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { runGit } from "../src/git/env.ts";
12 import { judgeLink, syncLink } from "../src/git/sync_link.ts";
13
14 let root = "";
15 const git = async (cwd: string, ...args: string[]): Promise<string> => {
16 const r = await runGit(["-c", "user.name=t", "-c", "[email protected]", "-c", "commit.gpgsign=false", ...args], { cwd });
17 if (!r.ok) throw new Error(r.error.stderr);
18 return r.value.trim();
19 };
20 let n = 0;
21 /** A bare "forge" repository, optionally cloned from another. */
22 const forge = async (from?: string): Promise<string> => {
23 const dir = join(root, `f${n++}.git`);
24 if (from === undefined) {
25 await git(root, "init", "-q", "--bare", "-b", "main", dir);
26 return dir;
27 }
28 await git(root, "clone", "-q", "--bare", from, dir);
29 return dir;
30 };
31 /** A commit on `dir`'s main, written through a throwaway clone. */
32 const commit = async (dir: string, file: string, body: string): Promise<string> => {
33 const work = join(root, `w${n++}`);
34 await git(root, "clone", "-q", dir, work).catch(async () => git(root, "init", "-q", "-b", "main", work));
35 await git(work, "remote", "get-url", "origin").catch(() => git(work, "remote", "add", "origin", dir));
36 await git(work, "checkout", "-q", "-B", "main").catch(() => undefined);
37 await Bun.write(join(work, file), body);
38 await git(work, "add", ".");
39 await git(work, "commit", "-q", "-m", `${file}: ${body.trim()}`);
40 await git(work, "push", "-q", "origin", "HEAD:refs/heads/main");
41 return git(work, "rev-parse", "HEAD");
42 };
43 const tip = (dir: string): Promise<string> => git(dir, "rev-parse", "refs/heads/main");
44 const pull = (me: string, from: string, as = "refs/sync/up") => syncLink(me, "refs/heads/main", from, "refs/heads/main", as);
45 const value = <T>(r: { ok: true; value: T } | { ok: false; error: string }): T => {
46 if (!r.ok) throw new Error(r.error);
47 return r.value;
48 };
49
50 beforeAll(async () => {
51 root = await mkdtemp(join(tmpdir(), "forge-sync-shapes-"));
52 });
53 afterAll(() => rm(root, { recursive: true, force: true }));
54
55 describe("one link", () => {
56 test("behind is taken in once; the second time nothing moves", async () => {
57 const up = await forge();
58 await commit(up, "a", "1\n");
59 const me = await forge(up);
60 const t2 = await commit(up, "b", "2\n");
61 const first = value(await pull(me, up));
62 expect(first).toMatchObject({ moved: true, to: t2 });
63 expect(first.judgement.action).toBe("fast-forward");
64 const again = value(await pull(me, up));
65 expect(again).toMatchObject({ moved: false, from: t2, to: t2 });
66 expect(again.judgement.state).toBe("synced");
67 expect(await tip(me)).toBe(t2);
68 });
69
70 test("syncs at once into one stage ref take turns: one moves, the rest are no-ops, none fails", async () => {
71 const up = await forge();
72 await commit(up, "a", "1\n");
73 const me = await forge(up);
74 const t2 = await commit(up, "b", "2\n");
75 const all = await Promise.all(Array.from({ length: 8 }, () => pull(me, up)));
76 expect(all.every((r) => r.ok)).toBe(true);
77 expect(all.filter((r) => r.ok && r.value.moved)).toHaveLength(1);
78 expect(await tip(me)).toBe(t2);
79 });
80
81 test("a sync cut off halfway leaves the branch and the next try finishes", async () => {
82 const up = await forge();
83 await commit(up, "a", "1\n");
84 const me = await forge(up);
85 const before = await tip(me);
86 const t2 = await commit(up, "b", "2\n");
87 const cut = await syncLink(me, "refs/heads/main", join(root, "gone.git"), "refs/heads/main", "refs/sync/up");
88 expect(cut).toEqual({ ok: false, error: "unreachable" });
89 expect(await tip(me)).toBe(before);
90 expect(value(await pull(me, up)).to).toBe(t2);
91 });
92
93 test("a branch with work of its own is not overwritten", async () => {
94 const up = await forge();
95 await commit(up, "a", "1\n");
96 const me = await forge(up);
97 const old = await tip(me);
98 await commit(up, "b", "2\n");
99 const fresh = await commit(me, "mine", "x\n");
100 const r = value(await pull(me, up));
101 expect(r.moved).toBe(false);
102 expect(r.judgement.state).toBe("diverged");
103 expect(await tip(me)).toBe(fresh);
104 expect(fresh).not.toBe(old);
105 });
106
107 test("ahead says to offer it; diverged says what to do and moves nothing", async () => {
108 const up = await forge();
109 await commit(up, "a", "1\n");
110 const me = await forge(up);
111 await commit(me, "mine", "1\n");
112 const ahead = value(await pull(me, up));
113 expect(ahead.judgement).toMatchObject({ state: "ahead", ahead: 1, action: "offer" });
114 await commit(up, "theirs", "1\n");
115 const clean = value(await pull(me, up)).judgement;
116 expect(clean).toMatchObject({ state: "diverged", clean: true, action: "merge" });
117 expect(clean.words).toContain("1 commit there, 1 commit here");
118 await commit(up, "c", "up\n");
119 await commit(me, "c", "me\n");
120 const clash = value(await pull(me, up));
121 expect(clash.moved).toBe(false);
122 expect(clash.judgement).toMatchObject({ state: "diverged", clean: false, action: "keep-both", conflicts: ["c"] });
123 expect(clash.judgement.words).toContain("c");
124 // Theirs is staged so the one action has what it needs.
125 expect(await git(me, "rev-parse", "refs/sync/up")).toBe(await tip(up));
126 });
127
128 test("an unborn branch is created from their tip", async () => {
129 const up = await forge();
130 const t = await commit(up, "a", "1\n");
131 const me = await forge();
132 const r = value(await pull(me, up));
133 expect(r).toMatchObject({ moved: true, from: "", to: t });
134 expect(await tip(me)).toBe(t);
135 // Created once: the second sync finds it in step.
136 expect(value(await pull(me, up)).moved).toBe(false);
137 });
138
139 test("a branch that moves under the sync is left as it is, and the sync says it raced", async () => {
140 const up = await forge();
141 await commit(up, "a", "1\n");
142 const me = await forge(up);
143 const before = await tip(me);
144 await commit(up, "b", "2\n");
145 // A held lock on our branch is another writer mid-update: the swap loses.
146 await Bun.write(join(me, "refs/heads/main.lock"), "");
147 const r = await pull(me, up);
148 expect(r).toEqual({ ok: false, error: "raced" });
149 await rm(join(me, "refs/heads/main.lock"));
150 expect(await tip(me)).toBe(before);
151 // The next try finishes.
152 expect(value(await pull(me, up)).moved).toBe(true);
153 });
154
155 test("bad refs and sources are refused at the door", async () => {
156 const me = await forge();
157 expect(await syncLink(me, "refs/heads/main", "-evil", "refs/heads/main", "refs/sync/x")).toEqual({ ok: false, error: "bad-ref" });
158 expect(await syncLink(me, "refs/heads/../x", "/x", "refs/heads/main", "refs/sync/x")).toEqual({ ok: false, error: "bad-ref" });
159 expect((await judgeLink(me, "nope", "nope")).action).toBe("keep-both");
160 });
161 });
162
163 describe("the shapes", () => {
164 test("follower and fork: a chain keeps flowing down, once", async () => {
165 const root0 = await forge();
166 await commit(root0, "a", "1\n");
167 const org = await forge(root0);
168 const person = await forge(org);
169 const t = await commit(root0, "b", "2\n");
170 value(await pull(org, root0));
171 value(await pull(person, org));
172 expect(await tip(person)).toBe(t);
173 expect(value(await pull(person, org)).moved).toBe(false);
174 });
175
176 test("mirror: a read-only copy follows and never leads", async () => {
177 const origin = await forge();
178 await commit(origin, "a", "1\n");
179 const mirror = await forge(origin);
180 for (let i = 0; i < 3; i++) await commit(origin, `f${i}`, "x\n");
181 const r = value(await pull(mirror, origin));
182 expect(r.judgement).toMatchObject({ state: "behind", behind: 3 });
183 expect(await tip(mirror)).toBe(await tip(origin));
184 expect(value(await pull(origin, mirror, "refs/sync/mirror")).moved).toBe(false);
185 });
186
187 test("both ways: each side's work reaches the other; a loop is not made", async () => {
188 const a = await forge();
189 await commit(a, "base", "1\n");
190 const b = await forge(a);
191 const ta = await commit(a, "fromA", "1\n");
192 value(await pull(b, a, "refs/sync/a"));
193 expect(await tip(b)).toBe(ta);
194 const tb = await commit(b, "fromB", "1\n");
195 value(await pull(a, b, "refs/sync/b"));
196 expect(await tip(a)).toBe(tb);
197 for (let i = 0; i < 3; i++) {
198 expect(value(await pull(b, a, "refs/sync/a")).moved).toBe(false);
199 expect(value(await pull(a, b, "refs/sync/b")).moved).toBe(false);
200 }
201 expect(await tip(a)).toBe(await tip(b));
202 });
203
204 test("both ways, at once: divergence is seen from both sides with the same action", async () => {
205 const a = await forge();
206 await commit(a, "base", "1\n");
207 const b = await forge(a);
208 await commit(a, "x", "a\n");
209 await commit(b, "y", "b\n");
210 const fromB = value(await pull(b, a, "refs/sync/a")).judgement;
211 const fromA = value(await pull(a, b, "refs/sync/b")).judgement;
212 expect(fromB).toMatchObject({ state: "diverged", action: "merge" });
213 expect(fromA).toMatchObject({ state: "diverged", action: "merge" });
214 expect(fromA.behind).toBe(fromB.ahead);
215 expect(fromA.ahead).toBe(fromB.behind);
216 });
217
218 test("several upstreams: each is taken in turn, and the one that diverges is reported", async () => {
219 const base = await forge();
220 await commit(base, "a", "1\n");
221 const u1 = await forge(base);
222 const u2 = await forge(base);
223 const me = await forge(base);
224 const t1 = await commit(u1, "one", "1\n");
225 await commit(u2, "two", "2\n");
226 expect(value(await pull(me, u1, "refs/sync/u1")).to).toBe(t1);
227 const second = value(await pull(me, u2, "refs/sync/u2"));
228 expect(second.moved).toBe(false);
229 expect(second.judgement).toMatchObject({ state: "diverged", action: "merge" });
230 expect(await tip(me)).toBe(t1);
231 // Each upstream keeps its own stage.
232 expect(await git(me, "rev-parse", "refs/sync/u1")).toBe(t1);
233 expect(await git(me, "rev-parse", "refs/sync/u2")).toBe(await tip(u2));
234 });
235
236 test("diamond: the same change by two routes lands once", async () => {
237 const top = await forge();
238 await commit(top, "a", "1\n");
239 const left = await forge(top);
240 const right = await forge(top);
241 const bottom = await forge(top);
242 const t = await commit(top, "b", "2\n");
243 value(await pull(left, top));
244 value(await pull(right, top));
245 const viaLeft = value(await pull(bottom, left, "refs/sync/left"));
246 const viaRight = value(await pull(bottom, right, "refs/sync/right"));
247 expect(viaLeft.moved).toBe(true);
248 expect(viaRight).toMatchObject({ moved: false });
249 expect(viaRight.judgement.state).toBe("synced");
250 expect(await tip(bottom)).toBe(t);
251 expect(await git(bottom, "rev-list", "--count", "main")).toBe("2");
252 });
253 });
test/tui-following.test.ts +4 −3
@@ -3,6 +3,7 @@ import { mkdtemp, rm } from "node:fs/promises";
33 import { tmpdir } from "node:os";
44 import { join } from "node:path";
55 import { commitToBranch, createRepo, forkRepo } from "../src/actions/repos.ts";
6 import { describeLink } from "../src/git/sync_link.ts";
67 import { forkRelation, type ForkRelation } from "../src/core/following.ts";
78 import { getRepo, type Repo } from "../src/core/repos.ts";
89 import { createUser, type User } from "../src/core/users.ts";
@@ -24,13 +25,13 @@ const must = <T>(r: { ok: true; value: T } | { ok: false; error: unknown }): T =
2425 describe("followingRows", () => {
2526 const parent = { ownerName: "alice", name: "app", detached: null } as unknown as Repo;
2627 const at = (state: ForkRelation["state"], behind: number, ahead: number, clean = true) =>
27 ({ parent, state, behind, ahead, clean, conflicts: [] }) as unknown as ForkRelation;
28 ({ parent, state, behind, ahead, clean, conflicts: [], action: describeLink(behind, ahead, clean, []).action }) as unknown as ForkRelation;
2829
2930 test("a fork behind its parent says by how much, in MCP following's state", () => {
3031 const rows = followingRows(at("behind", 3, 0), undefined);
3132 expect(rows).toHaveLength(1);
3233 expect(rows[0]!.name).toBe("follows alice/app");
33 expect(rows[0]!.words).toBe("behind · 0 ahead · 3 behind");
34 expect(rows[0]!.words).toBe("behind · 0 ahead · 3 behind · fast-forward");
3435 expect(rows[0]!.repo).toBe(parent);
3536 });
3637
@@ -44,7 +45,7 @@ describe("followingRows", () => {
4445 const issue = { number: 7 } as Issue;
4546 const proposed: Converge = { kind: "proposed", issue, pull: { number: 9 } as Pull };
4647 const rows = followingRows(at("diverged", 2, 1, false), proposed);
47 expect(rows.map((r) => r.words)).toEqual(["diverged · 1 ahead · 2 behind", ""]);
48 expect(rows.map((r) => r.words)).toEqual(["diverged · 1 ahead · 2 behind · keep-both", ""]);
4849 expect(rows[1]!.name).toContain("pull #9");
4950 expect(followingRows(at("diverged", 2, 1, false), undefined)).toHaveLength(1);
5051 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs