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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11421-small-the-fork-tree-names-how-each-fork → main 6 commits fork 2 threads · 2 unresolved
11 files
changed +84
−19
docs/features/fork-tree.md +3 −2
| @@ -8,7 +8,8 @@ No git is read. | ||
| 8 | 8 | ## Terminal |
| 9 | 9 | |
| 10 | 10 | 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). | |
| 12 | 13 | A repository you may not read answers "not found", as everywhere else. |
| 13 | 14 | |
| 14 | 15 | ## MCP |
| @@ -17,7 +18,7 @@ A repository you may not read answers "not found", as everywhere else. | ||
| 17 | 18 | { "name": "fork_tree", "arguments": { "repo": "alice/app", "depth": 4, "limit": 200 } } |
| 18 | 19 | |
| 19 | 20 | |
| 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 | |
| 21 | 22 | below the root; `limit` is 1-200 nodes (default 200). `truncated` is true when more exists than is shown. |
| 22 | 23 | |
| 23 | 24 | - 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( | ||
| 101 | 101 | const line = `${STANDING_LEADS[mode]} ${upstream ?? "its upstream"}`; |
| 102 | 102 | return tookAt === null ? line : `${line} · last took ${agoWords(tookAt, now)}`; |
| 103 | 103 | } |
| 104 | ||
| 105 | /** How a fork follows its upstream in one word, for a tree node; a severed fork says so rather than saying nothing. */ | |
| 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 @@ | ||
| 13 | 13 | */ |
| 14 | 14 | |
| 15 | 15 | 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"; | |
| 17 | 18 | import { canRead, forkParentFor, visibleForksByParent, type Repo } from "../core/repos.ts"; |
| 18 | 19 | import type { User } from "../core/users.ts"; |
| 19 | 20 | |
| @@ -36,6 +37,8 @@ export interface TreeNode { | ||
| 36 | 37 | /** Generations below the root; the root is 0. */ |
| 37 | 38 | readonly depth: number; |
| 38 | 39 | readonly follow: TreeFollow; |
| 40 | /** The same fact in the one word every door says (`FOLLOW_WORD`); empty for the root. */ | |
| 41 | readonly word: string; | |
| 39 | 42 | } |
| 40 | 43 | |
| 41 | 44 | export interface ForkTree { |
| @@ -100,12 +103,16 @@ export function forkTree( | ||
| 100 | 103 | level = next; |
| 101 | 104 | } |
| 102 | 105 | 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 | }); | |
| 109 | 116 | return { root: nameOf(root), nodes, truncated }; |
| 110 | 117 | } |
| 111 | 118 |
src/core/following.ts +15 −1
| @@ -43,7 +43,7 @@ import { readTips } from "../git/tips.ts"; | ||
| 43 | 43 | import { repoDir } from "../git/repos.ts"; |
| 44 | 44 | import { combine } from "../git/combined.ts"; |
| 45 | 45 | 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"; | |
| 47 | 47 | import { createLog } from "../kernel/log.ts"; |
| 48 | 48 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 49 | 49 | |
| @@ -573,6 +573,8 @@ export interface LineageNode { | ||
| 573 | 573 | readonly repo: Repo; |
| 574 | 574 | /** Against this node's parent; absent on the root, and past the budget. */ |
| 575 | 575 | 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; | |
| 576 | 578 | readonly children: readonly LineageNode[]; |
| 577 | 579 | } |
| 578 | 580 | |
| @@ -621,6 +623,7 @@ export async function lineageOf( | ||
| 621 | 623 | interface Draft { |
| 622 | 624 | repo: Repo; |
| 623 | 625 | relation: ForkRelation | undefined; |
| 626 | mode?: ForkFollowMode; | |
| 624 | 627 | children: Draft[]; |
| 625 | 628 | } |
| 626 | 629 | const root: Draft = { repo: top, relation: undefined, children: [] }; |
| @@ -660,5 +663,16 @@ export async function lineageOf( | ||
| 660 | 663 | // the bound is not hinted at, any more than one inside it is shown. |
| 661 | 664 | if (level.some((n) => forksOf(n.repo.id).some(mayRead))) |
| 662 | 665 | 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; | |
| 663 | 677 | return { root, size, truncated }; |
| 664 | 678 | } |
src/tui/screens/fork_tree.ts +1 −1
| @@ -20,7 +20,7 @@ import { Scroll } from "./common.ts"; | ||
| 20 | 20 | export function treeLines(tree: ForkTree | undefined, asked: string, width = 80, viewer?: string): string[] { |
| 21 | 21 | if (tree === undefined) return ["Nothing to show."]; |
| 22 | 22 | const lines = tree.nodes.map((n) => { |
| 23 | const follow = n.follow === "autonomous" ? "" : ` ${n.follow}`; | |
| 23 | const follow = n.word === "" ? "" : ` ${n.word}`; | |
| 24 | 24 | const you = n.repo !== asked && viewer !== undefined && n.repo.startsWith(`${viewer}/`) ? " (you)" : ""; |
| 25 | 25 | return truncate(`${" ".repeat(n.depth)}${n.repo === asked ? "* " : ""}${n.repo}${you}${follow}`, width); |
| 26 | 26 | }); |
src/web/pages/following.ts +2 −1
| @@ -41,7 +41,7 @@ import { FOLLOW_SWITCH_LABELS, followModeWords } from "../../actions/follow_word | ||
| 41 | 41 | import type { ForkFollowMode } from "../../core/fork_follow.ts"; |
| 42 | 42 | import { loadRepo, notFoundPage, repoBase, signInFirst } from "../chrome.ts"; |
| 43 | 43 | 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"; | |
| 45 | 45 | import { openProposal, proposeUpstream } from "../../actions/upstream.ts"; |
| 46 | 46 | import { proposesViaFork } from "../../origins/fork-route.ts"; |
| 47 | 47 | import { field, readForm } from "../form.ts"; |
| @@ -64,6 +64,7 @@ export function lineageCard(lineage: Lineage, hereId: string, viewerId?: string) | ||
| 64 | 64 | 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. |
||
| 65 | 65 | // The viewer's own forks say "you" in words. aria-current stays on the one row being looked at. |
| 66 | 66 | !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>`, | |
| 67 | 68 | n.relation === undefined ? null : relationBadge(n.relation), |
| 68 | 69 | ].filter((t): t is Html => t !== null); |
| 69 | 70 | return row({ |
src/web/studio/cards.ts +1 −1
| @@ -236,7 +236,7 @@ export function cardFor( | ||
| 236 | 236 | rows.push({ |
| 237 | 237 | label: repo, |
| 238 | 238 | ...(depth > 0 ? { depth } : {}), |
| 239 | value: FOLLOW_WORDS[text(n["follow"])] ?? "off", | |
| 239 | value: text(n["word"]) || (FOLLOW_WORDS[text(n["follow"])] ?? "off"), | |
| 240 | 240 | ...(web(origin) && slugOf(repo) ? { link: `${origin}/${repo}` } : {}), |
| 241 | 241 | }); |
| 242 | 242 | } |
test/fork-tree-links.test.ts +1 −1
| @@ -92,7 +92,7 @@ test("every readable node links, the viewer's own says you, a private fork is ab | ||
| 92 | 92 | |
| 93 | 93 | test("the terminal tree marks the viewer's own forks, not the asked one, as the page does", async () => { |
| 94 | 94 | const { treeLines } = await import("../src/tui/screens/fork_tree.ts"); |
| 95 | const node = (repo: string, parent: string | null, depth: number) => ({ repo, parent, depth, follow: "autonomous" as const }); | |
| 95 | const node = (repo: string, parent: string | null, depth: number) => ({ repo, parent, depth, follow: "autonomous" as const, word: "" }); | |
| 96 | 96 | const tree = { |
| 97 | 97 | root: "ann/yard", |
| 98 | 98 | truncated: false, |
test/fork-tree-reader.test.ts +7 −4
| @@ -63,10 +63,10 @@ describe("forkTree", () => { | ||
| 63 | 63 | expect(t.root).toBe("alice/app"); |
| 64 | 64 | expect(t.truncated).toBe(false); |
| 65 | 65 | 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" }); | |
| 68 | 68 | 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" }); | |
| 70 | 70 | }); |
| 71 | 71 | |
| 72 | 72 | 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", () => { | ||
| 146 | 146 | const { treeLines } = await import("../src/tui/screens/fork_tree.ts"); |
| 147 | 147 | const lines = treeLines(forkTree(app.db, org, users["dan"]!), "bob/app"); |
| 148 | 148 | 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"); | |
| 150 | 153 | expect(lines.join("\n")).not.toContain("secret"); |
| 151 | 154 | expect(treeLines(forkTree(app.db, hidden, users["dan"]!), "erin/secret")).toEqual(["Nothing to show."]); |
| 152 | 155 | }); |
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 | ||
| 96 | 96 | walk(lineage.root); |
| 97 | 97 | expect(names).not.toContain("leaf0"); |
| 98 | 98 | }); |
| 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", () => { | ||
| 73 | 73 | expect(rows.map((r: any) => r.depth ?? 0)).toEqual([0, 1, 2]); |
| 74 | 74 | expect(SHELL_JS).toContain("paddingInlineStart"); |
| 75 | 75 | expect(rows[0].value).toBe("follows on its own"); |
| 76 | expect(rows[1].value).toBe("offers updates"); | |
| 76 | expect(rows[1].value).toBe("proposes"); | |
| 77 | 77 | expect(rows[2].link).toMatch(/\/dan\/app$/); |
| 78 | 78 | expect(json.nodes.map((n: any) => n.repo)).toContain("dan/app"); |
| 79 | 79 | }); |
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.