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

Detach from parent goes through the confirm page, which names the parent and says there is no undo #8004

Merged opened this

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

Merged

mason/forge:mason/7978-detach-from-parent-goes-through-the-conf → main 1 commit fork 1 thread · 1 unresolved

  • Merged. mason/7978-detach-from-parent-goes-through-the-conf was merged into main as 60b2782.

Review

Sign in to review this change.
5 files changed +94 −8
src/web/pages/detach.ts +6 −6
@@ -2,7 +2,8 @@
22 * Settings › Danger zone › "Detach from parent" (#7452). The card is the
33 * plan — what the detach copies and what it ends — and its button starts
44 * it. While the repack runs the card says so and since when; a failure
5 * stays on the card with git's line until the next try. The mechanics are
5 * stays on the card with git's line until the next try. The button goes
6 * through /-/confirm first (#7978): the detach has no undo. The mechanics are
67 * `actions/detach.ts`.
78 */
89
@@ -14,7 +15,7 @@ import { repoBase } from "../chrome.ts";
1415 import { html, type Html } from "../html.ts";
1516 import { icon } from "../icons.ts";
1617 import { flashTo } from "../layout.ts";
17 import { btn, card, timeAgo } from "../ui.ts";
18 import { card, confirmAction, timeAgo } from "../ui.ts";
1819 import type { Database } from "bun:sqlite";
1920 import { adminForm } from "./settings.ts";
2021
@@ -30,7 +31,6 @@ export function detachCard(
3031 const parent = forkParentFor(db, repo, viewer);
3132 const name = parent === undefined ? "its parent" : `${parent.ownerName}/${parent.name}`;
3233 const job = detachJob(repo.id);
33 const action = `${repoBase(repo)}/settings/detach`;
3434 const body =
3535 job?.phase === "repacking"
3636 ? html`<p data-detach-progress>
@@ -53,9 +53,9 @@ export function detachCard(
5353 Following card, no updates to take, nothing to propose upstream.
5454 Branches, history, issues and pulls stay. There is no undo.
5555 </p>
56 <form method="post" action="${action}" class="form-actions">
57 ${btn("Detach from parent", { variant: "danger", icon: "fork" })}
58 </form>`;
56 <div class="form-actions">
57 ${confirmAction({ action: `${repoBase(repo)}/settings/detach`, verb: "Detach from parent" })}
58 </div>`;
5959 return card(body, {
6060 id: "detach",
6161 tone: "danger",
src/web/pages/search.ts +10
@@ -541,6 +541,16 @@ const DESTRUCTIVE: {
541541 phrase: `${m[1]}/${m[2]}`,
542542 }),
543543 },
544 {
545 pattern: /^\/([^/]+)\/([^/]+)\/settings\/detach$/,
546 describe: (m) => ({
547 verb: "Detach from parent",
548 title: `Detach ${m[1]}/${m[2]} from its parent?`,
549 detail:

Optional: the detail does not name the parent. That is fine for privacy, but note it in docs/features/detach.md.

550 "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.",
551 phrase: "",
552 }),
553 },
544554 {
545555 pattern: /^\/([^/]+)\/([^/]+)\/branches\/([^/]+)\/delete$/,
546556 describe: (m) => ({
test/confirm-detach.test.ts added +77
@@ -0,0 +1,77 @@
1 /**
2 * #7978: "Detach from parent" has no undo, so the fork's danger zone links
3 * it to /-/confirm, which names the fork and posts the detach on the second
4 * step exactly as the button used to.
5 */
6 import { afterAll, beforeAll, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { createApp, type App } from "../src/app.ts";
11 import { createRepo, forkRepo, type Repo } from "../src/core/repos.ts";
12 import { repoDir } from "../src/git/repos.ts";
13 import { commitFiles } from "../src/git/write.ts";
14 import { serve, type Servers } from "../src/http/server.ts";
15 import { quietLogs } from "./helpers/globals.ts";
16 import { signUpProven as signUp } from "./helpers/verified.ts";
17
18 quietLogs();
19
20 let dataDir: string;
21 let app: App;
22 let servers: Servers;
23 let fork: Repo;
24 let cookie: string;
25
26 const ok = <T>(r: { ok: true; value: T } | { ok: false; error: unknown }): T => {
27 if (!r.ok) throw new Error(JSON.stringify(r.error));
28 return r.value;
29 };
30
31 beforeAll(async () => {
32 dataDir = await mkdtemp(join(tmpdir(), "forge-confirm-detach-"));
33 app = createApp({ dataDir, port: 0 });
34 servers = await serve(app);
35 const user = async (username: string) =>
36 ok(await signUp(app, { username, email: `${username}@forge.test`, password: "a-long-passphrase" }));
37 const owner = await user("upstreamer");
38 const forker = await user("forker");
39 const parent = ok(await createRepo(app.db, app.reposRoot, { ownerId: owner.id, name: "up", visibility: "public" }));
40 ok(
41 await commitFiles(repoDir(app.reposRoot, parent.ownerName, parent.name), {
42 branch: "main",
43 message: "one",
44 files: [{ path: "one.txt", content: "one" }],
45 author: { name: "T", email: "[email protected]" },
46 }),
47 );
48 fork = ok(await forkRepo(app.db, app.reposRoot, parent, forker.id, "down"));
49 const signin = await fetch(`${servers.url}/signin`, {
50 method: "POST",
51 headers: { origin: servers.url },
52 body: new URLSearchParams({ username: "forker", password: "a-long-passphrase" }),
53 redirect: "manual",
54 });
55 cookie = (signin.headers.get("set-cookie") ?? "").split(";")[0]!;
56 }, 30_000);
57
58 afterAll(async () => {
59 servers.stop();
60 app.close();
61 await rm(dataDir, { recursive: true, force: true });
62 });
63
64 test("the fork's danger zone sends Detach from parent through the confirm page", async () => {
65 const base = `/${fork.ownerName}/${fork.name}`;
66 const to = `${base}/settings/detach`;
67 const danger = await (await fetch(`${servers.url}${base}/settings/danger`, { headers: { cookie } })).text();
68 expect(danger).toContain(`href="/-/confirm?to=${encodeURIComponent(to)}"`);
69 expect(danger).not.toContain(`action="${to}"`);
70
71 const confirm = await fetch(`${servers.url}/-/confirm?to=${encodeURIComponent(to)}`, { headers: { cookie } });
72 expect(confirm.status).toBe(200);
73 const body = await confirm.text();
74 expect(body).toContain(`Detach forker/down from its parent?`);
75 expect(body).toContain("There is no undo.");
76 expect(body).toContain(`action="${to}"`);
77 });
test/danger-buttons.test.ts −1
@@ -15,7 +15,6 @@ const allowed: readonly { file: string; button: string; reason: string }[] = [
1515 { file: "src/web/pages/boards-view.ts", button: `"Delete this board"`, reason: "board delete; waits for its confirm issue" },
1616 { file: "src/web/pages/org.ts", button: `"Remove"`, reason: "team member remove; waits for its confirm issue" },
1717 { file: "src/web/pages/edit.ts", button: `"Delete this file"`, reason: "the delete page is the confirmation, reached by a link" },
18 { file: "src/web/pages/detach.ts", button: `"Detach from parent"`, reason: "the detach page is the confirmation, reached from settings" },
1918 { file: "src/web/pages/pull.ts", button: `"Land past the gate"`, reason: "asks for a typed reason of at least 8 characters" },
2019 { file: "src/web/studio/notes.ts", button: "label", reason: "owner note ban; undone by unban on the same row" },
2120 ];
test/fork-detach.test.ts +1 −1
@@ -197,7 +197,7 @@ describe("detach from parent", () => {
197197 const home = await (await fetch(`${servers.url}${base}`, { headers: { cookie } })).text();
198198 expect(home).toContain(`href="${base}/settings/danger#detach"`);
199199 const danger = await (await fetch(`${servers.url}${base}/settings/danger`, { headers: { cookie } })).text();
200 expect(danger).toContain(`action="${base}/settings/detach"`);
200 expect(danger).toContain(`href="/-/confirm?to=${encodeURIComponent(`${base}/settings/detach`)}"`);
201201 expect(danger).toContain("Detach from parent");
202202
203203 const post = (origin: string) =>

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs