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

Merged opened this

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

Merged

mason/forge:mason/6336-the-danger-zone-and-repository-delete-le → main 6 commits fork 2 threads · 2 unresolved

  • Merged. mason/6336-the-danger-zone-and-repository-delete-le was merged into main as 022b979.

Review

Sign in to review this change.
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 {
377377 crewRemove,
378378 crewRoleUpdate,
379379 crewSettingsPage,
380 dangerPage,
381 repoDelete,
382380 settingsPage,
383381 settingsUpdate,
384382 waveCut,
@@ -386,6 +384,7 @@ import {
386384 provenanceSubmit,
387385 mirrorStop,
388386 } from "../web/pages/settings.ts";
387 import { dangerPage, repoDelete } from "../web/pages/danger.ts";
389388 import { hooksPage, webhookCreate, webhookDelete } from "../web/pages/hooks.ts";
390389 import {
391390 promptSourceAdd,
src/web/pages/branches.ts +48 −8
@@ -19,6 +19,7 @@ import { createBranch, deleteBranch } from "../../actions/branches.ts";
1919 import type { Repo } from "../../core/repos.ts";
2020 import { notePhase, takeSessionTime, timePhaseAsync } from "../../core/route_timing.ts";
2121 import { listBranches, tipCommits, type BranchInfo } from "../../git/branches.ts";
22 import { headsState } from "../../git/heads.ts";
2223 import type { Commit } from "../../git/read.ts";
2324 import { repoDir } from "../../git/repos.ts";
2425 import type { Ctx } from "../../http/context.ts";
@@ -104,22 +105,61 @@ async function branchRows(
104105 return { rows, compared: false };
105106 }
106107 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),
109110 );
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 }>();

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 headsState in src/git/ (or core), and consider a cap on total rows as well as entry count.

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;
111147 const bare = listed.value.branches.map((b: BranchInfo) => ({
112148 name: b.name,
113149 oid: b.oid,
114150 isDefault: b.isDefault,
115 protected: b.isDefault && repo.protectDefault,
151 protected: false,
116152 ahead: b.ahead,
117153 behind: b.behind,
118154 }));
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 (kept.delete(dir); kept.set(dir, hit)) would make this a real LRU.

161 }
162 return listing;
123163 }
124164
125165 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 {
4141 import { addToCrew, listCrew, removeFromCrew } from "../../actions/crew.ts";
4242 import { getAgentByName, isCrewRole, setCrewRole } from "../../core/agents.ts";
4343 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";
4845 import { appsRuntime } from "../../apps/runtime.ts";
4946 import { nameFrame, notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts";
5047 import { appVitals, shiftText, speedText as ms, type AppVitals } from "../../apps/vitals.ts";
@@ -82,7 +79,7 @@ import { appHref } from "./apps.ts";
8279 import { field, readForm, type Form } from "../form.ts";
8380 import { html, when, type Html } from "../html.ts";
8481 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";
8683 import { icon } from "../icons.ts";
8784 import { flashTo, page, redirect } from "../layout.ts";
8885 import type { Flash } from "../flash.ts";
@@ -1490,67 +1487,6 @@ export async function crewRemove(ctx: Ctx): Promise<Response> {
14901487 );
14911488 }
14921489
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
15541490 // POST /:owner/:name/settings/crew/:username/role
15551491 export async function crewRoleUpdate(ctx: Ctx): Promise<Response> {
15561492 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",
194194 // An unsafe oid is dropped, never handed to git.
195195 expect((await tipCommits(dir, ["--output=x", mergeTip])).size).toBe(1);
196196 }, 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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs