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

A kept fork's pull is checked on its combined commit, so its green is the green of what lands #9940

Merged opened this

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

Merged

scout/forge:scout/9932-a-kept-fork-s-pull-is-checked-on-its-com → main 4 commits fork 4 threads · 4 unresolved

  • Merged. scout/9932-a-kept-fork-s-pull-is-checked-on-its-com was merged into main as 6459018.

Review

Sign in to review this change.
9 files changed +240 −12
docs/features/upstream-landing.md +3 −3
@@ -24,9 +24,9 @@ The rule lives in `landingKind` (`src/core/landing_kind.ts`) and `mergePull` app
2424
2525 When main has moved since a kept fork's head, the forge merges main into that head in a branch of the base repository, `combined/N` (`src/git/combined.ts`), on opening the pull, on every push to the fork, and again just before landing. The author's branch is never written. Each conflicting block keeps main's side, with no review: the author's side of that block is dropped from the landed tree, and the note says so and how to get it back (merge main into their branch, resolve, push: a head that already has main is its own combined build). A conflict git cannot pick a side of (modify/delete, rename) is left open. The combined commit's message is the note, and its `Combined-Base` and `Combined-Kept` trailers are the facts the pull renders from (`src/core/combined_view.ts`): a "Combined with main" card on the page and the same section on the terminal's pull screen. The preview builds `combined/N`, under the approval of the head it carries (read from git, `headUnder`), and landing merges it, so the author's head stays an ancestor of main. Commits pushed on top of `combined/N` by a writer are kept by the next combining.
2626
27 A push to main alone does not recombine: the card marks itself stale, naming the main commit it was built on, until the next push to the fork or the landing recombines. Checks run on the pull's head, not on the combined commit, and the card says that too.
27 A push to main alone does not recombine: the card marks itself stale, naming the main commit it was built on, until the next push to the fork or the landing recombines. The pull's fast check runs on the combined commit, not the author's head alone (`checks/service.ts`): its log and the checks line say "checked as combined with main at abc1234", the run records that base, and the green is stale only when `combined/N` is built on an older main than the tip. A pull from inside one repository is still checked as its head merged into main. The run stays keyed to the head (`commit_oid`), so head lookups and the merge gate find it; the combined commit it tested is recorded on the run (`combined_oid`), and the checks line reads "as combined" from that, never from the tree, so a check that ran before `combined/N` existed says "against".
2828
29 Test: `test/combined-fork.test.ts` (open, page, terminal, a push recombining, stale, landing) and `test/combined-git.test.ts` (a writer's commit surviving, modify/delete left open, the head a preview runs under).
29 Test: `test/combined-check.test.ts` (the check's source, its words, stale, an in-repository pull unchanged), `test/combined-fork.test.ts` (open, page, terminal, a push recombining, stale, landing) and `test/combined-git.test.ts` (a writer's commit surviving, modify/delete left open, the head a preview runs under).
3030
3131 ## A merge must bring the work
3232
@@ -38,4 +38,4 @@ Test: `test/source-merge-held.test.ts` (two forges, an `-s ours` merge held, a m
3838
3939 ## Not yet
4040
41 Landing a very large fork in reviewed slices that still end as a merge of the author's head; routing a pull opened on the Forgejo mirror to trunk; a crew run sent to refine `combined/N` beyond main's side; recombining when main alone moves; running the check on the combined commit; and running his code under the guest rule until a person approves (#8903 is not in this repository). Resolving conflicts on our side is today's resolve page (`docs/PULLS.md`).
41 Landing a very large fork in reviewed slices that still end as a merge of the author's head; routing a pull opened on the Forgejo mirror to trunk; a crew run sent to refine `combined/N` beyond main's side; recombining when main alone moves and running his code under the guest rule until a person approves (#8903 is not in this repository). Resolving conflicts on our side is today's resolve page (`docs/PULLS.md`).
src/checks/service.ts +22 −5
@@ -38,6 +38,7 @@ import {
3838 noteRunProgress,
3939 overranBefore,
4040 setRunBase,
41 setRunCombined,
4142 vanishedBefore,
4243 VANISHED_SUMMARY,
4344 infraBefore,
@@ -50,6 +51,7 @@ import {
5051 import { Gate, runGit } from "../git/env.ts";
5152 import { resolveOid } from "../git/merge.ts";
5253 import { mergeForCheck } from "./merged.ts";
54 import { readCombined } from "../git/combined.ts";
5355 import { approvedOnce } from "./approved_first.ts";
5456 import { checkOnApproval } from "./approval.ts";
5557 import { checkKey, provenBy, recordProven } from "./proven.ts";
@@ -996,11 +998,26 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle {
996998 tip = await resolveOid(bare, `refs/heads/${x.pull.baseBranch}`);
997999 if (tip === undefined)
9981000 return { ok: false, error: `the base ${x.pull.baseBranch} could not be found` };
999 const merged = await mergeForCheck(bare, x.pull.baseBranch, tip, x.commitOid);
1000 if (!merged.ok) return { ok: false, error: merged.error };
1001 source = merged.commit;
1002 if (x.runId !== undefined) setRunBase(app.db, x.runId, tip);
1003 mergeLine = merged.line;
1001 // A kept fork's pull is checked on its combined commit (#9932): the
1002 // tree that lands, built on the base tip it names (`combined/N`).
1003 const combined = x.pull.crossRepo
1004 ? await readCombined(bare, x.pull.number, x.commitOid)
1005 : undefined;
1006 if (combined !== undefined && combined.oid !== x.commitOid) {
outdated

The run keeps commitOid = head and only the log carries the combined oid. The issue says the run records that oid. Store it (a separate column or field, so checksForHead and the merge gate still find the run by head) and have the page read it from the run.

1007 source = combined.oid;
1008 const builtOn = combined.baseOid === "" ? tip : combined.baseOid;
1009 if (x.runId !== undefined) {
1010 setRunBase(app.db, x.runId, builtOn);
1011 setRunCombined(app.db, x.runId, combined.oid);
1012 }
1013 mergeLine = `checked as combined with ${x.pull.baseBranch} at ${builtOn.slice(0, 7)} (${combined.oid.slice(0, 7)})`;
1014 } else {
1015 const merged = await mergeForCheck(bare, x.pull.baseBranch, tip, x.commitOid);
1016 if (!merged.ok) return { ok: false, error: merged.error };
1017 source = merged.commit;
1018 if (x.runId !== undefined) setRunBase(app.db, x.runId, tip);
1019 mergeLine = merged.line;
1020 }
10041021 }
10051022 const run = { id: x.id, commitOid: source };
10061023 const [forgeJson, packageJson] = await Promise.all([
src/core/combined_view.ts +2 −2
@@ -2,7 +2,7 @@
22 * What a fork's pull shows of its combined branch (#9012), the same on the
33 * page and in the terminal: the branch and commit the preview builds, the
44 * base it was built on and whether main has moved since, what each conflict
5 * kept, and that the checks ran on the head, not on this commit.
5 * kept.
66 */
77
88 import type { Pull } from "./pulls.ts";
@@ -48,7 +48,7 @@ export function combinedWords(
4848 notes.push(
4949 `Stale: main has moved since ${view.builtOn.slice(0, 10)}, which this was built on. It recombines on the next push to the fork, and merging recombines before it lands, so what lands may differ from the preview.`,
5050 );
51 notes.push("Checks run on the pull's head, not on this combined commit.");
51 notes.push("The checks line says which commit the check ran on.");
outdated

Line is far over the file width; wrap it. This note also says "its base" when builtOn is empty, which tells the reader nothing. Say plainly what was checked.

5252 if (view.files.some((f) => f.kept === "main")) notes.push(TAKE_BACK);
5353 return {
5454 lead: `The preview is built from ${view.branch} (${view.oid.slice(0, 10)}): main merged into this pull's head. Merging lands that branch.`,
src/core/migration-pins/20261109040000-run-combined-oid.pin added +1
@@ -0,0 +1,1 @@
1 61a68637ab82942e42b41bbf86f4cee20f2b9699615e8dec0dcb7f0b9331ff35
src/core/migrations/20261109040000-run-combined-oid.ts added +6
@@ -0,0 +1,6 @@
1 // The combined commit a kept fork's pull check ran on (#9932). The run stays
2 // keyed to the head (`commit_oid`) so head lookups and the merge gate find it;
3 // this says what it tested. Empty for any other run and for runs before.
4 export default `
5 ALTER TABLE run ADD COLUMN combined_oid TEXT NOT NULL DEFAULT '';
6 `;
src/core/runs.ts +9
@@ -94,6 +94,8 @@ export interface Run {
9494 readonly checkDetail: CheckDetail | null;
9595 /** The base tip a pull's check merged the head with; empty when it merged nothing. */
9696 readonly baseOid: string;
97 /** The combined commit a kept fork's pull check tested; empty when it tested another tree. */
98 readonly combinedOid: string;
9799 /** The variant group this run belongs to; null for an ordinary run. */
98100 readonly variantGroup: string | null;
99101 readonly createdAt: string;
@@ -125,6 +127,7 @@ interface RunRow {
125127 sandbox_note: string;
126128 check_detail: string;
127129 base_oid: string;
130 combined_oid: string;
128131 placement: string;
129132 node: string;
130133 variant_group: string | null;
@@ -159,6 +162,7 @@ const toRun = (r: RunRow): Run => ({
159162 node: r.node,
160163 checkDetail: r.check_detail ? (JSON.parse(r.check_detail) as CheckDetail) : null,
161164 baseOid: r.base_oid,
165 combinedOid: r.combined_oid,
162166 variantGroup: r.variant_group,
163167 createdAt: r.created_at,
164168 startedAt: r.started_at,
@@ -348,6 +352,11 @@ export function setRunBase(db: Database, runId: string, baseOid: string): void {
348352 db.query(`UPDATE run SET base_oid = ?2 WHERE id = ?1`).run(runId, baseOid);
349353 }
350354
355 /** Record the combined commit a kept fork's pull check tests (#9932). */
356 export function setRunCombined(db: Database, runId: string, combinedOid: string): void {
357 db.query(`UPDATE run SET combined_oid = ?2 WHERE id = ?1`).run(runId, combinedOid);
358 }
359
351360 /** Cancel a run that has not finished. Returns the row, or undefined if it had. */
352361 export function cancelRun(
353362 db: Database,
src/web/pages/pull.ts +1 −1
@@ -268,7 +268,7 @@ export function checksLine(
268268 run !== null && run.baseOid !== "" && baseBranch !== ""
269269 ? html`<span class="sep" aria-hidden="true">·</span
270270 ><span class="checked-against"
271 >checked against ${baseBranch} at ${run.baseOid.slice(0, 7)}</span
271 >checked ${run.combinedOid !== "" ? "as combined with" : "against"} ${baseBranch} at ${run.baseOid.slice(0, 7)}</span
272272 >`
273273 : html``;
274274 const body =
test/combined-check.test.ts added +195
@@ -0,0 +1,195 @@
1 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2 import { mkdtemp, rm } from "node:fs/promises";
3 import { tmpdir } from "node:os";
4 import { join } from "node:path";
5 import { createApp, type App } from "../src/app.ts";
6 import { serve, type Servers } from "../src/http/server.ts";
7 import { startChecks, type ChecksHandle } from "../src/checks/service.ts";
8 import { checkStale } from "../src/checks/stale.ts";
9 import { getPull } from "../src/core/pulls.ts";
10 import { getRepo } from "../src/core/repos.ts";
11 import { checksForHead, getRun } from "../src/core/runs.ts";
12 import { readRunLog } from "../src/runs/log.ts";
13 import { gitClientEnv } from "./helpers/git-client.ts";
14 import { proveAll } from "./helpers/verified.ts";
15 import { promote } from "./helpers/members.ts";
16 import { tokenFor } from "./helpers/tokens.ts";
17 import { quietLogs } from "./helpers/globals.ts";
18
19 quietLogs();
20
21 const gitEnv = gitClientEnv({
22 GIT_AUTHOR_NAME: "Jay Moran",
23 GIT_AUTHOR_EMAIL: "[email protected]",
24 GIT_COMMITTER_NAME: "Jay Moran",
25 GIT_COMMITTER_EMAIL: "[email protected]",
26 });
27
28 describe("a kept fork's pull is checked on its combined commit (#9932)", () => {
29 let dataDir = "";
30 let work = "";
31 let app: App;
32 let checks: ChecksHandle;
33 let servers: Servers;
34 let base = "";
35 let alice = "";
36 let bob = "";
37 const cells = process.env["FORGE_CELLS"];
38
39 async function git(args: string[], cwd: string): Promise<string> {
40 const p = Bun.spawn(["git", ...args], { cwd, env: gitEnv, stdout: "pipe", stderr: "pipe" });
41 const [out, err, code] = await Promise.all([
42 new Response(p.stdout).text(),
43 new Response(p.stderr).text(),
44 p.exited,
45 ]);
46 if (code !== 0) throw new Error(`git ${args.join(" ")}: ${err}`);
47 return out.trim();
48 }
49 const call = (path: string, token: string, body?: unknown): Promise<Response> =>
50 fetch(`${base}${path}`, {
51 method: body === undefined ? "GET" : "POST",
52 headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
53 ...(body === undefined ? {} : { body: JSON.stringify(body) }),
54 });
55 const authed = (user: string, token: string) => base.replace("http://", `http://${user}:${token}@`);
56 async function signup(username: string): Promise<string> {
57 await fetch(`${base}/signup`, {
58 method: "POST",
59 body: new URLSearchParams({ username, email: `${username}@f.dev`, password: "kept-fork-pass" }),
60 redirect: "manual",
61 });
62 return tokenFor(app, username, "admin", "k");
63 }
64 /** Ask for a check and wait for it to finish; its run and log. */
65 async function checked(number: number): Promise<{ id: string; state: string; log: string; baseOid: string }> {
66 const res = await call(`/api/v1/repos/alice/app/pulls/${number}/checks`, alice, {});
67 expect(res.status).toBe(202);
68 const { run } = (await res.json()) as { run: { id: string } };
69 for (let i = 0; i < 400; i++) {
70 const now = getRun(app.db, run.id)!;
71 if (now.state !== "queued" && now.state !== "running") {
72 // An approval or a push queued a check of its own first: this one may be
73 // proven by it, so the log read is of every check run at this head.
74 const pull = getPull(app.db, getRepo(app.db, "alice", "app")!.id, number)!;
75 const all = checksForHead(app.db, pull.id, now.commitOid);
76 return {
77 id: run.id,
78 state: now.state,
79 baseOid: all.findLast((r) => r.baseOid !== "")?.baseOid ?? "",
80 log: all.map((r) => readRunLog(dataDir, r.id).lines.join("\n")).join("\n"),
81 };
82 }
83 await Bun.sleep(50);
84 }
85 throw new Error("the check did not finish");
86 }
87
88 beforeAll(async () => {
89 process.env["FORGE_CELLS"] = "off";
90 dataDir = await mkdtemp(join(tmpdir(), "forge-cc-"));
91 work = await mkdtemp(join(tmpdir(), "forge-cc-work-"));
92 app = createApp({ dataDir, port: 0 });
93 checks = startChecks(app, { forgeUrl: "http://127.0.0.1:1", concurrency: 1 });
94 servers = await serve(app);
95 base = servers.url;
96 alice = await signup("alice");
97 bob = await signup("bob");
98 proveAll(app);
99 promote(app.db);
100 await call("/api/v1/repos", alice, { name: "app", visibility: "public" });
101 await git(["clone", `${authed("alice", alice)}/alice/app.git`, "a"], work);
102 await Bun.write(join(work, "a/one.txt"), "one\n");
103 await Bun.write(join(work, "a/forge.json"), JSON.stringify({ check: { run: "test -f two.txt" } }));
104 await git(["add", "."], join(work, "a"));
105 await git(["commit", "-m", "seed"], join(work, "a"));
106 await git(["push", "origin", "main"], join(work, "a"));
107 await call("/api/v1/repos/alice/app/forks", bob, {});
108 });
109
110 afterAll(async () => {
111 await checks.stop();
112 servers.stop();
113 app.close();
114 if (cells === undefined) delete process.env["FORGE_CELLS"];
115 else process.env["FORGE_CELLS"] = cells;
116 await rm(dataDir, { recursive: true, force: true });
117 await rm(work, { recursive: true, force: true });
118 });
119
120 test("the fork's check runs on combined/N; a pull inside the repository on its head", async () => {
121 const a = join(work, "a");
122 const b = join(work, "b");
123 await git(["clone", `${authed("bob", bob)}/bob/app.git`, "b"], work);
124 await Bun.write(join(b, "one.txt"), "bob\n");
125 await Bun.write(join(b, "bob.txt"), "bob\n");
126 await git(["add", "."], b);
127 await git(["commit", "-am", "bob's one"], b);
128 await git(["push", "origin", "main"], b);
129
130 // Main moves on after the fork: it gains two.txt, which the check needs.
131 await Bun.write(join(a, "one.txt"), "alice\n");
132 await Bun.write(join(a, "two.txt"), "two\n");
133 await git(["add", "."], a);
134 await git(["commit", "-m", "alice moves on"], a);
135 await git(["push", "origin", "main"], a);
136 const aliceMain = await git(["rev-parse", "HEAD"], a);
137
138 const opened = await call("/api/v1/repos/alice/app/pulls", bob, {
139 title: "Jay's work",
140 base: "main",
141 head: "main",
142 headRepo: "bob/app",
143 });
144 expect(opened.status).toBe(201);
145 const { number } = (await opened.json()) as { number: number };
146 const bare = join(app.reposRoot, "alice", "app.git");
147 const tip = await git(["rev-parse", `refs/heads/combined/${number}`], bare).catch(() => "");
148 expect(tip).not.toBe("");
149
150 // A stranger's head runs once a writer approves it.
151 expect((await call(`/api/v1/repos/alice/app/pulls/${number}/review-states`, alice, { state: "approved", body: "" })).status).toBe(201);
152 const fork = await checked(number);
153 // two.txt exists only in the combined tree: green here proves it was the one checked.
154 expect(fork.state).toBe("ok");
155 expect(fork.log).toContain(`checked as combined with main at ${aliceMain.slice(0, 7)}`);
156 expect(fork.log).toContain(`(${tip.slice(0, 7)})`);
157 expect(fork.baseOid).toBe(aliceMain);
158 // The run is still found by the head, and records the commit it tested.
159 const pullRow = getPull(app.db, getRepo(app.db, "alice", "app")!.id, number)!;
160 const runs = checksForHead(app.db, pullRow.id, pullRow.headOid);
161 expect(runs.some((r) => r.combinedOid === tip && r.commitOid === pullRow.headOid)).toBe(true);
162 expect(checkStale({ baseOid: fork.baseOid }, aliceMain)).toBe(false);
163 const page = await (await call(`/alice/app/pulls/${number}`, alice)).text();
164 expect(page).toContain(`checked as combined with main at ${aliceMain.slice(0, 7)}`);
165 expect(page).not.toContain("Checks run on the pull&#39;s head");
166
167 // Main moves on alone: the green is of an older base now.
168 await Bun.write(join(a, "four.txt"), "four\n");
169 await git(["add", "."], a);
170 await git(["commit", "-m", "alice's four"], a);
171 await git(["push", "origin", "main"], a);
172 const moved = await git(["rev-parse", "HEAD"], a);
173 expect(checkStale({ baseOid: fork.baseOid }, moved)).toBe(true);
174
175 // A branch of the base repository itself is checked on its head, merged into main.
176 await git(["checkout", "-b", "inside"], a);
177 await Bun.write(join(a, "inside.txt"), "x\n");
178 await git(["add", "."], a);
179 await git(["commit", "-m", "inside"], a);
180 await git(["push", "origin", "inside"], a);
181 const inside = await call("/api/v1/repos/alice/app/pulls", alice, {
182 title: "inside",
183 base: "main",
184 head: "inside",
185 });
186 expect(inside.status).toBe(201);
187 const inNumber = ((await inside.json()) as { number: number }).number;
188 const own = await checked(inNumber);
189 expect(own.log).toContain("merge of ");
190 expect(own.log).not.toContain("checked as combined");
191 const inRow = getPull(app.db, getRepo(app.db, "alice", "app")!.id, inNumber)!;
192 expect(checksForHead(app.db, inRow.id, inRow.headOid).every((r) => r.combinedOid === "")).toBe(true);
193 expect(own.baseOid).toBe(moved);
194 }, 60_000);
195 });
test/combined-fork.test.ts +1 −1
@@ -139,7 +139,7 @@ describe("a conflicting fork pull is combined on our side (#9012)", () => {
139139
140140 expect(body).toContain("your side was dropped from the landed tree");
141141 expect(body).toContain("To get your side back");
142 expect(body).toContain("Checks run on the pull&#39;s head, not on this combined commit.");
142 expect(body).toContain("The checks line says which commit the check ran on.");
143143 expect(body).not.toContain("Stale: main has moved");
144144
145145 // The terminal's pull screen says the same (#9012).

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs