Public: anyone can read this; the crew can change it.
On a fork, the terminal's forks screen takes the parent's updates with `t` after a confirm: fast-forward, clean merge, or the converge issue on a conflict, as MCP `take_updates` does #7495
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/7487-on-a-fork-the-terminal-s-forks-screen-ta → main 2 commits fork
6 files
changed +336
−69
docs/FOLLOWING.md +13 −3
| @@ -214,9 +214,19 @@ with `follows owner/name` and MCP `following`'s standing: `in step`, or the | ||
| 214 | 214 | state with `N ahead · M behind` (`parent frozen` behind a disconnected |
| 215 | 215 | origin). When the crew has a converge in hand, a second line says what it is |
| 216 | 216 | doing, in the words the following page uses; a fork that merges cleanly needs |
| 217 | no crew and has no such line. `enter` on either opens the parent. A parent the | |
| 218 | viewer may not read is never named (`relationOf` hides it), and a repository | |
| 219 | that follows nothing shows neither line. | |
| 217 | no crew and has no such line. `enter` on the first opens the parent; on the | |
| 218 | second, the converge issue. A parent the viewer may not read is never named | |
| 219 | (`relationOf` hides it), and a repository that follows nothing shows neither | |
| 220 | line. | |
| 221 | ||
| 222 | On a fork behind a parent that is not frozen, a person who writes the fork is | |
| 223 | offered `t`: a confirm says what will happen (`Fast-forward to owner/name: N | |
| 224 | commits.`, a merge that keeps the fork's own commits, or the converge issue | |
| 225 | when both sides changed the same files), and `y` calls `takeOrTell`, as the | |
| 226 | following page and MCP `take_updates` do. The answer is the page's sentence | |
| 227 | (`takeWords`); on a conflict the crew's line names the converge issue opened | |
| 228 | or found, and `enter` opens it. A reader, an agent, a fork in step and a | |
| 229 | repository that follows nothing are not offered `t`. | |
| 220 | 230 | |
| 221 | 231 | Forks with nothing ahead (or whose standing git cannot read) follow under |
| 222 | 232 | `N forks with nothing new`. At most 60 forks are ranked per view; past that |
src/actions/follow.ts +48
| @@ -14,6 +14,7 @@ import type { User } from "../core/users.ts"; | ||
| 14 | 14 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 15 | 15 | import { chainOfRepo, forgeLinkOf, FOLLOW_MODES, isFollowMode, setChainMode, severChain, type FollowMode } from "../spawn/chain.ts"; |
| 16 | 16 | import { FAMILY_WORDS } from "./family_words.ts"; |
| 17 | import { FOLLOWS_NOTHING } from "./follow_words.ts"; | |
| 17 | 18 | import { requireUser } from "./access.ts"; |
| 18 | 19 | import { takeFromParent, type Taken } from "../spawn/take.ts"; |
| 19 | 20 | import { takeUpdates, type ForkSync, type TakeUpdatesError } from "./forks.ts"; |
| @@ -185,3 +186,50 @@ export function convergeWords( | ||
| 185 | 186 | }; |
| 186 | 187 | } |
| 187 | 188 | } |
| 189 | /** | |
| 190 | * What taking updates says when there was nothing to take. "Up to date" | |
| 191 | * only of a parent that is itself up to date: a frozen one may be far | |
| 192 | * behind its own upstream, and the take only knows the frozen copy. | |
| 193 | */ | |
| 194 | export function nothingNew(parent: Repo): string { | |
| 195 | const name = `${parent.ownerName}/${parent.name}`; | |
| 196 | return parent.detached === null | |
| 197 | ? `Already up to date with ${name}.` | |
| 198 | : `Nothing new from ${name}, which is frozen: its origin was disconnected, so upstream may have moved. Reconnect it.`; | |
| 199 | } | |
| 200 | ||
| 201 | /** | |
| 202 | * What a take said, for every door that words it the same: taken (moved or | |
| 203 | * merged), nothing new, refused, or a conflict with what the crew did about it. | |
| 204 | */ | |
| 205 | export function takeWords( | |
| 206 | took: TakeOrTell, | |
| 207 | fork: Repo, | |
| 208 | parent: Repo, | |
| 209 | ): { tone?: "danger"; message: string } { | |
| 210 | const parentName = `${parent.ownerName}/${parent.name}`; | |
| 211 | if (took.ok) | |
| 212 | return { | |
| 213 | message: !took.value.updated | |
| 214 | ? nothingNew(parent) | |
| 215 | : took.value.merged | |
| 216 | ? `Took ${parentName}'s updates: merged into ${fork.defaultBranch} at ${took.value.to.slice(0, 7)}.` | |
| 217 | : `Took ${parentName}'s updates: ${fork.defaultBranch} is at ${took.value.to.slice(0, 7)}.`, | |
| 218 | }; | |
| 219 | const e = took.error; | |
| 220 | if (typeof e === "string") | |
| 221 | return { | |
| 222 | tone: "danger", | |
| 223 | message: e === "not_a_fork" ? FOLLOWS_NOTHING : "You need write access to take updates here.", | |
| 224 | }; | |
| 225 | // A conflict is a tell for the crew (`takeOrTell`). | |
| 226 | if (e.kind === "conflict") { | |
| 227 | const files = `${e.files.length === 1 ? "One file needs" : `${e.files.length} files need`} a decision (${e.files.join(", ")})`; | |
| 228 | const words = "converge" in e ? convergeWords(e.converge, parentName) : { message: "" }; | |
| 229 | return { tone: "danger", message: `${files}. ${words.message}` }; | |
| 230 | } | |
| 231 | return { | |
| 232 | tone: "danger", | |
| 233 | message: e.kind === "moved" ? "Something moved while this was read; try again." : `Git could not take the updates: ${e.message}`, | |
| 234 | }; | |
| 235 | } |
src/tui/screens/forks.ts +107 −21
| @@ -4,6 +4,9 @@ | ||
| 4 | 4 | * Forks that are ahead come first, with the one that has the most to offer on |
| 5 | 5 | * top; those with nothing new follow under their count. A fork's own parent |
| 6 | 6 | * leads, read as MCP `following` reads it (`relationOf`, `convergeState`). |
| 7 | * On a fork behind its parent, a person who writes it takes the parent's | |
| 8 | * updates with `t` after a confirm — `takeOrTell`, as MCP `take_updates` — | |
| 9 | * and a conflict's converge issue is the line Enter then opens. | |
| 7 | 10 | */ |
| 8 | 11 | |
| 9 | 12 | import { |
| @@ -15,9 +18,9 @@ import { | ||
| 15 | 18 | type ForkRanking, |
| 16 | 19 | type ForkRelation, |
| 17 | 20 | } from "../../actions/forks.ts"; |
| 18 | import { convergeWords } from "../../actions/follow.ts"; | |
| 21 | import { convergeWords, takeOrTell, takeWords } from "../../actions/follow.ts"; | |
| 19 | 22 | import { convergeState, type Converge } from "../../crew/converge.ts"; |
| 20 | import { canRead, forkTree, listForks, type ForkNode, type Repo } from "../../core/repos.ts"; | |
| 23 | import { canRead, canWrite, forkTree, listForks, type ForkNode, type Repo } from "../../core/repos.ts"; | |
| 21 | 24 | import type { Ctx, Screen } from "../app/app.ts"; |
| 22 | 25 | import { binding } from "../app/keys.ts"; |
| 23 | 26 | import { style } from "../term/style.ts"; |
| @@ -25,15 +28,25 @@ import { truncate } from "../term/width.ts"; | ||
| 25 | 28 | import { fill, len, split } from "../ui/layout.ts"; |
| 26 | 29 | import { emptyState } from "../ui/widgets.ts"; |
| 27 | 30 | import type { Session } from "../session.ts"; |
| 31 | import { issueScreen } from "./issue.ts"; | |
| 28 | 32 | import { local } from "./where.ts"; |
| 29 | 33 | import { repoScreen } from "./workspace.ts"; |
| 30 | import { ListState, drawList, plural, type ListRow } from "./common.ts"; | |
| 34 | import { ListState, confirmScreen, drawList, plural, runAsync, type ListRow } from "./common.ts"; | |
| 35 | ||
| 36 | /** One line of the screen; `issue` is a converge issue on the fork, which Enter opens. */ | |
| 37 | export interface ForkRow { | |
| 38 | repo: Repo; | |
| 39 | name: string; | |
| 40 | words: string; | |
| 41 | heading: string; | |
| 42 | issue?: number; | |
| 43 | } | |
| 31 | 44 | |
| 32 | 45 | /** The rows the screen draws, in order: pure so a test reads them without a terminal. */ |
| 33 | 46 | export function forkRows( |
| 34 | 47 | ranked: ForkRanking, |
| 35 | 48 | tree: readonly ForkNode[] = [], |
| 36 | ): { repo: Repo; name: string; words: string; heading: string }[] { | |
| 49 | ): ForkRow[] { | |
| 37 | 50 | const below = new Map(tree.map((n) => [n.repo.id, n.forks])); |
| 38 | 51 | const row = (f: ForkRanking["offering"][number], heading: string) => [ |
| 39 | 52 | { |
| @@ -64,7 +77,7 @@ export function forkRows( | ||
| 64 | 77 | export function followingRows( |
| 65 | 78 | relation: ForkRelation | undefined, |
| 66 | 79 | converge: Converge | undefined, |
| 67 | ): { repo: Repo; name: string; words: string; heading: string }[] { | |
| 80 | ): ForkRow[] { | |
| 68 | 81 | if (relation === undefined) return []; |
| 69 | 82 | const { parent } = relation; |
| 70 | 83 | const name = `${parent.ownerName}/${parent.name}`; |
| @@ -81,31 +94,98 @@ export function followingRows( | ||
| 81 | 94 | { repo: parent, name: `follows ${name}`, words, heading: "" }, |
| 82 | 95 | ...(converge === undefined |
| 83 | 96 | ? [] |
| 84 | : [{ repo: parent, name: ` ${convergeWords(converge, name).message}`, words: "", heading: "" }]), | |
| 97 | : [ | |
| 98 | { | |
| 99 | repo: parent, | |
| 100 | name: ` ${convergeWords(converge, name).message}`, | |
| 101 | words: "", | |
| 102 | heading: "", | |
| 103 | ...("issue" in converge ? { issue: converge.issue.number } : {}), | |
| 104 | }, | |
| 105 | ]), | |
| 85 | 106 | ]; |
| 86 | 107 | } |
| 87 | 108 | |
| 109 | /** | |
| 110 | * What `t` would do, or undefined when it is not offered: only on a fork | |
| 111 | * behind a parent that is not frozen, and only to a person who writes it. | |
| 112 | * Agents take updates through MCP `take_updates`, not a keypress. | |
| 113 | */ | |
| 114 | export function takeOffer( | |
| 115 | relation: ForkRelation | undefined, | |
| 116 | writer: boolean, | |
| 117 | person: boolean, | |
| 118 | ): string | undefined { | |
| 119 | if (relation === undefined || !writer || !person) return undefined; | |
| 120 | if (relation.behind === 0 || relation.parent.detached !== null) return undefined; | |
| 121 | const { parent, behind, ahead } = relation; | |
| 122 | const name = `${parent.ownerName}/${parent.name}`; | |
| 123 | const commits = plural(behind, "commit"); | |
| 124 | if (ahead === 0) return `Fast-forward to ${name}: ${commits}.`; | |
| 125 | if (relation.clean) return `Merge ${commits} from ${name}; your ${plural(ahead, "commit")} stay.`; | |
| 126 | return `Both sides changed ${relation.conflicts.join(", ")}: the crew is told on a converge issue.`; | |
| 127 | } | |
| 128 | ||
| 88 | 129 | export function forksScreen(f: Session, ctx: Ctx, repo: Repo): Screen { |
| 89 | 130 | let ranked: ForkRanking | undefined; |
| 90 | 131 | let tree: ForkNode[] = []; |
| 91 | let following: ReturnType<typeof followingRows> = []; | |
| 132 | let following: ForkRow[] = []; | |
| 133 | let relation: ForkRelation | undefined; | |
| 134 | let offer: string | undefined; | |
| 92 | 135 | const state = new ListState(); |
| 93 | 136 | const rows = () => [...following, ...(ranked === undefined ? [] : forkRows(ranked, tree))]; |
| 94 | return { | |
| 137 | const read = async (): Promise<void> => { | |
| 138 | const forks = listForks(f.db, repo.id).filter((r) => canRead(f.db, r, f.user)); | |
| 139 | ranked = await rankedForksOf(f.app, repo, forks); | |
| 140 | tree = forkTree(f.db, repo.id, f.user); | |
| 141 | relation = await relationOf(f.app, f.user, repo); | |
| 142 | offer = takeOffer(relation, canWrite(f.db, repo, f.user), f.user?.kind === "user"); | |
| 143 | const converge = | |
| 144 | relation !== undefined && relation.state === "diverged" && !relation.clean | |
| 145 | ? convergeState(f.app, repo, relation.parent) | |
| 146 | : undefined; | |
| 147 | following = followingRows(relation, converge); | |
| 148 | ctx.invalidate(); | |
| 149 | }; | |
| 150 | const take = (): void => { | |
| 151 | const parent = relation?.parent; | |
| 152 | if (offer === undefined || parent === undefined) return; | |
| 153 | ctx.push( | |
| 154 | confirmScreen(ctx, self, { | |
| 155 | title: "Take updates", | |
| 156 | body: [offer], | |
| 157 | actions: [{ label: "Take updates" }, { label: "Cancel", variant: "ghost" }], | |
| 158 | yesNo: true, | |
| 159 | onChoose: (choice) => { | |
| 160 | if (choice !== 0) return; | |
| 161 | runAsync(ctx, async () => { | |
| 162 | const took = await takeOrTell(f.app, f.user, repo); | |
| 163 | const words = takeWords(took, repo, parent); | |
| 164 | await read(); | |
| 165 | // The converge issue just opened or found is the line Enter opens. | |
| 166 | const e = took.ok ? undefined : took.error; | |
| 167 | if (typeof e === "object" && e.kind === "conflict" && "converge" in e && relation !== undefined) { | |
| 168 | following = followingRows(relation, e.converge); | |
| 169 | state.index = following.length - 1; | |
| 170 | } | |
| 171 | ctx.toast(words.message, words.tone === "danger" ? "danger" : "default"); | |
| 172 | ctx.invalidate(); | |
| 173 | }); | |
| 174 | }, | |
| 175 | }), | |
| 176 | ); | |
| 177 | }; | |
| 178 | const self: Screen = { | |
| 95 | 179 | title: "Forks", |
| 96 | keys: [binding(["j", "k"], "move"), binding(["enter"], "open"), binding(["esc"], "close")], | |
| 97 | async load() { | |
| 98 | const forks = listForks(f.db, repo.id).filter((r) => canRead(f.db, r, f.user)); | |
| 99 | ranked = await rankedForksOf(f.app, repo, forks); | |
| 100 | tree = forkTree(f.db, repo.id, f.user); | |
| 101 | const relation = await relationOf(f.app, f.user, repo); | |
| 102 | const converge = | |
| 103 | relation !== undefined && relation.state === "diverged" && !relation.clean | |
| 104 | ? convergeState(f.app, repo, relation.parent) | |
| 105 | : undefined; | |
| 106 | following = followingRows(relation, converge); | |
| 107 | ctx.invalidate(); | |
| 180 | get keys() { | |
| 181 | return [ | |
| 182 | binding(["j", "k"], "move"), | |
| 183 | binding(["enter"], "open"), | |
| 184 | ...(offer === undefined ? [] : [binding(["t"], "take updates")]), | |
| 185 | binding(["esc"], "close"), | |
| 186 | ]; | |
| 108 | 187 | }, |
| 188 | load: () => read(), | |
| 109 | 189 | render(area, s) { |
| 110 | 190 | const c = ctx.theme.c; |
| 111 | 191 | const [head, body] = split(area, "v", [len(1), fill(1)]); |
| @@ -144,7 +224,12 @@ export function forksScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 144 | 224 | if (state.key(e, list.length)) return true; |
| 145 | 225 | if (e.key === "enter") { |
| 146 | 226 | const row = list[state.index]; |
| 147 | if (row !== undefined) ctx.push(repoScreen(f, ctx, local(f, row.repo))); | |
| 227 | if (row?.issue !== undefined) ctx.push(issueScreen(f, ctx, local(f, repo), row.issue)); | |
| 228 | else if (row !== undefined) ctx.push(repoScreen(f, ctx, local(f, row.repo))); | |
| 229 | return true; | |
| 230 | } | |
| 231 | if (e.key === "t" && offer !== undefined) { | |
| 232 | take(); | |
| 148 | 233 | return true; |
| 149 | 234 | } |
| 150 | 235 | if (e.key === "esc" || e.key === "q") { |
| @@ -154,4 +239,5 @@ export function forksScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 154 | 239 | return false; |
| 155 | 240 | }, |
| 156 | 241 | }; |
| 242 | return self; | |
| 157 | 243 | } |
src/web/pages/following.ts +2 −44
| @@ -37,7 +37,7 @@ import { | ||
| 37 | 37 | } from "../../core/parent_releases.ts"; |
| 38 | 38 | import { running } from "../../kernel/version.ts"; |
| 39 | 39 | import { loadRepo, notFoundPage, repoBase, signInFirst } from "../chrome.ts"; |
| 40 | import { convergeWords, takeOrTell } from "../../actions/follow.ts"; | |
| 40 | import { convergeWords, takeOrTell, takeWords } from "../../actions/follow.ts"; | |
| 41 | 41 | import { FOLLOWS_NOTHING, PROPOSE_NEEDS_WRITE } from "../../actions/follow_words.ts"; |
| 42 | 42 | import { openProposal, proposeUpstream } from "../../actions/upstream.ts"; |
| 43 | 43 | import { field, readForm } from "../form.ts"; |
| @@ -352,18 +352,6 @@ export function followingCard( | ||
| 352 | 352 | ); |
| 353 | 353 | } |
| 354 | 354 | |
| 355 | /** | |
| 356 | * What taking updates says when there was nothing to take. "Up to date" | |
| 357 | * only of a parent that is itself up to date: a frozen one may be far | |
| 358 | * behind its own upstream, and the take only knows the frozen copy. | |
| 359 | */ | |
| 360 | export function nothingNew(parent: Repo): string { | |
| 361 | const name = `${parent.ownerName}/${parent.name}`; | |
| 362 | return parent.detached === null | |
| 363 | ? `Already up to date with ${name}.` | |
| 364 | : `Nothing new from ${name}, which is frozen: its origin was disconnected, so upstream may have moved. Reconnect it.`; | |
| 365 | } | |
| 366 | ||
| 367 | 355 | /** |
| 368 | 356 | * POST /:owner/:name/following { do: take | converge } |
| 369 | 357 | * One form, two verbs: take what git can take, or hand the rest to the crew. |
| @@ -421,35 +409,5 @@ export async function followingSubmit(ctx: Ctx): Promise<Response> { | ||
| 421 | 409 | } |
| 422 | 410 | |
| 423 | 411 | const took = await takeOrTell(ctx.app, ctx.auth.user, repo); |
| 424 | if (!took.ok) { | |
| 425 | const e = took.error; | |
| 426 | if (typeof e === "string") | |
| 427 | return flashOrJson(ctx, base, { | |
| 428 | tone: "danger", | |
| 429 | message: | |
| 430 | e === "not_a_fork" | |
| 431 | ? FOLLOWS_NOTHING | |
| 432 | : "You need write access to take updates here.", | |
| 433 | }); | |
| 434 | // A conflict is a tell for the crew (`actions/follow.ts`). | |
| 435 | if (e.kind === "conflict") { | |
| 436 | const files = `${e.files.length === 1 ? "One file needs" : `${e.files.length} files need`} a decision (${e.files.join(", ")})`; | |
| 437 | const words = "converge" in e ? convergeWords(e.converge, parentName) : { message: "" }; | |
| 438 | return flashOrJson(ctx, base, { tone: "danger", message: `${files}. ${words.message}` }); | |
| 439 | } | |
| 440 | return flashOrJson(ctx, base, { | |
| 441 | tone: "danger", | |
| 442 | message: | |
| 443 | e.kind === "moved" | |
| 444 | ? "Something moved while this was read; try again." | |
| 445 | : `Git could not take the updates: ${e.message}`, | |
| 446 | }); | |
| 447 | } | |
| 448 | return flashOrJson(ctx, base, { | |
| 449 | message: !took.value.updated | |
| 450 | ? nothingNew(parent) | |
| 451 | : took.value.merged | |
| 452 | ? `Took ${parentName}'s updates: merged into ${repo.defaultBranch} at ${took.value.to.slice(0, 7)}.` | |
| 453 | : `Took ${parentName}'s updates: ${repo.defaultBranch} is at ${took.value.to.slice(0, 7)}.`, | |
| 454 | }); | |
| 412 | return flashOrJson(ctx, base, takeWords(took, repo, parent)); | |
| 455 | 413 | } |
src/web/pages/repo.ts +1 −1
| @@ -31,8 +31,8 @@ import { | ||
| 31 | 31 | followingCard, |
| 32 | 32 | followingOf, |
| 33 | 33 | lineageCard, |
| 34 | nothingNew, | |
| 35 | 34 | } from "./following.ts"; |
| 35 | import { nothingNew } from "../../actions/follow.ts"; | |
| 36 | 36 | import { frozenBlock } from "./frozen.ts"; |
| 37 | 37 | import { pinRepo, syncRepoNow, unpinRepo } from "../../actions/origins.ts"; |
| 38 | 38 | import { |
test/tui-forks-take.test.ts added +165
| @@ -0,0 +1,165 @@ | ||
| 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 { commitToBranch, createRepo, forkRepo } from "../src/actions/repos.ts"; | |
| 6 | import { forkRelation, type ForkRelation } from "../src/core/following.ts"; | |
| 7 | import { getRepo, type Repo } from "../src/core/repos.ts"; | |
| 8 | import { createUser, type User } from "../src/core/users.ts"; | |
| 9 | import { convergeTitle } from "../src/crew/converge.ts"; | |
| 10 | import { createIssue } from "../src/core/issues.ts"; | |
| 11 | import { createApp, type App } from "../src/app.ts"; | |
| 12 | import { repoDir } from "../src/git/repos.ts"; | |
| 13 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 14 | import { forksScreen, takeOffer } from "../src/tui/screens/forks.ts"; | |
| 15 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 16 | import { settleApp } from "./helpers/tui.ts"; | |
| 17 | import { promote } from "./helpers/members.ts"; | |
| 18 | ||
| 19 | const must = <T>(r: { ok: true; value: T } | { ok: false; error: unknown }): T => { | |
| 20 | if (!r.ok) throw new Error(JSON.stringify(r.error)); | |
| 21 | return r.value; | |
| 22 | }; | |
| 23 | ||
| 24 | describe("takeOffer", () => { | |
| 25 | const parent = { ownerName: "alice", name: "app", detached: null } as unknown as Repo; | |
| 26 | const at = (behind: number, ahead: number, clean = true, conflicts: string[] = []) => | |
| 27 | ({ parent, state: "diverged", behind, ahead, clean, conflicts }) as unknown as ForkRelation; | |
| 28 | ||
| 29 | test("says a fast-forward, a merge, or the converge issue", () => { | |
| 30 | expect(takeOffer(at(3, 0), true, true)).toBe("Fast-forward to alice/app: 3 commits."); | |
| 31 | expect(takeOffer(at(1, 2), true, true)).toBe("Merge 1 commit from alice/app; your 2 commits stay."); | |
| 32 | expect(takeOffer(at(1, 1, false, ["a.txt"]), true, true)).toContain("converge issue"); | |
| 33 | }); | |
| 34 | ||
| 35 | test("not behind, no parent, a frozen parent, a reader or an agent: no offer", () => { | |
| 36 | expect(takeOffer(at(0, 2), true, true)).toBeUndefined(); | |
| 37 | expect(takeOffer(undefined, true, true)).toBeUndefined(); | |
| 38 | const frozen = { ...at(2, 0), parent: { ...parent, detached: { detachedAt: null } } } as ForkRelation; | |
| 39 | expect(takeOffer(frozen, true, true)).toBeUndefined(); | |
| 40 | expect(takeOffer(at(2, 0), false, true)).toBeUndefined(); | |
| 41 | expect(takeOffer(at(2, 0), true, false)).toBeUndefined(); | |
| 42 | }); | |
| 43 | }); | |
| 44 | ||
| 45 | describe("the terminal's forks screen takes the parent's updates with t", () => { | |
| 46 | let dataDir: string; | |
| 47 | let app: App; | |
| 48 | let upstream: Repo; | |
| 49 | let converge = 0; | |
| 50 | const users: Record<string, User> = {}; | |
| 51 | ||
| 52 | const screenOf = async (who: string, owner: string, ...keys: string[]) => { | |
| 53 | const terminal = headlessTerminal(120, 30); | |
| 54 | const { app: tui, session } = createForgeApp( | |
| 55 | app, | |
| 56 | { sshUsername: who, remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user: users[who]! }, | |
| 57 | { terminal, exitHooks: false }, | |
| 58 | ); | |
| 59 | tui.start(); | |
| 60 | tui.ctx.push(forksScreen(session, tui.ctx, getRepo(app.db, owner, "app")!)); | |
| 61 | await settleApp(tui); | |
| 62 | const frames: string[] = [tui.screen.toText()]; | |
| 63 | for (const k of keys) { | |
| 64 | terminal.feed(k); | |
| 65 | await settleApp(tui); | |
| 66 | frames.push(tui.screen.toText()); | |
| 67 | } | |
| 68 | tui.stop?.(); | |
| 69 | return frames; | |
| 70 | }; | |
| 71 | const behind = async (owner: string) => | |
| 72 | (await forkRelation(app, getRepo(app.db, owner, "app")!, upstream))!.behind; | |
| 73 | const git = async (cwd: string, ...args: string[]) => { | |
| 74 | const p = Bun.spawn(["git", ...args], { cwd, stdout: "pipe", stderr: "pipe" }); | |
| 75 | const out = await new Response(p.stdout).text(); | |
| 76 | expect(await p.exited).toBe(0); | |
| 77 | return out.trim(); | |
| 78 | }; | |
| 79 | ||
| 80 | beforeAll(async () => { | |
| 81 | dataDir = await mkdtemp(join(tmpdir(), "forge-tui-forks-take-")); | |
| 82 | app = createApp({ dataDir, port: 0 }); | |
| 83 | for (const name of ["alice", "bob", "carol", "dan"]) { | |
| 84 | users[name] = must( | |
| 85 | await createUser(app.db, { | |
| 86 | origin: "seed", | |
| 87 | trusted: true, | |
| 88 | username: name, | |
| 89 | email: `${name}@f.dev`, | |
| 90 | password: "a-good-passphrase", | |
| 91 | adminIfFirst: name === "alice", | |
| 92 | }), | |
| 93 | ); | |
| 94 | promote(app.db, users[name]!); | |
| 95 | } | |
| 96 | upstream = must(await createRepo(app, users["alice"]!, { name: "app", visibility: "public" })); | |
| 97 | must(await commitToBranch(app, users["alice"]!, upstream, { message: "one", files: [{ path: "a.txt", content: "a\n" }] })); | |
| 98 | must(await forkRepo(app, users["bob"]!, upstream)); | |
| 99 | const dan = must(await forkRepo(app, users["dan"]!, upstream)); | |
| 100 | must( | |
| 101 | await commitToBranch(app, users["dan"]!, dan, { | |
| 102 | message: "dan shared", | |
| 103 | files: [{ path: "shared.txt", content: "dan\n" }], | |
| 104 | }), | |
| 105 | ); | |
| 106 | const before = await git(repoDir(app.reposRoot, "bob", "app"), "rev-parse", "refs/heads/main"); | |
| 107 | must( | |
| 108 | await commitToBranch(app, users["alice"]!, upstream, { | |
| 109 | message: "two", | |
| 110 | files: [ | |
| 111 | { path: "b.txt", content: "b\n" }, | |
| 112 | { path: "shared.txt", content: "alice\n" }, | |
| 113 | ], | |
| 114 | }), | |
| 115 | ); | |
| 116 | // Bob's fork follows by itself; put it back where it was so it is behind only. | |
| 117 | for (let i = 0; i < 100 && (await behind("bob")) !== 0; i++) await Bun.sleep(50); | |
| 118 | await git(repoDir(app.reposRoot, "bob", "app"), "update-ref", "refs/heads/main", before); | |
| 119 | expect(await behind("bob")).toBe(1); | |
| 120 | converge = must(createIssue(app.db, { repoId: dan.id, authorId: users["dan"]!.id, title: convergeTitle(upstream) })).number; | |
| 121 | }, 60_000); | |
| 122 | ||
| 123 | afterAll(async () => { | |
| 124 | app.close(); | |
| 125 | await rm(dataDir, { recursive: true, force: true }); | |
| 126 | }); | |
| 127 | ||
| 128 | test("a reader of the fork is not offered t, and pressing it moves nothing", async () => { | |
| 129 | const [first, after] = await screenOf("carol", "bob", "t"); | |
| 130 | expect(first).toContain("1 behind"); | |
| 131 | expect(first).not.toContain("take updates"); | |
| 132 | expect(after).not.toContain("Fast-forward"); | |
| 133 | expect(await behind("bob")).toBe(1); | |
| 134 | }); | |
| 135 | ||
| 136 | test("a repository that follows nothing is not offered t", async () => { | |
| 137 | const [first] = await screenOf("alice", "alice"); | |
| 138 | expect(first).not.toContain("take updates"); | |
| 139 | }); | |
| 140 | ||
| 141 | test("a cancelled confirm moves nothing", async () => { | |
| 142 | const [first, asked, cancelled] = await screenOf("bob", "bob", "t", "n"); | |
| 143 | expect(first).toContain("take updates"); | |
| 144 | expect(asked).toContain("Fast-forward to alice/app: 1 commit."); | |
| 145 | expect(cancelled).not.toContain("Fast-forward"); | |
| 146 | expect(await behind("bob")).toBe(1); | |
| 147 | }); | |
| 148 | ||
| 149 | test("t and a confirm fast-forward the fork, said in the following page's words", async () => { | |
| 150 | const frames = await screenOf("bob", "bob", "t", "y"); | |
| 151 | expect(await behind("bob")).toBe(0); | |
| 152 | expect(frames.at(-1)).toContain("Took alice/app's updates: main is at"); | |
| 153 | expect(frames.at(-1)).toContain("in step"); | |
| 154 | expect(frames.at(-1)).not.toContain("take updates"); | |
| 155 | }); | |
| 156 | ||
| 157 | test("on a conflict the converge issue is said, and Enter opens it", async () => { | |
| 158 | const frames = await screenOf("dan", "dan", "t", "y", "\r"); | |
| 159 | expect(frames[1]).toContain("Both sides changed shared.txt"); | |
| 160 | expect(frames[2]).toContain("One file needs a decision (shared.txt)"); | |
| 161 | expect(frames[2]).toContain(`converging on #${converge}`); | |
| 162 | expect(frames[3]).toContain(convergeTitle(upstream)); | |
| 163 | expect(await behind("dan")).toBe(1); | |
| 164 | }); | |
| 165 | }); |
new above
offline