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

A plain fork offers how it follows its parent (tell me / take updates) on its own page #7827

Merged opened this

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

Merged

scout/forge:scout/7796-a-plain-fork-offers-how-it-follows-its-p → main 3 commits fork 3 threads · 3 unresolved

  • Merged. scout/7796-a-plain-fork-offers-how-it-follows-its-p was merged into main as 0fc6d8d.

Review

Sign in to review this change.
12 files changed +267 −38
README.md +2
@@ -309,6 +309,8 @@ the words it takes, on the page, in the terminal and in a `note` field of
309309
310310 The `forge` command keeps a local copy of the parts you work on: **[docs/ON-YOUR-MACHINE.md](docs/ON-YOUR-MACHINE.md)**.
311311
312 A fork chooses how it follows its parent: **tell me** (the default: a fork with nothing of its own is fast-forwarded, one with work of its own is left diverged and the crew is told) or **take updates** (when the parent moves, a diverged fork is also merged for its owner whenever git merges it cleanly; a conflict is still told, never forced). On the web, the fork's owner picks it in the Following card on the fork's home page (`POST /:owner/:name/following/mode`); in the SSH terminal, open the fork's Forks screen and press `m` to flip it; MCP's follow-mode tool and the JSON API reach the same `setFollowMode` action. The choice lives in `fork_follow` (`src/core/fork_follow.ts`).
313
312314 ## Develop
313315
314316 Tests, checks, the gate, lint, types and how a change reaches main: **[docs/DEVELOP.md](docs/DEVELOP.md)**.
src/actions/follow.ts +17 −3
@@ -15,6 +15,7 @@ import { err, ok, type Result } from "../kernel/result.ts";
1515 import { chainOfRepo, forgeLinkOf, FOLLOW_MODES, isFollowMode, setChainMode, severChain, type FollowMode } from "../spawn/chain.ts";
1616 import { FAMILY_WORDS } from "./family_words.ts";
1717 import { FOLLOWS_NOTHING } from "./follow_words.ts";
18 import { forkFollowMode, setForkFollowMode } from "../core/fork_follow.ts";
1819 import { requireUser } from "./access.ts";
1920 import { takeFromParent, type Taken } from "../spawn/take.ts";
2021 import { takeUpdates, type ForkSync, type TakeUpdatesError } from "./forks.ts";
@@ -98,6 +99,14 @@ export function takeForgeWords(r: TakeForgeResult): { tone?: "danger"; message:
9899 return { message: "Already up to date." };
99100 }
100101
102 /** 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> {
104 const link = await forgeLinkOf(app);
105 return link !== undefined && link.repo === `${repo.ownerName}/${repo.name}`
106 ? link.mode
107 : forkFollowMode(app.db, repo.id);
108 }
109
101110 export type FollowModeError = SeverError | "invalid_mode" | "severed";
102111
103112 /** The one answer every door gives a refused mode change, with the status an HTTP door sends. */
@@ -118,11 +127,16 @@ export async function setFollowMode(
118127 ): Promise<Result<FollowMode, FollowModeError>> {
119128 const who = requireUser(actor);
120129 if (!who.ok) return err("unauthorized");
121 if ((await chainOfRepo(app, repo)) === undefined) return err("not_following");
130 const carries = (await chainOfRepo(app, repo)) !== undefined;
131 if (!carries && repo.forkParentId === null) return err("not_following");
122132 if (!canAdmin(app.db, repo, who.value) || !canWrite(app.db, repo, who.value)) return err("forbidden");
123133 if (!isFollowMode(mode)) return err("invalid_mode");
124 const set = await setChainMode(app, mode);
125 if (!set.ok) return err(set.error);
134 if (carries) {
135 const set = await setChainMode(app, mode);
136 if (!set.ok) return err(set.error);
137 } else {
138 setForkFollowMode(app.db, repo.id, mode);
139 }
126140 app.bus.append({
127141 kind: "forge.follow_mode",
128142 repoId: repo.id,
src/app.ts +11 −1
@@ -4,6 +4,8 @@
44 * side-effect-contained, so tests boot real forges by the dozen.
55 */
66
7 import { commitIdentity } from "./core/commit_identity.ts";
8 import { getUserById } from "./core/users.ts";
79 import type { MailConfig, Mailer } from "./kernel/mail.ts";
810 import type { Database } from "bun:sqlite";
911 import type { Decider } from "./crew/decide.ts";
@@ -239,7 +241,15 @@ export function createApp(config: ForgeConfig): App {
239241 bus.subscribe((event) => enqueueForEvent(db, event));
240242 // What a push moves besides its own branch: the pulls it heads elsewhere,
241243 // and the forks whose default branch is still a copy of this one's.
242 follow({ db, reposRoot, bus });
244 follow({
245 db,
246 reposRoot,
247 bus,
248 authorOf: (userId) => {
249 const who = getUserById(db, userId);
250 return who === undefined ? undefined : { id: who.id, ...commitIdentity(config, who) };
251 },
252 });
243253
244254 const owner = id();
245255 const errors = forgeErrorLedger(owner);
src/core/following.ts +11 −1
@@ -40,6 +40,7 @@ import {
4040 import { runGit } from "../git/env.ts";
4141 import { readTipOrAbsent } from "../git/tip.ts";
4242 import { repoDir } from "../git/repos.ts";
43 import { forkFollowMode } from "./fork_follow.ts";
4344 import { createLog } from "../kernel/log.ts";
4445 import { err, ok, type Result } from "../kernel/result.ts";
4546
@@ -51,6 +52,8 @@ export interface Following {
5152 readonly db: Database;
5253 readonly reposRoot: string;
5354 readonly bus: EventBus;
55 /** Who a merge the forge makes for a fork's owner is written as; absent, a fork never merges by itself. */
56 readonly authorOf?: (userId: string) => (MergeIdentity & { readonly id: string }) | undefined;
5457 }
5558
5659 interface Moved {
@@ -105,12 +108,19 @@ export function follow(forge: Following): () => void {
105108 if (moved.some((m) => m.ref === `refs/heads/${repo.defaultBranch}`))
106109 for (const fork of listForks(forge.db, repo.id))
107110 fastForwardFork(forge, fork, repo, actor)
108 .then((followed) => {
111 .then(async (followed) => {
109112 // A fork with work of its own cannot be moved; it is told the
110113 // parent moved, and whoever listens (the crew, a card) takes it
111114 // from there. The fast-forward is silent because it changed
112115 // nothing the fork's owner would want to know about.
113116 if (followed.ok) return;
117 // A fork that chose to take updates merges what git merges
118 // cleanly; a conflict is told like any other divergence.
119 const author = forge.authorOf?.(fork.ownerId);
120 if (author !== undefined && forkFollowMode(forge.db, fork.id) === "take-updates") {
121 const took = await takeUpdates(forge, fork, repo, author);
122 if (took.ok) return;
123 }
114124 forge.bus.append({
115125 kind: "fork.diverged",
116126 repoId: fork.id,
src/core/fork_follow.ts added +15
@@ -0,0 +1,15 @@
1 /** How a plain fork follows its parent: one row per fork that chose; none means the default. */
2
3 import type { Database } from "bun:sqlite";
4 import { DEFAULT_MODE, type FollowMode } from "../spawn/chain.ts";
5
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);

Nothing consumes this value outside the page: the chosen mode changes no behaviour. Wire it into the update path or do not offer the choice.

8 return row?.mode ?? DEFAULT_MODE;
9 }
10
11 export function setForkFollowMode(db: Database, repoId: string, mode: FollowMode): void {
12 db.query(
13 "INSERT INTO fork_follow (repo_id, mode) VALUES (?1, ?2) ON CONFLICT(repo_id) DO UPDATE SET mode = excluded.mode",
14 ).run(repoId, mode);
15 }
src/core/migration-pins/20261101130000-fork-follow-mode.pin added +1
@@ -0,0 +1,1 @@
1 9adccb59a46e3330e11fc227f7f3de5a4ace109b11012fc0ef232c630758793c
src/core/migrations/20261101130000-fork-follow-mode.ts added +8
@@ -0,0 +1,8 @@
1 // How a plain fork follows its parent (#7796): tell me, or take updates. A
2 // fork with no row follows in the default mode.
3 export default `
4 CREATE TABLE IF NOT EXISTS fork_follow (
5 repo_id TEXT PRIMARY KEY REFERENCES repo(id) ON DELETE CASCADE,
6 mode TEXT NOT NULL CHECK (mode IN ('tell-me','take-updates'))
7 );
8 `;
src/tui/screens/forks.ts +27 −5
@@ -18,9 +18,10 @@ import {
1818 type ForkRanking,
1919 type ForkRelation,
2020 } from "../../actions/forks.ts";
21 import { convergeWords, takeOrTell, takeWords } from "../../actions/follow.ts";
21 import { FOLLOW_MODE_REFUSALS, convergeWords, followModeOf, setFollowMode, takeOrTell, takeWords } from "../../actions/follow.ts";
2222 import { convergeState, type Converge } from "../../crew/converge.ts";
23 import { canRead, canWrite, forkTree, listForks, type ForkNode, type Repo } from "../../core/repos.ts";
23 import type { FollowMode } from "../../spawn/chain.ts";
24 import { canAdmin, canRead, canWrite, forkTree, listForks, type ForkNode, type Repo } from "../../core/repos.ts";
2425 import type { Ctx, Screen } from "../app/app.ts";
2526 import { binding } from "../app/keys.ts";
2627 import { style } from "../term/style.ts";
@@ -77,6 +78,7 @@ export function forkRows(
7778 export function followingRows(
7879 relation: ForkRelation | undefined,
7980 converge: Converge | undefined,
81 mode?: FollowMode,
8082 ): ForkRow[] {
8183 if (relation === undefined) return [];
8284 const { parent } = relation;
@@ -91,7 +93,12 @@ export function followingRows(
9193 ? `diverged · ${counts}${relation.clean ? " · merges cleanly" : ""}`
9294 : `${relation.state} · ${counts}`;
9395 return [
94 { repo: parent, name: `follows ${name}`, words, heading: "" },
96 {
97 repo: parent,
98 name: `follows ${name}`,
99 words: mode === undefined ? words : `${words} · ${mode === "take-updates" ? "takes updates" : "tells you"}`,
100 heading: "",
101 },
95102 ...(converge === undefined
96103 ? []
97104 : [
@@ -132,6 +139,7 @@ export function forksScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
132139 let following: ForkRow[] = [];
133140 let relation: ForkRelation | undefined;
134141 let offer: string | undefined;
142 let mode: FollowMode | undefined;
135143 const state = new ListState();
136144 const rows = () => [...following, ...(ranked === undefined ? [] : forkRows(ranked, tree))];
137145 const read = async (): Promise<void> => {
@@ -144,7 +152,8 @@ export function forksScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
144152 relation !== undefined && relation.state === "diverged" && !relation.clean
145153 ? convergeState(f.app, repo, relation.parent)
146154 : undefined;
147 following = followingRows(relation, converge);
155 mode = relation === undefined ? undefined : await followModeOf(f.app, repo);
156 following = followingRows(relation, converge, mode);
148157 ctx.invalidate();
149158 };
150159 const take = (): void => {
@@ -165,7 +174,7 @@ export function forksScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
165174 // The converge issue just opened or found is the line Enter opens.
166175 const e = took.ok ? undefined : took.error;
167176 if (typeof e === "object" && e.kind === "conflict" && "converge" in e && relation !== undefined) {
168 following = followingRows(relation, e.converge);
177 following = followingRows(relation, e.converge, mode);
169178 state.index = following.length - 1;
170179 }
171180 ctx.toast(words.message, words.tone === "danger" ? "danger" : "default");
@@ -175,6 +184,8 @@ export function forksScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
175184 }),
176185 );
177186 };
187 const canSetMode = (): boolean =>
188 relation !== undefined && f.user?.kind === "user" && canAdmin(f.db, repo, f.user) && canWrite(f.db, repo, f.user);
178189 const self: Screen = {
179190 title: "Forks",
180191 get keys() {
@@ -182,6 +193,7 @@ export function forksScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
182193 binding(["j", "k"], "move"),
183194 binding(["enter"], "open"),
184195 ...(offer === undefined ? [] : [binding(["t"], "take updates")]),
196 ...(canSetMode() ? [binding(["m"], "follow mode")] : []),
185197 binding(["esc"], "close"),
186198 ];
187199 },
@@ -232,6 +244,16 @@ export function forksScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
232244 take();
233245 return true;
234246 }
247 if (e.key === "m" && canSetMode()) {
248 runAsync(ctx, async () => {
249 const next: FollowMode = mode === "take-updates" ? "tell-me" : "take-updates";
250 const set = await setFollowMode(f.app, f.user, repo, next);
251 if (!set.ok) return ctx.toast(FOLLOW_MODE_REFUSALS[set.error].message, "danger");
252 await read();
253 ctx.toast(`Now following in ${set.value} mode.`, "success");
254 });
255 return true;
256 }
235257 if (e.key === "esc" || e.key === "q") {
236258 ctx.pop();
237259 return true;
src/web/pages/chain.ts +9 −7
@@ -29,11 +29,11 @@ const MODE_LABELS: Record<FollowMode, string> = {
2929 "take-updates": "Take the parent's updates",
3030 };
3131
32 function modeCard(base: string, current: FollowMode | null): Html {
33 return card(
34 html`<form method="post" action="${base}/following/mode">
32 /** The mode choice as a form: a forge's, or a plain fork's on its own page. */
33 export function modeForm(base: string, current: FollowMode | null, legend: string): Html {
34 return html`<form method="post" action="${base}/following/mode">
3535 <fieldset class="form-field">

The legend is hard-coded to 'this forge', which is wrong on a plain fork's page. Pass the legend in.

36 <legend>How this forge follows its parent</legend>
36 <legend>${legend}</legend>
3737 ${FOLLOW_MODES.map(
3838 (m) => html`<label class="row"
3939 ><input type="radio" name="mode" value="${m}" ${m === current ? "checked" : null} />
@@ -42,9 +42,11 @@ function modeCard(base: string, current: FollowMode | null): Html {
4242 )}
4343 </fieldset>
4444 <div class="form-actions">${btn("Save", { type: "submit" })}</div>
45 </form>`,
46 { head: html`<h2>Follow mode</h2>` },
47 );
45 </form>`;
46 }
47
48 function modeCard(base: string, current: FollowMode | null): Html {
49 return card(modeForm(base, current, "How this forge follows its parent"), { head: html`<h2>Follow mode</h2>` });
4850 }
4951
5052 const MODE_WORDS = { "tell-me": "tell me", "take-updates": "take updates (as a pull you merge)" } as const;
src/web/pages/following.ts +8 −2
@@ -37,8 +37,10 @@ import {
3737 type ParentReleases,
3838 } from "../../core/parent_releases.ts";
3939 import { running } from "../../kernel/version.ts";
40 import { modeForm } from "./chain.ts";
41 import type { FollowMode } from "../../spawn/chain.ts";
4042 import { loadRepo, notFoundPage, repoBase, signInFirst } from "../chrome.ts";
41 import { convergeWords, takeOrTell, takeWords } from "../../actions/follow.ts";
43 import { convergeWords, followModeOf, takeOrTell, takeWords } from "../../actions/follow.ts";
4244 import { FOLLOWS_NOTHING, PROPOSE_NEEDS_WRITE } from "../../actions/follow_words.ts";
4345 import { openProposal, proposeUpstream } from "../../actions/upstream.ts";
4446 import { field, readForm } from "../form.ts";
@@ -92,6 +94,8 @@ export interface Following {
9294 readonly converge: Converge | undefined;
9395 /** The parent forge's releases, when this forge names a parent forge. */
9496 readonly releases: { url: string; view: ParentReleases } | undefined;
97 /** How this repository follows its parent (`followModeOf`). */
98 readonly mode: FollowMode;
9599 }
96100
97101 /** Everything the card needs, for a repo that follows something. */
@@ -119,7 +123,7 @@ export async function followingOf(
119123 loopback: ctx.app.config.parentLoopback === true,
120124 }),
121125 };
122 return { relation, converge, releases };
126 return { relation, converge, releases, mode: await followModeOf(ctx.app, repo) };
123127 }
124128
125129 /** The parent, when the viewer may read it and an admin has archived it. */
@@ -354,6 +358,8 @@ export function followingCard(
354358 rows.push(...block.links);
355359 }
356360 if (foot !== null) prose.push(html`<p class="muted small">${foot}</p>`);
361 if (canAdmin(ctx.app.db, repo, ctx.auth.user) && writer)
362 prose.push(modeForm(base, following.mode, "How this fork follows its parent"));
357363 if (followers > 0)
358364 rows.push(
359365 row({
test/fork-follow-mode.test.ts added +142
@@ -0,0 +1,142 @@
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 { setFollowMode } from "../src/actions/follow.ts";
6 import { commitToBranch, createRepo, forkRepo } from "../src/actions/repos.ts";
7 import { createApp, type App } from "../src/app.ts";
8 import { forkFollowMode } from "../src/core/fork_follow.ts";
9 import { forkRelation } from "../src/core/following.ts";
10 import { getRepo, type Repo } from "../src/core/repos.ts";
11 import { createUser, type User } from "../src/core/users.ts";
12 import { createForgeApp } from "../src/tui/forge.ts";
13 import { forksScreen } from "../src/tui/screens/forks.ts";
14 import { headlessTerminal } from "../src/tui/term/terminal.ts";
15 import { settleApp } from "./helpers/tui.ts";
16 import { promote } from "./helpers/members.ts";
17
18 const must = <T>(r: { ok: true; value: T } | { ok: false; error: unknown }): T => {
19 if (!r.ok) throw new Error(JSON.stringify(r.error));
20 return r.value;
21 };
22
23 let dataDir: string;
24 let app: App;
25 let original: Repo;
26 let fork: Repo;
27 const users: Record<string, User> = {};
28
29 const row = (id: string) => app.db.query("SELECT mode FROM fork_follow WHERE repo_id = ?1").get(id);
30 const commit = async (who: string, repo: Repo, path: string) =>
31 must(await commitToBranch(app, users[who]!, repo, { message: path, files: [{ path, content: `${path}\n` }] }));
32
33 beforeAll(async () => {
34 dataDir = await mkdtemp(join(tmpdir(), "forge-fork-follow-mode-"));
35 app = createApp({ dataDir, port: 0 });
36 for (const name of ["ann", "bea", "cal"]) {
37 users[name] = must(
38 await createUser(app.db, {
39 origin: "seed",
40 trusted: true,
41 username: name,
42 email: `${name}@f.dev`,
43 password: "a-good-passphrase",
44 adminIfFirst: name === "ann",
45 }),
46 );
47 promote(app.db, users[name]!);
48 }
49 original = must(await createRepo(app, users["ann"]!, { name: "yard", visibility: "public" }));
50 await commit("ann", original, "a.txt");
51 fork = must(await forkRepo(app, users["bea"]!, original));
52 }, 60_000);
53
54 afterAll(async () => {
55 app.close();
56 await rm(dataDir, { recursive: true, force: true });
57 });
58
59 describe("setFollowMode on a plain fork", () => {
60 test("a guest and a stranger are refused and write no row", async () => {
61 const guest = await setFollowMode(app, undefined, fork, "take-updates");
62 expect(guest).toEqual({ ok: false, error: "unauthorized" });
63 const stranger = await setFollowMode(app, users["cal"]!, fork, "take-updates");
64 expect(stranger).toEqual({ ok: false, error: "forbidden" });
65 expect(row(fork.id)).toBeNull();
66 expect(forkFollowMode(app.db, fork.id)).toBe("tell-me");
67 });
68
69 test("a non-fork still follows nothing", async () => {
70 const r = await setFollowMode(app, users["ann"]!, original, "take-updates");
71 expect(r).toEqual({ ok: false, error: "not_following" });
72 expect(row(original.id)).toBeNull();
73 });
74
75 test("a bad mode is refused; the owner's good one is kept", async () => {
76 expect(await setFollowMode(app, users["bea"]!, fork, "nope")).toEqual({ ok: false, error: "invalid_mode" });
77 expect(row(fork.id)).toBeNull();
78 expect(must(await setFollowMode(app, users["bea"]!, fork, "take-updates"))).toBe("take-updates");
79 expect(forkFollowMode(app.db, fork.id)).toBe("take-updates");
80 expect(must(await setFollowMode(app, users["bea"]!, fork, "tell-me"))).toBe("tell-me");
81 expect(forkFollowMode(app.db, fork.id)).toBe("tell-me");
82 });
83 });
84
85 describe("the mode changes what happens when the parent moves", () => {
86 const settle = async (f: Repo, state: string) => {
87 for (let i = 0; i < 100; i++) {
88 if ((await forkRelation(app, f, original))?.state === state) return true;
89 await Bun.sleep(50);
90 }
91 return false;
92 };
93
94 test("tell-me leaves a fork with work of its own diverged; take-updates merges it", async () => {
95 await commit("bea", fork, "mine.txt");
96 await commit("ann", original, "one.txt");
97 expect(await settle(fork, "diverged")).toBe(true);
98 await Bun.sleep(300);
99 expect((await forkRelation(app, fork, original))?.state).toBe("diverged");
100
101 must(await setFollowMode(app, users["bea"]!, fork, "take-updates"));
102 await commit("ann", original, "two.txt");
103 // A clean merge leaves the fork ahead of a parent it has caught up with.
104 expect(await settle(fork, "ahead")).toBe(true);
105 });
106 });
107
108 describe("the terminal", () => {
109 const open = async (who: string, keys: string) => {
110 const terminal = headlessTerminal(120, 30);
111 const { app: tui, session } = createForgeApp(
112 app,
113 { sshUsername: who, remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user: users[who]! },
114 { terminal, exitHooks: false },
115 );
116 tui.start();
117 tui.ctx.push(forksScreen(session, tui.ctx, getRepo(app.db, "bea", "yard")!));
118 await settleApp(tui);
119 terminal.feed(keys);
120 await settleApp(tui);
121 const text = tui.screen.toText();
122 tui.stop?.();
123 return text;
124 };
125
126 test("the owner sees the mode and flips it with m", async () => {
127 must(await setFollowMode(app, users["bea"]!, fork, "tell-me"));
128 const before = await open("bea", "");
129 expect(before).toContain("tells you");
130 expect(before).toContain("follow mode");
131 const after = await open("bea", "m");
132 expect(after).toContain("takes updates");
133 expect(forkFollowMode(app.db, fork.id)).toBe("take-updates");
134 });
135
136 test("someone who cannot write is not offered it, and m does nothing", async () => {
137 must(await setFollowMode(app, users["bea"]!, fork, "tell-me"));
138 const text = await open("cal", "m");
139 expect(text).not.toContain("follow mode");
140 expect(forkFollowMode(app.db, fork.id)).toBe("tell-me");
141 });
142 });
test/fork-tree-web.test.ts +16 −19
@@ -18,7 +18,7 @@ import { createApp, type App } from "../src/app.ts";
1818 import { getRepo, type Repo } from "../src/core/repos.ts";
1919 import { createUser, type User } from "../src/core/users.ts";
2020 import { serve, type Servers } from "../src/http/server.ts";
21 import { forgeLinkOf } from "../src/spawn/chain.ts";
21 import { forkFollowMode } from "../src/core/fork_follow.ts";
2222 import { quietLogs } from "./helpers/globals.ts";
2323
2424 quietLogs();
@@ -88,19 +88,6 @@ beforeAll(async () => {
8888 );
8989 middle = must(await forkRepo(app, bea, original));
9090 must(await forkRepo(app, cal, middle));
91 // The middle fork carries the forge's own code, so its following page offers the modes.
92 await Bun.write(
93 join(dataDir, "parent.json"),
94 JSON.stringify({
95 forgeCode: {
96 repo: "bea/yard",
97 upstream: "ann/yard",
98 mode: "tell-me",
99 tip: "a".repeat(40),
100 chain: [{ repo: "ann/yard", forge: "", mode: null, tip: "a".repeat(40), ahead: null }],
101 },
102 }),
103 );
10491 }, 60_000);
10592
10693 afterAll(async () => {
@@ -135,25 +122,35 @@ describe("the fork tree, on one forge", () => {
135122 expect(tree.indexOf("you are here", mark + 1)).toBe(-1);
136123 });
137124
138 test("the following page offers the modes; posting one changes it, and the page shows it", async () => {
125 test("a plain fork's page offers the modes; posting one changes it, and the page shows it", async () => {
139126 const cookie = await signIn("bea");
140 const before = await (await get("/bea/yard/following", cookie)).text();
127 const before = await (await get("/bea/yard", cookie)).text();
141128 expect(before).toContain('name="mode" value="tell-me"');
142129 expect(before).toContain('name="mode" value="take-updates"');
143130 expect(before).toMatch(/value="tell-me"\s+checked/);
144131
145132 const cross = await post("/bea/yard/following/mode", cookie, { mode: "take-updates" }, "https://evil.test");
146133 expect(cross.status).toBe(403);
147 expect((await forgeLinkOf(app))?.mode).toBe("tell-me");
134 expect(forkFollowMode(app.db, middle.id)).toBe("tell-me");
148135
149136 const changed = await post("/bea/yard/following/mode", cookie, { mode: "take-updates" });
150137 expect(changed.status).toBe(303);
151138 expect(changed.headers.get("location")).toBe("/bea/yard/following");
152 expect((await forgeLinkOf(app))?.mode).toBe("take-updates");
139 expect(forkFollowMode(app.db, middle.id)).toBe("take-updates");
153140
154 const after = await (await get("/bea/yard/following", cookie)).text();
141 const after = await (await get("/bea/yard", cookie)).text();
155142 expect(after).toMatch(/value="take-updates"\s+checked/);
156143 expect(after).not.toMatch(/value="tell-me"\s+checked/);
144
145 // A reader without write access is not offered the form.
146 const reader = await (await get("/bea/yard", await signIn("cal"))).text();
147 expect(reader).not.toContain('name="mode"');
148 expect(await (await fetch(`${servers.url}/bea/yard`)).text()).not.toContain('name="mode"');
149 expect(after).toContain("How this fork follows its parent");
150
151 const bad = await post("/bea/yard/following/mode", cookie, { mode: "nope" });
152 expect(bad.status).toBe(303);
153 expect(forkFollowMode(app.db, middle.id)).toBe("take-updates");
157154 });
158155
159156 test("one POST from the offer page opens exactly one pull on the parent", async () => {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs