Public: anyone can read this; the crew can change it.
After a fork's pull lands in slices, the author's `git merge main` is clean, proven with two forges #9054
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/9043-after-a-fork-s-pull-lands-in-slices-the → main 1 commit fork 1 thread · 1 unresolved
1 file
changed +225
−0
test/pull-slices-two-forges.test.ts added +225
| @@ -0,0 +1,225 @@ | ||
| 1 | /** | |
| 2 | * After a fork's pull lands in slices, the author's `git merge main` is | |
| 3 | * clean (#9043), proven with two forges: the author works on forge B, whose | |
| 4 | * repository is a fork of forge A's, and proposes to A through a fork there. | |
| 5 | * A lands the pull in two slices; the author's clone then merges A's main | |
| 6 | * with every one of their commits already an ancestor of it, and pushes the | |
| 7 | * result back to B without forcing. | |
| 8 | */ | |
| 9 | ||
| 10 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 11 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 12 | import { tmpdir } from "node:os"; | |
| 13 | import { join } from "node:path"; | |
| 14 | import { createApp, type App } from "../src/app.ts"; | |
| 15 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 16 | import { gitClientEnv } from "./helpers/git-client.ts"; | |
| 17 | import { proveAll } from "./helpers/verified.ts"; | |
| 18 | import { promote } from "./helpers/members.ts"; | |
| 19 | import { tokenFor } from "./helpers/tokens.ts"; | |
| 20 | import { quietLogs } from "./helpers/globals.ts"; | |
| 21 | ||
| 22 | quietLogs(); | |
| 23 | ||
| 24 | const gitEnv = gitClientEnv({ | |
| 25 | GIT_AUTHOR_NAME: "Jay Moran", | |
| 26 | GIT_AUTHOR_EMAIL: "[email protected]", | |
| 27 | GIT_COMMITTER_NAME: "Jay Moran", | |
| 28 | GIT_COMMITTER_EMAIL: "[email protected]", | |
| 29 | }); | |
| 30 | ||
| 31 | async function run(args: string[], cwd: string): Promise<{ ok: boolean; out: string; err: string }> { | |
| 32 | const p = Bun.spawn(["git", ...args], { cwd, env: gitEnv, stdout: "pipe", stderr: "pipe" }); | |
| 33 | const [out, err, code] = await Promise.all([ | |
| 34 | new Response(p.stdout).text(), | |
| 35 | new Response(p.stderr).text(), | |
| 36 | p.exited, | |
| 37 | ]); | |
| 38 | return { ok: code === 0, out: out.trim(), err }; | |
| 39 | } | |
| 40 | ||
| 41 | async function git(args: string[], cwd: string): Promise<string> { | |
| 42 | const r = await run(args, cwd); | |
| 43 | if (!r.ok) throw new Error(`git ${args.join(" ")}: ${r.err}`); | |
| 44 | return r.out; | |
| 45 | } | |
| 46 | ||
| 47 | /** | |
| 48 | * The author's side, as the issue states it: every commit of theirs is an | |
| 49 | * ancestor of `main`, and `git merge main` in their clone is a fast-forward | |
| 50 | * or a clean merge. Answers why not, or "" when it holds. | |
| 51 | */ | |
| 52 | async function authorMergeIsClean(clone: string, main: string, mine: readonly string[]): Promise<string> { | |
| 53 | for (const oid of mine) { | |
| 54 | if (!(await run(["merge-base", "--is-ancestor", oid, main], clone)).ok) | |
| 55 | return `${oid.slice(0, 7)} is not an ancestor of main: a slice rewrote it`; | |
| 56 | } | |
| 57 | const merged = await run(["merge", "--no-edit", main], clone); | |
| 58 | if (!merged.ok) { | |
| 59 | await run(["merge", "--abort"], clone); | |
| 60 | return `git merge main did not finish: ${merged.err.trim()}`; | |
| 61 | } | |
| 62 | return ""; | |
| 63 | } | |
| 64 | ||
| 65 | interface Forge { | |
| 66 | readonly app: App; | |
| 67 | readonly servers: Servers; | |
| 68 | readonly url: string; | |
| 69 | } | |
| 70 | ||
| 71 | describe("a fork's pull lands in slices, across two forges (#9043)", () => { | |
| 72 | let dir = ""; | |
| 73 | let a: Forge; | |
| 74 | let b: Forge; | |
| 75 | const tokens = new Map<string, string>(); | |
| 76 | ||
| 77 | const start = async (name: string): Promise<Forge> => { | |
| 78 | const app = createApp({ dataDir: join(dir, name), port: 0 }); | |
| 79 | const servers = await serve(app); | |
| 80 | return { app, servers, url: servers.url }; | |
| 81 | }; | |
| 82 | const signup = async (f: Forge, username: string): Promise<string> => { | |
| 83 | await fetch(`${f.url}/signup`, { | |
| 84 | method: "POST", | |
| 85 | body: new URLSearchParams({ username, email: `${username}@f.dev`, password: "two-forge-slice-pass" }), | |
| 86 | redirect: "manual", | |
| 87 | }); | |
| 88 | proveAll(f.app); | |
| 89 | promote(f.app.db); | |
| 90 | const token = tokenFor(f.app, username, "admin", "k"); | |
| 91 | tokens.set(`${f.url} ${username}`, token); | |
| 92 | return token; | |
| 93 | }; | |
| 94 | const call = (f: Forge, user: string, path: string, body?: unknown): Promise<Response> => | |
| 95 | fetch(`${f.url}${path}`, { | |
| 96 | method: body === undefined ? "GET" : "POST", | |
| 97 | headers: { authorization: `Bearer ${tokens.get(`${f.url} ${user}`)}`, "content-type": "application/json" }, | |
| 98 | ...(body === undefined ? {} : { body: JSON.stringify(body) }), | |
| 99 | }); | |
| 100 | const remote = (f: Forge, user: string, path: string) => | |
| 101 | `${f.url.replace("http://", `http://${user}:${tokens.get(`${f.url} ${user}`)}@`)}/${path}.git`; | |
| 102 | ||
| 103 | beforeAll(async () => { | |
| 104 | dir = await mkdtemp(join(tmpdir(), "forge-sl2-")); | |
| 105 | a = await start("a"); | |
| 106 | b = await start("b"); | |
| 107 | await signup(a, "alice"); | |
| 108 | await signup(a, "bob"); | |
| 109 | await signup(b, "bob"); | |
| 110 | ||
| 111 | // Forge A: alice's repository, one commit. | |
| 112 | await call(a, "alice", "/api/v1/repos", { name: "app", visibility: "public" }); | |
| 113 | await git(["clone", remote(a, "alice", "alice/app"), "alice"], dir); | |
| 114 | await Bun.write(join(dir, "alice/one.txt"), "one\n"); | |
| 115 | await git(["add", "."], join(dir, "alice")); | |
| 116 | await git(["commit", "-m", "seed"], join(dir, "alice")); | |
| 117 | await git(["push", "origin", "main"], join(dir, "alice")); | |
| 118 | ||
| 119 | // Forge B: bob's repository is a fork of A's, the same history. | |
| 120 | await call(b, "bob", "/api/v1/repos", { name: "app", visibility: "public" }); | |
| 121 | await git(["clone", remote(a, "bob", "alice/app"), "bob"], dir); | |
| 122 | const clone = join(dir, "bob"); | |
| 123 | await git(["remote", "rename", "origin", "a"], clone); | |
| 124 | await git(["remote", "add", "origin", remote(b, "bob", "bob/app")], clone); | |
| 125 | await git(["push", "origin", "main"], clone); | |
| 126 | // And on A, the fork a pull comes from. | |
| 127 | expect((await call(a, "bob", "/api/v1/repos/alice/app/forks", {})).status).toBeLessThan(300); | |
| 128 | await git(["remote", "add", "fork", remote(a, "bob", "bob/app")], clone); | |
| 129 | }); | |
| 130 | ||
| 131 | afterAll(async () => { | |
| 132 | for (const f of [a, b]) { | |
| 133 | f?.servers.stop(); | |
| 134 | f?.app.close(); | |
| 135 | } | |
| 136 | await rm(dir, { recursive: true, force: true }); | |
| 137 | }); | |
| 138 | ||
| 139 | test("two slices land the author's commits as they are, and their merge of main is clean", async () => { | |
| 140 | const clone = join(dir, "bob"); | |
| 141 | const mine: string[] = []; | |
| 142 | for (const n of ["x", "y", "z"]) { | |
| 143 | await Bun.write(join(clone, `${n}.txt`), `${n}\n`); | |
| 144 | await git(["add", "."], clone); | |
| 145 | await git(["commit", "-m", `add ${n}`], clone); | |
| 146 | mine.push(await git(["rev-parse", "HEAD"], clone)); | |
| 147 | } | |
| 148 | await git(["push", "origin", "main"], clone); | |
| 149 | await git(["push", "fork", "main:work"], clone); | |
| 150 | const opened = await call(a, "bob", "/api/v1/repos/alice/app/pulls", { | |
| 151 | title: "Work from forge B", | |
| 152 | base: "main", | |
| 153 | head: "work", | |
| 154 | headRepo: "bob/app", | |
| 155 | }); | |
| 156 | expect(opened.status).toBe(201); | |
| 157 | const { number } = (await opened.json()) as { number: number }; | |
| 158 | ||
| 159 | // A moves on meanwhile, so each slice is a true merge. | |
| 160 | const upstream = join(dir, "alice"); | |
| 161 | await Bun.write(join(upstream, "two.txt"), "two\n"); | |
| 162 | await git(["add", "."], upstream); | |
| 163 | await git(["commit", "-m", "alice moves on"], upstream); | |
| 164 | await git(["push", "origin", "main"], upstream); | |
| 165 | ||
| 166 | const slice = (upTo: string) => | |
| 167 | call(a, "alice", `/api/v1/repos/alice/app/pulls/${number}/slices`, { upTo }); | |
| 168 | const first = await slice(mine[1]!); | |
| 169 | expect(first.status).toBe(200); | |
| 170 | expect(await first.json()).toMatchObject({ last: false, left: 1 }); | |
| 171 | const last = await slice(mine[2]!); | |
| 172 | expect(last.status).toBe(200); | |
| 173 | expect(await last.json()).toMatchObject({ last: true, left: 0 }); | |
| 174 | ||
| 175 | // Each slice merged one of the author's commits as it was. | |
| 176 | await git(["pull", "--no-rebase", "origin", "main"], upstream); | |
| 177 | const merges = (await git(["log", "--first-parent", "--merges", "--format=%P", "main"], upstream)) | |
| 178 | .split("\n") | |
| 179 | .map((line) => line.split(" ")[1]!); | |
| 180 | expect(merges.length).toBeGreaterThanOrEqual(2); | |
| 181 | expect(merges.at(-1)).toBe(mine[1]!); | |
| 182 | ||
| 183 | // The author has gone on locally too: the merge is a real one, and clean. | |
| 184 | await Bun.write(join(clone, "w.txt"), "w\n"); | |
| 185 | await git(["add", "."], clone); | |
| 186 | await git(["commit", "-m", "add w"], clone); | |
| 187 | const before = await git(["rev-parse", "HEAD"], clone); | |
| 188 | await git(["fetch", "a", "main"], clone); | |
| 189 | expect(await authorMergeIsClean(clone, "a/main", mine)).toBe(""); | |
| 190 | const parents = (await git(["log", "-1", "--format=%P"], clone)).split(" "); | |
| 191 | expect(parents).toEqual([before, await git(["rev-parse", "a/main"], clone)]); | |
| 192 | // Nothing of A's main is a rewrite of the author's: no second copy of z's change. | |
| 193 | expect(await git(["log", "--format=%H", "-S", "z", "--", "z.txt"], clone)).toBe(mine[2]!); | |
| 194 | ||
| 195 | // And forge B's fork takes the result without a force. | |
| 196 | await git(["push", "origin", "main"], clone); | |
| 197 | expect(await git(["ls-remote", "origin", "main"], clone)).toStartWith(await git(["rev-parse", "HEAD"], clone)); | |
| 198 | }); | |
| 199 | ||
| 200 | test("the check fails when a slice is squashed or rebased in place of merged", async () => { | |
| 201 | const clone = join(dir, "bob"); | |
| 202 | for (const n of ["p", "q"]) { | |
| 203 | await Bun.write(join(clone, `${n}.txt`), `${n}\n`); | |
| 204 | await git(["add", "."], clone); | |
| 205 | await git(["commit", "-m", `add ${n}`], clone); | |
| 206 | } | |
| 207 | const mine = [await git(["rev-parse", "HEAD~1"], clone), await git(["rev-parse", "HEAD"], clone)]; | |
| 208 | const upstream = join(dir, "alice"); | |
| 209 | ||
| 210 | // A squash: the author's two commits as one new commit on main. | |
| 211 | await git(["checkout", "-b", "squashed", "main"], upstream); | |
| 212 | await git(["fetch", join(dir, "bob"), "main"], upstream); | |
| 213 | await git(["merge", "--squash", "FETCH_HEAD"], upstream); | |
| 214 | await git(["commit", "-m", "squash"], upstream); | |
| 215 | await git(["fetch", upstream, "squashed:refs/remotes/rewritten/squashed"], clone); | |
| 216 | expect(await authorMergeIsClean(clone, "rewritten/squashed", mine)).toContain("not an ancestor"); | |
| 217 | ||
| 218 | // A rebase: the same commits, copied onto main with new ids. | |
| 219 | await git(["checkout", "-b", "rebased", "main"], upstream); | |
| 220 | await git(["cherry-pick", ...mine], upstream); | |
| 221 | await git(["fetch", upstream, "rebased:refs/remotes/rewritten/rebased"], clone); | |
| 222 | expect(await authorMergeIsClean(clone, "rewritten/rebased", mine)).toContain("not an ancestor"); | |
| 223 | await git(["checkout", "main"], upstream); | |
| 224 | }); | |
| 225 | }); |
new above
offline
This test checks authorMergeIsClean against hand-built squash/rebase branches, not against the slicer. Box 2 is really met by test 1 (ancestor check on real slices); this one only guards the helper. Fine, but it relies on clone state from test 1 — build its own.