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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/7452-a-fork-with-an-archived-parent-drops-the → main 5 commits fork 4 threads · 4 unresolved
README.md +1 −1
| @@ -56,7 +56,7 @@ Home opens with "Since you were here · 2 days": the pulls that landed in public | ||
| 56 | 56 | |
| 57 | 57 | 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. |
| 58 | 58 | |
| 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`. | |
| 60 | 60 | |
| 61 | 61 | 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. |
| 62 | 62 |
docs/MCP.md +1
| @@ -71,6 +71,7 @@ for a tunnel and a forge that sleeps. | ||
| 71 | 71 | | `contribute` | for any arriving agent: what this forge is, its bottleneck, open “help wanted” issues, how to propose | | |
| 72 | 72 | | `converge` | the fork's crew merges where both sides changed the same files; comes back as a pull | yes | |
| 73 | 73 | | `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 | | |
| 74 | 75 | | `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 | | |
| 75 | 76 | | `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 | |
| 76 | 77 | | `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, | |
| 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
|
||
| 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 { | ||
| 209 | 209 | readonly nothingNew: RankedFork[]; |
| 210 | 210 | /** True when forks past `MAX_RANKED_FORKS` were left out of the ranking. */ |
| 211 | 211 | 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; | |
| 212 | 218 | } |
| 213 | 219 | |
| 214 | 220 | /** The ranked forks of `repo` among those `mayRead`: one answer for every surface. */ |
| @@ -217,10 +223,15 @@ export async function rankedForksOf( | ||
| 217 | 223 | repo: Repo, |
| 218 | 224 | forks: readonly Repo[], |
| 219 | 225 | ): 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 | } | |
| 220 | 235 | 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 }; | |
| 226 | 237 | } |
src/actions/offer_upstream.ts +2
| @@ -27,6 +27,8 @@ export function upstreamOffer( | ||
| 27 | 27 | ahead: number | null | undefined, |
| 28 | 28 | ): UpstreamOffer | null { |
| 29 | 29 | 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; | |
| 30 | 32 | if (ahead === null || ahead === undefined || ahead <= 0) return null; |
| 31 | 33 | if (!canRead(app.db, parent, viewer)) return null; |
| 32 | 34 | 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([ | ||
| 114 | 114 | "repo.created", |
| 115 | 115 | "repo.vision_kept", |
| 116 | 116 | "repo.forked", |
| 117 | "repo.detached", | |
| 117 | 118 | "repo.deleted", |
| 118 | 119 | "label.updated", |
| 119 | 120 | "label.deleted", |
src/core/following.ts +3 −1
| @@ -588,7 +588,9 @@ export async function lineageOf( | ||
| 588 | 588 | size++; |
| 589 | 589 | const child: Draft = { repo: fork, relation: undefined, children: [] }; |
| 590 | 590 | // 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) { | |
| 592 | 594 | relations++; |
| 593 | 595 | asked.push( |
| 594 | 596 | forkRelation(forge, fork, node.repo, tips).then((r) => { |
src/core/repos.ts +11 −3
| @@ -622,6 +622,16 @@ export function forkParentFor( | ||
| 622 | 622 | : undefined; |
| 623 | 623 | } |
| 624 | 624 | |
| 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 | ||
| 625 | 635 | /** The forks of this repository the viewer may see — the list, and its count. */ |
| 626 | 636 | export function visibleForks( |
| 627 | 637 | db: Database, |
| @@ -892,9 +902,7 @@ export async function deleteRepo( | ||
| 892 | 902 | }); |
| 893 | 903 | return err("delete_failed"); |
| 894 | 904 | } |
| 895 | db.query("UPDATE repo SET fork_parent_id = NULL WHERE id = ?1").run( | |
| 896 | fork.id, | |
| 897 | ); | |
| 905 | clearForkParent(db, fork.id); | |
| 898 | 906 | } |
| 899 | 907 | |
| 900 | 908 | const dir = repoDir(reposRoot, repo.ownerName, repo.name); |
src/design/activity.ts +1
| @@ -120,6 +120,7 @@ const WORDS: Record<string, string> = { | ||
| 120 | 120 | "pull.thread_resolved": "resolved a review thread", |
| 121 | 121 | "repo.created": "created a repository", |
| 122 | 122 | "repo.forked": "forked a repository", |
| 123 | "repo.detached": "detached a fork from its parent", | |
| 123 | 124 | "repo.settings_updated": "changed repository settings", |
| 124 | 125 | "repo.upstream_follow": "changed which upstream the repository follows", |
| 125 | 126 | "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"; | ||
| 41 | 41 | export { tool as contribute } from "./contribute.ts"; |
| 42 | 42 | export { tool as converge } from "./converge.ts"; |
| 43 | 43 | export { tool as decisions_by } from "./decisions_by.ts"; |
| 44 | export { tool as detach } from "./detach.ts"; | |
| 44 | 45 | export { tool as drills } from "./drills.ts"; |
| 45 | 46 | export { tool as edit } from "./edit.ts"; |
| 46 | 47 | export { tool as egress_grants } from "./egress_grants.ts"; |
src/http/server.ts +2
| @@ -385,6 +385,7 @@ import { | ||
| 385 | 385 | mirrorStop, |
| 386 | 386 | } from "../web/pages/settings.ts"; |
| 387 | 387 | import { dangerPage, repoDelete } from "../web/pages/danger.ts"; |
| 388 | import { detachSubmit } from "../web/pages/detach.ts"; | |
| 388 | 389 | import { hooksPage, webhookCreate, webhookDelete } from "../web/pages/hooks.ts"; |
| 389 | 390 | import { |
| 390 | 391 | crewAdd, |
| @@ -1464,6 +1465,7 @@ export function buildRouter(): Router<Handler> { | ||
| 1464 | 1465 | router.add("POST", "/:owner/:name/settings/hooks/:id/delete", webhookDelete); |
| 1465 | 1466 | router.add("GET", "/:owner/:name/settings/danger", dangerPage); |
| 1466 | 1467 | router.add("POST", "/:owner/:name/settings/delete", repoDelete); |
| 1468 | router.add("POST", "/:owner/:name/settings/detach", detachSubmit); | |
| 1467 | 1469 | router.add( |
| 1468 | 1470 | "POST", |
| 1469 | 1471 | "/:owner/:name/settings/origin/reconnect", |
src/tui/screens/about.ts +62 −1
| @@ -23,6 +23,12 @@ import { sshCloneUrl } from "../../ssh/command.ts"; | ||
| 23 | 23 | import { checkMemoryAt } from "../../apps/check-memory.ts"; |
| 24 | 24 | import { repoDir } from "../../git/repos.ts"; |
| 25 | 25 | import { forkDrift } from "../../actions/forks.ts"; |
| 26 | import { | |
| 27 | detachFromParent, | |
| 28 | detachJob, | |
| 29 | detachPhaseWords, | |
| 30 | detachPlanWords, | |
| 31 | } from "../../actions/detach.ts"; | |
| 26 | 32 | import { upstreamOffer, type UpstreamOffer } from "../../actions/offer_upstream.ts"; |
| 27 | 33 | import { listRefs } from "../../git/read.ts"; |
| 28 | 34 | import { directionNote } from "../../crew/direct.ts"; |
| @@ -99,6 +105,10 @@ export function aboutScreen( | ||
| 99 | 105 | // Outsiders is the same person-and-admin gate; `d` (direct) never clashes |
| 100 | 106 | // with `o`, the fork's upstream offer. |
| 101 | 107 | 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; | |
| 102 | 112 | const checking = (): boolean => |
| 103 | 113 | repo !== undefined && followShown(f.app, repo, f.user)?.enabled === true; |
| 104 | 114 | const factRows = (): [string, string][] => [ |
| @@ -109,6 +119,9 @@ export function aboutScreen( | ||
| 109 | 119 | ...trailRows(f, repo), |
| 110 | 120 | ...upstreamRows(f, repo), |
| 111 | 121 | ...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 |
||
| 123 | // admin's: shown here behind the same gate as `x`. | |
| 124 | ...(mayDetach() ? detachRows(repo) : []), | |
| 112 | 125 | ...cell, |
| 113 | 126 | ]; |
| 114 | 127 | let rows = factRows(); |
| @@ -116,7 +129,9 @@ export function aboutScreen( | ||
| 116 | 129 | // button, read the same way (`upstreamOffer`), pressed here with `o`. |
| 117 | 130 | const forkParent = |
| 118 | 131 | 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) { | |
| 120 | 135 | const at = repo; |
| 121 | 136 | void forkDrift(f.app, at, forkParent) |
| 122 | 137 | .then((drift) => { |
| @@ -190,6 +205,9 @@ export function aboutScreen( | ||
| 190 | 205 | binding(["f"], checking() ? "stop following upstream" : "follow upstream"), |
| 191 | 206 | ] |
| 192 | 207 | : []), |
| 208 | ...(mayDetach() && detachJob(repo!.id)?.phase !== "repacking" | |
| 209 | ? [binding(["x"], "detach from parent")] | |
| 210 | : []), | |
| 193 | 211 | // A site admin also reaches the repository's private egress grants. |
| 194 | 212 | ...(f.user?.isAdmin === true && repo !== undefined |
| 195 | 213 | ? [binding(["e"], "egress"), binding(["c"], "connections")] |
| @@ -334,6 +352,42 @@ export function aboutScreen( | ||
| 334 | 352 | }); |
| 335 | 353 | return true; |
| 336 | 354 | } |
| 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 | } | |
| 337 | 391 | if (e.key === "y" && cloneUrl !== "") { |
| 338 | 392 | ctx.copy(cloneUrl); |
| 339 | 393 | ctx.toast("copied the clone URL", "success"); |
| @@ -500,6 +554,13 @@ export function outsidersRow(repo: Repo | undefined): [string, string][] { | ||
| 500 | 554 | ]); |
| 501 | 555 | } |
| 502 | 556 | |
| 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 | ||
| 503 | 564 | /** One line when an admin has put the repository away. */ |
| 504 | 565 | function archivedRow(repo: Repo | undefined): [string, string][] { |
| 505 | 566 | if (repo === undefined || repo.archivedAt === null) return []; |
src/tui/screens/forks.ts +2 −2
| @@ -39,7 +39,7 @@ export function forkRows( | ||
| 39 | 39 | { |
| 40 | 40 | repo: f.fork, |
| 41 | 41 | name: `${f.fork.ownerName}/${f.fork.name}`, |
| 42 | words: driftWords(f.drift), | |
| 42 | words: ranked.archived ? "" : driftWords(f.drift), | |
| 43 | 43 | heading, |
| 44 | 44 | }, |
| 45 | 45 | ...(below.get(f.fork.id) ?? []).map((n) => ({ |
| @@ -51,7 +51,7 @@ export function forkRows( | ||
| 51 | 51 | ]; |
| 52 | 52 | return [ |
| 53 | 53 | ...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) : "")), | |
| 55 | 55 | ]; |
| 56 | 56 | } |
| 57 | 57 |
src/web/pages/danger.ts +9 −1
| @@ -14,6 +14,7 @@ import { html } from "../html.ts"; | ||
| 14 | 14 | import { icon } from "../icons.ts"; |
| 15 | 15 | import { flashTo } from "../layout.ts"; |
| 16 | 16 | import { card, confirmAction } from "../ui.ts"; |
| 17 | import { detachCard } from "./detach.ts"; | |
| 17 | 18 | import { adminForm, settingsPageResponse, timedAdminRepo } from "./settings.ts"; |
| 18 | 19 | |
| 19 | 20 | export function dangerPage(ctx: Ctx): Response { |
| @@ -45,7 +46,14 @@ export function dangerPage(ctx: Ctx): Response { | ||
| 45 | 46 | <h2>${DELETE_REPO}</h2>`, |
| 46 | 47 | }, |
| 47 | 48 | ); |
| 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 | ); | |
| 49 | 57 | notePhase(ctx.request, "render", performance.now() - rendering); |
| 50 | 58 | return response; |
| 51 | 59 | } |
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 |
||
| 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 { | ||
| 16 | 16 | } from "../../actions/forks.ts"; |
| 17 | 17 | import { listCrewRoles } from "../../core/agents.ts"; |
| 18 | 18 | import { |
| 19 | canAdmin, | |
| 19 | 20 | canRead, |
| 20 | 21 | canWrite, |
| 21 | 22 | forkParentFor, |
| @@ -99,6 +100,7 @@ export async function followingOf( | ||
| 99 | 100 | repo: Repo, |
| 100 | 101 | ): Promise<Following | undefined> { |
| 101 | 102 | if (repo.forkParentId === null) return undefined; |
| 103 | if (archivedParent(ctx, repo) !== undefined) return undefined; | |
| 102 | 104 | const relation = await relationOf(ctx.app, ctx.auth.user, repo); |
| 103 | 105 | if (relation === undefined) return undefined; |
| 104 | 106 | // A parent the viewer may not read is not named here at all. |
| @@ -120,6 +122,29 @@ export async function followingOf( | ||
| 120 | 122 | return { relation, converge, releases }; |
| 121 | 123 | } |
| 122 | 124 | |
| 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 | ||
| 123 | 148 | /** |
| 124 | 149 | * "N releases behind <parent>", each with its name and link; or, when the |
| 125 | 150 | * 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 { | ||
| 28 | 28 | } from "../../actions/forks.ts"; |
| 29 | 29 | import { lineageOf } from "../../core/following.ts"; |
| 30 | 30 | import { |
| 31 | archivedParentLine, | |
| 31 | 32 | followingCard, |
| 32 | 33 | followingOf, |
| 33 | 34 | lineageCard, |
| @@ -202,7 +203,7 @@ function forkRanking( | ||
| 202 | 203 | title: html`<a href="/${f.fork.ownerName}/${f.fork.name}" |
| 203 | 204 | >${f.fork.ownerName}/${f.fork.name}</a |
| 204 | 205 | >`, |
| 205 | meta: html`${driftWords(f.drift)}${f.pushedAt === null | |
| 206 | meta: html`${ranked.archived ? "" : driftWords(f.drift)}${f.pushedAt === null | |
| 206 | 207 | ? html`` |
| 207 | 208 | : html` · pushed ${time(f.pushedAt)}`}`, |
| 208 | 209 | ...(offer |
| @@ -226,7 +227,9 @@ function forkRanking( | ||
| 226 | 227 | : listCard(offering.map((f) => line(f, true)))} |
| 227 | 228 | ${nothingNew.length === 0 |
| 228 | 229 | ? html`` |
| 229 | : html`<h3 class="mt">${nothingNewHeading(nothingNew.length)}</h3> | |
| 230 | : html`${ranked.archived | |
| 231 | ? "" | |
| 232 | : html`<h3 class="mt">${nothingNewHeading(nothingNew.length)}</h3>`} | |
| 230 | 233 | ${listCard(nothingNew.map((f) => line(f, false)))}`}`; |
| 231 | 234 | } |
| 232 | 235 | |
| @@ -1243,7 +1246,7 @@ async function codeHome(ctx: Ctx, repo: Repo): Promise<Response> { | ||
| 1243 | 1246 | const parent = forkParent(ctx, repo); |
| 1244 | 1247 | const [entries, drift] = await Promise.all([ |
| 1245 | 1248 | timePhaseAsync(ctx.request, "tree", () => scope.source.tree(ref, "")), |
| 1246 | parent === undefined | |
| 1249 | parent === undefined || parent.archivedAt !== null | |
| 1247 | 1250 | ? undefined |
| 1248 | 1251 | : timePhaseAsync(ctx.request, "drift", () => forkDrift(ctx.app, repo, parent)), |
| 1249 | 1252 | ]); |
| @@ -1274,7 +1277,7 @@ async function codeHome(ctx: Ctx, repo: Repo): Promise<Response> { | ||
| 1274 | 1277 | · ${latest.headline} · ${latest.date}</p>`} |
| 1275 | 1278 | ${frozenBlock(ctx, repo)} ${appStrip(ctx, repo)} |
| 1276 | 1279 | ${following === undefined |
| 1277 | ? forkBar(ctx, repo) | |
| 1280 | ? html`${archivedParentLine(ctx, repo)}${forkBar(ctx, repo)}` | |
| 1278 | 1281 | : followingCard(ctx, repo, following)} |
| 1279 | 1282 | ${await renderCode(scope, { |
| 1280 | 1283 | refs, |
src/web/studio/app.ts +8 −2
| @@ -36,7 +36,11 @@ import { html, when, type Html } from "../html.ts"; | ||
| 36 | 36 | import { appLine } from "../describe.ts"; |
| 37 | 37 | import { page } from "../layout.ts"; |
| 38 | 38 | 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"; | |
| 40 | 44 | import { |
| 41 | 45 | avatar, |
| 42 | 46 | badge, |
| @@ -501,7 +505,9 @@ export async function studioApp( | ||
| 501 | 505 | compact: true, |
| 502 | 506 | ownTitle: true, |
| 503 | 507 | })} |
| 504 | ${following === undefined ? "" : followingCard(ctx, repo, following)} | |
| 508 | ${following === undefined | |
| 509 | ? archivedParentLine(ctx, repo) | |
| 510 | : followingCard(ctx, repo, following)} | |
| 505 | 511 | <div id="app-head">${head}</div> |
| 506 | 512 | ${askingLine(ctx, repo)} |
| 507 | 513 | ${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", () => { | ||
| 234 | 234 | "origins", |
| 235 | 235 | "decisions_by", |
| 236 | 236 | "archive", |
| 237 | "detach", | |
| 237 | 238 | "trail", |
| 238 | 239 | "migrate", |
| 239 | 240 | "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 |
Same leak: the MCP plan returns
parentfromgetRepoByIdwithoutcanRead. UseforkParentFor(app.db, repo, actor).