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

Conflicts, part 1: Resolve on a conflicted pull shows each conflict block as a card with ours, theirs, both or your own text #1972

Merged opened this

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

Merged

scout/forge:scout/1966-conflicts-part-1-resolve-on-a-conflicted → main 3 commits fork 8 threads · 8 unresolved

  • Merged. scout/1966-conflicts-part-1-resolve-on-a-conflicted was merged into main as 7df9aab.

Review

Sign in to review this change.
12 files changed +910 −1
README.md +2
@@ -213,6 +213,8 @@ git objects; anything you then do here is **written through** to the origin
213213 first and reflected locally after it succeeds. For a mirror the origin is
214214 truth, exactly as git is truth for a native repo.
215215
216 **Resolving a conflicted pull.** When a pull conflicts with its base, a writer sees **Resolve** in the "Can't merge" box, which opens `/:owner/:name/pulls/:n/resolve`: one file at a time, each conflict block a card with the two sides named by their branch and the taps **Ours**, **Theirs**, **Both** and **Mine** (words typed in the box count as Mine), a running "2 of 3 resolved", and the merged file beneath. The choices ride in the query string; nothing is written to git yet (committing the result is a later part). In the terminal, `v` on a conflicted pull does the same: `n`/`p` move over the blocks, `o`/`t`/`b` take ours, theirs or both, `e` opens a box for your own text, and each file shows "N of M resolved" with its merged file beneath. Readers and guests get neither.
217
216218 A pull from an outsider's fork on the origin is someone else's code, so its
217219 check and preview wait. A person with write access lets that exact head run:
218220 on the pull page, **Approve to run here**; in the terminal, `A` on the pull
scripts/gate.ts +2
@@ -28,6 +28,8 @@ export type Exclusion = {
2828
2929 export const GATE: readonly string[] = [
3030 "test/domain-drill.test.ts",
31 "test/conflicts.test.ts",
32 "test/conflicts-tui.test.ts",
3133 "test/accounts-retire.test.ts",
3234 "test/client-home.test.ts",
3335 "test/cloud-use.test.ts",
src/actions/conflicts.ts added +44
@@ -0,0 +1,44 @@
1 /**
2 * Reading a conflicted pull's blocks, for any surface that shows them. A
3 * writer only (the same rule as the Resolve page); it reads, it writes nothing.
4 */
5
6 import type { App } from "../app.ts";
7 import { canWrite, type Repo } from "../core/repos.ts";
8 import { pullDir, pullStatus, type Pull } from "../core/pulls.ts";
9 import type { User } from "../core/users.ts";
10 import { readConflictedFiles } from "../git/conflict-file.ts";
11 import { parseConflicts, type Block, type Conflicted } from "../git/conflicts.ts";
12 import { err, ok, type Result } from "../kernel/result.ts";
13
14 export interface ConflictedFile {
15 readonly path: string;
16 readonly blocks: readonly Block[];
17 /** The file cut into text and blocks, for applying choices. */
18 readonly parsed: Conflicted;
19 }
20
21 export interface PullConflicts {
22 /** The base branch's side, and the pull branch's. */
23 readonly ours: string;
24 readonly theirs: string;
25 readonly files: readonly ConflictedFile[];
26 }
27
28 export async function pullConflicts(
29 app: App,
30 actor: User | undefined,
31 repo: Repo,
32 pull: Pull,
33 ): Promise<Result<PullConflicts, "forbidden">> {
34 if (!canWrite(app.db, repo, actor)) return err("forbidden");
35 const dir = await pullDir(app.db, app.reposRoot, repo, pull);
36 const status = await pullStatus(dir, pull);
outdated

Each iteration reruns the whole merge in readConflictedFile. Run the merge once and read every path from the tree it wrote.

37 const files: ConflictedFile[] = [];
38 const texts = await readConflictedFiles(dir, status.baseOid, status.headOid, status.conflicts);
39 for (const path of status.conflicts) {
40 const parsed = parseConflicts(texts.get(path) ?? "");
41 files.push({ path, blocks: parsed.blocks, parsed });
42 }
43 return ok({ ours: pull.baseBranch, theirs: pull.headBranch, files });
44 }
src/git/conflict-file.ts added +53
@@ -0,0 +1,53 @@
1 /**
2 * One conflicted file as git leaves it: `merge-tree --write-tree` run over
3 * the two sides with `diff3` markers, then the file read out of the tree it
4 * wrote. Reads only; the objects it writes are unreachable and swept by gc.
5 */
6
7 import { runGitCapture } from "./env.ts";
8 import { isSafeOid, isSafeTreePath } from "./guard.ts";
9
10 const MAX_BYTES = 1_000_000;
11
12 export async function readConflictedFile(
13 dir: string,
14 ours: string,
15 theirs: string,
16 path: string,
17 ): Promise<string | undefined> {
18 return (await readConflictedFiles(dir, ours, theirs, [path])).get(path);
19 }
20
21 /** Several files from one merge: the merge runs once, each path is read from its tree. */
22 export async function readConflictedFiles(
23 dir: string,
24 ours: string,
25 theirs: string,
26 paths: readonly string[],
27 ): Promise<Map<string, string>> {
28 const out = new Map<string, string>();
29 if (!isSafeOid(ours) || !isSafeOid(theirs) || paths.length === 0) return out;
30 const merged = await runGitCapture(
31 [
32 "-c",
33 "merge.conflictStyle=diff3",
34 "merge-tree",
35 "--write-tree",
36 "--name-only",
37 ours,
38 theirs,
39 ],
40 { cwd: dir, timeoutMs: 60_000 },
41 );
42 if (merged.code !== 1) return out;
43 const tree = merged.stdout.split("\n")[0]!.trim();
44 if (!isSafeOid(tree)) return out;
45 for (const path of paths) {
46 if (!isSafeTreePath(path)) continue;
47 const blob = await runGitCapture(["cat-file", "blob", `${tree}:${path}`], {
48 cwd: dir,
49 });
50 if (blob.code === 0 && blob.stdout.length <= MAX_BYTES) out.set(path, blob.stdout);

Low: the 1 MB cap is checked after cat-file has buffered the whole blob into memory. Checking cat-file -s first, or skipping paths whose size is over the cap, would keep a huge conflicted file out of memory completely.

51 }
52 return out;
53 }
src/git/conflicts.ts added +128
@@ -0,0 +1,128 @@
1 /**
2 * Conflict blocks, read and chosen. Pure: the text of a file as
3 * `git merge-tree --write-tree` leaves it (conflict markers in `diff3` style,
4 * so each block carries its base) goes in; blocks come out, and a list of
5 * choices turns them back into the merged file. Nothing here touches git.
6 */
7
8 export type Part =
9 | { readonly kind: "text"; readonly text: string }
10 | {
11 readonly kind: "block";
12 /** The common ancestor's lines; empty when the file was not diff3. */
13 readonly base: string;
14 /** The base branch's side. */
15 readonly ours: string;
16 /** The pull branch's side. */
17 readonly theirs: string;
18 };
19
20 export interface Block {
21 readonly base: string;
22 readonly ours: string;
23 readonly theirs: string;
24 }
25
26 export type Choice =
27 | { readonly kind: "ours" }
28 | { readonly kind: "theirs" }
29 | { readonly kind: "both" }
30 | { readonly kind: "text"; readonly text: string };
31
32 export interface Conflicted {
33 readonly parts: readonly Part[];
34 readonly blocks: readonly Block[];
35 }
36
37 export interface Labels {
38 readonly ours: string;
39 readonly theirs: string;
40 }
41
42 const lines = (text: string): string[] => text.split(/(?<=\n)/);
43
44 /** Cut a conflicted file into plain text and conflict blocks. */
45 export function parseConflicts(text: string): Conflicted {
46 const parts: Part[] = [];
47 const blocks: Block[] = [];
48 let plain = "";
49 let state: "text" | "ours" | "base" | "theirs" = "text";
50 let ours = "";
51 let base = "";
52 let theirs = "";
53 let raw = ""; // the block as written, to give back if it never closes
54 for (const line of lines(text)) {
55 const bare = line.replace(/\r?\n$/, "");
56 if (state === "text") {
57 if (/^<{7}( |$)/.test(bare)) {
58 state = "ours";
59 raw = line;
60 ours = base = theirs = "";
61 } else plain += line;
62 continue;
63 }
64 raw += line;
65 if (state === "ours" && /^\|{7}( |$)/.test(bare)) state = "base";
66 else if ((state === "ours" || state === "base") && /^={7}$/.test(bare))
67 state = "theirs";
68 else if (state === "theirs" && /^>{7}( |$)/.test(bare)) {
69 if (plain !== "") parts.push({ kind: "text", text: plain });
70 plain = "";
71 const block = { base, ours, theirs };
72 parts.push({ kind: "block", ...block });
73 blocks.push(block);
74 state = "text";
75 } else if (state === "ours") ours += line;
76 else if (state === "base") base += line;
77 else theirs += line;
78 }
79 // An unclosed block is not a block: keep what was written.
80 plain += state === "text" ? "" : raw;
81 if (plain !== "") parts.push({ kind: "text", text: plain });
82 return { parts, blocks };
83 }
84
85 /**
86 * The file with each block settled by its choice. A block with no choice
87 * (`undefined`) is written back as conflict markers, named by `labels`, so
88 * the merged file shows what is still open. Text is kept verbatim.
89 */
90 export function applyChoices(
91 conflicted: Conflicted,
92 choices: readonly (Choice | undefined)[],
93 labels: Labels,
94 ): string {
95 let out = "";
96 let at = 0;
97 for (const part of conflicted.parts) {
98 if (part.kind === "text") {
99 out += part.text;
100 continue;
101 }
102 const choice = choices[at++];
103 if (choice === undefined) out += markers(part, labels);
104 else if (choice.kind === "ours") out += part.ours;
105 else if (choice.kind === "theirs") out += part.theirs;
106 else if (choice.kind === "both") out += part.ours + part.theirs;
107 else out += choice.text;
108 }
109 return out;
110 }
111
112 function markers(block: Block, labels: Labels): string {
113 const nl = (s: string): string => (s === "" || s.endsWith("\n") ? s : s + "\n");
114 return (
115 `<<<<<<< ${labels.ours}\n${nl(block.ours)}` +
116 `=======\n${nl(block.theirs)}>>>>>>> ${labels.theirs}\n`
117 );
118 }
119
120 /** How many of the choices settle a block. */
121 export function resolvedCount(
122 choices: readonly (Choice | undefined)[],
123 total: number,
124 ): number {
125 let n = 0;
126 for (let i = 0; i < total; i++) if (choices[i] !== undefined) n++;
127 return n;
128 }
src/http/server.ts +2
@@ -295,6 +295,7 @@ import {
295295 pullThreadFragment,
296296 threadResolveSubmit,
297297 } from "../web/pages/pull.ts";
298 import { conflictsPage } from "../web/pages/conflicts.ts";
298299 import {
299300 appDisable,
300301 appMembersUpdate,
@@ -988,6 +989,7 @@ export function buildRouter(): Router<Handler> {
988989 // The change's Files step: the diff, the review threads and the merge
989990 // box, one tab under the change itself.
990991 router.add("GET", "/:owner/:name/pulls/:number/files", pullFilesPage);
992 router.add("GET", "/:owner/:name/pulls/:number/resolve", conflictsPage);
991993 router.add("GET", "/:owner/:name/pulls/:number/thread", pullThreadFragment);
992994 router.add("GET", "/:owner/:name/pulls/:number/state", pullStateFragment);
993995 router.add("POST", "/:owner/:name/pulls/:number/comments", pullCommentSubmit);
src/tui/screens/conflicts.ts added +149
@@ -0,0 +1,149 @@
1 /**
2 * A conflicted pull's blocks in the terminal, the page's twin: a cursor over
3 * the blocks, `o`/`t`/`b` to take ours, theirs or both, `e` for your own
4 * text, and per file "N of M resolved" with the merged file beneath. Nothing
5 * is written to git; the choices live in this screen only.
6 */
7
8 import type { PullConflicts } from "../../actions/conflicts.ts";
9 import {
10 applyChoices,
11 resolvedCount,
12 type Choice,
13 } from "../../git/conflicts.ts";
14 import type { Ctx, Screen } from "../app/app.ts";
15 import { binding } from "../app/keys.ts";
16 import { style } from "../term/style.ts";
17 import { fill, len, split } from "../ui/layout.ts";
18 import { Scroll, drawLines, hitScroll, plural } from "./common.ts";
19 import { Composer } from "./thread.ts";
20
21 type Line = { text: string; style: ReturnType<typeof style> }[];
22
23 const NAMES = { ours: "Ours", theirs: "Theirs", both: "Both", text: "Mine" };
24
25 export function conflictScreen(ctx: Ctx, read: PullConflicts): Screen {
26 const scroll = new Scroll();
27 const composer = new Composer();
outdated

This is the parity gap: the terminal reads but cannot choose. Add o/t/b (and e for own text) on a block cursor, plus the count and the applyChoices merged file, so the terminal does what the page does.

28 /** choices[file][block] */
29 const choices: (Choice | undefined)[][] = read.files.map((f) =>
30 f.blocks.map(() => undefined),
31 );
32 const flat = read.files.flatMap((f, fi) => f.blocks.map((_, bi) => [fi, bi] as const));
33 let at = 0;
34 /** The line each block's header sits on, for keeping the cursor in view. */
35 let heads: number[] = [];
36
37 const rows = (side: string, max = 12): string[] =>
38 side.split("\n").slice(0, side.endsWith("\n") ? -1 : undefined).slice(0, max);
39
40 const lines = (): Line[] => {
41 const c = ctx.theme.c;
42 const plain = style(c.fg, c.bg);
43 const muted = style(c.mutedFg, c.bg);
44 const out: Line[] = [];
45 heads = [];
46 const say = (text: string, st = plain): void => {
47 out.push([{ text, style: st }]);
48 };
49 const total = flat.length;
50 say(`${plural(total, "conflict block")} in ${plural(read.files.length, "file")}`);
51 say("n/p block · o ours · t theirs · b both · e your own text · nothing is written to git", muted);
52 let n = 0;
53 read.files.forEach((file, fi) => {
54 const mine = choices[fi]!;
55 say("");
56 say(
57 `${file.path} · ${resolvedCount(mine, file.blocks.length)} of ${file.blocks.length} resolved`,
58 );
59 if (file.blocks.length === 0)
60 say(" no text blocks to choose between", muted);
61 file.blocks.forEach((block, bi) => {
62 const here = n === at;
63 const pick = mine[bi];
64 heads[n++] = out.length;
65 say(
66 `${here ? "▸" : " "} block ${bi + 1} of ${file.blocks.length}${pick ? ` · ${NAMES[pick.kind]}` : ""}`,
67 here ? plain : muted,
68 );
69 for (const [name, side] of [
70 [read.ours, block.ours],
71 [read.theirs, block.theirs],
72 ] as const) {
73 say(` ${name}`, muted);
74 for (const row of rows(side)) say(` ${row}`);
75 }
76 });
77 if (file.blocks.length > 0) {
78 say(" Merged file", muted);
79 const merged = applyChoices(file.parsed, mine, {
80 ours: read.ours,
81 theirs: read.theirs,
82 });
83 for (const row of rows(merged, 200))
84 say(` ${row}`);
85 }
86 });
87 return out;
88 };
89
90 const set = (choice: Choice): void => {
91 const target = flat[at];
92 if (target) choices[target[0]]![target[1]] = choice;
93 };
94 const move = (by: number): void => {
95 if (flat.length === 0) return;
96 at = Math.max(0, Math.min(flat.length - 1, at + by));
97 const all = lines();
98 scroll.to(heads[at] ?? 0, all.length);
99 };
100
101 return {
102 title: "Conflicts",
103 keys: [
104 binding(["n", "p"], "block"),
105 binding(["o", "t", "b"], "ours / theirs / both"),
106 binding(["e"], "your text"),
107 binding(["j", "k"], "scroll"),
108 binding(["esc", "q"], "back"),
109 ],
110 load: () => Promise.resolve(),
111 render(area, s) {
112 const [body, comp] = split(area, "v", [fill(1), len(composer.height())]);
113 const all = lines();
114 drawLines(s, body!, ctx.theme.c, scroll, all);
115 hitScroll(ctx, body!, scroll, all.length);
116 if (composer.height() > 0) composer.render(s, comp!, ctx.theme.c, ctx);
117 },
118 paste: (text) => composer.paste(text),
119 key(e) {
120 if (composer.open) {
121 const out = composer.key(e);
122 if (out === "submit") {
123 const text = composer.value.replace(/\r\n?/g, "\n");
124 set({ kind: "text", text: text === "" || text.endsWith("\n") ? text : text + "\n" });
125 composer.close();
126 } else if (out === "cancel") composer.close();
127 return true;
128 }
129 if (e.key === "esc" || e.key === "q") {
130 ctx.pop();
131 return true;
132 }
133 if (!e.ctrl && !e.alt && flat.length > 0) {
134 if (e.key === "n") return move(1), true;
135 if (e.key === "p") return move(-1), true;
136 if (e.key === "o") return set({ kind: "ours" }), true;
137 if (e.key === "t") return set({ kind: "theirs" }), true;
138 if (e.key === "b") return set({ kind: "both" }), true;
139 if (e.key === "e") {
140 const target = flat[at]!;
141 const was = choices[target[0]]![target[1]];
142 composer.start("Your text", was?.kind === "text" ? was.text : "");
143 return true;
144 }
145 }
146 return scroll.key(e, lines().length);
147 },
148 };
149 }
src/tui/screens/pull.ts +25
@@ -102,6 +102,8 @@ import {
102102 import { assignPicker, assignedText } from "./assign.ts";
103103 import { DiffView, type DiffAnchor, type DiffNote } from "./commits.ts";
104104 import { liveBindings, liveKey } from "./mirror.ts";
105 import { pullConflicts } from "../../actions/conflicts.ts";
106 import { conflictScreen } from "./conflicts.ts";
105107 import { reviewScreen } from "./review.ts";
106108 import { RunList, openRunLog, runTone } from "./runlist.ts";
107109 import { Composer, threadLines, type ThreadEntry } from "./thread.ts";
@@ -632,6 +634,22 @@ export function pullScreen(
632634 });
633635 };
634636
637 /** `v` — the conflict blocks, read; `pullConflicts` holds the writer gate. */
638 const seeConflicts = (): void => {
639 if (repo === undefined) return;
640 const target = repo;
641 const row = getPull(f.db, target.id, number);
642 if (row === undefined) return;
643 runAsync(ctx, async () => {
644 const read = await pullConflicts(f.app, f.user, target, row);
645 if (!read.ok) {
646 ctx.toast(errorText(read.error), "danger");
647 return;
648 }
649 ctx.push(conflictScreen(ctx, read.value));
650 });
651 };
652
635653 const mirrored = (): boolean => repo === undefined || repo.origin !== null;
636654 const state = (): MergeState =>
637655 mergeState(detail?.issue, facts, mirrored(), gated(), checksGated());
@@ -662,6 +680,9 @@ export function pullScreen(
662680 ? [binding(["A"], "approve to run here")]
663681 : []),
664682 binding(["a"], "assign"),
683 ...(state() === "conflicts"
684 ? [binding(["v"], "see the conflict blocks")]
685 : []),
665686 binding(["m"], "merge", { disabled: state() !== "ready" }),
666687 ...(tab === 1 && view.threadHere() !== null
667688 ? [
@@ -875,6 +896,10 @@ export function pullScreen(
875896 assign();
876897 return true;
877898 }
899 if (e.key === "v" && mayWrite() && state() === "conflicts") {
900 seeConflicts();
901 return true;
902 }
878903 // The crew tab is a list: the cursor moves, enter opens the run.
879904 if (tab === 2) {
880905 if (crew.state.key(e, crew.runs.length)) return true;
src/web/pages/conflicts.ts added +183
@@ -0,0 +1,183 @@
1 /**

This door is the page only. The terminal's pull screen lists the conflicted files but can't open them. Give it the same read, gated the same way, with a test. Also add a README entry.

2 * GET /:owner/:name/pulls/:number/resolve — a conflicted pull, one file at a
3 * time. Each conflict block is a card: the two sides named by their branch,
4 * three taps (ours, theirs, both) and a text box for your own words. The
5 * choices travel in the query string, so the page keeps no state and writes
6 * nothing to git; it shows the running count and the merged file beneath.
7 * Writers only.
8 */
9
10 import { canWrite } from "../../core/repos.ts";
11 import { pullDir, pullStatus } from "../../core/pulls.ts";
12 import { readConflictedFile } from "../../git/conflict-file.ts";
13 import {
14 applyChoices,
15 parseConflicts,
16 resolvedCount,
17 type Choice,
18 } from "../../git/conflicts.ts";
19 import type { Ctx } from "../../http/context.ts";
20 import { crumbsFor, notFoundPage, repoBase, repoPalette, repoHeader } from "../chrome.ts";
21 import { html, type Html } from "../html.ts";
22 import { page } from "../layout.ts";
23 import { btn, card, icon } from "../ui.ts";
24 import { changeHead, changeTabs, pullContext } from "../studio/change.ts";
25
26 /** More blocks or longer words than this are not a hand's work. */
27 export const MAX_BLOCKS = 500;
28 export const MAX_TEXT = 20_000;
29
30 /**
31 * The choices a query carries: `b<i>` is ours, theirs, both or text, and
32 * `t<i>` the words for text. Anything else leaves the block open. A box's
33 * words end in a newline, as a line of a file does, unless they are empty.
34 */
35 export function choicesFrom(
36 params: URLSearchParams,
37 total: number,
38 ): (Choice | undefined)[] {
39 const out: (Choice | undefined)[] = [];
40 for (let i = 0; i < Math.min(total, MAX_BLOCKS); i++) {
41 // Words in the box with no tap picked are "Mine": typing is the choice.
42 const typed = (params.get(`t${i}`) ?? "") !== "";
43 const kind = params.get(`b${i}`) ?? (typed ? "text" : null);
44 if (kind === "ours" || kind === "theirs" || kind === "both")
45 out.push({ kind });
46 else if (kind === "text") {
47 let text = (params.get(`t${i}`) ?? "").replace(/\r\n?/g, "\n");
48 if (text.length > MAX_TEXT) text = text.slice(0, MAX_TEXT);
49 out.push({
50 kind: "text",
51 text: text === "" || text.endsWith("\n") ? text : text + "\n",
outdated

If b<i> is missing but t<i> has text, the person's words are thrown away. Typing in the box is the clearest sign of 'Mine', so treat a non-empty t<i> as {kind: "text"} when no tap is picked, and add a test for it.

52 });
53 } else out.push(undefined);
54 }
55 return out;
56 }
57
58 const CSS = `
59 .rs-list { display: grid; gap: 12px; margin-top: 12px; }
60 .rs-card { content-visibility: auto; contain-intrinsic-size: auto 260px; }
61 .rs-sides { display: grid; gap: 8px; grid-template-columns: repeat(auto-fit, minmax(min(100%, 240px), 1fr)); }
62 .rs-side { min-width: 0; }
63 .rs-side pre, .rs-merged { margin: 4px 0 0; padding: 8px; overflow: auto; max-height: 12em; font-size: 12px; white-space: pre; background: var(--muted-bg, transparent); border: 1px solid var(--border, #8884); border-radius: 6px; }
64 .rs-merged { max-height: 32em; }
65 .rs-taps { display: flex; gap: 6px; flex-wrap: wrap; margin: 8px 0; }
66 .rs-taps label { flex: 1 1 0; text-align: center; padding: 8px 6px; min-height: 40px; border: 1px solid var(--border, #8884); border-radius: 6px; cursor: pointer; }
67 .rs-taps input { position: absolute; opacity: 0; pointer-events: none; }
68 .rs-taps label:has(input:checked) { font-weight: 600; border-color: currentColor; }
69 .rs-taps label:has(input:focus-visible) { outline: 2px solid currentColor; }
70 .rs-text { width: 100%; min-height: 3.5em; font-family: monospace; }
71 `;
72
73 export async function conflictsPage(ctx: Ctx): Promise<Response> {
74 const found = pullContext(ctx);
75 if (found instanceof Response) return found;
76 const { repo, pull } = found;
77 if (!canWrite(ctx.app.db, repo, ctx.auth.user)) return notFoundPage(ctx);
78 const base = repoBase(repo);
79 const back = `${base}/pulls/${pull.number}/files`;
80 const dir = await pullDir(ctx.app.db, ctx.app.reposRoot, repo, pull);
81 const status = await pullStatus(dir, pull);
82 const files = status.conflicts;
83 const asked = ctx.url.searchParams.get("file") ?? "";
84 // Only a path git itself named is read; the rest of the world is a 404.
85 const file = asked === "" ? files[0] : files.find((f) => f === asked);
86 const unknown = asked !== "" && file === undefined && files.length > 0;
87 if (pull.state !== "open" || file === undefined) {
88 return page({
89 ctx,
90 title: `Resolve · ${pull.title}`,
91 crumbs: crumbsFor(repo),
92 palette: repoPalette(ctx, repo),
93 body: html`${repoHeader(ctx, repo, "pulls", { compact: true, ownTitle: true })}
94 ${changeHead(ctx, repo, pull)}${changeTabs(repo, pull, "files")}
95 ${card(
outdated

Reached for an unknown ?file= on a pull that does conflict, so "has no conflicts" is false there. Word it for that case, or fall back to files[0].

96 html`<p>${unknown && pull.state === "open"
97 ? "Nothing to resolve there: that file is not one of the conflicted files."
98 : "Nothing to resolve: this pull has no conflicts."}
99 <a href="${back}">Back to the files</a>.</p>`,
100 )}`,
101 });
102 }
103
104 const text = await readConflictedFile(dir, status.baseOid, status.headOid, file);
105 const parsed = parseConflicts(text ?? "");
106 const choices = choicesFrom(ctx.url.searchParams, parsed.blocks.length);
107 const labels = { ours: pull.baseBranch, theirs: pull.headBranch };
108 const done = resolvedCount(choices, parsed.blocks.length);
109 const total = parsed.blocks.length;
110 const merged = applyChoices(parsed, choices, labels);
111 const here = `${base}/pulls/${pull.number}/resolve`;
112
113 const cards: Html[] = parsed.blocks.slice(0, MAX_BLOCKS).map((block, i) => {
114 const pick = choices[i]?.kind;
115 const tap = (kind: string, label: string): Html =>
116 html`<label
117 ><input type="radio" name="b${i}" value="${kind}" ${pick === kind ? html`checked` : ""} />${label}</label
118 >`;
119 const own = choices[i];
120 return card(
121 html`<div class="rs-sides">
122 <div class="rs-side">
123 <strong class="small mono">${pull.baseBranch}</strong>
124 <pre>${block.ours}</pre>
125 </div>
126 <div class="rs-side">
127 <strong class="small mono">${pull.headBranch}</strong>
128 <pre>${block.theirs}</pre>
129 </div>
130 </div>
131 ${block.base === ""
132 ? ""
133 : html`<details><summary class="small muted">Before either changed it</summary><pre class="rs-merged">${block.base}</pre></details>`}
134 <div class="rs-taps">
135 ${tap("ours", `Ours`)}${tap("theirs", `Theirs`)}${tap("both", "Both")}${tap("text", "Mine")}
136 </div>
137 <textarea class="rs-text" name="t${i}" rows="3" maxlength="${MAX_TEXT}"
138 aria-label="Your own text for block ${i + 1}">${own?.kind === "text" ? own.text : ""}</textarea>`,
139 {
140 extra: "rs-card",
141 head: html`<h2>Block ${i + 1} of ${total}</h2>`,
142 },
143 );
144 });
145
146 const body = html`${repoHeader(ctx, repo, "pulls", { compact: true, ownTitle: true })}
147 ${changeHead(ctx, repo, pull)}${changeTabs(repo, pull, "files")}
148 <p class="muted small mt">
149 <a href="${back}">← Files</a> · <span class="mono">${file}</span> ·
150 <strong id="rs-count">${done} of ${total} resolved</strong>
151 · nothing is written to git
152 </p>
153 ${files.length > 1
154 ? html`<p class="small">
155 ${files.map((f) =>
156 f === file
157 ? html`<strong class="mono">${f}</strong> `
158 : html`<a class="mono" href="${here}?file=${encodeURIComponent(f)}">${f}</a> `,
159 )}
160 </p>`
161 : ""}
162 ${total === 0
163 ? card(
164 html`<p>This file has no text blocks to choose between (it may be
165 deleted on one side). Resolve it in your own checkout.</p>`,
166 )
167 : html`<form method="get" action="${here}">
168 <input type="hidden" name="file" value="${file}" />
169 <div class="rs-list">${cards}</div>
170 <p class="mt">${btn("Update the merged file", { icon: "check" })}</p>
171 </form>`}
172 <h2 class="mt">Merged file</h2>
173 <pre class="rs-merged" aria-label="Merged ${file}">${merged}</pre>`;
174
175 return page({
176 ctx,
177 title: `Resolve · ${pull.title} · ${repo.name}`,
178 crumbs: crumbsFor(repo),
179 palette: repoPalette(ctx, repo),
180 body,
181 styleCss: CSS,
182 });
183 }
src/web/pages/pull.ts +3 −1
@@ -414,7 +414,9 @@ function mergeBox(
414414 >${status.conflicts.slice(0, 3).join(", ")}</span
415415 >${status.conflicts.length > 3 ? "…" : ""}. Merge
416416 <span class="mono">${pull.baseBranch}</span> into the branch and
417 push again.`
417 push again.${mayMerge
418 ? html` <a href="${thread}/resolve">Resolve</a> it here.`
419 : ""}`
418420 : html`This branch cannot be applied automatically.`;
419421 return card(
420422 html`${icon("alert")}
test/conflicts-tui.test.ts added +138
@@ -0,0 +1,138 @@
1 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2 import { mkdtemp, rm } from "node:fs/promises";
3 import { tmpdir } from "node:os";
4 import { join } from "node:path";
5 import { openPull } from "../src/actions/pulls.ts";
6 import { pullConflicts } from "../src/actions/conflicts.ts";
7 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
8 import { createApp, type App } from "../src/app.ts";
9 import { getPull, type Pull } from "../src/core/pulls.ts";
10 import type { Repo } from "../src/core/repos.ts";
11 import { createUser, type User } from "../src/core/users.ts";
12 import { createForgeApp } from "../src/tui/forge.ts";
13 import { pullScreen } from "../src/tui/screens/pull.ts";
14 import { local } from "../src/tui/screens/where.ts";
15 import { headlessTerminal } from "../src/tui/term/terminal.ts";
16 import { quietLogs } from "./helpers/globals.ts";
17 import { settleApp } from "./helpers/tui.ts";
18
19 quietLogs();
20
21 describe("the terminal reads a conflicted pull's blocks", () => {
22 let dataDir: string;
23 let app: App;
24 let alice: User;
25 let carol: User;
26 let repo: Repo;
27 let pull: Pull;
28
29 const put = async (branch: string, content: string, from?: string) => {
30 const done = await commitToBranch(app, alice, repo, {
31 branch,
32 ...(from ? { from } : {}),
33 message: `edit ${branch}`,
34 files: [{ path: "code.txt", content }],
35 });
36 if (!done.ok) throw new Error(JSON.stringify(done.error));
37 };
38
39 beforeAll(async () => {
40 dataDir = await mkdtemp(join(tmpdir(), "forge-cf-tui-"));
41 app = createApp({ dataDir, port: 0, host: "forge.test" });
42 const people: User[] = [];
43 for (const [username, first] of [["alice", true], ["carol", false]] as const) {
44 const made = await createUser(app.db, {
45 trusted: true,
46 username,
47 email: `${username}@forge.local`,
48 password: "a-good-passphrase",
49 adminIfFirst: first,
50 });
51 if (!made.ok) throw new Error(made.error);
52 people.push(made.value);
53 }
54 [alice, carol] = people as [User, User];
55 const made = await createRepo(app, alice, { name: "lib", visibility: "public" });
56 if (!made.ok) throw new Error(String(made.error));
57 repo = made.value;
58 await put("main", "top\nbase\nend\n");
59 await put("loud", "top\nloud line\nend\n");
60 const opened = await openPull(app, alice, repo, {
61 title: "Loud",
62 baseBranch: "main",
63 headBranch: "loud",
64 });
65 if (!opened.ok) throw new Error(String(opened.error));
66 await put("main", "top\nquiet line\nend\n");
67 pull = getPull(app.db, repo.id, opened.value.number)!;
68 }, 120_000);
69
70 afterAll(async () => {
71 app.close();
72 await rm(dataDir, { recursive: true, force: true });
73 });
74
75 async function pressV(user: User, then: string[] = []): Promise<string> {
76 const { app: tui, session } = createForgeApp(
77 app,
78 {
79 sshUsername: user.username,
80 remoteAddress: "127.0.0.1",
81 fingerprint: `SHA256:${user.username}`,
82 user,
83 },
84 { terminal: headlessTerminal(140, 44), exitHooks: false },
85 );
86 tui.start();
87 try {
88 tui.ctx.push(pullScreen(session, tui.ctx, local(session, repo), pull.number));
89 const end = Date.now() + 20_000;
90 while (!tui.screen.toText().includes("Conflicts in 1 file")) {
91 if (Date.now() > end) throw new Error(tui.screen.toText());
92 await settleApp(tui);
93 await Bun.sleep(20);
94 }
95 tui.dispatch({ type: "key", key: "v", ctrl: false, alt: false, shift: false });
96 await Bun.sleep(1500);
97 await settleApp(tui);
98 for (const key of then) {
99 tui.dispatch({ type: "key", key, ctrl: false, alt: false, shift: false });
100 await settleApp(tui);
101 }
102 return tui.screen.toText();
103 } finally {
104 tui.stop();
105 }
106 }
107
108 test("the read names both sides by branch; a reader gets nothing", async () => {
109 const read = await pullConflicts(app, alice, repo, pull);
110 expect(read.ok && read.value.files[0]?.blocks).toEqual([
111 { base: "base\n", ours: "quiet line\n", theirs: "loud line\n" },
112 ]);
113 const refused = await pullConflicts(app, carol, repo, pull);
114 expect(refused.ok).toBe(false);
115 });
116
117 test("a writer's v shows the blocks; a reader's does not", async () => {
118 const writer = await pressV(alice);
119 expect(writer).toContain("1 conflict block in 1 file");
120 expect(writer).toContain("quiet line");
121 expect(writer).toContain("loud line");
122 const reader = await pressV(carol);
123 expect(reader).not.toContain("conflict block");
124 expect(reader).not.toContain("quiet line");
125 }, 60_000);
126
127 test("b on the one block resolves it and the merged file keeps both lines", async () => {
128 const before = await pressV(alice);
129 expect(before).toContain("0 of 1 resolved");
130 expect(before).toContain("<<<<<<<");
131 const after = await pressV(alice, ["b"]);
132 expect(after).toContain("1 of 1 resolved");
133 expect(after).toContain("Both");
134 expect(after).not.toContain("<<<<<<<");
135 expect(after.indexOf("quiet line", after.indexOf("Merged file"))).toBeGreaterThan(-1);
136 expect(after.indexOf("loud line", after.indexOf("Merged file"))).toBeGreaterThan(-1);
137 }, 60_000);
138 });
test/conflicts.test.ts added +181
@@ -0,0 +1,181 @@
1 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2 import { mkdtemp, rm } from "node:fs/promises";
3 import { tmpdir } from "node:os";
4 import { join } from "node:path";
5 import { createApp, type App } from "../src/app.ts";
6 import { serve, type Servers } from "../src/http/server.ts";
7 import { applyChoices, parseConflicts } from "../src/git/conflicts.ts";
8 import { choicesFrom } from "../src/web/pages/conflicts.ts";
9 import { gitClientEnv } from "./helpers/git-client.ts";
10
11 const labels = { ours: "main", theirs: "loud" };
12 const FILE =
13 "top\n<<<<<<< a\none\n||||||| b\nzero\n=======\nuno\n>>>>>>> c\nmid\n" +
14 "<<<<<<< a\ntwo\n=======\ndos\n>>>>>>> c\nend\n";
15
16 describe("conflict blocks", () => {
17 test("a two-block conflict resolved by both gives the file", () => {
18 const parsed = parseConflicts(FILE);
19 expect(parsed.blocks).toEqual([
20 { base: "zero\n", ours: "one\n", theirs: "uno\n" },
21 { base: "", ours: "two\n", theirs: "dos\n" },
22 ]);
23 const both = { kind: "both" } as const;
24 expect(applyChoices(parsed, [both, both], labels)).toBe(
25 "top\none\nuno\nmid\ntwo\ndos\nend\n",
26 );
27 });
28
29 test("ours, theirs and your own text are kept verbatim", () => {
30 const parsed = parseConflicts(FILE);
31 const mine = " keep\tme \n\nexactly\n";
32 expect(
33 applyChoices(
34 parsed,
35 [{ kind: "text", text: mine }, { kind: "theirs" }],
36 labels,
37 ),
38 ).toBe(`top\n${mine}mid\ndos\nend\n`);
39 expect(
40 applyChoices(parsed, [{ kind: "ours" }, { kind: "text", text: "" }], labels),
41 ).toBe("top\none\nmid\nend\n");
42 });
43
44 test("an open block shows markers named by branch; an unclosed one is kept", () => {
45 const out = applyChoices(parseConflicts(FILE), [undefined, { kind: "ours" }], labels);
46 expect(out).toContain("<<<<<<< main\none\n=======\nuno\n>>>>>>> loud\n");
47 const broken = "a\n<<<<<<< x\nb\n";
48 expect(applyChoices(parseConflicts(broken), [], labels)).toBe(broken);
49 });
50
51 test("the query becomes choices; box text ends in a newline", () => {
52 const q = new URLSearchParams("b0=both&b1=text&t1=hi%0D%0Athere&b2=nope");
53 expect(choicesFrom(q, 3)).toEqual([
54 { kind: "both" },
55 { kind: "text", text: "hi\nthere\n" },
56 undefined,
57 ]);
58 // Words with no tap picked are the person's own text.
59 expect(choicesFrom(new URLSearchParams("t0=typed&b1=ours&t1=&t2="), 3)).toEqual([
60 { kind: "text", text: "typed\n" },
61 { kind: "ours" },
62 undefined,
63 ]);
64 });
65 });
66
67 describe("the Resolve page", () => {
68 const gitEnv = gitClientEnv();
69 let dataDir: string;
70 let work: string;
71 let app: App;
72 let servers: Servers;
73 let base: string;
74 let dir: string;
75 let headers: Record<string, string>;
76
77 async function git(args: string[]): Promise<void> {
78 const p = Bun.spawn(["git", ...args], {
79 cwd: dir,
80 env: gitEnv,
81 stdout: "ignore",
82 stderr: "pipe",
83 });
84 const [err, code] = await Promise.all([new Response(p.stderr).text(), p.exited]);
85 if (code !== 0) throw new Error(`git ${args.join(" ")}: ${err}`);
86 }
87 const commit = async (text: string, message: string) => {
88 await Bun.write(join(dir, "code.txt"), text);
89 await git(["commit", "-am", message]);
90 };
91
92 beforeAll(async () => {

No timeout on this beforeAll. It takes about 6 s locally and fails under bun test's 5 s default. Pass 120_000 as the TUI suite does.

93 dataDir = await mkdtemp(join(tmpdir(), "forge-cf-"));
94 work = await mkdtemp(join(tmpdir(), "forge-cf-work-"));
95 app = createApp({ dataDir, port: 0 });
96 servers = await serve(app);
97 base = servers.url;
98 await fetch(`${base}/signup`, {
99 method: "POST",
100 body: new URLSearchParams({
101 username: "dev",
102 email: "[email protected]",
103 password: "conflicts-pass-123",
104 }),
105 redirect: "manual",
106 });
107 const token = await fetch(`${base}/api/v1/tokens`, {
108 method: "POST",
109 headers: { "content-type": "application/json" },
110 body: JSON.stringify({
111 adminConfirm: true,
112 username: "dev",
113 password: "conflicts-pass-123",
114 name: "cli",
115 scope: "rw",
116 }),
117 })
118 .then((r) => r.json())
119 .then((j) => (j as { token: string }).token);
120 headers = { authorization: `Bearer ${token}`, "content-type": "application/json" };
121 await fetch(`${base}/api/v1/repos`, {
122 method: "POST",
123 headers,
124 body: JSON.stringify({ name: "app", visibility: "public" }),
125 });
126 dir = work;
127 await git(["clone", `${base.replace("http://", `http://dev:${token}@`)}/dev/app.git`, "app"]);
128 dir = join(work, "app");
129 const lines = (a: string, b: string) => `x\n${a}\nm1\nm2\nm3\nm4\nm5\nm6\n${b}\ny\n`;
130 await Bun.write(join(dir, "code.txt"), lines("a", "b"));
131 await git(["add", "."]);
132 await git(["commit", "-m", "base"]);
133 await git(["push", "origin", "main"]);
134 await git(["checkout", "-b", "loud"]);
135 await commit(lines("A-loud", "B-loud"), "loud");
136 await git(["push", "origin", "loud"]);
137 await fetch(`${base}/api/v1/repos/dev/app/pulls`, {
138 method: "POST",
139 headers,
140 body: JSON.stringify({ base: "main", head: "loud", title: "Loud" }),
141 });
142 await git(["checkout", "main"]);
143 await commit(lines("A-main", "B-main"), "main");
144 await git(["push", "origin", "main"]);
145 }, 120_000);
146
147 afterAll(async () => {
148 servers.stop();
149 app.close();
150 await rm(dataDir, { recursive: true, force: true });
151 await rm(work, { recursive: true, force: true });
152 });
153
154 test("shows the count and the merged file for the chosen blocks", async () => {
155 const url = `${base}/dev/app/pulls/1/resolve`;
156 const none = await (await fetch(url, { headers })).text();
157 expect(none).toContain("0 of 2 resolved");
158 expect(none).toContain("&lt;&lt;&lt;&lt;&lt;&lt;&lt; main");
159
160 const res = await fetch(`${url}?file=code.txt&b0=both&b1=text&t1=mine%0A`, { headers });
161 expect(res.status).toBe(200);
162 const page = await res.text();
163 expect(page).toContain("2 of 2 resolved");
164 expect(page).toContain("A-main\nA-loud\n");
165 expect(page).toContain("m6\nmine\ny\n");
166 });
167
168 test("readers and strangers do not get it; an unnamed file is not read", async () => {
169 const url = `${base}/dev/app/pulls/1/resolve`;
170 expect((await fetch(url)).status).toBe(404);

Low: on the web, only an anonymous visitor is shown to get a 404. A signed-in reader (a second account that is not a collaborator) should be checked too, the way the terminal test checks carol. The gate is correct today; this test would catch it if canWrite were ever loosened to canRead.

171 const odd = await (await fetch(`${url}?file=../../etc/passwd`, { headers })).text();
172 expect(odd).toContain("Nothing to resolve");
173 expect(odd).toContain("not one of the conflicted files");
174 expect(odd).not.toContain("has no conflicts");
175 });
176
177 test("the merge box offers Resolve to a writer", async () => {
178 const files = await (await fetch(`${base}/dev/app/pulls/1/files`, { headers })).text();
179 expect(files).toContain('href="/dev/app/pulls/1/resolve"');
180 });
181 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs