Public: anyone can read this; the crew can change it.
The terminal's forks screen says where this repository stands against what it follows, and what the crew is doing about it, as MCP `following` does #7311
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/7302-the-terminal-s-forks-screen-says-where-t → main 2 commits fork 2 threads · 2 unresolved
3 files
changed +253
−3
docs/FOLLOWING.md +9
| @@ -209,6 +209,15 @@ page and in the terminal alike); the forks below those are a count, `and N more | ||
| 209 | 209 | forks below`, linking on the page to that fork's forks page. A private fork is |
| 210 | 210 | never named or counted to someone who cannot read it. |
| 211 | 211 | |
| 212 | In the terminal, a repository that follows another opens its forks screen | |
| 213 | with `follows owner/name` and MCP `following`'s standing: `in step`, or the | |
| 214 | state with `N ahead · M behind` (`parent frozen` behind a disconnected | |
| 215 | origin). When the crew has a converge in hand, a second line says what it is | |
| 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. | |
| 220 | ||
| 212 | 221 | Forks with nothing ahead (or whose standing git cannot read) follow under |
| 213 | 222 | `N forks with nothing new`. At most 60 forks are ranked per view; past that |
| 214 | 223 | the page says `Ranking the first 60 of N forks`. A fork's standing is cached |
src/tui/screens/forks.ts +45 −3
| @@ -2,7 +2,8 @@ | ||
| 2 | 2 | * A repository's forks, ranked: the same order and words as the forks page |
| 3 | 3 | * (`actions/forks.ts` decides both), so the best of the tree rises here too. |
| 4 | 4 | * Forks that are ahead come first, with the one that has the most to offer on |
| 5 | * top; those with nothing new follow under their count. | |
| 5 | * top; those with nothing new follow under their count. A fork's own parent | |
| 6 | * leads, read as MCP `following` reads it (`relationOf`, `convergeState`). | |
| 6 | 7 | */ |
| 7 | 8 | |
| 8 | 9 | import { |
| @@ -10,8 +11,12 @@ import { | ||
| 10 | 11 | nothingNewHeading, |
| 11 | 12 | rankedForksOf, |
| 12 | 13 | MAX_RANKED_FORKS, |
| 14 | relationOf, | |
| 13 | 15 | type ForkRanking, |
| 16 | type ForkRelation, | |
| 14 | 17 | } from "../../actions/forks.ts"; |
| 18 | import { convergeWords } from "../../actions/follow.ts"; | |
| 19 | import { convergeState, type Converge } from "../../crew/converge.ts"; | |
| 15 | 20 | import { canRead, forkTree, listForks, type ForkNode, type Repo } from "../../core/repos.ts"; |
| 16 | 21 | import type { Ctx, Screen } from "../app/app.ts"; |
| 17 | 22 | import { binding } from "../app/keys.ts"; |
| @@ -50,18 +55,55 @@ export function forkRows( | ||
| 50 | 55 | ]; |
| 51 | 56 | } |
| 52 | 57 | |
| 58 | /** | |
| 59 | * What the repository follows, in at most two rows: where it stands against | |
| 60 | * its parent, in MCP `following`'s states and counts, and — only when the | |
| 61 | * crew has something in hand — what it is doing about it. A parent the | |
| 62 | * viewer may not read never reaches here: `relationOf` hides it. | |
| 63 | */ | |
| 64 | export function followingRows( | |
| 65 | relation: ForkRelation | undefined, | |
| 66 | converge: Converge | undefined, | |
| 67 | ): { repo: Repo; name: string; words: string; heading: string }[] { | |
| 68 | if (relation === undefined) return []; | |
| 69 | const { parent } = relation; | |
| 70 | const name = `${parent.ownerName}/${parent.name}`; | |
| 71 | const counts = `${relation.ahead} ahead · ${relation.behind} behind`; | |
| 72 | const words = | |
| 73 | parent.detached !== null | |
| 74 | ? "parent frozen" | |
| 75 | : relation.state === "synced" | |
| 76 | ? "in step" | |
| 77 | : relation.state === "diverged" | |
| 78 | ? `diverged · ${counts}${relation.clean ? " · merges cleanly" : ""}` | |
| 79 | : `${relation.state} · ${counts}`; | |
| 80 | return [ | |
| 81 | { repo: parent, name: `follows ${name}`, words, heading: "" }, | |
| 82 | ...(converge === undefined | |
| 83 | ? [] | |
| 84 | : [{ repo: parent, name: ` ${convergeWords(converge, name).message}`, words: "", heading: "" }]), | |
| 85 | ]; | |
| 86 | } | |
| 87 | ||
| 53 | 88 | export function forksScreen(f: Session, ctx: Ctx, repo: Repo): Screen { |
| 54 | 89 | let ranked: ForkRanking | undefined; |
| 55 | 90 | let tree: ForkNode[] = []; |
| 91 | let following: ReturnType<typeof followingRows> = []; | |
| 56 | 92 | const state = new ListState(); |
| 57 | const rows = () => (ranked === undefined ? [] : forkRows(ranked, tree)); | |
| 93 | const rows = () => [...following, ...(ranked === undefined ? [] : forkRows(ranked, tree))]; | |
| 58 | 94 | return { |
| 59 | 95 | title: "Forks", |
| 60 | keys: [binding(["j", "k"], "move"), binding(["enter"], "open this fork"), binding(["esc"], "close")], | |
| 96 | keys: [binding(["j", "k"], "move"), binding(["enter"], "open"), binding(["esc"], "close")], | |
| 61 | 97 | async load() { |
| 62 | 98 | const forks = listForks(f.db, repo.id).filter((r) => canRead(f.db, r, f.user)); |
| 63 | 99 | ranked = await rankedForksOf(f.app, repo, forks); |
| 64 | 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); | |
| 65 | 107 | ctx.invalidate(); |
| 66 | 108 | }, |
| 67 | 109 | render(area, s) { |
test/tui-following.test.ts added +199
| @@ -0,0 +1,199 @@ | ||
| 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, type Converge } from "../src/crew/converge.ts"; | |
| 10 | import { createIssue, type Issue } from "../src/core/issues.ts"; | |
| 11 | import type { Pull } from "../src/core/pulls.ts"; | |
| 12 | import { createApp, type App } from "../src/app.ts"; | |
| 13 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 14 | import { followingRows, forksScreen } 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("followingRows", () => { | |
| 25 | const parent = { ownerName: "alice", name: "app", detached: null } as unknown as Repo; | |
| 26 | const at = (state: ForkRelation["state"], behind: number, ahead: number, clean = true) => | |
| 27 | ({ parent, state, behind, ahead, clean, conflicts: [] }) as unknown as ForkRelation; | |
| 28 | ||
| 29 | test("a fork behind its parent says by how much, in MCP following's state", () => { | |
| 30 | const rows = followingRows(at("behind", 3, 0), undefined); | |
| 31 | expect(rows).toHaveLength(1); | |
| 32 | expect(rows[0]!.name).toBe("follows alice/app"); | |
| 33 | expect(rows[0]!.words).toBe("behind · 0 ahead · 3 behind"); | |
| 34 | expect(rows[0]!.repo).toBe(parent); | |
| 35 | }); | |
| 36 | ||
| 37 | test("in step says so; a frozen parent never claims it", () => { | |
| 38 | expect(followingRows(at("synced", 0, 0), undefined)[0]!.words).toBe("in step"); | |
| 39 | const frozen = { ...at("synced", 0, 0), parent: { ...parent, detached: { detachedAt: null } } } as ForkRelation; | |
| 40 | expect(followingRows(frozen, undefined)[0]!.words).toBe("parent frozen"); | |
| 41 | }); | |
| 42 | ||
| 43 | test("the crew's part is a second line only when there is one", () => { | |
| 44 | const issue = { number: 7 } as Issue; | |
| 45 | const proposed: Converge = { kind: "proposed", issue, pull: { number: 9 } as Pull }; | |
| 46 | const rows = followingRows(at("diverged", 2, 1, false), proposed); | |
| 47 | expect(rows.map((r) => r.words)).toEqual(["diverged · 1 ahead · 2 behind", ""]); | |
| 48 | expect(rows[1]!.name).toContain("pull #9"); | |
| 49 | expect(followingRows(at("diverged", 2, 1, false), undefined)).toHaveLength(1); | |
| 50 | }); | |
| 51 | ||
| 52 | ||
| 53 | test("following nothing shows nothing", () => { | |
| 54 | expect(followingRows(undefined, undefined)).toEqual([]); | |
| 55 | }); | |
| 56 | }); | |
| 57 | ||
| 58 | describe("the terminal's forks screen names what the repository follows", () => { | |
| 59 | let dataDir: string; | |
| 60 | let app: App; | |
| 61 | let upstream: Repo; | |
| 62 | const users: Record<string, User> = {}; | |
| 63 | ||
| 64 | const screenOf = async (who: string, owner: string, keys = "", name = "app") => { | |
| 65 | const terminal = headlessTerminal(120, 30); | |
| 66 | const { app: tui, session } = createForgeApp( | |
| 67 | app, | |
| 68 | { sshUsername: who, remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user: users[who]! }, | |
| 69 | { terminal, exitHooks: false }, | |
| 70 | ); | |
| 71 | tui.start(); | |
| 72 | tui.ctx.push(forksScreen(session, tui.ctx, getRepo(app.db, owner, name)!)); | |
| 73 | await settleApp(tui); | |
| 74 | if (keys !== "") { | |
| 75 | terminal.feed(keys); | |
| 76 | await settleApp(tui); | |
| 77 | } | |
| 78 | const text = tui.screen.toText(); | |
| 79 | tui.stop?.(); | |
| 80 | return text; | |
| 81 | }; | |
| 82 | ||
| 83 | beforeAll(async () => { | |
| 84 | dataDir = await mkdtemp(join(tmpdir(), "forge-tui-following-")); | |
| 85 | app = createApp({ dataDir, port: 0 }); | |
| 86 | for (const name of ["alice", "bob", "carol", "dan"]) { | |
| 87 | users[name] = must( | |
| 88 | await createUser(app.db, { | |
| 89 | origin: "seed", | |
| 90 | trusted: true, | |
| 91 | username: name, | |
| 92 | email: `${name}@f.dev`, | |
| 93 | password: "a-good-passphrase", | |
| 94 | adminIfFirst: name === "alice", | |
| 95 | }), | |
| 96 | ); | |
| 97 | promote(app.db, users[name]!); | |
| 98 | } | |
| 99 | const commit = async (who: string, repo: Repo) => | |
| 100 | must( | |
| 101 | await commitToBranch(app, users[who]!, repo, { | |
| 102 | message: `${who} ${repo.name}`, | |
| 103 | files: [{ path: `${who}.txt`, content: "x\n" }], | |
| 104 | }), | |
| 105 | ); | |
| 106 | upstream = must(await createRepo(app, users["alice"]!, { name: "app", visibility: "public" })); | |
| 107 | await commit("alice", upstream); | |
| 108 | const bob = must(await forkRepo(app, users["bob"]!, upstream)); | |
| 109 | const carol = must(await forkRepo(app, users["carol"]!, upstream)); | |
| 110 | const dan = must(await forkRepo(app, users["dan"]!, upstream)); | |
| 111 | await commit("bob", bob); | |
| 112 | // Dan and alice both write shared.txt: git cannot merge them. | |
| 113 | must( | |
| 114 | await commitToBranch(app, users["dan"]!, dan, { | |
| 115 | message: "dan shared", | |
| 116 | files: [{ path: "shared.txt", content: "dan\n" }], | |
| 117 | }), | |
| 118 | ); | |
| 119 | must( | |
| 120 | await commitToBranch(app, users["alice"]!, upstream, { | |
| 121 | message: "alice again", | |
| 122 | files: [ | |
| 123 | { path: "again.txt", content: "y\n" }, | |
| 124 | { path: "shared.txt", content: "alice\n" }, | |
| 125 | ], | |
| 126 | }), | |
| 127 | ); | |
| 128 | // A converge issue on both diverged forks: only the conflicting one is the crew's. | |
| 129 | for (const [fork, who] of [[dan, "dan"], [bob, "bob"]] as const) | |
| 130 | must(createIssue(app.db, { repoId: fork.id, authorId: users[who]!.id, title: convergeTitle(upstream) })); | |
| 131 | // A parent the fork's owner can no longer read. | |
| 132 | const hidden = must(await createRepo(app, users["alice"]!, { name: "hidden", visibility: "public" })); | |
| 133 | await commit("alice", hidden); | |
| 134 | must(await forkRepo(app, users["bob"]!, hidden)); | |
| 135 | app.db.query("UPDATE repo SET visibility = 'private' WHERE id = ?1").run(hidden.id); | |
| 136 | // Carol has nothing of her own, so her fork follows by itself. | |
| 137 | let synced = false; | |
| 138 | for (let i = 0; i < 100 && !synced; i++) { | |
| 139 | synced = (await forkRelation(app, carol, upstream))?.state === "synced"; | |
| 140 | if (!synced) await Bun.sleep(50); | |
| 141 | } | |
| 142 | if (!synced) throw new Error("carol's fork never caught up with its parent"); | |
| 143 | }, 60_000); | |
| 144 | ||
| 145 | afterAll(async () => { | |
| 146 | app.close(); | |
| 147 | await rm(dataDir, { recursive: true, force: true }); | |
| 148 | }); | |
| 149 | ||
| 150 | test("a fork behind its parent says so", async () => { | |
| 151 | const text = await screenOf("bob", "bob"); | |
| 152 | expect(text).toContain("follows alice/app"); | |
| 153 | expect(text).toContain("1 behind"); | |
| 154 | expect(text).toContain("1 ahead"); | |
| 155 | }); | |
| 156 | ||
| 157 | test("a diverged fork that merges cleanly has no crew line, even with a converge issue open", async () => { | |
| 158 | const text = await screenOf("bob", "bob"); | |
| 159 | expect(text).toContain("merges cleanly"); | |
| 160 | expect(text).not.toContain("converging"); | |
| 161 | }); | |
| 162 | ||
| 163 | test("a conflicting fork says what the crew is doing about it", async () => { | |
| 164 | const text = await screenOf("dan", "dan"); | |
| 165 | expect(text).toContain("follows alice/app"); | |
| 166 | expect(text).toContain("diverged · 1 ahead · 1 behind"); | |
| 167 | expect(text).not.toContain("merges cleanly"); | |
| 168 | expect(text).toMatch(/The crew is already converging on #\d+\./); | |
| 169 | }); | |
| 170 | ||
| 171 | test("a fork in step says it is in step", async () => { | |
| 172 | const text = await screenOf("carol", "carol"); | |
| 173 | expect(text).toContain("follows alice/app"); | |
| 174 | expect(text).toContain("in step"); | |
| 175 | }); | |
| 176 | ||
| 177 | test("a repository that follows nothing shows no such line", async () => { | |
| 178 | const text = await screenOf("alice", "alice"); | |
| 179 | expect(text).not.toContain("follows "); | |
| 180 | expect(text).toContain("bob/app"); | |
| 181 | }); | |
| 182 | ||
| 183 | test("enter on the line opens the original", async () => { | |
| 184 | const text = await screenOf("carol", "carol", "\r"); | |
| 185 | expect(text).toContain("alice/app"); | |
| 186 | expect(text).not.toContain("follows alice/app"); | |
| 187 | }); | |
| 188 | ||
| 189 | test("a parent the viewer cannot read is never named, and enter opens nothing", async () => { | |
| 190 | const before = await screenOf("bob", "bob", "", "hidden"); | |
| 191 | expect(before).toContain("bob/hidden"); | |
| 192 | expect(before).not.toContain("alice/hidden"); | |
| 193 | expect(before).not.toContain("follows "); | |
| 194 | const after = await screenOf("bob", "bob", "\r", "hidden"); | |
| 195 | expect(after).toContain("bob/hidden · 0 forks"); | |
| 196 | expect(after).toContain("No forks yet"); | |
| 197 | expect(after).not.toContain("alice/hidden"); | |
| 198 | }); | |
| 199 | }); |
new above
offline
This gate (diverged and not clean) has no test through the screen. Remove it and every test still passes. Add a conflicting-fork screen test.