Public: anyone can read this; the crew can change it.
deleteRepo orphans files when rm fails partway: presence is keyed on HEAD, not the directory #7173
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7140-deleterepo-orphans-files-when-rm-fails-p → main 5 commits fork 2 threads · 2 unresolved
4 files
changed +90
−5
src/core/reconcile.ts +2
| @@ -36,6 +36,8 @@ export async function reconcile(app: App): Promise<void> { | ||
| 36 | 36 | const row = rows[index]; |
| 37 | 37 | if (row === undefined) return; |
| 38 | 38 | const dir = repoDir(app.reposRoot, row.owner_name, row.name); |
| 39 | // HEAD, not the directory: hooks can only be installed into a usable | |
| 40 | // repository, and a half-removed one (no HEAD) is not that. | |
| 39 | 41 | if (await repoExists(dir)) { |
| 40 | 42 | await installHooks(dir); |
| 41 | 43 | refreshed++; |
src/core/repos.ts +2 −2
| @@ -15,7 +15,7 @@ import { | ||
| 15 | 15 | detachFork, |
| 16 | 16 | forkBareRepo, |
| 17 | 17 | initBareRepo, |
| 18 | repoExists, | |
| 18 | repoDirExists, | |
| 19 | 19 | repoDir, |
| 20 | 20 | } from "../git/repos.ts"; |
| 21 | 21 | import { id } from "../kernel/id.ts"; |
| @@ -902,7 +902,7 @@ export async function deleteRepo( | ||
| 902 | 902 | // keeps the space actually free after the unlink instead of pinned to a |
| 903 | 903 | // descriptor nobody will ever read again. |
| 904 | 904 | closeReader(dir); |
| 905 | const present = await repoExists(dir); | |
| 905 | const present = await repoDirExists(dir); | |
| 906 | 906 | // The directory goes first: when it will not, the row stays, so the |
| 907 | 907 | // repository is still listed and a retry finds it. With no directory there |
| 908 | 908 | // is nothing to orphan, so the row goes and the missing repository is |
src/git/repos.ts +15 −3
| @@ -5,7 +5,7 @@ | ||
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | 7 | import { join } from "node:path"; |
| 8 | import { rm } from "node:fs/promises"; | |
| 8 | import { rm, stat } from "node:fs/promises"; | |
| 9 | 9 | import { defect } from "../kernel/error.ts"; |
| 10 | 10 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 11 | 11 | import { runGit, type GitFailure } from "./env.ts"; |
| @@ -119,14 +119,26 @@ export async function setDefaultBranch( | ||
| 119 | 119 | return result.ok ? ok() : result; |
| 120 | 120 | } |
| 121 | 121 | |
| 122 | /** Permanently delete a repository directory. The caller owns the ceremony. */ | |
| 122 | /** Is anything at this path? Keyed on the directory, so a half-removed repo counts. */ | |
| 123 | export async function repoDirExists(dir: string): Promise<boolean> { | |
| 124 | return await stat(dir).then( | |
| 125 | () => true, | |
| 126 | () => false, | |
| 127 | ); | |
| 128 | } | |
| 129 | ||
| 130 | /** | |
| 131 | * Permanently delete a repository directory. The caller owns the ceremony. | |
| 132 | * Presence is the directory, not HEAD: a removal that died partway leaves no | |
| 133 | * HEAD but still leaves files, and a retry must finish the job. | |
| 134 | */ | |
| 123 | 135 | export async function deleteRepoDir( |
| 124 | 136 | dir: string, |
| 125 | 137 | ): Promise<Result<void, { message: string }>> { |
| 126 | 138 | if (!dir.endsWith(".git")) { |
| 127 | 139 | defect("git.refuse_delete", `refusing to delete non-repo path: ${dir}`); |
| 128 | 140 | } |
| 129 | if (!(await repoExists(dir))) | |
| 141 | if (!(await repoDirExists(dir))) | |
| 130 | 142 | return err({ message: `not a repository: ${dir}` }); |
| 131 | 143 | try { |
| 132 | 144 | await rm(dir, { recursive: true, force: true }); |
test/delete-repo-partial.test.ts added +71
| @@ -0,0 +1,71 @@ | ||
| 1 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 2 | import { chmodSync, existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createApp, type App } from "../src/app.ts"; | |
| 7 | import { createRepo, deleteRepo, getRepo } from "../src/core/repos.ts"; | |
| 8 | import { createUser } from "../src/core/users.ts"; | |
| 9 | import { repoDir } from "../src/git/repos.ts"; | |
| 10 | ||
| 11 | /** | |
| 12 | * deleteRepo keys presence on the directory, not on HEAD: a removal that died | |
| 13 | * partway (HEAD gone, a subdirectory stuck) keeps the row, and the retry | |
| 14 | * finishes the job instead of dropping the row over orphaned files. | |
| 15 | */ | |
| 16 | ||
| 17 | const root = process.getuid?.() === 0; | |
| 18 | ||
| 19 | describe("deleteRepo after a partial removal", () => { | |
| 20 | let dataDir: string; | |
| 21 | let app: App; | |
| 22 | ||
| 23 | beforeAll(async () => { | |
| 24 | dataDir = await mkdtemp(join(tmpdir(), "forge-delpartial-")); | |
| 25 | app = createApp({ dataDir, port: 0 }); | |
| 26 | }); | |
| 27 | afterAll(async () => { | |
| 28 | app.db.close(); | |
| 29 | await rm(dataDir, { recursive: true, force: true }); | |
| 30 | }); | |
| 31 | ||
| 32 | test.skipIf(root)("a retry finishes what a failed removal left", async () => { | |
| 33 | const made = await createUser(app.db, { | |
| 34 | origin: "seed", | |
| 35 | trusted: true, | |
| 36 | username: "alice", | |
| 37 | email: "[email protected]", | |
| 38 | password: "a-good-passphrase", | |
| 39 | adminIfFirst: true, | |
| 40 | }); | |
| 41 | if (!made.ok) throw new Error("user"); | |
| 42 | const created = await createRepo(app.db, app.reposRoot, { | |
| 43 | ownerId: made.value.id, | |
| 44 | name: "stuck", | |
| 45 | }); | |
| 46 | if (!created.ok) throw new Error("repo"); | |
| 47 | const repo = created.value; | |
| 48 | const dir = repoDir(app.reposRoot, repo.ownerName, repo.name); | |
| 49 | ||
| 50 | // HEAD is already gone; a locked subdirectory will not let rm finish. | |
| 51 | rmSync(join(dir, "HEAD")); | |
| 52 | const locked = join(dir, "objects"); | |
| 53 | writeFileSync(join(locked, "keep"), "x"); | |
| 54 | mkdirSync(join(locked, "sub"), { recursive: true }); | |
| 55 | chmodSync(locked, 0o500); | |
| 56 | ||
| 57 | try { | |
| 58 | const first = await deleteRepo(app.db, app.reposRoot, repo); | |
| 59 | expect(first.ok).toBe(false); | |
| 60 | expect(existsSync(dir)).toBe(true); | |
| 61 | expect(getRepo(app.db, repo.ownerName, repo.name)).toBeDefined(); | |
| 62 | } finally { | |
| 63 | chmodSync(locked, 0o700); | |
| 64 | } | |
| 65 | ||
| 66 | const second = await deleteRepo(app.db, app.reposRoot, repo); | |
| 67 | expect(second.ok).toBe(true); | |
| 68 | expect(existsSync(dir)).toBe(false); | |
| 69 | expect(getRepo(app.db, repo.ownerName, repo.name)).toBeUndefined(); | |
| 70 | }); | |
| 71 | }); |
new above
offline