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

Merged opened this

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

Merged

scout/forge:scout/9043-after-a-fork-s-pull-lands-in-slices-the → main 1 commit fork 1 thread · 1 unresolved

  • Merged. scout/9043-after-a-fork-s-pull-lands-in-slices-the was merged into main as 137dec4.

Review

Sign in to review this change.
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

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.

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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs