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

Merged opened this

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

Merged

scout/forge:scout/11338-severing-a-fork-from-its-upstream-asks-o → main 2 commits fork 5 threads · 5 unresolved

  • Merged. scout/11338-severing-a-fork-from-its-upstream-asks-o was merged into main as 56f7aab.

Review

Sign in to review this change.
7 files changed +63 −9
docs/features/detach.md +2
@@ -6,3 +6,5 @@ A fork borrows its parent's objects through git alternates, and the forge shows
66 - **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.
77 - **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.
88 - **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";
1616 import { createLog } from "../kernel/log.ts";
1717 import { err, ok, type Result } from "../kernel/result.ts";
1818 import { requireRepoAdmin } from "./access.ts";
19 import { DETACH_STAYS, DETACH_STOPS } from "./detach_words.ts";
1920
2021 const log = createLog("detach");
2122
@@ -130,7 +131,7 @@ function failureLine(stderr: string): string {
130131 /** The plan in one sentence, for the terminal's confirm and the MCP. */
131132 export function detachPlanWords(plan: DetachPlan): string {
132133 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.`;
134135 }
135136
136137 /** 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 @@
1010 */
1111
1212 import { VIA_WORDS } from "../core/acting_via.ts";
13 import { DETACH_RECEIPT } from "../actions/detach_words.ts";
1314 import type { ForgeEvent } from "../core/events.ts";

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.

1415
1516 /** The pull number an environment name carries, for `pull-12`. */
@@ -170,7 +171,7 @@ const WORDS: Record<string, string> = {
170171 "pull.thread_resolved": "resolved a review thread",
171172 "repo.created": "created a repository",
172173 "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.

174175 "repo.settings_updated": "changed repository settings",
175176 "repo.upstream_follow": "changed which upstream the repository follows",
176177 "repo.source_added": "followed another source",
src/web/pages/detach.ts +5 −5
@@ -8,6 +8,7 @@
88 */
99
1010 import { detachFromParent, detachJob, type DetachJob } from "../../actions/detach.ts";
11 import { DETACH_STAYS, DETACH_STOPS } from "../../actions/detach_words.ts";
1112 import { forkParentFor, type Repo } from "../../core/repos.ts";
1213 import type { User } from "../../core/users.ts";
1314 import type { Ctx } from "../../http/context.ts";
@@ -64,10 +65,9 @@ export function detachBlock({ name, action, job }: DetachView): Html {
6465 </div>`
6566 : null}
6667 <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.

7171 </p>
7272 <div class="form-actions">
7373 ${confirmAction({ action, verb: "Detach from parent" })}
@@ -96,6 +96,6 @@ export async function detachSubmit(ctx: Ctx): Promise<Response> {
9696 : "Only an admin of this repository detaches it.",
9797 });
9898 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.'

100100 });
101101 }
src/web/pages/search.ts +2 −2
@@ -10,6 +10,7 @@
1010 * that was never meant to be confirmed.
1111 */
1212
13 import { DETACH_STAYS, DETACH_STOPS } from "../../actions/detach_words.ts";
1314 import { DISCONNECT_DETAIL } from "../../actions/origin_words.ts";
1415 import { strangerCodeSearch } from "../../http/strangers.ts";
1516 import { search } from "../../actions/search.ts";
@@ -606,8 +607,7 @@ const DESTRUCTIVE: {
606607 describe: (m) => ({
607608 verb: "Detach from parent",
608609 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.`,
611611 phrase: "",
612612 }),
613613 },
test/confirm-detach.test.ts +38
@@ -8,6 +8,9 @@ import { mkdtemp, rm } from "node:fs/promises";
88 import { tmpdir } from "node:os";
99 import { join } from "node:path";
1010 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";
1114 import { createRepo, forkRepo, type Repo } from "../src/core/repos.ts";
1215 import { repoDir } from "../src/git/repos.ts";
1316 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)",
8386 expect(body).not.toContain('class="card');
8487 expect(body).toMatch(new RegExp(`<form method="post" action="${to}"`));
8588 });
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);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs