Public: anyone can read this; the crew can change it.
A fork tree reader returns an app's ancestors, siblings and descendants as plain data, filtered by what the caller may read #10587
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/10508-a-fork-tree-reader-returns-an-app-s-ance → main 5 commits fork 2 threads · 2 unresolved
11 files
changed +410
−3
docs/features/fork-tree.md added +31
| @@ -0,0 +1,31 @@ | ||
| 1 | # The fork tree | |
| 2 | ||
| 3 | Every repository sits in a tree of forks. The fork tree reader (`forkTree` in `src/actions/fork_tree.ts`) returns | |
| 4 | the part of it you may read: the highest ancestor you can see, then every fork below it, so siblings and cousins | |
| 5 | come with it. A fork you cannot read is left out before anything is counted, and everything under it goes too. | |
| 6 | No git is read. | |
| 7 | ||
| 8 | ## Terminal | |
| 9 | ||
| 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`. | |
| 12 | A repository you may not read answers "not found", as everywhere else. | |
| 13 | ||
| 14 | ## MCP | |
| 15 | ||
| 16 | json | |
| 17 | { "name": "fork_tree", "arguments": { "repo": "alice/app", "depth": 4, "limit": 200 } } | |
| 18 | ||
| 19 | ||
| 20 | Returns `{ root, nodes: [{ repo, parent, depth, follow }], truncated }`. `depth` is 1-8 (default 4) generations | |
| 21 | below the root; `limit` is 1-200 nodes (default 200). `truncated` is true when more exists than is shown. | |
| 22 | ||
| 23 | - The repository asked about is always in `nodes`, with its ancestors, even when it lies deeper than `depth` or | |
| 24 | `limit` would reach. | |
| 25 | - The root's `follow` is `autonomous`; any other fork is `take-updates` or `offers`. | |
| 26 | - A repo you cannot read gives `{ root: null, nodes: [], truncated: false }`. | |
| 27 | ||
| 28 | ## Cost | |
| 29 | ||
| 30 | One query per generation plus one for the follow modes, not one per fork; the test counts statements | |
| 31 | (`test/fork-tree-reader.test.ts`), not a clock. |
src/actions/fork_tree.ts added +107
| @@ -0,0 +1,107 @@ | ||
| 1 | /** | |
| 2 | * A fork tree as plain data: an app's ancestors up to the root, then every | |
| 3 | * descendant of that root breadth-first, so siblings and cousins come free. | |
| 4 | * | |
| 5 | * Only what the caller may read is here. A fork they cannot read is left out | |
| 6 | * before anything is counted, and everything beneath it goes with it | |
| 7 | * (`visibleForks`, `forkParentFor`). An ancestor they cannot read ends the | |
| 8 | * climb: the root is the highest one they may see. No git is read. | |
| 9 | * | |
| 10 | * The repo asked about is always in `nodes`, however far below the root it sits | |
| 11 | * (its ancestors come with it, past `depth` and `limit` if need be). The cost | |
| 12 | * is one query per generation plus one for the follow modes, not per node. | |
| 13 | */ | |
| 14 | ||
| 15 | import type { Database } from "bun:sqlite"; | |
| 16 | import { forkFollowModes } from "../core/fork_follow.ts"; | |
| 17 | import { canRead, forkParentFor, visibleForksByParent, type Repo } from "../core/repos.ts"; | |
| 18 | import type { User } from "../core/users.ts"; | |
| 19 | ||
| 20 | /** Generations below the root shown when the caller does not say. */ | |
| 21 | export const DEFAULT_DEPTH = 4; | |
| 22 | /** The most generations below the root any call may ask for. */ | |
| 23 | export const MAX_DEPTH = 8; | |
| 24 | /** The most nodes any call returns; past it `truncated` is true. A page of a tree, not a crawl. */ | |
| 25 | export const MAX_NODES = 200; | |
| 26 | ||
| 27 | /** How a node follows its parent: `offers` is the stored `tell-me`; the root of what is visible follows nothing. */ | |
| 28 | export type TreeFollow = "take-updates" | "offers" | "autonomous"; | |
| 29 | ||
| 30 | export interface TreeNode { | |
| 31 | readonly repo: string; | |
| 32 | readonly parent: string | null; | |
| 33 | /** Generations below the root; the root is 0. */ | |
| 34 | readonly depth: number; | |
| 35 | readonly follow: TreeFollow; | |
| 36 | } | |
| 37 | ||
| 38 | export interface ForkTree { | |
| 39 | readonly root: string; | |
| 40 | readonly nodes: TreeNode[]; | |
| 41 | readonly truncated: boolean; | |
| 42 | } | |
| 43 | ||
| 44 | const nameOf = (r: Repo): string => `${r.ownerName}/${r.name}`; | |
| 45 | ||
| 46 | const clamp = (v: number | undefined, fallback: number, max: number): number => | |
| 47 | v === undefined || !Number.isFinite(v) ? fallback : Math.min(max, Math.max(1, Math.floor(v))); | |
| 48 | ||
| 49 | /** The tree around `repo`, or undefined when the caller may not read `repo` itself. */ | |
| 50 | export function forkTree( | |
| 51 | db: Database, | |
| 52 | repo: Repo, | |
| 53 | user: User | undefined, | |
| 54 | opts: { depth?: number; limit?: number } = {}, | |
| 55 | ): ForkTree | undefined { | |
| 56 | if (!canRead(db, repo, user)) return undefined; | |
| 57 | const depth = clamp(opts.depth, DEFAULT_DEPTH, MAX_DEPTH); | |
| 58 | const limit = clamp(opts.limit, MAX_NODES, MAX_NODES); | |
| 59 | ||
| 60 | // Climb to the highest readable ancestor; `keep` is the path from there to `repo`. | |
| 61 | let root = repo; | |
| 62 | const keep = new Set<string>([repo.id]); | |
| 63 | for (;;) { | |
| 64 | const up = forkParentFor(db, root, user); | |
| 65 | if (up === undefined || keep.has(up.id)) break; | |
| 66 | keep.add(up.id); | |
| 67 | root = up; | |
| 68 | } | |
| 69 | // Generations to show: as asked, but never short of the repo itself. | |
| 70 | const reach = Math.max(depth, keep.size - 1); | |
| 71 | ||
| 72 | const rows: { repo: Repo; parent: Repo | null; depth: number }[] = [{ repo: root, parent: null, depth: 0 }]; | |
| 73 | let truncated = false; | |
| 74 | const visited = new Set<string>([root.id]); | |
| 75 | let level: Repo[] = [root]; | |
| 76 | for (let d = 1; d <= reach + 1 && level.length > 0; d++) { | |
| 77 | const by = visibleForksByParent(db, level, user); | |
| 78 | // One generation past the last shown is read only to know more exists. | |
| 79 | if (d > reach) { | |
| 80 | truncated = truncated || [...by.values()].some((kids) => kids.some((k) => !visited.has(k.id))); | |
| 81 | break; | |
| 82 | } | |
| 83 | const next: Repo[] = []; | |
| 84 | for (const parent of level) { | |
| 85 | for (const child of by.get(parent.id) ?? []) { | |
| 86 | if (visited.has(child.id)) continue; | |
| 87 | // A node on the path to `repo` is never cut: it is the one asked about. | |
| 88 | if (rows.length >= limit && !keep.has(child.id)) { | |
| 89 | truncated = true; | |
| 90 | continue; | |
| 91 | } | |
| 92 | visited.add(child.id); | |
| 93 | rows.push({ repo: child, parent, depth: d }); | |
| 94 | next.push(child); | |
| 95 | } | |
| 96 | } | |
| 97 | level = next; | |
| 98 | } | |
| 99 | const modes = forkFollowModes(db, rows.slice(1).map((r) => r.repo.id)); | |
| 100 | const nodes: TreeNode[] = rows.map((r) => ({ | |
| 101 | repo: nameOf(r.repo), | |
| 102 | parent: r.parent === null ? null : nameOf(r.parent), | |
| 103 | depth: r.depth, | |
| 104 | follow: r.parent === null ? "autonomous" : modes.get(r.repo.id) === "take-updates" ? "take-updates" : "offers", | |
| 105 | })); | |
| 106 | return { root: nameOf(root), nodes, truncated }; | |
| 107 | } |
src/core/fork_follow.ts +11
| @@ -13,3 +13,14 @@ export function setForkFollowMode(db: Database, repoId: string, mode: FollowMode | ||
| 13 | 13 | "INSERT INTO fork_follow (repo_id, mode) VALUES (?1, ?2) ON CONFLICT(repo_id) DO UPDATE SET mode = excluded.mode", |
| 14 | 14 | ).run(repoId, mode); |
| 15 | 15 | } |
| 16 | ||
| 17 | /** The stored modes of these repos in one query; a repo with none stored is absent (it follows `DEFAULT_MODE`). */ | |
| 18 | export function forkFollowModes(db: Database, repoIds: readonly string[]): Map<string, FollowMode> { | |
| 19 | if (repoIds.length === 0) return new Map(); | |
| 20 | const rows = db | |
| 21 | .query<{ repo_id: string; mode: FollowMode }, string[]>( | |
| 22 | `SELECT repo_id, mode FROM fork_follow WHERE repo_id IN (${repoIds.map(() => "?").join(",")})`, | |
| 23 | ) | |
| 24 | .all(...repoIds); | |
| 25 | return new Map(rows.map((r) => [r.repo_id, r.mode])); | |
| 26 | } |
src/core/repos.ts +17 −3
| @@ -714,14 +714,28 @@ const forksOfMany = (db: Database, ids: readonly string[]): Repo[] => | ||
| 714 | 714 | .map(toRepo); |
| 715 | 715 | |
| 716 | 716 | /** The visible forks of these parents, grouped by parent id: one query. */ |
| 717 | function visibleForksByParent( | |
| 717 | export function visibleForksByParent( | |
| 718 | 718 | db: Database, |
| 719 | 719 | parents: readonly Repo[], |
| 720 | 720 | viewer: User | undefined, |
| 721 | 721 | ): Map<string, Repo[]> { |
| 722 | 722 | const byParent = new Map<string, Repo[]>(); |
| 723 | for (const fork of forksOfMany(db, parents.map((p) => p.id))) { | |
| 724 | if (fork.forkParentId === null || !canRead(db, fork, viewer)) continue; | |
| 723 | const forks = forksOfMany(db, parents.map((p) => p.id)); | |
| 724 | // One query says which owners stand; a public repo of one is readable by | |
| 725 | // everyone (the first two rules of `canRead`), so only the rest ask again. | |
| 726 | const standing = new Set( | |
| 727 | forks.length === 0 | |
| 728 | ? [] | |
| 729 | : db | |
| 730 | .query<{ id: string }, string[]>( | |
| 731 | `SELECT repo.id AS id FROM repo WHERE repo.id IN (${forks.map(() => "?").join(",")}) AND ${OWNER_STANDING}`, | |
| 732 | ) | |
| 733 | .all(...forks.map((f) => f.id)) | |
| 734 | .map((r) => r.id), | |
| 735 | ); | |
| 736 | for (const fork of forks) { | |
| 737 | const open = fork.visibility === "public" && standing.has(fork.id); | |
| 738 | if (fork.forkParentId === null || !(open || canRead(db, fork, viewer))) continue; | |
| 725 | 739 | const siblings = byParent.get(fork.forkParentId) ?? []; |
| 726 | 740 | siblings.push(fork); |
| 727 | 741 | byParent.set(fork.forkParentId, siblings); |
src/http/mcp/tools/fork_tree.ts added +28
| @@ -0,0 +1,28 @@ | ||
| 1 | import { forkTree } from "../../../actions/fork_tree.ts"; | |
Only an MCP door exists. Add a |
||
| 2 | import { good, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts"; | |
| 3 | ||
| 4 | /** The whole tree a repo sits in, as the caller may read it. */ | |
| 5 | export const tool: Tool = { | |
| 6 | name: "fork_tree", | |
| 7 | doc: "`{ repo, depth?, limit? }`: `{ root, nodes: [{ repo, parent, depth, follow }], truncated }`, the fork tree around a repo from its topmost readable ancestor down: ancestors, siblings and descendants the caller may read, nothing else; `follow` is `autonomous` (the root), `take-updates` or `offers`; `depth` 1-8 (default 4), `limit` 1-200 nodes (default 200); `truncated` is true when more exists than is shown", | |
| 8 | description: "The fork tree around a repo: ancestors, siblings and descendants you may read.", | |
| 9 | inputSchema: { | |
| 10 | type: "object", | |
| 11 | properties: { | |
| 12 | repo: REPO_ARG, | |
| 13 | depth: { type: "integer", minimum: 1, maximum: 8 }, | |
| 14 | limit: { type: "integer", minimum: 1, maximum: 200 }, | |
| 15 | }, | |
| 16 | required: ["repo"], | |
| 17 | }, | |
| 18 | writes: false, | |
| 19 | async run(ctx, user, args) { | |
| 20 | const repo = repoArg(ctx, user, args); | |
| 21 | if (isAnswer(repo)) return repo; | |
| 22 | const opts: { depth?: number; limit?: number } = {}; | |
| 23 | if (typeof args["depth"] === "number") opts.depth = args["depth"]; | |
| 24 | if (typeof args["limit"] === "number") opts.limit = args["limit"]; | |
| 25 | const tree = forkTree(ctx.app.db, repo, user, opts); | |
| 26 | return good(tree ?? { root: null, nodes: [], truncated: false }); | |
| 27 | }, | |
| 28 | }; |
src/http/mcp/tools/index.ts +1
| @@ -72,6 +72,7 @@ export { tool as forge_proposals } from "./forge_proposals.ts"; | ||
| 72 | 72 | export { tool as forge_view } from "./forge_view.ts"; |
| 73 | 73 | export { tool as forges } from "./forges.ts"; |
| 74 | 74 | export { tool as fork } from "./fork.ts"; |
| 75 | export { tool as fork_tree } from "./fork_tree.ts"; | |
| 75 | 76 | export { tool as forks } from "./forks.ts"; |
| 76 | 77 | export { tool as front_page } from "./front_page.ts"; |
| 77 | 78 | export { tool as health } from "./health.ts"; |
src/tui/forge.ts +7
| @@ -17,6 +17,7 @@ import { sourceFor } from "../view/source.ts"; | ||
| 17 | 17 | import { fleetCommand } from "./screens/fleet.ts"; |
| 18 | 18 | import { dataCommand } from "./screens/data.ts"; |
| 19 | 19 | import { packsCommand } from "./screens/packs.ts"; |
| 20 | import { forkTreeScreen } from "./screens/fork_tree.ts"; | |
| 20 | 21 | import { roadmapScreen } from "./screens/roadmap.ts"; |
| 21 | 22 | import { App, type Ctx, type Screen } from "./app/app.ts"; |
| 22 | 23 | import type { Command, PaletteItem, PaletteSearch } from "./app/palette.ts"; |
| @@ -128,6 +129,12 @@ const REPO_VERBS: readonly { | ||
| 128 | 129 | title: "Held by the Later gate", |
| 129 | 130 | open: (f, ctx, repo) => heldScreen(f, ctx, repo), |
| 130 | 131 | }, |
| 132 | { | |
| 133 | // The ancestors, siblings and descendants of a repository the person may read. | |
| 134 | id: "fork-tree", | |
| 135 | title: "Fork tree of a repository", | |
| 136 | open: (f, ctx, repo) => forkTreeScreen(f, ctx, repo), | |
| 137 | }, | |
| 131 | 138 | { |
| 132 | 139 | id: "roadmap", |
| 133 | 140 | title: "Roadmap of a repository", |
src/tui/screens/fork_tree.ts added +50
| @@ -0,0 +1,50 @@ | ||
| 1 | /** | |
| 2 | * The fork tree in the terminal: the same reader the MCP `fork_tree` tool | |
| 3 | * calls (`actions/fork_tree.ts`), drawn as indented lines. It gates exactly as | |
| 4 | * the tool does: a repo the session's person may not read has no tree. | |
| 5 | */ | |
| 6 | ||
| 7 | import { forkTree, type ForkTree } from "../../actions/fork_tree.ts"; | |
| 8 | import type { Repo } from "../../core/repos.ts"; | |
| 9 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 10 | import { binding } from "../app/keys.ts"; | |
| 11 | import type { Session } from "../session.ts"; | |
| 12 | import { style } from "../term/style.ts"; | |
| 13 | import { truncate } from "../term/width.ts"; | |
| 14 | import { Scroll } from "./common.ts"; | |
| 15 | ||
| 16 | /** The tree as text lines, `asked` marked with `*`; two spaces per generation. */ | |
| 17 | export function treeLines(tree: ForkTree | undefined, asked: string, width = 80): string[] { | |
| 18 | if (tree === undefined) return ["Nothing to show."]; | |
| 19 | const lines = tree.nodes.map((n) => { | |
| 20 | const follow = n.follow === "autonomous" ? "" : ` ${n.follow}`; | |
| 21 | return truncate(`${" ".repeat(n.depth)}${n.repo === asked ? "* " : ""}${n.repo}${follow}`, width); | |
| 22 | }); | |
| 23 | if (tree.truncated) lines.push("", "More forks exist than are shown."); | |
| 24 | return lines; | |
| 25 | } | |
| 26 | ||
| 27 | export function forkTreeScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | |
| 28 | const scroll = new Scroll(); | |
| 29 | let lines: string[] = []; | |
| 30 | return { | |
| 31 | title: `Fork tree · ${repo.ownerName}/${repo.name}`, | |
| 32 | keys: [binding(["j", "k"], "scroll"), binding(["esc"], "back")], | |
| 33 | render(area, s) { | |
| 34 | const c = ctx.theme.c; | |
| 35 | lines = treeLines(forkTree(f.db, repo, f.user), `${repo.ownerName}/${repo.name}`, area.w - 4); | |
| 36 | const from = scroll.window(area.h, lines.length); | |
| 37 | for (let i = 0; i < area.h; i++) { | |
| 38 | const line = lines[from + i]; | |
| 39 | if (line !== undefined) s.text(area.x + 1, area.y + i, line, style(c.fg, c.bg), area.x + area.w); | |
| 40 | } | |
| 41 | }, | |
| 42 | key(e) { | |
| 43 | if (e.key === "esc" || e.key === "q") { | |
| 44 | ctx.pop(); | |
| 45 | return true; | |
| 46 | } | |
| 47 | return scroll.key(e, lines.length); | |
| 48 | }, | |
| 49 | }; | |
| 50 | } |
test/fork-tree-reader.test.ts added +153
| @@ -0,0 +1,153 @@ | ||
| 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 { MAX_NODES, forkTree } from "../src/actions/fork_tree.ts"; | |
| 6 | import { createRepo, forkRepo } from "../src/actions/repos.ts"; | |
| 7 | import { createApp, type App } from "../src/app.ts"; | |
| 8 | import { setForkFollowMode } from "../src/core/fork_follow.ts"; | |
| 9 | import type { Repo } from "../src/core/repos.ts"; | |
| 10 | import { createUser, type User } from "../src/core/users.ts"; | |
| 11 | import { promote } from "./helpers/members.ts"; | |
| 12 | import { once } from "./helpers/statements.ts"; | |
| 13 | import { expectWork } from "./helpers/work.ts"; | |
| 14 | ||
| 15 | const must = <T>(r: { ok: true; value: T } | { ok: false; error: unknown }): T => { | |
| 16 | if (!r.ok) throw new Error(JSON.stringify(r.error)); | |
| 17 | return r.value; | |
| 18 | }; | |
| 19 | ||
| 20 | let dir: string; | |
| 21 | let app: App; | |
| 22 | const users: Record<string, User> = {}; | |
| 23 | let forge: Repo; | |
| 24 | let org: Repo; | |
| 25 | let person: Repo; | |
| 26 | let hidden: Repo; | |
| 27 | ||
| 28 | beforeAll(async () => { | |
| 29 | dir = await mkdtemp(join(tmpdir(), "forge-fork-tree-reader-")); | |
| 30 | app = createApp({ dataDir: dir, port: 0 }); | |
| 31 | for (const name of ["alice", "bob", "carol", "dan", "erin"]) { | |
| 32 | users[name] = must( | |
| 33 | await createUser(app.db, { | |
| 34 | origin: "seed", | |
| 35 | trusted: true, | |
| 36 | username: name, | |
| 37 | email: `${name}@f.dev`, | |
| 38 | password: "a-good-passphrase", | |
| 39 | adminIfFirst: name === "alice", | |
| 40 | }), | |
| 41 | ); | |
| 42 | promote(app.db, users[name]!); | |
| 43 | } | |
| 44 | forge = must(await createRepo(app, users["alice"]!, { name: "app", visibility: "public" })); | |
| 45 | org = must(await forkRepo(app, users["bob"]!, forge)); | |
| 46 | must(await forkRepo(app, users["carol"]!, forge)); | |
| 47 | person = must(await forkRepo(app, users["dan"]!, org)); | |
| 48 | hidden = must(await forkRepo(app, users["erin"]!, org, "secret", { visibility: "private" })); | |
| 49 | must(await forkRepo(app, users["erin"]!, hidden, "under", { visibility: "public" })); | |
| 50 | setForkFollowMode(app.db, person.id, "take-updates"); | |
| 51 | }, 60_000); | |
| 52 | ||
| 53 | afterAll(async () => { | |
| 54 | app.close(); | |
| 55 | await rm(dir, { recursive: true, force: true }); | |
| 56 | }); | |
| 57 | ||
| 58 | const names = (t: { nodes: { repo: string }[] }) => t.nodes.map((n) => n.repo); | |
| 59 | ||
| 60 | describe("forkTree", () => { | |
| 61 | test("read from the middle node: root, siblings, descendants, with parent, depth and follow", () => { | |
| 62 | const t = forkTree(app.db, org, users["alice"]!)!; | |
| 63 | expect(t.root).toBe("alice/app"); | |
| 64 | expect(t.truncated).toBe(false); | |
| 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" }); | |
| 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" }); | |
| 70 | }); | |
| 71 | ||
| 72 | test("a stranger and the owner of a private fork get different lists, and the stranger's has no trace", () => { | |
| 73 | const owner = names(forkTree(app.db, org, users["erin"]!)!); | |
| 74 | const stranger = forkTree(app.db, org, users["dan"]!)!; | |
| 75 | const anon = forkTree(app.db, org, undefined)!; | |
| 76 | expect(owner).toContain("erin/secret"); | |
| 77 | expect(owner).toContain("erin/under"); | |
| 78 | for (const t of [stranger, anon]) { | |
| 79 | const json = JSON.stringify(t); | |
| 80 | expect(json).not.toContain("secret"); | |
| 81 | expect(json).not.toContain("under"); | |
| 82 | expect(names(t)).not.toEqual(owner); | |
| 83 | } | |
| 84 | }); | |
| 85 | ||
| 86 | test("a repo the caller cannot read yields nothing", () => { | |
| 87 | expect(forkTree(app.db, hidden, users["dan"]!)).toBeUndefined(); | |
| 88 | }); | |
| 89 | ||
| 90 | test("depth limits generations and says it truncated", () => { | |
| 91 | const t = forkTree(app.db, forge, users["alice"]!, { depth: 1 })!; | |
| 92 | expect(t.nodes.every((n) => n.depth <= 1)).toBe(true); | |
| 93 | expect(t.truncated).toBe(true); | |
| 94 | expect(forkTree(app.db, forge, users["alice"]!, { depth: 99 })!.nodes.length).toBeGreaterThan(4); | |
| 95 | }); | |
| 96 | ||
| 97 | test("a limit cuts the nodes and says so; the cap is 200", () => { | |
| 98 | const t = forkTree(app.db, forge, users["alice"]!, { limit: 3 })!; | |
| 99 | expect(t.nodes.length).toBe(3); | |
| 100 | expect(t.truncated).toBe(true); | |
| 101 | expect(MAX_NODES).toBe(200); | |
| 102 | }); | |
| 103 | ||
| 104 | test("the repo asked about is in nodes even when it lies deeper than depth", async () => { | |
| 105 | let at = forge; | |
| 106 | for (let i = 0; i < 5; i++) { | |
| 107 | const u = must( | |
| 108 | await createUser(app.db, { origin: "seed", trusted: true, username: `deep${i}`, email: `deep${i}@f.dev`, password: "a-good-passphrase" }), | |
| 109 | ); | |
| 110 | promote(app.db, u); | |
| 111 | at = must(await forkRepo(app, u, at)); | |
| 112 | } | |
| 113 | const t = forkTree(app.db, at, users["alice"]!, { depth: 1 })!; | |
| 114 | const me = t.nodes.find((n) => n.repo === "deep4/app")!; | |
| 115 | expect(me.depth).toBe(5); | |
| 116 | expect(t.nodes.some((n) => n.repo === "deep3/app")).toBe(true); | |
| 117 | const tiny = forkTree(app.db, at, users["alice"]!, { limit: 1 })!; | |
| 118 | expect(tiny.nodes.some((n) => n.repo === "deep4/app")).toBe(true); | |
| 119 | }); | |
| 120 | ||
| 121 | test("200 forks: capped, truncated, and a warm call stays within a counted number of statements", async () => { | |
| 122 | const cols = ( | |
| 123 | app.db.query<{ name: string }, []>("PRAGMA table_info(repo)").all() | |
| 124 | ).map((c) => c.name); | |
| 125 | const list = (f: (c: string) => string) => cols.map(f).join(","); | |
| 126 | const base = forge.id; | |
| 127 | for (let i = 0; i < 205; i++) { | |
| 128 | const sub = (c: string) => | |
| 129 | c === "id" ? `'bulk${i}'` : c === "name" ? `'bulk${i}'` : c === "fork_parent_id" ? `'${base}'` : c; | |
| 130 | app.db.query(`INSERT INTO repo (${cols.join(",")}) SELECT ${list(sub)} FROM repo WHERE id = ?1`).run(base); | |
| 131 | } | |
| 132 | forkTree(app.db, forge, users["bob"]!); | |
| 133 | let t!: NonNullable<ReturnType<typeof forkTree>>; | |
| 134 | const n = await once(app.db, () => { | |
| 135 | t = forkTree(app.db, forge, users["bob"]!)!; | |
| 136 | }); | |
| 137 | expect(t.nodes.length).toBe(200); | |
| 138 | expect(t.truncated).toBe(true); | |
| 139 | // 205 forks: a handful of statements per generation, not per node (was 408). | |
| 140 | expectWork(n, { max: 25 }); | |
| 141 | }); | |
| 142 | }); | |
| 143 | ||
| 144 | describe("the terminal screen", () => { | |
| 145 | test("draws the tree from the reader, marks the asked repo, and shows nothing for an unreadable one", async () => { | |
| 146 | const { treeLines } = await import("../src/tui/screens/fork_tree.ts"); | |
| 147 | const lines = treeLines(forkTree(app.db, org, users["dan"]!), "bob/app"); | |
| 148 | expect(lines).toContain("alice/app"); | |
| 149 | expect(lines).toContain(" * bob/app offers"); | |
| 150 | expect(lines.join("\n")).not.toContain("secret"); | |
| 151 | expect(treeLines(forkTree(app.db, hidden, users["dan"]!), "erin/secret")).toEqual(["Nothing to show."]); | |
| 152 | }); | |
| 153 | }); |
test/mcp.test.ts +1
| @@ -295,6 +295,7 @@ describe("the forge over MCP", () => { | ||
| 295 | 295 | "follow_mode", |
| 296 | 296 | "fork", |
| 297 | 297 | "forks", |
| 298 | "fork_tree", | |
| 298 | 299 | "upstream", |
| 299 | 300 | "propose_to_parent_forge", |
| 300 | 301 | "forge_proposals", |
test/palette-roster.test.ts +4
| @@ -57,6 +57,7 @@ test("a site-admin person is offered the whole roster", () => { | ||
| 57 | 57 | ["repo", "Open a repository", "owner/name"], |
| 58 | 58 | ["issues", "Issues in a repository", "owner/name"], |
| 59 | 59 | ["held", "Held by the Later gate", "owner/name"], |
| 60 | ["fork-tree", "Fork tree of a repository", "owner/name"], | |
| 60 | 61 | ["roadmap", "Roadmap of a repository", "owner/name"], |
| 61 | 62 | ["pulls", "Pull requests in a repository", "owner/name"], |
| 62 | 63 | ["milestones", "Milestones in a repository", "owner/name"], |
| @@ -174,6 +175,7 @@ test("a site-admin agent is offered the roster without the person-only entries", | ||
| 174 | 175 | ["repo", "Open a repository", "owner/name"], |
| 175 | 176 | ["issues", "Issues in a repository", "owner/name"], |
| 176 | 177 | ["held", "Held by the Later gate", "owner/name"], |
| 178 | ["fork-tree", "Fork tree of a repository", "owner/name"], | |
| 177 | 179 | ["roadmap", "Roadmap of a repository", "owner/name"], |
| 178 | 180 | ["pulls", "Pull requests in a repository", "owner/name"], |
| 179 | 181 | ["milestones", "Milestones in a repository", "owner/name"], |
| @@ -247,6 +249,7 @@ test("a member person is offered the signed-in roster without the admin entries" | ||
| 247 | 249 | ["repo", "Open a repository", "owner/name"], |
| 248 | 250 | ["issues", "Issues in a repository", "owner/name"], |
| 249 | 251 | ["held", "Held by the Later gate", "owner/name"], |
| 252 | ["fork-tree", "Fork tree of a repository", "owner/name"], | |
| 250 | 253 | ["roadmap", "Roadmap of a repository", "owner/name"], |
| 251 | 254 | ["pulls", "Pull requests in a repository", "owner/name"], |
| 252 | 255 | ["milestones", "Milestones in a repository", "owner/name"], |
| @@ -313,6 +316,7 @@ test("signed out, the palette offers sign-in and the public entries", () => { | ||
| 313 | 316 | ["repo", "Open a repository", "owner/name"], |
| 314 | 317 | ["issues", "Issues in a repository", "owner/name"], |
| 315 | 318 | ["held", "Held by the Later gate", "owner/name"], |
| 319 | ["fork-tree", "Fork tree of a repository", "owner/name"], | |
| 316 | 320 | ["roadmap", "Roadmap of a repository", "owner/name"], |
| 317 | 321 | ["pulls", "Pull requests in a repository", "owner/name"], |
| 318 | 322 | ["milestones", "Milestones in a repository", "owner/name"], |
N+1 queries (
visibleForksper parent,forkFollowModeper child, then a second sweep fortruncated): about 408 statements for 200 nodes. Batch per level. Also guarantee the requested repo is innodeswhen it sits deeper thandepth.