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

Merged opened this

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

Merged

scout/forge:scout/3978-an-upstream-that-merges-cleanly-opens-on → main 2 commits fork

  • Merged. scout/3978-an-upstream-that-merges-cleanly-opens-on was merged into main as 7b6f84f.

Review

Sign in to review this change.
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
17531753 longer hollow turns the check red, so the list only shrinks.
17541754 `bun scripts/hollow.ts` prints the same lines on their own.
17551755
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
17561779 ## Shape
17571780
17581781 ```
src/actions/upstream_follow.ts +64 −4
@@ -2,7 +2,14 @@
22 * Checking a followed upstream (#3442, layer 2): one `ls-remote`, a fetch
33 * only when the tip moved, into `refs/upstream-follow/<branch>` — not a
44 * 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.
613 *
714 * The fetch is the mirror's: the host is vetted and the vetted address
815 * pinned (`vettedEnv`), https and http only. A connected origin's token goes
@@ -11,20 +18,23 @@
1118 */
1219
1320 import type { App } from "../app.ts";
21 import { getIssueById, updateIssueText } from "../core/issues.ts";
1422 import { getOriginFor } from "../core/origins.ts";
23 import { pullForBranch, retargetPull } from "../core/pulls.ts";
1524 import { canWrite, getRepoById, type Repo } from "../core/repos.ts";
1625 import { storageStale } from "../core/storage.ts";
1726 import { getFollow, listFollowing, noteCheck } from "../core/upstream_follow.ts";
1827 import { getUserById } from "../core/users.ts";
1928 import { runGitCapture } from "../git/env.ts";
2029 import { isSafeOid } from "../git/guard.ts";
21 import { aheadCount, resolveOid } from "../git/merge.ts";
30 import { aheadCount, mergeable, resolveOid, writeRef } from "../git/merge.ts";
2231 import { REMOTE_PROTOCOLS } from "../git/remote-env.ts";
2332 import { repoDir } from "../git/repos.ts";
2433 import { err, ok, type Result } from "../kernel/result.ts";
2534 import { providerForOrigin } from "../origins/credential.ts";
2635 import { redact, secretsIn } from "../origins/credentials.ts";
2736 import { vettedEnv } from "../origins/git-mirror.ts";
37 import { openPull } from "./pulls.ts";
2838
2939 /** How often a following repository is checked on its own. */
3040 export const FOLLOW_EVERY_MS = 30 * 60_000;
@@ -34,7 +44,7 @@ const CHECK_TIMEOUT_MS = 120_000;
3444 /** Where the upstream tip is staged: not a branch, so a check moves nothing of ours. */
3545 export const stagedRef = (branch: string): string => `refs/upstream-follow/${branch}`;
3646
37 export type CheckError = "off" | "nobody" | "unreadable" | "no_branch" | "git" | "busy";
47 export type CheckError = "off" | "nobody" | "unreadable" | "no_branch" | "git" | "busy" | "squatted";
3848
3949 /** What each refusal says, at every door. */
4050 export const CHECK_REFUSALS: Record<CheckError, string> = {
@@ -44,14 +54,22 @@ export const CHECK_REFUSALS: Record<CheckError, string> = {
4454 no_branch: "The upstream has no such branch.",
4555 git: "Git could not read the staged upstream.",
4656 busy: "Already checking this upstream",
57 squatted: "A branch of the proposal's name already exists and is not the upstream's head.",
4758 };
4859
4960 /** What one check read. */
5061 export interface Checked {
5162 readonly tip: string;
5263 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;
5368 }
5469
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
5573 /**
5674 * Whether an origin's token may travel to this upstream: only to the exact
5775 * 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
169187 const main = await resolveOid(dir, `refs/heads/${repo.defaultBranch}`);
170188 if (main === undefined) return failed("git", "The default branch has no commits yet.");
171189 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 }
172232 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 });
174234 }
src/core/pulls.ts +12
@@ -375,6 +375,18 @@ export function trackPullHead(
375375 );
376376 }
377377
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
378390 /**
379391 * The pull that already reverts this one, if taking it back has already
380392 * 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";
1212 import { createRepo } from "../src/actions/repos.ts";
1313 import { audienceFilter } from "../src/core/event_audience.ts";
1414 import { listEvents } from "../src/core/events.ts";
15 import { getPull, listPulls } from "../src/core/pulls.ts";
1516 import type { Repo } from "../src/core/repos.ts";
1617 import { createUser, type User } from "../src/core/users.ts";
1718 import { checkDue, checkUpstream, mayCarryCredential } from "../src/actions/upstream_follow.ts";
@@ -244,7 +245,7 @@ describe("checking an upstream (layer 2)", () => {
244245 });
245246
246247 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 () => {
248249 const git = async (args: string[]) => ok(await runGit(args, { cwd: dir, env: ident })).trim();
249250 const ident = {
250251 GIT_AUTHOR_NAME: "t",
@@ -263,19 +264,140 @@ describe("checking an upstream (layer 2)", () => {
263264 try {
264265 follow(up.url);
265266 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" });
267268 const row = getFollow(app.db, target.id)!;
268269 expect(row.tip).toBe(c2);
269270 expect(row.behind).toBe(1);
270271 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);
273274 } finally {
274275 up.server.stop(true);
275276 }
276277 });
277278 });
278279
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
279401 test("the clock checks a due following once and leaves a fresh one alone", async () => {
280402 const up = upstream(() => "");
281403 try {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs