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

A failed recombine leaves the combined card stale with the failure's sentence, and the docs stop saying main alone does not recombine #10069

Merged opened this

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

Merged

scout/forge:scout/9995-a-failed-recombine-leaves-the-combined-c → main 3 commits fork 3 threads · 3 unresolved

  • Merged. scout/9995-a-failed-recombine-leaves-the-combined-c was merged into main as a0cd5a3.

Review

Sign in to review this change.
5 files changed +85 −11
docs/features/upstream-landing.md +2 −2
@@ -24,7 +24,7 @@ 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. 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".
27 A push to main alone recombines every open kept-fork pull in the background (`core/recombine.ts`, started by the post-receive hook after it answers). When a rebuild fails the card stays stale, naming the main commit it was built on, and says what to do in one fixed sentence (the detail of an unexpected error goes to the log, never the page); any successful `combine()` (a push to main or to the fork, or the landing) clears the line. Main is merged into the head with its side winning every block, so a conflict never fails a rebuild; only a missing head or a git failure does, and the sentence then says to push to the fork to rebuild. 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
2929 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
@@ -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 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; 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/core/combined_view.ts +7 −2
@@ -6,7 +6,7 @@
66 */
77
88 import type { Pull } from "./pulls.ts";
9 import { keptLine, readCombined, TAKE_BACK, type Kept } from "../git/combined.ts";
9 import { keptLine, readCombined, readFailure, TAKE_BACK, type Kept } from "../git/combined.ts";
1010
1111 export interface CombinedView {
1212 readonly branch: string;
@@ -16,6 +16,8 @@ export interface CombinedView {
1616 /** Main has moved past `builtOn`: the preview is not what lands until it recombines. */
1717 readonly stale: boolean;
1818 readonly files: readonly Kept[];
19 /** Why the last rebuild after a push to main failed; "" when it did not, or a later one succeeded. */
20 readonly failure: string;
1921 }
2022
2123 /** The combined branch of an open fork's pull, when it differs from the head. */
@@ -29,11 +31,13 @@ export async function combinedView(
2931 const read = await readCombined(dir, pull.number, headOid);
3032 // Main already in the head: the preview is the head itself, nothing to say.
3133 if (read === undefined || read.oid === headOid) return undefined;
34 const stale = read.baseOid !== "" && read.baseOid !== baseOid;
3235 return {
36 failure: stale ? ((await readFailure(dir, pull.number)) ?? "") : "",
3337 branch: read.branch,
3438 oid: read.oid,
3539 builtOn: read.baseOid,
36 stale: read.baseOid !== "" && read.baseOid !== baseOid,
40 stale,
3741 files: read.files,
3842 };
3943 }
@@ -48,6 +52,7 @@ export function combinedWords(
4852 notes.push(
4953 `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.`,
5054 );
55 if (view.failure !== "") notes.push(`${view.failure} The preview stays on the older main until a rebuild succeeds.`);
5156 notes.push("The checks line says which commit the check ran on.");
5257 if (view.files.some((f) => f.kept === "main")) notes.push(TAKE_BACK);
5358 return {
src/core/recombine.ts +16 −7
@@ -7,7 +7,7 @@
77 */
88
99 import type { Database } from "bun:sqlite";
10 import { combine } from "../git/combined.ts";
10 import { combine, setFailure } from "../git/combined.ts";
1111 import { repoDir } from "../git/repos.ts";
1212 import { resolveOid } from "../git/merge.ts";
1313 import type { Log } from "../kernel/log.ts";
@@ -35,6 +35,9 @@ export async function idle(repoId: string): Promise<void> {
3535 await running.get(repoId)?.done;
3636 }
3737
38 /** What the page says of an error nobody expected: the detail stays in the log. */
39 const UNEXPECTED = "The rebuild failed unexpectedly; the forge's log has the detail. Push to the fork to rebuild.";
40
3841 /** One pass: the tip is read once, each pull combined against it. */
3942 async function pass(forge: RecombineForge, repoId: string): Promise<void> {
4043 const repo = getRepoById(forge.db, repoId);
@@ -50,14 +53,20 @@ async function pass(forge: RecombineForge, repoId: string): Promise<void> {
5053 const headOid = await resolveOid(dir, `refs/pull/${pull.number}/head`);
outdated

Clearing belongs in combine() so that fork pushes and landing clear it too; otherwise a stale old sentence reappears on the next main push.

5154 const made =
5255 headOid === undefined ? undefined : await combine(dir, { number: pull.number, baseOid, headOid });
53 if (made === undefined)
56 if (made === undefined) {
5457 forge.log.warn("recombine skipped a pull", { repo: repoId, pull: pull.number });
58 await setFailure(
59 dir,
60 pull.number,
61 headOid === undefined
62 ? "The rebuild failed: the pull's head is missing from the repository. Push to the fork again to rebuild."
63 : "The rebuild failed: git could not merge main into the pull's head. Push to the fork to rebuild it.",
64 );
65 }
5566 } catch (e) {
outdated

Do not put the raw error message on a page; use a fixed sentence and keep the detail in the log.

56 forge.log.warn("recombine failed on a pull", {
57 repo: repoId,
58 pull: pull.number,
59 error: e instanceof Error ? e.message : String(e),
60 });
67 const error = e instanceof Error ? e.message : String(e);
68 forge.log.warn("recombine failed on a pull", { repo: repoId, pull: pull.number, error });
69 await setFailure(dir, pull.number, UNEXPECTED).catch(() => undefined);
6170 }
6271 }
6372 }
src/git/combined.ts +30
@@ -119,6 +119,16 @@ async function mergeTree(
119119 export async function combine(
120120 dir: string,
121121 input: { readonly number: number; readonly baseOid: string; readonly headOid: string },
122 ): Promise<{ oid: string; files: Kept[] | null } | undefined> {
123 const made = await build(dir, input);
124 // A rebuild that worked ends any earlier failure's sentence, whoever asked for it.
125 if (made !== undefined) await setFailure(dir, input.number, undefined);
126 return made;
127 }
128
129 async function build(
130 dir: string,
131 input: { readonly number: number; readonly baseOid: string; readonly headOid: string },
122132 ): Promise<{ oid: string; files: Kept[] | null } | undefined> {
123133 const { number, baseOid, headOid } = input;
124134 if (!Number.isInteger(number) || number < 1 || !isSafeOid(baseOid) || !isSafeOid(headOid))
@@ -227,3 +237,23 @@ export async function previewHead(
227237 const combined = pull.crossRepo ? await combinedTip(dir, pull.number, head) : undefined;
228238 return { commit: combined ?? head, head };
229239 }
240
241 const failureKey = (number: number): string => `recombine.failed${number}`;
242
243 /** Remember (or, with undefined, forget) why the last rebuild of this pull failed: one line, in the repository's own config. */
244 export async function setFailure(dir: string, number: number, sentence: string | undefined): Promise<void> {
245 if (!Number.isInteger(number) || number < 1) return;
246 if (sentence === undefined) await runGit(["config", "--local", "--unset-all", failureKey(number)], { cwd: dir });
247 else {
248 const one = sentence.replace(/\s+/g, " ").trim().slice(0, 300);
249 await runGit(["config", "--local", failureKey(number), one], { cwd: dir });
250 }
251 }
252
253 /** Why the last rebuild of this pull failed, when it did and nothing has rebuilt since. */
254 export async function readFailure(dir: string, number: number): Promise<string | undefined> {
255 if (!Number.isInteger(number) || number < 1) return undefined;
256 const out = await runGit(["config", "--local", "--get", failureKey(number)], { cwd: dir });
257 const line = out.ok ? out.value.trim() : "";
258 return line === "" ? undefined : line;
259 }
test/combined-recombine.test.ts +30
@@ -8,6 +8,7 @@ import { serve, type Servers } from "../src/http/server.ts";
88 import { gitClientEnv } from "./helpers/git-client.ts";
99 import { proveAll } from "./helpers/verified.ts";
1010 import { promote } from "./helpers/members.ts";
11 import { combine, readFailure } from "../src/git/combined.ts";
1112 import { idle, recombine, runsStarted } from "../src/core/recombine.ts";
1213 import { getRepo } from "../src/core/repos.ts";
1314 import { repoDir } from "../src/git/repos.ts";
@@ -179,3 +180,32 @@ test("a push to another branch, or a delete of the default branch, starts no pas
179180 await idle(repoId);
180181 expect(runsStarted(repoId)).toBe(runs + 1);
181182 });
183
184 test("a failed rebuild is worded on the pull's combined card, and any good rebuild clears it", async () => {
185 const card = async (): Promise<string> =>
186 (await (await call(`/alice/app/pulls/${first}`, alice)).text()).replace(/&#39;/g, "'");
187 const sentence = "The rebuild failed: the pull's head is missing from the repository.";
outdated

Relies on an earlier test having deleted the head ref; set that up here.

188 const head = await git(["rev-parse", "refs/heads/one"], b);
189 // This test makes its own state: a combined branch built on one main, the head ref then gone.
190 await git(["update-ref", `refs/pull/${first}/head`, head], bare);
191 await settled(await moveMain("f1.txt"));
192 await git(["update-ref", "-d", `refs/pull/${first}/head`], bare);
193 await moveMain("m5.txt");
194 await idle(repoId);
195 const failed = await card();
196 expect(failed).toContain("Combined with main");
197 expect(failed).toContain("stale");
198 expect(failed).toContain(sentence);
199 expect(failed).toContain("Push to the fork again to rebuild.");
200 // A push to the fork rebuilds through combine() alone, not the recombine pass.
201 await git(["update-ref", `refs/pull/${first}/head`, head], bare);
202 const main = await git(["rev-parse", "refs/heads/main"], bare);
203 expect(await combine(bare, { number: first, baseOid: main, headOid: head })).toBeDefined();
204 expect(await readFailure(bare, first)).toBeUndefined();
205 // Main moves again: the old sentence does not come back.
206 await moveMain("m6.txt");
207 await idle(repoId);
208 const fixed = await card();
209 expect(fixed).not.toContain(sentence);
210 expect(fixed).not.toContain("stale");
211 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs