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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/9932-a-kept-fork-s-pull-is-checked-on-its-com → main 4 commits fork 4 threads · 4 unresolved
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 | ||
| 24 | 24 | |
| 25 | 25 | 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. |
| 26 | 26 | |
| 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". | |
| 28 | 28 | |
| 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). | |
| 30 | 30 | |
| 31 | 31 | ## A merge must bring the work |
| 32 | 32 | |
| @@ -38,4 +38,4 @@ Test: `test/source-merge-held.test.ts` (two forges, an `-s ours` merge held, a m | ||
| 38 | 38 | |
| 39 | 39 | ## Not yet |
| 40 | 40 | |
| 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 { | ||
| 38 | 38 | noteRunProgress, |
| 39 | 39 | overranBefore, |
| 40 | 40 | setRunBase, |
| 41 | setRunCombined, | |
| 41 | 42 | vanishedBefore, |
| 42 | 43 | VANISHED_SUMMARY, |
| 43 | 44 | infraBefore, |
| @@ -50,6 +51,7 @@ import { | ||
| 50 | 51 | import { Gate, runGit } from "../git/env.ts"; |
| 51 | 52 | import { resolveOid } from "../git/merge.ts"; |
| 52 | 53 | import { mergeForCheck } from "./merged.ts"; |
| 54 | import { readCombined } from "../git/combined.ts"; | |
| 53 | 55 | import { approvedOnce } from "./approved_first.ts"; |
| 54 | 56 | import { checkOnApproval } from "./approval.ts"; |
| 55 | 57 | import { checkKey, provenBy, recordProven } from "./proven.ts"; |
| @@ -996,11 +998,26 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle { | ||
| 996 | 998 | tip = await resolveOid(bare, `refs/heads/${x.pull.baseBranch}`); |
| 997 | 999 | if (tip === undefined) |
| 998 | 1000 | 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) { | |
| 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 | } | |
| 1004 | 1021 | } |
| 1005 | 1022 | const run = { id: x.id, commitOid: source }; |
| 1006 | 1023 | const [forgeJson, packageJson] = await Promise.all([ |
src/core/combined_view.ts +2 −2
| @@ -2,7 +2,7 @@ | ||
| 2 | 2 | * What a fork's pull shows of its combined branch (#9012), the same on the |
| 3 | 3 | * page and in the terminal: the branch and commit the preview builds, the |
| 4 | 4 | * 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. | |
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | 8 | import type { Pull } from "./pulls.ts"; |
| @@ -48,7 +48,7 @@ export function combinedWords( | ||
| 48 | 48 | notes.push( |
| 49 | 49 | `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.`, |
| 50 | 50 | ); |
| 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. |
||
| 52 | 52 | if (view.files.some((f) => f.kept === "main")) notes.push(TAKE_BACK); |
| 53 | 53 | return { |
| 54 | 54 | 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 { | ||
| 94 | 94 | readonly checkDetail: CheckDetail | null; |
| 95 | 95 | /** The base tip a pull's check merged the head with; empty when it merged nothing. */ |
| 96 | 96 | readonly baseOid: string; |
| 97 | /** The combined commit a kept fork's pull check tested; empty when it tested another tree. */ | |
| 98 | readonly combinedOid: string; | |
| 97 | 99 | /** The variant group this run belongs to; null for an ordinary run. */ |
| 98 | 100 | readonly variantGroup: string | null; |
| 99 | 101 | readonly createdAt: string; |
| @@ -125,6 +127,7 @@ interface RunRow { | ||
| 125 | 127 | sandbox_note: string; |
| 126 | 128 | check_detail: string; |
| 127 | 129 | base_oid: string; |
| 130 | combined_oid: string; | |
| 128 | 131 | placement: string; |
| 129 | 132 | node: string; |
| 130 | 133 | variant_group: string | null; |
| @@ -159,6 +162,7 @@ const toRun = (r: RunRow): Run => ({ | ||
| 159 | 162 | node: r.node, |
| 160 | 163 | checkDetail: r.check_detail ? (JSON.parse(r.check_detail) as CheckDetail) : null, |
| 161 | 164 | baseOid: r.base_oid, |
| 165 | combinedOid: r.combined_oid, | |
| 162 | 166 | variantGroup: r.variant_group, |
| 163 | 167 | createdAt: r.created_at, |
| 164 | 168 | startedAt: r.started_at, |
| @@ -348,6 +352,11 @@ export function setRunBase(db: Database, runId: string, baseOid: string): void { | ||
| 348 | 352 | db.query(`UPDATE run SET base_oid = ?2 WHERE id = ?1`).run(runId, baseOid); |
| 349 | 353 | } |
| 350 | 354 | |
| 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 | ||
| 351 | 360 | /** Cancel a run that has not finished. Returns the row, or undefined if it had. */ |
| 352 | 361 | export function cancelRun( |
| 353 | 362 | db: Database, |
src/web/pages/pull.ts +1 −1
| @@ -268,7 +268,7 @@ export function checksLine( | ||
| 268 | 268 | run !== null && run.baseOid !== "" && baseBranch !== "" |
| 269 | 269 | ? html`<span class="sep" aria-hidden="true">·</span |
| 270 | 270 | ><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 | |
| 272 | 272 | >` |
| 273 | 273 | : html``; |
| 274 | 274 | 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'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)", () => { | ||
| 139 | 139 | |
| 140 | 140 | expect(body).toContain("your side was dropped from the landed tree"); |
| 141 | 141 | expect(body).toContain("To get your side back"); |
| 142 | expect(body).toContain("Checks run on the pull's head, not on this combined commit."); | |
| 142 | expect(body).toContain("The checks line says which commit the check ran on."); | |
| 143 | 143 | expect(body).not.toContain("Stale: main has moved"); |
| 144 | 144 | |
| 145 | 145 | // The terminal's pull screen says the same (#9012). |
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.