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

Fast test/forks.test.ts: chop and rewrite under the budget (1 s a test, 10 s a file) #1290

Merged opened this

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

Merged

scout/forge:scout/1251-fast-test-forks-test-ts-chop-and-rewrite → main 2 commits fork

  • Merged. scout/1251-fast-test-forks-test-ts-chop-and-rewrite was merged into main as fc41cb8.

Review

Sign in to review this change.
1 file changed +164 −398
test/forks.test.ts +164 −398
@@ -2,473 +2,239 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
22 import { mkdtemp, rm } from "node:fs/promises";
33 import { tmpdir } from "node:os";
44 import { join } from "node:path";
5 import {
6 commitToBranch,
7 createRepo,
8 deleteRepo,
9 forkRepo,
10 updateRepoSettings,
11 } from "../src/actions/repos.ts";
12 import { syncFork } from "../src/actions/forks.ts";
513 import { createApp, type App } from "../src/app.ts";
6 import { deleteRepo, getRepo } from "../src/core/repos.ts";
14 import { getRepo, type Repo } from "../src/core/repos.ts";
15 import { createSession } from "../src/core/sessions.ts";
16 import { createUser, type User } from "../src/core/users.ts";
17 import { commitFiles } from "../src/git/write.ts";
18 import { resolveOid } from "../src/git/merge.ts";
719 import { repoDir } from "../src/git/repos.ts";
820 import { serve, type Servers } from "../src/http/server.ts";
9 import { gitClientEnv } from "./helpers/git-client.ts";
10 import { proveAll } from "./helpers/verified.ts";
1121
12 const gitEnv = gitClientEnv({
13 GIT_AUTHOR_NAME: "F",
14 GIT_AUTHOR_EMAIL: "[email protected]",
15 GIT_COMMITTER_NAME: "F",
16 GIT_COMMITTER_EMAIL: "[email protected]",
17 });
22 /**
23 * The invariants, one line each (the old file proved them through git over
24 * HTTP, a token and a sign-in per person, and a 50 ms poll):
25 * - a fork borrows its parent's objects, carries its history, and a push to
26 * it never reaches the parent;
27 * - a fork follows its parent's default branch on its own, fast-forward
28 * only; one with work of its own is refused and told so;
29 * - the About rail counts how far a fork is behind or ahead, and offers an
30 * update only when one can be taken;
31 * - the fork count and the list hold only forks the viewer may read;
32 * - a private repo cannot be forked by a stranger, nor forked twice;
33 * - a public fork never names a private parent to a stranger;
34 * - deleting the parent detaches the fork whole.
35 * Dropped as duplicates of what git.test.ts and the API suites prove: the
36 * clone/push wire itself, and the sync route's JSON shape.
37 */
38
39 const PASSWORD = "a-good-passphrase";
1840
1941 describe("forks", () => {
2042 let dataDir: string;
21 let work: string;
2243 let app: App;
2344 let servers: Servers;
2445 let base: string;
25 let aliceToken = "";
26 let bobToken = "";
46 const users: Record<string, User> = {};
47 const cookies: Record<string, string> = { "": "forge_mode=dev" };
48 let upstream: Repo;
2749
28 async function gitOk(args: string[], cwd: string): Promise<string> {
29 const p = Bun.spawn(["git", ...args], {
30 cwd,
31 env: gitEnv,
32 stdout: "pipe",
33 stderr: "pipe",
34 });
35 const [out, err, code] = await Promise.all([
36 new Response(p.stdout).text(),
37 new Response(p.stderr).text(),
38 p.exited,
39 ]);
40 if (code !== 0) throw new Error(`git ${args.join(" ")}: ${err}`);
41 return out.trim();
42 }
43 async function gitCode(args: string[], cwd: string): Promise<number> {
44 const p = Bun.spawn(["git", ...args], {
45 cwd,
46 env: gitEnv,
47 stdout: "ignore",
48 stderr: "ignore",
49 });
50 return await p.exited;
51 }
52
53 async function mkUser(username: string): Promise<string> {
54 await fetch(`${base}/api/v1/users`, {
55 method: "POST",
56 headers: { "content-type": "application/json" },
57 body: JSON.stringify({
58 username,
59 email: `${username}@f.dev`,
60 password: "fork-me-please",
50 const dirOf = (repo: Repo) => repoDir(app.reposRoot, repo.ownerName, repo.name);
51 const tip = (repo: Repo) => resolveOid(dirOf(repo), `refs/heads/${repo.defaultBranch}`);
52 const must = <T>(r: { ok: true; value: T } | { ok: false; error: unknown }): T => {
53 if (!r.ok) throw new Error(JSON.stringify(r.error));
54 return r.value;
55 };
56 const forkAs = async (who: string, parent: Repo, name?: string) =>
57 must(await forkRepo(app, users[who]!, parent, name));
58 const commitAs = async (who: string, repo: Repo, file: string, message: string) =>
59 must(
60 await commitToBranch(app, users[who]!, repo, {
61 message,
62 files: [{ path: file, content: `${message}\n` }],
6163 }),
62 });
63 proveAll(app);
64 return fetch(`${base}/api/v1/tokens`, {
65 method: "POST",
66 headers: { "content-type": "application/json" },
67 body: JSON.stringify({ adminConfirm: true,
68 username,
69 password: "fork-me-please",
70 name: "c",
71 scope: "admin",
72 }),
73 })
74 .then((r) => r.json())
75 .then((j) => (j as { token: string }).token);
76 }
77
78 /** Sign in through the web form; the cookie is what a browser would carry. */
79 async function signIn(username: string): Promise<string> {
80 const res = await fetch(`${base}/signin`, {
81 method: "POST",
82 body: new URLSearchParams({ username, password: "fork-me-please" }),
83 redirect: "manual",
84 });
85 return (res.headers.get("set-cookie") ?? "").split(";")[0]!;
86 }
87
88 /** The HTML of a page as somebody — or, with no cookie, as a stranger. */
89 async function pageAs(path: string, cookie?: string): Promise<string> {
90 const res = await fetch(
91 `${base}${path}`,
92 cookie === undefined ? {} : { headers: { cookie } },
9364 );
65 /** Pushes are followed in the background; give it turns of the event loop, not a clock. */
66 const follows = async (fork: Repo, want: string | undefined) => {
67 for (let turns = 0; turns < 5_000; turns++) {
68 if ((await tip(fork)) === want) return true;
69 await new Promise((resolve) => setImmediate(resolve));
70 }
71 return false;
72 };
73 const page = async (path: string, who = "") => {
74 const res = await fetch(`${base}${path}`, { headers: { cookie: cookies[who]! } });
9475 expect(res.status).toBe(200);
9576 return await res.text();
96 }
77 };
9778
9879 beforeAll(async () => {
9980 dataDir = await mkdtemp(join(tmpdir(), "forge-fork-"));
100 work = await mkdtemp(join(tmpdir(), "forge-fork-work-"));
10181 app = createApp({ dataDir, port: 0 });
10282 servers = await serve(app);
10383 base = servers.url;
104 aliceToken = await mkUser("alice");
105 bobToken = await mkUser("bob");
106
107 // Alice makes a public repo with a commit.
108 await fetch(`${base}/api/v1/repos`, {
109 method: "POST",
110 headers: {
111 authorization: `Bearer ${aliceToken}`,
112 "content-type": "application/json",
113 },
114 body: JSON.stringify({ name: "app", visibility: "public" }),
115 });
116 const remote = `${base.replace("http://", `http://alice:${aliceToken}@`)}/alice/app.git`;
117 await gitOk(["clone", remote, "app"], work);
118 const dir = join(work, "app");
119 await Bun.write(join(dir, "README.md"), "# original\n");
120 await gitOk(["add", "."], dir);
121 await gitOk(["commit", "-m", "seed"], dir);
122 await gitOk(["push", "origin", "main"], dir);
123 });
84 for (const name of ["alice", "bob", "carol", "dan"]) {
85 const made = await createUser(app.db, {
86 trusted: true,
87 username: name,
88 email: `${name}@f.dev`,
89 password: PASSWORD,
90 adminIfFirst: name === "alice",
91 });
92 users[name] = must(made);
93 cookies[name] =
94 `forge_session=${createSession(app.db, users[name]!.id).token}; forge_mode=dev`;
95 }
96 upstream = must(
97 await createRepo(app, users["alice"]!, { name: "app", visibility: "public" }),
98 );
99 await commitAs("alice", upstream, "README.md", "seed");
100 }, 60_000);
124101
125102 afterAll(async () => {
126103 servers.stop();
127104 app.close();
128105 await rm(dataDir, { recursive: true, force: true });
129 await rm(work, { recursive: true, force: true });
130106 });
131107
132 test("bob forks alice's repo; it shares objects via alternates", async () => {
133 const res = await fetch(`${base}/api/v1/repos/alice/app/forks`, {
134 method: "POST",
135 headers: {
136 authorization: `Bearer ${bobToken}`,
137 "content-type": "application/json",
138 },
139 body: JSON.stringify({}),
140 });
141 expect(res.status).toBe(201);
142 const fork = (await res.json()) as {
143 ownerName: string;
144 name: string;
145 forkParentId: string | null;
146 };
147 expect(fork.ownerName).toBe("bob");
148 expect(fork.forkParentId).not.toBeNull();
149
150 const forkDir = repoDir(join(dataDir, "repos"), "bob", "app");
151 // Alternates present → objects are shared, not copied.
108 test("a fork borrows its parent's objects and a push to it never reaches the parent", async () => {
109 const bob = await forkAs("bob", upstream);
110 expect(bob.ownerName).toBe("bob");
111 expect(bob.forkParentId).toBe(upstream.id);
152112 expect(
153 await Bun.file(join(forkDir, "objects", "info", "alternates")).exists(),
113 await Bun.file(join(dirOf(bob), "objects", "info", "alternates")).exists(),
154114 ).toBe(true);
155 // The fork carries the parent's history.
156 const log = await gitOk(["-C", forkDir, "log", "--oneline"], work);
157 expect(log).toContain("seed");
115 const seed = await tip(upstream);
116 expect(await tip(bob)).toBe(seed);
117
118 await commitAs("bob", bob, "feature.txt", "bob feature");
119 expect(await tip(bob)).not.toBe(seed);
120 expect(await tip(upstream)).toBe(seed);
158121 });
159122
160 test("bob pushes to his fork; alice's repo is untouched", async () => {
161 const forkRemote = `${base.replace("http://", `http://bob:${bobToken}@`)}/bob/app.git`;
162 await gitOk(["clone", forkRemote, "bob-app"], work);
163 const dir = join(work, "bob-app");
164 await Bun.write(join(dir, "feature.txt"), "bob's work\n");
165 await gitOk(["add", "."], dir);
166 await gitOk(["commit", "-m", "bob feature"], dir);
167 await gitOk(["push", "origin", "main"], dir);
123 test("a fork follows its parent fast-forward only; one with work of its own is refused", async () => {
124 const carol = await forkAs("carol", upstream);
125 await commitAs("alice", upstream, "NEWS.md", "alice moves on");
126 const moved = await tip(upstream);
127 expect(await follows(carol, moved)).toBe(true);
168128
169 // The fork has bob's commit…
170 const forkLog = await gitOk(
171 ["-C", repoDir(join(dataDir, "repos"), "bob", "app"), "log", "--oneline"],
172 work,
173 );
174 expect(forkLog).toContain("bob feature");
175 // …but alice's repo does not.
176 const aliceLog = await gitOk(
177 [
178 "-C",
179 repoDir(join(dataDir, "repos"), "alice", "app"),
180 "log",
181 "--oneline",
182 ],
183 work,
184 );
185 expect(aliceLog).not.toContain("bob feature");
186 });
187
188 test("a fork follows its parent's default branch, fast-forward only", async () => {
189 const carolToken = await mkUser("carol");
190 const as = (token: string) => ({
191 authorization: `Bearer ${token}`,
192 "content-type": "application/json",
193 });
194 const forked = await fetch(`${base}/api/v1/repos/alice/app/forks`, {
195 method: "POST",
196 headers: as(carolToken),
197 body: "{}",
198 });
199 expect(forked.status).toBe(201);
200 const repos = join(dataDir, "repos");
201
202 // Alice moves main; carol's untouched fork follows on its own.
203 const dir = join(work, "app");
204 await Bun.write(join(dir, "NEWS.md"), "moved\n");
205 await gitOk(["add", "."], dir);
206 await gitOk(["commit", "-m", "alice moves on"], dir);
207 await gitOk(["push", "origin", "main"], dir);
208 const aliceTip = (
209 await gitOk(
210 ["-C", repoDir(repos, "alice", "app"), "rev-parse", "main"],
211 work,
212 )
213 ).trim();
214 const deadline = Date.now() + 5_000;
215 let carolTip = "";
216 while (Date.now() < deadline) {
217 carolTip = (
218 await gitOk(
219 ["-C", repoDir(repos, "carol", "app"), "rev-parse", "main"],
220 work,
221 )
222 ).trim();
223 if (carolTip === aliceTip) break;
224 await Bun.sleep(50);
225 }
226 expect(carolTip).toBe(aliceTip);
227129 // Asked by hand, there is nothing left to do.
228 const again = await fetch(`${base}/api/v1/repos/carol/app/sync`, {
229 method: "POST",
230 headers: as(carolToken),
231 body: "{}",
232 });
233 expect(again.status).toBe(200);
234 const answer = (await again.json()) as { updated: boolean; to: string };
235 expect(answer.updated).toBe(false);
236 expect(answer.to).toBe(aliceTip);
130 const again = must(await syncFork(app, users["carol"]!, carol));
131 expect(again.updated).toBe(false);
132 expect(again.to).toBe(moved!);
237133
238 // Bob committed on his fork's main: it is his to reconcile, and he is told.
239 const bobBefore = (
240 await gitOk(
241 ["-C", repoDir(repos, "bob", "app"), "rev-parse", "main"],
242 work,
243 )
244 ).trim();
245 const refused = await fetch(`${base}/api/v1/repos/bob/app/sync`, {
246 method: "POST",
247 headers: as(bobToken),
248 body: "{}",
249 });
250 expect(refused.status).toBe(409);
251 expect(((await refused.json()) as { error: string }).error).toBe(
252 "diverged",
253 );
254 expect(
255 (
256 await gitOk(
257 ["-C", repoDir(repos, "bob", "app"), "rev-parse", "main"],
258 work,
259 )
260 ).trim(),
261 ).toBe(bobBefore);
134 const bob = getRepo(app.db, "bob", "app")!;
135 const before = await tip(bob);
136 const refused = await syncFork(app, users["bob"]!, bob);
137 expect(refused.ok).toBe(false);
138 if (!refused.ok) expect(refused.error).toBe("diverged");
139 expect(await tip(bob)).toBe(before);
262140 });
263141
264 test("the About rail says how far a fork is from its parent", async () => {
265 const as = (token: string) => ({
266 authorization: `Bearer ${token}`,
267 "content-type": "application/json",
268 });
269
270 // Dan forks alice/app as it stands, so his copy starts level with it:
271 // nothing to take, and nothing offered.
272 const danToken = await mkUser("dan");
273 const danCookie = await signIn("dan");
274 expect(
275 (
276 await fetch(`${base}/api/v1/repos/alice/app/forks`, {
277 method: "POST",
278 headers: as(danToken),
279 body: "{}",
280 })
281 ).status,
282 ).toBe(201);
283 const current = await pageAs("/dan/app", danCookie);
142 test("the About rail says how far a fork is from its parent, and offers an update only when one can be taken", async () => {
143 // Dan forks as upstream stands: level, nothing offered.
144 await forkAs("dan", upstream);
145 const current = await page("/dan/app", "dan");
284146 expect(current).toContain("up to date");
285147 expect(current).not.toContain("Update from parent");
286148
287 // Alice's main moves without a push the forge saw — a follow that failed,
288 // or has not happened yet — which is how a fork ends up plainly behind:
289 // the page counts the commit it is missing and offers the update.
290 const dir = join(work, "app");
291 await Bun.write(join(dir, "LATER.md"), "later\n");
292 await gitOk(["add", "."], dir);
293 await gitOk(["commit", "-m", "alice again"], dir);
294 await gitOk(
295 [
296 "-C",
297 repoDir(join(dataDir, "repos"), "alice", "app"),
298 "fetch",
299 "-q",
300 dir,
301 "main:main",
302 ],
303 work,
149 // Upstream's main moves with no push the forge saw (a follow that failed):
150 // the fork is plainly behind and the page offers the update.
151 must(
152 await commitFiles(dirOf(upstream), {
153 branch: "main",
154 message: "alice again",
155 files: [{ path: "LATER.md", content: "later\n" }],
156 author: { name: "A", email: "[email protected]" },
157 }),
304158 );
305
306 const behind = await pageAs("/dan/app", danCookie);
159 const behind = await page("/dan/app", "dan");
307160 expect(behind).toContain("1 commit behind");
308161 expect(behind).toContain("Take updates");
309162
310 // Bob committed on his fork and alice moved on twice since: an update
311 // would be refused, so the page says why instead of offering one.
312 const diverged = await pageAs("/bob/app", await signIn("bob"));
163 // Bob has work of his own and is two behind: an update would be refused,
164 // so the page says why instead of offering one.
165 const diverged = await page("/bob/app", "bob");
313166 expect(diverged).toContain("1 of your own");
314167 expect(diverged).toContain("2 behind");
315168 expect(diverged).not.toContain("Update from parent");
316169 });
317170
318 test("the fork count on the repository page opens the list of forks", async () => {
319 const home = await (await fetch(`${base}/alice/app`)).text();
171 test("the fork count opens the list, and counts only forks the viewer may read", async () => {
172 const home = await page("/alice/app");
320173 expect(home).toContain('href="/alice/app/forks"');
321174 expect(home).toContain("3 forks");
322 const listed = await (await fetch(`${base}/alice/app/forks`)).text();
175 const listed = await page("/alice/app/forks");
323176 expect(listed).toContain("3 forks");
324 expect(listed).toContain('href="/bob/app"');
325 expect(listed).toContain('href="/carol/app"');
326 expect(listed).toContain('href="/dan/app"');
177 for (const who of ["bob", "carol", "dan"])
178 expect(listed).toContain(`href="/${who}/app"`);
179
180 const quiet = await forkAs("alice", upstream, "app-quiet");
181 must(await updateRepoSettings(app, users["alice"]!, quiet, { visibility: "private" }));
182 expect(await page("/alice/app")).toContain("3 forks");
183 expect(await page("/alice/app/forks")).not.toContain('href="/alice/app-quiet"');
184 expect(await page("/alice/app", "alice")).toContain("4 forks");
327185 });
328186
329 test("a stranger cannot fork a private repo", async () => {
330 await fetch(`${base}/api/v1/repos`, {
331 method: "POST",
332 headers: {
333 authorization: `Bearer ${aliceToken}`,
334 "content-type": "application/json",
335 },
336 body: JSON.stringify({ name: "secret", visibility: "private" }),
337 });
338 const res = await fetch(`${base}/api/v1/repos/alice/secret/forks`, {
339 method: "POST",
340 headers: {
341 authorization: `Bearer ${bobToken}`,
342 "content-type": "application/json",
343 },
344 body: JSON.stringify({}),
345 });
346 expect(res.status).toBe(404);
347 });
348
349 test("double-forking is refused", async () => {
350 const res = await fetch(`${base}/api/v1/repos/alice/app/forks`, {
351 method: "POST",
352 headers: {
353 authorization: `Bearer ${bobToken}`,
354 "content-type": "application/json",
355 },
356 body: JSON.stringify({}),
357 });
358 expect(res.status).toBe(422);
359 expect(((await res.json()) as { error: string }).error).toBe(
360 "already_forked",
187 test("a stranger cannot fork a private repo, and nobody forks a parent twice", async () => {
188 const secret = must(
189 await createRepo(app, users["alice"]!, { name: "secret", visibility: "private" }),
361190 );
191 const refused = await forkRepo(app, users["bob"]!, secret);
192 expect(refused.ok).toBe(false);
193 if (!refused.ok) expect(refused.error).toBe("not_found");
194
195 const twice = await forkRepo(app, users["bob"]!, upstream);
196 expect(twice.ok).toBe(false);
197 if (!twice.ok) expect(twice.error).toBe("already_forked");
362198 });
363199
364 test("a fork does not name a parent the viewer cannot read", async () => {
365 const asAlice = {
366 authorization: `Bearer ${aliceToken}`,
367 "content-type": "application/json",
368 };
369 // A private repo, forked by its own owner and then opened: the copy is
370 // public, the parent is not. The lineage row is the one place a public
371 // page could name a repository the visitor cannot see — and the drift
372 // beside it would say how busy that repository has been.
373 await fetch(`${base}/api/v1/repos`, {
374 method: "POST",
375 headers: asAlice,
376 body: JSON.stringify({ name: "vault", visibility: "private" }),
377 });
378 const remote = `${base.replace("http://", `http://alice:${aliceToken}@`)}/alice/vault.git`;
379 await gitOk(["clone", remote, "vault"], work);
380 const dir = join(work, "vault");
381 await Bun.write(join(dir, "README.md"), "# vault\n");
382 await gitOk(["add", "."], dir);
383 await gitOk(["commit", "-m", "vault seed"], dir);
384 await gitOk(["push", "origin", "main"], dir);
385 expect(
386 (
387 await fetch(`${base}/api/v1/repos/alice/vault/forks`, {
388 method: "POST",
389 headers: asAlice,
390 body: JSON.stringify({ name: "vault-open" }),
391 })
392 ).status,
393 ).toBe(201);
394 expect(
395 (
396 await fetch(`${base}/api/v1/repos/alice/vault-open`, {
397 method: "PATCH",
398 headers: asAlice,
399 body: JSON.stringify({ visibility: "public" }),
400 })
401 ).status,
402 ).toBe(200);
200 test("a public fork does not name a private parent to a stranger", async () => {
201 const vault = must(
202 await createRepo(app, users["alice"]!, { name: "vault", visibility: "private" }),
203 );
204 await commitAs("alice", vault, "README.md", "vault seed");
205 const open = await forkAs("alice", vault, "vault-open");
206 must(await updateRepoSettings(app, users["alice"]!, open, { visibility: "public" }));
403207
404 const stranger = await pageAs("/alice/vault-open");
208 const stranger = await page("/alice/vault-open");
405209 expect(stranger).not.toContain("Fork of");
406210 expect(stranger).not.toContain('href="/alice/vault"');
407211 expect(stranger).not.toContain("up to date");
408212
409 // Alice reads the same page and the row is all there.
410 const owner = await pageAs("/alice/vault-open", await signIn("alice"));
213 const owner = await page("/alice/vault-open", "alice");
411214 expect(owner).toContain("Fork of");
412215 expect(owner).toContain('href="/alice/vault"');
413216 expect(owner).toContain("up to date");
414217 });
415218
416 test("a fork the viewer cannot read is not counted on the parent", async () => {
417 const asAlice = {
418 authorization: `Bearer ${aliceToken}`,
419 "content-type": "application/json",
420 };
219 test("deleting the parent detaches the fork, which survives whole", async () => {
220 const bob = getRepo(app.db, "bob", "app")!;
221 must(await deleteRepo(app, users["alice"]!, upstream));
222
421223 expect(
422 (
423 await fetch(`${base}/api/v1/repos/alice/app/forks`, {
424 method: "POST",
425 headers: asAlice,
426 body: JSON.stringify({ name: "app-quiet" }),
427 })
428 ).status,
429 ).toBe(201);
430 expect(
431 (
432 await fetch(`${base}/api/v1/repos/alice/app-quiet`, {
433 method: "PATCH",
434 headers: asAlice,
435 body: JSON.stringify({ visibility: "private" }),
436 })
437 ).status,
438 ).toBe(200);
439
440 // The count is a link to the list, so it counts what the list would show.
441 const stranger = await pageAs("/alice/app");
442 expect(stranger).toContain("3 forks");
443 expect(await pageAs("/alice/app/forks")).not.toContain(
444 'href="/alice/app-quiet"',
445 );
446 expect(await pageAs("/alice/app", await signIn("alice"))).toContain(
447 "4 forks",
448 );
449 });
450
451 test("deleting the parent detaches the fork, which survives standalone", async () => {
452 const forkDir = repoDir(join(dataDir, "repos"), "bob", "app");
453 const parent = getRepo(app.db, "alice", "app")!;
454
455 // Delete the parent whose objects the fork borrows.
456 const deleted = await deleteRepo(app.db, join(dataDir, "repos"), parent);
457 expect(deleted.ok).toBe(true);
458
459 // The fork's alternates link is gone (objects were absorbed)…
460 expect(
461 await Bun.file(join(forkDir, "objects", "info", "alternates")).exists(),
224 await Bun.file(join(dirOf(bob), "objects", "info", "alternates")).exists(),
462225 ).toBe(false);
463 // …its parent link is cleared…
464226 expect(getRepo(app.db, "bob", "app")!.forkParentId).toBeNull();
465 // …and it is fully intact: git can still walk the original + bob's history
466 // with strict connectivity (would fail if borrowed objects were lost).
467 expect(
468 await gitCode(["-C", forkDir, "fsck", "--connectivity-only"], work),
469 ).toBe(0);
470 const log = await gitOk(["-C", forkDir, "log", "--oneline"], work);
471 expect(log).toContain("seed");
472 expect(log).toContain("bob feature");
227 // Strict connectivity fails if any borrowed object was not absorbed.
228 const fsck = Bun.spawn(["git", "-C", dirOf(bob), "fsck", "--connectivity-only"], {
229 stdout: "ignore",
230 stderr: "ignore",
231 });
232 expect(await fsck.exited).toBe(0);
233 const log = Bun.spawn(["git", "-C", dirOf(bob), "log", "--format=%s"], {
234 stdout: "pipe",
235 });
236 const subjects = await new Response(log.stdout).text();
237 expect(subjects).toContain("seed");
238 expect(subjects).toContain("bob feature");
473239 });
474240 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs