Public: anyone can read this; the crew can change it.
A fork's page says in one line how it follows its upstream and an admin switches it in one tap #10765
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/10748-a-fork-s-page-says-in-one-line-how-it-fo → main 4 commits fork 3 threads · 3 unresolved
docs/features/follow-mode.md added +11
| @@ -0,0 +1,11 @@ | ||
| 1 | # How a fork follows its upstream | |
| 2 | ||
| 3 | A fork's page says it in one line: **Proposes updates from ann/yard** (you are told, and take updates when you choose) or **Follows ann/yard automatically** (new code is merged as it lands; a conflict goes to the crew). The line shows only on a fork, to anyone who can see the page. | |
| 4 | ||
| 5 | ## Switching | |
| 6 | ||
| 7 | - **Web:** an admin of the fork sees one button beside the line. It opens a confirm that says what changes ("without asking", "nothing is merged until you take it"); the POST is same-origin only, and the change is recorded as a `forge.follow_mode` event with the repository and the person. | |
| 8 | - **MCP:** `follow_mode` with `repo` and `mode` (`tell-me` or `take-updates`) switches it for an admin and answers in the same words as the page. Anyone else is refused. | |
| 9 | - **Terminal:** on a fork's screen the row shows the same line; `m` flips the mode and the toast says the same sentence. | |
| 10 | ||
| 11 | The words live in `src/actions/follow_words.ts` and `followModeSaid` in `src/actions/follow.ts`; every door reads them from there. |
src/actions/follow.ts +8 −1
| @@ -14,7 +14,7 @@ import type { User } from "../core/users.ts"; | ||
| 14 | 14 | 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 | import { FOLLOWS_NOTHING } from "./follow_words.ts"; | |
| 17 | import { FOLLOWS_NOTHING, followModeWords } from "./follow_words.ts"; | |
| 18 | 18 | import { forkFollowMode, setForkFollowMode } from "../core/fork_follow.ts"; |
| 19 | 19 | import { requireUser } from "./access.ts"; |
| 20 | 20 | import { takeFromParent, type Taken } from "../spawn/take.ts"; |
| @@ -146,6 +146,13 @@ export async function setFollowMode( | ||
| 146 | 146 | return ok(mode); |
| 147 | 147 | } |
| 148 | 148 | |
| 149 | /** The sentence every door answers a mode change with. */ | |
| 150 | export function followModeSaid(parent: Repo | undefined, mode: FollowMode): string { | |
| 151 | const upstream = parent === undefined ? "its parent" : `${parent.ownerName}/${parent.name}`; | |
| 152 | const words = followModeWords(mode, upstream); | |
| 153 | return `${words.line}. ${words.reason}`; | |
| 154 | } | |
| 155 | ||
| 149 | 156 | /** What the crew did about a conflict, in a sentence; `danger` when nothing is in hand. */ |
| 150 | 157 | export function convergeWords( |
| 151 | 158 | outcome: Converge, |
src/actions/follow_words.ts +27
| @@ -1,3 +1,5 @@ | ||
| 1 | import type { FollowMode } from "../spawn/chain.ts"; | |
| 2 | ||
| 1 | 3 | /** |
| 2 | 4 | * Following's refusals in words (#5589): said by the chain page and the |
| 3 | 5 | * following page alike, so they are typed once and read from here. |
| @@ -23,3 +25,28 @@ export function proposeRefusalWords( | ||
| 23 | 25 | if (typeof e === "string") return PROPOSE_NEEDS_WRITE; |
| 24 | 26 | return `${parentName} did not take the proposal: ${e.reason}.`; |
| 25 | 27 | } |
| 28 | ||
| 29 | /** How a fork follows its upstream, in one line and the reason beside it: the page and the MCP say these words. */ | |
| 30 | export function followModeWords( | |
| 31 | mode: FollowMode, | |
| 32 | upstream: string, | |
| 33 | ): { readonly line: string; readonly reason: string; readonly other: FollowMode } { | |
| 34 | return mode === "take-updates" | |
| 35 | ? { | |
| 36 | line: `Follows ${upstream} automatically`, | |
| 37 | reason: `New code from ${upstream} is merged in as it lands; a conflict goes to the crew.`, | |
| 38 | other: "tell-me", | |
| 39 | } | |
| 40 | : { | |
| 41 | line: `Proposes updates from ${upstream}`, | |
| 42 | reason: `You are told when ${upstream} has something new and take it when you choose.`, | |
| 43 | other: "take-updates", | |
| 44 | }; | |
| 45 | } | |
| 46 | ||
| 47 | /** What switching to `mode` changes, for the confirm step. */ | |
| 48 | export function followSwitchPreview(mode: FollowMode, upstream: string): string { | |
| 49 | return mode === "take-updates" | |
| 50 | ? `From now on new code from ${upstream} is merged into this fork as it lands, without asking. When both sides changed the same files, nothing is merged and the crew is told to converge them. You can switch back at any time.` | |
| 51 | : `From now on ${upstream} only proposes: you are told when it has something new and nothing is merged until you take it. You can switch back at any time.`; | |
| 52 | } |
src/http/mcp/tools/follow_mode.ts +3 −2
| @@ -1,4 +1,5 @@ | ||
| 1 | import { FOLLOW_MODE_REFUSALS, setFollowMode } from "../../../actions/follow.ts"; | |
| 1 | import { FOLLOW_MODE_REFUSALS, followModeSaid, setFollowMode } from "../../../actions/follow.ts"; | |
| 2 | import { forkParentFor } from "../../../core/repos.ts"; | |
| 2 | 3 | import { FOLLOW_MODES } from "../../../spawn/chain.ts"; |
| 3 | 4 | import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts"; |
| 4 | 5 | |
| @@ -20,7 +21,7 @@ export const tool: Tool = { | ||
| 20 | 21 | const repo = repoArg(ctx, user, args); |
| 21 | 22 | if (isAnswer(repo)) return repo; |
| 22 | 23 | const set = await setFollowMode(ctx.app, user, repo, args["mode"]); |
| 23 | if (set.ok) return good({ says: `Now following in ${set.value} mode.`, mode: set.value }); | |
| 24 | if (set.ok) return good({ says: followModeSaid(forkParentFor(ctx.app.db, repo, user), set.value), mode: set.value }); | |
| 24 | 25 | return bad(set.error, FOLLOW_MODE_REFUSALS[set.error].message); |
| 25 | 26 | }, |
| 26 | 27 | }; |
src/http/server.ts +1
| @@ -1449,6 +1449,7 @@ export function buildRouter(): Router<Handler> { | ||
| 1449 | 1449 | router.add("GET", "/:owner/:name/following", chainPage); |
| 1450 | 1450 | router.add("POST", "/:owner/:name/following/sever", severSubmit); |
| 1451 | 1451 | router.add("POST", "/:owner/:name/following/mode", modeSubmit); |
| 1452 | router.add("POST", "/:owner/:name/following/mode/:mode", modeSubmit); | |
| 1452 | 1453 | router.add("POST", "/:owner/:name/following/take", takeSubmit); |
| 1453 | 1454 | router.add("POST", "/:owner/:name/following/propose", proposeParentSubmit); |
| 1454 | 1455 | router.add("POST", "/:owner/:name/following/check", checkSubmit); |
src/tui/screens/chain.ts +2 −2
| @@ -6,7 +6,7 @@ | ||
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | 8 | import type { App as Forge } from "../../app.ts"; |
| 9 | import { FOLLOW_MODE_REFUSALS, setFollowMode, severForge, takeForgeCode, takeForgeWords } from "../../actions/follow.ts"; | |
| 9 | import { FOLLOW_MODE_REFUSALS, followModeSaid, setFollowMode, severForge, takeForgeCode, takeForgeWords } from "../../actions/follow.ts"; | |
| 10 | 10 | import { SEVER_CONFIRM } from "../../actions/sever_invite_words.ts"; |
| 11 | 11 | import { forgeProposalLine, forgeProposals, proposeToParentForge } from "../../actions/upstream.ts"; |
| 12 | 12 | import { checkUpstream, upstreamState, upstreamWords } from "../../spawn/upstream.ts"; |
| @@ -110,7 +110,7 @@ export async function chainScreen(app: Forge, user: User | undefined, ctx: Ctx): | ||
| 110 | 110 | const set = await setFollowMode(app, user, repo, next); |
| 111 | 111 | if (!set.ok) return ctx.toast(FOLLOW_MODE_REFUSALS[set.error].message, "danger"); |
| 112 | 112 | rows = await linesOf(); |
| 113 | ctx.toast(`Now following in ${set.value} mode.`, "success"); | |
| 113 | ctx.toast(followModeSaid(undefined, set.value), "success"); | |
| 114 | 114 | }); |
| 115 | 115 | else if (e.key === "s" && owner) |
| 116 | 116 | ctx.push( |
src/tui/screens/forks.ts +4 −3
| @@ -18,7 +18,8 @@ import { | ||
| 18 | 18 | type ForkRanking, |
| 19 | 19 | type ForkRelation, |
| 20 | 20 | } from "../../actions/forks.ts"; |
| 21 | import { FOLLOW_MODE_REFUSALS, convergeWords, followModeOf, setFollowMode, takeOrTell, takeWords } from "../../actions/follow.ts"; | |
| 21 | import { followModeWords } from "../../actions/follow_words.ts"; | |
| 22 | import { FOLLOW_MODE_REFUSALS, convergeWords, followModeOf, followModeSaid, setFollowMode, takeOrTell, takeWords } from "../../actions/follow.ts"; | |
| 22 | 23 | import { FOLLOWS_NOTHING, proposeRefusalWords } from "../../actions/follow_words.ts"; |
| 23 | 24 | import { openProposal, proposeUpstream } from "../../actions/upstream.ts"; |
| 24 | 25 | import type { Pull } from "../../core/pulls.ts"; |
| @@ -100,7 +101,7 @@ export function followingRows( | ||
| 100 | 101 | { |
| 101 | 102 | repo: parent, |
| 102 | 103 | name: `follows ${name}`, |
| 103 | words: mode === undefined ? words : `${words} · ${mode === "take-updates" ? "takes updates" : "tells you"}`, | |
| 104 | words: mode === undefined ? words : `${words} · ${followModeWords(mode, name).line}`, | |
| 104 | 105 | heading: "", |
| 105 | 106 | }, |
| 106 | 107 | ...(converge === undefined |
| @@ -304,7 +305,7 @@ export function forksScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 304 | 305 | const set = await setFollowMode(f.app, f.user, repo, next); |
| 305 | 306 | if (!set.ok) return ctx.toast(FOLLOW_MODE_REFUSALS[set.error].message, "danger"); |
| 306 | 307 | await read(); |
| 307 | ctx.toast(`Now following in ${set.value} mode.`, "success"); | |
| 308 | ctx.toast(followModeSaid(relation?.parent, set.value), "success"); | |
| 308 | 309 | }); |
| 309 | 310 | return true; |
| 310 | 311 | } |
src/web/pages/chain.ts +5 −5
| @@ -5,11 +5,11 @@ | ||
| 5 | 5 | * sever. A severed forge says so with the date and nothing is deleted. |
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | import { FOLLOW_MODE_REFUSALS, setFollowMode, severForge, takeForgeCode, takeForgeWords } from "../../actions/follow.ts"; | |
| 8 | import { FOLLOW_MODE_REFUSALS, followModeSaid, setFollowMode, severForge, takeForgeCode, takeForgeWords } from "../../actions/follow.ts"; | |
| 9 | 9 | import { FOLLOWS_NOTHING, PROPOSE_NEEDS_WRITE } from "../../actions/follow_words.ts"; |
| 10 | 10 | import { forgeProposals, proposeToParentForge, type ForgeProposal } from "../../actions/upstream.ts"; |
| 11 | 11 | import { SEVER_CONFIRM } from "../../actions/sever_invite_words.ts"; |
| 12 | import { canAdmin, canWrite } from "../../core/repos.ts"; | |
| 12 | import { canAdmin, canWrite, forkParentFor } from "../../core/repos.ts"; | |
| 13 | 13 | import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts"; |
| 14 | 14 | import type { Ctx } from "../../http/context.ts"; |
| 15 | 15 | import { chainView, FOLLOW_MODES, forgeLinkOf, type ChainLine, type FollowMode } from "../../spawn/chain.ts"; |
| @@ -228,7 +228,7 @@ export async function takeSubmit(ctx: Ctx): Promise<Response> { | ||
| 228 | 228 | return flashOrJson(ctx, to, takeForgeWords(took.value)); |
| 229 | 229 | } |
| 230 | 230 | |
| 231 | /** POST /:owner/:name/following/mode {mode}; the owner only. */ | |
| 231 | /** POST /:owner/:name/following/mode {mode} or /following/mode/:mode (what the confirm step posts); the owner only. */ | |
| 232 | 232 | export async function modeSubmit(ctx: Ctx): Promise<Response> { |
| 233 | 233 | const repo = loadRepo(ctx); |
| 234 | 234 | if (!repo) return notFoundPage(ctx); |
| @@ -236,9 +236,9 @@ export async function modeSubmit(ctx: Ctx): Promise<Response> { | ||
| 236 | 236 | const form = await readForm(ctx, "rw"); |
| 237 | 237 | if (form instanceof Response) return form; |
| 238 | 238 | const to = `${repoBase(repo)}/following`; |
| 239 | const set = await setFollowMode(ctx.app, ctx.auth.user, repo, field(form, "mode")); | |
| 239 | const set = await setFollowMode(ctx.app, ctx.auth.user, repo, ctx.params["mode"] ?? field(form, "mode")); | |
| 240 | 240 | if (!set.ok) return flashOrJson(ctx, to, { tone: "danger", message: FOLLOW_MODE_REFUSALS[set.error].message }); |
| 241 | return flashOrJson(ctx, to, { message: `Now following in ${set.value} mode.` }); | |
| 241 | return flashOrJson(ctx, to, { message: followModeSaid(forkParentFor(ctx.app.db, repo, ctx.auth.user), set.value) }); | |
| 242 | 242 | } |
| 243 | 243 | |
| 244 | 244 | /** POST /:owner/:name/following/propose: push to the parent forge and open the pull there; a writer only. */ |
src/web/pages/following.ts +13 −3
| @@ -37,7 +37,7 @@ 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"; | |
| 40 | import { followModeWords } from "../../actions/follow_words.ts"; | |
| 41 | 41 | import type { FollowMode } from "../../spawn/chain.ts"; |
| 42 | 42 | import { loadRepo, notFoundPage, repoBase, signInFirst } from "../chrome.ts"; |
| 43 | 43 | import { convergeWords, followModeOf, takeOrTell, takeWords } from "../../actions/follow.ts"; |
| @@ -371,8 +371,18 @@ export function followingCard( | ||
| 371 | 371 | if (parent.detached === null && relation.action !== "none") |
| 372 | 372 | prose.push(html`<p class="muted small">${relation.words}</p>`); |
| 373 | 373 | if (foot !== null) prose.push(html`<p class="muted small">${foot}</p>`); |
| 374 | if (canAdmin(ctx.app.db, repo, ctx.auth.user) && writer) | |
| 375 | prose.push(modeForm(base, following.mode, "How this fork follows its parent")); | |
| 374 | const words = followModeWords(following.mode, parentName); | |
| 375 | prose.push( | |
| 376 | html`<p data-follow-mode="${following.mode}"> | |
| 377 | <strong>${words.line}</strong>. | |
| 378 | ${canAdmin(ctx.app.db, repo, ctx.auth.user) && writer | |
| 379 | ? btn(following.mode === "take-updates" ? "Only propose updates" : "Follow automatically", { | |
| 380 | href: `/-/confirm?to=${encodeURIComponent(`${base}/following/mode/${words.other}`)}`, | |
| 381 | size: "sm", | |
| 382 | }) | |
| 383 | : null} | |
| 384 | </p>`, | |
outdated
Principle 1 (nothing explains itself twice): the card already prints relation.words and a foot line just above, and now adds line plus reason. Fold them so the card says the mode once. Also the line renders for any viewer who can see the card, so check that it appears only for forks; the test covers the reader but not a non-fork repo. |
||
| 385 | ); | |
| 376 | 386 | if (followers > 0) |
| 377 | 387 | rows.push( |
| 378 | 388 | row({ |
src/web/pages/search.ts +14 −1
| @@ -17,7 +17,9 @@ import { envsFor } from "../../apps/runtime.ts"; | ||
| 17 | 17 | import type { AppRecord } from "../../core/apps.ts"; |
| 18 | 18 | import { openLabelCounts } from "../../core/issues.ts"; |
| 19 | 19 | import { getPull } from "../../core/pulls.ts"; |
| 20 | import { canAdmin, canWrite, enterableApps, getRepo } from "../../core/repos.ts"; | |
| 20 | import { canAdmin, canWrite, enterableApps, forkParentFor, getRepo } from "../../core/repos.ts"; | |
| 21 | import { followModeWords, followSwitchPreview } from "../../actions/follow_words.ts"; | |
| 22 | import type { FollowMode } from "../../spawn/chain.ts"; | |
| 21 | 23 | import { getMirrorTarget } from "../../core/migrate_mirror.ts"; |
| 22 | 24 | import { |
| 23 | 25 | cleanQuery, |
| @@ -901,6 +903,17 @@ const DESTRUCTIVE: { | ||
| 901 | 903 | }; |
| 902 | 904 | }, |
| 903 | 905 | }, |
| 906 | { | |
| 907 | pattern: /^\/([^/]+)\/([^/]+)\/following\/mode\/(tell-me|take-updates)$/, | |
| 908 | describe: (m, ctx) => { | |
| 909 | const mode = m[3] as FollowMode; | |
| 910 | const repo = getRepo(ctx.app.db, m[1]!, m[2]!); | |
| 911 | const parent = repo && canWrite(ctx.app.db, repo, ctx.auth.user) ? forkParentFor(ctx.app.db, repo, ctx.auth.user) : undefined; | |
| 912 | const upstream = parent === undefined ? "its parent" : `${parent.ownerName}/${parent.name}`; | |
| 913 | const words = followModeWords(mode, upstream); | |
| 914 | return { verb: "Switch", title: `${words.line}?`, detail: followSwitchPreview(mode, upstream), phrase: "" }; | |
| 915 | }, | |
| 916 | }, | |
outdated
Dead branch. The pattern only matches tell-me|take-updates, so isFollowMode(mode) is always true here; the guard exists only to satisfy the type. Narrow the type with a cast or a const map and drop the unreachable 'Pick a mode' answer. Also the preview shows 'its parent' to a non-writer, which is fine, but add a test for it. |
||
| 904 | 917 | { |
| 905 | 918 | pattern: /^\/admin\/invites\/([^/]+)\/revoke$/, |
| 906 | 919 | describe: (m) => ({ |
test/fork-follow-mode.test.ts +2 −2
| @@ -126,10 +126,10 @@ describe("the terminal", () => { | ||
| 126 | 126 | test("the owner sees the mode and flips it with m", async () => { |
| 127 | 127 | must(await setFollowMode(app, users["bea"]!, fork, "tell-me")); |
| 128 | 128 | const before = await open("bea", ""); |
| 129 | expect(before).toContain("tells you"); | |
| 129 | expect(before).toContain("Proposes updates from"); | |
| 130 | 130 | expect(before).toContain("follow mode"); |
| 131 | 131 | const after = await open("bea", "m"); |
| 132 | expect(after).toContain("takes updates"); | |
| 132 | expect(after).toContain("Follows ann/yard automatically"); | |
| 133 | 133 | expect(forkFollowMode(app.db, fork.id)).toBe("take-updates"); |
| 134 | 134 | }); |
| 135 | 135 |
test/fork-tree-web.test.ts +40 −12
| @@ -19,6 +19,8 @@ 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 | 21 | import { forkFollowMode } from "../src/core/fork_follow.ts"; |
| 22 | import { listEvents } from "../src/core/events.ts"; | |
| 23 | import { followModeSaid } from "../src/actions/follow.ts"; | |
| 22 | 24 | import { quietLogs } from "./helpers/globals.ts"; |
| 23 | 25 | |
| 24 | 26 | quietLogs(); |
| @@ -122,37 +124,63 @@ describe("the fork tree, on one forge", () => { | ||
| 122 | 124 | expect(tree.indexOf("you are here", mark + 1)).toBe(-1); |
| 123 | 125 | }); |
| 124 | 126 | |
| 125 | test("a plain fork's page offers the modes; posting one changes it, and the page shows it", async () => { | |
| 127 | test("a plain fork's page says how it follows in one line; the admin switches it after a confirm, a stranger and a cross-origin post change nothing", async () => { | |
| 126 | 128 | const cookie = await signIn("bea"); |
| 127 | 129 | const before = await (await get("/bea/yard", cookie)).text(); |
| 128 | expect(before).toContain('name="mode" value="tell-me"'); | |
| 129 | expect(before).toContain('name="mode" value="take-updates"'); | |
| 130 | expect(before).toMatch(/value="tell-me"\s+checked/); | |
| 130 | expect(before).toContain("Proposes updates from ann/yard"); | |
| 131 | expect(before).not.toContain("automatically</strong>"); | |
| 132 | expect(before).toContain("/-/confirm?to=%2Fbea%2Fyard%2Ffollowing%2Fmode%2Ftake-updates"); | |
| 131 | 133 | |
| 132 | const cross = await post("/bea/yard/following/mode", cookie, { mode: "take-updates" }, "https://evil.test"); | |
| 134 | const confirm = await (await get("/-/confirm?to=%2Fbea%2Fyard%2Ffollowing%2Fmode%2Ftake-updates", cookie)).text(); | |
| 135 | expect(confirm).toContain("Follows ann/yard automatically?"); | |
| 136 | expect(confirm).toContain("the crew is told to converge"); | |
| 137 | expect(confirm).toContain('action="/bea/yard/following/mode/take-updates"'); | |
| 138 | ||
| 139 | const cross = await post("/bea/yard/following/mode/take-updates", cookie, {}, "https://evil.test"); | |
| 133 | 140 | expect(cross.status).toBe(403); |
| 141 | const stranger = await post("/bea/yard/following/mode/take-updates", await signIn("cal"), {}); | |
| 142 | expect(stranger.status).toBe(303); | |
| 134 | 143 | expect(forkFollowMode(app.db, middle.id)).toBe("tell-me"); |
| 135 | 144 | |
| 136 | const changed = await post("/bea/yard/following/mode", cookie, { mode: "take-updates" }); | |
| 145 | const audits = () => listEvents(app.db, { kind: "forge.follow_mode" }); | |
| 146 | const audited = audits().length; | |
| 147 | const changed = await post("/bea/yard/following/mode/take-updates", cookie, {}); | |
| 137 | 148 | expect(changed.status).toBe(303); |
| 138 | 149 | expect(changed.headers.get("location")).toBe("/bea/yard/following"); |
| 139 | 150 | expect(forkFollowMode(app.db, middle.id)).toBe("take-updates"); |
| 151 | expect(audits().length).toBe(audited + 1); | |
| 152 | const receipt = audits()[0]!; | |
| 153 | expect(receipt.repoId).toBe(middle.id); | |
| 154 | expect(receipt.actorId).toBe(bea.id); | |
| 155 | expect(receipt.payload).toMatchObject({ repo: "bea/yard", mode: "take-updates" }); | |
| 140 | 156 | |
| 141 | 157 | const after = await (await get("/bea/yard", cookie)).text(); |
| 142 | expect(after).toMatch(/value="take-updates"\s+checked/); | |
| 143 | expect(after).not.toMatch(/value="tell-me"\s+checked/); | |
| 158 | expect(after).toContain("Follows ann/yard automatically"); | |
| 159 | expect(after).toContain('data-follow-mode="take-updates"'); | |
| 160 | expect(after).toContain("Only propose updates"); | |
| 144 | 161 | |
| 145 | // A reader without write access is not offered the form. | |
| 162 | // A reader sees the line but no switch. | |
| 146 | 163 | 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"); | |
| 164 | expect(reader).toContain("Follows ann/yard automatically"); | |
| 165 | expect(reader).not.toContain("following%2Fmode"); | |
| 150 | 166 | |
| 151 | 167 | const bad = await post("/bea/yard/following/mode", cookie, { mode: "nope" }); |
| 152 | 168 | expect(bad.status).toBe(303); |
| 153 | 169 | expect(forkFollowMode(app.db, middle.id)).toBe("take-updates"); |
| 154 | 170 | }); |
| 155 | 171 | |
| 172 | test("the confirm for tell-me says it only proposes; a non-writer sees 'its parent'; a root repository has no line", async () => { | |
| 173 | const confirm = await (await get("/-/confirm?to=%2Fbea%2Fyard%2Ffollowing%2Fmode%2Ftell-me", await signIn("bea"))).text(); | |
| 174 | expect(confirm).toContain("Proposes updates from ann/yard?"); | |
| 175 | expect(confirm).toContain("nothing is merged until you take it"); | |
| 176 | const stranger = await (await get("/-/confirm?to=%2Fbea%2Fyard%2Ffollowing%2Fmode%2Ftell-me", await signIn("cal"))).text(); | |
| 177 | expect(stranger).toContain("Proposes updates from its parent?"); | |
| 178 | expect(stranger).not.toContain("from ann/yard"); | |
| 179 | const root = await (await get("/ann/yard", await signIn("ann"))).text(); | |
| 180 | expect(root).not.toContain("data-follow-mode"); | |
| 181 | expect(followModeSaid(undefined, "tell-me")).toContain("Proposes updates from its parent."); | |
| 182 | }); | |
| 183 | ||
| 156 | 184 | test("one POST from the offer page opens exactly one pull on the parent", async () => { |
| 157 | 185 | const cookie = await signIn("bea"); |
| 158 | 186 | const fork = getRepo(app.db, "bea", "yard")!; |
test/spawn-chain-doors.test.ts +1 −1
| @@ -185,7 +185,7 @@ describe("the chain at the MCP and terminal doors", () => { | ||
| 185 | 185 | await owner.openChain(); |
| 186 | 186 | expect(owner.text()).toContain("follow mode"); |
| 187 | 187 | await owner.press("m"); |
| 188 | await settleUntil(owner.app, `Now following in ${next} mode`); | |
| 188 | await settleUntil(owner.app, next === "tell-me" ? "Proposes updates from its parent." : "Follows its parent automatically."); | |
| 189 | 189 | expect((await forgeLinkOf(child))?.mode).toBe(next); |
| 190 | 190 | expect(events()).toBe(before + 1); |
| 191 | 191 | owner.app.stop(); |
test/spawn-chain-mode.test.ts +7 −1
| @@ -143,8 +143,14 @@ describe("choosing how a forge follows its parent", () => { | ||
| 143 | 143 | expect(((await res.json()) as any).mode).toBe("tell-me"); |
| 144 | 144 | const viaTool = await tool(ownerTok, { repo: "kid/forge", mode: "take-updates" }); |
| 145 | 145 | expect(viaTool.error).toBe(false); |
| 146 | expect(viaTool.text).toContain("automatically"); | |
| 147 | const told = await tool(ownerTok, { repo: "kid/forge", mode: "tell-me" }); | |
| 148 | expect(told.error).toBe(false); | |
| 149 | expect(told.text).toContain("Proposes updates from"); | |
| 150 | expect((await tool(readerTok, { repo: "kid/forge", mode: "tell-me" })).error).toBe(true); | |
| 151 | expect((await tool(ownerTok, { repo: "kid/forge", mode: "take-updates" })).error).toBe(false); | |
| 146 | 152 | expect((await forgeLinkOf(child))?.mode).toBe("take-updates"); |
| 147 | expect(listEvents(child.db, { kind: "forge.follow_mode" })).toHaveLength(3); | |
| 153 | expect(listEvents(child.db, { kind: "forge.follow_mode" })).toHaveLength(5); | |
| 148 | 154 | }); |
| 149 | 155 | |
| 150 | 156 | test("a severed forge refuses with 409 and keeps its record", async () => { |
Type the mode as FollowMode (from spawn/chain.ts) instead of repeating the literal union in two functions and in
other. One definition keeps the modes from drifting.