Public: anyone can read this; the crew can change it.
Landing never drops a contributor's reviewed lines: a combined build that had… #12119
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/12104-landing-never-drops-a-contributor-s-revi → main 24 commits fork 5 threads · 5 unresolved
17 files
changed +564
−153
scripts/gate.ts +1 −1
| @@ -112,7 +112,7 @@ export const SLOW: readonly Exclusion[] = [ | ||
| 112 | 112 | { file: "test/home-frame.test.ts", reason: "renders the signed-in home after writes and times the answer from memory; 2.2 s a test on the gate box", issue: 1242 }, |
| 113 | 113 | { file: "test/job-slices.test.ts", reason: "ticks every timed job on a busy fixture while sampling the event loop; 2.3 s a test on the gate box", issue: 1242 }, |
| 114 | 114 | { file: "test/kpis-landings-scale.test.ts", reason: "seeds a production-sized landings table and counts it two ways; 7.5 s a test on the gate box", issue: 1242 }, |
| 115 | { file: "test/landsim.test.ts", reason: "simulates landing a seeded corpus of pulls through review and the merge queue; 18.4 s a test on the gate box", issue: 1242 }, | |
| 115 | { file: "test/landsim.test.ts", reason: "simulates landing a seeded corpus of pulls through review and the merge queue; 18.4 s a test on the gate box", issue: 12447 }, | |
| 116 | 116 | { file: "test/live-arrive.test.ts", reason: "drops and restores a live connection in happy-dom, waiting on real timers; 2.8 s a test on the gate box", issue: 1242 }, |
| 117 | 117 | { file: "test/live-hold.test.ts", reason: "runs the live client on a stubbed DOM with real timers; 30 s for one swap points to a stalled wait; 30.0 s a test on the gate box", issue: 1242 }, |
| 118 | 118 | { file: "test/loading-chrome.test.ts", reason: "watches the loading indicator across pages in a real Chromium browser; 7.6 s a test on the gate box", issue: 1242 }, |
src/core/combined_view.ts +2 −2
| @@ -57,9 +57,9 @@ export function combinedWords( | ||
| 57 | 57 | const notes: string[] = []; |
| 58 | 58 | if (view.oid === "") |
| 59 | 59 | return { |
| 60 | lead: "There is no combined preview: main could not be merged into this pull's head.", | |
| 60 | lead: view.failure, | |
| 61 | 61 | files: [], |
| 62 | notes: [view.failure], | |
| 62 | notes: [], | |
| 63 | 63 | }; |
| 64 | 64 | if (view.stale) |
| 65 | 65 | notes.push( |
src/core/pulls.ts +4
| @@ -1264,7 +1264,11 @@ export async function mergePull( | ||
| 1264 | 1264 | input.sealedFrom === undefined && input.strategy === "merge" && fromKeptFork(db, pull) |
| 1265 | 1265 | ? await combine(dir, { number: pull.number, baseOid: status.baseOid, headOid: status.headOid }) |
| 1266 | 1266 | : undefined; |
| 1267 | // Overlapping edits are never settled by picking a side: the pull is held. | |
| 1268 | if (combined?.held === true) | |
| 1269 | return err({ kind: "conflict", files: (combined.files ?? []).map((f) => f.path) }); | |
| 1267 | 1270 | const asIs = |
| 1271 | combined === undefined && | |
| 1268 | 1272 | input.fastForward === true && |
| 1269 | 1273 | input.expect === undefined && |
| 1270 | 1274 | (await landsAsIs(dir, status.baseOid, status.headOid)); |
src/git/combined.ts +71 −58
| @@ -1,10 +1,12 @@ | ||
| 1 | 1 | /** |
| 2 | * A kept fork's pull, combined on our side (#9012): main merged into the | |
| 2 | * A kept fork's pull, combined with main (#9012): main merged into the | |
| 3 | 3 | * author's head, in a branch of the base repository (`combined/N`). The |
| 4 | * author's branch is never written. Each conflicting block keeps main's | |
| 5 | * side, and the combined commit's message is the note the author reads: | |
| 6 | * every conflicted file and what was kept. The preview builds this commit | |
| 7 | * and the landing merges it, so the author's head stays an ancestor of main. | |
| 4 | * author's branch is never written. Two sides that only add lines at the same | |
| 5 | * place keep both (`union.ts`); any other overlap is never settled by picking | |
| 6 | * a side (#12104): no combined commit is made, the pull is held with a | |
| 7 | * sentence until its author merges main in. The preview builds the combined | |
| 8 | * commit and the landing merges it, so the author's head stays an ancestor | |
| 9 | * of main. | |
| 8 | 10 | * |
| 9 | 11 | * Commits pushed on top of the combined branch (a resolution done by hand) |
| 10 | 12 | * are kept: the next combining starts from the branch's tip as long as it |
| @@ -15,7 +17,7 @@ import { gitEnv, runGit, runGitCapture } from "./env.ts"; | ||
| 15 | 17 | import { isSafeOid, isSafeTreePath } from "./guard.ts"; |
| 16 | 18 | import { refTip } from "./read.ts"; |
| 17 | 19 | import { mergeBase, resolveOid } from "./merge.ts"; |
| 18 | import { parseConflicts } from "./conflicts.ts"; | |
| 20 | import { unionTree } from "./union.ts"; | |
| 19 | 21 | |
| 20 | 22 | /** The branch a pull's combined tree lives on, in the base repository. */ |
| 21 | 23 | export const combinedBranch = (number: number): string => `combined/${number}`; |
| @@ -26,7 +28,7 @@ export interface Kept { | ||
| 26 | 28 | readonly path: string; |
| 27 | 29 | /** Conflicting blocks in the file; 0 when it is not a text conflict. */ |
| 28 | 30 | readonly blocks: number; |
| 29 | /** "main": main's side of every block; "open": git could not pick, it awaits a hand. */ | |
| 31 | /** "open": the sides overlap and neither was picked; it awaits a hand. */ | |
| 30 | 32 | readonly kept: "main" | "open"; |
| 31 | 33 | } |
| 32 | 34 | |
| @@ -46,24 +48,15 @@ async function carriesWriter(dir: string, headOid: string, tip: string): Promise | ||
| 46 | 48 | return out.ok && out.value.split("\n").some((e) => e.trim() !== "" && e.trim() !== "forge@localhost"); |
| 47 | 49 | } |
| 48 | 50 | |
| 49 | /** The note the author reads: what was combined and what was kept, file by file. */ | |
| 50 | export function combinedNote(number: number, baseOid: string, files: readonly Kept[]): string { | |
| 51 | const lines = [ | |
| 52 | `Main merged into #${number}'s head on our side (${combinedBranch(number)})`, | |
| 51 | /** The note on a combined commit: main merged into the head, nothing dropped. */ | |
| 52 | export function combinedNote(number: number, baseOid: string): string { | |
| 53 | return [ | |
| 54 | `Main merged into #${number}'s head (${combinedBranch(number)})`, | |
| 53 | 55 | "", |
| 54 | "Your branch is untouched. The preview builds this combined tree, and landing merges it, so your head stays an ancestor of main.", | |
| 56 | "Your branch is untouched. The preview builds this combined tree, and landing merges it, so your head stays an ancestor of main. Nothing of yours was dropped: where you and main only added lines at the same place, both were kept, main's first.", | |
| 55 | 57 | "", |
| 56 | ]; | |
| 57 | if (files.length === 0) lines.push("No file conflicted: this is a clean merge of main into your head."); | |
| 58 | else { | |
| 59 | lines.push("Conflicted files, and what was kept:"); | |
| 60 | for (const f of files) lines.push(`- ${keptLine(f, number)}`); | |
| 61 | if (files.some((f) => f.kept === "main")) lines.push("", TAKE_BACK); | |
| 62 | } | |
| 63 | // The same facts as trailers: what readers render from, not the words above. | |
| 64 | lines.push("", `${BASE_TRAILER}: ${baseOid}`); | |
| 65 | for (const f of files) lines.push(`${KEPT_TRAILER}: ${f.kept} ${f.blocks} ${f.path}`); | |
| 66 | return lines.join("\n"); | |
| 58 | `${BASE_TRAILER}: ${baseOid}`, | |
| 59 | ].join("\n"); | |
| 67 | 60 | } |
| 68 | 61 | |
| 69 | 62 | /** One conflicted file, said to its author. */ |
| @@ -77,7 +70,6 @@ export const TAKE_BACK = | ||
| 77 | 70 | "To get your side back: merge main into your branch, resolve these files as you want them, and push. A head that already has main is its own combined build, so what you resolved is what lands."; |
| 78 | 71 | |
| 79 | 72 | const BASE_TRAILER = "Combined-Base"; |
| 80 | const KEPT_TRAILER = "Combined-Kept"; | |
| 81 | 73 | |
| 82 | 74 | /** The trailers of a combining commit, read back as data. */ |
| 83 | 75 | export function parseCombined(message: string): { baseOid: string; files: Kept[] } { |
| @@ -117,6 +109,23 @@ async function mergeTree( | ||
| 117 | 109 | return { tree, files }; |
| 118 | 110 | } |
| 119 | 111 | |
| 112 | /** | |
| 113 | * What a combining made: the tip, the files it left open, and `held` when the sides overlap and no | |
| 114 | * combined commit was made. | |
| 115 | */ | |
| 116 | export interface Combined { | |
| 117 | readonly oid: string; | |
| 118 | readonly files: Kept[] | null; | |
| 119 | readonly held?: true; | |
| 120 | } | |
| 121 | ||
| 122 | /** The one sentence a held pull says: what overlaps, and what it waits for. */ | |
| 123 | export function heldSentence(number: number, files: readonly Kept[]): string { | |
| 124 | const names = files.map((f) => f.path).slice(0, 5).join(", "); | |
| 125 | const more = files.length > 5 ? ` and ${files.length - 5} more` : ""; | |
| 126 | return `#${number} is held: it overlaps main in ${names}${more}, nothing was dropped, and it waits for its author to merge main into their branch and push.`; | |
| 127 | } | |
| 128 | ||
| 120 | 129 | /** |
| 121 | 130 | * Write (or bring up to date) the combined branch of pull `number`: main at |
| 122 | 131 | * `baseOid` merged into `headOid`. Answers the combined tip, and the files |
| @@ -125,22 +134,27 @@ async function mergeTree( | ||
| 125 | 134 | export async function combine( |
| 126 | 135 | dir: string, |
| 127 | 136 | input: { readonly number: number; readonly baseOid: string; readonly headOid: string }, |
| 128 | ): Promise<{ oid: string; files: Kept[] | null } | undefined> { | |
| 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); | |
| 137 | ): Promise<Combined | undefined> { | |
| 138 | const made = await build(dir, input); | |
| 139 | // A rebuild that worked ends any earlier failure's sentence, whoever asked for it; | |
| 140 | // one that held the pull says why in its place. | |
| 141 | if (made !== undefined) | |
| 142 | await setFailure(dir, input.number, made.held === true ? heldSentence(input.number, made.files ?? []) : undefined); | |
| 143 | return made; | |
| 132 | 144 | } |
| 133 | 145 | |
| 134 | 146 | async function build( |
| 135 | 147 | dir: string, |
| 136 | 148 | input: { readonly number: number; readonly baseOid: string; readonly headOid: string }, |
| 137 | ): Promise<{ oid: string; files: Kept[] | null } | undefined> { | |
| 149 | ): Promise<Combined | undefined> { | |
| 138 | 150 | const { number, baseOid, headOid } = input; |
| 139 | 151 | if (!Number.isInteger(number) || number < 1 || !isSafeOid(baseOid) || !isSafeOid(headOid)) |
| 140 | 152 | return undefined; |
| 141 | 153 | const ref = combinedRef(number); |
| 142 | 154 | const prior = await resolveOid(dir, ref); |
| 143 | const from = prior !== undefined && (await isAncestor(dir, headOid, prior)) ? prior : headOid; | |
| 155 | // A tip that dropped a side (an older combining) is never built on or landed. | |
| 156 | const tainted = prior !== undefined && (await droppedASide(dir, headOid, prior)); | |
| 157 | const from = prior !== undefined && !tainted && (await isAncestor(dir, headOid, prior)) ? prior : headOid; | |
| 144 | 158 | if (from === prior && (await isAncestor(dir, baseOid, from))) return { oid: from, files: null }; |
| 145 | 159 | |
| 146 | 160 | const update = async (oid: string): Promise<boolean> => |
| @@ -152,42 +166,41 @@ async function build( | ||
| 152 | 166 | const plain = await mergeTree(dir, baseOid, from, []); |
| 153 | 167 | if (plain === undefined) return undefined; |
| 154 | 168 | let tree = plain.tree; |
| 155 | const files: Kept[] = []; | |
| 156 | 169 | if (plain.files.length > 0) { |
| 157 | // Main's side of each block; what git still cannot pick stays open. | |
| 158 | const picked = await mergeTree(dir, baseOid, from, ["-X", "ours"]); | |
| 159 | const open = new Set(picked?.files ?? plain.files); | |
| 160 | if (picked !== undefined) tree = picked.tree; | |
| 161 | for (const path of plain.files) { | |
| 162 | const text = await runGit(["cat-file", "blob", `${plain.tree}:${path}`], { cwd: dir }); | |
| 163 | const blocks = text.ok ? parseConflicts(text.value).blocks.length : 0; | |
| 164 | files.push({ path, blocks, kept: open.has(path) || blocks === 0 ? "open" : "main" }); | |
| 170 | // Pure additions at the same place keep both; any other overlap is held, never settled by | |
| 171 | // picking a side. | |
| 172 | const both = await unionTree(dir, baseOid, from); | |
| 173 | if (both === undefined) { | |
| 174 | // Held: a branch of forge's own combining goes (the pull answers as its head does); one that | |
| 175 | // carries a writer's commits stays, as they are theirs to keep (#12151). | |
| 176 | let held = from; | |
| 177 | if (prior !== undefined && !(await carriesWriter(dir, headOid, prior))) { | |
| 178 | await runGit(["update-ref", "-d", ref], { cwd: dir }); | |
| 179 | held = headOid; | |
| 180 | } | |
| 181 | return { oid: held, files: plain.files.map((path) => ({ path, blocks: 0, kept: "open" as const })), held: true }; | |
| 165 | 182 | } |
| 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 | tree = both; | |
| 183 | 184 | } |
| 184 | 185 | const commit = await runGit( |
| 185 | ["commit-tree", tree, "-p", from, "-p", baseOid, "-m", combinedNote(number, baseOid, files)], | |
| 186 | ["commit-tree", tree, "-p", from, "-p", baseOid, "-m", combinedNote(number, baseOid)], | |
| 186 | 187 | { cwd: dir, env: IDENTITY }, |
| 187 | 188 | ); |
| 188 | 189 | if (!commit.ok) return undefined; |
| 189 | 190 | const oid = commit.value.trim(); |
| 190 | return (await update(oid)) ? { oid, files } : undefined; | |
| 191 | return (await update(oid)) ? { oid, files: [] } : undefined; | |
| 192 | } | |
| 193 | ||
| 194 | /** | |
| 195 | * Whether a combining between the head and this tip kept main's side and dropped the pull's (the | |
| 196 | * retired `Combined-Kept: main` note). | |
| 197 | */ | |
| 198 | async function droppedASide(dir: string, headOid: string, tip: string): Promise<boolean> { | |
| 199 | const log = await runGit( | |
| 200 | ["log", "-1", "--grep=^Combined-Kept: main ", "--format=%H", tip, `^${headOid}`], | |
| 201 | { cwd: dir }, | |
| 202 | ); | |
| 203 | return log.ok && log.value.trim() !== ""; | |
| 191 | 204 | } |
| 192 | 205 | |
| 193 | 206 | /** The combined tip for this head, when the branch exists and still carries it. */ |
src/git/merge.ts +37 −12
| @@ -5,6 +5,7 @@ | ||
| 5 | 5 | * `update-ref` lands it only if the base hasn't moved under us. |
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | import { unionTree } from "./union.ts"; | |
| 8 | 9 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 9 | 10 | import { diffMemoryKey, recalledBase, rememberBase } from "./diff-memory.ts"; |
| 10 | 11 | import { runGit, runGitCapture } from "./env.ts"; |
| @@ -120,20 +121,42 @@ async function writeMergeTreeWithLegacyBase( | ||
| 120 | 121 | if (!fakeTheirs.ok) |
| 121 | 122 | return err({ kind: "git", message: fakeTheirs.error.stderr }); |
| 122 | 123 | |
| 123 | return parseWriteTree( | |
| 124 | await runGitCapture( | |
| 125 | [ | |
| 126 | "merge-tree", | |
| 127 | "--write-tree", | |
| 128 | "--name-only", | |
| 129 | fakeOurs.value, | |
| 130 | fakeTheirs.value, | |
| 131 | ], | |
| 132 | { cwd: dir, timeoutMs: 60_000 }, | |
| 124 | // The two synthesized commits share `base` as their only parent, so the union finds it too. | |
| 125 | return orUnion( | |
| 126 | dir, | |
| 127 | parseWriteTree( | |
| 128 | await runGitCapture( | |
| 129 | [ | |
| 130 | "merge-tree", | |
| 131 | "--write-tree", | |
| 132 | "--name-only", | |
| 133 | fakeOurs.value, | |
| 134 | fakeTheirs.value, | |
| 135 | ], | |
| 136 | { cwd: dir, timeoutMs: 60_000 }, | |
| 137 | ), | |
| 133 | 138 | ), |
| 139 | fakeOurs.value, | |
| 140 | fakeTheirs.value, | |
| 134 | 141 | ); |
| 135 | 142 | } |
| 136 | 143 | |
| 144 | /** | |
| 145 | * Every conflict outcome of `writeMergeTree` goes through here: two sides that only add lines at | |
| 146 | * the same place keep both (`union.ts`); any other conflict stays one. | |
| 147 | */ | |
| 148 | async function orUnion( | |
| 149 | dir: string, | |
| 150 | plain: Result<{ treeOid: string }, MergeError>, | |
| 151 | ours: string, | |
| 152 | theirs: string, | |
| 153 | base?: string, | |
| 154 | ): Promise<Result<{ treeOid: string }, MergeError>> { | |
| 155 | if (plain.ok || plain.error.kind !== "conflict") return plain; | |
| 156 | const treeOid = await unionTree(dir, ours, theirs, base); | |
| 157 | return treeOid === undefined ? plain : ok({ treeOid }); | |
| 158 | } | |
| 159 | ||
| 137 | 160 | /** |
| 138 | 161 | * Args for the modern `merge-tree --write-tree` form. Exported so a unit |
| 139 | 162 | * test can lock the flag shape without spawning git. |
| @@ -176,12 +199,13 @@ export async function writeMergeTree( | ||
| 176 | 199 | // (stdout = tree oid, blank line, then conflicted file names). Both outcomes |
| 177 | 200 | // are data on stdout, so capture regardless of exit code. |
| 178 | 201 | if (base === undefined) { |
| 179 | return parseWriteTree( | |
| 202 | const plain = parseWriteTree( | |
| 180 | 203 | await runGitCapture(mergeTreeWriteArgs(ours, theirs), { |
| 181 | 204 | cwd: dir, |
| 182 | 205 | timeoutMs: 60_000, |
| 183 | 206 | }), |
| 184 | 207 | ); |
| 208 | return orUnion(dir, plain, ours, theirs); | |
| 185 | 209 | } |
| 186 | 210 | |
| 187 | 211 | if (mergeTreeAcceptsMergeBase) { |
| @@ -189,7 +213,8 @@ export async function writeMergeTree( | ||
| 189 | 213 | cwd: dir, |
| 190 | 214 | timeoutMs: 60_000, |
| 191 | 215 | }); |
| 192 | if (!isUnknownMergeBaseOption(out.stderr)) return parseWriteTree(out); | |
| 216 | if (!isUnknownMergeBaseOption(out.stderr)) | |
| 217 | return orUnion(dir, parseWriteTree(out), ours, theirs, base); | |
| 193 | 218 | // git < 2.40: remember and fall through to the shared-parent workaround. |
| 194 | 219 | mergeTreeAcceptsMergeBase = false; |
| 195 | 220 | } |
src/git/union.ts added +138
| @@ -0,0 +1,138 @@ | ||
| 1 | /** | |
| 2 | * Two changes that only ADD lines at the same place (the common base region | |
| 3 | * is empty) are not a conflict: both are kept, the base side's first, the | |
| 4 | * pull's second, the same every time. Anything else that overlaps stays a | |
| 5 | * conflict; a side is never picked, so nothing reviewed is dropped. | |
| 6 | * | |
| 7 | * `merge-tree` leaves the conflicted files with markers in its tree; this | |
| 8 | * settles the files whose every block has an empty base and writes the tree | |
| 9 | * again. It answers undefined unless every conflicted file settled. | |
| 10 | */ | |
| 11 | ||
| 12 | import { runGit, runGitCapture } from "./env.ts"; | |
| 13 | import { isSafeOid, isSafeTreePath } from "./guard.ts"; | |
| 14 | import { applyChoices, parseConflicts } from "./conflicts.ts"; | |
| 15 | ||
| 16 | const LABELS = { ours: "main", theirs: "pull" }; | |
| 17 | ||
| 18 | /** | |
| 19 | * One conflicted file's text with every pure-addition block kept on both sides; undefined if any | |
| 20 | * block is more than that. | |
| 21 | */ | |
| 22 | export function unionText(text: string): string | undefined { | |
| 23 | const found = parseConflicts(text); | |
| 24 | if (found.blocks.length === 0 || found.blocks.some((b) => b.base !== "")) return undefined; | |
| 25 | const nl = (s: string): string => (s === "" || s.endsWith("\n") ? s : s + "\n"); | |
| 26 | const both = found.blocks.map(() => ({ kind: "text" as const, text: "" })); | |
| 27 | let at = 0; | |
| 28 | for (const part of found.parts) | |
| 29 | if (part.kind === "block") both[at++] = { kind: "text", text: nl(part.ours) + part.theirs }; | |
| 30 | const out = applyChoices(found, both, LABELS); | |
| 31 | return /^(<{7}|>{7})( |$)/m.test(out) ? undefined : out; | |
| 32 | } | |
| 33 | ||
| 34 | /** | |
| 35 | * The tree of `ours` merged with `theirs`, when every conflict is two pure additions; else | |
| 36 | * undefined. `base` overrides the merge base git would compute, as `writeMergeTree` allows. | |
| 37 | */ | |
| 38 | export async function unionTree( | |
| 39 | dir: string, | |
| 40 | ours: string, | |
| 41 | theirs: string, | |
| 42 | base?: string, | |
| 43 | ): Promise<string | undefined> { | |
| 44 | if (!isSafeOid(ours) || !isSafeOid(theirs) || (base !== undefined && !isSafeOid(base))) return undefined; | |
| 45 | const merged = await runGitCapture( | |
| 46 | [ | |
| 47 | "-c", | |
| 48 | "merge.conflictStyle=diff3", | |
| 49 | "merge-tree", | |
| 50 | "--write-tree", | |
| 51 | "--name-only", | |
| 52 | ...(base === undefined ? [] : [`--merge-base=${base}`]), | |
| 53 | ours, | |
| 54 | theirs, | |
| 55 | ], | |
| 56 | { cwd: dir, timeoutMs: 60_000 }, | |
| 57 | ); | |
| 58 | if (merged.code !== 1) return undefined; | |
| 59 | const [first, ...rest] = merged.stdout.split("\n"); | |
| 60 | const tree = (first ?? "").trim(); | |
| 61 | if (!isSafeOid(tree)) return undefined; | |
| 62 | const files: string[] = []; | |
| 63 | for (const line of rest) { | |
| 64 | if (line.trim() === "") break; | |
| 65 | if (!isSafeTreePath(line)) return undefined; | |
| 66 | files.push(line); | |
| 67 | } | |
| 68 | if (files.length === 0) return undefined; | |
| 69 | const common = base === undefined ? await runGit(["merge-base", ours, theirs], { cwd: dir }) : undefined; | |
| 70 | if (common !== undefined && !common.ok) return undefined; | |
| 71 | const baseOid = base ?? common!.value.trim(); | |
| 72 | ||
| 73 | const settled: [string, string, string][] = []; | |
| 74 | for (const path of files) { | |
| 75 | // A file both sides added whole, or one side deleted, is not an append. | |
| 76 | const there = await Promise.all( | |
| 77 | [baseOid, ours, theirs].map((c) => runGit(["cat-file", "-e", `${c}:${path}`], { cwd: dir })), | |
| 78 | ); | |
| 79 | if (there.some((r) => !r.ok)) return undefined; | |
| 80 | const entry = await runGit(["ls-tree", tree, "--", path], { cwd: dir }); | |
| 81 | const m = /^(100644|100755) blob [0-9a-f]+\t/.exec(entry.ok ? entry.value : ""); | |
| 82 | if (m === null) return undefined; | |
| 83 | const blob = await runGitCapture(["cat-file", "blob", `${tree}:${path}`], { cwd: dir }); | |
| 84 | if (blob.code !== 0 || blob.stdout.includes("\0")) return undefined; | |
| 85 | const text = unionText(blob.stdout); | |
| 86 | if (text === undefined) return undefined; | |
| 87 | const written = await runGitCapture(["hash-object", "-w", "--stdin"], { cwd: dir, stdin: text }); | |
| 88 | const oid = written.stdout.trim(); | |
| 89 | if (written.code !== 0 || !isSafeOid(oid)) return undefined; | |
| 90 | settled.push([path, m[1]!, oid]); | |
| 91 | } | |
| 92 | ||
| 93 | // The settled blobs replace the conflicted ones in the merged tree; no index, no work tree. | |
| 94 | let out: string | undefined = tree; | |
| 95 | for (const [path, mode, oid] of settled) { | |
| 96 | out = await replaceIn(dir, out, path.split("/"), mode, oid); | |
| 97 | if (out === undefined) return undefined; | |
| 98 | } | |
| 99 | return out; | |
| 100 | } | |
| 101 | ||
| 102 | /** | |
| 103 | * `tree` with the blob at `parts` replaced: the trees along the path are written again, nothing | |
| 104 | * else is touched. | |
| 105 | */ | |
| 106 | async function replaceIn( | |
| 107 | dir: string, | |
| 108 | tree: string, | |
| 109 | parts: readonly string[], | |
| 110 | mode: string, | |
| 111 | oid: string, | |
| 112 | ): Promise<string | undefined> { | |
| 113 | const listing = await runGit(["ls-tree", "-z", tree], { cwd: dir }); | |
| 114 | if (!listing.ok) return undefined; | |
| 115 | const [name, ...below] = parts; | |
| 116 | let entries = ""; | |
| 117 | let found = false; | |
| 118 | for (const entry of listing.value.split("\0")) { | |
| 119 | const m = /^(\d+) (blob|tree|commit) ([0-9a-f]+)\t(.*)$/s.exec(entry); | |
| 120 | if (m === null) continue; | |
| 121 | if (m[4] !== name) { | |
| 122 | entries += `${entry}\0`; | |
| 123 | continue; | |
| 124 | } | |
| 125 | found = true; | |
| 126 | if (below.length === 0) entries += `${mode} blob ${oid}\t${name}\0`; | |
| 127 | else { | |
| 128 | if (m[2] !== "tree") return undefined; | |
| 129 | const inner = await replaceIn(dir, m[3]!, below, mode, oid); | |
| 130 | if (inner === undefined) return undefined; | |
| 131 | entries += `040000 tree ${inner}\t${name}\0`; | |
| 132 | } | |
| 133 | } | |
| 134 | if (!found) return undefined; | |
| 135 | const made = await runGitCapture(["mktree", "-z"], { cwd: dir, stdin: entries }); | |
| 136 | const next = made.stdout.trim(); | |
| 137 | return made.code === 0 && isSafeOid(next) ? next : undefined; | |
| 138 | } |
src/tui/screens/pull.ts +1 −1
| @@ -2071,7 +2071,7 @@ const lookOrigin = (config: { | ||
| 2071 | 2071 | * terminal can open it. Nothing at all when the pull has none. |
| 2072 | 2072 | */ |
| 2073 | 2073 | /** The combined branch, said as the page's card says it (`web/pages/pull-combined.ts`). */ |
| 2074 | function combinedLines( | |
| 2074 | export function combinedLines( | |
| 2075 | 2075 | view: CombinedView | undefined, |
| 2076 | 2076 | number: number, |
| 2077 | 2077 | width: number, |
test/combined-check.test.ts +2 −2
| @@ -124,14 +124,14 @@ describe("a kept fork's pull is checked on its combined commit (#9932)", () => { | ||
| 124 | 124 | const a = join(work, "a"); |
| 125 | 125 | const b = join(work, "b"); |
| 126 | 126 | await git(["clone", `${authed("bob", bob)}/bob/app.git`, "b"], work); |
| 127 | await Bun.write(join(b, "one.txt"), "bob\n"); | |
| 127 | await Bun.write(join(b, "one.txt"), "one\nbob\n"); | |
| 128 | 128 | await Bun.write(join(b, "bob.txt"), "bob\n"); |
| 129 | 129 | await git(["add", "."], b); |
| 130 | 130 | await git(["commit", "-am", "bob's one"], b); |
| 131 | 131 | await git(["push", "origin", "main"], b); |
| 132 | 132 | |
| 133 | 133 | // Main moves on after the fork: it gains two.txt, which the check needs. |
| 134 | await Bun.write(join(a, "one.txt"), "alice\n"); | |
| 134 | await Bun.write(join(a, "one.txt"), "one\nalice\n"); | |
| 135 | 135 | await Bun.write(join(a, "two.txt"), "two\n"); |
| 136 | 136 | await git(["add", "."], a); |
| 137 | 137 | await git(["commit", "-m", "alice moves on"], a); |
test/combined-fork.test.ts +76 −30
| @@ -91,21 +91,41 @@ describe("a conflicting fork pull is combined on our side (#9012)", () => { | ||
| 91 | 91 | await rm(work, { recursive: true, force: true }); |
| 92 | 92 | }); |
| 93 | 93 | |
| 94 | test("the combined branch, its note, its preview, and the landing keep the author's head", async () => { | |
| 94 | let number = 0; | |
| 95 | let aliceMain = ""; | |
| 96 | let bare = ""; | |
| 97 | const screenText = async (): Promise<string> => { | |
| 98 | const repo = getRepo(app.db, "alice", "app")!; | |
| 99 | const user = getUserByName(app.db, "alice")!; | |
| 100 | const { app: tui, session } = createForgeApp( | |
| 101 | app, | |
| 102 | { sshUsername: "alice", remoteAddress: "127.0.0.1", user }, | |
| 103 | { terminal: headlessTerminal(200, 80), exitHooks: false }, | |
| 104 | ); | |
| 105 | tui.start(); | |
| 106 | await settleApp(tui); | |
| 107 | tui.push(pullScreen(session, tui.ctx, local(session, repo), number)); | |
| 108 | await settleApp(tui); | |
| 109 | const out = tui.screen.toText(); | |
| 110 | tui.stop(); | |
| 111 | return out; | |
| 112 | }; | |
| 113 | ||
| 114 | test("the combined branch, its note and its preview keep the author's head", async () => { | |
| 95 | 115 | const a = join(work, "a"); |
| 96 | 116 | const b = join(work, "b"); |
| 97 | 117 | await git(["clone", `${authed("bob", bob)}/bob/app.git`, "b"], work); |
| 98 | await Bun.write(join(b, "one.txt"), "bob\n"); | |
| 118 | await Bun.write(join(b, "one.txt"), "one\nbob\n"); | |
| 99 | 119 | await git(["commit", "-am", "bob's one"], b); |
| 100 | 120 | await git(["push", "origin", "main"], b); |
| 101 | 121 | const bobHead = await git(["rev-parse", "HEAD"], b); |
| 102 | 122 | |
| 103 | await Bun.write(join(a, "one.txt"), "alice\n"); | |
| 123 | await Bun.write(join(a, "one.txt"), "one\nalice\n"); | |
| 104 | 124 | await Bun.write(join(a, "two.txt"), "two\n"); |
| 105 | 125 | await git(["add", "."], a); |
| 106 | 126 | await git(["commit", "-m", "alice moves on"], a); |
| 107 | 127 | await git(["push", "origin", "main"], a); |
| 108 | const aliceMain = await git(["rev-parse", "HEAD"], a); | |
| 128 | aliceMain = await git(["rev-parse", "HEAD"], a); | |
| 109 | 129 | |
| 110 | 130 | const opened = await call("/api/v1/repos/alice/app/pulls", bob, { |
| 111 | 131 | title: "Jay's work", |
| @@ -114,16 +134,18 @@ describe("a conflicting fork pull is combined on our side (#9012)", () => { | ||
| 114 | 134 | headRepo: "bob/app", |
| 115 | 135 | }); |
| 116 | 136 | expect(opened.status).toBe(201); |
| 117 | const { number } = (await opened.json()) as { number: number }; | |
| 137 | number = ((await opened.json()) as { number: number }).number; | |
| 118 | 138 | |
| 119 | // The branch on our side: main merged into his head, main's side kept. | |
| 120 | const bare = repoDir(app.reposRoot, "alice", "app"); | |
| 139 | // The branch on our side: main merged into his head; both appended at the same place, so both | |
| 140 | // lines are kept, main's first. | |
| 141 | bare = repoDir(app.reposRoot, "alice", "app"); | |
| 121 | 142 | const tip = await git(["rev-parse", `refs/heads/combined/${number}`], bare); |
| 122 | 143 | expect((await git(["log", "-1", "--format=%P", tip], bare)).split(" ")).toEqual([bobHead, aliceMain]); |
| 123 | expect(await git(["show", `${tip}:one.txt`], bare)).toBe("alice"); | |
| 144 | expect(await git(["show", `${tip}:one.txt`], bare)).toBe("one\nalice\nbob"); | |
| 124 | 145 | expect(await git(["show", `${tip}:two.txt`], bare)).toBe("two"); |
| 125 | 146 | const note = await git(["log", "-1", "--format=%B", tip], bare); |
| 126 | expect(note).toContain("- one.txt: 1 block, main's side kept"); | |
| 147 | expect(note).toContain("Nothing of yours was dropped"); | |
| 148 | expect(note).not.toContain("Combined-Kept"); | |
| 127 | 149 | // His branch is untouched. |
| 128 | 150 | expect(await git(["ls-remote", "origin", "refs/heads/main"], b)).toStartWith(bobHead); |
| 129 | 151 | |
| @@ -136,34 +158,20 @@ describe("a conflicting fork pull is combined on our side (#9012)", () => { | ||
| 136 | 158 | const page = await call(`/alice/app/pulls/${number}`, alice); |
| 137 | 159 | const body = await page.text(); |
| 138 | 160 | expect(body).toContain(`combined/${number}`); |
| 139 | expect(body).toContain("one.txt: 1 block, main's side kept"); | |
| 140 | ||
| 141 | expect(body).toContain("your side was dropped from the landed tree"); | |
| 142 | expect(body).toContain("To get your side back"); | |
| 161 | expect(body).not.toContain("your side was dropped"); | |
| 143 | 162 | expect(body).toContain("The checks line says which commit the check ran on."); |
| 144 | 163 | expect(body).not.toContain("Stale: main has moved"); |
| 145 | 164 | |
| 146 | 165 | // The terminal's pull screen says the same (#9012). |
| 147 | const screenText = async (): Promise<string> => { | |
| 148 | const user = getUserByName(app.db, "alice")!; | |
| 149 | const { app: tui, session } = createForgeApp( | |
| 150 | app, | |
| 151 | { sshUsername: "alice", remoteAddress: "127.0.0.1", user }, | |
| 152 | { terminal: headlessTerminal(200, 80), exitHooks: false }, | |
| 153 | ); | |
| 154 | tui.start(); | |
| 155 | await settleApp(tui); | |
| 156 | tui.push(pullScreen(session, tui.ctx, local(session, repo), number)); | |
| 157 | await settleApp(tui); | |
| 158 | const out = tui.screen.toText(); | |
| 159 | tui.stop(); | |
| 160 | return out; | |
| 161 | }; | |
| 162 | 166 | const shown = await screenText(); |
| 163 | 167 | expect(shown).toContain("Combined with main"); |
| 164 | 168 | expect(shown).toContain(`combined/${number} (${tip.slice(0, 10)})`); |
| 165 | expect(shown).toContain("one.txt: 1 block, main's side kept; your side was dropped"); | |
| 169 | }, 30_000); | |
| 166 | 170 | |
| 171 | test("a push to the fork recombines, main moving marks it stale, and the landing keeps his head", async () => { | |
| 172 | const a = join(work, "a"); | |
| 173 | const b = join(work, "b"); | |
| 174 | const repo = getRepo(app.db, "alice", "app")!; | |
| 167 | 175 | // A push to the fork recombines: the new head is the branch's first parent. |
| 168 | 176 | await Bun.write(join(b, "three.txt"), "three\n"); |
| 169 | 177 | await git(["add", "."], b); |
| @@ -200,6 +208,44 @@ describe("a conflicting fork pull is combined on our side (#9012)", () => { | ||
| 200 | 208 | await git(["pull", "--no-rebase", "origin", "main"], a); |
| 201 | 209 | await git(["merge-base", "--is-ancestor", bobNext, "HEAD"], a); |
| 202 | 210 | expect(await Bun.file(join(a, "four.txt")).text()).toBe("four\n"); |
| 203 | expect(await Bun.file(join(a, "one.txt")).text()).toBe("alice\n"); | |
| 211 | expect(await Bun.file(join(a, "one.txt")).text()).toBe("one\nalice\nbob\n"); | |
| 212 | }, 30_000); | |
| 213 | ||
| 214 | test("an overlapping edit is held with a sentence, and main drops nothing (#12104)", async () => { | |
| 215 | const a = join(work, "a"); | |
| 216 | const b = join(work, "b"); | |
| 217 | await git(["pull", "--no-rebase", "origin", "main"], b); | |
| 218 | await Bun.write(join(b, "five.txt"), "bob five\n"); | |
| 219 | await git(["add", "."], b); | |
| 220 | await git(["commit", "-m", "bob's five"], b); | |
| 221 | await git(["push", "origin", "main"], b); | |
| 222 | await Bun.write(join(a, "five.txt"), "alice five\n"); | |
| 223 | await git(["add", "."], a); | |
| 224 | await git(["commit", "-m", "alice's five"], a); | |
| 225 | await git(["push", "origin", "main"], a); | |
| 226 | const mainTip = await git(["rev-parse", "HEAD"], a); | |
| 227 | // Both added five.txt whole with different words: overlap, not an append. | |
| 228 | const opened = await call("/api/v1/repos/alice/app/pulls", bob, { | |
| 229 | title: "Five", | |
| 230 | base: "main", | |
| 231 | head: "main", | |
| 232 | headRepo: "bob/app", | |
| 233 | }); | |
| 234 | expect(opened.status).toBe(201); | |
| 235 | const { number } = (await opened.json()) as { number: number }; | |
| 236 | const bare = repoDir(app.reposRoot, "alice", "app"); | |
| 237 | ||
| 238 | // No combined build, and the pull says why in one sentence. | |
| 239 | const nope = Bun.spawnSync(["git", "rev-parse", "--verify", "-q", `refs/heads/combined/${number}`], { cwd: bare }); | |
| 240 | expect(nope.exitCode).not.toBe(0); | |
| 241 | const page = await (await call(`/alice/app/pulls/${number}`, alice)).text(); | |
| 242 | expect(page).toContain(`#${number} is held: it overlaps main in five.txt`); | |
| 243 | expect(page).toContain("waits for its author to merge main into their branch and push"); | |
| 244 | expect(page).not.toContain("author's crew"); | |
| 245 | ||
| 246 | // Landing it is refused, and main is as it was. | |
| 247 | const merged = await call(`/api/v1/repos/alice/app/pulls/${number}/merge`, alice, { strategy: "merge" }); | |
| 248 | expect(merged.status).toBe(409); | |
| 249 | expect(await git(["rev-parse", "refs/heads/main"], bare)).toBe(mainTip); | |
| 204 | 250 | }, 30_000); |
| 205 | 251 | }); |
test/combined-git.test.ts +70 −37
| @@ -43,51 +43,71 @@ afterAll(async () => { | ||
| 43 | 43 | await rm(dir, { recursive: true, force: true }); |
| 44 | 44 | }); |
| 45 | 45 | |
| 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 | const made = await combine(gitDir(), { number: 7, baseOid: base, headOid: head }); | |
| 50 | expect(made).toBeDefined(); | |
| 51 | const files = [...(made!.files ?? [])]; | |
| 52 | expect(files).toEqual([{ path: "one.txt", blocks: 1, kept: "main" }]); | |
| 53 | expect(git("show", `${made!.oid}:one.txt`)).toBe("main"); | |
| 54 | // The page and the terminal read the same facts back, from trailers. | |
| 55 | const read = await readCombined(gitDir(), 7, head); | |
| 56 | expect(read?.baseOid).toBe(base); | |
| 57 | expect([...read!.files]).toEqual(files); | |
| 58 | const note = git("log", "-1", "--format=%B", made!.oid); | |
| 59 | expect(note).toContain("your side was dropped from the landed tree"); | |
| 46 | test("two sides that only append at the same place keep both, main's first, with no trailer", async () => { | |
| 47 | const seed = commitOn("main", { "t.txt": "a\n" }, "a test file"); | |
| 48 | git("branch", "-f", "adds", seed); | |
| 49 | const head = commitOn("adds", { "t.txt": "a\nfork test\n" }, "fork appends"); | |
| 50 | const base = commitOn("main", { "t.txt": "a\nmain test\n" }, "main appends"); | |
| 51 | const made = await combine(gitDir(), { number: 8, baseOid: base, headOid: head }); | |
| 52 | expect(made?.held).toBeUndefined(); | |
| 53 | expect(git("show", `${made!.oid}:t.txt`)).toBe("a\nmain test\nfork test"); | |
| 54 | expect(git("log", "-1", "--format=%B", made!.oid)).not.toContain("Combined-Kept"); | |
| 55 | // The same two sides, built again, give the same tree. | |
| 56 | git("update-ref", "-d", "refs/heads/combined/8"); | |
| 57 | const again = await combine(gitDir(), { number: 8, baseOid: base, headOid: head }); | |
| 58 | expect(git("rev-parse", `${again!.oid}^{tree}`)).toBe(git("rev-parse", `${made!.oid}^{tree}`)); | |
| 60 | 59 | }); |
| 61 | 60 | |
| 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(); | |
| 61 | test("an overlapping edit is held: no combined commit, no side picked, one sentence", async () => { | |
| 62 | const head = commitOn("fork", { "one.txt": "fork\n", "gone.txt": "changed\n" }, "fork edits"); | |
| 63 | const base = commitOn("main", { "one.txt": "main\n", "gone.txt": null }, "main deletes"); | |
| 64 | const made = await combine(gitDir(), { number: 7, baseOid: base, headOid: head }); | |
| 65 | expect(made?.held).toBe(true); | |
| 66 | expect(made!.oid).toBe(head); | |
| 67 | expect(made!.files!.map((f) => f.path).sort()).toEqual(["gone.txt", "one.txt"]); | |
| 68 | expect(Bun.spawnSync(["git", "rev-parse", "--verify", "-q", "combined/7"], { cwd: dir, env }).exitCode).not.toBe(0); | |
| 69 | const sentence = await readFailure(gitDir(), 7); | |
| 70 | expect(sentence).toContain("#7 is held: it overlaps main in"); | |
| 71 | expect(sentence).toContain("nothing was dropped"); | |
| 72 | expect(sentence).toContain("waits for its author to merge main into their branch and push"); | |
| 73 | expect(sentence).not.toContain("crew"); | |
| 74 | expect(await readCombined(gitDir(), 7, head)).toBeUndefined(); | |
| 75 | }); | |
| 76 | ||
| 77 | test("a combined tip an older forge built by dropping a side is never built on or landed", async () => { | |
| 78 | const head = git("rev-parse", "fork"); | |
| 79 | const base = git("rev-parse", "main"); | |
| 80 | const old = git( | |
| 81 | "commit-tree", `${base}^{tree}`, "-p", head, "-p", base, | |
| 82 | "-m", "old\n\nCombined-Kept: main 1 one.txt", | |
| 83 | ); | |
| 84 | git("update-ref", "refs/heads/combined/7", old); | |
| 85 | // Combining (what a landing and every rebuild start with) builds again from the head. | |
| 86 | const made = await combine(gitDir(), { number: 7, baseOid: base, headOid: head }); | |
| 87 | expect(made).toBeDefined(); | |
| 88 | expect(made!.oid).not.toBe(old); | |
| 89 | // The old tip is not built on: the pull overlaps main, so it is held at its own head. | |
| 90 | expect(made).toMatchObject({ oid: head, held: true }); | |
| 73 | 91 | }); |
| 74 | 92 | |
| 75 | 93 | test("a writer's commit on the combined branch survives main moving on", async () => { |
| 76 | const head = git("rev-parse", "fork"); | |
| 77 | const tip = git("rev-parse", "combined/7"); | |
| 78 | const fixed = commitOn("combined/7", { "one.txt": "both\n" }, "a writer resolves"); | |
| 79 | expect(git("rev-parse", "combined/7")).not.toBe(tip); | |
| 80 | const base = commitOn("main", { "two.txt": "two\n" }, "main moves"); | |
| 81 | const again = await combine(gitDir(), { number: 7, baseOid: base, headOid: head }); | |
| 82 | expect(git("log", "-1", "--format=%P", again!.oid).split(" ")).toEqual([fixed, base]); | |
| 83 | expect(git("show", `${again!.oid}:one.txt`)).toBe("both"); | |
| 94 | const head = commitOn("adds", { "u.txt": "u\n" }, "fork adds u"); | |
| 95 | const base = git("rev-parse", "main"); | |
| 96 | const first = await combine(gitDir(), { number: 9, baseOid: base, headOid: head }); | |
| 97 | const fixed = commitOn("combined/9", { "t.txt": "both\n" }, "a writer resolves"); | |
| 98 | expect(git("rev-parse", "combined/9")).toBe(fixed); | |
| 99 | const next = commitOn("main", { "two.txt": "two\n" }, "main moves"); | |
| 100 | const again = await combine(gitDir(), { number: 9, baseOid: next, headOid: head }); | |
| 101 | expect(first).toBeDefined(); | |
| 102 | expect(git("log", "-1", "--format=%P", again!.oid).split(" ")).toEqual([fixed, next]); | |
| 103 | expect(git("show", `${again!.oid}:t.txt`)).toBe("both"); | |
| 84 | 104 | expect(git("show", `${again!.oid}:two.txt`)).toBe("two"); |
| 85 | 105 | }); |
| 86 | 106 | |
| 87 | 107 | test("a combined preview runs under the head it carries, and nothing else does", () => { |
| 88 | const head = git("rev-parse", "fork"); | |
| 89 | const tip = git("rev-parse", "combined/7"); | |
| 90 | const pull = { crossRepo: true, number: 7, headOid: head }; | |
| 108 | const head = git("rev-parse", "adds"); | |
| 109 | const tip = git("rev-parse", "combined/9"); | |
| 110 | const pull = { crossRepo: true, number: 9, headOid: git("rev-parse", "adds") }; | |
| 91 | 111 | expect(headUnder(gitDir(), pull, tip)).toBe(head); |
| 92 | 112 | // Not the combined tip: it answers for itself, so its own approval is asked. |
| 93 | 113 | const main = git("rev-parse", "main"); |
| @@ -113,7 +133,20 @@ test("a combined branch carrying a writer's commits is kept when main later dele | ||
| 113 | 133 | expect(await combine(gitDir(), { number: 9, baseOid: main, headOid: head })).toBeDefined(); |
| 114 | 134 | const written = commitOn("combined/9", { "two.txt": "writer\n" }, "a writer resolves"); |
| 115 | 135 | const base = commitOn("main", { "two.txt": null }, "main deletes two.txt"); |
| 116 | expect(await combine(gitDir(), { number: 9, baseOid: base, headOid: head })).toBeUndefined(); | |
| 136 | expect(await combine(gitDir(), { number: 9, baseOid: base, headOid: head }).then((m) => m?.held)).toBe(true); | |
| 117 | 137 | expect(git("rev-parse", "combined/9")).toBe(written); |
| 118 | 138 | expect(await readFailure(gitDir(), 9)).toContain("two.txt"); |
| 119 | 139 | }); |
| 140 | ||
| 141 | test("a held rebuild takes down a branch the forge's own combining made (#12151)", async () => { | |
| 142 | commitOn("main", { "three.txt": "three\n" }, "main adds three.txt"); | |
| 143 | git("branch", "own", "main"); | |
| 144 | const head = commitOn("own", { "three.txt": "fork\n" }, "fork edits three.txt"); | |
| 145 | const main = git("rev-parse", "main"); | |
| 146 | expect(await combine(gitDir(), { number: 10, baseOid: main, headOid: head })).toBeDefined(); | |
| 147 | const base = commitOn("main", { "three.txt": null }, "main deletes three.txt"); | |
| 148 | const made = await combine(gitDir(), { number: 10, baseOid: base, headOid: head }); | |
| 149 | expect(made?.held).toBe(true); | |
| 150 | expect(Bun.spawnSync(["git", "rev-parse", "--verify", "--quiet", "refs/heads/combined/10"], { cwd: gitDir() }).exitCode).not.toBe(0); | |
| 151 | expect(await readFailure(gitDir(), 10)).toContain("three.txt"); | |
| 152 | }); |
test/combined-held-words.test.ts added +26
| @@ -0,0 +1,26 @@ | ||
| 1 | /** | |
| 2 | * A pull held for overlapping main (#12104) is said in one sentence, the same on the page's card | |
| 3 | * and the terminal's pull screen: both word it from `combinedWords`. | |
| 4 | */ | |
| 5 | import { expect, test } from "bun:test"; | |
| 6 | import { combinedWords, type CombinedView } from "../src/core/combined_view.ts"; | |
| 7 | import { heldSentence } from "../src/git/combined.ts"; | |
| 8 | import { combinedLines } from "../src/tui/screens/pull.ts"; | |
| 9 | import { theme } from "../src/tui/ui/theme.ts"; | |
| 10 | ||
| 11 | const said = heldSentence(7, [{ path: "one.txt", blocks: 0, kept: "open" }]); | |
| 12 | const view: CombinedView = { branch: "combined/7", oid: "", builtOn: "", stale: false, files: [], failure: said }; | |
| 13 | ||
| 14 | test("a held pull's words are its one sentence, with no second line saying the same", () => { | |
| 15 | expect(combinedWords(view, 7)).toEqual({ lead: said, files: [], notes: [] }); | |
| 16 | // It says what is true: nothing sends the pull anywhere; its author merges main in. | |
| 17 | expect(said).toBe( | |
| 18 | "#7 is held: it overlaps main in one.txt, nothing was dropped, and it waits for its author to merge main into their branch and push.", | |
| 19 | ); | |
| 20 | }); | |
| 21 | ||
| 22 | test("the terminal's pull screen shows the held sentence", () => { | |
| 23 | const lines = combinedLines(view, 7, 200, theme("dark").c).map((l) => l.map((s) => s.text).join("")); | |
| 24 | expect(lines.some((l) => l.includes("Not combined with main"))).toBe(true); | |
| 25 | expect(lines.join(" ")).toContain("overlaps main in one.txt, nothing was dropped"); | |
| 26 | }); |
test/combined-recombine.test.ts +4 −3
| @@ -280,16 +280,17 @@ test("a refusal's own sentence is kept, not replaced by the generic one", async | ||
| 280 | 280 | await git(["push", "origin", "main"], a); |
| 281 | 281 | await idle(repoId); |
| 282 | 282 | const said = await readFailure(bare, third); |
| 283 | expect(said).toContain("The rebuild stopped: one.txt"); | |
| 283 | expect(said).toContain("#" + third + " is held: it overlaps main in one.txt"); | |
| 284 | 284 | expect(said).not.toContain("git could not merge"); |
| 285 | expect(said).toContain("waits for its author to merge main into their branch and push"); | |
| 285 | 286 | // The page and the terminal word the card from one view: the sentence is on both. |
| 286 | 287 | const { combinedView, combinedWords } = await import("../src/core/combined_view.ts"); |
| 287 | 288 | const head = await git(["rev-parse", `refs/pull/${third}/head`], bare); |
| 288 | 289 | 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 | expect(combinedWords(view!, third).lead).toBe(said!); | |
| 290 | 291 | const page = (await (await call(`/alice/app/pulls/${third}`, alice)).text()).replace(/'/g, "'"); |
| 291 | 292 | expect(page).toContain("Not combined with main"); |
| 292 | expect(page).toContain("The rebuild stopped: one.txt"); | |
| 293 | expect(page).toContain("is held: it overlaps main in one.txt"); | |
| 293 | 294 | }); |
| 294 | 295 | |
| 295 | 296 | test("a build that throws still leaves a sentence", async () => { |
test/crew-merge-conflict.test.ts +2 −2
| @@ -57,7 +57,7 @@ describe("the crew, with a pull that does not merge", () => { | ||
| 57 | 57 | const conflicted = async (path: string): Promise<Pull> => { |
| 58 | 58 | const branch = `clash-${++n}`; |
| 59 | 59 | await commit("main", path, "- base\n"); |
| 60 | await commit(branch, path, "- base\n- theirs\n"); | |
| 60 | await commit(branch, path, "- theirs\n"); | |
| 61 | 61 | const o = await openPull(app, bot, repo, { title: branch, baseBranch: "main", headBranch: branch }); |
| 62 | 62 | if (!o.ok) throw new Error(String(o.error)); |
| 63 | 63 | app.db.run("UPDATE issue SET assignee_id = ?1 WHERE id = ?2", [bot.id, o.value.id]); |
| @@ -66,7 +66,7 @@ describe("the crew, with a pull that does not merge", () => { | ||
| 66 | 66 | claimRun(app.db, tried.id); |
| 67 | 67 | finishRun(app.db, tried.id, { state: "ok", summary: "reworked" }); |
| 68 | 68 | } |
| 69 | await commit("main", path, "- base\n- ours\n"); | |
| 69 | await commit("main", path, "- ours\n"); | |
| 70 | 70 | return getPull(app.db, repo.id, o.value.number)!; |
| 71 | 71 | }; |
| 72 | 72 | const redForConflict = (pull: Pull) => { |
test/landsim.test.ts +2 −3
| @@ -69,9 +69,8 @@ describe("the landing simulator", () => { | ||
| 69 | 69 | expect(corpus.landed).toBeGreaterThan(0); |
| 70 | 70 | }, 300_000); |
| 71 | 71 | |
| 72 | // #12104: when two changes add lines at the same place, a kept fork's landing keeps main's side only. | |
| 73 | // This holds the reproducer. It fails today; when the issue is fixed it passes, which fails the suite: remove `.failing`. | |
| 74 | test.failing( | |
| 72 | // #12104: when two changes add lines at the same place, a kept fork's landing keeps both sides. | |
| 73 | test( | |
| 75 | 74 | "two fork pulls that add lines at the end of one file both land, both sides present (#12104)", |
| 76 | 75 | async () => { |
| 77 | 76 | const done = await landInOrder( |
test/studio-shipping.test.ts +1 −1
| @@ -667,7 +667,7 @@ describe("a person's own change ships from its own studio page", () => { | ||
| 667 | 667 | test("a conflicted change alice opened herself offers the bring-up-to-date wording, never a Ship that would be refused", async () => { |
| 668 | 668 | const base1 = await commitToBranch(app, alice, repo, { |
| 669 | 669 | message: "Add a file two branches will fight over", |
| 670 | files: [{ path: "CONFLICT.md", content: "base\n" }], | |
| 670 | files: [{ path: "CONFLICT.md", content: "base\nline\n" }], | |
| 671 | 671 | }); |
| 672 | 672 | if (!base1.ok) throw new Error(JSON.stringify(base1.error)); |
| 673 | 673 | const branch = await commitToBranch(app, alice, repo, { |
test/studio.test.ts +1 −1
| @@ -662,7 +662,7 @@ describe("the studio", () => { | ||
| 662 | 662 | const scout = getUserByName(app.db, "scout")!; |
| 663 | 663 | const base1 = await commitToBranch(app, alice, repo, { |
| 664 | 664 | message: "Add a file two branches will fight over", |
| 665 | files: [{ path: "CONFLICT.md", content: "base\n" }], | |
| 665 | files: [{ path: "CONFLICT.md", content: "base\nline\n" }], | |
| 666 | 666 | }); |
| 667 | 667 | if (!base1.ok) throw new Error(JSON.stringify(base1.error)); |
| 668 | 668 | const branch = await commitToBranch(app, alice, repo, { |
test/union-merge.test.ts added +126
| @@ -0,0 +1,126 @@ | ||
| 1 | /** | |
| 2 | * Landing never drops a reviewed line (#12104): two pulls that only append at the same place both | |
| 3 | * land; an overlap is refused. | |
| 4 | */ | |
| 5 | import { afterEach, beforeEach, expect, test } from "bun:test"; | |
| 6 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 7 | import { tmpdir } from "node:os"; | |
| 8 | import { join } from "node:path"; | |
| 9 | import { mergeable, merge, setMergeTreeAcceptsMergeBaseForTest, writeMergeTree } from "../src/git/merge.ts"; | |
| 10 | import { unionText } from "../src/git/union.ts"; | |
| 11 | import { gitClientEnv } from "./helpers/git-client.ts"; | |
| 12 | ||
| 13 | const env = gitClientEnv({ | |
| 14 | GIT_AUTHOR_NAME: "t", | |
| 15 | GIT_AUTHOR_EMAIL: "[email protected]", | |
| 16 | GIT_COMMITTER_NAME: "t", | |
| 17 | GIT_COMMITTER_EMAIL: "[email protected]", | |
| 18 | }); | |
| 19 | let dir = ""; | |
| 20 | const gitDir = (): string => join(dir, ".git"); | |
| 21 | const git = (...args: string[]): string => { | |
| 22 | const p = Bun.spawnSync(["git", ...args], { cwd: dir, env, stdout: "pipe", stderr: "pipe" }); | |
| 23 | if (p.exitCode !== 0) throw new Error(`git ${args.join(" ")}: ${p.stderr.toString()}`); | |
| 24 | return p.stdout.toString().replace(/\n$/, ""); | |
| 25 | }; | |
| 26 | function commitOn(branch: string, path: string, text: string): string { | |
| 27 | if (git("symbolic-ref", "--short", "HEAD") !== branch) git("checkout", "-q", branch); | |
| 28 | Bun.spawnSync(["sh", "-c", 'printf "%s" "$1" > "$2"', "_", text, path], { cwd: dir }); | |
| 29 | git("add", path); | |
| 30 | git("commit", "-q", "-m", `${branch} edits ${path}`); | |
| 31 | return git("rev-parse", "HEAD"); | |
| 32 | } | |
| 33 | const author = { name: "t", email: "[email protected]" }; | |
| 34 | ||
| 35 | beforeEach(async () => { | |
| 36 | dir = await mkdtemp(join(tmpdir(), "forge-union-")); | |
| 37 | git("init", "-q", "-b", "main"); | |
| 38 | commitOn("main", "links.test.ts", "// tests\n"); | |
| 39 | git("branch", "one"); | |
| 40 | git("branch", "two"); | |
| 41 | }); | |
| 42 | afterEach(async () => { | |
| 43 | await rm(dir, { recursive: true, force: true }); | |
| 44 | }); | |
| 45 | ||
| 46 | async function land(branch: string, base: string): Promise<void> { | |
| 47 | const landed = await merge(gitDir(), { | |
| 48 | baseBranch: "main", | |
| 49 | baseOid: base, | |
| 50 | headOid: git("rev-parse", branch), | |
| 51 | strategy: "squash", | |
| 52 | message: `land ${branch}`, | |
| 53 | author, | |
| 54 | }); | |
| 55 | expect(landed.ok).toBe(true); | |
| 56 | } | |
| 57 | ||
| 58 | for (const order of [["one", "two"], ["two", "one"]] as const) { | |
| 59 | test(`two pulls that each append a test to the same file both land, ${order.join(" then ")}`, async () => { | |
| 60 | commitOn("one", "links.test.ts", "// tests\ntest('one');\n"); | |
| 61 | commitOn("two", "links.test.ts", "// tests\ntest('two');\n"); | |
| 62 | await land(order[0], git("rev-parse", "main")); | |
| 63 | await land(order[1], git("rev-parse", "main")); | |
| 64 | const text = git("show", "main:links.test.ts"); | |
| 65 | expect(text).toContain("test('one');"); | |
| 66 | expect(text).toContain("test('two');"); | |
| 67 | // Main's side first, then the pull's. | |
| 68 | expect(text.indexOf(`test('${order[0]}')`)).toBeLessThan(text.indexOf(`test('${order[1]}')`)); | |
| 69 | }); | |
| 70 | } | |
| 71 | ||
| 72 | test("an overlapping edit is a conflict: nothing lands, main is unmoved", async () => { | |
| 73 | commitOn("one", "links.test.ts", "// one\n"); | |
| 74 | commitOn("two", "links.test.ts", "// two\n"); | |
| 75 | await land("one", git("rev-parse", "main")); | |
| 76 | const main = git("rev-parse", "main"); | |
| 77 | const two = git("rev-parse", "two"); | |
| 78 | const result = await mergeable(gitDir(), main, two); | |
| 79 | expect(result.ok).toBe(false); | |
| 80 | const landed = await merge(gitDir(), { baseBranch: "main", baseOid: main, headOid: two, strategy: "squash", message: "x", author }); | |
| 81 | expect(landed.ok).toBe(false); | |
| 82 | expect(git("rev-parse", "main")).toBe(main); | |
| 83 | }); | |
| 84 | ||
| 85 | test("a block with anything in its base is not a pure addition", () => { | |
| 86 | const block = (base: string) => `a\n<<<<<<< ours\nm\n||||||| base\n${base}=======\np\n>>>>>>> theirs\nz\n`; | |
| 87 | expect(unionText(block(""))).toBe("a\nm\np\nz\n"); | |
| 88 | expect(unionText(block("old\n"))).toBeUndefined(); | |
| 89 | expect(unionText("no markers\n")).toBeUndefined(); | |
| 90 | }); | |
| 91 | ||
| 92 | test("a file in a nested directory settles too, and its neighbours are untouched", async () => { | |
| 93 | Bun.spawnSync(["mkdir", "-p", "test/deep"], { cwd: dir }); | |
| 94 | commitOn("main", "test/deep/links.test.ts", "// tests\n"); | |
| 95 | commitOn("main", "README.md", "# list\n"); | |
| 96 | git("branch", "-f", "one", "main"); | |
| 97 | git("branch", "-f", "two", "main"); | |
| 98 | commitOn("one", "test/deep/links.test.ts", "// tests\nconst one = 1;\n"); | |
| 99 | commitOn("two", "test/deep/links.test.ts", "// tests\nconst two = 2;\n"); | |
| 100 | const result = await mergeable(gitDir(), git("rev-parse", "one"), git("rev-parse", "two")); | |
| 101 | expect(result.ok).toBe(true); | |
| 102 | if (!result.ok) return; | |
| 103 | expect(git("show", `${result.value.treeOid}:test/deep/links.test.ts`)).toBe("// tests\nconst one = 1;\nconst two = 2;"); | |
| 104 | expect(git("show", `${result.value.treeOid}:README.md`)).toBe("# list"); | |
| 105 | }); | |
| 106 | ||
| 107 | for (const legacy of [false, true]) { | |
| 108 | test(`a merge with its base given keeps both pure additions too${legacy ? ", on git without --merge-base" : ""}`, async () => { | |
| 109 | const base = git("rev-parse", "main"); | |
| 110 | commitOn("one", "links.test.ts", "// tests\ntest('one');\n"); | |
| 111 | commitOn("two", "links.test.ts", "// tests\ntest('two');\n"); | |
| 112 | if (legacy) setMergeTreeAcceptsMergeBaseForTest(false); | |
| 113 | try { | |
| 114 | const result = await writeMergeTree(gitDir(), git("rev-parse", "one"), git("rev-parse", "two"), base); | |
| 115 | expect(result.ok).toBe(true); | |
| 116 | if (!result.ok) return; | |
| 117 | expect(git("show", `${result.value.treeOid}:links.test.ts`)).toBe("// tests\ntest('one');\ntest('two');"); | |
| 118 | // An overlap with the base given is still a conflict. | |
| 119 | commitOn("two", "links.test.ts", "// two\n"); | |
| 120 | const overlap = await writeMergeTree(gitDir(), git("rev-parse", "one"), git("rev-parse", "two"), base); | |
| 121 | expect(overlap.ok).toBe(false); | |
| 122 | } finally { | |
| 123 | setMergeTreeAcceptsMergeBaseForTest(true); | |
| 124 | } | |
| 125 | }); | |
| 126 | } |
new above
offline