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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7796-a-plain-fork-offers-how-it-follows-its-p → main 3 commits fork 3 threads · 3 unresolved
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 | ||
| 309 | 309 | |
| 310 | 310 | The `forge` command keeps a local copy of the parts you work on: **[docs/ON-YOUR-MACHINE.md](docs/ON-YOUR-MACHINE.md)**. |
| 311 | 311 | |
| 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 | ||
| 312 | 314 | ## Develop |
| 313 | 315 | |
| 314 | 316 | 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"; | ||
| 15 | 15 | import { chainOfRepo, forgeLinkOf, FOLLOW_MODES, isFollowMode, setChainMode, severChain, type FollowMode } from "../spawn/chain.ts"; |
| 16 | 16 | import { FAMILY_WORDS } from "./family_words.ts"; |
| 17 | 17 | import { FOLLOWS_NOTHING } from "./follow_words.ts"; |
| 18 | import { forkFollowMode, setForkFollowMode } from "../core/fork_follow.ts"; | |
| 18 | 19 | import { requireUser } from "./access.ts"; |
| 19 | 20 | import { takeFromParent, type Taken } from "../spawn/take.ts"; |
| 20 | 21 | import { takeUpdates, type ForkSync, type TakeUpdatesError } from "./forks.ts"; |
| @@ -98,6 +99,14 @@ export function takeForgeWords(r: TakeForgeResult): { tone?: "danger"; message: | ||
| 98 | 99 | return { message: "Already up to date." }; |
| 99 | 100 | } |
| 100 | 101 | |
| 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 | ||
| 101 | 110 | export type FollowModeError = SeverError | "invalid_mode" | "severed"; |
| 102 | 111 | |
| 103 | 112 | /** 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( | ||
| 118 | 127 | ): Promise<Result<FollowMode, FollowModeError>> { |
| 119 | 128 | const who = requireUser(actor); |
| 120 | 129 | 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"); | |
| 122 | 132 | if (!canAdmin(app.db, repo, who.value) || !canWrite(app.db, repo, who.value)) return err("forbidden"); |
| 123 | 133 | 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 | } | |
| 126 | 140 | app.bus.append({ |
| 127 | 141 | kind: "forge.follow_mode", |
| 128 | 142 | repoId: repo.id, |
src/app.ts +11 −1
| @@ -4,6 +4,8 @@ | ||
| 4 | 4 | * side-effect-contained, so tests boot real forges by the dozen. |
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | import { commitIdentity } from "./core/commit_identity.ts"; | |
| 8 | import { getUserById } from "./core/users.ts"; | |
| 7 | 9 | import type { MailConfig, Mailer } from "./kernel/mail.ts"; |
| 8 | 10 | import type { Database } from "bun:sqlite"; |
| 9 | 11 | import type { Decider } from "./crew/decide.ts"; |
| @@ -239,7 +241,15 @@ export function createApp(config: ForgeConfig): App { | ||
| 239 | 241 | bus.subscribe((event) => enqueueForEvent(db, event)); |
| 240 | 242 | // What a push moves besides its own branch: the pulls it heads elsewhere, |
| 241 | 243 | // 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 | }); | |
| 243 | 253 | |
| 244 | 254 | const owner = id(); |
| 245 | 255 | const errors = forgeErrorLedger(owner); |
src/core/following.ts +11 −1
| @@ -40,6 +40,7 @@ import { | ||
| 40 | 40 | import { runGit } from "../git/env.ts"; |
| 41 | 41 | import { readTipOrAbsent } from "../git/tip.ts"; |
| 42 | 42 | import { repoDir } from "../git/repos.ts"; |
| 43 | import { forkFollowMode } from "./fork_follow.ts"; | |
| 43 | 44 | import { createLog } from "../kernel/log.ts"; |
| 44 | 45 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 45 | 46 | |
| @@ -51,6 +52,8 @@ export interface Following { | ||
| 51 | 52 | readonly db: Database; |
| 52 | 53 | readonly reposRoot: string; |
| 53 | 54 | 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; | |
| 54 | 57 | } |
| 55 | 58 | |
| 56 | 59 | interface Moved { |
| @@ -105,12 +108,19 @@ export function follow(forge: Following): () => void { | ||
| 105 | 108 | if (moved.some((m) => m.ref === `refs/heads/${repo.defaultBranch}`)) |
| 106 | 109 | for (const fork of listForks(forge.db, repo.id)) |
| 107 | 110 | fastForwardFork(forge, fork, repo, actor) |
| 108 | .then((followed) => { | |
| 111 | .then(async (followed) => { | |
| 109 | 112 | // A fork with work of its own cannot be moved; it is told the |
| 110 | 113 | // parent moved, and whoever listens (the crew, a card) takes it |
| 111 | 114 | // from there. The fast-forward is silent because it changed |
| 112 | 115 | // nothing the fork's owner would want to know about. |
| 113 | 116 | 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 | } | |
| 114 | 124 | forge.bus.append({ |
| 115 | 125 | kind: "fork.diverged", |
| 116 | 126 | 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); | |
| 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 { | ||
| 18 | 18 | type ForkRanking, |
| 19 | 19 | type ForkRelation, |
| 20 | 20 | } 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"; | |
| 22 | 22 | 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"; | |
| 24 | 25 | import type { Ctx, Screen } from "../app/app.ts"; |
| 25 | 26 | import { binding } from "../app/keys.ts"; |
| 26 | 27 | import { style } from "../term/style.ts"; |
| @@ -77,6 +78,7 @@ export function forkRows( | ||
| 77 | 78 | export function followingRows( |
| 78 | 79 | relation: ForkRelation | undefined, |
| 79 | 80 | converge: Converge | undefined, |
| 81 | mode?: FollowMode, | |
| 80 | 82 | ): ForkRow[] { |
| 81 | 83 | if (relation === undefined) return []; |
| 82 | 84 | const { parent } = relation; |
| @@ -91,7 +93,12 @@ export function followingRows( | ||
| 91 | 93 | ? `diverged · ${counts}${relation.clean ? " · merges cleanly" : ""}` |
| 92 | 94 | : `${relation.state} · ${counts}`; |
| 93 | 95 | 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 | }, | |
| 95 | 102 | ...(converge === undefined |
| 96 | 103 | ? [] |
| 97 | 104 | : [ |
| @@ -132,6 +139,7 @@ export function forksScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 132 | 139 | let following: ForkRow[] = []; |
| 133 | 140 | let relation: ForkRelation | undefined; |
| 134 | 141 | let offer: string | undefined; |
| 142 | let mode: FollowMode | undefined; | |
| 135 | 143 | const state = new ListState(); |
| 136 | 144 | const rows = () => [...following, ...(ranked === undefined ? [] : forkRows(ranked, tree))]; |
| 137 | 145 | const read = async (): Promise<void> => { |
| @@ -144,7 +152,8 @@ export function forksScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 144 | 152 | relation !== undefined && relation.state === "diverged" && !relation.clean |
| 145 | 153 | ? convergeState(f.app, repo, relation.parent) |
| 146 | 154 | : undefined; |
| 147 | following = followingRows(relation, converge); | |
| 155 | mode = relation === undefined ? undefined : await followModeOf(f.app, repo); | |
| 156 | following = followingRows(relation, converge, mode); | |
| 148 | 157 | ctx.invalidate(); |
| 149 | 158 | }; |
| 150 | 159 | const take = (): void => { |
| @@ -165,7 +174,7 @@ export function forksScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 165 | 174 | // The converge issue just opened or found is the line Enter opens. |
| 166 | 175 | const e = took.ok ? undefined : took.error; |
| 167 | 176 | 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); | |
| 169 | 178 | state.index = following.length - 1; |
| 170 | 179 | } |
| 171 | 180 | ctx.toast(words.message, words.tone === "danger" ? "danger" : "default"); |
| @@ -175,6 +184,8 @@ export function forksScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 175 | 184 | }), |
| 176 | 185 | ); |
| 177 | 186 | }; |
| 187 | const canSetMode = (): boolean => | |
| 188 | relation !== undefined && f.user?.kind === "user" && canAdmin(f.db, repo, f.user) && canWrite(f.db, repo, f.user); | |
| 178 | 189 | const self: Screen = { |
| 179 | 190 | title: "Forks", |
| 180 | 191 | get keys() { |
| @@ -182,6 +193,7 @@ export function forksScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 182 | 193 | binding(["j", "k"], "move"), |
| 183 | 194 | binding(["enter"], "open"), |
| 184 | 195 | ...(offer === undefined ? [] : [binding(["t"], "take updates")]), |
| 196 | ...(canSetMode() ? [binding(["m"], "follow mode")] : []), | |
| 185 | 197 | binding(["esc"], "close"), |
| 186 | 198 | ]; |
| 187 | 199 | }, |
| @@ -232,6 +244,16 @@ export function forksScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 232 | 244 | take(); |
| 233 | 245 | return true; |
| 234 | 246 | } |
| 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 | } | |
| 235 | 257 | if (e.key === "esc" || e.key === "q") { |
| 236 | 258 | ctx.pop(); |
| 237 | 259 | return true; |
src/web/pages/chain.ts +9 −7
| @@ -29,11 +29,11 @@ const MODE_LABELS: Record<FollowMode, string> = { | ||
| 29 | 29 | "take-updates": "Take the parent's updates", |
| 30 | 30 | }; |
| 31 | 31 | |
| 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"> | |
| 35 | 35 | <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> | |
| 37 | 37 | ${FOLLOW_MODES.map( |
| 38 | 38 | (m) => html`<label class="row" |
| 39 | 39 | ><input type="radio" name="mode" value="${m}" ${m === current ? "checked" : null} /> |
| @@ -42,9 +42,11 @@ function modeCard(base: string, current: FollowMode | null): Html { | ||
| 42 | 42 | )} |
| 43 | 43 | </fieldset> |
| 44 | 44 | <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>` }); | |
| 48 | 50 | } |
| 49 | 51 | |
| 50 | 52 | 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 { | ||
| 37 | 37 | type ParentReleases, |
| 38 | 38 | } from "../../core/parent_releases.ts"; |
| 39 | 39 | import { running } from "../../kernel/version.ts"; |
| 40 | import { modeForm } from "./chain.ts"; | |
| 41 | import type { FollowMode } from "../../spawn/chain.ts"; | |
| 40 | 42 | 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"; | |
| 42 | 44 | import { FOLLOWS_NOTHING, PROPOSE_NEEDS_WRITE } from "../../actions/follow_words.ts"; |
| 43 | 45 | import { openProposal, proposeUpstream } from "../../actions/upstream.ts"; |
| 44 | 46 | import { field, readForm } from "../form.ts"; |
| @@ -92,6 +94,8 @@ export interface Following { | ||
| 92 | 94 | readonly converge: Converge | undefined; |
| 93 | 95 | /** The parent forge's releases, when this forge names a parent forge. */ |
| 94 | 96 | readonly releases: { url: string; view: ParentReleases } | undefined; |
| 97 | /** How this repository follows its parent (`followModeOf`). */ | |
| 98 | readonly mode: FollowMode; | |
| 95 | 99 | } |
| 96 | 100 | |
| 97 | 101 | /** Everything the card needs, for a repo that follows something. */ |
| @@ -119,7 +123,7 @@ export async function followingOf( | ||
| 119 | 123 | loopback: ctx.app.config.parentLoopback === true, |
| 120 | 124 | }), |
| 121 | 125 | }; |
| 122 | return { relation, converge, releases }; | |
| 126 | return { relation, converge, releases, mode: await followModeOf(ctx.app, repo) }; | |
| 123 | 127 | } |
| 124 | 128 | |
| 125 | 129 | /** The parent, when the viewer may read it and an admin has archived it. */ |
| @@ -354,6 +358,8 @@ export function followingCard( | ||
| 354 | 358 | rows.push(...block.links); |
| 355 | 359 | } |
| 356 | 360 | 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")); | |
| 357 | 363 | if (followers > 0) |
| 358 | 364 | rows.push( |
| 359 | 365 | 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"; | ||
| 18 | 18 | import { getRepo, type Repo } from "../src/core/repos.ts"; |
| 19 | 19 | import { createUser, type User } from "../src/core/users.ts"; |
| 20 | 20 | 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"; | |
| 22 | 22 | import { quietLogs } from "./helpers/globals.ts"; |
| 23 | 23 | |
| 24 | 24 | quietLogs(); |
| @@ -88,19 +88,6 @@ beforeAll(async () => { | ||
| 88 | 88 | ); |
| 89 | 89 | middle = must(await forkRepo(app, bea, original)); |
| 90 | 90 | 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 | ); | |
| 104 | 91 | }, 60_000); |
| 105 | 92 | |
| 106 | 93 | afterAll(async () => { |
| @@ -135,25 +122,35 @@ describe("the fork tree, on one forge", () => { | ||
| 135 | 122 | expect(tree.indexOf("you are here", mark + 1)).toBe(-1); |
| 136 | 123 | }); |
| 137 | 124 | |
| 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 () => { | |
| 139 | 126 | 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(); | |
| 141 | 128 | expect(before).toContain('name="mode" value="tell-me"'); |
| 142 | 129 | expect(before).toContain('name="mode" value="take-updates"'); |
| 143 | 130 | expect(before).toMatch(/value="tell-me"\s+checked/); |
| 144 | 131 | |
| 145 | 132 | const cross = await post("/bea/yard/following/mode", cookie, { mode: "take-updates" }, "https://evil.test"); |
| 146 | 133 | expect(cross.status).toBe(403); |
| 147 | expect((await forgeLinkOf(app))?.mode).toBe("tell-me"); | |
| 134 | expect(forkFollowMode(app.db, middle.id)).toBe("tell-me"); | |
| 148 | 135 | |
| 149 | 136 | const changed = await post("/bea/yard/following/mode", cookie, { mode: "take-updates" }); |
| 150 | 137 | expect(changed.status).toBe(303); |
| 151 | 138 | 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"); | |
| 153 | 140 | |
| 154 | const after = await (await get("/bea/yard/following", cookie)).text(); | |
| 141 | const after = await (await get("/bea/yard", cookie)).text(); | |
| 155 | 142 | expect(after).toMatch(/value="take-updates"\s+checked/); |
| 156 | 143 | 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"); | |
| 157 | 154 | }); |
| 158 | 155 | |
| 159 | 156 | test("one POST from the offer page opens exactly one pull on the parent", async () => { |
new above
offline
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.