Public: anyone can read this; the crew can change it.
An upstream that merges cleanly opens one pull from upstream/<tip7>, and a branch someone planted there opens nothing #4067
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3978-an-upstream-that-merges-cleanly-opens-on → main 2 commits fork
4 files
changed +225
−8
README.md +23
| @@ -1753,6 +1753,29 @@ carries today, each entry under the issue that owns it; an entry that is no | ||
| 1753 | 1753 | longer hollow turns the check red, so the list only shrinks. |
| 1754 | 1754 | `bun scripts/hollow.ts` prints the same lines on their own. |
| 1755 | 1755 | |
| 1756 | ## Following an upstream | |
| 1757 | ||
| 1758 | A repository can follow an outside git branch (`repo_upstream`, https or | |
| 1759 | http only, no credentials in the address). The clock checks each following | |
| 1760 | repository every 30 minutes (`checkDue`); the result is the follow row: | |
| 1761 | upstream tip, commits behind, the pull carrying the last proposal, and | |
| 1762 | `error`. There is no button or page for it yet, so that row is where a | |
| 1763 | check is seen. A check fetches only when the tip moved, into | |
| 1764 | `refs/upstream-follow/<branch>` (not a branch), then: | |
| 1765 | ||
| 1766 | - **Nothing new:** nothing happens. | |
| 1767 | - **New commits that merge cleanly into the default branch:** the forge | |
| 1768 | writes `upstream/<tip7>` at the fetched head and opens one pull from it, | |
| 1769 | as the person who turned following on. A second check with the same tip | |
| 1770 | opens nothing, and a proposal someone closed is not reopened. | |
| 1771 | - **A newer tip while that pull is open:** the pull is moved to | |
| 1772 | `upstream/<newtip7>` and its title and body rewritten; the old | |
| 1773 | `upstream/<old7>` branch stays, as a person may have built on it. | |
| 1774 | - **A conflict:** the row's `error` reads `conflict`; no branch, no pull. | |
| 1775 | - **A planted branch:** if `upstream/<tip7>` already exists anywhere but at | |
| 1776 | the fetched head, the check fails (`squatted`), says so on the row, opens | |
| 1777 | no pull and leaves the branch as it was. | |
| 1778 | ||
| 1756 | 1779 | ## Shape |
| 1757 | 1780 | |
| 1758 | 1781 |
src/actions/upstream_follow.ts +64 −4
| @@ -2,7 +2,14 @@ | ||
| 2 | 2 | * Checking a followed upstream (#3442, layer 2): one `ls-remote`, a fetch |
| 3 | 3 | * only when the tip moved, into `refs/upstream-follow/<branch>` — not a |
| 4 | 4 | * branch, so nothing of ours moves. What it found is noted on the follow row |
| 5 | * (upstream tip, commits behind). It opens nothing. | |
| 5 | * (upstream tip, commits behind). | |
| 6 | * | |
| 7 | * Layer 3, the proposal: new commits that merge cleanly into the default | |
| 8 | * branch are pushed as `upstream/<tip7>` at the fetched head, and one pull | |
| 9 | * is opened from it — or the open one is moved to it. A branch of that name | |
| 10 | * that is not at the fetched head was planted by someone: the check fails | |
| 11 | * and leaves it alone. A conflicting merge is noted as "conflict" and | |
| 12 | * opens nothing yet. | |
| 6 | 13 | * |
| 7 | 14 | * The fetch is the mirror's: the host is vetted and the vetted address |
| 8 | 15 | * pinned (`vettedEnv`), https and http only. A connected origin's token goes |
| @@ -11,20 +18,23 @@ | ||
| 11 | 18 | */ |
| 12 | 19 | |
| 13 | 20 | import type { App } from "../app.ts"; |
| 21 | import { getIssueById, updateIssueText } from "../core/issues.ts"; | |
| 14 | 22 | import { getOriginFor } from "../core/origins.ts"; |
| 23 | import { pullForBranch, retargetPull } from "../core/pulls.ts"; | |
| 15 | 24 | import { canWrite, getRepoById, type Repo } from "../core/repos.ts"; |
| 16 | 25 | import { storageStale } from "../core/storage.ts"; |
| 17 | 26 | import { getFollow, listFollowing, noteCheck } from "../core/upstream_follow.ts"; |
| 18 | 27 | import { getUserById } from "../core/users.ts"; |
| 19 | 28 | import { runGitCapture } from "../git/env.ts"; |
| 20 | 29 | import { isSafeOid } from "../git/guard.ts"; |
| 21 | import { aheadCount, resolveOid } from "../git/merge.ts"; | |
| 30 | import { aheadCount, mergeable, resolveOid, writeRef } from "../git/merge.ts"; | |
| 22 | 31 | import { REMOTE_PROTOCOLS } from "../git/remote-env.ts"; |
| 23 | 32 | import { repoDir } from "../git/repos.ts"; |
| 24 | 33 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 25 | 34 | import { providerForOrigin } from "../origins/credential.ts"; |
| 26 | 35 | import { redact, secretsIn } from "../origins/credentials.ts"; |
| 27 | 36 | import { vettedEnv } from "../origins/git-mirror.ts"; |
| 37 | import { openPull } from "./pulls.ts"; | |
| 28 | 38 | |
| 29 | 39 | /** How often a following repository is checked on its own. */ |
| 30 | 40 | export const FOLLOW_EVERY_MS = 30 * 60_000; |
| @@ -34,7 +44,7 @@ const CHECK_TIMEOUT_MS = 120_000; | ||
| 34 | 44 | /** Where the upstream tip is staged: not a branch, so a check moves nothing of ours. */ |
| 35 | 45 | export const stagedRef = (branch: string): string => `refs/upstream-follow/${branch}`; |
| 36 | 46 | |
| 37 | export type CheckError = "off" | "nobody" | "unreadable" | "no_branch" | "git" | "busy"; | |
| 47 | export type CheckError = "off" | "nobody" | "unreadable" | "no_branch" | "git" | "busy" | "squatted"; | |
| 38 | 48 | |
| 39 | 49 | /** What each refusal says, at every door. */ |
| 40 | 50 | export const CHECK_REFUSALS: Record<CheckError, string> = { |
| @@ -44,14 +54,22 @@ export const CHECK_REFUSALS: Record<CheckError, string> = { | ||
| 44 | 54 | no_branch: "The upstream has no such branch.", |
| 45 | 55 | git: "Git could not read the staged upstream.", |
| 46 | 56 | busy: "Already checking this upstream", |
| 57 | squatted: "A branch of the proposal's name already exists and is not the upstream's head.", | |
| 47 | 58 | }; |
| 48 | 59 | |
| 49 | 60 | /** What one check read. */ |
| 50 | 61 | export interface Checked { |
| 51 | 62 | readonly tip: string; |
| 52 | 63 | readonly behind: number; |
| 64 | /** What the check did about the commits: nothing new, opened or moved a pull, or found a conflict. */ | |
| 65 | readonly outcome: "none" | "proposed" | "conflict"; | |
| 66 | /** The pull carrying the proposal, when there is one. */ | |
| 67 | readonly pull?: number; | |
| 53 | 68 | } |
| 54 | 69 | |
| 70 | /** The branch a proposal for this upstream tip is pushed to. */ | |
| 71 | export const proposalBranch = (tip: string): string => `upstream/${tip.slice(0, 7)}`; | |
| 72 | ||
| 55 | 73 | /** |
| 56 | 74 | * Whether an origin's token may travel to this upstream: only to the exact |
| 57 | 75 | * https origin (scheme, host and port) the person connected. An `http://` |
| @@ -169,6 +187,48 @@ async function checkOnce(app: App, repo: Repo): Promise<Result<Checked, CheckErr | ||
| 169 | 187 | const main = await resolveOid(dir, `refs/heads/${repo.defaultBranch}`); |
| 170 | 188 | if (main === undefined) return failed("git", "The default branch has no commits yet."); |
| 171 | 189 | const behind = await aheadCount(dir, main, staged); |
| 190 | if (behind === 0) { | |
| 191 | noteCheck(app.db, repo.id, { at, tip: staged, behind, error: "" }); | |
| 192 | return ok({ tip: staged, behind, outcome: "none" }); | |
| 193 | } | |
| 194 | ||
| 195 | const merged = await mergeable(dir, main, staged); | |
| 196 | if (!merged.ok) { | |
| 197 | if (merged.error.kind !== "conflict") return failed("git"); | |
| 198 | noteCheck(app.db, repo.id, { at, tip: staged, behind, error: "conflict" }); | |
| 199 | return ok({ tip: staged, behind, outcome: "conflict" }); | |
| 200 | } | |
| 201 | ||
| 202 | // The branch is ours only if it is at the fetched head, or not there yet. | |
| 203 | const name = proposalBranch(staged); | |
| 204 | const planted = await resolveOid(dir, `refs/heads/${name}`); | |
| 205 | if (planted !== undefined && planted !== staged) return failed("squatted"); | |
| 206 | if (planted === undefined && !(await writeRef(dir, `refs/heads/${name}`, staged))) | |
| 207 | return failed("git", "The proposal's branch could not be written."); | |
| 208 | ||
| 209 | const text = { | |
| 210 | title: `Upstream: ${behind} new commit${behind === 1 ? "" : "s"} on ${follow.branch}`, | |
| 211 | body: `New commits from ${follow.url} (${follow.branch}), up to ${staged.slice(0, 7)}. They merge cleanly into ${repo.defaultBranch}.`, | |
| 212 | }; | |
| 213 | const known = pullForBranch(app.db, repo.id, name); | |
| 214 | const pending = follow.pendingId === null ? undefined : getIssueById(app.db, follow.pendingId); | |
| 215 | const open = pending !== undefined && pending.kind === "pull" && pending.state === "open"; | |
| 216 | let number: number; | |
| 217 | if (known !== undefined) { | |
| 218 | // This tip was proposed already, whatever came of it: nothing new. | |
| 219 | number = known.number; | |
| 220 | } else if (open) { | |
| 221 | // The upstream moved on: the open pull follows it instead of a second opening. | |
| 222 | retargetPull(app.db, pending.id, name, staged); | |
| 223 | await writeRef(dir, `refs/pull/${pending.number}/head`, staged); | |
| 224 | updateIssueText(app.db, pending.id, text); | |
| 225 | number = pending.number; | |
| 226 | } else { | |
| 227 | const opened = await openPull(app, by, repo, { ...text, baseBranch: repo.defaultBranch, headBranch: name }); | |
| 228 | if (!opened.ok) return failed("git", `The proposal could not be opened: ${opened.error}`); | |
| 229 | noteCheck(app.db, repo.id, { at, tip: staged, behind, error: "", pendingId: opened.value.id, opened: true }); | |
| 230 | return ok({ tip: staged, behind, outcome: "proposed", pull: opened.value.number }); | |
| 231 | } | |
| 172 | 232 | noteCheck(app.db, repo.id, { at, tip: staged, behind, error: "" }); |
| 173 | return ok({ tip: staged, behind }); | |
| 233 | return ok({ tip: staged, behind, outcome: "proposed", pull: number }); | |
| 174 | 234 | } |
src/core/pulls.ts +12
| @@ -375,6 +375,18 @@ export function trackPullHead( | ||
| 375 | 375 | ); |
| 376 | 376 | } |
| 377 | 377 | |
| 378 | /** Point an open same-repo pull at another branch of this repository, at its tip. */ | |
| 379 | export function retargetPull( | |
| 380 | db: Database, | |
| 381 | pullId: string, | |
| 382 | branch: string, | |
| 383 | headOid: string, | |
| 384 | ): void { | |
| 385 | db.query( | |
| 386 | "UPDATE pull SET head_branch = ?2, head_oid = ?3 WHERE issue_id = ?1", | |
| 387 | ).run(pullId, branch, headOid); | |
| 388 | } | |
| 389 | ||
| 378 | 390 | /** |
| 379 | 391 | * The pull that already reverts this one, if taking it back has already |
| 380 | 392 | * been done — open, or merged, either one counts as "already taken back". |
test/upstream_follow.test.ts +126 −4
| @@ -12,6 +12,7 @@ import { createApp, type App } from "../src/app.ts"; | ||
| 12 | 12 | import { createRepo } from "../src/actions/repos.ts"; |
| 13 | 13 | import { audienceFilter } from "../src/core/event_audience.ts"; |
| 14 | 14 | import { listEvents } from "../src/core/events.ts"; |
| 15 | import { getPull, listPulls } from "../src/core/pulls.ts"; | |
| 15 | 16 | import type { Repo } from "../src/core/repos.ts"; |
| 16 | 17 | import { createUser, type User } from "../src/core/users.ts"; |
| 17 | 18 | import { checkDue, checkUpstream, mayCarryCredential } from "../src/actions/upstream_follow.ts"; |
| @@ -244,7 +245,7 @@ describe("checking an upstream (layer 2)", () => { | ||
| 244 | 245 | }); |
| 245 | 246 | |
| 246 | 247 | describe("what a check records", () => { |
| 247 | test("the upstream tip and how far behind, without opening anything", async () => { | |
| 248 | test("the upstream tip and how far behind, and the one pull it opens", async () => { | |
| 248 | 249 | const git = async (args: string[]) => ok(await runGit(args, { cwd: dir, env: ident })).trim(); |
| 249 | 250 | const ident = { |
| 250 | 251 | GIT_AUTHOR_NAME: "t", |
| @@ -263,19 +264,140 @@ describe("checking an upstream (layer 2)", () => { | ||
| 263 | 264 | try { |
| 264 | 265 | follow(up.url); |
| 265 | 266 | const r = ok(await checkUpstream(app, target)); |
| 266 | expect(r).toEqual({ tip: c2, behind: 1 }); | |
| 267 | expect(r).toMatchObject({ tip: c2, behind: 1, outcome: "proposed" }); | |
| 267 | 268 | const row = getFollow(app.db, target.id)!; |
| 268 | 269 | expect(row.tip).toBe(c2); |
| 269 | 270 | expect(row.behind).toBe(1); |
| 270 | 271 | expect(row.error).toBe(""); |
| 271 | expect(row.pendingId).toBeNull(); | |
| 272 | expect(row.opened).toBe(0); | |
| 272 | expect(row.pendingId).not.toBeNull(); | |
| 273 | expect(row.opened).toBe(1); | |
| 273 | 274 | } finally { |
| 274 | 275 | up.server.stop(true); |
| 275 | 276 | } |
| 276 | 277 | }); |
| 277 | 278 | }); |
| 278 | 279 | |
| 280 | describe("proposing (layer 3)", () => { | |
| 281 | const ident = { | |
| 282 | GIT_AUTHOR_NAME: "t", | |
| 283 | GIT_AUTHOR_EMAIL: "t@x", | |
| 284 | GIT_COMMITTER_NAME: "t", | |
| 285 | GIT_COMMITTER_EMAIL: "t@x", | |
| 286 | }; | |
| 287 | /** A fresh repository whose main holds a file, and an upstream tip staged on top of it. */ | |
| 288 | async function staged(name: string, mainText: string, upText: string) { | |
| 289 | const r = ok(await createRepo(app, alice, { name, visibility: "public" })); | |
| 290 | const d = repoDir(app.reposRoot, r.ownerName, r.name); | |
| 291 | const git = async (args: string[], input?: string) => | |
| 292 | ok(await runGit(args, { cwd: d, env: ident, ...(input === undefined ? {} : { stdin: input }) })).trim(); | |
| 293 | const commit = async (text: string, parent?: string) => { | |
| 294 | const blob = await git(["hash-object", "-w", "--stdin"], text); | |
| 295 | const tree = await git(["mktree"], `100644 blob ${blob}\tf.txt\n`); | |
| 296 | return git(["commit-tree", tree, ...(parent ? ["-p", parent] : []), "-m", text]); | |
| 297 | }; | |
| 298 | const base = await commit("base\n"); | |
| 299 | const main = mainText === "base\n" ? base : await commit(mainText, base); | |
| 300 | const up = await commit(upText, base); | |
| 301 | await git(["update-ref", `refs/heads/${r.defaultBranch}`, main]); | |
| 302 | await git(["update-ref", "refs/upstream-follow/main", up]); | |
| 303 | const srv = upstream(() => `${up}\trefs/heads/main\n`); | |
| 304 | putFollow(app.db, { repoId: r.id, url: srv.url, branch: "main", enabled: true, byId: alice.id, originId: "" }); | |
| 305 | return { r, d, up, srv, git, main, branch: `upstream/${up.slice(0, 7)}` }; | |
| 306 | } | |
| 307 | ||
| 308 | test("a clean merge pushes upstream/<tip7> and opens one pull; a second check opens nothing", async () => { | |
| 309 | const t = await staged("clean", "base\n", "new\n"); | |
| 310 | try { | |
| 311 | const first = ok(await checkUpstream(app, t.r)); | |
| 312 | expect(first.outcome).toBe("proposed"); | |
| 313 | expect(await t.git(["rev-parse", `refs/heads/${t.branch}`])).toBe(t.up); | |
| 314 | const row = getFollow(app.db, t.r.id)!; | |
| 315 | expect(row.opened).toBe(1); | |
| 316 | expect(row.error).toBe(""); | |
| 317 | const second = ok(await checkUpstream(app, t.r)); | |
| 318 | expect(second.pull).toBe(first.pull); | |
| 319 | expect(getFollow(app.db, t.r.id)!.opened).toBe(1); | |
| 320 | expect(getFollow(app.db, t.r.id)!.pendingId).toBe(row.pendingId); | |
| 321 | } finally { | |
| 322 | t.srv.server.stop(true); | |
| 323 | } | |
| 324 | }); | |
| 325 | ||
| 326 | test("a later tip moves the open pull instead of opening another", async () => { | |
| 327 | const t = await staged("moving", "base\n", "new\n"); | |
| 328 | try { | |
| 329 | const first = ok(await checkUpstream(app, t.r)); | |
| 330 | const blob = await t.git(["hash-object", "-w", "--stdin"], "newer\n"); | |
| 331 | const tree = await t.git(["mktree"], `100644 blob ${blob}\tf.txt\n`); | |
| 332 | const next = await t.git(["commit-tree", tree, "-p", t.up, "-m", "newer"]); | |
| 333 | await t.git(["update-ref", "refs/upstream-follow/main", next]); | |
| 334 | t.srv.server.stop(true); | |
| 335 | const srv = upstream(() => `${next}\trefs/heads/main\n`); | |
| 336 | putFollow(app.db, { repoId: t.r.id, url: srv.url, branch: "main", enabled: true, byId: alice.id, originId: "" }); | |
| 337 | try { | |
| 338 | const second = ok(await checkUpstream(app, t.r)); | |
| 339 | expect(second.pull).toBe(first.pull); | |
| 340 | expect(getFollow(app.db, t.r.id)!.opened).toBe(1); | |
| 341 | const branch = `upstream/${next.slice(0, 7)}`; | |
| 342 | expect(await t.git(["rev-parse", `refs/heads/${branch}`])).toBe(next); | |
| 343 | const pull = getPull(app.db, t.r.id, first.pull!)!; | |
| 344 | expect([pull.headBranch, pull.headOid]).toEqual([branch, next]); | |
| 345 | expect(await t.git(["rev-parse", `refs/pull/${pull.number}/head`])).toBe(next); | |
| 346 | expect(pull.title).toBe("Upstream: 2 new commits on main"); | |
| 347 | expect(pull.body).toContain(next.slice(0, 7)); | |
| 348 | expect(pull.body).not.toContain(t.up.slice(0, 7)); | |
| 349 | } finally { | |
| 350 | srv.server.stop(true); | |
| 351 | } | |
| 352 | } finally { | |
| 353 | t.srv.server.stop(true); | |
| 354 | } | |
| 355 | }); | |
| 356 | ||
| 357 | test("a closed proposal for the same tip is not reopened", async () => { | |
| 358 | const t = await staged("closed", "base\n", "new\n"); | |
| 359 | try { | |
| 360 | const first = ok(await checkUpstream(app, t.r)); | |
| 361 | app.db.query("UPDATE issue SET state = 'closed' WHERE repo_id = ?1 AND number = ?2").run(t.r.id, first.pull!); | |
| 362 | const again = ok(await checkUpstream(app, t.r)); | |
| 363 | expect(again.pull).toBe(first.pull); | |
| 364 | expect(getFollow(app.db, t.r.id)!.opened).toBe(1); | |
| 365 | expect(listPulls(app.db, t.r.id, { state: "all" }).length).toBe(1); | |
| 366 | } finally { | |
| 367 | t.srv.server.stop(true); | |
| 368 | } | |
| 369 | }); | |
| 370 | ||
| 371 | test("a planted upstream/<tip7> fails the check, opens nothing and is left as it was", async () => { | |
| 372 | const t = await staged("squat", "base\n", "new\n"); | |
| 373 | try { | |
| 374 | const planted = await t.git(["rev-parse", `${t.up}^`]); | |
| 375 | await t.git(["update-ref", `refs/heads/${t.branch}`, planted]); | |
| 376 | const r = await checkUpstream(app, t.r); | |
| 377 | expect(!r.ok && r.error).toBe("squatted"); | |
| 378 | expect(getFollow(app.db, t.r.id)!.error).not.toBe(""); | |
| 379 | expect(getFollow(app.db, t.r.id)!.opened).toBe(0); | |
| 380 | expect(getFollow(app.db, t.r.id)!.pendingId).toBeNull(); | |
| 381 | expect(await t.git(["rev-parse", `refs/heads/${t.branch}`])).toBe(planted); | |
| 382 | } finally { | |
| 383 | t.srv.server.stop(true); | |
| 384 | } | |
| 385 | }); | |
| 386 | ||
| 387 | test("a conflicting merge is recorded as conflict and opens nothing", async () => { | |
| 388 | const t = await staged("clash", "ours\n", "theirs\n"); | |
| 389 | try { | |
| 390 | const r = ok(await checkUpstream(app, t.r)); | |
| 391 | expect(r.outcome).toBe("conflict"); | |
| 392 | const row = getFollow(app.db, t.r.id)!; | |
| 393 | expect([row.error, row.opened, row.pendingId]).toEqual(["conflict", 0, null]); | |
| 394 | expect((await runGit(["rev-parse", "--verify", "-q", `refs/heads/${t.branch}`], { cwd: t.d })).ok).toBe(false); | |
| 395 | } finally { | |
| 396 | t.srv.server.stop(true); | |
| 397 | } | |
| 398 | }); | |
| 399 | }); | |
| 400 | ||
| 279 | 401 | test("the clock checks a due following once and leaves a fresh one alone", async () => { |
| 280 | 402 | const up = upstream(() => ""); |
| 281 | 403 | try { |
new above
offline