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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/9560-compat-lab-sync-shapes-idempotent-resuma → main 10 commits fork 2 threads · 2 unresolved
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/ | ||
| 44 | 44 | |
| 45 | 45 | 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`. |
| 46 | 46 | |
| 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 | ||
| 47 | 59 | ## A downstream forge as a peer (#9451) |
| 48 | 60 | |
| 49 | 61 | 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 @@ | ||
| 6 | 6 | */ |
| 7 | 7 | import { spawnSync } from "node:child_process"; |
| 8 | 8 | import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; |
| 9 | import { runShapes } from "./shapes.ts"; | |
| 9 | 10 | |
| 10 | 11 | type Seeded = { cookie: string; ro: string; rw: string; admin: string; revokable: { id: string; token: string } }; |
| 11 | 12 | type Seed = Record<"ann" | "rick" | "boss" | "ona" | "pat" | "uma" | "dee", Seeded>; |
| @@ -872,6 +873,17 @@ const main = async (): Promise<void> => { | ||
| 872 | 873 | } |
| 873 | 874 | for (const [r, s] of [["forge-new", "forge-old"], ["forge-old", "forge-new"], ["forge-new2", "forge-new"]] as const) |
| 874 | 875 | 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 | }); | |
| 875 | 887 | await results(); |
| 876 | 888 | 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`); |
| 877 | 889 | }; |
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 | |
| 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 { | ||
| 31 | 31 | import { |
| 32 | 32 | fetchInto, |
| 33 | 33 | merge, |
| 34 | mergeBase, | |
| 35 | 34 | mergeable, |
| 36 | 35 | resolveOid, |
| 37 | 36 | writeRef, |
| 38 | 37 | type MergeIdentity, |
| 39 | 38 | } from "../git/merge.ts"; |
| 40 | 39 | import { runGit } from "../git/env.ts"; |
| 40 | import { describeLink, syncLink, type Reconcile } from "../git/sync_link.ts"; | |
| 41 | 41 | import { readTipOrAbsent } from "../git/tip.ts"; |
| 42 | 42 | import { readTips } from "../git/tips.ts"; |
| 43 | 43 | import { repoDir } from "../git/repos.ts"; |
| @@ -152,6 +152,20 @@ export function follow(forge: Following): () => void { | ||
| 152 | 152 | * the comparison is memoized on that pair of tips, so a relationship is |
| 153 | 153 | * recomputed only when either side moves; the database never holds it. |
| 154 | 154 | */ |
| 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 | ||
| 155 | 169 | export type RelationState = "synced" | "behind" | "ahead" | "diverged"; |
| 156 | 170 | |
| 157 | 171 | export interface ForkRelation { |
| @@ -167,6 +181,9 @@ export interface ForkRelation { | ||
| 167 | 181 | readonly conflicts: readonly string[]; |
| 168 | 182 | readonly forkTip: string; |
| 169 | 183 | 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; | |
| 170 | 187 | } |
| 171 | 188 | |
| 172 | 189 | export async function forkRelation( |
| @@ -202,6 +219,7 @@ export async function forkRelation( | ||
| 202 | 219 | conflicts, |
| 203 | 220 | forkTip: forkTip ?? "", |
| 204 | 221 | parentTip: parentTip ?? "", |
| 222 | ...reconcileOf(state, behind, ahead, clean, conflicts), | |
| 205 | 223 | }); |
| 206 | 224 | // An empty side has nothing to follow or to differ by. |
| 207 | 225 | if (parentTip === undefined || forkTip === undefined || parentTip === forkTip) |
| @@ -513,17 +531,22 @@ export async function fastForwardFork( | ||
| 513 | 531 | const from = await resolveOid(forkDir, ref); |
| 514 | 532 | if (to === undefined || from === to) |
| 515 | 533 | 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( | |
| 521 | 538 | forkDir, |
| 539 | ref, | |
| 522 | 540 | parentDir, |
| 523 | 541 | `refs/heads/${parent.defaultBranch}`, |
| 542 | `refs/following/${parent.defaultBranch}`, | |
| 524 | 543 | ); |
| 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; | |
| 527 | 550 | forge.bus.append({ |
| 528 | 551 | kind: "push", |
| 529 | 552 | repoId: fork.id, |
src/git/sync_link.ts added +164
| @@ -0,0 +1,164 @@ | ||
| 1 | /** | |
| 2 | * The sync contract between two repositories that follow one another, for | |
| 3 | * every shape a network takes: a follower, a fork, a mirror, two forges | |
| 4 | * syncing both ways, a forge with several upstreams, a diamond. | |
| 5 | * | |
| 6 | * One link is one question and one action: | |
| 7 | * `judgeLink` where do the two tips stand, and what is the one action | |
| 8 | * that reconciles them; | |
| 9 | * `syncLink` bring their tip in, and move our branch only when that is a | |
| 10 | * fast-forward. | |
| 11 | * | |
| 12 | * Idempotent: the same tip taken twice is the second time a no-op, whichever | |
| 13 | * route it came by (a diamond lands once). Resumable: objects arrive by a | |
| 14 | * fetch into a staging ref that git writes whole or not at all, and the | |
| 15 | * branch moves by a compare-and-swap, so a sync cut off at any point leaves | |
| 16 | * the branch where it was and the next try finishes. Divergence never moves | |
| 17 | * a branch: it is staged and reported, with the action that reconciles it. | |
| 18 | * | |
| 19 | * Plain data in, plain data out; `source` is a path or URL the forge | |
| 20 | * computed (never user text) and refs are checked at the door. | |
| 21 | */ | |
| 22 | ||
| 23 | import { runGit } from "./env.ts"; | |
| 24 | import { isSafeOid, isSafeRef } from "./guard.ts"; | |
| 25 | import { mergeable, mergeBase } from "./merge.ts"; | |
| 26 | ||
| 27 | export type LinkState = "synced" | "behind" | "ahead" | "diverged"; | |
| 28 | ||
| 29 | /** What reconciles a link: nothing, take theirs, offer ours, merge, or keep both and decide. */ | |
| 30 | export type Reconcile = "none" | "fast-forward" | "offer" | "merge" | "keep-both"; | |
| 31 | ||
| 32 | export interface LinkJudgement { | |
| 33 | readonly state: LinkState; | |
| 34 | /** Commits on their side we do not have. */ | |
| 35 | readonly behind: number; | |
| 36 | /** Commits on our side they do not have. */ | |
| 37 | readonly ahead: number; | |
| 38 | /** For `diverged`: git merges the two without a decision. */ | |
| 39 | readonly clean: boolean; | |
| 40 | /** For `diverged` and not clean: files both sides changed. */ | |
| 41 | readonly conflicts: readonly string[]; | |
| 42 | readonly action: Reconcile; | |
| 43 | /** Plain words for both sides to read. */ | |
| 44 | readonly words: string; | |
| 45 | } | |
| 46 | ||
| 47 | const plural = (n: number, one: string): string => `${n} ${one}${n === 1 ? "" : "s"}`; | |
| 48 | ||
| 49 | /** | |
| 50 | * The state, the one action that reconciles it, and the words both sides read, | |
| 51 | * from counts alone: the contract with no git in it, so a fork's relation here | |
| 52 | * and a link between forges say the same thing. | |
| 53 | */ | |
| 54 | export function describeLink( | |
| 55 | behind: number, | |
| 56 | ahead: number, | |
| 57 | clean: boolean, | |
| 58 | conflicts: readonly string[], | |
| 59 | ): LinkJudgement { | |
| 60 | const at = (state: LinkState, action: Reconcile, words: string): LinkJudgement => ({ state, behind, ahead, clean, conflicts, action, words }); | |
| 61 | if (behind === 0 && ahead === 0) return at("synced", "none", "In sync: both sides are at the same commit."); | |
| 62 | if (ahead === 0) return at("behind", "fast-forward", `Behind by ${plural(behind, "commit")}: take them in, nothing of ours is lost.`); | |
| 63 | if (behind === 0) return at("ahead", "offer", `Ahead by ${plural(ahead, "commit")}: they do not have it yet; offer it as a pull.`); | |
| 64 | const counts = `${plural(behind, "commit")} there, ${plural(ahead, "commit")} here`; | |
| 65 | if (clean) return at("diverged", "merge", `Diverged (${counts}) but git merges the two cleanly: merge them.`); | |
| 66 | return at( | |
| 67 | "diverged", | |
| 68 | "keep-both", | |
| 69 | `Diverged (${counts}) and ${conflicts.length === 0 ? "git cannot merge them" : `both changed ${conflicts.join(", ")}`}: keep both and decide.`, | |
| 70 | ); | |
| 71 | } | |
| 72 | ||
| 73 | /** Where two tips stand, both already present in `dir`. */ | |
| 74 | export async function judgeLink(dir: string, ours: string, theirs: string): Promise<LinkJudgement> { | |
| 75 | const unknown = (words: string): LinkJudgement => ({ state: "diverged", behind: 0, ahead: 0, clean: false, conflicts: [], action: "keep-both", words }); | |
| 76 | if (!isSafeOid(ours) || !isSafeOid(theirs)) return unknown("A tip could not be read, so nothing was moved."); | |
| 77 | if (ours === theirs) return describeLink(0, 0, true, []); | |
| 78 | const count = async (range: string): Promise<number> => { | |
| 79 | const r = await runGit(["rev-list", "--count", range], { cwd: dir }); | |
| 80 | return r.ok ? Number(r.value) || 0 : 0; | |
| 81 | }; | |
| 82 | if ((await mergeBase(dir, ours, theirs)) === undefined) return unknown("The two sides share no history; keep both and choose which one to follow."); | |
| 83 | const [behind, ahead] = await Promise.all([count(`${ours}..${theirs}`), count(`${theirs}..${ours}`)]); | |
| 84 | if (ahead === 0 || behind === 0) return describeLink(behind, ahead, true, []); | |
| 85 | const merged = await mergeable(dir, ours, theirs); | |
| 86 | if (merged.ok) return describeLink(behind, ahead, true, []); | |
| 87 | return describeLink(behind, ahead, false, merged.error.kind === "conflict" ? merged.error.files : []); | |
| 88 | } | |
| 89 | ||
| 90 | export interface LinkSync { | |
| 91 | /** Whether our branch moved. */ | |
| 92 | readonly moved: boolean; | |
| 93 | readonly from: string; | |
| 94 | readonly to: string; | |
| 95 | readonly judgement: LinkJudgement; | |
| 96 | /** Where their tip is held in our repository, for the action that follows. */ | |
| 97 | readonly staged: string; | |
| 98 | } | |
| 99 | ||
| 100 | export type LinkError = "bad-ref" | "unreachable" | "raced"; | |
| 101 | ||
| 102 | export type LinkResult = { ok: true; value: LinkSync } | { ok: false; error: LinkError }; | |
| 103 | ||
| 104 | /** | |
| 105 | * Bring `source`'s `theirRef` into the repository at `dir` and move `ourRef` | |
| 106 | * to it when that is a fast-forward (or our branch is unborn). Their tip is | |
| 107 | * always held at `stageRef`, moved or not, so the one action that reconciles | |
| 108 | * a divergence has what it needs. Syncs to the same stage ref wait their turn. | |
| 109 | */ | |
| 110 | /** | |
| 111 | * Syncs into one repository's stage ref, one at a time. Two at once would | |
| 112 | * force-fetch the same ref and swap the same branch, and the loser would come | |
| 113 | * back as a refused lock or a lost swap that is no fault of the link; queued, | |
| 114 | * the second finds the first's work done and is the no-op it should be. | |
| 115 | * Keyed by repository and stage ref; the forge is one process. | |
| 116 | */ | |
| 117 | const queues = new Map<string, Promise<unknown>>(); | |
| 118 | ||
| 119 | export function syncLink( | |
| 120 | dir: string, | |
| 121 | ourRef: string, | |
| 122 | source: string, | |
| 123 | theirRef: string, | |
| 124 | stageRef: string, | |
| 125 | ): Promise<LinkResult> { | |
| 126 | const key = `${dir}\0${stageRef}`; | |
| 127 | const run = (queues.get(key) ?? Promise.resolve()).then(() => syncOnce(dir, ourRef, source, theirRef, stageRef)); | |
| 128 | const settled = run.catch(() => undefined); | |
| 129 | queues.set(key, settled); | |
| 130 | void settled.then(() => { | |
| 131 | if (queues.get(key) === settled) queues.delete(key); | |
| 132 | }); | |
| 133 | return run; | |
| 134 | } | |
| 135 | ||
| 136 | async function syncOnce( | |
| 137 | dir: string, | |
| 138 | ourRef: string, | |
| 139 | source: string, | |
| 140 | theirRef: string, | |
| 141 | stageRef: string, | |
| 142 | ): Promise<LinkResult> { | |
| 143 | if (![ourRef, theirRef, stageRef].every(isSafeRef) || !stageRef.startsWith("refs/") || source.startsWith("-") || source === "") return { ok: false, error: "bad-ref" }; | |
| 144 | const fetched = await runGit(["fetch", "--no-tags", "--quiet", source, `+${theirRef}:${stageRef}`], { cwd: dir, timeoutMs: 120_000 }); | |
| 145 | if (!fetched.ok) return { ok: false, error: "unreachable" }; | |
| 146 | const read = async (ref: string): Promise<string | undefined> => { | |
| 147 | const r = await runGit(["rev-parse", "--verify", "--quiet", `${ref}^{commit}`], { cwd: dir }); | |
| 148 | return r.ok ? r.value.trim() : undefined; | |
| 149 | }; | |
| 150 | const to = await read(stageRef); | |
| 151 | if (to === undefined) return { ok: false, error: "unreachable" }; | |
| 152 | const from = await read(ourRef); | |
| 153 | if (from === undefined) { | |
| 154 | const made = await runGit(["update-ref", ourRef, to, "0".repeat(40)], { cwd: dir }); | |
| 155 | if (!made.ok) return { ok: false, error: "raced" }; | |
| 156 | return { ok: true, value: { moved: true, from: "", to, judgement: await judgeLink(dir, to, to), staged: stageRef } }; | |
| 157 | } | |
| 158 | const judgement = await judgeLink(dir, from, to); | |
| 159 | if (judgement.state !== "behind") return { ok: true, value: { moved: false, from, to: from, judgement, staged: stageRef } }; | |
| 160 | // Compare-and-swap: if our branch moved since it was read, it is left as it is. | |
| 161 | const swapped = await runGit(["update-ref", ourRef, to, from], { cwd: dir }); | |
| 162 | if (!swapped.ok) return { ok: false, error: "raced" }; | |
| 163 | return { ok: true, value: { moved: true, from, to, judgement, staged: stageRef } }; | |
| 164 | } |
src/http/api/following.ts +3
| @@ -82,6 +82,9 @@ export function followingRoutes(router: Router<Handler>): void { | ||
| 82 | 82 | ahead: relation.ahead, |
| 83 | 83 | clean: relation.clean, |
| 84 | 84 | 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, | |
| 85 | 88 | forkTip: relation.forkTip, |
| 86 | 89 | parentTip: relation.parentTip, |
| 87 | 90 | 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" | ||
| 8 | 8 | export const tool: Tool = { |
| 9 | 9 | name: "following", |
| 10 | 10 | 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", | |
| 12 | 12 | description: |
| 13 | 13 | "Where a fork stands against what it follows: in step, behind, ahead, diverged.", |
| 14 | 14 | inputSchema: { |
| @@ -40,6 +40,8 @@ export const tool: Tool = { | ||
| 40 | 40 | ahead: relation.ahead, |
| 41 | 41 | clean: relation.clean, |
| 42 | 42 | conflicts: relation.conflicts, |
| 43 | action: relation.action, | |
| 44 | words: relation.words, | |
| 43 | 45 | parentForge: parentForgeFor(ctx.app.config), |
| 44 | 46 | converge: converge?.kind ?? null, |
| 45 | 47 | }); |
src/tui/screens/forks.ts +2 −2
| @@ -94,8 +94,8 @@ export function followingRows( | ||
| 94 | 94 | : relation.state === "synced" |
| 95 | 95 | ? "in step" |
| 96 | 96 | : 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}`}`; | |
| 99 | 99 | return [ |
| 100 | 100 | { |
| 101 | 101 | repo: parent, |
src/web/pages/following.ts +2
| @@ -368,6 +368,8 @@ export function followingCard( | ||
| 368 | 368 | prose.push(block.sentence); |
| 369 | 369 | rows.push(...block.links); |
| 370 | 370 | } |
| 371 | if (parent.detached === null && relation.action !== "none") | |
| 372 | prose.push(html`<p class="muted small">${relation.words}</p>`); | |
| 371 | 373 | if (foot !== null) prose.push(html`<p class="muted small">${foot}</p>`); |
| 372 | 374 | if (canAdmin(ctx.app.db, repo, ctx.auth.user) && writer) |
| 373 | 375 | 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"; | ||
| 3 | 3 | import { tmpdir } from "node:os"; |
| 4 | 4 | import { join } from "node:path"; |
| 5 | 5 | import { commitToBranch, createRepo, forkRepo } from "../src/actions/repos.ts"; |
| 6 | import { describeLink } from "../src/git/sync_link.ts"; | |
| 6 | 7 | import { forkRelation, type ForkRelation } from "../src/core/following.ts"; |
| 7 | 8 | import { getRepo, type Repo } from "../src/core/repos.ts"; |
| 8 | 9 | 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 = | ||
| 24 | 25 | describe("followingRows", () => { |
| 25 | 26 | const parent = { ownerName: "alice", name: "app", detached: null } as unknown as Repo; |
| 26 | 27 | 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; | |
| 28 | 29 | |
| 29 | 30 | test("a fork behind its parent says by how much, in MCP following's state", () => { |
| 30 | 31 | const rows = followingRows(at("behind", 3, 0), undefined); |
| 31 | 32 | expect(rows).toHaveLength(1); |
| 32 | 33 | 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"); | |
| 34 | 35 | expect(rows[0]!.repo).toBe(parent); |
| 35 | 36 | }); |
| 36 | 37 | |
| @@ -44,7 +45,7 @@ describe("followingRows", () => { | ||
| 44 | 45 | const issue = { number: 7 } as Issue; |
| 45 | 46 | const proposed: Converge = { kind: "proposed", issue, pull: { number: 9 } as Pull }; |
| 46 | 47 | 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", ""]); | |
| 48 | 49 | expect(rows[1]!.name).toContain("pull #9"); |
| 49 | 50 | expect(followingRows(at("diverged", 2, 1, false), undefined)).toHaveLength(1); |
| 50 | 51 | }); |
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.