Public: anyone can read this; the crew can change it.
Severing a fork from its upstream asks one confirm that says what stays and what stops, and is receipted #11404
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11338-severing-a-fork-from-its-upstream-asks-o → main 2 commits fork 5 threads · 5 unresolved
docs/features/detach.md +2
| @@ -6,3 +6,5 @@ A fork borrows its parent's objects through git alternates, and the forge shows | ||
| 6 | 6 | - **Detach.** A repository admin opens *Settings › Danger zone › Detach from parent*. From an archived parent's quiet line it is the "detach from it" link. The card names the parent only to an admin who may still read it. The `repo.detached` event carries the parent's id, never its name. The detach repacks the fork so it owns every object it borrowed (`detachFork`, the same step deleting a parent takes), drops the alternates link, then forgets the parent. The repack runs in the background, and the card says it is running and since when. If it fails, git's line stays on the card and nothing changes. There is no undo. |
| 7 | 7 | - **Terminal.** On a fork's About card (`I`), an admin presses `x`. The terminal shows the plan, and the detach starts only after it is confirmed. A `detach` row shows the phase, as the card does. |
| 8 | 8 | - **MCP.** `detach { repo }` returns the plan: the parent, and whether the fork still borrows objects. `detach { repo, dryRun: false }` starts the detach, and needs an admin token. Calling it again shows the `phase` until the fork has no `parent`. An agent is refused, as with `archive`. |
| 9 | - **What it says.** Every door (the confirm page, the Danger zone section, the terminal's plan, the MCP dry run) says the same two lines from `actions/detach_words.ts`: the code and its history stay, with the branches, issues and pulls; updates from the parent and offers to it stop. Following cannot be set up again on the same repository, since the parent is forgotten; fork the upstream afresh to follow it. | |
| 10 | - **Receipt.** When the detach lands, the repository's history gets a `repo.detached` row: "severed a fork from its upstream: code and history kept, updates and offers stopped", with who and when. After confirming, the page's flash says the repack runs on its own and the history records it when it lands. |
src/actions/detach.ts +2 −1
| @@ -16,6 +16,7 @@ import { detachFork, hasAlternates, repoDir } from "../git/repos.ts"; | ||
| 16 | 16 | import { createLog } from "../kernel/log.ts"; |
| 17 | 17 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 18 | 18 | import { requireRepoAdmin } from "./access.ts"; |
| 19 | import { DETACH_STAYS, DETACH_STOPS } from "./detach_words.ts"; | |
| 19 | 20 | |
| 20 | 21 | const log = createLog("detach"); |
| 21 | 22 | |
| @@ -130,7 +131,7 @@ function failureLine(stderr: string): string { | ||
| 130 | 131 | /** The plan in one sentence, for the terminal's confirm and the MCP. */ |
| 131 | 132 | export function detachPlanWords(plan: DetachPlan): string { |
| 132 | 133 | const parent = plan.parent ?? "its parent"; |
| 133 | return `${plan.borrows ? `Copies every object it borrows from ${parent} into its own store, drops the link, then forgets` : `Borrows nothing from ${parent}; forgets`} the parent: no updates to take, nothing to propose upstream. There is no undo.`; | |
| 134 | return `${plan.borrows ? `Copies every object it borrows from ${parent} into its own store, drops the link, then forgets` : `Borrows nothing from ${parent}; forgets`} the parent. ${DETACH_STAYS} ${DETACH_STOPS} There is no undo.`; | |
| 134 | 135 | } |
| 135 | 136 | |
| 136 | 137 | /** Where a detach stands, in words; null when none is under way or failed. */ |
src/actions/detach_words.ts added +12
| @@ -0,0 +1,12 @@ | ||
| 1 | /** | |
| 2 | * What severing a fork says, in one place: the confirm page, the Danger zone | |
| 3 | * section, the terminal's confirm, the MCP plan and the history receipt all | |
| 4 | * read these lines, so they tell one story. | |
| 5 | */ | |
| 6 | ||
| 7 | /** What a detach keeps. */ | |
| 8 | export const DETACH_STAYS = "The code and its history stay, with the branches, issues and pulls."; | |
| 9 | /** What a detach ends. */ | |
| 10 | export const DETACH_STOPS = "Updates from the parent and offers to it stop."; | |
| 11 | /** The history row's verb; the row names the repository and carries when. */ | |
| 12 | export const DETACH_RECEIPT = "severed a fork from its upstream: code and history kept, updates and offers stopped"; |
src/design/activity.ts +2 −1
| @@ -10,6 +10,7 @@ | ||
| 10 | 10 | */ |
| 11 | 11 | |
| 12 | 12 | import { VIA_WORDS } from "../core/acting_via.ts"; |
| 13 | import { DETACH_RECEIPT } from "../actions/detach_words.ts"; | |
| 13 | 14 | import type { ForgeEvent } from "../core/events.ts"; |
| 14 | 15 | |
| 15 | 16 | /** The pull number an environment name carries, for `pull-12`. */ |
| @@ -170,7 +171,7 @@ const WORDS: Record<string, string> = { | ||
| 170 | 171 | "pull.thread_resolved": "resolved a review thread", |
| 171 | 172 | "repo.created": "created a repository", |
| 172 | 173 | "repo.forked": "forked a repository", |
| 173 | "repo.detached": "detached a fork from its parent", | |
| 174 | "repo.detached": DETACH_RECEIPT, | |
The verb reads 'X severed this fork from its upstream' in any feed, where 'this fork' has no referent. Name the repository or say 'a fork'. Principle 8, plain words. |
||
| 174 | 175 | "repo.settings_updated": "changed repository settings", |
| 175 | 176 | "repo.upstream_follow": "changed which upstream the repository follows", |
| 176 | 177 | "repo.source_added": "followed another source", |
src/web/pages/detach.ts +5 −5
| @@ -8,6 +8,7 @@ | ||
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | 10 | import { detachFromParent, detachJob, type DetachJob } from "../../actions/detach.ts"; |
| 11 | import { DETACH_STAYS, DETACH_STOPS } from "../../actions/detach_words.ts"; | |
| 11 | 12 | import { forkParentFor, type Repo } from "../../core/repos.ts"; |
| 12 | 13 | import type { User } from "../../core/users.ts"; |
| 13 | 14 | import type { Ctx } from "../../http/context.ts"; |
| @@ -64,10 +65,9 @@ export function detachBlock({ name, action, job }: DetachView): Html { | ||
| 64 | 65 | </div>` |
| 65 | 66 | : null} |
| 66 | 67 | <p> |
| 67 | Copies every object this fork borrows from <strong>${name}</strong> | |
| 68 | into its own store, drops the link, then forgets the parent: no | |
| 69 | Following card, no updates to take, nothing to propose upstream. | |
| 70 | Branches, history, issues and pulls stay. There is no undo. | |
| 68 | Copies what this fork borrows from <strong>${name}</strong> into its | |
| 69 | own store and forgets the parent. ${DETACH_STAYS} ${DETACH_STOPS} | |
| 70 | It is recorded in this repository's history. There is no undo. | |
outdated
Principle 1: the paragraph says what happens twice ('Copies every object... drops the link, then forgets the parent' and then the stays and stops lines). Keep one confident sentence and the two shared lines. |
||
| 71 | 71 | </p> |
| 72 | 72 | <div class="form-actions"> |
| 73 | 73 | ${confirmAction({ action, verb: "Detach from parent" })} |
| @@ -96,6 +96,6 @@ export async function detachSubmit(ctx: Ctx): Promise<Response> { | ||
| 96 | 96 | : "Only an admin of this repository detaches it.", |
| 97 | 97 | }); |
| 98 | 98 | return flashTo(ctx, back, { |
| 99 | message: `Detaching from ${started.value.parent ?? "its parent"}: the repack runs on its own.`, | |
| 99 | message: "The repack runs on its own; the history records it when it lands.", | |
outdated
Principle 8: '(2026-11-11 10:00 UTC)' built with toISOString is machine-looking, and it is not the event's time. Say 'The repack runs on its own; the history records it when it lands.' |
||
| 100 | 100 | }); |
| 101 | 101 | } |
src/web/pages/search.ts +2 −2
| @@ -10,6 +10,7 @@ | ||
| 10 | 10 | * that was never meant to be confirmed. |
| 11 | 11 | */ |
| 12 | 12 | |
| 13 | import { DETACH_STAYS, DETACH_STOPS } from "../../actions/detach_words.ts"; | |
| 13 | 14 | import { DISCONNECT_DETAIL } from "../../actions/origin_words.ts"; |
| 14 | 15 | import { strangerCodeSearch } from "../../http/strangers.ts"; |
| 15 | 16 | import { search } from "../../actions/search.ts"; |
| @@ -606,8 +607,7 @@ const DESTRUCTIVE: { | ||
| 606 | 607 | describe: (m) => ({ |
| 607 | 608 | verb: "Detach from parent", |
| 608 | 609 | title: `Detach ${m[1]}/${m[2]} from its parent?`, |
| 609 | detail: | |
| 610 | "Every object it borrows is copied into its own store and the link is dropped: no updates to take, nothing to propose upstream. Branches, history, issues and pulls stay. There is no undo.", | |
| 610 | detail: `Every object it borrows is copied into its own store and the link is dropped. ${DETACH_STAYS} ${DETACH_STOPS} It is recorded in this repository's history. There is no undo.`, | |
| 611 | 611 | phrase: "", |
| 612 | 612 | }), |
| 613 | 613 | }, |
test/confirm-detach.test.ts +38
| @@ -8,6 +8,9 @@ import { mkdtemp, rm } from "node:fs/promises"; | ||
| 8 | 8 | import { tmpdir } from "node:os"; |
| 9 | 9 | import { join } from "node:path"; |
| 10 | 10 | import { createApp, type App } from "../src/app.ts"; |
| 11 | import { detachJob, detachPlanWords } from "../src/actions/detach.ts"; | |
| 12 | import { listEvents } from "../src/core/events.ts"; | |
| 13 | import { eventVerb } from "../src/design/activity.ts"; | |
| 11 | 14 | import { createRepo, forkRepo, type Repo } from "../src/core/repos.ts"; |
| 12 | 15 | import { repoDir } from "../src/git/repos.ts"; |
| 13 | 16 | import { commitFiles } from "../src/git/write.ts"; |
| @@ -83,3 +86,38 @@ test("the confirm page is a heading, a sentence and a form, not a box (#9860)", | ||
| 83 | 86 | expect(body).not.toContain('class="card'); |
| 84 | 87 | expect(body).toMatch(new RegExp(`<form method="post" action="${to}"`)); |
| 85 | 88 | }); |
| 89 | ||
| 90 | test("the plan words say what stays and what stops, for the terminal and the MCP (#11338)", () => { | |
| 91 | for (const borrows of [true, false]) { | |
| 92 | const words = detachPlanWords({ parent: "a/b", borrows, dryRun: true }); | |
| 93 | expect(words).toContain("The code and its history stay"); | |
| 94 | expect(words).toContain("Updates from the parent and offers to it stop."); | |
| 95 | } | |
| 96 | }); | |
| 97 | ||
| 98 | test("the confirm and the Danger zone say what stays and what stops; the history receipts the severing (#11338)", async () => { | |
| 99 | const base = `/${fork.ownerName}/${fork.name}`; | |
outdated
Dynamic imports inside the test are needless. Import at the top as the file does elsewhere. Also assert the flash message and the Danger zone section wording, which the shared constants claim to cover. |
||
| 100 | const to = `${base}/settings/detach`; | |
| 101 | const danger = await (await fetch(`${servers.url}${base}/settings/danger`, { headers: { cookie } })).text(); | |
| 102 | expect(danger).toContain("The code and its history stay"); | |
| 103 | expect(danger).toContain("Updates from the parent and offers to it stop."); | |
| 104 | const body = await (await fetch(`${servers.url}/-/confirm?to=${encodeURIComponent(to)}`, { headers: { cookie } })).text(); | |
| 105 | expect(body).toContain("The code and its history stay"); | |
| 106 | expect(body).toContain("Updates from the parent and offers to it stop."); | |
| 107 | expect(body).toContain("recorded in this repository"); | |
| 108 | ||
| 109 | const post = await fetch(`${servers.url}${to}`, { | |
| 110 | method: "POST", | |
| 111 | headers: { cookie, origin: servers.url }, | |
| 112 | redirect: "manual", | |
| 113 | }); | |
| 114 | expect(post.status).toBe(303); | |
| 115 | const flash = decodeURIComponent(post.headers.get("set-cookie") ?? "").replace(/\+/g, " "); | |
| 116 | expect(flash).toContain("The repack runs on its own; the history records it when it lands."); | |
| 117 | await detachJob(fork.id)?.done; | |
| 118 | const events = listEvents(app.db, { repoId: fork.id, kind: "repo.detached", limit: 5 }); | |
| 119 | expect(events).toHaveLength(1); | |
| 120 | expect(eventVerb(events[0]!)).toBe( | |
| 121 | "severed a fork from its upstream: code and history kept, updates and offers stopped", | |
| 122 | ); | |
| 123 | }, 30_000); |
A design module now imports actions/detach.ts, so the presentation layer depends on an action module. Put the shared wording in a small words module (like actions/origin_words.ts) that both can import.