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

A new forge follows the topmost forge on its own: its forge repository takes updates by default, and a clean update merges itself #11148

Merged opened this in answer to

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

Merged

mason/forge:mason/11143-a-new-forge-follows-the-topmost-forge-on → main 5 commits fork

  • Merged. mason/11143-a-new-forge-follows-the-topmost-forge-on was merged into main as 8869849.

Review

Sign in to review this change.
20 files changed +141 −76
docs/FOLLOWING.md +4 −4
@@ -184,8 +184,8 @@ there is landing it upstream.
184184 the API.
185185
186186 A fork across forges (#1085): a spawned forge's `<org>/forge` follows its
187 parent's forge code in the mode the spawner chose, `tell-me` (default) or
188 `take-updates`; `/:owner/forge/following` shows the chain to the root and
187 parent's forge code in the mode the spawner chose, `take-updates` (default) or
188 `tell-me`; `/:owner/forge/following` shows the chain to the root and
189189 the owner's **Sever**, which cuts the link and deletes nothing (the page
190190 and the parent's `/family` say "follows nothing since …"). `docs/SPAWN.md`
191191 has the shape. A **conflict** on **Take updates** is a tell for the crew:
@@ -256,8 +256,8 @@ the default branch is pushed to `child/<name>/<branch>-<tip>` at the parent,
256256 and the pull opens there, titled by the change that landed last and naming it
257257 (`org/forge#7`). Asking again for the same tip returns the pull already made.
258258
259 The repository's admin changes how the forge follows its parent (`tell-me` or
260 `take-updates`) from the radio form on the same page, with `PATCH
259 The repository's admin changes how the forge follows its parent (`take-updates` or
260 `tell-me`) from the radio form on the same page, with `PATCH
261261 /api/v1/forge/following {"mode"}`, with the MCP `follow_mode` tool, or by pressing `m` on the chain screen in the terminal. The
262262 record in `parent.json` is rewritten atomically and a `forge.follow_mode` event
263263 is appended. A non-admin is refused (403), a value outside the two modes is
docs/MCP.md +1 −1
@@ -197,7 +197,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`;
197197 | `signup` | — | Sign-ups: open (default); invite lets in only a person with an open invite link; closed refuses every new account | 291 |
198198 | `sizes` | — | A child forge's sizes: small, medium and large, each with CPU, memory and disk | 196 |
199199 | `snapshot_now` | — | Take a snapshot now, as Backups does (admin) | 133 |
200 | `spawn` | — | Spawn a child forge | 910 |
200 | `spawn` | — | Spawn a child forge | 908 |
201201 | `spend` | — | Report what your session spent outside forge runs: who, model, tokens or dollars, issue | 470 |
202202 | `ssh_keys` | `list`, `add`, `remove` | Your SSH keys; add: key, remove: id | 250 |
203203 | `steer` | — | Say a word to one crew run by its id | 224 |
docs/SPAWN.md +6 −6
@@ -95,8 +95,8 @@ reads child → parent → … → root.
9595 `parent.json` keeps `forgeCode`: `repo`, `upstream`, `mode`, `tip` (the
9696 upstream's tip at birth) and `chain` (the ancestors' hops, copied from the
9797 parent's own `parent.json`, so a grandchild reads the whole chain offline).
98 The spawner chooses `followMode` in the spawn input: `tell-me` (default) or
99 `take-updates`; it is part of the plan. `/:owner/forge/following` on the
98 The spawner chooses `followMode` in the spawn input: `take-updates` (default) or
99 `tell-me`; it is part of the plan. A plain fork (not a spawned forge) defaults to `tell-me`. `/:owner/forge/following` on the
100100 child shows the chain, root first, one line per hop with its mode and how
101101 many commits of its own it had when the next hop was born (the last hop is
102102 counted now). What the modes do is in
@@ -125,7 +125,7 @@ learned (the parent's tip, how far behind the child is) is kept in
125125 The owner's `followMode` decides what happens when the parent has new commits:
126126
127127 - `tell-me` sends one inbox card per new tip, listing its commits.
128 - `take-updates` opens one pull from the branch `forge/take-updates`
128 - `take-updates` (the default) fast-forwards main when the fork has no commits of its own; otherwise it opens one pull from the branch `forge/take-updates`
129129 (`TAKE_BRANCH`) holding the parent's code, and updates that same pull when
130130 the parent moves again. The pull carries the check like any other.
131131
@@ -142,7 +142,7 @@ leaves. The page and the parent's tree then say it follows nothing since
142142 the date.
143143
144144 A spawned forge's own code is a fork of its parent's (`<org>/forge`, chosen
145 `followMode` `tell-me` or `take-updates`; in `take-updates` the hourly check puts what the parent gained in a pull.
145 `followMode` `take-updates` (default) or `tell-me`; in `take-updates` the hourly check fast-forwards a fork with no commits of its own, and puts what the parent gained in a pull when the fork has its own.
146146 Once an hour, and
147147 on demand, a forge that follows and has not severed fetches its parent's code into
148148 `refs/upstream/*` of its own `<org>/forge` (no branch of the fork moves) and keeps
@@ -150,10 +150,10 @@ on demand, a forge that follows and has not severed fetches its parent's code in
150150 the count shows on the chain page, in the terminal's **Chain** (`c` checks now), and as
151151 `upstream` in the API and MCP payloads. `POST /:owner/forge/following/check` (a writer,
152152 same-origin) checks on demand; a fetch is cut at 60 s and a failure is recorded, not
153 thrown. In `take-updates` the check then puts `refs/upstream/HEAD` (a fast-forward, or a clean merge commit) on the
153 thrown. In `take-updates` the check first fast-forwards main itself when the fork has no commits of its own (compare-and-swap, so a push in between wins). Otherwise it puts `refs/upstream/HEAD` (a fast-forward, or a clean merge commit) on the
154154 fork's `forge/take-updates` branch and opens one pull, "Take updates from <parent>", into main, or updates
155155 that pull when it is open (`spawn/take.ts`); the fork's check runs on it like on any pull and a person merges it.
156 Main is never moved, so nothing deploys by itself. It is recorded as `taken` in `upstream.json` (the chain line
156 Main is moved only by that untouched-fork fast-forward; a fork with its own commits gets the pull and a person merges it. It is recorded as `taken` in `upstream.json` (the chain line
157157 reads "N commits from your parent are in pull #P") and as a `forge.updates_taken` event; a pull closed unmerged
158158 is not reopened until the parent moves again. `forge/take-updates` belongs to the check: a commit a person pushes there is overwritten by the next check that finds something different to put on it (open a branch of your own to build on it). On a conflict nothing changes and one open issue,
159159 "Take updates from <parent>", names the files. In `tell-me`, a new parent tip makes one inbox card for the owner (an issue on the fork assigned to them, "Your parent forge has N new commits", linking the chain page); `toldTip` in `upstream.json` keeps it to one per tip, and `take-updates` or a severed link makes none. Its chain
docs/features/follow-mode.md +1 −1
@@ -3,7 +3,7 @@
33 A fork follows its upstream in one of three modes, said in one line on its page:
44
55 - **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.
6 - **Takes updates from ann/yard** (`take-updates`, the default for a new forge): a fork with no commits of its own fast-forwards by itself; one with commits of its own gets a pull to merge. A plain fork defaults to `tell-me`: its owner is told and chooses.
77 - **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.
88
99 The line shows only on a fork, to anyone who can see the page.
src/core/fork_follow.ts +6 −3
@@ -1,7 +1,7 @@
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, FOLLOW_MODES } from "../spawn/chain.ts";
4 import { FOLLOW_MODES, type FollowMode } from "../spawn/chain.ts";
55
66 /**
77 * A plain fork's modes: the chain's two, and `dont-follow` (#608), which a
@@ -13,9 +13,12 @@ export type ForkFollowMode = (typeof FORK_FOLLOW_MODES)[number];
1313 export const isForkFollowMode = (v: unknown): v is ForkFollowMode =>
1414 typeof v === "string" && (FORK_FOLLOW_MODES as readonly string[]).includes(v);
1515
16 /** A plain fork still proposes by default; only a spawned forge takes updates. */
17 export const FORK_DEFAULT_MODE: FollowMode = "tell-me";
18
1619 export function forkFollowMode(db: Database, repoId: string): ForkFollowMode {
1720 const row = db.query<{ mode: ForkFollowMode }, [string]>("SELECT mode FROM fork_follow WHERE repo_id = ?1").get(repoId);
18 return row?.mode ?? DEFAULT_MODE;
21 return row?.mode ?? FORK_DEFAULT_MODE;
1922 }
2023
2124 export function setForkFollowMode(db: Database, repoId: string, mode: ForkFollowMode): void {
@@ -24,7 +27,7 @@ export function setForkFollowMode(db: Database, repoId: string, mode: ForkFollow
2427 ).run(repoId, mode);
2528 }
2629
27 /** The stored modes of these repos in one query; a repo with none stored is absent (it follows `DEFAULT_MODE`). */
30 /** The stored modes of these repos in one query; a repo with none stored is absent (it follows `FORK_DEFAULT_MODE`). */
2831 export function forkFollowModes(db: Database, repoIds: readonly string[]): Map<string, ForkFollowMode> {
2932 if (repoIds.length === 0) return new Map();
3033 const rows = db
src/spawn/chain.ts +3 −2
@@ -20,7 +20,8 @@ import { err, ok, type Result } from "../kernel/result.ts";
2020
2121 export const FOLLOW_MODES = ["tell-me", "take-updates"] as const;
2222 export type FollowMode = (typeof FOLLOW_MODES)[number];
23 export const DEFAULT_MODE: FollowMode = "tell-me";
23 /** What a new forge follows with; a link on disk with no mode is read as `tell-me`, as it always was. */
24 export const DEFAULT_MODE: FollowMode = "take-updates";
2425
2526 /** One forge in the chain, as recorded when the hop below it was made. */
2627 export interface Hop {
@@ -98,7 +99,7 @@ export async function forgeLinkOf(app: App): Promise<ForgeLink | undefined> {
9899 return {
99100 repo: l["repo"],
100101 upstream: l["upstream"],
101 mode: isMode(l["mode"]) ? l["mode"] : DEFAULT_MODE,
102 mode: isMode(l["mode"]) ? l["mode"] : "tell-me",
102103 tip: typeof l["tip"] === "string" && TIP.test(l["tip"]) ? l["tip"] : "",
103104 chain,
104105 ...(severedAt === undefined ? {} : { severedAt }),
src/spawn/follow_words.ts +6 −5
@@ -2,8 +2,9 @@
22 * Follow words (#7199): what each follow mode does, in one plain line, so
33 * `/forges/new`, the terminal's new-forge screen and MCP `spawn` say the
44 * same thing. They describe `src/spawn/upstream.ts`: `tell-me` sends the
5 * owner an inbox card when this forge's code moves; `take-updates` opens the
6 * one `forge/take-updates` pull (`src/spawn/take.ts`), which a person merges.
5 * owner an inbox card when this forge's code moves; `take-updates` (the
6 * default) fast-forwards a forge the owner has not changed, and otherwise
7 * opens the one `forge/take-updates` pull (`src/spawn/take.ts`), which a person merges.
78 */
89
910 import type { FollowMode } from "./chain.ts";
@@ -15,12 +16,12 @@ export const FOLLOW_LABELS: Readonly<Record<FollowMode, string>> = {
1516
1617 export const FOLLOW_WORDS: Readonly<Record<FollowMode, string>> = {
1718 "tell-me": "tells the owner in their inbox when this forge's code moves.",
18 "take-updates": "opens a pull with the new code, and a person merges it.",
19 "take-updates": "takes new code in by itself; opens a pull once the repository has commits of its own.",
1920 };
2021
2122 /** One line naming both modes, for the terminal's hint. */
2223 export const FOLLOW_LINE =
23 `tell-me (default) ${FOLLOW_WORDS["tell-me"]} take-updates ${FOLLOW_WORDS["take-updates"]}`;
24 `take-updates (default) ${FOLLOW_WORDS["take-updates"]} tell-me ${FOLLOW_WORDS["tell-me"]}`;
2425
2526 /** The same, short enough for an MCP field (60 bytes); docs/MCP.md has the long words. */
26 export const FOLLOW_SHORT = "tell-me: an inbox card; take-updates: a pull a person merges";
27 export const FOLLOW_SHORT = "take-updates: merges itself or a pull; tell-me: inbox card";
src/spawn/plan.ts +2 −2
@@ -70,7 +70,7 @@ export interface SpawnInput {
7070 readonly budgetUsd?: unknown;
7171 /** One line describing a fresh app to start in the child, beside the forked ones; optional. */
7272 readonly fresh?: unknown;
73 /** How the child follows the forge's code: `tell-me` (default) or `take-updates`. */
73 /** How the child follows the forge's code: `take-updates` (default) or `tell-me`. */
7474 readonly followMode?: unknown;
7575 /** How big the child is: `small`, `medium` or `large`; optional. */
7676 readonly size?: unknown;
@@ -116,7 +116,7 @@ export interface SpawnPlan {
116116 readonly forWhom?: string;
117117 /** A fresh app, present only when asked for. */
118118 readonly fresh?: PlannedFresh;
119 /** How the child follows its parent's forge code; absent means `tell-me`. */
119 /** How the child follows its parent's forge code; absent means `take-updates`. */
120120 readonly followMode?: FollowMode;
121121 /** The child's size, present only when chosen. */
122122 readonly size?: Size;
src/spawn/take.ts +27 −4
@@ -1,11 +1,12 @@
11 /**
22 * Taking the parent's code, for a forge that follows in `take-updates`
33 * mode. Once the hourly fetch has put the parent's tip at
4 * `refs/upstream/HEAD`, this puts a fast-forward, or a merge commit when git
5 * merges the two without conflict, on one branch, `forge/take-updates`, and
4 * `refs/upstream/HEAD`, a fork the owner has not changed (its default branch
5 * is an ancestor of the parent's) is fast-forwarded in place. Otherwise this
6 * puts a merge commit, when git merges the two without conflict, on one branch, `forge/take-updates`, and
67 * opens one pull from it into the default branch (or updates the one already
7 * open). The fork's check runs on it like on any pull; a person merges it.
8 * It never moves the default branch, and production follows that branch.
8 * open). The fork's check runs on it like on any pull.
9 * That pull never moves the default branch; a person merges it.
910 * A conflict changes nothing; it opens (or finds) one issue naming the files.
1011 */
1112
@@ -26,6 +27,7 @@ import type { ForgeLink } from "./chain.ts";
2627 /** What the last check did with the parent's new commits; absent when it did nothing. */
2728 export type Taken =
2829 | { readonly kind: "pull"; readonly number: number; readonly commits: number }
30 | { readonly kind: "forwarded"; readonly commits: number }
2931 | { readonly kind: "conflict"; readonly files: readonly string[]; readonly issue: number | null };
3032
3133 export const takeTitle = (upstream: string): string => `Take updates from ${upstream}`;
@@ -120,6 +122,27 @@ export async function takeFromParent(app: App, link: ForgeLink): Promise<Taken |
120122 const count = await runGit(["rev-list", "--count", `${head}..${up}`], { cwd: dir });
121123 const commits = count.ok ? Number(count.value.trim()) || 0 : 0;
122124
125 // A forge that takes updates and whose owner has not changed the repository:
126 // the parent's code is a pure fast-forward, with nothing of theirs to review it against.
127 // A forge that only tells (and is pressed to take) still gets the pull.
128 if (base === head && link.mode === "take-updates") {
129 const ref = `refs/heads/${branch}`;
130 // Compare-and-swap: a push to the branch since `head` was read wins, and the next check looks again.
131 if (!(await runGit(["update-ref", ref, up, head], { cwd: dir })).ok) return undefined;
132 app.bus.append({
133 kind: "push",
134 repoId: repo.id,
135 actorId: owner.id,
136 payload: { updates: [{ ref, oldOid: head, newOid: up }], followed: link.upstream },
137 });
138 app.bus.append({
139 kind: "forge.updates_taken",
140 repoId: repo.id,
141 payload: { repo: link.repo, upstream: link.upstream, from: head, to: up, commits, pull: null },
142 });
143 return { kind: "forwarded", commits };
144 }
145
123146 const had = await resolveOid(dir, `refs/heads/${TAKE_BRANCH}`);
124147 let to = up;
125148 if (base !== head) {
src/spawn/upstream.ts +4
@@ -68,6 +68,8 @@ export async function upstreamState(app: App): Promise<UpstreamState | undefined
6868 function readTaken(v: unknown): { taken?: Taken } {
6969 if (typeof v !== "object" || v === null) return {};
7070 const t = v as Record<string, unknown>;
71 if (t["kind"] === "forwarded" && typeof t["commits"] === "number")
72 return { taken: { kind: "forwarded", commits: t["commits"] } };
7173 if (t["kind"] === "pull" && typeof t["number"] === "number" && typeof t["commits"] === "number")
7274 return { taken: { kind: "pull", number: t["number"], commits: t["commits"] } };
7375 if (t["kind"] === "conflict" && Array.isArray(t["files"]))
@@ -221,6 +223,8 @@ export function upstreamWords(s: UpstreamState | undefined, now = Date.now()): s
221223 const mins = Math.max(0, Math.floor((now - s.checkedAt) / 60_000));
222224 const when = mins < 1 ? "just now" : mins < 60 ? `${mins} min ago` : `${Math.floor(mins / 60)} h ago`;
223225 if (s.error !== "") return `could not check your parent: ${s.error} (${when})`;
226 if (s.taken?.kind === "forwarded")
227 return `${s.taken.commits} commit${s.taken.commits === 1 ? "" : "s"} from your parent ${s.taken.commits === 1 ? "was" : "were"} taken in (checked ${when})`;
224228 if (s.taken?.kind === "pull")
225229 return `${s.taken.commits} commit${s.taken.commits === 1 ? "" : "s"} from your parent ${s.taken.commits === 1 ? "is" : "are"} in pull #${s.taken.number} (checked ${when})`;
226230 if (s.taken?.kind === "conflict")
src/tui/screens/newforge.ts +2 −2
@@ -82,7 +82,7 @@ export function newForgeScreen(f: Session, ctx: Ctx, initial: Record<string, str
8282 name: val("name"),
8383 apps,
8484 ...(val("fresh") === "" ? {} : { fresh: val("fresh") }),
85 followMode: val("followMode") === "" ? "tell-me" : val("followMode"),
85 followMode: val("followMode") === "" ? "take-updates" : val("followMode"),
8686 ...(val("for") === "" ? {} : { for: val("for") }),
8787 ...(val("direction") === "" ? {} : { direction: val("direction") }),
8888 ...(val("modelKey") === "" ? {} : { modelKey: val("modelKey") }),
@@ -136,7 +136,7 @@ export function newForgeScreen(f: Session, ctx: Ctx, initial: Record<string, str
136136 const budget = (body["plan"] as { budgetUsd?: number | null } | undefined)?.budgetUsd ?? null;
137137 const size = (body["plan"] as { size?: string } | undefined)?.size;
138138 const template = (body["plan"] as { template?: string } | undefined)?.template;
139 const mode = val("followMode") === "" ? "tell-me" : val("followMode");
139 const mode = val("followMode") === "" ? "take-updates" : val("followMode");
140140 plan = [
141141 `A forge at ${childUrlOf(f.app, val("name"))}.`,
142142 `It follows this forge's code as ${mode}${isFollowMode(mode) ? `: it ${FOLLOW_WORDS[mode]}` : "."}`,
src/web/pages/new-forge.ts +8 −8
@@ -59,7 +59,7 @@ const EMPTY: Values = {
5959 apps: [],
6060 crew: [],
6161 fresh: "",
62 followMode: "tell-me",
62 followMode: "take-updates",
6363 keepAccess: false,
6464 budgetUsd: "",
6565 modelEndpoint: "",
@@ -248,22 +248,22 @@ function formView(
248248 <input
249249 type="radio"
250250 name="followMode"
251 value="tell-me"
252 ${values.followMode !== "take-updates" ? html`checked` : null}
251 value="take-updates"
252 ${values.followMode !== "tell-me" ? html`checked` : null}
253253 />
254254 <span
255 >${FOLLOW_LABELS["tell-me"]} <span class="muted small">${FOLLOW_WORDS["tell-me"]}</span></span
255 >${FOLLOW_LABELS["take-updates"]} <span class="muted small">${FOLLOW_WORDS["take-updates"]}</span></span
256256 >
257257 </label>
258258 <label class="check">
259259 <input
260260 type="radio"
261261 name="followMode"
262 value="take-updates"
263 ${values.followMode === "take-updates" ? html`checked` : null}
262 value="tell-me"
263 ${values.followMode === "tell-me" ? html`checked` : null}
264264 />
265265 <span
266 >${FOLLOW_LABELS["take-updates"]} <span class="muted small">${FOLLOW_WORDS["take-updates"]}</span></span
266 >${FOLLOW_LABELS["tell-me"]} <span class="muted small">${FOLLOW_WORDS["tell-me"]}</span></span
267267 >
268268 </label>
269269 ${errors["followMode"]
@@ -485,7 +485,7 @@ export async function newForgeSubmit(ctx: Ctx): Promise<Response> {
485485 apps: all(form, "apps"),
486486 crew: all(form, "crew"),
487487 fresh: field(form, "fresh").trim(),
488 followMode: field(form, "followMode").trim() || "tell-me",
488 followMode: field(form, "followMode").trim() || "take-updates",
489489 keepAccess: asBool(form, "keepAccess"),
490490 budgetUsd: field(form, "budgetUsd").trim(),
491491 modelEndpoint: field(form, "modelEndpoint").trim(),
test/follow-e2e.test.ts +4 −3
@@ -87,8 +87,6 @@ describe("following across forges, end to end", () => {
8787 });
8888
8989 test("the whole path", async () => {
90 const start = await main();
91
9290 // 1-2. Two parent commits: behind 2, one card listing both.
9391 await theirs("p1.txt", "1\n", "parent one");
9492 await theirs("p2.txt", "2\n", "parent two");
@@ -101,7 +99,10 @@ describe("following across forges, end to end", () => {
10199 expect(cards[0]!.body).toContain("parent two");
102100 expect(takes()).toHaveLength(0);
103101
104 // 3. take-updates: the next check opens one pull listing both; main stays.
102 // 3. take-updates, the fork having a commit of its own: the next check opens one pull listing both; main stays.
103 const own = await commitToBranch(app, kid, repo, { message: "own", files: [{ path: "own.txt", content: "own\n" }] });
104 expect(own.ok).toBe(true);
105 const start = await main();
105106 expect((await setChainMode(app, "take-updates")).ok).toBe(true);
106107 const took = await checkUpstream(app, { remote: parent });
107108 expect(took?.taken).toMatchObject({ kind: "pull", commits: 2 });
test/new-forge.test.ts +7 −7
@@ -296,15 +296,15 @@ describe("+ New forge", () => {
296296 expect(await again.text()).not.toMatch(/claim#[A-Za-z0-9_-]{43}/);
297297 });
298298
299 test("the form offers how the child follows the forge's code, tell-me by default, and take-updates is recorded", async () => {
299 test("the form offers how the child follows the forge's code, take-updates by default, and tell-me is recorded", async () => {
300300 const html = (await (await get(admin, "/forges/new")).text()).replace(/\s+/g, " ");
301 expect(html).toMatch(/name="followMode" value="tell-me" checked/);
302 expect(html).toContain('value="take-updates"');
301 expect(html).toMatch(/name="followMode" value="take-updates" checked/);
302 expect(html).toContain('value="tell-me"');
303303 (app.config as { forgeRepo?: string }).forgeRepo = "alice/bakery";
304304 try {
305305 const res = await post(admin, [
306306 ["name", "follower"],
307 ["followMode", "take-updates"],
307 ["followMode", "tell-me"],
308308 ["do", "spawn"],
309309 ]);
310310 expect(res.status).toBe(201);
@@ -314,7 +314,7 @@ describe("+ New forge", () => {
314314 ),
315315 );
316316 const mine = docs.find((d) => d?.name === "follower");
317 expect(mine?.forgeCode?.mode).toBe("take-updates");
317 expect(mine?.forgeCode?.mode).toBe("tell-me");
318318 const bad = await post(admin, [
319319 ["name", "wrongmode"],
320320 ["followMode", "yolo"],
@@ -330,7 +330,7 @@ describe("+ New forge", () => {
330330 test("the page promises no update that does not happen", async () => {
331331 const html = (await (await get(admin, "/forges/new")).text()).replace(/\s+/g, " ");
332332 expect(html).toContain("Tell me <span class=\"muted small\">tells the owner in their inbox when this forge&#39;s code moves.</span>");
333 expect(html).toContain("Take updates <span class=\"muted small\">opens a pull with the new code, and a person merges it.</span>");
333 expect(html).toContain("Take updates <span class=\"muted small\">takes new code in by itself; opens a pull once the repository has commits of its own.</span>");
334334 expect(html).toContain("<legend>How it follows this forge's code</legend>");
335335 expect(html).not.toMatch(/recorded for now|nothing acts on it yet/i);
336336 expect(html).not.toContain("when this forge changes");
@@ -339,7 +339,7 @@ describe("+ New forge", () => {
339339
340340 test("MCP spawn says what each follow mode does, as the page does", () => {
341341 const followMode = (spawnTool.inputSchema.properties as Record<string, { description?: string }>)["followMode"];
342 expect(followMode?.description).toBe("tell-me: an inbox card; take-updates: a pull a person merges");
342 expect(followMode?.description).toBe("take-updates: merges itself or a pull; tell-me: inbox card");
343343 });
344344
345345 test("a forge at its depth limit shows no door, and its page says why", async () => {
test/spawn-anon.test.ts +1 −1
@@ -163,7 +163,7 @@ describe("anonymous spawn", () => {
163163 join(childDirOf(app, body.bodyId), "parent.json"),
164164 ).json()) as any;
165165 expect(parent.forgeCode).toEqual(
166 expect.objectContaining({ repo: "kid/forge", upstream: "alice/forge-code", mode: "tell-me" }),
166 expect.objectContaining({ repo: "kid/forge", upstream: "alice/forge-code", mode: "take-updates" }),
167167 );
168168 } finally {
169169 child.close();
test/spawn-chain-mode.test.ts +5 −5
@@ -126,20 +126,20 @@ describe("choosing how a forge follows its parent", () => {
126126 });
127127
128128 test("the owner changes it from the page, the API and MCP; the page shows it", async () => {
129 expect((await forgeLinkOf(child))?.mode).toBe("tell-me");
129 expect((await forgeLinkOf(child))?.mode).toBe("take-updates");
130130 const page = async () => (await (await fetch(`${servers.url}/kid/forge/following`, { headers: { cookie } })).text()).replace(/\s+/g, " ");
131131 const html = await page();
132132 expect(html).toContain('action="/kid/forge/following/mode"');
133 expect(html).toMatch(/value="tell-me" checked/);
133 expect(html).toMatch(/value="take-updates" checked/);
134134 expect(html).not.toContain("<script>");
135135 // Rows and headings, not boxes.
136136 expect(html).not.toContain('class="card');
137137 expect(html).toContain('<ul class="list" aria-label="Forges, root first">');
138138 expect(html).toContain("<h2");
139139
140 expect((await form(cookie, "take-updates")).status).toBe(303);
141 expect((await forgeLinkOf(child))?.mode).toBe("take-updates");
142 expect(await page()).toMatch(/value="take-updates" checked/);
140 expect((await form(cookie, "tell-me")).status).toBe(303);
141 expect((await forgeLinkOf(child))?.mode).toBe("tell-me");
142 expect(await page()).toMatch(/value="tell-me" checked/);
143143
144144 const res = await patch(ownerTok, { mode: "tell-me" });
145145 expect(res.status).toBe(200);
test/spawn-chain.test.ts +8 −6
@@ -71,7 +71,7 @@ describe("the chain of forges", () => {
7171 });
7272 if (!c.ok) throw new Error("commit");
7373
74 const a = await spawn(root, alice.value, "kid", "root.test", "take-updates");
74 const a = await spawn(root, alice.value, "kid", "root.test");
7575 if (!a.ok) throw new Error(JSON.stringify(a.error));
7676 child = createApp({ dataDir: childDirOf(root, a.value.bodyId), url: "https://kid.test", port: 0 });
7777 // The child's owner changes their forge all they want.
@@ -84,7 +84,7 @@ describe("the chain of forges", () => {
8484 if (!own.ok) throw new Error("own commit");
8585
8686 const founder = getUserByName(child.db, "alice")!;
87 const b = await spawn(child, founder, "tot", "kid.test");
87 const b = await spawn(child, founder, "tot", "kid.test", "tell-me");
8888 if (!b.ok) throw new Error(JSON.stringify(b.error));
8989 grand = createApp({ dataDir: childDirOf(child, b.value.bodyId), port: 0 });
9090 servers = await serve(grand);
@@ -100,13 +100,15 @@ describe("the chain of forges", () => {
100100 await rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
101101 });
102102
103 test("followMode is validated and defaults to tell-me", () => {
103 test("followMode is validated and defaults to take-updates", () => {
104104 const base = { name: "x", org: "x", apps: [], carryBriefs: false, carrySubscriptions: false };
105105 expect(planSpawn(base, source).ok).toBe(true);
106106 const bad = planSpawn({ ...base, followMode: "yolo" }, source);
107107 expect(bad.ok).toBe(false);
108 const grandLink = planSpawn({ ...base, followMode: "tell-me" }, source);
109 expect(grandLink.ok && grandLink.value.followMode).toBeUndefined();
108 const taking = planSpawn({ ...base, followMode: "take-updates" }, source);
109 expect(taking.ok && taking.value.followMode).toBeUndefined();
110 const telling = planSpawn({ ...base, followMode: "tell-me" }, source);
111 expect(telling.ok && telling.value.followMode).toBe("tell-me");
110112 });
111113
112114 test("the child carries a fork of the parent's forge code in the chosen mode", async () => {
@@ -118,7 +120,7 @@ describe("the chain of forges", () => {
118120 expect(getRepo(child.db, "kid", "forge")).toBeDefined();
119121 });
120122
121 test("the grandchild's fork names the child's forge, mode tell-me by default", async () => {
123 test("the grandchild's fork names the child's forge, mode as chosen", async () => {
122124 const link = await forgeLinkOf(grand);
123125 expect(link?.upstream).toBe("kid/forge");
124126 expect(link?.mode).toBe("tell-me");
test/spawn-take-body.test.ts +4 −1
@@ -59,6 +59,9 @@ describe("the take pull's description", () => {
5959 work = join(dir, "work");
6060 await git(dir, "clone", "-q", "--bare", repoDir(app.reposRoot, "kid", "forge"), parent);
6161 await git(dir, "clone", "-q", parent, work);
62 // The fork has a commit of its own, so updates arrive as a pull.
63 const own = await commitToBranch(app, u.value, repo, { message: "own", files: [{ path: "own.txt", content: "own\n" }] });
64 if (!own.ok) throw new Error(JSON.stringify(own.error));
6265 await Bun.write(
6366 join(app.config.dataDir, "parent.json"),
6467 JSON.stringify({
@@ -98,7 +101,7 @@ describe("the take pull's description", () => {
98101 await theirs("c6", "subject 6");
99102 await take();
100103 expect(body()).toBe("My own words.");
101 expect(open()[0]!.headOid).toBe(await git(parent, "rev-parse", "refs/heads/main"));
104 expect((await git(repoDir(app.reposRoot, "kid", "forge"), "rev-list", "--parents", "-n1", open()[0]!.headOid)).split(" ")).toContain(await git(parent, "rev-parse", "refs/heads/main"));
102105 });
103106
104107 test("hostile subjects stay inert in a fresh pull's body", async () => {
test/spawn-take.test.ts +36 −9
@@ -101,7 +101,23 @@ describe("taking the parent's code", () => {
101101
102102 const pulls = () => listPulls(app.db, repo.id, { state: "open" }).filter((p) => p.headBranch === "forge/take-updates");
103103
104 test("a fast-forward opens one pull and leaves main alone", async () => {
104 test("an untouched fork fast-forwards main within one check, and opens no pull", async () => {
105 const s = await checkUpstream(app, { remote: parent });
106 const tip = await git(parent, "rev-parse", "refs/heads/main");
107 expect(s?.taken).toEqual({ kind: "forwarded", commits: 1 });
108 expect(await main()).toBe(tip);
109 expect(pulls()).toHaveLength(0);
110 expect(s?.behind).toBe(0);
111 expect(taken()).toHaveLength(1);
112 expect(listEvents(app.db, { repoId: repo.id, kind: "push" }).some((e) => JSON.stringify(e.payload).includes(tip))).toBe(true);
113 expect(upstreamWords(s, s!.checkedAt)).toContain("1 commit from your parent was taken in");
114 await checkUpstream(app, { remote: parent });
115 expect(taken()).toHaveLength(1); // nothing new: nothing said again
116 });
117
118 test("a fork with commits of its own gets a pull, never an automatic merge", async () => {
119 await mine("own.txt", "own\n");
120 await theirs("p1.txt", "1\n");
105121 const before = await main();
106122 const s = await checkUpstream(app, { remote: parent });
107123 expect(pulls()).toHaveLength(1);
@@ -110,27 +126,23 @@ describe("taking the parent's code", () => {
110126 expect(p.authorId).toBe(kid.id);
111127 expect(s?.taken).toEqual({ kind: "pull", number: p.number, commits: 1 });
112128 expect(await main()).toBe(before);
113 expect(await git(child, "rev-parse", "refs/heads/forge/take-updates")).toBe(await git(parent, "rev-parse", "refs/heads/main"));
114 expect(taken()).toHaveLength(1);
129 expect(taken()).toHaveLength(2);
115130 expect(upstreamWords(s, s!.checkedAt)).toContain(`1 commit from your parent is in pull #${p.number}`);
116131 });
117132
118133 test("a second fetch with one more commit updates the same pull", async () => {
119134 const before = await main();
120135 const first = pulls()[0]!;
121 await theirs("p1.txt", "1\n");
136 await theirs("p1b.txt", "1b\n");
122137 const s = await checkUpstream(app, { remote: parent });
123138 expect(pulls()).toHaveLength(1);
124139 expect(pulls()[0]!.number).toBe(first.number);
125 expect(pulls()[0]!.headOid).toBe(await git(parent, "rev-parse", "refs/heads/main"));
126140 expect(s?.taken).toEqual({ kind: "pull", number: first.number, commits: 2 });
127141 expect(await main()).toBe(before);
128 expect(taken()).toHaveLength(2);
142 expect(taken()).toHaveLength(3);
129143 const pushed = listEvents(app.db, { repoId: repo.id, kind: "pull.pushed" });
130144 expect(pushed).toHaveLength(1);
131145 expect(JSON.stringify(pushed[0]!.payload)).toContain(first.number.toString());
132 await checkUpstream(app, { remote: parent });
133 expect(taken()).toHaveLength(2); // nothing new: nothing said again
134146 });
135147
136148 test("a clean merge goes on the branch, not on main", async () => {
@@ -198,6 +210,21 @@ describe("taking the parent's code", () => {
198210 const issues = listIssues(app.db, repo.id, { sealed: true }).filter((i) => i.title === "Take updates from root/forge");
199211 expect(issues).toHaveLength(1);
200212 expect(issues[0]!.body).toContain("a.txt");
201 expect(taken()).toHaveLength(4);
213 expect(taken()).toHaveLength(5);
214 });
215
216 test("a severed fork gets nothing", async () => {
217 await Bun.write(
218 join(app.config.dataDir, "parent.json"),
219 JSON.stringify({
220 upstream: "https://parent.test",
221 forgeCode: { repo: "kid/forge", upstream: "root/forge", mode: "take-updates", tip: "a".repeat(40), chain: [], severedAt: new Date().toISOString() },
222 }),
223 );
224 await theirs("p9.txt", "9\n");
225 const before = await main();
226 expect(await checkUpstream(app, { remote: parent })).toBeUndefined();
227 expect(await main()).toBe(before);
228 expect(taken()).toHaveLength(5);
202229 });
203230 });
test/tui-newforge.test.ts +6 −6
@@ -87,11 +87,11 @@ describe("the new forge screen", () => {
8787 test("the screen and its plan promise no update that does not happen", () =>
8888 withScreen(admin, async ({ tui, terminal }) => {
8989 const form = tui.screen.toText();
90 expect(form).toContain("tell-me (default) tells the owner in their inbox");
90 expect(form).toContain("take-updates (default) takes new code in by itself");
9191 terminal.feed("kai\x10");
9292 await settleApp(tui);
9393 const text = form + tui.screen.toText();
94 expect(tui.screen.toText()).toContain("It follows this forge's code as tell-me: it tells the owner in their inbox");
94 expect(tui.screen.toText()).toContain("It follows this forge's code as take-updates: it takes new code in by itself");
9595 expect(text).not.toMatch(/recorded for now/i);
9696 expect(text).not.toMatch(/keeps? (itself )?up to date|updates itself|automatically/i);
9797 }));
@@ -173,13 +173,13 @@ describe("the new forge screen", () => {
173173 expect(tui.screen.toText()).toMatch(/https:\/\/zed--forge\.test\/claim#/);
174174 }));
175175
176 test("the follow mode is a field: take-updates reaches parent.json", () =>
176 test("the follow mode is a field: tell-me reaches parent.json", () =>
177177 withScreen(admin, async ({ tui, terminal }) => {
178178 (app.config as { forgeRepo?: string }).forgeRepo = "alice/bakery";
179179 try {
180 terminal.feed("tuifollow" + "\t".repeat(8) + "take-updates\x10");
180 terminal.feed("tuifollow" + "\t".repeat(8) + "tell-me\x10");
181181 await settleApp(tui);
182 expect(tui.screen.toText()).toContain("take-updates");
182 expect(tui.screen.toText()).toContain("tell-me");
183183 terminal.feed("\r");
184184 await settleApp(tui);
185185 const docs = await Promise.all(
@@ -187,7 +187,7 @@ describe("the new forge screen", () => {
187187 Bun.file(join(dir, "children", d, "parent.json")).json().catch(() => undefined),
188188 ),
189189 );
190 expect(docs.find((d) => d?.name === "tuifollow")?.forgeCode?.mode).toBe("take-updates");
190 expect(docs.find((d) => d?.name === "tuifollow")?.forgeCode?.mode).toBe("tell-me");
191191 } finally {
192192 delete (app.config as { forgeRepo?: string }).forgeRepo;
193193 }

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs