Public: anyone can read this; the crew can change it.
Four more ways a landing goes wrong, found by the landing simulator: stale co… #12181
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/12151-four-more-ways-a-landing-goes-wrong-foun → main 4 commits fork
12 files
changed +225
−43
docs/features/combined-rebuild-stopped.md added +13
| @@ -0,0 +1,13 @@ | ||
| 1 | # A rebuild that stopped | |
| 2 | ||
| 3 | A kept fork's pull is built as main merged into its head (`combined/N`). When main deletes, renames or changes the mode of a file the pull also changed, no side can be picked for it, and nothing lands that nobody chose (#12151). The rebuild stops and the pull says so: | |
| 4 | ||
| 5 | - "The rebuild stopped: gone.txt was deleted, renamed or changed in a way that conflicts with main, and no side is picked for it. Push to the fork with it resolved." | |
| 6 | - If `combined/N` holds only the forge's own combinings, it is deleted: the card reads "Not combined with main" and the pull answers as its head does (conflicting with main). | |
| 7 | - If it carries someone's own commits, it is kept as it was, and the card reads "Combined with main · stale" with the sentence under it. | |
| 8 | - Other stops word themselves: "The rebuild failed: the pull's head is missing…", "…git could not merge main into the pull's head", and "The rebuild failed unexpectedly" (the detail is in the log). A good rebuild clears the sentence. | |
| 9 | - A landing rebuilds the other kept forks' branches as a push to main does; passes never overlap, and calls during one share it (`core/recombine.ts`). | |
| 10 | ||
| 11 | - **Web:** the combined card on the pull. **Terminal:** the same card in `forge tui`'s pull screen. Both read `combinedView()` and `combinedWords()` in `src/core/combined_view.ts`. | |
| 12 | - Try it: open a pull from a kept fork that edits a file, delete that file on main, open the pull. | |
| 13 | - Tests: `test/combined-git.test.ts`, `test/combined-recombine.test.ts`. |
src/actions/pulls.ts +8
| @@ -4,6 +4,7 @@ | ||
| 4 | 4 | * issues — a pull is an issue with a diff. |
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | import { recombine } from "../core/recombine.ts"; | |
| 7 | 8 | import { teamsHolding } from "../core/issue_teams.ts"; |
| 8 | 9 | import { isContributionFork, type GuestRefusal } from "../core/contribute.ts"; |
| 9 | 10 | import { takeGuestPull } from "./guests.ts"; |
| @@ -592,6 +593,13 @@ export async function mergePull( | ||
| 592 | 593 | }, |
| 593 | 594 | ); |
| 594 | 595 | if (!merged.ok) return merged; |
| 596 | // A landing moves the default branch as a push to it does: every other kept | |
| 597 | // fork's `combined/N` is built on the old tip, and its check with it, so | |
| 598 | // they rebuild now (`core/recombine.ts`). Off the answer, as the hook's is. | |
| 599 | if (pull.baseBranch === repo.defaultBranch) | |
| 600 | void recombine({ db: app.db, reposRoot: app.reposRoot, log: app.log }, repo.id).catch((error) => | |
| 601 | app.log.warn("could not recombine after a landing", { repo: `${repo.ownerName}/${repo.name}`, error: String(error) }), | |
| 602 | ); | |
| 595 | 603 | // Its author, when they live elsewhere, reads what landed and what changed on the way (#9013). |
| 596 | 604 | await thankAuthor(app, repo, getPull(app.db, repo.id, pull.number) ?? pull, { |
| 597 | 605 | mergeOid: merged.value.mergeOid, |
src/core/combined_view.ts +15 −2
| @@ -6,7 +6,7 @@ | ||
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | 8 | import type { Pull } from "./pulls.ts"; |
| 9 | import { keptLine, readCombined, readFailure, TAKE_BACK, type Kept } from "../git/combined.ts"; | |
| 9 | import { combinedBranch, keptLine, readCombined, readFailure, TAKE_BACK, type Kept } from "../git/combined.ts"; | |
| 10 | 10 | |
| 11 | 11 | export interface CombinedView { |
| 12 | 12 | readonly branch: string; |
| @@ -29,8 +29,15 @@ export async function combinedView( | ||
| 29 | 29 | ): Promise<CombinedView | undefined> { |
| 30 | 30 | if (!pull.crossRepo || pull.state !== "open" || pull.mergedAt !== null) return undefined; |
| 31 | 31 | const read = await readCombined(dir, pull.number, headOid); |
| 32 | if (read === undefined) { | |
| 33 | // A refusal deleted the branch: only its sentence is left to say (#12151). | |
| 34 | const failure = (await readFailure(dir, pull.number)) ?? ""; | |
| 35 | return failure === "" | |
| 36 | ? undefined | |
| 37 | : { branch: combinedBranch(pull.number), oid: "", builtOn: "", stale: false, files: [], failure }; | |
| 38 | } | |
| 32 | 39 | // Main already in the head: the preview is the head itself, nothing to say. |
| 33 | if (read === undefined || read.oid === headOid) return undefined; | |
| 40 | if (read.oid === headOid) return undefined; | |
| 34 | 41 | const stale = read.baseOid !== "" && read.baseOid !== baseOid; |
| 35 | 42 | return { |
| 36 | 43 | failure: stale ? ((await readFailure(dir, pull.number)) ?? "") : "", |
| @@ -48,6 +55,12 @@ export function combinedWords( | ||
| 48 | 55 | number: number, |
| 49 | 56 | ): { lead: string; files: string[]; notes: string[] } { |
| 50 | 57 | const notes: string[] = []; |
| 58 | if (view.oid === "") | |
| 59 | return { | |
| 60 | lead: "There is no combined preview: main could not be merged into this pull's head.", | |
| 61 | files: [], | |
| 62 | notes: [view.failure], | |
| 63 | }; | |
| 51 | 64 | if (view.stale) |
| 52 | 65 | notes.push( |
| 53 | 66 | `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.`, |
src/core/pulls.ts +9 −8
| @@ -1177,7 +1177,7 @@ export async function mergePull( | ||
| 1177 | 1177 | // What the pull proposes is already on the base: there is nothing to |
| 1178 | 1178 | // land, so nothing is committed — the pull closes as merged into the |
| 1179 | 1179 | // commit that carried it, and the base does not move. |
| 1180 | const carrier = await carriedBy(dir, pull, status, input.strategy); | |
| 1180 | const carrier = await carriedBy(dir, pull, status); | |
| 1181 | 1181 | if (carrier !== undefined) { |
| 1182 | 1182 | closeAsMerged(db, pull.id, { |
| 1183 | 1183 | mergeOid: carrier, |
| @@ -1239,19 +1239,20 @@ export async function mergePull( | ||
| 1239 | 1239 | } |
| 1240 | 1240 | |
| 1241 | 1241 | /** |
| 1242 | * The commit on the base that already carries a squash of this pull, when | |
| 1243 | * squashing it again would commit the base's own tree: the pull's own | |
| 1244 | * landing if the base still holds it, else the base's tip. Undefined when | |
| 1245 | * the squash would change something — or is not a squash, or does not | |
| 1246 | * merge cleanly, which the merge itself reports. | |
| 1242 | * The commit on the base that already carries this pull's changes, when | |
| 1243 | * landing it again, squashed or merged, would commit the base's own tree: the | |
| 1244 | * pull's own landing if the base still holds it, else the base's tip. A | |
| 1245 | * pull stacked on another lands that way when the one above it landed | |
| 1246 | * first (#12151): it closes as merged, its author credited, and the base | |
| 1247 | * does not move. Undefined when landing would change something, or the | |
| 1248 | * pull does not merge cleanly, which the merge itself reports. | |
| 1247 | 1249 | */ |
| 1248 | 1250 | async function carriedBy( |
| 1249 | 1251 | dir: string, |
| 1250 | 1252 | pull: Pull, |
| 1251 | 1253 | status: { baseOid: string; headOid: string; ahead: number; mergeable: boolean }, |
| 1252 | strategy: MergeStrategy, | |
| 1253 | 1254 | ): Promise<string | undefined> { |
| 1254 | if (strategy !== "squash" || status.ahead === 0 || !status.mergeable) | |
| 1255 | if (status.ahead === 0 || !status.mergeable) | |
| 1255 | 1256 | return undefined; |
| 1256 | 1257 | if (!(await contentOnBase(dir, status.baseOid, status.headOid))) |
| 1257 | 1258 | return undefined; |
src/core/recombine.ts +12 −8
| @@ -7,7 +7,7 @@ | ||
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | 9 | import type { Database } from "bun:sqlite"; |
| 10 | import { combine, setFailure } from "../git/combined.ts"; | |
| 10 | import { combine, readFailure, setFailure } from "../git/combined.ts"; | |
| 11 | 11 | import { repoDir } from "../git/repos.ts"; |
| 12 | 12 | import { resolveOid } from "../git/merge.ts"; |
| 13 | 13 | import type { Log } from "../kernel/log.ts"; |
| @@ -55,13 +55,17 @@ async function pass(forge: RecombineForge, repoId: string): Promise<void> { | ||
| 55 | 55 | headOid === undefined ? undefined : await combine(dir, { number: pull.number, baseOid, headOid }); |
| 56 | 56 | if (made === undefined) { |
| 57 | 57 | 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 | ); | |
| 58 | // A refusal has written its own sentence (naming the files); | |
| 59 | // only an unexplained one gets this. | |
| 60 | if ((await readFailure(dir, pull.number)) === undefined) { | |
| 61 | await setFailure( | |
| 62 | dir, | |
| 63 | pull.number, | |
| 64 | headOid === undefined | |
| 65 | ? "The rebuild failed: the pull's head is missing from the repository. Push to the fork again to rebuild." | |
| 66 | : "The rebuild failed: git could not merge main into the pull's head. Push to the fork to rebuild it.", | |
| 67 | ); | |
| 68 | } | |
| 65 | 69 | } |
| 66 | 70 | } catch (e) { |
| 67 | 71 | const error = e instanceof Error ? e.message : String(e); |
src/git/combined.ts +26 −4
| @@ -40,6 +40,12 @@ const IDENTITY = { | ||
| 40 | 40 | const isAncestor = async (dir: string, a: string, b: string): Promise<boolean> => |
| 41 | 41 | a === b || (await mergeBase(dir, a, b)) === a; |
| 42 | 42 | |
| 43 | /** Whether the branch has a commit beyond the head that the forge's own combining did not make. */ | |
| 44 | async function carriesWriter(dir: string, headOid: string, tip: string): Promise<boolean> { | |
| 45 | const out = await runGit(["log", "--format=%ae", `${headOid}..${tip}`], { cwd: dir }); | |
| 46 | return out.ok && out.value.split("\n").some((e) => e.trim() !== "" && e.trim() !== "forge@localhost"); | |
| 47 | } | |
| 48 | ||
| 43 | 49 | /** The note the author reads: what was combined and what was kept, file by file. */ |
| 44 | 50 | export function combinedNote(number: number, baseOid: string, files: readonly Kept[]): string { |
| 45 | 51 | const lines = [ |
| @@ -120,10 +126,9 @@ export async function combine( | ||
| 120 | 126 | dir: string, |
| 121 | 127 | input: { readonly number: number; readonly baseOid: string; readonly headOid: string }, |
| 122 | 128 | ): 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; | |
| 129 | // Any earlier failure's sentence ends with this try; a refusal writes its own. | |
| 130 | await setFailure(dir, input.number, undefined); | |
| 131 | return build(dir, input); | |
| 127 | 132 | } |
| 128 | 133 | |
| 129 | 134 | async function build( |
| @@ -159,6 +164,23 @@ async function build( | ||
| 159 | 164 | files.push({ path, blocks, kept: open.has(path) || blocks === 0 ? "open" : "main" }); |
| 160 | 165 | } |
| 161 | 166 | } |
| 167 | // A delete, a rename or a mode change that meets a change on main has no | |
| 168 | // side git can pick, and `-X ours` leaves whatever it likes (the file kept, | |
| 169 | // or under both names, or back at its old mode). Nothing lands that nobody | |
| 170 | // chose (#12151): no rebuild is written. A branch of forge's own combinings | |
| 171 | // goes, and the pull answers as its head does (conflicting with main); one | |
| 172 | // that carries a writer's commits stays, as they are theirs to keep. | |
| 173 | const unresolved = files.filter((f) => f.kept === "open").map((f) => f.path); | |
| 174 | if (unresolved.length > 0) { | |
| 175 | if (prior !== undefined && !(await carriesWriter(dir, headOid, prior))) | |
| 176 | await runGit(["update-ref", "-d", ref], { cwd: dir }); | |
| 177 | await setFailure( | |
| 178 | dir, | |
| 179 | number, | |
| 180 | `The rebuild stopped: ${unresolved.join(", ")} was deleted, renamed or changed in a way that conflicts with main, and no side is picked for it. Push to the fork with it resolved.`, | |
| 181 | ); | |
| 182 | return undefined; | |
| 183 | } | |
| 162 | 184 | const commit = await runGit( |
| 163 | 185 | ["commit-tree", tree, "-p", from, "-p", baseOid, "-m", combinedNote(number, baseOid, files)], |
| 164 | 186 | { cwd: dir, env: IDENTITY }, |
src/tui/screens/pull.ts +1 −1
| @@ -2088,7 +2088,7 @@ function combinedLines( | ||
| 2088 | 2088 | wrap(text, indent).map((t): Span[] => [{ text: t, style: style(fg, c.bg) }]); |
| 2089 | 2089 | return [ |
| 2090 | 2090 | [], |
| 2091 | [{ text: ` Combined with main${view.stale ? " · stale" : ""}`, style: style(c.fg, c.bg, BOLD) }], | |
| 2091 | [{ text: ` ${view.oid === "" ? "Not combined with main" : "Combined with main"}${view.stale ? " · stale" : ""}`, style: style(c.fg, c.bg, BOLD) }], | |
| 2092 | 2092 | ...say(words.lead, " ", c.fg), |
| 2093 | 2093 | ...words.files.flatMap((l) => say(`- ${l}`, " ", c.fg)), |
| 2094 | 2094 | ...words.notes.flatMap((l) => say(l, " ", c.mutedFg)), |
src/web/pages/pull-combined.ts +1 −1
| @@ -26,7 +26,7 @@ export async function combinedCard( | ||
| 26 | 26 | { |
| 27 | 27 | extra: "mb", |
| 28 | 28 | head: html`${icon("merge")} |
| 29 | <h2>Combined with main${view.stale ? " · stale" : ""}</h2>`, | |
| 29 | <h2>${view.oid === "" ? "Not combined with main" : "Combined with main"}${view.stale ? " · stale" : ""}</h2>`, | |
| 30 | 30 | }, |
| 31 | 31 | ); |
| 32 | 32 | } |
test/combined-git.test.ts +33 −12
| @@ -3,7 +3,7 @@ import { afterAll, beforeAll, expect, test } from "bun:test"; | ||
| 3 | 3 | import { mkdtemp, rm } from "node:fs/promises"; |
| 4 | 4 | import { tmpdir } from "node:os"; |
| 5 | 5 | import { join } from "node:path"; |
| 6 | import { combine, headUnder, parseCombined, readCombined } from "../src/git/combined.ts"; | |
| 6 | import { combine, headUnder, parseCombined, readCombined, readFailure } from "../src/git/combined.ts"; | |
| 7 | 7 | import { gitClientEnv } from "./helpers/git-client.ts"; |
| 8 | 8 | |
| 9 | 9 | const env = gitClientEnv({ |
| @@ -43,30 +43,39 @@ afterAll(async () => { | ||
| 43 | 43 | await rm(dir, { recursive: true, force: true }); |
| 44 | 44 | }); |
| 45 | 45 | |
| 46 | test("a modify/delete conflict is left open, a line conflict keeps main's side", async () => { | |
| 47 | const head = commitOn("fork", { "one.txt": "fork\n", "gone.txt": "changed\n" }, "fork edits"); | |
| 48 | const base = commitOn("main", { "one.txt": "main\n", "gone.txt": null }, "main deletes"); | |
| 46 | test("a line conflict keeps main's side, and says so in trailers", async () => { | |
| 47 | const head = commitOn("fork", { "one.txt": "fork\n" }, "fork edits"); | |
| 48 | const base = commitOn("main", { "one.txt": "main\n" }, "main edits"); | |
| 49 | 49 | const made = await combine(gitDir(), { number: 7, baseOid: base, headOid: head }); |
| 50 | 50 | expect(made).toBeDefined(); |
| 51 | const files = [...(made!.files ?? [])].sort((a, b) => a.path.localeCompare(b.path)); | |
| 52 | expect(files).toEqual([ | |
| 53 | { path: "gone.txt", blocks: 0, kept: "open" }, | |
| 54 | { path: "one.txt", blocks: 1, kept: "main" }, | |
| 55 | ]); | |
| 51 | const files = [...(made!.files ?? [])]; | |
| 52 | expect(files).toEqual([{ path: "one.txt", blocks: 1, kept: "main" }]); | |
| 56 | 53 | expect(git("show", `${made!.oid}:one.txt`)).toBe("main"); |
| 57 | 54 | // The page and the terminal read the same facts back, from trailers. |
| 58 | 55 | const read = await readCombined(gitDir(), 7, head); |
| 59 | 56 | expect(read?.baseOid).toBe(base); |
| 60 | expect([...read!.files].sort((a, b) => a.path.localeCompare(b.path))).toEqual(files); | |
| 57 | expect([...read!.files]).toEqual(files); | |
| 61 | 58 | const note = git("log", "-1", "--format=%B", made!.oid); |
| 62 | 59 | expect(note).toContain("your side was dropped from the landed tree"); |
| 63 | expect(note).toContain("gone.txt: left open"); | |
| 60 | }); | |
| 61 | ||
| 62 | test("a modify/delete conflict has no side to pick: no combined branch, and the reason is kept (#12151)", async () => { | |
| 63 | git("branch", "doomed", "fork"); | |
| 64 | const head = commitOn("doomed", { "gone.txt": "changed\n" }, "fork edits gone.txt"); | |
| 65 | const base = commitOn("main", { "gone.txt": null }, "main deletes gone.txt"); | |
| 66 | expect(await combine(gitDir(), { number: 8, baseOid: base, headOid: head })).toBeUndefined(); | |
| 67 | expect(Bun.spawnSync(["git", "rev-parse", "--verify", "--quiet", "refs/heads/combined/8"], { cwd: gitDir() }).exitCode).not.toBe(0); | |
| 68 | expect(await readFailure(gitDir(), 8)).toContain("gone.txt"); | |
| 69 | // A rebuild that works ends the sentence. | |
| 70 | const fixed = commitOn("doomed", { "gone.txt": null }, "fork agrees"); | |
| 71 | expect(await combine(gitDir(), { number: 8, baseOid: base, headOid: fixed })).toBeDefined(); | |
| 72 | expect(await readFailure(gitDir(), 8)).toBeUndefined(); | |
| 64 | 73 | }); |
| 65 | 74 | |
| 66 | 75 | test("a writer's commit on the combined branch survives main moving on", async () => { |
| 67 | 76 | const head = git("rev-parse", "fork"); |
| 68 | 77 | const tip = git("rev-parse", "combined/7"); |
| 69 | const fixed = commitOn("combined/7", { "one.txt": "both\n", "gone.txt": "changed\n" }, "a writer resolves"); | |
| 78 | const fixed = commitOn("combined/7", { "one.txt": "both\n" }, "a writer resolves"); | |
| 70 | 79 | expect(git("rev-parse", "combined/7")).not.toBe(tip); |
| 71 | 80 | const base = commitOn("main", { "two.txt": "two\n" }, "main moves"); |
| 72 | 81 | const again = await combine(gitDir(), { number: 7, baseOid: base, headOid: head }); |
| @@ -96,3 +105,15 @@ test("trailers that are not ours are not read as facts", () => { | ||
| 96 | 105 | files: [], |
| 97 | 106 | }); |
| 98 | 107 | }); |
| 108 | ||
| 109 | test("a combined branch carrying a writer's commits is kept when main later deletes a file they touched (#12151)", async () => { | |
| 110 | git("branch", "wr", "main"); | |
| 111 | const head = commitOn("wr", { "two.txt": "fork\n" }, "fork edits two.txt"); | |
| 112 | const main = git("rev-parse", "main"); | |
| 113 | expect(await combine(gitDir(), { number: 9, baseOid: main, headOid: head })).toBeDefined(); | |
| 114 | const written = commitOn("combined/9", { "two.txt": "writer\n" }, "a writer resolves"); | |
| 115 | const base = commitOn("main", { "two.txt": null }, "main deletes two.txt"); | |
| 116 | expect(await combine(gitDir(), { number: 9, baseOid: base, headOid: head })).toBeUndefined(); | |
| 117 | expect(git("rev-parse", "combined/9")).toBe(written); | |
| 118 | expect(await readFailure(gitDir(), 9)).toContain("two.txt"); | |
| 119 | }); |
test/combined-recombine.test.ts +70 −2
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | /** `recombine` (#10039): main moves, every kept-fork pull's combined branch follows, one pass at a time. */ |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 2 | import { afterAll, beforeAll, expect, spyOn, test } from "bun:test"; | |
| 3 | 3 | import { mkdtemp, rm } from "node:fs/promises"; |
| 4 | 4 | import { tmpdir } from "node:os"; |
| 5 | 5 | import { join } from "node:path"; |
| @@ -8,9 +8,12 @@ import { serve, type Servers } from "../src/http/server.ts"; | ||
| 8 | 8 | import { gitClientEnv } from "./helpers/git-client.ts"; |
| 9 | 9 | import { proveAll } from "./helpers/verified.ts"; |
| 10 | 10 | import { promote } from "./helpers/members.ts"; |
| 11 | import { combine, readFailure } from "../src/git/combined.ts"; | |
| 11 | import { combine, readFailure, setFailure } from "../src/git/combined.ts"; | |
| 12 | 12 | import { idle, recombine, runsStarted } from "../src/core/recombine.ts"; |
| 13 | 13 | import { getRepo } from "../src/core/repos.ts"; |
| 14 | import { getUserByName } from "../src/core/users.ts"; | |
| 15 | import { mergePull } from "../src/actions/pulls.ts"; | |
| 16 | import { getPull } from "../src/core/pulls.ts"; | |
| 14 | 17 | import { repoDir } from "../src/git/repos.ts"; |
| 15 | 18 | import { tokenFor } from "./helpers/tokens.ts"; |
| 16 | 19 | |
| @@ -240,3 +243,68 @@ test("a main git cannot merge puts its sentence on the combined card, and a fixe | ||
| 240 | 243 | expect((await parents(`refs/heads/combined/${first}`))[1]).toBe(fixed); |
| 241 | 244 | expect(head).toBe(await git(["rev-parse", "refs/pull/" + first + "/head"], bare)); |
| 242 | 245 | }); |
| 246 | ||
| 247 | test("landing a pull rebuilds the other kept fork's combined branch, once, with a second call joining it (#12151)", async () => { | |
| 248 | const user = getUserByName(app.db, "alice")!; | |
| 249 | const repo = getRepo(app.db, "alice", "app")!; | |
| 250 | await idle(repoId); | |
| 251 | const before = await git(["rev-parse", `refs/heads/combined/${first}`], bare); | |
| 252 | const runs = runsStarted(repoId); | |
| 253 | const landed = await mergePull(app, user, repo, getPull(app.db, repo.id, second)!, "merge"); | |
| 254 | expect(landed.ok).toBe(true); | |
| 255 | // A second call while the landing's own pass runs joins it: two passes at most, never two | |
| 256 | // at once. | |
| 257 | const joined = recombine(forge(), repoId); | |
| 258 | await idle(repoId); | |
| 259 | expect(await joined).toBeLessThanOrEqual(2); | |
| 260 | expect(runsStarted(repoId)).toBe(runs + 1); | |
| 261 | const main = await git(["rev-parse", "refs/heads/main"], bare); | |
| 262 | expect(main).not.toBe(before); | |
| 263 | expect((await parents(`refs/heads/combined/${first}`))[1]).toBe(main); | |
| 264 | }); | |
| 265 | ||
| 266 | test("a refusal's own sentence is kept, not replaced by the generic one", async () => { | |
| 267 | await git(["fetch", "-q", "origin"], a); | |
| 268 | await git(["reset", "-q", "--hard", "origin/main"], a); | |
| 269 | await git(["fetch", "-q", "origin"], b); | |
| 270 | await git(["checkout", "-q", "-b", "three", "origin/main"], b); | |
| 271 | await Bun.write(join(b, "one.txt"), "bob\n"); | |
| 272 | await git(["add", "."], b); | |
| 273 | await git(["commit", "-m", "bob three"], b); | |
| 274 | await git(["push", "origin", "three"], b); | |
| 275 | const r = await call("/api/v1/repos/alice/app/pulls", bob, { title: "three", base: "main", head: "three", headRepo: "bob/app" }); | |
| 276 | expect(r.status).toBe(201); | |
| 277 | const third = ((await r.json()) as { number: number }).number; | |
| 278 | await git(["rm", "-q", "one.txt"], a); | |
| 279 | await git(["commit", "-m", "main deletes one.txt"], a); | |
| 280 | await git(["push", "origin", "main"], a); | |
| 281 | await idle(repoId); | |
| 282 | const said = await readFailure(bare, third); | |
| 283 | expect(said).toContain("The rebuild stopped: one.txt"); | |
| 284 | expect(said).not.toContain("git could not merge"); | |
| 285 | // The page and the terminal word the card from one view: the sentence is on both. | |
| 286 | const { combinedView, combinedWords } = await import("../src/core/combined_view.ts"); | |
| 287 | const head = await git(["rev-parse", `refs/pull/${third}/head`], bare); | |
| 288 | const view = await combinedView(bare, getPull(app.db, repoId, third)!, head, await git(["rev-parse", "refs/heads/main"], bare)); | |
| 289 | expect(combinedWords(view!, third).notes).toEqual([said!]); | |
| 290 | const page = (await (await call(`/alice/app/pulls/${third}`, alice)).text()).replace(/'/g, "'"); | |
| 291 | expect(page).toContain("Not combined with main"); | |
| 292 | expect(page).toContain("The rebuild stopped: one.txt"); | |
| 293 | }); | |
| 294 | ||
| 295 | test("a build that throws still leaves a sentence", async () => { | |
| 296 | const third = Number((await git(["config", "--local", "--get-regexp", "^recombine.failed"], bare)).split("\n").pop()!.split(" ")[0]!.replace("recombine.failed", "")); | |
| 297 | await setFailure(bare, third, undefined); | |
| 298 | const real = Bun.spawn; | |
| 299 | const spy = spyOn(Bun, "spawn").mockImplementation(((cmd: string[], ...rest: unknown[]) => { | |
| 300 | if (cmd.includes("merge-tree")) throw new Error("boom"); | |
| 301 | return (real as (...a: unknown[]) => unknown)(cmd, ...rest); | |
| 302 | }) as typeof Bun.spawn); | |
| 303 | try { | |
| 304 | await moveMain("m9.txt"); | |
| 305 | await idle(repoId); | |
| 306 | } finally { | |
| 307 | spy.mockRestore(); | |
| 308 | } | |
| 309 | expect(await readFailure(bare, third)).toContain("The rebuild failed unexpectedly"); | |
| 310 | }); |
test/lands-once.test.ts +33
| @@ -13,6 +13,7 @@ import { join } from "node:path"; | ||
| 13 | 13 | import { createApp, type App } from "../src/app.ts"; |
| 14 | 14 | import { mergePull, openPull } from "../src/actions/pulls.ts"; |
| 15 | 15 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; |
| 16 | import { idle } from "../src/core/recombine.ts"; | |
| 16 | 17 | import { getPull, type Pull } from "../src/core/pulls.ts"; |
| 17 | 18 | import { getRepoById, type Repo } from "../src/core/repos.ts"; |
| 18 | 19 | import { claimRun, createRun, finishRun } from "../src/core/runs.ts"; |
| @@ -67,6 +68,7 @@ describe("a pull lands once", () => { | ||
| 67 | 68 | }); |
| 68 | 69 | |
| 69 | 70 | afterAll(async () => { |
| 71 | await idle(repo.id); | |
| 70 | 72 | await app.close?.(); |
| 71 | 73 | await rm(dataDir, { recursive: true, force: true }); |
| 72 | 74 | }); |
| @@ -173,4 +175,35 @@ describe("a pull lands once", () => { | ||
| 173 | 175 | if (!again.ok) expect(again.error).toBe("already_merged"); |
| 174 | 176 | expect(mainTip()).toBe(tip); |
| 175 | 177 | }); |
| 178 | ||
| 179 | test("a stacked pull landing as a merge commit closes as merged, and main does not move (#12151)", async () => { | |
| 180 | await push("feature/stack-a", "stack-a.ts", "export const a = 1;\n"); | |
| 181 | const upper = await commitToBranch(app, alice, repo, { | |
| 182 | branch: "feature/stack-b", | |
| 183 | base: "feature/stack-a", | |
| 184 | message: "stack-b", | |
| 185 | files: [{ path: "stack-b.ts", content: "export const b = 1;\n" }], | |
| 186 | }); | |
| 187 | if (!upper.ok) throw new Error(JSON.stringify(upper.error)); | |
| 188 | const open = async (name: string): Promise<Pull> => { | |
| 189 | const o = await openPull(app, alice, repo, { | |
| 190 | title: name, | |
| 191 | baseBranch: "main", | |
| 192 | headBranch: `feature/${name}`, | |
| 193 | }); | |
| 194 | if (!o.ok) throw new Error(String(o.error)); | |
| 195 | return getPull(app.db, repo.id, o.value.number)!; | |
| 196 | }; | |
| 197 | const lower = await open("stack-a"); | |
| 198 | const top = await open("stack-b"); | |
| 199 | const landed = await mergePull(app, alice, fresh(), top, "squash"); | |
| 200 | expect(landed.ok).toBe(true); | |
| 201 | const tip = mainTip(); | |
| 202 | const done = await mergePull(app, alice, fresh(), lower, "merge"); | |
| 203 | expect(done.ok).toBe(true); | |
| 204 | expect(mainTip()).toBe(tip); | |
| 205 | const closed = getPull(app.db, repo.id, lower.number)!; | |
| 206 | expect(closed.state).toBe("closed"); | |
| 207 | expect(closed.mergedOid).toBe(tip); | |
| 208 | }); | |
| 176 | 209 | }); |
test/support/landsim.ts +4 −5
| @@ -45,7 +45,7 @@ import { | ||
| 45 | 45 | } from "../../src/core/runs.ts"; |
| 46 | 46 | import { commitIdentity } from "../../src/core/commit_identity.ts"; |
| 47 | 47 | import { getPull, pullStatus, type Pull } from "../../src/core/pulls.ts"; |
| 48 | import { recombine } from "../../src/core/recombine.ts"; | |
| 48 | import { idle, recombine } from "../../src/core/recombine.ts"; | |
| 49 | 49 | import { refreshPullHead } from "../../src/core/following.ts"; |
| 50 | 50 | import { getRepoById, type Repo } from "../../src/core/repos.ts"; |
| 51 | 51 | import { reviewSummary } from "../../src/core/reviews.ts"; |
| @@ -1114,6 +1114,7 @@ export class Sim { | ||
| 1114 | 1114 | this.row(st), |
| 1115 | 1115 | step.how, |
| 1116 | 1116 | ); |
| 1117 | await idle(this.repo.id); | |
| 1117 | 1118 | if (!merged.ok) |
| 1118 | 1119 | this.refuse( |
| 1119 | 1120 | `land: ${typeof merged.error === "string" ? merged.error : merged.error.kind}`, |
| @@ -1127,6 +1128,7 @@ export class Sim { | ||
| 1127 | 1128 | world.onGate.set(this.repo.id, () => this.exec(during)); |
| 1128 | 1129 | } |
| 1129 | 1130 | await sweepGreen(app, this.repo); |
| 1131 | await idle(this.repo.id); | |
| 1130 | 1132 | world.onGate.delete(this.repo.id); |
| 1131 | 1133 | return; |
| 1132 | 1134 | } |
| @@ -1224,6 +1226,7 @@ export class Sim { | ||
| 1224 | 1226 | this.row(st), |
| 1225 | 1227 | st.how, |
| 1226 | 1228 | ); |
| 1229 | await idle(this.repo.id); | |
| 1227 | 1230 | if (!merged.ok) { |
| 1228 | 1231 | st.lastRefusal = typeof merged.error === "string" ? merged.error : merged.error.kind; |
| 1229 | 1232 | this.refuse(`settle: ${st.lastRefusal}`); |
| @@ -1682,10 +1685,6 @@ export const KNOWN_VIOLATIONS: readonly { prefix: string; issue: string; why: st | ||
| 1682 | 1685 | { prefix: "reviewedIsLanded/removed-line-survives-by-combine", issue: "#12104", why: "the same, for a line the pull removed or rewrote" }, |
| 1683 | 1686 | { prefix: "noSilentLoss/dropped-by-combine", issue: "#12104", why: "the same, seen from main's side" }, |
| 1684 | 1687 | { prefix: "noDroppedBlocksLand/combined-kept", issue: "#12104", why: "a tree with Combined-Kept blocks lands" }, |
| 1685 | { prefix: "liveness/queue-named-never-lands-fork", issue: "unfiled", why: "after one pull lands, the other kept forks' combined/N are stale and landing fires no hook: the check is stale (checks_required), the pull says queue, and neither the queue nor a person can land it until something else pushes to main" }, | |
| 1686 | { prefix: "reviewedIsLanded/removed-line-survives", issue: "unfiled", why: "a kept fork's delete or rename that conflicts with main lands through combine with the file kept or under both names, so a line the pull removed survives (or a later pull's edit reaches one copy of it)" }, | |
| 1687 | { prefix: "reviewedIsLanded/mode-lost", issue: "unfiled", why: "a kept fork's chmod on a file main deleted lands through combine with the file back at the old mode" }, | |
| 1688 | { prefix: "authorshipKept/repo", issue: "unfiled", why: "a pull whose commits already landed inside a stacked pull lands again as an empty squash by the lander, crediting nobody" }, | |
| 1689 | 1688 | { prefix: "additiveMergesCommute/needs-a-person", issue: "#12104", why: "two in-repo pulls that only add lines at one place do not merge: git conflicts, and the pull waits for a crew run or a person" }, |
| 1690 | 1689 | ]; |
| 1691 | 1690 |
new above
offline