Public: anyone can read this; the crew can change it.

A test pins deleting a repository with forks: every fork still clones afterwards, and a fork that cannot be detached stops the delete #7178

Merged opened this

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

Merged

scout/forge:scout/7174-a-test-pins-deleting-a-repository-with-f → main 3 commits fork 1 thread · 1 unresolved

  • Merged. scout/7174-a-test-pins-deleting-a-repository-with-f was merged into main as c5ecd55.

Review

Sign in to review this change.
1 file changed +152 −0
test/repo-delete-forks.test.ts added +152
@@ -0,0 +1,152 @@
1 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2 import { chmod, mkdtemp, rm } from "node:fs/promises";
3 import { existsSync } from "node:fs";
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, forkRepo, getRepo } from "../src/core/repos.ts";
8 import { repoDir } from "../src/git/repos.ts";
9 import { commitFiles } from "../src/git/write.ts";
10 import { signUpProven as signUp } from "./helpers/verified.ts";
11 import type { Repo } from "../src/core/repos.ts";
12
13 /**
14 * Deleting a repository with forks: each fork is detached first, so every
15 * fork still clones afterwards; a fork that cannot be detached stops the
16 * delete with `delete_failed` and leaves the parent whole.
17 */
18
19 const git = async (cwd: string, ...args: string[]) => {
20 const p = Bun.spawn(["git", ...args], { cwd, stdout: "pipe", stderr: "pipe" });
21 const [out, code] = await Promise.all([new Response(p.stdout).text(), p.exited]);
22 return { out: out.trim(), code };
23 };
24
25 /** As root, a read-only directory does not stop a write: the failures cannot be made. */
26 const unprivileged = process.getuid?.() !== 0;

Good: as root, chmod 0o555 blocks nothing, so skipping these tests is the honest choice. Neither the author nor I could run under root, so the skip itself has not been exercised.

27
28 describe("deleting a repository with forks", () => {
29 let dataDir: string;
30 let app: App;
31 let n = 0;
32 const subjects = ["one", "two", "three"];
33
34 const user = async (username: string) => {
35 const u = await signUp(app, {
36 username,
37 email: `${username}@forge.test`,
38 password: "a-long-passphrase",
39 });
40 if (!u.ok) throw new Error("signup failed");
41 return u.value;
42 };
43
44 /** A public parent of three commits, owned by `owner`, with two forks. */
45 const family = async () => {
46 n += 1;
47 const owner = await user(`owner${n}`);
48 const a = await user(`forka${n}`);
49 const b = await user(`forkb${n}`);
50 const made = await createRepo(app.db, app.reposRoot, {
51 ownerId: owner.id,
52 name: "parent",
53 visibility: "public",
54 });
55 if (!made.ok) throw new Error("create failed");
56 const parent = made.value;
57 const dir = repoDir(app.reposRoot, parent.ownerName, parent.name);
58 for (const subject of subjects) {
59 const c = await commitFiles(dir, {
60 branch: "main",
61 message: subject,
62 files: [{ path: `${subject}.txt`, content: subject }],
63 author: { name: "T", email: "[email protected]" },
64 });
65 if (!c.ok) throw new Error("commit failed");
66 }
67 const forks: Repo[] = [];
68 for (const f of [a, b]) {
69 const r = await forkRepo(app.db, app.reposRoot, parent, f.id);
70 if (!r.ok) throw new Error(`fork failed: ${r.error}`);
71 forks.push(r.value);
72 }
73 const fork = (f: Repo) => repoDir(app.reposRoot, f.ownerName, f.name);
74 return { parent, dir, forks, fork };
75 };
76
77 const cloneSubjects = async (from: string): Promise<string[]> => {
78 const into = await mkdtemp(join(tmpdir(), "forge-clone-"));
79 try {
80 const target = join(into, "c");
81 const cloned = await git(into, "clone", "--quiet", from, target);
82 expect(cloned.code).toBe(0);
83 const log = await git(target, "log", "--format=%s", "main");
84 expect(log.code).toBe(0);
85 const fsck = await git(target, "fsck", "--full");
86 expect(fsck.code).toBe(0);
87 return log.out.split("\n").reverse();
88 } finally {
89 await rm(into, { recursive: true, force: true });
90 }
91 };
92
93 beforeAll(async () => {
94 dataDir = await mkdtemp(join(tmpdir(), "forge-delete-forks-"));
95 app = createApp({ dataDir, port: 0 });
96 });
97
98 afterAll(async () => {
99 app.close();
100 await rm(dataDir, { recursive: true, force: true });
101 });
102
103 test("both forks still clone with every commit after the parent is gone", async () => {
104 const { parent, dir, forks, fork } = await family();
105 expect(await deleteRepo(app.db, app.reposRoot, parent)).toEqual({ ok: true, value: undefined });
106 expect(existsSync(dir)).toBe(false);
107 expect(getRepo(app.db, parent.ownerName, parent.name)).toBeUndefined();
108 for (const f of forks) {
109 expect(existsSync(join(fork(f), "objects", "info", "alternates"))).toBe(false);
110 expect(await cloneSubjects(fork(f))).toEqual(subjects);
111 }
112 });
113
114 test.skipIf(!unprivileged)("a fork that cannot be detached stops the delete and leaves everything whole", async () => {
115 const { parent, dir, forks, fork } = await family();
116 const pack = join(fork(forks[1]!), "objects", "pack");
117 await chmod(pack, 0o555);
118 try {
119 const r = await deleteRepo(app.db, app.reposRoot, parent);
120 expect(r).toEqual({ ok: false, error: "delete_failed" });
121 } finally {
122 await chmod(pack, 0o755);
123 }
124 expect(existsSync(dir)).toBe(true);
125 expect(getRepo(app.db, parent.ownerName, parent.name)?.id).toBe(parent.id);
126 expect(await cloneSubjects(dir)).toEqual(subjects);
127 for (const f of forks) expect(await cloneSubjects(fork(f))).toEqual(subjects);
128 });
129
130 test.skipIf(!unprivileged)("a directory that cannot be removed answers delete_failed and keeps the row and the directory", async () => {
131 n += 1;
132 const owner = await user(`stuck${n}`);
133 const made = await createRepo(app.db, app.reposRoot, {
134 ownerId: owner.id,
135 name: "stuck",
136 });
137 if (!made.ok) throw new Error("create failed");
138 const dir = repoDir(app.reposRoot, made.value.ownerName, made.value.name);
139 const holder = join(dir, "..");
140 await chmod(holder, 0o555);
141 try {
142 expect(await deleteRepo(app.db, app.reposRoot, made.value)).toEqual({
143 ok: false,
144 error: "delete_failed",
145 });
146 } finally {
147 await chmod(holder, 0o755);
148 }
149 expect(existsSync(dir)).toBe(true);
150 expect(getRepo(app.db, made.value.ownerName, made.value.name)?.id).toBe(made.value.id);
151 });
152 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs