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

The terminal's conflict screen has a test: take ours, theirs, both or your own text per block, and `f` commits what was chosen #8364

Merged opened this

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

Merged

torque/forge:torque/8354-the-terminal-s-conflict-screen-has-a-tes → main 1 commit fork

  • Merged. torque/8354-the-terminal-s-conflict-screen-has-a-tes was merged into main as dcabd02.

Review

Sign in to review this change.
2 files changed +169 −1
src/tui/screens/conflicts.ts +4 −1
@@ -141,7 +141,10 @@ export function conflictScreen(
141141 if (flat.length === 0) return;
142142 at = Math.max(0, Math.min(flat.length - 1, at + by));
143143 const all = lines();
144 scroll.to(heads[at] ?? 0, all.length);
144 // Only when the block's header is out of sight: scrolling to a header that
145 // already shows would hide the file's "N of M resolved" row above it.
146 const head = heads[at] ?? 0;
147 if (head < scroll.top || head >= scroll.top + scroll.height) scroll.to(head, all.length);
145148 };
146149
147150 return {
test/tui-conflicts.test.ts added +165
@@ -0,0 +1,165 @@
1 /**
2 * The terminal's conflict screen does what its keys say (#8354): on a pull
3 * whose one file conflicts in two blocks, `o`/`t`/`b`/`e` settle a block each,
4 * the file's row counts them, `f` commits the merged file to the pull's
5 * branch, and leaving without `f` commits nothing.
6 */
7 import { afterAll, beforeAll, expect, test } from "bun:test";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { openPull } from "../src/actions/pulls.ts";
12 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
13 import { createApp, type App as Forge } from "../src/app.ts";
14 import { pullStatus, type Pull } from "../src/core/pulls.ts";
15 import type { Repo } from "../src/core/repos.ts";
16 import type { User } from "../src/core/users.ts";
17 import { repoDir } from "../src/git/repos.ts";
18 import { createForgeApp } from "../src/tui/forge.ts";
19 import { pullScreen } from "../src/tui/screens/pull.ts";
20 import { local } from "../src/tui/screens/where.ts";
21 import { headlessTerminal } from "../src/tui/term/terminal.ts";
22 import { seedAdmin } from "./helpers/admin.ts";
23 import { quietLogs } from "./helpers/globals.ts";
24 import { settleApp, settleUntil } from "./helpers/tui.ts";
25
26 quietLogs();
27
28 let dataDir: string;
29 let forge: Forge;
30 let alice: User;
31 let n = 0;
32
33 beforeAll(async () => {
34 dataDir = await mkdtemp(join(tmpdir(), "forge-tui-conflicts-"));
35 forge = createApp({ dataDir, port: 0 });
36 const a = await seedAdmin(forge, { username: "alice", email: "[email protected]", password: "a-long-passphrase" });
37 if (!a.ok) throw new Error("signup failed");
38 alice = a.value;
39 }, 30_000);
40
41 afterAll(async () => {
42 forge.close();
43 await rm(dataDir, { recursive: true, force: true });
44 });
45
46 /** Four untouched lines apart, so git keeps the two edits as two blocks. */
47 const file = (one: string, two: string): string =>
48 ["head", one, "keep 1", "keep 2", "keep 3", "keep 4", two, "tail", ""].join("\n");
49
50 async function commit(repo: Repo, branch: string, content: string, base?: string): Promise<void> {
51 const r = await commitToBranch(forge, alice, repo, {
52 branch,
53 ...(base ? { base } : {}),
54 message: `Write a.txt on ${branch}`,
55 files: [{ path: "a.txt", content }],
56 });
57 if (!r.ok) throw new Error(JSON.stringify(r.error));
58 }
59
60 /** A pull whose a.txt conflicts with main in two blocks. */
61 async function conflicted(): Promise<{ repo: Repo; pull: Pull; dir: string }> {
62 const made = await createRepo(forge, alice, { name: `clash${++n}`, visibility: "private" });
63 if (!made.ok) throw new Error(String(made.error));
64 const repo = made.value;
65 await commit(repo, "main", file("one", "two"));
66 await commit(repo, "feature", file("feature one", "feature two"), "main");
67 await commit(repo, "main", file("main one", "main two"));
68 const opened = await openPull(forge, alice, repo, {
69 title: "Clash",
70 body: "",
71 baseBranch: "main",
72 headBranch: "feature",
73 });
74 if (!opened.ok) throw new Error(String(opened.error));
75 return { repo, pull: opened.value, dir: repoDir(forge.reposRoot, repo.ownerName, repo.name) };
76 }
77
78 /** The file as committed on a branch. */
79 function show(dir: string, branch: string): string {
80 const r = Bun.spawnSync(["git", "-C", dir, "show", `${branch}:a.txt`]);
81 if (r.exitCode !== 0) throw new Error(r.stderr.toString());
82 return r.stdout.toString();
83 }
84
85 /** The pull screen, open on `pull`, with `v` pressed: the conflict screen. */
86 async function openConflicts(repo: Repo, pull: Pull, rows = 60) {
87 const { app, session } = createForgeApp(
88 forge,
89 { sshUsername: "alice", remoteAddress: "127.0.0.1", user: alice },
90 { terminal: headlessTerminal(120, rows), exitHooks: false },
91 );
92 app.start();
93 await settleApp(app);
94 app.ctx.push(pullScreen(session, app.ctx, local(session, repo), pull.number));
95 await settleUntil(app, "conflicts");
96 const press = async (...keys: string[]): Promise<string> => {
97 for (const key of keys) {
98 const ctrl = key.startsWith("ctrl+");
99 app.dispatch({ type: "key", key: ctrl ? key.slice(5) : key, ctrl, alt: false, shift: false });
100 await settleApp(app);
101 }
102 return app.screen.toText();
103 };
104 const type = async (text: string): Promise<void> => {
105 for (const ch of text) app.dispatch({ type: "key", key: ch, ctrl: false, alt: false, shift: false });
106 await settleApp(app);
107 };
108 await press("v");
109 await settleUntil(app, "conflict block");
110 return { app, press, type, text: () => app.screen.toText() };
111 }
112
113 test("o on the first block and t on the second, then f, commits ours-then-theirs", async () => {
114 const { repo, pull, dir } = await conflicted();
115 const h = await openConflicts(repo, pull);
116 expect(h.text()).toContain("2 conflict blocks in 1 file");
117 expect(h.text()).toContain("a.txt · 0 of 2 resolved");
118
119 expect(await h.press("o")).toContain("a.txt · 1 of 2 resolved");
120 expect(h.text()).toContain("block 1 of 2 · Ours");
121 await h.press("n", "t");
122 expect(h.text()).toContain("a.txt · 2 of 2 resolved");
123 expect(h.text()).toContain("block 2 of 2 · Theirs");
124
125 await h.press("f");
126 await settleUntil(h.app, "Conflicts resolved");
127 expect(show(dir, "feature")).toBe(file("main one", "feature two"));
128 const after = await pullStatus(dir, pull);
129 expect(after.conflicts).toEqual([]);
130 expect(after.mergeable).toBe(true);
131 h.app.stop();
132 });
133
134 test("b and e settle their blocks, and leaving without f commits nothing", async () => {
135 const { repo, pull, dir } = await conflicted();
136 const before = await pullStatus(dir, pull);
137 const h = await openConflicts(repo, pull);
138
139 await h.press("b");
140 expect(h.text()).toContain("block 1 of 2 · Both");
141 await h.press("n", "e");
142 await h.type("mine");
143 await h.press("ctrl+d");
144 expect(h.text()).toContain("a.txt · 2 of 2 resolved");
145 expect(h.text()).toContain("block 2 of 2 · Mine");
146 // The merged file beneath: ours then theirs in block one, the typed line in two.
147 const merged = h.text().split("Merged file")[1]!.split("\n").map((l) => l.trim()).filter((l) => l !== "");
148 expect(merged.slice(0, 9)).toEqual(["head", "main one", "feature one", "keep 1", "keep 2", "keep 3", "keep 4", "mine", "tail"]);
149
150 await h.press("esc");
151 expect(h.text()).not.toContain("conflict blocks");
152 const after = await pullStatus(dir, pull);
153 expect(after.headOid).toBe(before.headOid);
154 expect(after.conflicts).toEqual(["a.txt"]);
155 expect(show(dir, "feature")).toBe(file("feature one", "feature two"));
156 h.app.stop();
157 });
158
159 test("n still brings a block below the fold into view", async () => {
160 const { repo, pull } = await conflicted();
161 const h = await openConflicts(repo, pull, 10);
162 expect(h.text()).not.toContain("block 2 of 2");
163 expect(await h.press("n")).toContain("▸ block 2 of 2");
164 h.app.stop();
165 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs