Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

9 files changed+186−46
Files (9)
modifiedapps/diffinery/docs/reference/limits.md+2−1View file
@@ -12,7 +12,7 @@
1212 | Limit | Value | Applies to |
1313 | --- | --- | --- |
1414 | Browser session lifetime | 30 days, extended to 30 days from now on each day the session is used, never past 90 days after sign-in | The session cookie you get after signing in with a passkey, a sign-in link or email recovery. An unused session ends 30 days after its last extension, and every session ends 90 days after its sign-in. |
15-| Recent sign-in for sensitive actions | 2 hours | Adding or removing a passkey, creating a token, an agent or an agent token, changing your username, issuing a sign-in link, deleting, transferring or purging unreachable objects of a repository, and deleting an organization. An older browser sign-in gets **Confirm it's you** (`403` with a `confirm` URL over the API). See [Confirming it's you](/docs/security/overview#confirming-its-you). |
15+| Recent sign-in for sensitive actions | 2 hours | Adding or removing a passkey, creating a token, an agent or an agent token, changing your username, issuing a sign-in link, deleting, transferring, purging unreachable objects of or deleting the closed pull requests of a repository, and deleting an organization. An older browser sign-in gets **Confirm it's you** (`403` with a `confirm` URL over the API). See [Confirming it's you](/docs/security/overview#confirming-its-you). |
1616 | Passkey browser prompt | 2 minutes | Time to complete the browser's passkey dialog when signing in or adding a passkey. |
1717 | Passkey sign-in attempt | 5 minutes | A sign-in or registration that started more than 5 minutes ago can't be completed; start again. |
1818 | Sign-in link lifetime | 24 hours, single use | Links a site administrator sends with **Sign-in link** in **Settings → People and invitations**. |
@@ -226,6 +226,7 @@
226226 | Unfinished runs per repository | 500 | Past it, a `workflow_dispatch` or `repository_dispatch` is refused with `429 this repository has 500 unfinished workflow runs, the most allowed; wait for some to finish`, and other events start no run. |
227227 | Self-hosted runners per repository | 100, each with at most 20 labels | Registering one more gets `429 this repository has 100 self-hosted runners, the most allowed; remove one first`. A registration token works for an hour. |
228228 | Purging unreachable objects | Repositories of up to 300,000 objects | A larger repository gets `422`; ask a site administrator. See [Removing a secret that was pushed](/docs/security/push-protection#removing-a-secret-that-was-pushed). |
229+| Deleting closed pull requests | Repositories of up to 300,000 objects | It purges unreachable objects too, so the same limit applies. See [Start over with fresh history](/docs/repositories/overview#start-over-with-fresh-history). |
229230 | Jobs during a deploy | Not interrupted | A deploy doesn't replace the container of a running hosted job: it finishes on the version it started with. If the platform replaces a job's container anyway, the job keeps going for up to 13 minutes, then is cancelled. |
230231 | Service container health check | 15 minutes | How long a job waits for a `services:` container's health check to pass. |
231232 | Log line length | 16,384 characters | Longer lines are cut and end with `…`. |
modifiedapps/diffinery/docs/repositories/overview.md+13−1View file
@@ -151,7 +151,7 @@
151151 - **Default branch**: a list of the repository's branches (a text field while the repository is empty). The default branch is what the repository page shows, what clones check out, the base for new pull requests, and it is always protected from agents.
152152 - **Visibility**: **Private** or **Public**.
153153
154-Below it, the **Danger zone** has **Archive this repository**, **Transfer ownership** (see [Transfer a repository](#transfer-a-repository)), **Purge unreachable objects** (see [Removing a secret that was pushed](/docs/security/push-protection#removing-a-secret-that-was-pushed)) and **Delete this repository**.
154+Below it, the **Danger zone** has **Archive this repository**, **Transfer ownership** (see [Transfer a repository](#transfer-a-repository)), **Purge unreachable objects** (see [Removing a secret that was pushed](/docs/security/push-protection#removing-a-secret-that-was-pushed)), **Delete closed pull requests** (see [Start over with fresh history](#start-over-with-fresh-history)) and **Delete this repository**.
155155
156156 ### Secrets and variables
157157
@@ -387,6 +387,18 @@
387387 | `type acme/website to confirm the transfer` | The web confirmation box didn't match the repository's full name. |
388388 | `choose the new owner` | Pick a **New owner** in the web form. |
389389
390+## Start over with fresh history
391+
392+To restart a repository's history (for example a single new first commit before you open-source it) while keeping its issues, settings, secrets, environments and collaborators:
393+
394+1. **Back up the old history** if you want to keep it: `git clone --mirror` the repository, or `git bundle create backup.bundle --all` in a mirror clone, and keep the bundle somewhere private, such as a release asset in a private repository.
395+2. **Rewrite the branches.** For a single first commit with today's files: `git commit-tree "$(git rev-parse 'main^{tree}')" -m "Initial commit"` gives the commit. A repository admin then force-pushes it to the default branch (`git push --force origin <sha>:main`); agents can't push to the default branch, and a branch protection rule with **Do not allow administrators to bypass these rules** refuses it until you turn that off. Delete or rewrite the other branches and tags.
396+3. **Delete the old pull requests.** Pull requests keep their commits fetchable through `refs/pull/<number>/head` and `refs/pull/<number>/merge`, as on GitHub. Under **Settings → General → Danger zone**, click **Delete closed pull requests**, type the repository's full name and confirm. Or `POST /api/v3/repos/{owner}/{repo}/diffinery/delete-closed-pulls`, which answers `{"pull_requests": 42, "objects": 1830, "bytes": 5242880}`.
397+
398+**Delete closed pull requests** permanently deletes every closed and merged pull request with its comments, reviews, review comments, timeline events, reactions and `refs/pull` refs, then [purges unreachable objects](/docs/security/push-protection#removing-a-secret-that-was-pushed), so the old commits can't be fetched by their SHA any more. It can't be undone. Issues, open pull requests (close the ones you want gone first; pull requests in the merge queue stay), branches, tags and workflow runs stay, and new pull requests and issues keep counting from the last number, so old links answer 404 instead of pointing at something new.
399+
400+Only repository admins (people, not agents) can do it, with a sign-in from the last 2 hours in the browser (otherwise you're asked to [Confirm it's you](/docs/security/overview#confirming-its-you)). It works for repositories of up to 300,000 objects. It is recorded in the [audit log](/docs/security/audit-log) as `repo.delete_closed_pulls` with the number of pull requests and objects deleted.
401+
390402 ## Delete a repository
391403
392404 Deleting permanently removes the repository with its history, issues, pull requests, releases, runs, secrets, environments, webhooks and pending invitations, its LFS files and release assets, and its Actions caches, artifacts, job logs and cached source archives. It can't be undone. An archived repository can be deleted without unarchiving it first.
modifiedapps/diffinery/docs/security/audit-log.md+1−0View file
@@ -163,6 +163,7 @@
163163 | `repo.archive`, `repo.unarchive` | The repository is archived or unarchived, on **General** or with `PATCH /api/v3/repos/{owner}/{repo}` and `{"archived": true}` (or `false`) | none |
164164 | `repo.delete` | The repository is deleted (web or `DELETE /api/v3/repos/{owner}/{repo}`) | none |
165165 | `repo.purge_unreachable` | Unreachable objects are purged (**Settings → Danger zone**, or `POST /api/v3/repos/{owner}/{repo}/diffinery/purge-unreachable`) | `objects`, `bytes` |
166+| `repo.delete_closed_pulls` | Every closed pull request is deleted and unreachable objects are purged (**Settings → Danger zone → Delete closed pull requests**, or `POST /api/v3/repos/{owner}/{repo}/diffinery/delete-closed-pulls`) | `pull_requests`, `objects`, `bytes` |
166167 | `repo.transfer` | The repository is transferred to another owner (web or `POST /api/v3/repos/{owner}/{repo}/transfer`); the target is the new name | `from`, `to` |
167168 | `repo.collaborator.set`, `repo.collaborator.remove` | On **Collaborators**: an account gets a role at once with **Add people** (an agent, an existing collaborator, a member of the owning organization, or yourself), a collaborator's role is changed, or a collaborator is removed. Through the API: `PUT` (when the role applied at once) or `DELETE /api/v3/repos/{owner}/{repo}/collaborators/{username}` | `login`, `role` |
168169 | `repo.invite` | **Add people** or `PUT /api/v3/repos/{owner}/{repo}/collaborators/{username}` sends an invitation, to an email address or a username | `email` or `login`, and `role` |
modifiedapps/diffinery/docs/security/push-protection.md+1−1View file
@@ -224,7 +224,7 @@
224224 Force-pushing a branch to drop a commit doesn't delete the commit. Like git, Diffinery keeps objects no branch reaches, and anyone who can read the repository can still fetch the commit by its SHA (`GET .../git/commits/{sha}`, the commit page, or `git fetch origin <sha>`). To remove it:
225225
226226 1. **Rotate the credential** at its provider. Treat it as exposed: clones, forks, caches and webhook receivers may already have it.
227-2. Rewrite the history without it and force-push every branch and tag that contains it. Close or update any pull request whose head contains it.
227+2. Rewrite the history without it and force-push every branch and tag that contains it. Close or update any pull request whose head contains it; a closed pull request still keeps its commits through `refs/pull/<number>/head` until it is deleted with **Delete closed pull requests** (see [Start over with fresh history](/docs/repositories/overview#start-over-with-fresh-history)).
228228 3. On the repository's **Settings**, under **Danger zone**, click **Purge unreachable objects** (or `POST /api/v3/repos/{owner}/{repo}/diffinery/purge-unreachable`). It permanently deletes every commit, tree and file that no branch, tag, pull request, merge queue entry or unfinished workflow run reaches, and empties the cache of downloadable archives. It answers `{"objects": 3, "bytes": 1234}`.
229229
230230 Only repository admins (people, not agents) can purge, with a sign-in from the last 2 hours in the browser. It is recorded in the [audit log](/docs/security/audit-log) as `repo.purge_unreachable`. A push that was in flight while the purge ran is refused with `the repository's unreachable objects were purged during this push; push again`. Purging works for repositories of up to 300,000 objects. Review comments on commits that were purged no longer show their diff.
modifiedapps/diffinery/src/docs/api-meta.ts+5−0View file
@@ -420,6 +420,11 @@
420420 description: "Permanently deletes every commit, tree and file that no branch, tag, pull request, merge queue entry or unfinished workflow run reaches, and empties the archive cache, so a secret force-pushed away can't be fetched by its SHA. Returns `{ objects, bytes }`. People only, not agents. Repositories of up to 300,000 objects (422 otherwise). From a browser session whose sign-in is more than 2 hours old: 403 `Confirm it's you: sign in with your passkey again to do this.` with a `confirm` URL. Audited as `repo.purge_unreachable`. See /docs/security/push-protection#removing-a-secret-that-was-pushed.",
421421 access: "admin",
422422 },
423+ "POST /repos/{owner}/{repo}/diffinery/delete-closed-pulls": {
424+ summary: "Delete closed pull requests",
425+ description: "For starting over with fresh history after rewriting the branches. Permanently deletes every closed and merged pull request (not ones still in the merge queue) with its comments, reviews, review comments, timeline events, reactions and `refs/pull/{number}/*` refs, then purges unreachable objects as `POST .../diffinery/purge-unreachable` does. Issues, open pull requests, branches, tags and workflow runs stay; pull request numbers are not reused. Returns `{ pull_requests, objects, bytes }`. People only, not agents. Repositories of up to 300,000 objects (422 otherwise). From a browser session whose sign-in is more than 2 hours old: 403 `Confirm it's you: sign in with your passkey again to do this.` with a `confirm` URL. Audited as `repo.delete_closed_pulls`. See /docs/repositories/overview#start-over-with-fresh-history.",
426+ access: "admin",
427+ },
423428 "DELETE /repos/{owner}/{repo}": { summary: "Delete a repository", description: "People only; agents can't delete repositories. Works on an archived repository too. Audited. From a browser session whose sign-in is more than 2 hours old: 403 `Confirm it's you: sign in with your passkey again to do this.` with a `confirm` URL (see /docs/security/overview#confirming-its-you).", access: "admin", status: 204 },
424429 "POST /repos/{owner}/{repo}/transfer": {
425430 summary: "Transfer a repository",
modifiedapps/diffinery/src/http/api.ts+8−0View file
@@ -443,6 +443,14 @@
443443 auditRepo(c, caller, repo, "repo.purge_unreachable", { objects: r.objects, bytes: r.bytes, via: "api" });
444444 return c.json({ objects: r.objects, bytes: r.bytes });
445445 });
446+// Start over with fresh history: delete every closed pull request and its refs/pull refs, then purge.
447+api.post("/repos/:owner/:repo/diffinery/delete-closed-pulls", async (c) => {
448+ const { stub, caller, repo } = await loadRepo(c, P(c).owner, P(c).repo, "admin");
449+ requireRecentSignIn(c, `/${repo.owner_login}/${repo.name}/settings`);
450+ const r = await stub.deleteClosedPullRequests(caller);
451+ auditRepo(c, caller, repo, "repo.delete_closed_pulls", { pull_requests: r.pullRequests, objects: r.objects, bytes: r.bytes, via: "api" });
452+ return c.json({ pull_requests: r.pullRequests, objects: r.objects, bytes: r.bytes });
453+});
446454 api.delete("/repos/:owner/:repo", async (c) => {
447455 const { repo, caller } = await loadRepo(c, P(c).owner, P(c).repo, "admin", { whileArchived: true });
448456 if (caller.kind === "agent") throw new HttpError(403, "agents cannot delete repositories");
modifiedapps/diffinery/src/repo/RepoDO.ts+80−43View file
@@ -396,52 +396,89 @@
396396 */
397397 async purgeUnreachable(caller: Caller): Promise<{ objects: number; bytes: number }> {
398398 if (caller.kind !== "user" || caller.permissions || !atLeast(caller.role, "admin")) throw new HttpError(403, "only repository admins can purge unreachable objects");
399+ this.checkPurgeSize();
400+ return this.ctx.blockConcurrencyWhile(() => this.purgeHeld());
401+ }
402+
403+ /**
404+ * Start over with fresh history: delete every closed and merged pull request (its conversation,
405+ * reviews, review comments, events, reactions and refs/pull/<n>/* refs), then purge what nothing
406+ * reaches any more. Without it, refs/pull keeps every old commit fetchable after the branches were
407+ * rewritten. Issues, open pull requests (and those in the merge queue), branches, tags and workflow
408+ * runs stay, and numbers are never reused. Runs with every other request held, like a purge.
409+ */
410+ async deleteClosedPullRequests(caller: Caller): Promise<{ pullRequests: number; objects: number; bytes: number }> {
411+ if (caller.kind !== "user" || caller.permissions || !atLeast(caller.role, "admin")) throw new HttpError(403, "only repository admins can delete closed pull requests");
412+ this.checkPurgeSize();
413+ return this.ctx.blockConcurrencyWhile(async () => {
414+ const numbers = this.sql
415+ .exec<{ number: number }>("SELECT number FROM issues WHERE kind = 'pull' AND state = 'closed' AND number NOT IN (SELECT number FROM merge_queue) ORDER BY number")
416+ .toArray()
417+ .map((r) => r.number);
418+ for (const n of numbers) this.forgetPull(n);
419+ const purged = await this.purgeHeld();
420+ return { pullRequests: numbers.length, ...purged };
421+ });
422+ }
423+
424+ /** Every row and ref a pull request left behind. */
425+ private forgetPull(n: number): void {
426+ const comments = this.sql.exec<{ id: string; review_id: string | null }>("SELECT id, review_id FROM comments WHERE number = ?", n).toArray();
427+ for (const c of comments) this.sql.exec("DELETE FROM reactions WHERE subject = ?", `${c.review_id ? "review_comment" : "comment"}:${c.id}`);
428+ this.sql.exec("DELETE FROM reactions WHERE subject = ?", `issue:${n}`);
429+ for (const table of ["comments", "reviews", "issue_events", "issue_milestones", "pull_merge_state", "pulls", "issues"]) this.sql.exec(`DELETE FROM ${table} WHERE number = ?`, n);
430+ this.sql.exec("DELETE FROM refs WHERE name >= ? AND name < ?", `refs/pull/${n}/`, `refs/pull/${n}/￿`);
431+ }
432+
433+ private checkPurgeSize(): void {
399434 const total = this.store.count();
400435 if (total > MAX_PURGE_OBJECTS) throw new HttpError(422, `this repository has ${total} objects; purging works for up to ${MAX_PURGE_OBJECTS}. Ask a site administrator`);
401- return this.ctx.blockConcurrencyWhile(async () => {
402- const roots = [
403- ...this.sql.exec<{ oid: string }>("SELECT oid FROM refs").toArray().map((r) => r.oid),
404- ...this.sql.exec<{ oid: string }>("SELECT head_sha AS oid FROM pulls UNION SELECT head_sha FROM merge_queue UNION SELECT group_sha FROM merge_queue WHERE group_sha IS NOT NULL UNION SELECT parent_sha FROM merge_queue WHERE parent_sha IS NOT NULL").toArray().map((r) => r.oid),
405- ...this.sql.exec<{ oid: string }>("SELECT sha AS oid FROM runs WHERE status != 'completed' UNION SELECT head_sha FROM runs WHERE status != 'completed' AND head_sha IS NOT NULL").toArray().map((r) => r.oid),
406- ];
407- const keep = new Set<string>();
408- const stack = [...roots];
409- while (stack.length) {
410- const oid = stack.pop()!;
411- if (keep.has(oid)) continue;
412- const row = this.sql.exec<{ type: number }>("SELECT type FROM objects WHERE oid = ?", oid).toArray()[0];
413- if (!row) continue;
414- keep.add(oid);
415- const type = CODE_TYPES[row.type];
416- if (type === "blob") continue;
417- const obj = await this.store.read(oid);
418- if (!obj) continue;
419- if (type === "commit") {
420- const c = parseCommit(obj.content);
421- stack.push(c.tree, ...c.parents);
422- } else if (type === "tree") {
423- // A gitlink (160000) names a commit in another repository.
424- for (const e of parseTree(obj.content)) if (e.mode !== "160000") stack.push(e.oid);
425- } else if (type === "tag") {
426- const target = /^object ([0-9a-f]{40})/.exec(fromUtf8(obj.content))?.[1];
427- if (target) stack.push(target);
428- }
436+ }
437+
438+ /** The purge itself; the caller holds every other request (blockConcurrencyWhile). */
439+ private async purgeHeld(): Promise<{ objects: number; bytes: number }> {
440+ const roots = [
441+ ...this.sql.exec<{ oid: string }>("SELECT oid FROM refs").toArray().map((r) => r.oid),
442+ ...this.sql.exec<{ oid: string }>("SELECT head_sha AS oid FROM pulls UNION SELECT head_sha FROM merge_queue UNION SELECT group_sha FROM merge_queue WHERE group_sha IS NOT NULL UNION SELECT parent_sha FROM merge_queue WHERE parent_sha IS NOT NULL").toArray().map((r) => r.oid),
443+ ...this.sql.exec<{ oid: string }>("SELECT sha AS oid FROM runs WHERE status != 'completed' UNION SELECT head_sha FROM runs WHERE status != 'completed' AND head_sha IS NOT NULL").toArray().map((r) => r.oid),
444+ ];
445+ const keep = new Set<string>();
446+ const stack = [...roots];
447+ while (stack.length) {
448+ const oid = stack.pop()!;
449+ if (keep.has(oid)) continue;
450+ const row = this.sql.exec<{ type: number }>("SELECT type FROM objects WHERE oid = ?", oid).toArray()[0];
451+ if (!row) continue;
452+ keep.add(oid);
453+ const type = CODE_TYPES[row.type];
454+ if (type === "blob") continue;
455+ const obj = await this.store.read(oid);
456+ if (!obj) continue;
457+ if (type === "commit") {
458+ const c = parseCommit(obj.content);
459+ stack.push(c.tree, ...c.parents);
460+ } else if (type === "tree") {
461+ // A gitlink (160000) names a commit in another repository.
462+ for (const e of parseTree(obj.content)) if (e.mode !== "160000") stack.push(e.oid);
463+ } else if (type === "tag") {
464+ const target = /^object ([0-9a-f]{40})/.exec(fromUtf8(obj.content))?.[1];
465+ if (target) stack.push(target);
429466 }
430- const doomed = this.sql.exec<{ oid: string; r2: number; size: number }>("SELECT oid, r2, size FROM objects").toArray().filter((r) => !keep.has(r.oid));
431- for (let i = 0; i < doomed.length; i += 100) {
432- const chunk = doomed.slice(i, i + 100).map((r) => r.oid);
433- this.sql.exec(`DELETE FROM objects WHERE oid IN (${chunk.map(() => "?").join(",")})`, ...chunk);
434- }
435- this.store.forget();
436- this.store.recount();
437- this.storageChanged();
438- this.purgeEpoch++;
439- const r2 = doomed.filter((r) => r.r2).map((r) => this.store.r2KeyOf(r.oid));
440- for (let i = 0; i < r2.length; i += 1000) await this.env.BUCKET.delete(r2.slice(i, i + 1000));
441- // Cached zipballs and tarballs are rebuilt from what's left on the next download.
442- await deletePrefixes(this.env.BUCKET, [archivePrefix(this.meta.repoId)], 50);
443- return { objects: doomed.length, bytes: doomed.reduce((n, r) => n + r.size, 0) };
444- });
467+ }
468+ const doomed = this.sql.exec<{ oid: string; r2: number; size: number }>("SELECT oid, r2, size FROM objects").toArray().filter((r) => !keep.has(r.oid));
469+ for (let i = 0; i < doomed.length; i += 100) {
470+ const chunk = doomed.slice(i, i + 100).map((r) => r.oid);
471+ this.sql.exec(`DELETE FROM objects WHERE oid IN (${chunk.map(() => "?").join(",")})`, ...chunk);
472+ }
473+ this.store.forget();
474+ this.store.recount();
475+ this.storageChanged();
476+ this.purgeEpoch++;
477+ const r2 = doomed.filter((r) => r.r2).map((r) => this.store.r2KeyOf(r.oid));
478+ for (let i = 0; i < r2.length; i += 1000) await this.env.BUCKET.delete(r2.slice(i, i + 1000));
479+ // Cached zipballs and tarballs are rebuilt from what's left on the next download.
480+ await deletePrefixes(this.env.BUCKET, [archivePrefix(this.meta.repoId)], 50);
481+ return { objects: doomed.length, bytes: doomed.reduce((n, r) => n + r.size, 0) };
445482 }
446483
447484 // ---------------------------------------------------------------- storage (the owner's storage quota, lib/quotas.ts)
modifiedapps/diffinery/src/web/repo-settings.tsx+24−0View file
@@ -384,6 +384,17 @@
384384 <button class="btn danger">Purge</button>
385385 </Form>
386386 <hr />
387+ <Form action={`${base}/settings/delete-closed-pulls`} t={t} match={full}>
388+ <b>Delete closed pull requests</b>
389+ <p class="muted small">
390+ To start over with fresh history after rewriting the branches: permanently delete every closed and merged pull request with its conversation, reviews and refs, then purge the commits and files nothing else reaches. Issues, open pull requests, branches, tags and workflow runs stay. Type <b class="mono">{full}</b> to confirm.
391+ </p>
392+ <div class="row">
393+ <input name="confirm" type="text" required autocomplete="off" style="max-width:320px" aria-label="Confirm repository name" />
394+ <button class="btn danger">Delete closed pull requests</button>
395+ </div>
396+ </Form>
397+ <hr />
387398 <Form action={`${base}/settings/delete`} t={t} match={full}>
388399 <b>Delete this repository</b>
389400 <p class="muted small">
@@ -1029,6 +1040,19 @@
10291040 });
10301041 });
10311042
1043+ post(web, "/:owner/:repo/settings/delete-closed-pulls", async (c, f) => {
1044+ const l = await load(c);
1045+ requireAccount(c);
1046+ const full = `${l.repo.owner_login}/${l.repo.name}`;
1047+ return act(c, `${repoUrl(l.repo)}/settings`, async () => {
1048+ if (f.s("confirm").trim() !== full) throw new HttpError(422, `type ${full} to confirm`);
1049+ requireRecentSignIn(c, `${repoUrl(l.repo)}/settings`);
1050+ const r = await l.stub.deleteClosedPullRequests(l.caller);
1051+ audit(c, l, "repo.delete_closed_pulls", { pull_requests: r.pullRequests, objects: r.objects, bytes: r.bytes });
1052+ flash(c, "notice", r.pullRequests ? `Deleted ${r.pullRequests} closed pull request${r.pullRequests === 1 ? "" : "s"} and purged ${r.objects} unreachable object${r.objects === 1 ? "" : "s"}.` : `No closed pull requests to delete; purged ${r.objects} unreachable object${r.objects === 1 ? "" : "s"}.`);
1053+ });
1054+ });
1055+
10321056 post(web, "/:owner/:repo/settings/transfer", async (c, f) => {
10331057 const l = await load(c, { whileArchived: true });
10341058 const me = requireAccount(c);
modifiedapps/diffinery/test/e2e/security.test.mjs+52−0View file
@@ -2095,6 +2095,58 @@
20952095 assert.equal((await ok("GET", `${R}/branches/main`)).commit.sha, g(["rev-parse", "HEAD"]));
20962096 });
20972097
2098+test("security: an admin can start over by deleting closed pull requests and the commits only they keep", async () => {
2099+ const R = `/repos/${OWNER}/fresh-start`;
2100+ const first = await repoWithReadme("fresh-start");
2101+ await collaborator(R, "rita", "write", T.rita);
2102+ const merged = await pullFrom(R, first, "merged-change");
2103+ await ok("PUT", `${R}/pulls/${merged.number}/merge`, { merge_method: "squash" });
2104+ const closed = await pullFrom(R, first, "closed-change");
2105+ const comment = await ok("POST", `${R}/issues/${closed.number}/comments`, { body: "not this one" });
2106+ await status(201, "POST", `${R}/issues/comments/${comment.id}/reactions`, { content: "+1" });
2107+ await status(201, "POST", `${R}/issues/${closed.number}/reactions`, { content: "eyes" });
2108+ await ok("PATCH", `${R}/pulls/${closed.number}`, { state: "closed" });
2109+ const open = await pullFrom(R, first, "open-change");
2110+ const issue = await ok("POST", `${R}/issues`, { title: "Keep me" });
2111+
2112+ // Fresh history: main back to the first commit, the old branches gone. The closed pull
2113+ // requests still keep their commits fetchable, even through a purge.
2114+ await ok("PATCH", `${R}/git/refs/heads/main`, { sha: first, force: true });
2115+ await status(204, "DELETE", `${R}/git/refs/heads/merged-change`);
2116+ await status(204, "DELETE", `${R}/git/refs/heads/closed-change`);
2117+ await ok("POST", `${R}/diffinery/purge-unreachable`);
2118+ await ok("GET", `${R}/git/commits/${closed.head.sha}`);
2119+ await ok("GET", `${R}/git/commits/${merged.head.sha}`);
2120+
2121+ // People with the admin role only: not writers, not agents.
2122+ await status(403, "POST", `${R}/diffinery/delete-closed-pulls`, undefined, T.rita);
2123+ await status(403, "POST", `${R}/diffinery/delete-closed-pulls`, undefined, T.bot);
2124+ const r = await ok("POST", `${R}/diffinery/delete-closed-pulls`);
2125+ assert.equal(r.pull_requests, 2, JSON.stringify(r));
2126+ assert.ok(r.objects >= 4 && r.bytes > 0, JSON.stringify(r)); // both heads with their trees and files, and the squash commit
2127+ for (const n of [merged.number, closed.number]) {
2128+ await status(404, "GET", `${R}/pulls/${n}`);
2129+ await status(404, "GET", `${R}/issues/${n}`);
2130+ }
2131+ await status(404, "GET", `${R}/issues/comments/${comment.id}`);
2132+ for (const sha of [closed.head.sha, merged.head.sha]) await status(404, "GET", `${R}/git/commits/${sha}`);
2133+ const authed = `${base.replace("://", `://x:${T.olive}@`)}/${OWNER}/fresh-start.git`;
2134+ const refs = execFileSync("git", ["ls-remote", authed], { stdio: "pipe" }).toString();
2135+ assert.ok(!refs.includes(`refs/pull/${closed.number}/`) && !refs.includes(`refs/pull/${merged.number}/`), refs);
2136+ assert.ok(refs.includes(`refs/pull/${open.number}/head`), refs);
2137+
2138+ // The open pull request, the issue and main stay, and numbers aren't reused.
2139+ assert.deepEqual((await ok("GET", `${R}/pulls?state=all`)).map((p) => p.number), [open.number]);
2140+ await ok("GET", `${R}/git/commits/${open.head.sha}`);
2141+ assert.equal((await ok("GET", `${R}/issues/${issue.number}`)).title, "Keep me");
2142+ assert.equal((await ok("GET", `${R}/branches/main`)).commit.sha, first);
2143+ const next = await ok("POST", `${R}/issues`, { title: "Next" });
2144+ assert.equal(next.number, issue.number + 1);
2145+ await until("the audit entry", async () => auditLog("repo.delete_closed_pulls").some((e) => e.target === `${OWNER}/fresh-start` && JSON.parse(e.detail).pull_requests === 2));
2146+ // Nothing left to delete the second time.
2147+ assert.equal((await ok("POST", `${R}/diffinery/delete-closed-pulls`)).pull_requests, 0);
2148+});
2149+
20982150 test("security: webhooks reach only public hosts other than Diffinery, at most 20 per repository", async () => {
20992151 const R = `/repos/${OWNER}/hooked`;
21002152 await repoWithReadme("hooked");