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

Reading a cross-repo pull spawns no git process when its head has not moved #8167

Merged opened this

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

Merged

scout/forge:scout/8157-reading-a-cross-repo-pull-spawns-no-git → main 1 commit fork

  • Merged. scout/8157-reading-a-cross-repo-pull-spawns-no-git was merged into main as b56eed2.

Review

Sign in to review this change.
2 files changed +160 −6
src/core/following.ts +13 −6
@@ -39,6 +39,7 @@ import {
3939 } from "../git/merge.ts";
4040 import { runGit } from "../git/env.ts";
4141 import { readTipOrAbsent } from "../git/tip.ts";
42 import { readTips } from "../git/tips.ts";
4243 import { repoDir } from "../git/repos.ts";
4344 import { forkFollowMode } from "./fork_follow.ts";
4445 import { createLog } from "../kernel/log.ts";
@@ -429,13 +430,19 @@ export async function refreshPullHead(
429430 // everyone reads (`pullDir`, `core/sealed.ts`).
430431 const apart = pull.sealed && head.sealedFor === repo.id;
431432 const snapshotDir = apart ? headDir : baseDir;
432 // Two cheap `rev-parse`s before a fetch: this runs on every read of a
433 // cross-repo pull, and the overwhelmingly common answer is "nothing moved".
434 const [live, snapshot] = await Promise.all([
435 resolveOid(headDir, `refs/heads/${pull.headBranch}`),
436 resolveOid(snapshotDir, `refs/pull/${pull.number}/head`),
433 // This runs on every read of a cross-repo pull, and the overwhelmingly
434 // common answer is "nothing moved": both tips come from the ref files, and
435 // git is asked only for one they cannot say (#8157).
436 const headRef = `refs/heads/${pull.headBranch}`;
437 const pullRef = `refs/pull/${pull.number}/head`;
438 const [heads, snaps] = await Promise.all([
439 readTips(headDir, [headRef]),
440 readTips(snapshotDir, [pullRef]),
437441 ]);
438 if (live === undefined || live === snapshot) return undefined;
442 const live = heads.get(headRef) ?? (await resolveOid(headDir, headRef));
443 if (live === undefined) return undefined;
444 const snapshot = snaps.get(pullRef) ?? (await resolveOid(snapshotDir, pullRef));
445 if (live === snapshot) return undefined;
439446 const tip = apart
440447 ? live
441448 : await fetchInto(baseDir, headDir, `refs/heads/${pull.headBranch}`);
test/pull-head-refresh.test.ts added +147
@@ -0,0 +1,147 @@
1 /**
2 * Reading a cross-repo pull refreshes its head from the ref files (#8157):
3 * an unmoved head spawns no git process, loose or packed, a moved one is
4 * fetched, and a vanished branch leaves the last snapshot in place.
5 */
6 import { afterAll, beforeAll, expect, spyOn, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { setRole } from "../src/actions/roles.ts";
11 import { openPull } from "../src/actions/pulls.ts";
12 import { commitToBranch, createRepo, forkRepo } from "../src/actions/repos.ts";
13 import { createApp, type App } from "../src/app.ts";
14 import { refreshPullHead } from "../src/core/following.ts";
15 import { getPull, type Pull } from "../src/core/pulls.ts";
16 import { getRepo, type Repo } from "../src/core/repos.ts";
17 import { createUser, type User } from "../src/core/users.ts";
18 import { runGit } from "../src/git/env.ts";
19 import { resolveOid, writeRef } from "../src/git/merge.ts";
20 import { repoDir } from "../src/git/repos.ts";
21
22 let dataDir: string;
23 let app: App;
24 let parent: Repo;
25 let fork: Repo;
26 let ravi: User;
27 let number: number;
28
29 const mk = async (username: string, first: boolean): Promise<User> => {
30 const u = await createUser(app.db, {
31 origin: "seed",
32 trusted: true,
33 username,
34 email: `${username}@forge.local`,
35 password: "a-good-passphrase",
36 adminIfFirst: first,
37 });
38 if (!u.ok) throw new Error(u.error);
39 return u.value;
40 };
41
42 const pull = (): Pull => getPull(app.db, parent.id, number)!;
43 const baseDir = () => repoDir(app.reposRoot, parent.ownerName, parent.name);
44 const forkDir = () => repoDir(app.reposRoot, fork.ownerName, fork.name);
45 const snapshot = () => resolveOid(baseDir(), `refs/pull/${number}/head`);
46
47 /** How many processes `refreshPullHead` starts for one read. */
48 async function spawnsOf(): Promise<{ tip: string | undefined; spawns: number }> {
49 const spy = spyOn(Bun, "spawn");
50 try {
51 const tip = await refreshPullHead(app, parent, pull());
52 return { tip, spawns: spy.mock.calls.length };
53 } finally {
54 spy.mockRestore();
55 }
56 }
57
58 beforeAll(async () => {
59 dataDir = await mkdtemp(join(tmpdir(), "forge-pull-head-"));
60 app = createApp({ dataDir, port: 0 });
61 const admin = await mk("hank", true);
62 const olive = await mk("olive", false);
63 ravi = await mk("ravi", false);
64 for (const name of ["olive", "ravi"]) {
65 const promoted = setRole(app, admin, name, "member");
66 if (!promoted.ok) throw new Error(promoted.error);
67 }
68 const r = await createRepo(app, olive, { name: "yard", visibility: "public" });
69 if (!r.ok) throw new Error(String(r.error));
70 await commitToBranch(app, olive, r.value, {
71 message: "first",
72 files: [{ path: "a.txt", content: "a\n" }],
73 });
74 const f = await forkRepo(app, ravi, r.value);
75 if (!f.ok) throw new Error(String(f.error));
76 parent = getRepo(app.db, "olive", "yard")!;
77 fork = getRepo(app.db, "ravi", "yard")!;
78 const side = await commitToBranch(app, ravi, fork, {
79 message: "side",
80 branch: "side",
81 files: [{ path: "side.txt", content: "s\n" }],
82 });
83 if (!side.ok) throw new Error(JSON.stringify(side.error));
84 const opened = await openPull(app, ravi, parent, {
85 title: "Side",
86 body: "",
87 baseBranch: "main",
88 headBranch: "side",
89 headRepo: fork,
90 });
91 if (!opened.ok) throw new Error(String(opened.error));
92 number = opened.value.number;
93 }, 60_000);
94
95 afterAll(async () => {
96 app.close();
97 await rm(dataDir, { recursive: true, force: true });
98 });
99
100 test("an unmoved head, read from loose refs, spawns no git process", async () => {
101 expect(pull().crossRepo).toBe(true);
102 expect(await snapshot()).toBe(await resolveOid(forkDir(), "refs/heads/side"));
103 expect(await spawnsOf()).toEqual({ tip: undefined, spawns: 0 });
104 });
105
106 test("a moved head is fetched into the base's snapshot", async () => {
107 const before = (await snapshot())!;
108 const next = await commitToBranch(app, ravi, fork, {
109 message: "more",
110 branch: "side",
111 files: [{ path: "more.txt", content: "m\n" }],
112 });
113 if (!next.ok) throw new Error(JSON.stringify(next.error));
114 const live = (await resolveOid(forkDir(), "refs/heads/side"))!;
115 // Put the snapshot back where it was, as if the push had not been followed.
116 expect(await writeRef(baseDir(), `refs/pull/${number}/head`, before)).toBe(true);
117 const { tip, spawns } = await spawnsOf();
118 expect(tip).toBe(live);
119 expect(spawns).toBeGreaterThan(0);
120 expect(await snapshot()).toBe(live);
121 expect(pull().headOid).toBe(live);
122 }, 30_000);
123
124 test("packed refs on both sides answer the same, still without a process", async () => {
125 for (const dir of [baseDir(), forkDir()]) {
126 const packed = await runGit(["pack-refs", "--all"], { cwd: dir });
127 expect(packed.ok).toBe(true);
128 expect(await Bun.file(join(dir, `refs/pull/${number}/head`)).exists()).toBe(false);
129 expect(await Bun.file(join(dir, "refs/heads/side")).exists()).toBe(false);
130 }
131 expect(await spawnsOf()).toEqual({ tip: undefined, spawns: 0 });
132 // A loose snapshot behind a packed head still sees the difference.
133 const live = (await resolveOid(forkDir(), "refs/heads/side"))!;
134 const first = await runGit(["rev-parse", "side~1"], { cwd: forkDir() });
135 if (!first.ok) throw new Error(first.error.stderr);
136 expect(await writeRef(baseDir(), `refs/pull/${number}/head`, first.value)).toBe(true);
137 expect((await spawnsOf()).tip).toBe(live);
138 expect(await snapshot()).toBe(live);
139 }, 30_000);
140
141 test("a vanished branch leaves the last snapshot in place", async () => {
142 const kept = await snapshot();
143 const gone = await runGit(["update-ref", "-d", "refs/heads/side"], { cwd: forkDir() });
144 expect(gone.ok).toBe(true);
145 expect((await spawnsOf()).tip).toBeUndefined();
146 expect(await snapshot()).toBe(kept);
147 }, 30_000);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs