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

small: The fork tree names how each fork follows its upstream in one word #11440

Merged opened this

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

Merged

scout/forge:scout/11421-small-the-fork-tree-names-how-each-fork → main 6 commits fork 2 threads · 2 unresolved

  • Merged. scout/11421-small-the-fork-tree-names-how-each-fork was merged into main as 9c0ccd2.

Review

Sign in to review this change.
11 files changed +84 −19
docs/features/fork-tree.md +3 −2
@@ -8,7 +8,8 @@ No git is read.
88 ## Terminal
99
1010 Open the palette and choose **Fork tree of a repository**, then type `owner/name`. The repository you asked
11 about is marked `*`, each generation is indented, and each fork says whether it `take-updates` or only `offers`.
11 about is marked `*`, each generation is indented, and each fork says in one word how it follows its parent: `follows`, `proposes` or `severed` (`FOLLOW_WORD` in
12 `src/actions/follow_words.ts`, the same word the fork's family tree on the web says).
1213 A repository you may not read answers "not found", as everywhere else.
1314
1415 ## MCP
@@ -17,7 +18,7 @@ A repository you may not read answers "not found", as everywhere else.
1718 { "name": "fork_tree", "arguments": { "repo": "alice/app", "depth": 4, "limit": 200 } }
1819 ```
1920
20 Returns `{ root, nodes: [{ repo, parent, depth, follow }], truncated }`. `depth` is 1-8 (default 4) generations
21 Returns `{ root, nodes: [{ repo, parent, depth, follow, word }], truncated }`; `word` is the one word above (empty for the root). `depth` is 1-8 (default 4) generations
2122 below the root; `limit` is 1-200 nodes (default 200). `truncated` is true when more exists than is shown.
2223
2324 - The repository asked about is always in `nodes`, with its ancestors, even when it lies deeper than `depth` or
src/actions/follow_words.ts +7
@@ -101,3 +101,10 @@ export function followStandingLine(
101101 const line = `${STANDING_LEADS[mode]} ${upstream ?? "its upstream"}`;
102102 return tookAt === null ? line : `${line} · last took ${agoWords(tookAt, now)}`;
103103 }
104
105 /** How a fork follows its upstream in one word, for a tree node; a severed fork says so rather than saying nothing. */

This is the one source for the word, but only the web page reads it. TUI, MCP fork_tree and studio card must use it too, or they drift.

106 export const FOLLOW_WORD: Readonly<Record<ForkFollowMode, string>> = {
107 "take-updates": "follows",
108 "tell-me": "proposes",
109 "dont-follow": "severed",
110 };
src/actions/fork_tree.ts +14 −7
@@ -13,7 +13,8 @@
1313 */
1414
1515 import type { Database } from "bun:sqlite";
16 import { forkFollowModes } from "../core/fork_follow.ts";
16 import { FOLLOW_WORD } from "./follow_words.ts";
17 import { FORK_DEFAULT_MODE, forkFollowModes } from "../core/fork_follow.ts";
1718 import { canRead, forkParentFor, visibleForksByParent, type Repo } from "../core/repos.ts";
1819 import type { User } from "../core/users.ts";
1920
@@ -36,6 +37,8 @@ export interface TreeNode {
3637 /** Generations below the root; the root is 0. */
3738 readonly depth: number;
3839 readonly follow: TreeFollow;
40 /** The same fact in the one word every door says (`FOLLOW_WORD`); empty for the root. */
41 readonly word: string;
3942 }
4043
4144 export interface ForkTree {
@@ -100,12 +103,16 @@ export function forkTree(
100103 level = next;
101104 }
102105 const modes = forkFollowModes(db, rows.slice(1).map((r) => r.repo.id));
103 const nodes: TreeNode[] = rows.map((r) => ({
104 repo: nameOf(r.repo),
105 parent: r.parent === null ? null : nameOf(r.parent),
106 depth: r.depth,
107 follow: r.parent === null ? "autonomous" : treeFollow(modes.get(r.repo.id)),
108 }));
106 const nodes: TreeNode[] = rows.map((r) => {
107 const mode = r.parent === null ? undefined : (modes.get(r.repo.id) ?? FORK_DEFAULT_MODE);
108 return {
109 repo: nameOf(r.repo),
110 parent: r.parent === null ? null : nameOf(r.parent),
111 depth: r.depth,
112 follow: r.parent === null ? "autonomous" : treeFollow(modes.get(r.repo.id)),
113 word: mode === undefined ? "" : FOLLOW_WORD[mode],
114 };
115 });
109116 return { root: nameOf(root), nodes, truncated };
110117 }
111118
src/core/following.ts +15 −1
@@ -43,7 +43,7 @@ import { readTips } from "../git/tips.ts";
4343 import { repoDir } from "../git/repos.ts";
4444 import { combine } from "../git/combined.ts";
4545 import { fromKeptFork } from "./landing_kind.ts";
46 import { forkFollowMode } from "./fork_follow.ts";
46 import { forkFollowMode, forkFollowModes, FORK_DEFAULT_MODE, type ForkFollowMode } from "./fork_follow.ts";
4747 import { createLog } from "../kernel/log.ts";
4848 import { err, ok, type Result } from "../kernel/result.ts";
4949
@@ -573,6 +573,8 @@ export interface LineageNode {
573573 readonly repo: Repo;
574574 /** Against this node's parent; absent on the root, and past the budget. */
575575 readonly relation: ForkRelation | undefined;
576 /** How it follows its parent, as stored (a fork that never chose has the default); absent on the root. */
577 readonly mode?: ForkFollowMode;
576578 readonly children: readonly LineageNode[];
577579 }
578580
@@ -621,6 +623,7 @@ export async function lineageOf(
621623 interface Draft {
622624 repo: Repo;
623625 relation: ForkRelation | undefined;
626 mode?: ForkFollowMode;
624627 children: Draft[];
625628 }
626629 const root: Draft = { repo: top, relation: undefined, children: [] };
@@ -660,5 +663,16 @@ export async function lineageOf(
660663 // the bound is not hinted at, any more than one inside it is shown.
661664 if (level.some((n) => forksOf(n.repo.id).some(mayRead)))
662665 truncated = true;
666 // The follow modes of every fork in the tree, in one query.
667 const drafts: Draft[] = [];
668 const walk = (d: Draft): void => {
669 for (const c of d.children) {
670 drafts.push(c);
671 walk(c);
672 }
673 };
674 walk(root);
675 const modes = forkFollowModes(forge.db, drafts.map((d) => d.repo.id));
676 for (const d of drafts) d.mode = modes.get(d.repo.id) ?? FORK_DEFAULT_MODE;
663677 return { root, size, truncated };
664678 }
src/tui/screens/fork_tree.ts +1 −1
@@ -20,7 +20,7 @@ import { Scroll } from "./common.ts";
2020 export function treeLines(tree: ForkTree | undefined, asked: string, width = 80, viewer?: string): string[] {
2121 if (tree === undefined) return ["Nothing to show."];
2222 const lines = tree.nodes.map((n) => {
23 const follow = n.follow === "autonomous" ? "" : ` ${n.follow}`;
23 const follow = n.word === "" ? "" : ` ${n.word}`;
2424 const you = n.repo !== asked && viewer !== undefined && n.repo.startsWith(`${viewer}/`) ? " (you)" : "";
2525 return truncate(`${" ".repeat(n.depth)}${n.repo === asked ? "* " : ""}${n.repo}${you}${follow}`, width);
2626 });
src/web/pages/following.ts +2 −1
@@ -41,7 +41,7 @@ import { FOLLOW_SWITCH_LABELS, followModeWords } from "../../actions/follow_word
4141 import type { ForkFollowMode } from "../../core/fork_follow.ts";
4242 import { loadRepo, notFoundPage, repoBase, signInFirst } from "../chrome.ts";
4343 import { convergeWords, followModeOf, takeOrTell, takeWords } from "../../actions/follow.ts";
44 import { FOLLOWS_NOTHING, proposeRefusalWords } from "../../actions/follow_words.ts";
44 import { FOLLOW_WORD, FOLLOWS_NOTHING, proposeRefusalWords } from "../../actions/follow_words.ts";
4545 import { openProposal, proposeUpstream } from "../../actions/upstream.ts";
4646 import { proposesViaFork } from "../../origins/fork-route.ts";
4747 import { field, readForm } from "../form.ts";
@@ -64,6 +64,7 @@ export function lineageCard(lineage: Lineage, hereId: string, viewerId?: string)
6464 here ? badge("you are here", { variant: "outline" }) : null,
outdated

Principle 1/8 and both doors: the word shows on the page only. Give the terminal fork tree the same word from FOLLOW_WORD. Also check the word is plain and consistent next to the relation badge; two outline badges per row is close to noise, consider the word as muted text in the row instead.

6565 // The viewer's own forks say "you" in words. aria-current stays on the one row being looked at.
6666 !here && viewerId !== undefined && n.repo.ownerId === viewerId ? badge("you", { variant: "outline" }) : null,
67 n.mode === undefined ? null : html`<span class="muted small">${FOLLOW_WORD[n.mode]}</span>`,
6768 n.relation === undefined ? null : relationBadge(n.relation),
6869 ].filter((t): t is Html => t !== null);
6970 return row({
src/web/studio/cards.ts +1 −1
@@ -236,7 +236,7 @@ export function cardFor(
236236 rows.push({
237237 label: repo,
238238 ...(depth > 0 ? { depth } : {}),
239 value: FOLLOW_WORDS[text(n["follow"])] ?? "off",
239 value: text(n["word"]) || (FOLLOW_WORDS[text(n["follow"])] ?? "off"),
240240 ...(web(origin) && slugOf(repo) ? { link: `${origin}/${repo}` } : {}),
241241 });
242242 }
test/fork-tree-reader.test.ts +7 −4
@@ -63,10 +63,10 @@ describe("forkTree", () => {
6363 expect(t.root).toBe("alice/app");
6464 expect(t.truncated).toBe(false);
6565 const by = Object.fromEntries(t.nodes.map((n) => [n.repo, n]));
66 expect(by["alice/app"]).toEqual({ repo: "alice/app", parent: null, depth: 0, follow: "autonomous" });
67 expect(by["bob/app"]).toEqual({ repo: "bob/app", parent: "alice/app", depth: 1, follow: "offers" });
66 expect(by["alice/app"]).toEqual({ repo: "alice/app", parent: null, depth: 0, follow: "autonomous", word: "" });
67 expect(by["bob/app"]).toEqual({ repo: "bob/app", parent: "alice/app", depth: 1, follow: "offers", word: "proposes" });
6868 expect(by["carol/app"]!.parent).toBe("alice/app");
69 expect(by["dan/app"]).toEqual({ repo: "dan/app", parent: "bob/app", depth: 2, follow: "take-updates" });
69 expect(by["dan/app"]).toEqual({ repo: "dan/app", parent: "bob/app", depth: 2, follow: "take-updates", word: "follows" });
7070 });
7171
7272 test("a stranger and the owner of a private fork get different lists, and the stranger's has no trace", () => {
@@ -146,7 +146,10 @@ describe("the terminal screen", () => {
146146 const { treeLines } = await import("../src/tui/screens/fork_tree.ts");
147147 const lines = treeLines(forkTree(app.db, org, users["dan"]!), "bob/app");
148148 expect(lines).toContain("alice/app");
149 expect(lines).toContain(" * bob/app offers");
149 expect(lines).toContain(" * bob/app proposes");
150 const own = treeLines(forkTree(app.db, person, users["dan"]!), "dan/app");
151 expect(own).toContain(" * dan/app follows");
152 expect(lines.join("\n")).not.toContain("offers");
150153 expect(lines.join("\n")).not.toContain("secret");
151154 expect(treeLines(forkTree(app.db, hidden, users["dan"]!), "erin/secret")).toEqual(["Nothing to show."]);
152155 });
test/forks-lineage-timing.test.ts +32
@@ -96,3 +96,35 @@ test("a fork the viewer may not read is not in the lineage, nor hinted at", asyn
9696 walk(lineage.root);
9797 expect(names).not.toContain("leaf0");
9898 });
99
100 test("every fork in the tree carries how it follows, severed included, with no query per node (#11421)", async () => {
101 const { setForkFollowMode } = await import("../src/core/fork_follow.ts");
102 const { lineageCard } = await import("../src/web/pages/following.ts");
103 const { FOLLOW_WORD } = await import("../src/actions/follow_words.ts");
104 const leaf0 = getRepo(app.db, "u0", "leaf0")!;
105 const leaf1 = getRepo(app.db, "u1", "leaf1")!;
106 setForkFollowMode(app.db, leaf0.id, "dont-follow");
107 setForkFollowMode(app.db, leaf1.id, "take-updates");
108 const lineage = await lineageOf(app, chain[0]!, () => true);
109 const modes = new Map<string, string | undefined>();
110 const walk = (n: typeof lineage.root): void => {
111 modes.set(n.repo.name, n.mode);
112 n.children.forEach(walk);
113 };
114 walk(lineage.root);
115 expect(modes.get("r0")).toBeUndefined();
116 expect(modes.get("leaf0")).toBe("dont-follow");
117 expect(modes.get("leaf1")).toBe("take-updates");
118 expect(modes.get("leaf2")).toBe("tell-me");
119 expect(modes.get("r1")).toBe("tell-me");
120 const html = String(lineageCard(lineage, chain[0]!.id));
121 expect(html).toContain(">severed<");
122 expect(html).toContain(">follows<");
123 expect(html).toContain(">proposes<");
124 expect(FOLLOW_WORD["dont-follow"]).toBe("severed");
125 const big = await queriesDuring(() => lineageOf(app, chain[0]!, () => true));
126 const small = await queriesDuring(() => lineageOf(app, getRepo(app.db, "u0", "leaf0")!, () => true));
127 // The whole tree (32 forks) costs no more than a lone leaf's view of it: the modes are one query.
128 expect(big).toBeLessThanOrEqual(small);
129 expect(small).toBeLessThanOrEqual(4);
130 });
test/mcp-forks-card.test.ts +1 −1
@@ -73,7 +73,7 @@ describe("MCP fork_tree card", () => {
7373 expect(rows.map((r: any) => r.depth ?? 0)).toEqual([0, 1, 2]);
7474 expect(SHELL_JS).toContain("paddingInlineStart");
7575 expect(rows[0].value).toBe("follows on its own");
76 expect(rows[1].value).toBe("offers updates");
76 expect(rows[1].value).toBe("proposes");
7777 expect(rows[2].link).toMatch(/\/dan\/app$/);
7878 expect(json.nodes.map((n: any) => n.repo)).toContain("dan/app");
7979 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs