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

The fork tree, browsable, and each fork chooses how it follows #10990

Merged opened this in answer to

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

Merged

mason/forge:mason/608-the-fork-tree-browsable-and-each-fork-ch → main 6 commits fork 1 thread · 1 unresolved

  • Merged. mason/608-the-fork-tree-browsable-and-each-fork-ch was merged into main as da5cbd6.

Review

Sign in to review this change.
21 files changed +271 −75
docs/MCP.md +1 −1
@@ -108,7 +108,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`;
108108 | `flag_winner` | — | An A/B flag winner becomes default | 206 |
109109 | `flags` | — | Declared flags: why, default, owner issue, live value | 171 |
110110 | `flow` | `bugs`, `drift` | Pipeline (admin); `q`: `/fleet` rows, `action` plans an op, `apply` does it; `kind:bugs`; `repo`: why stuck | 473 |
111 | `follow_mode` | — | How a spawned forge follows its parent: `tell-me` or `take-updates` | 265 |
111 | `follow_mode` | — | How a fork follows its parent: take updates, tell me, or don't follow | 281 |
112112 | `following` | — | Where a fork stands against what it follows: in step, behind, ahead, diverged | 243 |
113113 | `forge_proposals` | — | What this forge proposed to its parent; `reachable` false if it is silent | 245 |
114114 | `forge_view` | — | The forge as one picture: counts, apps, runs with stations, health | 189 |
docs/features/follow-mode.md +20 −4
@@ -1,11 +1,27 @@
11 # How a fork follows its upstream
22
3 A fork's page says it in one line: **Proposes updates from ann/yard** (you are told, and take updates when you choose) or **Follows ann/yard automatically** (new code is merged as it lands; a conflict goes to the crew). The line shows only on a fork, to anyone who can see the page.
3 A fork follows its upstream in one of three modes, said in one line on its page:
4
5 - **Follows ann/yard automatically** (`take-updates`): new code is merged as it lands; a conflict goes to the crew.
6 - **Proposes updates from ann/yard** (`tell-me`, the default): a fork with nothing of its own still moves along; one with work of its own is told, and takes updates when its owner chooses.
7 - **Does not follow ann/yard** (`dont-follow`, #608): nothing is fast-forwarded, merged or announced, and the crew is never asked to converge. The fork can still offer its changes up.
8
9 The line shows only on a fork, to anyone who can see the page.
10
11 ## Where
12
13 The fork's repository page and its forks page (`/:owner/:name/forks`) carry the same Following card. On the forks page the card shows only to the fork's admin, above the family tree. The tree runs from the oldest ancestor the viewer may read, with each fork's ahead and behind counts against its own parent. When the fork is ahead, the card's **Offer this change** opens one pull on the parent (`proposeUpstream`).
414
515 ## Switching
616
7 - **Web:** an admin of the fork sees one button beside the line. It opens a confirm that says what changes ("without asking", "nothing is merged until you take it"); the POST is same-origin only, and the change is recorded as a `forge.follow_mode` event with the repository and the person.
8 - **MCP:** `follow_mode` with `repo` and `mode` (`tell-me` or `take-updates`) switches it for an admin and answers in the same words as the page. Anyone else is refused.
9 - **Terminal:** on a fork's screen the row shows the same line; `m` flips the mode and the toast says the same sentence.
17 - **Web:** an admin of the fork sees a button for each of the other two modes beside the line. Each opens a confirm that says what changes ("without asking", "nothing is merged until you take it", "the crew is never asked to converge"). The POST is same-origin only, and the change is recorded as a `forge.follow_mode` event with the repository and the person.
18 - **MCP:** `follow_mode` with `repo` and `mode` (`take-updates`, `tell-me` or `dont-follow`) switches it for an admin and answers in the same words as the page. Anyone else is refused. `following` returns the current `mode`, and `fork_tree` names each node's `follow`.
19 - **Terminal:** on a fork's screen the row shows the same line; `m` steps to the next mode and the toast says the same sentence.
20
21 A spawned forge's chain (its code repository following its parent forge) takes only `tell-me` or `take-updates`. To follow nothing, it severs.
22
23 ## What honours it
24
25 `follow` in `src/core/following.ts` skips a `dont-follow` fork when its parent's default branch moves, and `followDivergence` in `src/crew/converge.ts` never converges one.
1026
1127 The words live in `src/actions/follow_words.ts` and `followModeSaid` in `src/actions/follow.ts`; every door reads them from there.
src/actions/follow.ts +14 −8
@@ -12,10 +12,10 @@ import { convergeFork, type Converge } from "../crew/converge.ts";
1212 import { ARCHIVED_WHY, canAdmin, canWrite, forkParentFor, type Repo } from "../core/repos.ts";
1313 import type { User } from "../core/users.ts";
1414 import { err, ok, type Result } from "../kernel/result.ts";
15 import { chainOfRepo, forgeLinkOf, FOLLOW_MODES, isFollowMode, setChainMode, severChain, type FollowMode } from "../spawn/chain.ts";
15 import { chainOfRepo, forgeLinkOf, FOLLOW_MODES, isFollowMode, setChainMode, severChain } from "../spawn/chain.ts";
1616 import { FAMILY_WORDS } from "./family_words.ts";
1717 import { FOLLOWS_NOTHING, followModeWords } from "./follow_words.ts";
18 import { forkFollowMode, setForkFollowMode } from "../core/fork_follow.ts";
18 import { FORK_FOLLOW_MODES, forkFollowMode, isForkFollowMode, setForkFollowMode, type ForkFollowMode } from "../core/fork_follow.ts";
1919 import { requireUser } from "./access.ts";
2020 import { takeFromParent, type Taken } from "../spawn/take.ts";
2121 import { takeUpdates, type ForkSync, type TakeUpdatesError } from "./forks.ts";
@@ -100,7 +100,7 @@ export function takeForgeWords(r: TakeForgeResult): { tone?: "danger"; message:
100100 }
101101
102102 /** How a repository follows its parent now: the forge chain's record when it carries one, else the fork's own choice. */
103 export async function followModeOf(app: App, repo: Repo): Promise<FollowMode> {
103 export async function followModeOf(app: App, repo: Repo): Promise<ForkFollowMode> {
104104 const link = await forgeLinkOf(app);
105105 return link !== undefined && link.repo === `${repo.ownerName}/${repo.name}`
106106 ? link.mode
@@ -114,24 +114,30 @@ export const FOLLOW_MODE_REFUSALS: Record<FollowModeError, { status: number; mes
114114 unauthorized: { status: 401, message: "sign in to change how this forge follows its parent" },
115115 forbidden: { status: 403, message: "only this forge's owner can change how it follows" },
116116 not_following: { status: 404, message: FAMILY_WORDS.not_following },
117 invalid_mode: { status: 422, message: `mode is one of: ${FOLLOW_MODES.join(", ")}` },
117 invalid_mode: { status: 422, message: `mode is one of: ${FORK_FOLLOW_MODES.join(", ")}; a spawned forge takes ${FOLLOW_MODES.join(" or ")} and severs instead of not following` },
118118 severed: { status: 409, message: "this forge is severed and follows nothing; there is no mode to change" },
119119 };
120120
121 /** Choose how a forge follows its parent: the repository's admin only; the record changes atomically. */
121 /**
122 * Choose how a fork follows its parent: the repository's admin only; the
123 * record changes atomically. A plain fork takes any of `FORK_FOLLOW_MODES`;
124 * a spawned forge's chain takes `tell-me` or `take-updates` and severs to
125 * follow nothing.
126 */
122127 export async function setFollowMode(
123128 app: App,
124129 actor: User | undefined,
125130 repo: Repo,
126131 mode: unknown,
127 ): Promise<Result<FollowMode, FollowModeError>> {
132 ): Promise<Result<ForkFollowMode, FollowModeError>> {
128133 const who = requireUser(actor);
129134 if (!who.ok) return err("unauthorized");
130135 const carries = (await chainOfRepo(app, repo)) !== undefined;
131136 if (!carries && repo.forkParentId === null) return err("not_following");
132137 if (!canAdmin(app.db, repo, who.value) || !canWrite(app.db, repo, who.value)) return err("forbidden");
133 if (!isFollowMode(mode)) return err("invalid_mode");
138 if (!isForkFollowMode(mode)) return err("invalid_mode");
134139 if (carries) {
140 if (!isFollowMode(mode)) return err("invalid_mode");
135141 const set = await setChainMode(app, mode);
136142 if (!set.ok) return err(set.error);
137143 } else {
@@ -147,7 +153,7 @@ export async function setFollowMode(
147153 }
148154
149155 /** The sentence every door answers a mode change with. */
150 export function followModeSaid(parent: Repo | undefined, mode: FollowMode): string {
156 export function followModeSaid(parent: Repo | undefined, mode: ForkFollowMode): string {
151157 const upstream = parent === undefined ? "its parent" : `${parent.ownerName}/${parent.name}`;
152158 const words = followModeWords(mode, upstream);
153159 return `${words.line}. ${words.reason}`;
src/actions/follow_words.ts +34 −18
@@ -1,4 +1,4 @@
1 import type { FollowMode } from "../spawn/chain.ts";
1 import { FORK_FOLLOW_MODES, type ForkFollowMode } from "../core/fork_follow.ts";
22
33 /**
44 * Following's refusals in words (#5589): said by the chain page and the
@@ -26,27 +26,43 @@ export function proposeRefusalWords(
2626 return `${parentName} did not take the proposal: ${e.reason}.`;
2727 }
2828
29 /** The words of a button that switches a fork to `mode`. */
30 export const FOLLOW_SWITCH_LABELS: Record<ForkFollowMode, string> = {
31 "take-updates": "Follow automatically",
32 "tell-me": "Only propose updates",
33 "dont-follow": "Stop following",
34 };
35
2936 /** How a fork follows its upstream, in one line and the reason beside it: the page and the MCP say these words. */
3037 export function followModeWords(
31 mode: FollowMode,
38 mode: ForkFollowMode,
3239 upstream: string,
33 ): { readonly line: string; readonly reason: string; readonly other: FollowMode } {
34 return mode === "take-updates"
35 ? {
36 line: `Follows ${upstream} automatically`,
37 reason: `New code from ${upstream} is merged in as it lands; a conflict goes to the crew.`,
38 other: "tell-me",
39 }
40 : {
41 line: `Proposes updates from ${upstream}`,
42 reason: `You are told when ${upstream} has something new and take it when you choose.`,
43 other: "take-updates",
44 };
40 ): { readonly line: string; readonly reason: string; readonly others: readonly ForkFollowMode[] } {
41 const others = FORK_FOLLOW_MODES.filter((m) => m !== mode);
42 if (mode === "take-updates")
43 return {
44 line: `Follows ${upstream} automatically`,
45 reason: `New code from ${upstream} is merged in as it lands; a conflict goes to the crew.`,
46 others,
47 };
48 if (mode === "dont-follow")
49 return {
50 line: `Does not follow ${upstream}`,
51 reason: `Nothing from ${upstream} is merged or announced here; it can still offer its changes up.`,
52 others,
53 };
54 return {
55 line: `Proposes updates from ${upstream}`,
56 reason: `You are told when ${upstream} has something new and take it when you choose.`,
57 others,
58 };
4559 }
4660
4761 /** What switching to `mode` changes, for the confirm step. */
48 export function followSwitchPreview(mode: FollowMode, upstream: string): string {
49 return mode === "take-updates"
50 ? `From now on new code from ${upstream} is merged into this fork as it lands, without asking. When both sides changed the same files, nothing is merged and the crew is told to converge them. You can switch back at any time.`
51 : `From now on ${upstream} only proposes: you are told when it has something new and nothing is merged until you take it. You can switch back at any time.`;
62 export function followSwitchPreview(mode: ForkFollowMode, upstream: string): string {
63 if (mode === "take-updates")
64 return `From now on new code from ${upstream} is merged into this fork as it lands, without asking. When both sides changed the same files, nothing is merged and the crew is told to converge them. You can switch back at any time.`;
65 if (mode === "dont-follow")
66 return `From now on this fork goes its own way: nothing from ${upstream} is merged or announced here, and the crew is never asked to converge. You can still offer your changes up, and follow again at any time.`;
67 return `From now on ${upstream} only proposes: you are told when it has something new and nothing is merged until you take it. You can switch back at any time.`;
5268 }
src/actions/fork_tree.ts +10 −3
@@ -24,8 +24,11 @@ export const MAX_DEPTH = 8;
2424 /** The most nodes any call returns; past it `truncated` is true. A page of a tree, not a crawl. */
2525 export const MAX_NODES = 200;
2626
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";
27 /**
28 * How a node follows its parent: `offers` is the stored `tell-me`, `dont-follow` a fork
29 * that chose to follow nothing; the root of what is visible follows nothing.
30 */
31 export type TreeFollow = "take-updates" | "offers" | "dont-follow" | "autonomous";
2932
3033 export interface TreeNode {
3134 readonly repo: string;
@@ -101,7 +104,11 @@ export function forkTree(
101104 repo: nameOf(r.repo),
102105 parent: r.parent === null ? null : nameOf(r.parent),
103106 depth: r.depth,
104 follow: r.parent === null ? "autonomous" : modes.get(r.repo.id) === "take-updates" ? "take-updates" : "offers",
107 follow: r.parent === null ? "autonomous" : treeFollow(modes.get(r.repo.id)),
105108 }));
106109 return { root: nameOf(root), nodes, truncated };
107110 }
111
112 /** A stored mode as the tree names it: `tell-me`, stored or by default, is `offers`. */
113 const treeFollow = (mode: string | undefined): TreeFollow =>
114 mode === "take-updates" || mode === "dont-follow" ? mode : "offers";
src/core/following.ts +4 −1
@@ -109,7 +109,9 @@ export function follow(forge: Following): () => void {
109109 }),
110110 );
111111 if (moved.some((m) => m.ref === `refs/heads/${repo.defaultBranch}`))
112 for (const fork of listForks(forge.db, repo.id))
112 for (const fork of listForks(forge.db, repo.id)) {
113 // A fork that chose not to follow (#608) is neither moved nor told.
114 if (forkFollowMode(forge.db, fork.id) === "dont-follow") continue;
113115 fastForwardFork(forge, fork, repo, actor)
114116 .then(async (followed) => {
115117 // A fork with work of its own cannot be moved; it is told the
@@ -140,6 +142,7 @@ export function follow(forge: Following): () => void {
140142 error: String(error),
141143 }),
142144 );
145 }
143146 });
144147 }
145148
src/core/fork_follow.ts +16 −6
@@ -1,24 +1,34 @@
11 /** How a plain fork follows its parent: one row per fork that chose; none means the default. */
22
33 import type { Database } from "bun:sqlite";
4 import { DEFAULT_MODE, type FollowMode } from "../spawn/chain.ts";
4 import { DEFAULT_MODE, FOLLOW_MODES } from "../spawn/chain.ts";
55
6 export function forkFollowMode(db: Database, repoId: string): FollowMode {
7 const row = db.query<{ mode: FollowMode }, [string]>("SELECT mode FROM fork_follow WHERE repo_id = ?1").get(repoId);
6 /**
7 * A plain fork's modes: the chain's two, and `dont-follow` (#608), which a
8 * spawned forge has no need of because it severs instead.
9 */
10 export const FORK_FOLLOW_MODES = [...FOLLOW_MODES, "dont-follow"] as const;
11 export type ForkFollowMode = (typeof FORK_FOLLOW_MODES)[number];
12
13 export const isForkFollowMode = (v: unknown): v is ForkFollowMode =>
14 typeof v === "string" && (FORK_FOLLOW_MODES as readonly string[]).includes(v);
15
16 export function forkFollowMode(db: Database, repoId: string): ForkFollowMode {
17 const row = db.query<{ mode: ForkFollowMode }, [string]>("SELECT mode FROM fork_follow WHERE repo_id = ?1").get(repoId);
818 return row?.mode ?? DEFAULT_MODE;
919 }
1020
11 export function setForkFollowMode(db: Database, repoId: string, mode: FollowMode): void {
21 export function setForkFollowMode(db: Database, repoId: string, mode: ForkFollowMode): void {
1222 db.query(
1323 "INSERT INTO fork_follow (repo_id, mode) VALUES (?1, ?2) ON CONFLICT(repo_id) DO UPDATE SET mode = excluded.mode",
1424 ).run(repoId, mode);
1525 }
1626
1727 /** 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> {
28 export function forkFollowModes(db: Database, repoIds: readonly string[]): Map<string, ForkFollowMode> {
1929 if (repoIds.length === 0) return new Map();
2030 const rows = db
21 .query<{ repo_id: string; mode: FollowMode }, string[]>(
31 .query<{ repo_id: string; mode: ForkFollowMode }, string[]>(
2232 `SELECT repo_id, mode FROM fork_follow WHERE repo_id IN (${repoIds.map(() => "?").join(",")})`,
2333 )
2434 .all(...repoIds);
src/core/migration-pins/20261110170000-fork-follow-dont-follow.pin added +1
@@ -0,0 +1,1 @@
1 596f05f01516a33179a8f6a65f86497f7cd031e251710d85ada2f13a45b8fb14
src/core/migrations/20261110170000-fork-follow-dont-follow.ts added +12
@@ -0,0 +1,12 @@
1 // A plain fork may also choose not to follow its parent at all (#608):
2 // nothing is fast-forwarded, merged or told. SQLite cannot widen a CHECK in
3 // place, so the table is rebuilt with every row kept.
4 export default `
5 CREATE TABLE fork_follow_new (
6 repo_id TEXT PRIMARY KEY REFERENCES repo(id) ON DELETE CASCADE,
7 mode TEXT NOT NULL CHECK (mode IN ('tell-me','take-updates','dont-follow'))
8 );
9 INSERT INTO fork_follow_new (repo_id, mode) SELECT repo_id, mode FROM fork_follow;
10 DROP TABLE fork_follow;
11 ALTER TABLE fork_follow_new RENAME TO fork_follow;
12 `;
src/crew/converge.ts +3
@@ -36,6 +36,7 @@ import { closesFromBody, listPulls, type Pull } from "../core/pulls.ts";
3636 import { canWrite, getRepoById, listAllForks, type Repo } from "../core/repos.ts";
3737 import { activeRunFor, cancelRun, listRuns } from "../core/runs.ts";
3838 import { commitIdentity } from "../core/commit_identity.ts";
39 import { forkFollowMode } from "../core/fork_follow.ts";
3940 import { getUserById, type User } from "../core/users.ts";
4041 import { runGit } from "../git/env.ts";
4142 import { isSafeOid } from "../git/guard.ts";
@@ -226,6 +227,8 @@ export function followDivergence(app: App): () => void {
226227 typeof parentId === "string" ? getRepoById(app.db, parentId) : undefined;
227228 if (fork === undefined || parent === undefined) return;
228229 if (fork.autonomy === "off") return;
230 // A fork that chose not to follow is never converged on its behalf.
231 if (forkFollowMode(app.db, fork.id) === "dont-follow") return;
229232 diverged(app, fork, parent).catch((error) =>
230233 log.error("could not act on a diverged fork", {
231234 fork: `${fork.ownerName}/${fork.name}`,
src/http/mcp/tools/follow_mode.ts +4 −4
@@ -1,18 +1,18 @@
11 import { FOLLOW_MODE_REFUSALS, followModeSaid, setFollowMode } from "../../../actions/follow.ts";
22 import { forkParentFor } from "../../../core/repos.ts";
3 import { FOLLOW_MODES } from "../../../spawn/chain.ts";
3 import { FORK_FOLLOW_MODES } from "../../../core/fork_follow.ts";
44 import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
55
66 export const tool: Tool = {
77 name: "follow_mode",
8 doc: "`{ repo, mode }`: choose how a spawned forge follows its parent, `mode` being `tell-me` or `take-updates`; its `forge` repository's admin only; answers `{ says, mode }`; refused `unauthorized`, `forbidden`, `not_following`, `invalid_mode` or `severed`",
8 doc: "`{ repo, mode }`: choose how a fork follows its parent: `take-updates`, `tell-me` or `dont-follow` (a spawned forge: the first two; it severs instead); the admin only; answers `{ says, mode }`; refused `unauthorized`, `forbidden`, `not_following`, `invalid_mode` or `severed`",
99 description:
10 "How a spawned forge follows its parent: `tell-me` or `take-updates`.",
10 "How a fork follows its parent: take updates, tell me, or don't follow.",
1111 inputSchema: {
1212 type: "object",
1313 properties: {
1414 repo: REPO_ARG,
15 mode: { type: "string", enum: [...FOLLOW_MODES] },
15 mode: { type: "string", enum: [...FORK_FOLLOW_MODES] },
1616 },
1717 required: ["repo", "mode"],
1818 },
src/http/mcp/tools/following.ts +3 −1
@@ -1,5 +1,6 @@
11 import { upstreamPayload } from "../../../spawn/upstream.ts";
22 import { relationOf } from "../../../actions/forks.ts";
3 import { followModeOf } from "../../../actions/follow.ts";
34 import { chainOfRepo } from "../../../spawn/chain.ts";
45 import { convergeState } from "../../../crew/converge.ts";
56 import { parentForgeFor } from "../../../core/parent_releases.ts";
@@ -8,7 +9,7 @@ import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts"
89 export const tool: Tool = {
910 name: "following",
1011 doc:
11 "`{ repo }`: for a fork `{ parent, state, behind, ahead, clean, conflicts, action, words, parentForge, converge, chain, severedAt, upstream }`, where it stands against what it follows (`state` in step, behind, ahead or diverged; `behind`/`ahead` commits; `clean` whether a merge would apply; `conflicts` the files that clash; `action` the one thing that reconciles it (none, fast-forward, offer, merge, keep-both) and `words` it said plainly; `converge` what the crew is doing about a conflict, else `null`; `parentForge`, `chain`, `severedAt` and `upstream` the forge-to-forge link, `null` when there is none); for a repository that is no fork but a spawned forge, only `{ chain, severedAt, upstream }`; refused `not_a_fork` when it follows nothing, `not_found` for a repository the caller may not see",
12 "`{ repo }`: for a fork `{ parent, mode, state, behind, ahead, clean, conflicts, action, words, parentForge, converge, chain, severedAt, upstream }`, where it stands against what it follows (`mode` take-updates, tell-me or dont-follow, set by `follow_mode`; `state` in step, behind, ahead or diverged; `behind`/`ahead` commits; `clean` whether a merge would apply; `conflicts` the files that clash; `action` the one thing that reconciles it (none, fast-forward, offer, merge, keep-both) and `words` it said plainly; `converge` what the crew is doing about a conflict, else `null`; `parentForge`, `chain`, `severedAt` and `upstream` the forge-to-forge link, `null` when there is none); for a repository that is no fork but a spawned forge, only `{ chain, severedAt, upstream }`; refused `not_a_fork` when it follows nothing, `not_found` for a repository the caller may not see",
1213 description:
1314 "Where a fork stands against what it follows: in step, behind, ahead, diverged.",
1415 inputSchema: {
@@ -35,6 +36,7 @@ export const tool: Tool = {
3536 severedAt: chain?.severedAt ?? null,
3637 upstream: chain === undefined ? null : await upstreamPayload(ctx.app),
3738 parent: `${relation.parent.ownerName}/${relation.parent.name}`,
39 mode: await followModeOf(ctx.app, repo),
3840 state: relation.state,
3941 behind: relation.behind,
4042 ahead: relation.ahead,
src/http/mcp/tools/fork_tree.ts +1 −1
@@ -4,7 +4,7 @@ import { good, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
44 /** The whole tree a repo sits in, as the caller may read it. */
55 export const tool: Tool = {
66 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",
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`, `offers` or `dont-follow`; `depth` 1-8 (default 4), `limit` 1-200 nodes (default 200); `truncated` is true when more exists than is shown",
88 description: "The fork tree around a repo: ancestors, siblings and descendants you may read.",
99 inputSchema: {
1010 type: "object",
src/tui/screens/forks.ts +5 −4
@@ -24,7 +24,7 @@ import { FOLLOWS_NOTHING, proposeRefusalWords } from "../../actions/follow_words
2424 import { openProposal, proposeUpstream } from "../../actions/upstream.ts";
2525 import type { Pull } from "../../core/pulls.ts";
2626 import { convergeState, type Converge } from "../../crew/converge.ts";
27 import type { FollowMode } from "../../spawn/chain.ts";
27 import { FORK_FOLLOW_MODES, type ForkFollowMode } from "../../core/fork_follow.ts";
2828 import { canAdmin, canRead, canWrite, forkTree, listForks, type ForkNode, type Repo } from "../../core/repos.ts";
2929 import type { Ctx, Screen } from "../app/app.ts";
3030 import { binding } from "../app/keys.ts";
@@ -83,7 +83,7 @@ export function forkRows(
8383 export function followingRows(
8484 relation: ForkRelation | undefined,
8585 converge: Converge | undefined,
86 mode?: FollowMode,
86 mode?: ForkFollowMode,
8787 ): ForkRow[] {
8888 if (relation === undefined) return [];
8989 const { parent } = relation;
@@ -145,7 +145,7 @@ export function forksScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
145145 let relation: ForkRelation | undefined;
146146 let offer: string | undefined;
147147 let open: Pull | undefined;
148 let mode: FollowMode | undefined;
148 let mode: ForkFollowMode | undefined;
149149 const state = new ListState();
150150 /** Only a person who writes to a fork may propose its work upstream. */
151151 const mayPropose = () => f.user !== undefined && f.user.kind !== "agent" && canWrite(f.db, repo, f.user);
@@ -301,7 +301,8 @@ export function forksScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
301301 }
302302 if (e.key === "m" && canSetMode()) {
303303 runAsync(ctx, async () => {
304 const next: FollowMode = mode === "take-updates" ? "tell-me" : "take-updates";
304 // `m` steps through the three modes in turn.
305 const next = FORK_FOLLOW_MODES[(FORK_FOLLOW_MODES.indexOf(mode ?? "tell-me") + 1) % FORK_FOLLOW_MODES.length]!;
305306 const set = await setFollowMode(f.app, f.user, repo, next);
306307 if (!set.ok) return ctx.toast(FOLLOW_MODE_REFUSALS[set.error].message, "danger");
307308 await read();
src/web/pages/following.ts +15 −12
@@ -37,8 +37,8 @@ import {
3737 type ParentReleases,
3838 } from "../../core/parent_releases.ts";
3939 import { running } from "../../kernel/version.ts";
40 import { followModeWords } from "../../actions/follow_words.ts";
41 import type { FollowMode } from "../../spawn/chain.ts";
40 import { FOLLOW_SWITCH_LABELS, followModeWords } from "../../actions/follow_words.ts";
41 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";
4444 import { FOLLOWS_NOTHING, proposeRefusalWords } from "../../actions/follow_words.ts";
@@ -95,7 +95,7 @@ export interface Following {
9595 /** The parent forge's releases, when this forge names a parent forge. */
9696 readonly releases: { url: string; view: ParentReleases } | undefined;
9797 /** How this repository follows its parent (`followModeOf`). */
98 readonly mode: FollowMode;
98 readonly mode: ForkFollowMode;
9999 }
100100
101101 /** Everything the card needs, for a repo that follows something. */
@@ -187,8 +187,8 @@ export function relationBadge(relation: ForkRelation): Html {
187187 : relation.state === "ahead"
188188 ? [`${relation.ahead} ahead`, "default"]
189189 : relation.clean
190 ? ["diverged · merges cleanly", "attn"]
191 : ["diverged", "attn"];
190 ? [`diverged · ${relation.ahead} ahead · ${relation.behind} behind · merges cleanly`, "attn"]
191 : [`diverged · ${relation.ahead} ahead · ${relation.behind} behind`, "attn"];
192192 return badge(text, { tone });
193193 }
194194
@@ -228,7 +228,8 @@ export function followingCard(
228228 ctx: Ctx,
229229 repo: Repo,
230230 following: Following,
231 o: { bare?: boolean } = {},
231 /** `bare` drops the card's margin; `family: false` drops the link to the forks page, for the forks page itself. */
232 o: { bare?: boolean; family?: boolean } = {},
232233 ): Html {
233234 const { relation, converge } = following;
234235 const parent = relation.parent;
@@ -292,7 +293,7 @@ export function followingCard(
292293 icon: "fork",
293294 title: `Your account can only read ${parent.ownerName}/${parent.name}'s origin, or its operator protects it: the change goes to your own fork there, and the pull opens from it`,
294295 })
295 : btn("Propose upstream", {
296 : btn("Offer this change", {
296297 size: "sm",
297298 type: "submit",
298299 icon: "merge",
@@ -376,14 +377,16 @@ export function followingCard(
376377 html`<p data-follow-mode="${following.mode}">
377378 <strong>${words.line}</strong>.
378379 ${canAdmin(ctx.app.db, repo, ctx.auth.user) && writer
379 ? btn(following.mode === "take-updates" ? "Only propose updates" : "Follow automatically", {
380 href: `/-/confirm?to=${encodeURIComponent(`${base}/following/mode/${words.other}`)}`,
381 size: "sm",
382 })
380 ? words.others.map((m) =>
381 btn(FOLLOW_SWITCH_LABELS[m], {
382 href: `/-/confirm?to=${encodeURIComponent(`${base}/following/mode/${m}`)}`,
383 size: "sm",
384 }),
385 )
383386 : null}
384387 </p>`,
385388 );
386 if (followers > 0)
389 if (followers > 0 && o.family !== false)
387390 rows.push(
388391 row({
389392 href: `${base}/forks`,
src/web/pages/repo.ts +10
@@ -40,6 +40,7 @@ import {
4040 archivedParentLine,
4141 followingCard,
4242 followingOf,
43 type Following,
4344 lineageCard,
4445 } from "./following.ts";
4546 import { nothingNew } from "../../actions/follow.ts";
@@ -172,8 +173,17 @@ export async function forksPage(ctx: Ctx): Promise<Response> {
172173 t = performance.now();
173174 const ranked = await rankedForksOf(ctx.app, repo, forks);
174175 notePhase(ctx.request, "drift", performance.now() - t);
176 // On a fork its admin may steer, the Following card: how this fork
177 // follows, and its change offered up when ahead (#608).
178 let following: Following | undefined;
179 if (repo.forkParentId !== null && canAdmin(ctx.app.db, repo, ctx.auth.user)) {
180 t = performance.now();
181 following = await followingOf(ctx, repo);
182 notePhase(ctx.request, "following", performance.now() - t);
183 }
175184 t = performance.now();
176185 const body = html`${repoHeader(ctx, repo, "forks")}
186 ${following === undefined ? "" : followingCard(ctx, repo, following, { family: false })}
177187 <div class="page-head mt">
178188 <h2>${forks.length} fork${forks.length === 1 ? "" : "s"}</h2>
179189 </div>
src/web/pages/search.ts +3 −3
@@ -19,7 +19,7 @@ import { openLabelCounts } from "../../core/issues.ts";
1919 import { getPull } from "../../core/pulls.ts";
2020 import { canAdmin, canWrite, enterableApps, forkParentFor, getRepo } from "../../core/repos.ts";
2121 import { followModeWords, followSwitchPreview } from "../../actions/follow_words.ts";
22 import type { FollowMode } from "../../spawn/chain.ts";
22 import type { ForkFollowMode } from "../../core/fork_follow.ts";
2323 import { getMirrorTarget } from "../../core/migrate_mirror.ts";
2424 import {
2525 cleanQuery,
@@ -914,9 +914,9 @@ const DESTRUCTIVE: {
914914 },
915915 },
916916 {
917 pattern: /^\/([^/]+)\/([^/]+)\/following\/mode\/(tell-me|take-updates)$/,
917 pattern: /^\/([^/]+)\/([^/]+)\/following\/mode\/(tell-me|take-updates|dont-follow)$/,
918918 describe: (m, ctx) => {
919 const mode = m[3] as FollowMode;
919 const mode = m[3] as ForkFollowMode;
920920 const repo = getRepo(ctx.app.db, m[1]!, m[2]!);
921921 const parent = repo && canWrite(ctx.app.db, repo, ctx.auth.user) ? forkParentFor(ctx.app.db, repo, ctx.auth.user) : undefined;
922922 const upstream = parent === undefined ? "its parent" : `${parent.ownerName}/${parent.name}`;
test/fork-follow-mode.test.ts +43 −1
@@ -8,6 +8,7 @@ import { createApp, type App } from "../src/app.ts";
88 import { forkFollowMode } from "../src/core/fork_follow.ts";
99 import { forkRelation } from "../src/core/following.ts";
1010 import { getRepo, type Repo } from "../src/core/repos.ts";
11 import { listEvents } from "../src/core/events.ts";
1112 import { createUser, type User } from "../src/core/users.ts";
1213 import { createForgeApp } from "../src/tui/forge.ts";
1314 import { forksScreen } from "../src/tui/screens/forks.ts";
@@ -33,7 +34,7 @@ const commit = async (who: string, repo: Repo, path: string) =>
3334 beforeAll(async () => {
3435 dataDir = await mkdtemp(join(tmpdir(), "forge-fork-follow-mode-"));
3536 app = createApp({ dataDir, port: 0 });
36 for (const name of ["ann", "bea", "cal"]) {
37 for (const name of ["ann", "bea", "cal", "dee", "eve"]) {
3738 users[name] = must(
3839 await createUser(app.db, {
3940 origin: "seed",
@@ -105,6 +106,47 @@ describe("the mode changes what happens when the parent moves", () => {
105106 });
106107 });
107108
109 describe("don't follow (#608)", () => {
110 const settle = async (f: Repo, state: string) => {
111 for (let i = 0; i < 100; i++) {
112 if ((await forkRelation(app, f, original))?.state === state) return true;
113 await Bun.sleep(50);
114 }
115 return false;
116 };
117
118 test("is a mode the owner may choose and a stranger may not", async () => {
119 const own = must(await forkRepo(app, users["cal"]!, original, "yard-alone"));
120 expect(await setFollowMode(app, users["bea"]!, own, "dont-follow")).toEqual({ ok: false, error: "forbidden" });
121 expect(must(await setFollowMode(app, users["cal"]!, own, "dont-follow"))).toBe("dont-follow");
122 expect(forkFollowMode(app.db, own.id)).toBe("dont-follow");
123 });
124
125 test("the parent moving neither moves the fork nor tells anyone; tell-me beside it still fast-forwards", async () => {
126 const alone = getRepo(app.db, "cal", "yard-alone")!;
127 const told = must(await forkRepo(app, users["dee"]!, original, "yard-told"));
128 must(await setFollowMode(app, users["dee"]!, told, "tell-me"));
129 const diverged = () => listEvents(app.db, { kind: "fork.diverged" }).filter((e) => e.repoId === alone.id).length;
130 await commit("cal", alone, "own.txt");
131 const before = diverged();
132 await commit("ann", original, "three.txt");
133 // The tell-me fork, with nothing of its own, catches up on its own.
134 // Its fast-forward starts in the same synchronous pass of `follow` that
135 // skips a dont-follow fork, and a skipped fork starts no work at all; so
outdated

Fixed sleep to prove a negative is a flake source. Wait on a deterministic signal from the follow handler instead.

136 // once the sibling has settled, the handler is done with this fork too.
137 expect(await settle(told, "synced")).toBe(true);
138 expect((await forkRelation(app, alone, original))?.state).toBe("diverged");
139 expect(diverged()).toBe(before);
140
141 // With nothing of its own it would have been fast-forwarded; it is not.
142 const bare = must(await forkRepo(app, users["eve"]!, original, "yard-bare"));
143 must(await setFollowMode(app, users["eve"]!, bare, "dont-follow"));
144 await commit("ann", original, "four.txt");
145 expect(await settle(told, "synced")).toBe(true);
146 expect((await forkRelation(app, bare, original))?.state).toBe("behind");
147 });
148 });
149
108150 describe("the terminal", () => {
109151 const open = async (who: string, keys: string) => {
110152 const terminal = headlessTerminal(120, 30);
test/fork-tree-web.test.ts +68 −8
@@ -2,11 +2,8 @@
22 * #7756: the Fork tree test, on the web, on one forge. A person looks at a
33 * fork's page and sees its parent and its child as a tree, chooses how it
44 * follows, and offers its own commit up in one POST from the offer page.
5 *
6 * The mode step is covered only for the forge's own code: the follow modes
7 * exist nowhere else, so the middle fork is made that repository by hand.
8 * A plain in-forge fork offers no mode choice; that step is missing, and is
9 * #7796.
5 * A plain in-forge fork chooses among three modes on its page and its forks
6 * page (#7796, #608).
107 */
118 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
129 import { mkdtemp, rm } from "node:fs/promises";
@@ -20,7 +17,8 @@ import { createUser, type User } from "../src/core/users.ts";
2017 import { serve, type Servers } from "../src/http/server.ts";
2118 import { forkFollowMode } from "../src/core/fork_follow.ts";
2219 import { listEvents } from "../src/core/events.ts";
23 import { followModeSaid } from "../src/actions/follow.ts";
20 import { createToken } from "../src/core/tokens.ts";
21 import { followModeSaid, setFollowMode } from "../src/actions/follow.ts";
2422 import { quietLogs } from "./helpers/globals.ts";
2523
2624 quietLogs();
@@ -181,6 +179,63 @@ describe("the fork tree, on one forge", () => {
181179 expect(followModeSaid(undefined, "tell-me")).toContain("Proposes updates from its parent.");
182180 });
183181
182 test("the forks page lets the fork's owner choose any of three modes, and not a reader (#608)", async () => {
183 const cookie = await signIn("bea");
184 const forks = await (await get("/bea/yard/forks", cookie)).text();
185 expect(forks).toContain('id="following"');
186 expect(forks).toContain("data-follow-mode=");
187 // The card on its own forks page does not link back to it.
188 expect(forks).not.toContain("forks follow this one");
189 const now = forkFollowMode(app.db, middle.id);
190 const others = (["take-updates", "tell-me", "dont-follow"] as const).filter((m) => m !== now);
191 for (const m of others) expect(forks).toContain(`following%2Fmode%2F${m}"`);
192 expect(forks).not.toContain(`following%2Fmode%2F${now}"`);
193
194 const reader = await (await get("/bea/yard/forks", await signIn("cal"))).text();
195 expect(reader).not.toContain('id="following"');
196 expect(reader).toContain('aria-label="Forks, by who follows whom"');
197
198 const confirm = await (await get("/-/confirm?to=%2Fbea%2Fyard%2Ffollowing%2Fmode%2Fdont-follow", cookie)).text();
199 expect(confirm).toContain("Does not follow ann/yard?");
200 expect(confirm).toContain("the crew is never asked to converge");
201 const changed = await post("/bea/yard/following/mode/dont-follow", cookie, {});
202 expect(changed.status).toBe(303);
203 expect(forkFollowMode(app.db, middle.id)).toBe("dont-follow");
204 const after = await (await get("/bea/yard/forks", cookie)).text();
205 expect(after).toContain("Does not follow ann/yard");
206 expect(after).toContain("Follow automatically");
207 expect(after).toContain("Only propose updates");
208 expect(after).not.toContain("Stop following");
209 must(await setFollowMode(app, bea, middle, "tell-me"));
210 });
211
212 test("MCP: following returns the mode, follow_mode sets any of the three; a stranger is refused (#608)", async () => {
213 let id = 1;
214 const call = async (who: User, name: string, args: unknown) => {
215 const res = await fetch(`${servers.url}/mcp`, {
216 method: "POST",
217 headers: {
218 "content-type": "application/json",
219 accept: "application/json, text/event-stream",
220 authorization: `Bearer ${createToken(app.db, who.id, "t", "rw").token}`,
221 },
222 body: JSON.stringify({ jsonrpc: "2.0", id: id++, method: "tools/call", params: { name, arguments: args } }),
223 });
224 const j = (await res.json()) as any;
225 return { error: j.result.isError === true, text: String(j.result.content[0].text) };
226 };
227 const set = await call(bea, "follow_mode", { repo: "bea/yard", mode: "dont-follow" });
228 expect(set.error).toBe(false);
229 expect(JSON.parse(set.text).mode).toBe("dont-follow");
230 const read = await call(bea, "following", { repo: "bea/yard" });
231 expect(JSON.parse(read.text).mode).toBe("dont-follow");
232 const tree = await call(bea, "fork_tree", { repo: "bea/yard" });
233 expect(JSON.parse(tree.text).nodes.find((n: { repo: string }) => n.repo === "bea/yard").follow).toBe("dont-follow");
234 expect((await call(cal, "follow_mode", { repo: "bea/yard", mode: "take-updates" })).error).toBe(true);
235 expect(forkFollowMode(app.db, middle.id)).toBe("dont-follow");
236 must(await setFollowMode(app, bea, middle, "tell-me"));
237 });
238
184239 test("one POST from the offer page opens exactly one pull on the parent", async () => {
185240 const cookie = await signIn("bea");
186241 const fork = getRepo(app.db, "bea", "yard")!;
@@ -190,12 +245,17 @@ describe("the fork tree, on one forge", () => {
190245 files: [{ path: "mine.txt", content: "m\n" }],
191246 }),
192247 );
193 // The door is the Following card's "Propose upstream" form: read its
248 // The door is the Following card's "Offer this change" form: read its
194249 // action and fields off the page, then send exactly that.
195250 const home = await (await get("/bea/yard", cookie)).text();
196251 const formTag = home.match(/<form method="post" action="([^"]+)">\s*<input type="hidden" name="do" value="propose"/);
197252 expect(formTag).not.toBeNull();
198 expect(home).toContain("Propose upstream");
253 expect(home).toContain("Offer this change");
254 // The forks page carries the same card for its owner (#608): the
255 // offer, and the tree saying the fork is one ahead.
256 const forks = await (await get("/bea/yard/forks", cookie)).text();
257 expect(forks).toContain("Offer this change");
258 expect(forks.slice(forks.indexOf('aria-label="Forks, by who follows whom"'))).toContain("1 ahead");
199259 const sent = await post(formTag![1]!, cookie, { do: "propose" });
200260 expect(sent.status).toBe(303);
201261 expect(sent.headers.get("location")).toBe("/ann/yard/pulls/1");
test/schema-additive.test.ts +1
@@ -161,6 +161,7 @@ const RETIRED: Record<string, { readonly drops: readonly string[]; readonly sinc
161161 const REBUILT: Record<string, string> = {
162162 "20261103120000-conversations-many": "conversation loses UNIQUE(user_id) (#8814); same columns, plus title and archived_at",
163163 "20261104100000-conversation-run-stopped-state": "conversation_run widens its state CHECK to admit stopped (#8972); same columns, same rows, the stopped column kept",
164 "20261110170000-fork-follow-dont-follow": "fork_follow widens its mode CHECK to admit dont-follow (#608); same columns, same rows; the release before reads dont-follow as not take-updates",
164165 };
165166
166167 const src = join(import.meta.dir, "../src");
test/spawn-chain-mode.test.ts +3
@@ -112,6 +112,9 @@ describe("choosing how a forge follows its parent", () => {
112112 expect((await form(cookie, "yolo")).status).toBe(303);
113113 expect((await tool(readerTok, { repo: "kid/forge", mode: "take-updates" })).error).toBe(true);
114114 expect((await tool(ownerTok, { repo: "kid/forge", mode: "yolo" })).error).toBe(true);
115 // Not following is a plain fork's mode; a spawned forge severs instead (#608).
116 expect((await patch(ownerTok, { mode: "dont-follow" })).status).toBe(422);
117 expect((await tool(ownerTok, { repo: "kid/forge", mode: "dont-follow" })).error).toBe(true);
115118 const cross = await fetch(`${servers.url}/kid/forge/following/mode`, {
116119 method: "POST",
117120 headers: { cookie, origin: "https://evil.test" },

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs