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

Merged opened this

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

Merged

mason/forge:mason/7302-the-terminal-s-forks-screen-says-where-t → main 2 commits fork 2 threads · 2 unresolved

  • Merged. mason/7302-the-terminal-s-forks-screen-says-where-t was merged into main as 6109bb1.

Review

Sign in to review this change.
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
209209 forks below`, linking on the page to that fork's forks page. A private fork is
210210 never named or counted to someone who cannot read it.
211211
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
212221 Forks with nothing ahead (or whose standing git cannot read) follow under
213222 `N forks with nothing new`. At most 60 forks are ranked per view; past that
214223 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 @@
22 * A repository's forks, ranked: the same order and words as the forks page
33 * (`actions/forks.ts` decides both), so the best of the tree rises here too.
44 * 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`).
67 */
78
89 import {
@@ -10,8 +11,12 @@ import {
1011 nothingNewHeading,
1112 rankedForksOf,
1213 MAX_RANKED_FORKS,
14 relationOf,
1315 type ForkRanking,
16 type ForkRelation,
1417 } from "../../actions/forks.ts";
18 import { convergeWords } from "../../actions/follow.ts";
19 import { convergeState, type Converge } from "../../crew/converge.ts";
1520 import { canRead, forkTree, listForks, type ForkNode, type Repo } from "../../core/repos.ts";
1621 import type { Ctx, Screen } from "../app/app.ts";
1722 import { binding } from "../app/keys.ts";
@@ -50,18 +55,55 @@ export function forkRows(
5055 ];
5156 }
5257
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
5388 export function forksScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
5489 let ranked: ForkRanking | undefined;
5590 let tree: ForkNode[] = [];
91 let following: ReturnType<typeof followingRows> = [];
5692 const state = new ListState();
57 const rows = () => (ranked === undefined ? [] : forkRows(ranked, tree));
93 const rows = () => [...following, ...(ranked === undefined ? [] : forkRows(ranked, tree))];
5894 return {
5995 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")],
6197 async load() {
outdated

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.

6298 const forks = listForks(f.db, repo.id).filter((r) => canRead(f.db, r, f.user));
6399 ranked = await rankedForksOf(f.app, repo, forks);
64100 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);
65107 ctx.invalidate();
66108 },
67109 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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs