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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/1966-conflicts-part-1-resolve-on-a-conflicted → main 3 commits fork 8 threads · 8 unresolved
README.md +2
| @@ -213,6 +213,8 @@ git objects; anything you then do here is **written through** to the origin | ||
| 213 | 213 | first and reflected locally after it succeeds. For a mirror the origin is |
| 214 | 214 | truth, exactly as git is truth for a native repo. |
| 215 | 215 | |
| 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 | ||
| 216 | 218 | A pull from an outsider's fork on the origin is someone else's code, so its |
| 217 | 219 | check and preview wait. A person with write access lets that exact head run: |
| 218 | 220 | 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 = { | ||
| 28 | 28 | |
| 29 | 29 | export const GATE: readonly string[] = [ |
| 30 | 30 | "test/domain-drill.test.ts", |
| 31 | "test/conflicts.test.ts", | |
| 32 | "test/conflicts-tui.test.ts", | |
| 31 | 33 | "test/accounts-retire.test.ts", |
| 32 | 34 | "test/client-home.test.ts", |
| 33 | 35 | "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); | |
| 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 |
||
| 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 { | ||
| 295 | 295 | pullThreadFragment, |
| 296 | 296 | threadResolveSubmit, |
| 297 | 297 | } from "../web/pages/pull.ts"; |
| 298 | import { conflictsPage } from "../web/pages/conflicts.ts"; | |
| 298 | 299 | import { |
| 299 | 300 | appDisable, |
| 300 | 301 | appMembersUpdate, |
| @@ -988,6 +989,7 @@ export function buildRouter(): Router<Handler> { | ||
| 988 | 989 | // The change's Files step: the diff, the review threads and the merge |
| 989 | 990 | // box, one tab under the change itself. |
| 990 | 991 | router.add("GET", "/:owner/:name/pulls/:number/files", pullFilesPage); |
| 992 | router.add("GET", "/:owner/:name/pulls/:number/resolve", conflictsPage); | |
| 991 | 993 | router.add("GET", "/:owner/:name/pulls/:number/thread", pullThreadFragment); |
| 992 | 994 | router.add("GET", "/:owner/:name/pulls/:number/state", pullStateFragment); |
| 993 | 995 | 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 |
||
| 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 { | ||
| 102 | 102 | import { assignPicker, assignedText } from "./assign.ts"; |
| 103 | 103 | import { DiffView, type DiffAnchor, type DiffNote } from "./commits.ts"; |
| 104 | 104 | import { liveBindings, liveKey } from "./mirror.ts"; |
| 105 | import { pullConflicts } from "../../actions/conflicts.ts"; | |
| 106 | import { conflictScreen } from "./conflicts.ts"; | |
| 105 | 107 | import { reviewScreen } from "./review.ts"; |
| 106 | 108 | import { RunList, openRunLog, runTone } from "./runlist.ts"; |
| 107 | 109 | import { Composer, threadLines, type ThreadEntry } from "./thread.ts"; |
| @@ -632,6 +634,22 @@ export function pullScreen( | ||
| 632 | 634 | }); |
| 633 | 635 | }; |
| 634 | 636 | |
| 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 | ||
| 635 | 653 | const mirrored = (): boolean => repo === undefined || repo.origin !== null; |
| 636 | 654 | const state = (): MergeState => |
| 637 | 655 | mergeState(detail?.issue, facts, mirrored(), gated(), checksGated()); |
| @@ -662,6 +680,9 @@ export function pullScreen( | ||
| 662 | 680 | ? [binding(["A"], "approve to run here")] |
| 663 | 681 | : []), |
| 664 | 682 | binding(["a"], "assign"), |
| 683 | ...(state() === "conflicts" | |
| 684 | ? [binding(["v"], "see the conflict blocks")] | |
| 685 | : []), | |
| 665 | 686 | binding(["m"], "merge", { disabled: state() !== "ready" }), |
| 666 | 687 | ...(tab === 1 && view.threadHere() !== null |
| 667 | 688 | ? [ |
| @@ -875,6 +896,10 @@ export function pullScreen( | ||
| 875 | 896 | assign(); |
| 876 | 897 | return true; |
| 877 | 898 | } |
| 899 | if (e.key === "v" && mayWrite() && state() === "conflicts") { | |
| 900 | seeConflicts(); | |
| 901 | return true; | |
| 902 | } | |
| 878 | 903 | // The crew tab is a list: the cursor moves, enter opens the run. |
| 879 | 904 | if (tab === 2) { |
| 880 | 905 | 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 |
||
| 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 |
||
| 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( | ||
| 414 | 414 | >${status.conflicts.slice(0, 3).join(", ")}</span |
| 415 | 415 | >${status.conflicts.length > 3 ? "…" : ""}. Merge |
| 416 | 416 | <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 | : ""}` | |
| 418 | 420 | : html`This branch cannot be applied automatically.`; |
| 419 | 421 | return card( |
| 420 | 422 | 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 |
||
| 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("<<<<<<< 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 |
||
| 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 | }); |
Each iteration reruns the whole merge in
readConflictedFile. Run the merge once and read every path from the tree it wrote.