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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/8157-reading-a-cross-repo-pull-spawns-no-git → main 1 commit fork
2 files
changed +160
−6
src/core/following.ts +13 −6
| @@ -39,6 +39,7 @@ import { | ||
| 39 | 39 | } from "../git/merge.ts"; |
| 40 | 40 | import { runGit } from "../git/env.ts"; |
| 41 | 41 | import { readTipOrAbsent } from "../git/tip.ts"; |
| 42 | import { readTips } from "../git/tips.ts"; | |
| 42 | 43 | import { repoDir } from "../git/repos.ts"; |
| 43 | 44 | import { forkFollowMode } from "./fork_follow.ts"; |
| 44 | 45 | import { createLog } from "../kernel/log.ts"; |
| @@ -429,13 +430,19 @@ export async function refreshPullHead( | ||
| 429 | 430 | // everyone reads (`pullDir`, `core/sealed.ts`). |
| 430 | 431 | const apart = pull.sealed && head.sealedFor === repo.id; |
| 431 | 432 | 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]), | |
| 437 | 441 | ]); |
| 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; | |
| 439 | 446 | const tip = apart |
| 440 | 447 | ? live |
| 441 | 448 | : 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); |
new above
offline