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

Landing never drops a contributor's reviewed lines: a combined build that had… #12119

Merged opened this in answer to

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

Merged

mason/forge:mason/12104-landing-never-drops-a-contributor-s-revi → main 24 commits fork 5 threads · 5 unresolved

  • Merged. mason/12104-landing-never-drops-a-contributor-s-revi was merged into main as 8a17a37.

Review

Sign in to review this change.
17 files changed +564 −153
scripts/gate.ts +1 −1
@@ -112,7 +112,7 @@ export const SLOW: readonly Exclusion[] = [
112112 { 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 },
113113 { 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 },
114114 { 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 },
116116 { 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 },
117117 { 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 },
118118 { 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(
5757 const notes: string[] = [];
5858 if (view.oid === "")
5959 return {
60 lead: "There is no combined preview: main could not be merged into this pull's head.",
60 lead: view.failure,
6161 files: [],
62 notes: [view.failure],
62 notes: [],
6363 };
6464 if (view.stale)
6565 notes.push(
src/core/pulls.ts +4
@@ -1264,7 +1264,11 @@ export async function mergePull(
12641264 input.sealedFrom === undefined && input.strategy === "merge" && fromKeptFork(db, pull)
12651265 ? await combine(dir, { number: pull.number, baseOid: status.baseOid, headOid: status.headOid })
12661266 : 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) });
12671270 const asIs =
1271 combined === undefined &&
12681272 input.fastForward === true &&
12691273 input.expect === undefined &&
12701274 (await landsAsIs(dir, status.baseOid, status.headOid));
src/git/combined.ts +71 −58
@@ -1,10 +1,12 @@
11 /**
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
33 * 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.
810 *
911 * Commits pushed on top of the combined branch (a resolution done by hand)
1012 * 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";
1517 import { isSafeOid, isSafeTreePath } from "./guard.ts";
1618 import { refTip } from "./read.ts";
1719 import { mergeBase, resolveOid } from "./merge.ts";
18 import { parseConflicts } from "./conflicts.ts";
20 import { unionTree } from "./union.ts";
1921
2022 /** The branch a pull's combined tree lives on, in the base repository. */
2123 export const combinedBranch = (number: number): string => `combined/${number}`;
@@ -26,7 +28,7 @@ export interface Kept {
2628 readonly path: string;
2729 /** Conflicting blocks in the file; 0 when it is not a text conflict. */
2830 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. */
3032 readonly kept: "main" | "open";
3133 }
3234
@@ -46,24 +48,15 @@ async function carriesWriter(dir: string, headOid: string, tip: string): Promise
4648 return out.ok && out.value.split("\n").some((e) => e.trim() !== "" && e.trim() !== "forge@localhost");
4749 }
4850
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)})`,
5355 "",
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.",
5557 "",
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");
6760 }
6861
6962 /** One conflicted file, said to its author. */
@@ -77,7 +70,6 @@ export const TAKE_BACK =
7770 "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.";
7871
7972 const BASE_TRAILER = "Combined-Base";
80 const KEPT_TRAILER = "Combined-Kept";
8173
8274 /** The trailers of a combining commit, read back as data. */
8375 export function parseCombined(message: string): { baseOid: string; files: Kept[] } {
@@ -117,6 +109,23 @@ async function mergeTree(
117109 return { tree, files };
118110 }
119111
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
120129 /**
121130 * Write (or bring up to date) the combined branch of pull `number`: main at
122131 * `baseOid` merged into `headOid`. Answers the combined tip, and the files
@@ -125,22 +134,27 @@ async function mergeTree(
125134 export async function combine(
126135 dir: string,
127136 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;
132144 }
133145
134146 async function build(
135147 dir: string,
136148 input: { readonly number: number; readonly baseOid: string; readonly headOid: string },
137 ): Promise<{ oid: string; files: Kept[] | null } | undefined> {
149 ): Promise<Combined | undefined> {
138150 const { number, baseOid, headOid } = input;
139151 if (!Number.isInteger(number) || number < 1 || !isSafeOid(baseOid) || !isSafeOid(headOid))
140152 return undefined;
141153 const ref = combinedRef(number);
142154 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;
144158 if (from === prior && (await isAncestor(dir, baseOid, from))) return { oid: from, files: null };
145159
146160 const update = async (oid: string): Promise<boolean> =>
@@ -152,42 +166,41 @@ async function build(
152166 const plain = await mergeTree(dir, baseOid, from, []);
153167 if (plain === undefined) return undefined;
154168 let tree = plain.tree;
155 const files: Kept[] = [];
156169 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 };
165182 }
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;
183184 }
184185 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)],
186187 { cwd: dir, env: IDENTITY },
187188 );
188189 if (!commit.ok) return undefined;
189190 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() !== "";
191204 }
192205
193206 /** The combined tip for this head, when the branch exists and still carries it. */
src/git/merge.ts +37 −12
@@ -5,6 +5,7 @@
55 * `update-ref` lands it only if the base hasn't moved under us.
66 */
77
8 import { unionTree } from "./union.ts";
89 import { err, ok, type Result } from "../kernel/result.ts";
910 import { diffMemoryKey, recalledBase, rememberBase } from "./diff-memory.ts";
1011 import { runGit, runGitCapture } from "./env.ts";
@@ -120,20 +121,42 @@ async function writeMergeTreeWithLegacyBase(
120121 if (!fakeTheirs.ok)
121122 return err({ kind: "git", message: fakeTheirs.error.stderr });
122123
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 ),
133138 ),
139 fakeOurs.value,
140 fakeTheirs.value,
134141 );
135142 }
136143
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
137160 /**
138161 * Args for the modern `merge-tree --write-tree` form. Exported so a unit
139162 * test can lock the flag shape without spawning git.
@@ -176,12 +199,13 @@ export async function writeMergeTree(
176199 // (stdout = tree oid, blank line, then conflicted file names). Both outcomes
177200 // are data on stdout, so capture regardless of exit code.
178201 if (base === undefined) {
179 return parseWriteTree(
202 const plain = parseWriteTree(
180203 await runGitCapture(mergeTreeWriteArgs(ours, theirs), {
181204 cwd: dir,
182205 timeoutMs: 60_000,
183206 }),
184207 );
208 return orUnion(dir, plain, ours, theirs);
185209 }
186210
187211 if (mergeTreeAcceptsMergeBase) {
@@ -189,7 +213,8 @@ export async function writeMergeTree(
189213 cwd: dir,
190214 timeoutMs: 60_000,
191215 });
192 if (!isUnknownMergeBaseOption(out.stderr)) return parseWriteTree(out);
216 if (!isUnknownMergeBaseOption(out.stderr))
217 return orUnion(dir, parseWriteTree(out), ours, theirs, base);
193218 // git < 2.40: remember and fall through to the shared-parent workaround.
194219 mergeTreeAcceptsMergeBase = false;
195220 }
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: {
20712071 * terminal can open it. Nothing at all when the pull has none.
20722072 */
20732073 /** The combined branch, said as the page's card says it (`web/pages/pull-combined.ts`). */
2074 function combinedLines(
2074 export function combinedLines(
20752075 view: CombinedView | undefined,
20762076 number: number,
20772077 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)", () => {
124124 const a = join(work, "a");
125125 const b = join(work, "b");
126126 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");
128128 await Bun.write(join(b, "bob.txt"), "bob\n");
129129 await git(["add", "."], b);
130130 await git(["commit", "-am", "bob's one"], b);
131131 await git(["push", "origin", "main"], b);
132132
133133 // 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");
135135 await Bun.write(join(a, "two.txt"), "two\n");
136136 await git(["add", "."], a);
137137 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)", () => {
9191 await rm(work, { recursive: true, force: true });
9292 });
9393
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 () => {
95115 const a = join(work, "a");
96116 const b = join(work, "b");
97117 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");
99119 await git(["commit", "-am", "bob's one"], b);
100120 await git(["push", "origin", "main"], b);
101121 const bobHead = await git(["rev-parse", "HEAD"], b);
102122
103 await Bun.write(join(a, "one.txt"), "alice\n");
123 await Bun.write(join(a, "one.txt"), "one\nalice\n");
104124 await Bun.write(join(a, "two.txt"), "two\n");
105125 await git(["add", "."], a);
106126 await git(["commit", "-m", "alice moves on"], a);
107127 await git(["push", "origin", "main"], a);
108 const aliceMain = await git(["rev-parse", "HEAD"], a);
128 aliceMain = await git(["rev-parse", "HEAD"], a);
109129
110130 const opened = await call("/api/v1/repos/alice/app/pulls", bob, {
111131 title: "Jay's work",
@@ -114,16 +134,18 @@ describe("a conflicting fork pull is combined on our side (#9012)", () => {
114134 headRepo: "bob/app",
115135 });
116136 expect(opened.status).toBe(201);
117 const { number } = (await opened.json()) as { number: number };
137 number = ((await opened.json()) as { number: number }).number;
118138
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");
121142 const tip = await git(["rev-parse", `refs/heads/combined/${number}`], bare);
122143 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");
124145 expect(await git(["show", `${tip}:two.txt`], bare)).toBe("two");
125146 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");
127149 // His branch is untouched.
128150 expect(await git(["ls-remote", "origin", "refs/heads/main"], b)).toStartWith(bobHead);
129151
@@ -136,34 +158,20 @@ describe("a conflicting fork pull is combined on our side (#9012)", () => {
136158 const page = await call(`/alice/app/pulls/${number}`, alice);
137159 const body = await page.text();
138160 expect(body).toContain(`combined/${number}`);
139 expect(body).toContain("one.txt: 1 block, main&#39;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");
143162 expect(body).toContain("The checks line says which commit the check ran on.");
144163 expect(body).not.toContain("Stale: main has moved");
145164
146165 // 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 };
162166 const shown = await screenText();
163167 expect(shown).toContain("Combined with main");
164168 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);
166170
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")!;
167175 // A push to the fork recombines: the new head is the branch's first parent.
168176 await Bun.write(join(b, "three.txt"), "three\n");
169177 await git(["add", "."], b);
@@ -200,6 +208,44 @@ describe("a conflicting fork pull is combined on our side (#9012)", () => {
200208 await git(["pull", "--no-rebase", "origin", "main"], a);
201209 await git(["merge-base", "--is-ancestor", bobNext, "HEAD"], a);
202210 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);
204250 }, 30_000);
205251 });
test/combined-git.test.ts +70 −37
@@ -43,51 +43,71 @@ afterAll(async () => {
4343 await rm(dir, { recursive: true, force: true });
4444 });
4545
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}`));
6059 });
6160
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 });
7391 });
7492
7593 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");
84104 expect(git("show", `${again!.oid}:two.txt`)).toBe("two");
85105 });
86106
87107 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") };
91111 expect(headUnder(gitDir(), pull, tip)).toBe(head);
92112 // Not the combined tip: it answers for itself, so its own approval is asked.
93113 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
113133 expect(await combine(gitDir(), { number: 9, baseOid: main, headOid: head })).toBeDefined();
114134 const written = commitOn("combined/9", { "two.txt": "writer\n" }, "a writer resolves");
115135 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);
117137 expect(git("rev-parse", "combined/9")).toBe(written);
118138 expect(await readFailure(gitDir(), 9)).toContain("two.txt");
119139 });
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
280280 await git(["push", "origin", "main"], a);
281281 await idle(repoId);
282282 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");
284284 expect(said).not.toContain("git could not merge");
285 expect(said).toContain("waits for its author to merge main into their branch and push");
285286 // The page and the terminal word the card from one view: the sentence is on both.
286287 const { combinedView, combinedWords } = await import("../src/core/combined_view.ts");
287288 const head = await git(["rev-parse", `refs/pull/${third}/head`], bare);
288289 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!);
290291 const page = (await (await call(`/alice/app/pulls/${third}`, alice)).text()).replace(/&#39;/g, "'");
291292 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");
293294 });
294295
295296 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", () => {
5757 const conflicted = async (path: string): Promise<Pull> => {
5858 const branch = `clash-${++n}`;
5959 await commit("main", path, "- base\n");
60 await commit(branch, path, "- base\n- theirs\n");
60 await commit(branch, path, "- theirs\n");
6161 const o = await openPull(app, bot, repo, { title: branch, baseBranch: "main", headBranch: branch });
6262 if (!o.ok) throw new Error(String(o.error));
6363 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", () => {
6666 claimRun(app.db, tried.id);
6767 finishRun(app.db, tried.id, { state: "ok", summary: "reworked" });
6868 }
69 await commit("main", path, "- base\n- ours\n");
69 await commit("main", path, "- ours\n");
7070 return getPull(app.db, repo.id, o.value.number)!;
7171 };
7272 const redForConflict = (pull: Pull) => {
test/landsim.test.ts +2 −3
@@ -69,9 +69,8 @@ describe("the landing simulator", () => {
6969 expect(corpus.landed).toBeGreaterThan(0);
7070 }, 300_000);
7171
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(
7574 "two fork pulls that add lines at the end of one file both land, both sides present (#12104)",
7675 async () => {
7776 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", () => {
667667 test("a conflicted change alice opened herself offers the bring-up-to-date wording, never a Ship that would be refused", async () => {
668668 const base1 = await commitToBranch(app, alice, repo, {
669669 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" }],
671671 });
672672 if (!base1.ok) throw new Error(JSON.stringify(base1.error));
673673 const branch = await commitToBranch(app, alice, repo, {
test/studio.test.ts +1 −1
@@ -662,7 +662,7 @@ describe("the studio", () => {
662662 const scout = getUserByName(app.db, "scout")!;
663663 const base1 = await commitToBranch(app, alice, repo, {
664664 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" }],
666666 });
667667 if (!base1.ok) throw new Error(JSON.stringify(base1.error));
668668 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 }

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs