Public: anyone can read this; the crew can change it.
The danger zone and repository delete leave settings.ts for src/web/pages/danger.ts #6340
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/6336-the-danger-zone-and-repository-delete-le → main 6 commits fork 2 threads · 2 unresolved
7 files
changed +268
−76
src/git/heads.ts added +56
| @@ -0,0 +1,56 @@ | ||
| 1 | /** | |
| 2 | * Where every branch points, read from the repository's own files instead of | |
| 3 | * a git process: the loose files under `refs/heads/` and the `refs/heads/` | |
| 4 | * lines of `packed-refs`, a loose ref winning as in git. The answer is one | |
| 5 | * string, `name oid` lines sorted by name, so two reads compare equal exactly | |
| 6 | * when no branch moved, appeared or went away. | |
| 7 | * | |
| 8 | * Anything this does not fully understand (a symbolic ref, an odd line, a | |
| 9 | * reftable repository) answers undefined, and the caller asks git. | |
| 10 | */ | |
| 11 | import { existsSync, readdirSync, readFileSync } from "node:fs"; | |
| 12 | import { join, relative, sep } from "node:path"; | |
| 13 | ||
| 14 | const OID = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/; | |
| 15 | ||
| 16 | export function headsState(dir: string): string | undefined { | |
| 17 | if (existsSync(join(dir, "reftable"))) return undefined; | |
| 18 | const heads = new Map<string, string>(); | |
| 19 | let packed = ""; | |
| 20 | try { | |
| 21 | packed = readFileSync(join(dir, "packed-refs"), "utf8"); | |
| 22 | } catch { | |
| 23 | // no packed-refs: every branch is loose | |
| 24 | } | |
| 25 | for (const line of packed.split("\n")) { | |
| 26 | if (line === "" || line[0] === "#" || line[0] === "^") continue; | |
| 27 | const space = line.indexOf(" "); | |
| 28 | const oid = line.slice(0, space); | |
| 29 | const name = line.slice(space + 1); | |
| 30 | if (!OID.test(oid)) return undefined; | |
| 31 | if (name.startsWith("refs/heads/")) heads.set(name, oid); | |
| 32 | } | |
| 33 | let loose: string[]; | |
| 34 | try { | |
| 35 | loose = readdirSync(join(dir, "refs/heads"), { recursive: true, withFileTypes: true }) | |
| 36 | .filter((e) => !e.isDirectory()) | |
| 37 | .map((e) => join(e.parentPath, e.name)); | |
| 38 | } catch { | |
| 39 | return undefined; | |
| 40 | } | |
| 41 | for (const path of loose) { | |
| 42 | if (path.endsWith(".lock")) return undefined; // a write in flight: let git answer | |
| 43 | let text: string; | |
| 44 | try { | |
| 45 | text = readFileSync(path, "utf8").trim(); | |
| 46 | } catch { | |
| 47 | return undefined; // moved while we looked: let git answer | |
| 48 | } | |
| 49 | if (!OID.test(text)) return undefined; | |
| 50 | heads.set(relative(dir, path).split(sep).join("/"), text); | |
| 51 | } | |
| 52 | return [...heads] | |
| 53 | .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) | |
| 54 | .map(([name, oid]) => `${name} ${oid}`) | |
| 55 | .join("\n"); | |
| 56 | } |
src/http/server.ts +1 −2
| @@ -377,8 +377,6 @@ import { | ||
| 377 | 377 | crewRemove, |
| 378 | 378 | crewRoleUpdate, |
| 379 | 379 | crewSettingsPage, |
| 380 | dangerPage, | |
| 381 | repoDelete, | |
| 382 | 380 | settingsPage, |
| 383 | 381 | settingsUpdate, |
| 384 | 382 | waveCut, |
| @@ -386,6 +384,7 @@ import { | ||
| 386 | 384 | provenanceSubmit, |
| 387 | 385 | mirrorStop, |
| 388 | 386 | } from "../web/pages/settings.ts"; |
| 387 | import { dangerPage, repoDelete } from "../web/pages/danger.ts"; | |
| 389 | 388 | import { hooksPage, webhookCreate, webhookDelete } from "../web/pages/hooks.ts"; |
| 390 | 389 | import { |
| 391 | 390 | promptSourceAdd, |
src/web/pages/branches.ts +48 −8
| @@ -19,6 +19,7 @@ import { createBranch, deleteBranch } from "../../actions/branches.ts"; | ||
| 19 | 19 | import type { Repo } from "../../core/repos.ts"; |
| 20 | 20 | import { notePhase, takeSessionTime, timePhaseAsync } from "../../core/route_timing.ts"; |
| 21 | 21 | import { listBranches, tipCommits, type BranchInfo } from "../../git/branches.ts"; |
| 22 | import { headsState } from "../../git/heads.ts"; | |
| 22 | 23 | import type { Commit } from "../../git/read.ts"; |
| 23 | 24 | import { repoDir } from "../../git/repos.ts"; |
| 24 | 25 | import type { Ctx } from "../../http/context.ts"; |
| @@ -104,22 +105,61 @@ async function branchRows( | ||
| 104 | 105 | return { rows, compared: false }; |
| 105 | 106 | } |
| 106 | 107 | const dir = repoDir(ctx.app.reposRoot, repo.ownerName, repo.name); |
| 107 | const listed = await timePhaseAsync(ctx.request, "compare", () => | |
| 108 | listBranches(dir, repo.defaultBranch), | |
| 108 | const listing = await timePhaseAsync(ctx.request, "compare", () => | |
| 109 | compareBranches(dir, repo.defaultBranch), | |
| 109 | 110 | ); |
| 110 | if (!listed.ok) return { rows: [], compared: false }; | |
| 111 | if (listing === undefined) return { rows: [], compared: false }; | |
| 112 | return { | |
| 113 | rows: listing.rows.map((r) => ({ | |
| 114 | ...r, | |
| 115 | protected: r.isDefault && repo.protectDefault, | |
| 116 | })), | |
| 117 | compared: listing.compared, | |
| 118 | }; | |
| 119 | } | |
| 120 | ||
| 121 | interface Compared { | |
| 122 | readonly rows: readonly BranchRow[]; | |
| 123 | readonly compared: boolean; | |
| 124 | } | |
| 125 | ||
| 126 | /** How many repositories keep their last listing in memory. */ | |
| 127 | const KEPT_LISTINGS = 64; | |
| 128 | ||
| 129 | /** The last listing per repository directory, with the branch state it was read at. */ | |
| 130 | const kept = new Map<string, { readonly state: string; readonly listing: Compared }>(); | |
| 131 | ||
| 132 | /** | |
| 133 | * Every branch with its divergence and tip. The answer depends only on where | |
| 134 | * each branch points (commits never change), so while `headsState` reads the | |
| 135 | * same, the last listing is the answer and no git process runs. | |
| 136 | */ | |
| 137 | async function compareBranches( | |
| 138 | dir: string, | |
| 139 | defaultBranch: string, | |
| 140 | ): Promise<Compared | undefined> { | |
| 141 | const heads = headsState(dir); | |
| 142 | const state = heads === undefined ? undefined : `${defaultBranch}\n${heads}`; | |
| 143 | const hit = kept.get(dir); | |
| 144 | if (state !== undefined && hit?.state === state) return hit.listing; | |
| 145 | const listed = await listBranches(dir, defaultBranch); | |
| 146 | if (!listed.ok) return undefined; | |
| 111 | 147 | const bare = listed.value.branches.map((b: BranchInfo) => ({ |
| 112 | 148 | name: b.name, |
| 113 | 149 | oid: b.oid, |
| 114 | 150 | isDefault: b.isDefault, |
| 115 | protected: b.isDefault && repo.protectDefault, | |
| 151 | protected: false, | |
| 116 | 152 | ahead: b.ahead, |
| 117 | 153 | behind: b.behind, |
| 118 | 154 | })); |
| 119 | return { | |
| 120 | rows: await withTips(dir, bare), | |
| 121 | compared: listed.value.compared, | |
| 122 | }; | |
| 155 | const listing = { rows: await withTips(dir, bare), compared: listed.value.compared }; | |
| 156 | kept.delete(dir); | |
| 157 | // Read again after git answered: a push meanwhile means this listing may be stale. | |
| 158 | if (state !== undefined && headsState(dir) === heads) { | |
| 159 | kept.set(dir, { state, listing }); | |
| 160 | if (kept.size > KEPT_LISTINGS) kept.delete(kept.keys().next().value!); | |
Low: eviction is by insertion order, and a hit does not refresh recency. So the busiest repository is evicted as soon as 64 others have been looked at. Re-inserting on a hit ( |
||
| 161 | } | |
| 162 | return listing; | |
| 123 | 163 | } |
| 124 | 164 | |
| 125 | 165 | function divergence(row: BranchRow): Html { |
src/web/pages/danger.ts added +76
| @@ -0,0 +1,76 @@ | ||
| 1 | /** | |
| 2 | * Settings › Danger zone: the one door that deletes a repository. The page | |
| 3 | * says what goes and what survives; the POST refuses unless the repository's | |
| 4 | * full name was typed back. The frame and the admin gate are settings.ts's. | |
| 5 | */ | |
| 6 | ||
| 7 | import { deleteRepo } from "../../actions/repos.ts"; | |
| 8 | import { notePhase } from "../../core/route_timing.ts"; | |
| 9 | import type { Ctx } from "../../http/context.ts"; | |
| 10 | import { repoBase, repoNotFound } from "../chrome.ts"; | |
| 11 | import { DELETE_REPO } from "../danger_words.ts"; | |
| 12 | import { field } from "../form.ts"; | |
| 13 | import { html } from "../html.ts"; | |
| 14 | import { icon } from "../icons.ts"; | |
| 15 | import { flashTo } from "../layout.ts"; | |
| 16 | import { card, confirmAction } from "../ui.ts"; | |
| 17 | import { adminForm, settingsPageResponse, timedAdminRepo } from "./settings.ts"; | |
| 18 | ||
| 19 | export function dangerPage(ctx: Ctx): Response { | |
| 20 | const repo = timedAdminRepo(ctx); | |
| 21 | if (!repo) return repoNotFound(ctx); | |
| 22 | const rendering = performance.now(); | |
| 23 | const base = repoBase(repo); | |
| 24 | const full = `${repo.ownerName}/${repo.name}`; | |
| 25 | ||
| 26 | const body = card( | |
| 27 | html`<p> | |
| 28 | Deleting <strong>${full}</strong> removes its issues, pull requests, | |
| 29 | reviews, labels and webhooks, and erases the git objects from disk. | |
| 30 | </p> | |
| 31 | <p class="muted small mt"> | |
| 32 | Any fork of this repository is detached first — it repacks so it owns | |
| 33 | its objects, then loses the link. Forks survive; this repository does | |
| 34 | not. There is no undo and no backup. | |
| 35 | </p> | |
| 36 | <div class="form-actions"> | |
| 37 | ${confirmAction({ | |
| 38 | action: `${base}/settings/delete`, | |
| 39 | verb: DELETE_REPO, | |
| 40 | })} | |
| 41 | </div>`, | |
| 42 | { | |
| 43 | tone: "danger", | |
| 44 | head: html`${icon("alert")} | |
| 45 | <h2>${DELETE_REPO}</h2>`, | |
| 46 | }, | |
| 47 | ); | |
| 48 | const response = settingsPageResponse(ctx, repo, "danger", "Danger zone", body); | |
| 49 | notePhase(ctx.request, "render", performance.now() - rendering); | |
| 50 | return response; | |
| 51 | } | |
| 52 | ||
| 53 | /** | |
| 54 | * The typed-name check lives here and nowhere else: the confirm page collects | |
| 55 | * the phrase, this handler is the one that refuses without it. | |
| 56 | */ | |
| 57 | export async function repoDelete(ctx: Ctx): Promise<Response> { | |
| 58 | const gate = await adminForm(ctx); | |
| 59 | if (gate instanceof Response) return gate; | |
| 60 | const { repo, form } = gate; | |
| 61 | const full = `${repo.ownerName}/${repo.name}`; | |
| 62 | if (field(form, "confirm") !== full) { | |
| 63 | return flashTo(ctx, `${repoBase(repo)}/settings/danger`, { | |
| 64 | tone: "danger", | |
| 65 | message: `Type ${full} exactly to confirm. Nothing was deleted.`, | |
| 66 | }); | |
| 67 | } | |
| 68 | const deleted = await deleteRepo(ctx.app, ctx.auth.user, repo); | |
| 69 | if (!deleted.ok) { | |
| 70 | return flashTo(ctx, `${repoBase(repo)}/settings/danger`, { | |
| 71 | tone: "danger", | |
| 72 | message: "The repository could not be deleted.", | |
| 73 | }); | |
| 74 | } | |
| 75 | return flashTo(ctx, "/", { message: `${full} was deleted.` }); | |
| 76 | } |
src/web/pages/settings.ts +2 −66
| @@ -41,10 +41,7 @@ import { | ||
| 41 | 41 | import { addToCrew, listCrew, removeFromCrew } from "../../actions/crew.ts"; |
| 42 | 42 | import { getAgentByName, isCrewRole, setCrewRole } from "../../core/agents.ts"; |
| 43 | 43 | import { isAutonomy, isDecisionsBy } from "../../core/repos.ts"; |
| 44 | import { | |
| 45 | deleteRepo, | |
| 46 | updateRepoSettings, | |
| 47 | } from "../../actions/repos.ts"; | |
| 44 | import { updateRepoSettings } from "../../actions/repos.ts"; | |
| 48 | 45 | import { appsRuntime } from "../../apps/runtime.ts"; |
| 49 | 46 | import { nameFrame, notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts"; |
| 50 | 47 | import { appVitals, shiftText, speedText as ms, type AppVitals } from "../../apps/vitals.ts"; |
| @@ -82,7 +79,7 @@ import { appHref } from "./apps.ts"; | ||
| 82 | 79 | import { field, readForm, type Form } from "../form.ts"; |
| 83 | 80 | import { html, when, type Html } from "../html.ts"; |
| 84 | 81 | import { NOT_CHANGED } from "../edit_words.ts"; |
| 85 | import { DELETE_REPO, STOP_APP } from "../danger_words.ts"; | |
| 82 | import { STOP_APP } from "../danger_words.ts"; | |
| 86 | 83 | import { icon } from "../icons.ts"; |
| 87 | 84 | import { flashTo, page, redirect } from "../layout.ts"; |
| 88 | 85 | import type { Flash } from "../flash.ts"; |
| @@ -1490,67 +1487,6 @@ export async function crewRemove(ctx: Ctx): Promise<Response> { | ||
| 1490 | 1487 | ); |
| 1491 | 1488 | } |
| 1492 | 1489 | |
| 1493 | // ---- danger zone ---- | |
| 1494 | ||
| 1495 | export function dangerPage(ctx: Ctx): Response { | |
| 1496 | const repo = timedAdminRepo(ctx); | |
| 1497 | if (!repo) return repoNotFound(ctx); | |
| 1498 | const rendering = performance.now(); | |
| 1499 | const base = repoBase(repo); | |
| 1500 | const full = `${repo.ownerName}/${repo.name}`; | |
| 1501 | ||
| 1502 | const body = card( | |
| 1503 | html`<p> | |
| 1504 | Deleting <strong>${full}</strong> removes its issues, pull requests, | |
| 1505 | reviews, labels and webhooks, and erases the git objects from disk. | |
| 1506 | </p> | |
| 1507 | <p class="muted small mt"> | |
| 1508 | Any fork of this repository is detached first — it repacks so it owns | |
| 1509 | its objects, then loses the link. Forks survive; this repository does | |
| 1510 | not. There is no undo and no backup. | |
| 1511 | </p> | |
| 1512 | <div class="form-actions"> | |
| 1513 | ${confirmAction({ | |
| 1514 | action: `${base}/settings/delete`, | |
| 1515 | verb: DELETE_REPO, | |
| 1516 | })} | |
| 1517 | </div>`, | |
| 1518 | { | |
| 1519 | tone: "danger", | |
| 1520 | head: html`${icon("alert")} | |
| 1521 | <h2>${DELETE_REPO}</h2>`, | |
| 1522 | }, | |
| 1523 | ); | |
| 1524 | const response = settingsPageResponse(ctx, repo, "danger", "Danger zone", body); | |
| 1525 | notePhase(ctx.request, "render", performance.now() - rendering); | |
| 1526 | return response; | |
| 1527 | } | |
| 1528 | ||
| 1529 | /** | |
| 1530 | * The typed-name check lives here and nowhere else: the confirm page collects | |
| 1531 | * the phrase, this handler is the one that refuses without it. | |
| 1532 | */ | |
| 1533 | export async function repoDelete(ctx: Ctx): Promise<Response> { | |
| 1534 | const gate = await adminForm(ctx); | |
| 1535 | if (gate instanceof Response) return gate; | |
| 1536 | const { repo, form } = gate; | |
| 1537 | const full = `${repo.ownerName}/${repo.name}`; | |
| 1538 | if (field(form, "confirm") !== full) { | |
| 1539 | return flashTo(ctx, `${repoBase(repo)}/settings/danger`, { | |
| 1540 | tone: "danger", | |
| 1541 | message: `Type ${full} exactly to confirm. Nothing was deleted.`, | |
| 1542 | }); | |
| 1543 | } | |
| 1544 | const deleted = await deleteRepo(ctx.app, ctx.auth.user, repo); | |
| 1545 | if (!deleted.ok) { | |
| 1546 | return flashTo(ctx, `${repoBase(repo)}/settings/danger`, { | |
| 1547 | tone: "danger", | |
| 1548 | message: "The repository could not be deleted.", | |
| 1549 | }); | |
| 1550 | } | |
| 1551 | return flashTo(ctx, "/", { message: `${full} was deleted.` }); | |
| 1552 | } | |
| 1553 | ||
| 1554 | 1490 | // POST /:owner/:name/settings/crew/:username/role |
| 1555 | 1491 | export async function crewRoleUpdate(ctx: Ctx): Promise<Response> { |
| 1556 | 1492 | const gate = await adminForm(ctx); |
test/branches-many.test.ts +16
| @@ -194,3 +194,19 @@ test("tipCommits reads every field commitLog does: body, parents, author, date", | ||
| 194 | 194 | // An unsafe oid is dropped, never handed to git. |
| 195 | 195 | expect((await tipCommits(dir, ["--output=x", mergeTip])).size).toBe(1); |
| 196 | 196 | }, 60_000); |
| 197 | ||
| 198 | test("a warm look shows a branch that moved or appeared since the last look", async () => { | |
| 199 | await get("/alice/app/branches"); | |
| 200 | const dir = join(work, "app"); | |
| 201 | await git(["switch", "-q", "feat/b000"], dir); | |
| 202 | await git(["commit", "--allow-empty", "-m", "moved on"], dir); | |
| 203 | await git(["push", "-q", "origin", "feat/b000"], dir); | |
| 204 | await git(["switch", "-q", "-c", "feat/zz-new"], dir); | |
| 205 | await git(["push", "-q", "origin", "feat/zz-new"], dir); | |
| 206 | const { text } = await get("/alice/app/branches"); | |
| 207 | expect(text).toContain("201 branches"); | |
| 208 | expect(text).toContain("feat/zz-new"); | |
| 209 | expect(text).toContain("moved on"); | |
| 210 | await git(["push", "-q", "origin", "--delete", "feat/zz-new"], dir); | |
| 211 | expect((await get("/alice/app/branches")).text).toContain("200 branches"); | |
| 212 | }, 60_000); |
test/git-heads.test.ts added +69
| @@ -0,0 +1,69 @@ | ||
| 1 | /** | |
| 2 | * headsState reads where every branch points from the repository's files, | |
| 3 | * the same answer git gives, and gives up on anything it does not understand. | |
| 4 | */ | |
| 5 | import { afterAll, expect, test } from "bun:test"; | |
| 6 | import { mkdtemp, rm, writeFile } from "node:fs/promises"; | |
| 7 | import { tmpdir } from "node:os"; | |
| 8 | import { join } from "node:path"; | |
| 9 | import { headsState } from "../src/git/heads.ts"; | |
| 10 | import { gitClientEnv } from "./helpers/git-client.ts"; | |
| 11 | ||
| 12 | const env = gitClientEnv({ | |
| 13 | GIT_AUTHOR_NAME: "A", | |
| 14 | GIT_AUTHOR_EMAIL: "[email protected]", | |
| 15 | GIT_COMMITTER_NAME: "A", | |
| 16 | GIT_COMMITTER_EMAIL: "[email protected]", | |
| 17 | }); | |
| 18 | const dirs: string[] = []; | |
| 19 | afterAll(() => Promise.all(dirs.map((d) => rm(d, { recursive: true, force: true })))); | |
| 20 | ||
| 21 | async function git(args: string[], cwd: string): Promise<string> { | |
| 22 | const p = Bun.spawn(["git", ...args], { cwd, env, stdout: "pipe", stderr: "pipe" }); | |
| 23 | const [out, err, code] = await Promise.all([new Response(p.stdout).text(), new Response(p.stderr).text(), p.exited]); | |
| 24 | if (code !== 0) throw new Error(`git ${args.join(" ")}: ${err}`); | |
| 25 | return out; | |
| 26 | } | |
| 27 | ||
| 28 | async function repo(): Promise<string> { | |
| 29 | const dir = await mkdtemp(join(tmpdir(), "forge-heads-")); | |
| 30 | dirs.push(dir); | |
| 31 | await git(["init", "-q", "-b", "main", dir], dir); | |
| 32 | await git(["commit", "-q", "--allow-empty", "-m", "one"], dir); | |
| 33 | await git(["branch", "feat/a"], dir); | |
| 34 | await git(["commit", "-q", "--allow-empty", "-m", "two"], dir); | |
| 35 | return dir; | |
| 36 | } | |
| 37 | ||
| 38 | const fromGit = async (dir: string) => | |
| 39 | (await git(["for-each-ref", "--format=%(refname) %(objectname)", "refs/heads/"], dir)).trim(); | |
| 40 | ||
| 41 | test("loose, packed and mixed refs read the same as for-each-ref", async () => { | |
| 42 | const dir = await repo(); | |
| 43 | const gitDir = join(dir, ".git"); | |
| 44 | expect(headsState(gitDir)).toBe(await fromGit(dir)); | |
| 45 | await git(["pack-refs", "--all"], dir); | |
| 46 | expect(headsState(gitDir)).toBe(await fromGit(dir)); | |
| 47 | // A loose ref written after packing wins over its packed line. | |
| 48 | await git(["commit", "-q", "--allow-empty", "-m", "three"], dir); | |
| 49 | await git(["branch", "z"], dir); | |
| 50 | expect(headsState(gitDir)).toBe(await fromGit(dir)); | |
| 51 | }); | |
| 52 | ||
| 53 | test("a moved branch changes the state", async () => { | |
| 54 | const dir = await repo(); | |
| 55 | const before = headsState(join(dir, ".git")); | |
| 56 | await git(["branch", "-f", "feat/a", "main"], dir); | |
| 57 | expect(headsState(join(dir, ".git"))).not.toBe(before); | |
| 58 | }); | |
| 59 | ||
| 60 | test("a symbolic ref, a lock file or no refs directory gives up", async () => { | |
| 61 | const dir = await repo(); | |
| 62 | const gitDir = join(dir, ".git"); | |
| 63 | await writeFile(join(gitDir, "refs/heads/feat/a.lock"), ""); | |
| 64 | expect(headsState(gitDir)).toBeUndefined(); | |
| 65 | await rm(join(gitDir, "refs/heads/feat/a.lock")); | |
| 66 | await writeFile(join(gitDir, "refs/heads/alias"), "ref: refs/heads/main\n"); | |
| 67 | expect(headsState(gitDir)).toBeUndefined(); | |
| 68 | expect(headsState(join(dir, "nowhere"))).toBeUndefined(); | |
| 69 | }); |
new above
offline
Low: this is module-level state in a page module. Each entry holds every row (with tip commits) of a repository, so 64 repositories with very many branches stay resident. That is bounded, but it is not small. When this grows, move it beside
headsStateinsrc/git/(or core), and consider a cap on total rows as well as entry count.