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

A fork with an archived parent drops the 'following · N ahead' badge, and an admin can detach a fork from its parent #7477

Merged opened this in answer to

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

Merged

mason/forge:mason/7452-a-fork-with-an-archived-parent-drops-the → main 5 commits fork 4 threads · 4 unresolved

  • Merged. mason/7452-a-fork-with-an-archived-parent-drops-the was merged into main as c62d0b7.

Review

Sign in to review this change.
23 files changed +840 −20
README.md +1 −1
@@ -56,7 +56,7 @@ Home opens with "Since you were here · 2 days": the pulls that landed in public
5656
5757 In the terminal, the System screen (palette: System) shows a forge admin who is a person one more line, `telemetry:read held by: …` (the agents holding the grant), and `g` there picks an agent and asks `grant <agent> telemetry:read? y/n` (or `take back …`; the default is n). `y` calls `setTelemetryGrant`, as MCP `telemetry_grant` does; anyone that tool refuses sees neither the line nor the key hint, and `g` answers them in the tool's words.
5858
59 An admin's home and the terminal's Flow dialog also say when the forge is clogged and how long the last clog took to notice and to clear: `docs/features/clog-watch.md`.
59 An admin's home and the terminal's Flow dialog also say when the forge is clogged and how long the last clog took to notice and to clear: `docs/features/clog-watch.md`. A fork whose parent is archived shows one quiet line instead of its ahead/behind count, and its admin can detach it from the parent (Settings › Danger zone, the terminal's About `x`, MCP `detach`): `docs/features/detach.md`.
6060
6161 A pull the merge queue holds as red alone (red on its own merge at its current head; cleared by a new head or a moved main) says so under its checks line: "red alone at <time>, waiting for a new head or a new main", on the pull page, its live checks line and the studio change step, and in the checks row of the pull's terminal screen (`redMark` in `src/checks/queue.ts`). Nothing is drawn when it is not held.
6262
docs/MCP.md +1
@@ -71,6 +71,7 @@ for a tunnel and a forge that sleeps.
7171 | `contribute` | for any arriving agent: what this forge is, its bottleneck, open “help wanted” issues, how to propose | |
7272 | `converge` | the fork's crew merges where both sides changed the same files; comes back as a pull | yes |
7373 | `decisions_by` | `{ repo, by?, dryRun? }`: the standing rule for a repository's hand-overs (`me`, `decider`, `decider_tell`); with `by`, planned first (`dryRun` is true unless `false`); set only by the app's admin with an admin token, never an agent | yes |
74 | `detach` | `{ repo, dryRun? }`: detach a fork from its parent — repack so it owns its objects, drop the alternates link, forget the parent; planned first (`dryRun` is true unless `false`); the repack runs on its own, so call again to read its `phase` (`repacking`, `failed`) until the fork has no `parent`; only the repository's admin with an admin token, never an agent | yes |
7475 | `drills` | each failure domain: `key`, what breaks, its last drill's time, green or red and why (or why it is not drilled), and the `command` that runs it; a forge admin who is a person only (`forbidden` otherwise), as Settings → Backups says them | |
7576 | `edit` | a new title or body for an issue or a pull: its author's, or a writer's; an agent edits only what it wrote | yes |
7677 | `egress_grants` | `{ repo, op?, destination? }`: the private `host:port` destinations an app's forge.json asks for and which are granted; `op` grant or revoke by a site admin (never an agent), and every op needs an admin-scope token; the same as the settings page and `/api/v1/repos/:owner/:name/egress-grants` | yes |
docs/features/detach.md added +8
@@ -0,0 +1,8 @@
1 # Detach a fork from its parent
2
3 A fork borrows its parent's objects through git alternates, and the forge shows how far it stands from that parent. When the parent is put away for good, the fork can stand alone.
4
5 - **Archived parent.** When a fork's parent is archived, the fork's page shows one quiet line, "forked from owner/name (archived)", in place of the Following card. There is no ahead/behind count, nothing to take, and no Propose upstream. The archived repository's forks page lists its forks by name, with no count against them.
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 - **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 - **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`.
src/actions/detach.ts added +142
@@ -0,0 +1,142 @@
1 /**
2 * Detach a fork from its parent (#7452): repack so the fork owns every
3 * object it borrowed through alternates, drop the link (`detachFork`, the
4 * same step the delete path takes), then forget the parent. Planned first,
5 * like archive. The repack can take minutes on a big history, so it runs
6 * off the request path; its phase is kept here, per process, and the
7 * Settings card and the MCP read it back. Git stays the truth: a restart
8 * mid-repack leaves the parent named and the alternates file in place, and
9 * asking again finishes the job.
10 */
11
12 import type { App } from "../app.ts";
13 import { clearForkParent, forkParentFor, type Repo } from "../core/repos.ts";
14 import type { User } from "../core/users.ts";
15 import { detachFork, hasAlternates, repoDir } from "../git/repos.ts";
16 import { createLog } from "../kernel/log.ts";
17 import { err, ok, type Result } from "../kernel/result.ts";
18 import { requireRepoAdmin } from "./access.ts";
19
20 const log = createLog("detach");
21
22 /** A detach under way, or the last one that failed. Gone once it lands. */
23 export interface DetachJob {
24 readonly phase: "repacking" | "failed";
25 readonly startedAt: string;
26 /** Why it failed; empty while repacking. */
27 readonly message: string;
28 /** Settles when the job does; for tests and for whoever waits. */
29 readonly done: Promise<void>;
30 }
31
32 /**
33 * One entry per fork that is detaching or whose last detach failed; a
34 * failure stays until the next try lands. So it is bounded by the number of
35 * forks in this forge, and empties on restart.
36 */
37 const jobs = new Map<string, DetachJob>();
38
39 /** The detach under way on this repository, or the last that failed. */
40 export function detachJob(repoId: string): DetachJob | undefined {
41 return jobs.get(repoId);
42 }
43
44 export interface DetachPlan {
45 /**
46 * owner/name of the parent, only while `actor` may read it
47 * (`forkParentFor`); null when they may not, or its row is gone.
48 */
49 readonly parent: string | null;
50 /** Whether the fork still borrows objects, so a repack copies them in. */
51 readonly borrows: boolean;
52 readonly dryRun: boolean;
53 }
54
55 export type DetachError = "not_found" | "not_a_fork" | "running";
56
57 /**
58 * Plan (dryRun) or start a detach. A repository admin's, never an agent's.
59 * Starting answers at once; the repack and the parent's removal follow.
60 */
61 export async function detachFromParent(
62 app: App,
63 actor: User | undefined,
64 repo: Repo,
outdated

Same leak: the MCP plan returns parent from getRepoById without canRead. Use forkParentFor(app.db, repo, actor).

65 dryRun: boolean,
66 ): Promise<Result<DetachPlan, DetachError>> {
67 const admin = requireRepoAdmin(app, actor, repo);
68 if (!admin.ok || admin.value.kind === "agent") return err("not_found");
69 if (repo.forkParentId === null) return err("not_a_fork");
70 const parentId = repo.forkParentId;
71 const row = forkParentFor(app.db, repo, actor);
72 const parent = row === undefined ? null : `${row.ownerName}/${row.name}`;
73 const dir = repoDir(app.reposRoot, repo.ownerName, repo.name);
74 const plan = { parent, borrows: await hasAlternates(dir), dryRun };
75 if (dryRun) return ok(plan);
76 if (jobs.get(repo.id)?.phase === "repacking") return err("running");
77 const startedAt = new Date().toISOString();
78 const done = run(app, repo, dir, admin.value.id, parentId, startedAt);
79 jobs.set(repo.id, {
80 phase: "repacking",
81 startedAt,
82 message: "",
83 done,
84 });
85 return ok(plan);
86 }
87
88 async function run(
89 app: App,
90 repo: Repo,
91 dir: string,
92 actorId: string,
93 parentId: string,
94 startedAt: string,
95 ): Promise<void> {
96 // Yield first, so the job is recorded before anything can finish it.
97 await Promise.resolve();
98 const detached = await detachFork(dir);
99 if (!detached.ok) {
100 log.error("detach failed", {
101 repo: `${repo.ownerName}/${repo.name}`,
102 stderr: detached.error.stderr,
103 });
104 jobs.set(repo.id, {
105 phase: "failed",
106 startedAt,
107 message: failureLine(detached.error.stderr),
108 done: Promise.resolve(),
109 });
110 return;
111 }
112 clearForkParent(app.db, repo.id);
113 jobs.delete(repo.id);
outdated

repo.detached is in READER_KINDS, so every reader of the fork receives this payload. The parent's owner/name must not be in it unless every such reader may read the parent. Drop it, or store only the id.

114 // Every reader of the fork reads this event, and not every one may read
115 // the parent: its id, never its name.
116 app.bus.append({
117 kind: "repo.detached",
118 repoId: repo.id,
119 actorId,
120 payload: { parentId },
121 });
122 }
123
124 /** git's first line about it, short: the card says it, the log keeps the rest. */
125 function failureLine(stderr: string): string {
126 const line = stderr.trim().split("\n")[0] ?? "";
127 return line === "" ? "git could not repack it" : line.slice(0, 200);
128 }
129
130 /** The plan in one sentence, for the terminal's confirm and the MCP. */
131 export function detachPlanWords(plan: DetachPlan): string {
132 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 }
135
136 /** Where a detach stands, in words; null when none is under way or failed. */
137 export function detachPhaseWords(job: DetachJob | undefined, ago: (iso: string) => string): string | null {
138 if (job === undefined) return null;
139 return job.phase === "repacking"
140 ? `repacking, started ${ago(job.startedAt)}`
141 : `stopped: ${job.message}`;
142 }
src/actions/forks.ts +16 −5
@@ -209,6 +209,12 @@ export interface ForkRanking {
209209 readonly nothingNew: RankedFork[];
210210 /** True when forks past `MAX_RANKED_FORKS` were left out of the ranking. */
211211 readonly truncated: boolean;
212 /**
213 * The repository is archived: no fork is counted against it, because a
214 * frozen parent's "N ahead" reads as unshipped work (#7452). Every fork is
215 * in `nothingNew`, with no drift, and a surface lists names only.
216 */
217 readonly archived: boolean;
212218 }
213219
214220 /** The ranked forks of `repo` among those `mayRead`: one answer for every surface. */
@@ -217,10 +223,15 @@ export async function rankedForksOf(
217223 repo: Repo,
218224 forks: readonly Repo[],
219225 ): Promise<ForkRanking> {
226 const truncated = forks.length > MAX_RANKED_FORKS;
227 if (repo.archivedAt !== null) {
228 const unmeasured = forks.slice(0, MAX_RANKED_FORKS).map((fork) => ({
229 fork,
230 drift: { ahead: null, behind: null },
231 pushedAt: null,
232 }));
233 return { total: forks.length, offering: [], nothingNew: unmeasured, truncated, archived: true };
234 }
220235 const ranked = rankForks(await driftOfForks(app, repo, forks));
221 return {
222 total: forks.length,
223 ...ranked,
224 truncated: forks.length > MAX_RANKED_FORKS,
225 };
236 return { total: forks.length, ...ranked, truncated, archived: false };
226237 }
src/actions/offer_upstream.ts +2
@@ -27,6 +27,8 @@ export function upstreamOffer(
2727 ahead: number | null | undefined,
2828 ): UpstreamOffer | null {
2929 if (parent === undefined || viewer === undefined) return null;
30 // An archived parent takes nothing in; the fork's own work is not owed to it.
31 if (parent.archivedAt !== null) return null;
3032 if (ahead === null || ahead === undefined || ahead <= 0) return null;
3133 if (!canRead(app.db, parent, viewer)) return null;
3234 const open = openPullFromHead(app.db, parent.id, fork.id, fork.defaultBranch);
src/core/event_audience.ts +1
@@ -114,6 +114,7 @@ const READER_KINDS: ReadonlySet<string> = new Set([
114114 "repo.created",
115115 "repo.vision_kept",
116116 "repo.forked",
117 "repo.detached",
117118 "repo.deleted",
118119 "label.updated",
119120 "label.deleted",
src/core/following.ts +3 −1
@@ -588,7 +588,9 @@ export async function lineageOf(
588588 size++;
589589 const child: Draft = { repo: fork, relation: undefined, children: [] };
590590 // The budget is spent in order; the reads themselves run together.
591 if (relations < LINEAGE_MAX_RELATIONS) {
591 // Against an archived parent a count reads as unshipped work, so
592 // none is taken (#7452).
593 if (node.repo.archivedAt === null && relations < LINEAGE_MAX_RELATIONS) {
592594 relations++;
593595 asked.push(
594596 forkRelation(forge, fork, node.repo, tips).then((r) => {
src/core/repos.ts +11 −3
@@ -622,6 +622,16 @@ export function forkParentFor(
622622 : undefined;
623623 }
624624
625 /**
626 * Forget a fork's parent. Only once the fork owns its objects (`detachFork`):
627 * a row without a parent over a store that still borrows one is a fork that
628 * breaks when the parent goes.
629 */
630 export function clearForkParent(db: Database, repoId: string): void {
631 db.query("UPDATE repo SET fork_parent_id = NULL WHERE id = ?1").run(repoId);
632 forgetListings(db);
633 }
634
625635 /** The forks of this repository the viewer may see — the list, and its count. */
626636 export function visibleForks(
627637 db: Database,
@@ -892,9 +902,7 @@ export async function deleteRepo(
892902 });
893903 return err("delete_failed");
894904 }
895 db.query("UPDATE repo SET fork_parent_id = NULL WHERE id = ?1").run(
896 fork.id,
897 );
905 clearForkParent(db, fork.id);
898906 }
899907
900908 const dir = repoDir(reposRoot, repo.ownerName, repo.name);
src/design/activity.ts +1
@@ -120,6 +120,7 @@ const WORDS: Record<string, string> = {
120120 "pull.thread_resolved": "resolved a review thread",
121121 "repo.created": "created a repository",
122122 "repo.forked": "forked a repository",
123 "repo.detached": "detached a fork from its parent",
123124 "repo.settings_updated": "changed repository settings",
124125 "repo.upstream_follow": "changed which upstream the repository follows",
125126 "repo.following": "turned following an upstream on or off",
src/http/mcp/tools/detach.ts added +45
@@ -0,0 +1,45 @@
1 import { scopeAllows } from "../../../core/tokens.ts";
2 import { detachFromParent, detachJob, detachPlanWords } from "../../../actions/detach.ts";
3 import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
4
5 export const tool: Tool = {
6 name: "detach",
7 doc: "`{ repo, dryRun? }`: detach a fork from its parent — repack so it owns its objects, drop the alternates link, forget the parent; planned first (`dryRun` is true unless `false`); the repack runs on its own, so call again to read its `phase` (`repacking`, `failed`) until the fork has no `parent`; only the repository's admin with an admin token, never an agent",
8 description: "Detach a fork from its parent. Plans first; admin token.",
9 inputSchema: {
10 type: "object",
11 properties: {
12 repo: REPO_ARG,
13 dryRun: { type: "boolean" },
14 },
15 required: ["repo"],
16 },
17 writes: true,
18 dryByDefault: true,
19 async run(ctx, user, args) {
20 const repo = repoArg(ctx, user, args);
21 if (isAnswer(repo)) return repo;
22 const dryRun = args["dryRun"] !== false;
23 if (!dryRun && !scopeAllows(ctx.auth.scope ?? "ro", "admin"))
24 return bad("forbidden", "detaching needs an admin token");
25 const job = detachJob(repo.id);
26 const phase = job === undefined ? null : { phase: job.phase, startedAt: job.startedAt, message: job.message };
27 if (repo.forkParentId === null && job === undefined)
28 return good({ parent: null, says: "No parent: this repository is not a fork, or is already detached." });
29 const planned = await detachFromParent(ctx.app, user, repo, dryRun);
30 if (!planned.ok) {
31 if (planned.error === "running")
32 return good({ ...phase, says: "A detach is already running; call again to see it finish." });
33 if (planned.error === "not_a_fork")
34 return good({ parent: null, says: "No parent: this repository is not a fork, or is already detached." });
35 return bad("forbidden", "only an admin of this repository detaches it");
36 }
37 return good({
38 ...planned.value,
39 ...(phase === null ? {} : { last: phase }),
40 says: dryRun
41 ? `This is the plan; nothing changed. ${detachPlanWords(planned.value)} Send dryRun: false to detach.`
42 : "Detaching: the repack runs on its own. Call again to see it finish.",
43 });
44 },
45 };
src/http/mcp/tools/index.ts +1
@@ -41,6 +41,7 @@ export { tool as confirm_wait } from "./confirm_wait.ts";
4141 export { tool as contribute } from "./contribute.ts";
4242 export { tool as converge } from "./converge.ts";
4343 export { tool as decisions_by } from "./decisions_by.ts";
44 export { tool as detach } from "./detach.ts";
4445 export { tool as drills } from "./drills.ts";
4546 export { tool as edit } from "./edit.ts";
4647 export { tool as egress_grants } from "./egress_grants.ts";
src/http/server.ts +2
@@ -385,6 +385,7 @@ import {
385385 mirrorStop,
386386 } from "../web/pages/settings.ts";
387387 import { dangerPage, repoDelete } from "../web/pages/danger.ts";
388 import { detachSubmit } from "../web/pages/detach.ts";
388389 import { hooksPage, webhookCreate, webhookDelete } from "../web/pages/hooks.ts";
389390 import {
390391 crewAdd,
@@ -1464,6 +1465,7 @@ export function buildRouter(): Router<Handler> {
14641465 router.add("POST", "/:owner/:name/settings/hooks/:id/delete", webhookDelete);
14651466 router.add("GET", "/:owner/:name/settings/danger", dangerPage);
14661467 router.add("POST", "/:owner/:name/settings/delete", repoDelete);
1468 router.add("POST", "/:owner/:name/settings/detach", detachSubmit);
14671469 router.add(
14681470 "POST",
14691471 "/:owner/:name/settings/origin/reconnect",
src/tui/screens/about.ts +62 −1
@@ -23,6 +23,12 @@ import { sshCloneUrl } from "../../ssh/command.ts";
2323 import { checkMemoryAt } from "../../apps/check-memory.ts";
2424 import { repoDir } from "../../git/repos.ts";
2525 import { forkDrift } from "../../actions/forks.ts";
26 import {
27 detachFromParent,
28 detachJob,
29 detachPhaseWords,
30 detachPlanWords,
31 } from "../../actions/detach.ts";
2632 import { upstreamOffer, type UpstreamOffer } from "../../actions/offer_upstream.ts";
2733 import { listRefs } from "../../git/read.ts";
2834 import { directionNote } from "../../crew/direct.ts";
@@ -99,6 +105,10 @@ export function aboutScreen(
99105 // Outsiders is the same person-and-admin gate; `d` (direct) never clashes
100106 // with `o`, the fork's upstream offer.
101107 const mayOutsiders = (): boolean => mayFlow();
108 // Detach is a person's, an admin's, and only on a fork made here: the
109 // Danger zone card's gate (#7452).
110 const mayDetach = (): boolean =>
111 mayFlow() && repo !== undefined && repo.forkParentId !== null;
102112 const checking = (): boolean =>
103113 repo !== undefined && followShown(f.app, repo, f.user)?.enabled === true;
104114 const factRows = (): [string, string][] => [
@@ -109,6 +119,9 @@ export function aboutScreen(
109119 ...trailRows(f, repo),
110120 ...upstreamRows(f, repo),
111121 ...offerRows(offered),
122 // The phase and git's failure line are the Danger zone card's, an
outdated

This row is drawn for every viewer of About, but on the web the phase and git's failure line live only on the admin-only Danger zone card. A guest on a public fork would see stopped: <git stderr>, which can contain the server's repository path. Gate it the way the x key is gated: ...(mayDetach() ? detachRows(repo) : []), and add a reader-sees-no-row test.

123 // admin's: shown here behind the same gate as `x`.
124 ...(mayDetach() ? detachRows(repo) : []),
112125 ...cell,
113126 ];
114127 let rows = factRows();
@@ -116,7 +129,9 @@ export function aboutScreen(
116129 // button, read the same way (`upstreamOffer`), pressed here with `o`.
117130 const forkParent =
118131 repo === undefined ? undefined : forkParentFor(f.db, repo, f.user);
119 if (repo !== undefined && forkParent !== undefined) {
132 // An archived parent is offered nothing (`upstreamOffer`), so its drift
133 // is not read either, as the web home does.
134 if (repo !== undefined && forkParent !== undefined && forkParent.archivedAt === null) {
120135 const at = repo;
121136 void forkDrift(f.app, at, forkParent)
122137 .then((drift) => {
@@ -190,6 +205,9 @@ export function aboutScreen(
190205 binding(["f"], checking() ? "stop following upstream" : "follow upstream"),
191206 ]
192207 : []),
208 ...(mayDetach() && detachJob(repo!.id)?.phase !== "repacking"
209 ? [binding(["x"], "detach from parent")]
210 : []),
193211 // A site admin also reaches the repository's private egress grants.
194212 ...(f.user?.isAdmin === true && repo !== undefined
195213 ? [binding(["e"], "egress"), binding(["c"], "connections")]
@@ -334,6 +352,42 @@ export function aboutScreen(
334352 });
335353 return true;
336354 }
355 if (e.key === "x" && repo !== undefined && mayDetach()) {
356 const at = repo;
357 void detachFromParent(f.app, f.user, at, true).then((plan) => {
358 if (!plan.ok) return ctx.toast("that could not be detached", "danger");
359 ctx.push(
360 confirmScreen(ctx, screen, {
361 title: "Detach from parent",
362 body: [detachPlanWords(plan.value)],
363 actions: [{ label: "Detach" }, { label: "Cancel" }],
364 onChoose: (i) => {
365 if (i !== 0) return;
366 void detachFromParent(f.app, f.user, at, false).then((started) => {
367 if (!started.ok) {
368 ctx.toast(
369 started.error === "running" ? "a detach is already running" : "that could not be detached",
370 "danger",
371 );
372 return ctx.invalidate();
373 }
374 rows = factRows();
375 ctx.toast("detaching: the repack runs on its own", "default");
376 ctx.invalidate();
377 void detachJob(at.id)?.done.then(() => {
378 repo = getRepoById(f.db, at.id) ?? at;
379 rows = factRows();
380 const failed = detachJob(at.id)?.phase === "failed";
381 ctx.toast(failed ? "the detach stopped" : "detached from its parent", failed ? "danger" : "success");
382 ctx.invalidate();
383 });
384 });
385 },
386 }),
387 );
388 });
389 return true;
390 }
337391 if (e.key === "y" && cloneUrl !== "") {
338392 ctx.copy(cloneUrl);
339393 ctx.toast("copied the clone URL", "success");
@@ -500,6 +554,13 @@ export function outsidersRow(repo: Repo | undefined): [string, string][] {
500554 ]);
501555 }
502556
557 /** Where a detach from the parent stands (#7452): the Danger zone card's line. */
558 export function detachRows(repo: Repo | undefined): [string, string][] {
559 if (repo === undefined) return [];
560 const words = detachPhaseWords(detachJob(repo.id), ago);
561 return words === null ? [] : [["detach", words]];
562 }
563
503564 /** One line when an admin has put the repository away. */
504565 function archivedRow(repo: Repo | undefined): [string, string][] {
505566 if (repo === undefined || repo.archivedAt === null) return [];
src/tui/screens/forks.ts +2 −2
@@ -39,7 +39,7 @@ export function forkRows(
3939 {
4040 repo: f.fork,
4141 name: `${f.fork.ownerName}/${f.fork.name}`,
42 words: driftWords(f.drift),
42 words: ranked.archived ? "" : driftWords(f.drift),
4343 heading,
4444 },
4545 ...(below.get(f.fork.id) ?? []).map((n) => ({
@@ -51,7 +51,7 @@ export function forkRows(
5151 ];
5252 return [
5353 ...ranked.offering.flatMap((f) => row(f, "")),
54 ...ranked.nothingNew.flatMap((f, i) => row(f, i === 0 ? nothingNewHeading(ranked.nothingNew.length) : "")),
54 ...ranked.nothingNew.flatMap((f, i) => row(f, i === 0 && !ranked.archived ? nothingNewHeading(ranked.nothingNew.length) : "")),
5555 ];
5656 }
5757
src/web/pages/danger.ts +9 −1
@@ -14,6 +14,7 @@ import { html } from "../html.ts";
1414 import { icon } from "../icons.ts";
1515 import { flashTo } from "../layout.ts";
1616 import { card, confirmAction } from "../ui.ts";
17 import { detachCard } from "./detach.ts";
1718 import { adminForm, settingsPageResponse, timedAdminRepo } from "./settings.ts";
1819
1920 export function dangerPage(ctx: Ctx): Response {
@@ -45,7 +46,14 @@ export function dangerPage(ctx: Ctx): Response {
4546 <h2>${DELETE_REPO}</h2>`,
4647 },
4748 );
48 const response = settingsPageResponse(ctx, repo, "danger", "Danger zone", body);
49 const detach = detachCard(ctx.app.db, repo, ctx.auth.user);
50 const response = settingsPageResponse(
51 ctx,
52 repo,
53 "danger",
54 "Danger zone",
55 detach === null ? body : html`${detach}<div class="mt">${body}</div>`,
56 );
4957 notePhase(ctx.request, "render", performance.now() - rendering);
5058 return response;
5159 }
src/web/pages/detach.ts added +87
@@ -0,0 +1,87 @@
1 /**
2 * Settings › Danger zone › "Detach from parent" (#7452). The card is the
3 * plan — what the detach copies and what it ends — and its button starts
4 * 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
6 * `actions/detach.ts`.
7 */
8
9 import { detachFromParent, detachJob } from "../../actions/detach.ts";
10 import { forkParentFor, type Repo } from "../../core/repos.ts";
11 import type { User } from "../../core/users.ts";
12 import type { Ctx } from "../../http/context.ts";
13 import { repoBase } from "../chrome.ts";
14 import { html, type Html } from "../html.ts";
15 import { icon } from "../icons.ts";
16 import { flashTo } from "../layout.ts";
17 import { btn, card, timeAgo } from "../ui.ts";
18 import type { Database } from "bun:sqlite";
19 import { adminForm } from "./settings.ts";
20
21 /** The card, for a fork; null on a repository that follows nothing. */
22 export function detachCard(
23 db: Database,
outdated

Use forkParentFor(db, repo, viewer) here. getRepoById names a parent the admin may not read, which leaks a private repository's name. Pass the viewer into detachCard.

24 repo: Repo,
25 viewer: User | undefined,
26 ): Html | null {
27 if (repo.forkParentId === null) return null;
28 // Named only for an admin who may read it; a fork never speaks for a
29 // parent that was not opened to them.
30 const parent = forkParentFor(db, repo, viewer);
31 const name = parent === undefined ? "its parent" : `${parent.ownerName}/${parent.name}`;
32 const job = detachJob(repo.id);
33 const action = `${repoBase(repo)}/settings/detach`;
34 const body =
35 job?.phase === "repacking"
36 ? html`<p data-detach-progress>
37 ${icon("sync")} Detaching from ${name}: copying the objects it lends
38 into this repository, started ${timeAgo(job.startedAt)}. Reload to
39 see it finish; pushes and clones keep working meanwhile.
40 </p>`
41 : html`${job?.phase === "failed"
42 ? html`<div class="toast" data-tone="danger" role="status" data-detach-failed>
43 ${icon("alert")}
44 <div class="toast-body">
45 The last detach stopped: ${job.message}. Nothing changed; try
46 again.
47 </div>
48 </div>`
49 : null}
50 <p>
51 Copies every object this fork borrows from <strong>${name}</strong>
52 into its own store, drops the link, then forgets the parent: no
53 Following card, no updates to take, nothing to propose upstream.
54 Branches, history, issues and pulls stay. There is no undo.
55 </p>
56 <form method="post" action="${action}" class="form-actions">
57 ${btn("Detach from parent", { variant: "danger", icon: "fork" })}
58 </form>`;
59 return card(body, {
60 id: "detach",
61 tone: "danger",
62 head: html`${icon("fork")}
63 <h2>Detach from parent</h2>`,
64 });
65 }
66
67 /** POST /:owner/:name/settings/detach — start the detach; the card shows it. */
68 export async function detachSubmit(ctx: Ctx): Promise<Response> {
69 const gate = await adminForm(ctx);
70 if (gate instanceof Response) return gate;
71 const { repo } = gate;
72 const back = `${repoBase(repo)}/settings/danger`;
73 const started = await detachFromParent(ctx.app, ctx.auth.user, repo, false);
74 if (!started.ok)
75 return flashTo(ctx, back, {
76 tone: "danger",
77 message:
78 started.error === "running"
79 ? "A detach is already running."
80 : started.error === "not_a_fork"
81 ? "This repository has no parent to detach from."
82 : "Only an admin of this repository detaches it.",
83 });
84 return flashTo(ctx, back, {
85 message: `Detaching from ${started.value.parent ?? "its parent"}: the repack runs on its own.`,
86 });
87 }
src/web/pages/following.ts +25
@@ -16,6 +16,7 @@ import {
1616 } from "../../actions/forks.ts";
1717 import { listCrewRoles } from "../../core/agents.ts";
1818 import {
19 canAdmin,
1920 canRead,
2021 canWrite,
2122 forkParentFor,
@@ -99,6 +100,7 @@ export async function followingOf(
99100 repo: Repo,
100101 ): Promise<Following | undefined> {
101102 if (repo.forkParentId === null) return undefined;
103 if (archivedParent(ctx, repo) !== undefined) return undefined;
102104 const relation = await relationOf(ctx.app, ctx.auth.user, repo);
103105 if (relation === undefined) return undefined;
104106 // A parent the viewer may not read is not named here at all.
@@ -120,6 +122,29 @@ export async function followingOf(
120122 return { relation, converge, releases };
121123 }
122124
125 /** The parent, when the viewer may read it and an admin has archived it. */
126 function archivedParent(ctx: Ctx, repo: Repo): Repo | undefined {
127 const parent = forkParentFor(ctx.app.db, repo, ctx.auth.user);
128 return parent?.archivedAt === null ? undefined : parent;
129 }
130
131 /**
132 * In place of the Following card, when the parent is archived (#7452): one
133 * quiet line. A frozen parent's "N ahead" reads as unshipped work, and there
134 * is nothing to take from it or offer it, so no count and no button.
135 */
136 export function archivedParentLine(ctx: Ctx, repo: Repo): Html | null {
137 const parent = archivedParent(ctx, repo);
138 if (parent === undefined) return null;
139 return html`<p class="mt small muted" data-archived-parent>
140 ${icon("fork")} forked from
141 <a href="/${parent.ownerName}/${parent.name}">${parent.ownerName}/${parent.name}</a>
142 (archived)${canAdmin(ctx.app.db, repo, ctx.auth.user)
143 ? html` · <a href="${repoBase(repo)}/settings/danger#detach">detach from it</a>`
144 : null}
145 </p>`;
146 }
147
123148 /**
124149 * "N releases behind <parent>", each with its name and link; or, when the
125150 * parent cannot be read, that it cannot and how old the last copy is.
src/web/pages/repo.ts +7 −4
@@ -28,6 +28,7 @@ import {
2828 } from "../../actions/forks.ts";
2929 import { lineageOf } from "../../core/following.ts";
3030 import {
31 archivedParentLine,
3132 followingCard,
3233 followingOf,
3334 lineageCard,
@@ -202,7 +203,7 @@ function forkRanking(
202203 title: html`<a href="/${f.fork.ownerName}/${f.fork.name}"
203204 >${f.fork.ownerName}/${f.fork.name}</a
204205 >`,
205 meta: html`${driftWords(f.drift)}${f.pushedAt === null
206 meta: html`${ranked.archived ? "" : driftWords(f.drift)}${f.pushedAt === null
206207 ? html``
207208 : html` · pushed ${time(f.pushedAt)}`}`,
208209 ...(offer
@@ -226,7 +227,9 @@ function forkRanking(
226227 : listCard(offering.map((f) => line(f, true)))}
227228 ${nothingNew.length === 0
228229 ? html``
229 : html`<h3 class="mt">${nothingNewHeading(nothingNew.length)}</h3>
230 : html`${ranked.archived
231 ? ""
232 : html`<h3 class="mt">${nothingNewHeading(nothingNew.length)}</h3>`}
230233 ${listCard(nothingNew.map((f) => line(f, false)))}`}`;
231234 }
232235
@@ -1243,7 +1246,7 @@ async function codeHome(ctx: Ctx, repo: Repo): Promise<Response> {
12431246 const parent = forkParent(ctx, repo);
12441247 const [entries, drift] = await Promise.all([
12451248 timePhaseAsync(ctx.request, "tree", () => scope.source.tree(ref, "")),
1246 parent === undefined
1249 parent === undefined || parent.archivedAt !== null
12471250 ? undefined
12481251 : timePhaseAsync(ctx.request, "drift", () => forkDrift(ctx.app, repo, parent)),
12491252 ]);
@@ -1274,7 +1277,7 @@ async function codeHome(ctx: Ctx, repo: Repo): Promise<Response> {
12741277 · ${latest.headline} · ${latest.date}</p>`}
12751278 ${frozenBlock(ctx, repo)} ${appStrip(ctx, repo)}
12761279 ${following === undefined
1277 ? forkBar(ctx, repo)
1280 ? html`${archivedParentLine(ctx, repo)}${forkBar(ctx, repo)}`
12781281 : followingCard(ctx, repo, following)}
12791282 ${await renderCode(scope, {
12801283 refs,
src/web/studio/app.ts +8 −2
@@ -36,7 +36,11 @@ import { html, when, type Html } from "../html.ts";
3636 import { appLine } from "../describe.ts";
3737 import { page } from "../layout.ts";
3838 import { liveClientJs, liveMount } from "../live.ts";
39 import { followingCard, followingOf } from "../pages/following.ts";
39 import {
40 archivedParentLine,
41 followingCard,
42 followingOf,
43 } from "../pages/following.ts";
4044 import {
4145 avatar,
4246 badge,
@@ -501,7 +505,9 @@ export async function studioApp(
501505 compact: true,
502506 ownTitle: true,
503507 })}
504 ${following === undefined ? "" : followingCard(ctx, repo, following)}
508 ${following === undefined
509 ? archivedParentLine(ctx, repo)
510 : followingCard(ctx, repo, following)}
505511 <div id="app-head">${head}</div>
506512 ${askingLine(ctx, repo)}
507513 ${policy}
test/fork-detach.test.ts added +248
@@ -0,0 +1,248 @@
1 /**
2 * #7452: a fork whose parent is archived shows one quiet line, never
3 * "following <parent> · N ahead", and neither does the parent's forks page;
4 * and a repository admin detaches a fork from its parent — planned first,
5 * repacked off the request path, then the parent forgotten.
6 */
7 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { createApp, type App } from "../src/app.ts";
12 import { detachFromParent, detachJob } from "../src/actions/detach.ts";
13 import { updateRepoSettings } from "../src/actions/repos.ts";
14 import { createRepo, forkRepo, getRepoById, type Repo } from "../src/core/repos.ts";
15 import { createToken } from "../src/core/tokens.ts";
16 import { listEvents } from "../src/core/events.ts";
17 import type { User } from "../src/core/users.ts";
18 import { hasAlternates, repoDir } from "../src/git/repos.ts";
19 import { commitFiles } from "../src/git/write.ts";
20 import { serve, type Servers } from "../src/http/server.ts";
21 import { quietLogs } from "./helpers/globals.ts";
22 import { signUpProven as signUp } from "./helpers/verified.ts";
23
24 quietLogs();
25
26 let dataDir: string;
27 let app: App;
28 let servers: Servers;
29 let owner: User;
30 let forker: User;
31 let n = 0;
32
33 const ok = <T>(r: { ok: true; value: T } | { ok: false; error: unknown }): T => {
34 if (!r.ok) throw new Error(JSON.stringify(r.error));
35 return r.value;
36 };
37
38 const git = async (cwd: string, ...args: string[]) => {
39 const p = Bun.spawn(["git", ...args], { cwd, stdout: "pipe", stderr: "pipe" });
40 const [out, code] = await Promise.all([new Response(p.stdout).text(), p.exited]);
41 return { out: out.trim(), code };
42 };
43
44 const commit = async (dir: string, subject: string) =>
45 ok(
46 await commitFiles(dir, {
47 branch: "main",
48 message: subject,
49 files: [{ path: `${subject}.txt`, content: subject }],
50 author: { name: "T", email: "[email protected]" },
51 }),
52 );
53
54 /** A public parent of two commits, and a fork two commits ahead of it. */
55 async function family(): Promise<{ parent: Repo; fork: Repo; forkDir: string }> {
56 n += 1;
57 const parent = ok(
58 await createRepo(app.db, app.reposRoot, { ownerId: owner.id, name: `up${n}`, visibility: "public" }),
59 );
60 const parentDir = repoDir(app.reposRoot, parent.ownerName, parent.name);
61 await commit(parentDir, "one");
62 await commit(parentDir, "two");
63 const fork = ok(await forkRepo(app.db, app.reposRoot, parent, forker.id, `down${n}`));
64 const forkDir = repoDir(app.reposRoot, fork.ownerName, fork.name);
65 await commit(forkDir, "mine");
66 await commit(forkDir, "also-mine");
67 return { parent, fork, forkDir };
68 }
69
70 const page = async (path: string) => (await fetch(`${servers.url}${path}`)).text();
71
72 async function mcp(token: string, args: unknown): Promise<{ error?: string; value?: any }> {
73 const res = await fetch(`${servers.url}/mcp`, {
74 method: "POST",
75 headers: {
76 "content-type": "application/json",
77 accept: "application/json, text/event-stream",
78 authorization: `Bearer ${token}`,
79 },
80 body: JSON.stringify({
81 jsonrpc: "2.0",
82 id: 1,
83 method: "tools/call",
84 params: { name: "detach", arguments: args },
85 }),
86 });
87 const body = (await res.json()) as any;
88 const text: string = body.result.content[0].text;
89 return body.result.isError === true ? { error: text } : { value: JSON.parse(text) };
90 }
91
92 beforeAll(async () => {
93 dataDir = await mkdtemp(join(tmpdir(), "forge-fork-detach-"));
94 app = createApp({ dataDir, port: 0 });
95 servers = await serve(app);
96 const user = async (username: string) =>
97 ok(await signUp(app, { username, email: `${username}@forge.test`, password: "a-long-passphrase" }));
98 owner = await user("upstreamer");
99 forker = await user("forker");
100 }, 30_000);
101
102 afterAll(async () => {
103 servers?.stop();
104 app?.close();
105 await rm(dataDir, { recursive: true, force: true });
106 });
107
108 describe("an archived parent", () => {
109 test("hides the following card and the ahead badge on the fork, its forks page and its lineage", async () => {
110 const { parent, fork } = await family();
111 const forkPath = `/${fork.ownerName}/${fork.name}`;
112 const parentForks = `/${parent.ownerName}/${parent.name}/forks`;
113
114 // Awake, the fork says how far it is ahead.
115 const before = await page(forkPath);
116 expect(before).toContain('id="following"');
117 expect(before).toContain("2 ahead");
118 expect(await page(parentForks)).toContain("2 ahead");
119
120 ok(await updateRepoSettings(app, owner, parent, { archived: true }));
121
122 const after = await page(forkPath);
123 expect(after).not.toContain('id="following"');
124 expect(after).not.toContain("2 ahead");
125 expect(after).toContain("data-archived-parent");
126 expect(after).toMatch(/forked from\s*<a href="\/upstreamer\/up\d+">upstreamer\/up\d+<\/a>\s*\(archived\)/);
127 // Its forks page counts nothing against it, and names the fork still.
128 const forks = await page(parentForks);
129 expect(forks).not.toContain("2 ahead");
130 expect(forks).not.toContain("nothing new");
131 expect(forks).toContain(`${fork.ownerName}/${fork.name}`);
132 // The family tree seen from the fork carries no badge against it.
133 const lineage = await page(`${forkPath}/forks`);
134 expect(lineage).not.toContain("2 ahead");
135 }, 30_000);
136 });
137
138 describe("detach from parent", () => {
139 test("is planned first, then leaves no alternates, a sound repository and no parent", async () => {
140 const { parent, fork, forkDir } = await family();
141 expect(await hasAlternates(forkDir)).toBe(true);
142 const token = createToken(app.db, forker.id, "admin", "admin").token;
143
144 const plan = await mcp(token, { repo: `${fork.ownerName}/${fork.name}` });
145 expect(plan.value).toMatchObject({
146 dryRun: true,
147 borrows: true,
148 parent: `${parent.ownerName}/${parent.name}`,
149 });
150 expect(getRepoById(app.db, fork.id)!.forkParentId).toBe(parent.id);
151 expect(detachJob(fork.id)).toBeUndefined();
152
153 const started = await mcp(token, { repo: `${fork.ownerName}/${fork.name}`, dryRun: false });
154 expect(started.value).toMatchObject({ dryRun: false });
155 await detachJob(fork.id)?.done;
156
157 expect(await hasAlternates(forkDir)).toBe(false);
158 expect((await git(forkDir, "fsck", "--full")).code).toBe(0);
159 expect((await git(forkDir, "log", "--format=%s", "main")).out.split("\n")).toEqual([
160 "also-mine",
161 "mine",
162 "two",
163 "one",
164 ]);
165 expect(getRepoById(app.db, fork.id)!.forkParentId).toBeNull();
166 expect(detachJob(fork.id)).toBeUndefined();
167 expect(listEvents(app.db, { repoId: fork.id, kind: "repo.detached", limit: 5 })).toHaveLength(1);
168 // The parent is not its parent any more, on the page or to the tool.
169 expect(await page(`/${fork.ownerName}/${fork.name}`)).not.toContain('id="following"');
170 expect((await mcp(token, { repo: `${fork.ownerName}/${fork.name}` })).value).toMatchObject({ parent: null });
171 }, 30_000);
172
173 test("is an admin's: a reader is refused and nothing changes", async () => {
174 const { fork, forkDir } = await family();
175 const asked = await detachFromParent(app, owner, fork, false);
176 expect(asked).toEqual({ ok: false, error: "not_found" });
177 expect(await hasAlternates(forkDir)).toBe(true);
178 const small = createToken(app.db, forker.id, "rw", "rw").token;
179 expect((await mcp(small, { repo: `${fork.ownerName}/${fork.name}`, dryRun: false })).error).toContain(
180 "forbidden",
181 );
182 expect(getRepoById(app.db, fork.id)!.forkParentId).not.toBeNull();
183 }, 30_000);
184
185 test("Settings › Danger zone shows the card, and its same-origin POST detaches", async () => {
186 const { fork, forkDir } = await family();
187 const base = `/${fork.ownerName}/${fork.name}`;
188 const signin = await fetch(`${servers.url}/signin`, {
189 method: "POST",
190 headers: { origin: servers.url },
191 body: new URLSearchParams({ username: "forker", password: "a-long-passphrase" }),
192 redirect: "manual",
193 });
194 const cookie = (signin.headers.get("set-cookie") ?? "").split(";")[0]!;
195 // Two taps from the fork once its parent is archived: the quiet line, then the card.
196 ok(await updateRepoSettings(app, owner, getRepoById(app.db, fork.forkParentId!)!, { archived: true }));
197 const home = await (await fetch(`${servers.url}${base}`, { headers: { cookie } })).text();
198 expect(home).toContain(`href="${base}/settings/danger#detach"`);
199 const danger = await (await fetch(`${servers.url}${base}/settings/danger`, { headers: { cookie } })).text();
200 expect(danger).toContain(`action="${base}/settings/detach"`);
201 expect(danger).toContain("Detach from parent");
202
203 const post = (origin: string) =>
204 fetch(`${servers.url}${base}/settings/detach`, {
205 method: "POST",
206 headers: { cookie, origin },
207 body: new URLSearchParams({}),
208 redirect: "manual",
209 });
210 const foreign = await post("https://evil.example");
211 expect(foreign.status).toBeGreaterThanOrEqual(400);
212 expect(detachJob(fork.id)).toBeUndefined();
213 const res = await post(servers.url);
214 expect(res.status).toBe(303);
215 await detachJob(fork.id)?.done;
216 expect(await hasAlternates(forkDir)).toBe(false);
217 expect(getRepoById(app.db, fork.id)!.forkParentId).toBeNull();
218 }, 30_000);
219
220 test("never names a parent its admin may not read: not on the card, in the plan or in the event", async () => {
221 n += 1;
222 const secret = ok(
223 await createRepo(app.db, app.reposRoot, { ownerId: owner.id, name: `hidden${n}`, visibility: "private" }),
224 );
225 await commit(repoDir(app.reposRoot, secret.ownerName, secret.name), "one");
226 // Forked while it could be read; the forker cannot read it now.
227 const fork = ok(await forkRepo(app.db, app.reposRoot, secret, forker.id, `kept${n}`));
228 const forkDir = repoDir(app.reposRoot, fork.ownerName, fork.name);
229 const name = `${secret.ownerName}/${secret.name}`;
230 const { detachCard } = await import("../src/web/pages/detach.ts");
231 const card = String(detachCard(app.db, fork, forker));
232 expect(card).toContain("its parent");
233 expect(card).not.toContain(secret.name);
234
235 const token = createToken(app.db, forker.id, "admin", "admin").token;
236 const plan = await mcp(token, { repo: `${fork.ownerName}/${fork.name}` });
237 expect(plan.value).toMatchObject({ parent: null, borrows: true, dryRun: true });
238 expect(JSON.stringify(plan.value)).not.toContain(secret.name);
239
240 await mcp(token, { repo: `${fork.ownerName}/${fork.name}`, dryRun: false });
241 await detachJob(fork.id)?.done;
242 expect(await hasAlternates(forkDir)).toBe(false);
243 const events = listEvents(app.db, { repoId: fork.id, kind: "repo.detached", limit: 5 });
244 expect(events).toHaveLength(1);
245 expect(JSON.stringify(events[0]!.payload)).not.toContain(name);
246 expect(events[0]!.payload).toEqual({ parentId: secret.id });
247 }, 30_000);
248 });
test/mcp.test.ts +1
@@ -234,6 +234,7 @@ describe("the forge over MCP", () => {
234234 "origins",
235235 "decisions_by",
236236 "archive",
237 "detach",
237238 "trail",
238239 "migrate",
239240 "events",
test/tui-about-detach.test.ts added +157
@@ -0,0 +1,157 @@
1 /**
2 * Detach at the terminal's door (#7452): on a fork's About, an admin's `x`
3 * shows the plan, and only the confirm detaches; the card's row says the
4 * phase. A reader and an agent are not offered it, and an archived parent
5 * offers no `o`.
6 */
7 import { afterAll, beforeAll, expect, test } from "bun:test";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { createApp, type App } from "../src/app.ts";
12 import { detachJob } from "../src/actions/detach.ts";
13 import { addToCrew, createAgent } from "../src/actions/crew.ts";
14 import { commitToBranch, updateRepoSettings } from "../src/actions/repos.ts";
15 import { createRepo, forkRepo, getRepoById, type Repo } from "../src/core/repos.ts";
16 import { createUser, type User } from "../src/core/users.ts";
17 import { hasAlternates, repoDir } from "../src/git/repos.ts";
18 import type { Ctx, Screen } from "../src/tui/app/app.ts";
19 import { aboutScreen, detachRows } from "../src/tui/screens/about.ts";
20 import { localSource } from "../src/view/local.ts";
21 import type { Session } from "../src/tui/session.ts";
22 import { Screen as Surface } from "../src/tui/term/screen.ts";
23 import { theme } from "../src/tui/ui/theme.ts";
24 import { detachFromParent } from "../src/actions/detach.ts";
25 import { quietLogs } from "./helpers/globals.ts";
26
27 quietLogs();
28
29 let dataDir: string;
30 let app: App;
31 let upstreamer: User;
32 let forker: User;
33 let reader: User;
34 let bot: User;
35 let parent: Repo;
36 let fork: Repo;
37
38 const ok = <T>(r: { ok: true; value: T } | { ok: false; error: unknown }): T => {
39 if (!r.ok) throw new Error(JSON.stringify(r.error));
40 return r.value;
41 };
42 /** Poll for what the test checks; fail clearly at the deadline. */
43 async function until(what: string, done: () => boolean, ms = 5000): Promise<void> {
44 const end = Date.now() + ms;
45 while (!done()) {
46 if (Date.now() > end) throw new Error(`timed out waiting for ${what}`);
47 await Bun.sleep(10);
48 }
49 }
50
51 function card(user: User, target: Repo = fork) {
52 const pushed: Screen[] = [];
53 const ctx = {
54 theme: theme("dark"),
55 invalidate() {},
56 toast() {},
57 push: (s: Screen) => pushed.push(s),
58 pop() {},
59 copy() {},
60 } as unknown as Ctx;
61 const f = { app, db: app.db, user } as unknown as Session;
62 const src = localSource(app, getRepoById(app.db, target.id)!, user);
63 const screen = aboutScreen(f, ctx, { render() {} } as unknown as Screen, src, undefined, "main");
64 const offered = (key: string) => screen.keys.some((b) => b.keys.includes(key));
65 const press = (key: string) => screen.key!({ key } as never);
66 const draw = (): string => {
67 const s = new Surface(110, 40);
68 screen.render({ x: 0, y: 0, w: 110, h: 40 }, s);
69 return s.toText();
70 };
71 return { pushed, offered, press, draw };
72 }
73
74 beforeAll(async () => {
75 dataDir = await mkdtemp(join(tmpdir(), "forge-tui-detach-"));
76 app = createApp({ dataDir, port: 0 });
77 const make = async (username: string, adminIfFirst = false) =>
78 ok(
79 await createUser(app.db, {
80 origin: "seed",
81 trusted: true,
82 username,
83 email: `${username}@forge.local`,
84 password: "a-good-passphrase",
85 adminIfFirst,
86 }),
87 );
88 // The site admin only makes the agent; a site admin reads everything.
89 const root = await make("founder", true);
90 upstreamer = await make("upstreamer");
91 forker = await make("forker");
92 reader = await make("reader");
93 parent = ok(await createRepo(app.db, app.reposRoot, { ownerId: upstreamer.id, name: "up", visibility: "public" }));
94 ok(await commitToBranch(app, upstreamer, parent, { message: "one", files: [{ path: "a.txt", content: "a\n" }] }));
95 fork = ok(await forkRepo(app.db, app.reposRoot, parent, forker.id, "down"));
96 ok(await commitToBranch(app, forker, fork, { message: "two", files: [{ path: "b.txt", content: "b\n" }] }));
97 const agent = ok(await createAgent(app, root, { username: "bot" }));
98 expect(addToCrew(app, root, fork, "bot").ok).toBe(true);
99 bot = agent.user;
100 }, 30_000);
101
102 afterAll(async () => {
103 app?.close();
104 await rm(dataDir, { recursive: true, force: true });
105 });
106
107 test("a reader and an agent are not offered x, and it does nothing", async () => {
108 for (const who of [reader, bot]) {
109 const c = card(who);
110 expect(c.offered("x")).toBe(false);
111 c.press("x");
112 expect(c.pushed).toHaveLength(0);
113 }
114 expect(getRepoById(app.db, fork.id)!.forkParentId).toBe(parent.id);
115 });
116
117 test("an archived parent offers no o, and x plans, confirms, then detaches", async () => {
118 ok(await updateRepoSettings(app, upstreamer, parent, { archived: true }));
119 // Against an archived parent the drift is never read, so no offer can
120 // arrive later: the keys are final as drawn.
121 const c = card(forker);
122 expect(c.offered("o")).toBe(false);
123 expect(c.offered("x")).toBe(true);
124
125 // Cancel changes nothing.
126 c.press("x");
127 await until("the plan", () => c.pushed.length === 1);
128 c.pushed[0]!.key!({ key: "esc" } as never);
129 expect(detachJob(fork.id)).toBeUndefined();
130
131 c.press("x");
132 await until("the plan again", () => c.pushed.length === 2);
133 expect(c.pushed[1]!.title).toBe("Detach from parent");
134 c.pushed[1]!.key!({ key: "enter" } as never);
135 // The repack may land between two polls, so wait for the outcome, not
136 // for the job to be seen running (the next test reads the running row).
137 await until("the detach to land", () => getRepoById(app.db, fork.id)!.forkParentId === null, 20_000);
138 expect(detachJob(fork.id)).toBeUndefined();
139 expect(await hasAlternates(repoDir(app.reposRoot, fork.ownerName, fork.name))).toBe(false);
140 expect(detachRows(getRepoById(app.db, fork.id))).toEqual([]);
141 // Once detached, there is nothing to detach from.
142 expect(card(forker).offered("x")).toBe(false);
143 }, 30_000);
144
145 test("the phase row is the admin's: a reader's About has none while a detach runs", async () => {
146 const side = ok(await forkRepo(app.db, app.reposRoot, parent, forker.id, "side"));
147 ok(await detachFromParent(app, forker, side, false));
148 expect(detachJob(side.id)?.phase).toBe("repacking");
149 // Drawn before the repack can land: the admin sees the phase, a reader does not.
150 const admin = card(forker, side).draw();
151 const guest = card(reader, side).draw();
152 await detachJob(side.id)?.done;
153 expect(admin).toMatch(/detach +repacking, started/);
154 expect(guest).toContain("clone (http)");
155 expect(guest).not.toMatch(/detach +(repacking|stopped)/);
156 }, 30_000);
157

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs