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

A fellow forge owner's work lands cleanly and he can pull back: real merges, kept authorship, no squash across forks #8936

Merged opened this in answer to

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

Merged

mason/forge:mason/8935-a-fellow-forge-owner-s-work-lands-cleanl → main 4 commits fork

  • Merged. mason/8935-a-fellow-forge-owner-s-work-lands-cleanl was merged into main as 08ae4b8.

Review

Sign in to review this change.
7 files changed +374 −13
docs/features/upstream-landing.md added +25
@@ -0,0 +1,25 @@
1 # Sending your forge's idea upstream, and pulling back
2
3 Two directions, three steps each. A pull from a fork someone keeps lands here as a true merge commit (#8935): your commits stay ancestors of main and keep your name, so pulling back never conflicts with your own code. Crew pulls and pulls made inside one repository still squash.
4
5 ## Send an idea upstream
6
7 1. **Fork** the app (or connect its forge at `/settings/origins`) and push your work to a branch of your fork.
8 2. **Open a pull** from that branch against the upstream's `main` (`Contribute`, or `POST /api/v1/repos/:owner/:name/pulls` with `headRepo`).
9 3. **Wait for a person to land it.** They choose nothing: a kept fork always lands as a merge, whatever strategy is asked. You get a thank-you note on the pull saying how many commits, by whom, touching how many files.
10
11 ## Pull back what landed
12
13 1. **Fetch** the upstream's `main` into your fork (`git fetch <upstream> main`).
14 2. **Merge** it (`git merge FETCH_HEAD`). Your landed commits are already ancestors of it, so it is clean or a fast-forward; only work that landed from others can need a hand.
15 3. **Push** to your fork. Your next pull upstream carries only what is new.
16
17 Test: `test/kept-fork-landing.test.ts` runs two clones of two repositories through both directions.
18
19 ## Every door says so
20
21 The rule lives in `landingKind` (`src/core/landing_kind.ts`) and `mergePull` applies it, so the API, the MCP `merge` tool and both screens land a kept fork as a merge. The choice is not offered where it would be ignored: the web merge box shows "Lands as a merge commit" in place of the strategy menu, and the terminal's merge confirm (`m` on a pull) drops Squash and says the same. A pull by or from an agent, or inside one repository, keeps both choices.
22
23 ## Not yet
24
25 Landing a very large fork in reviewed slices that still end as a merge of the author's head; routing a pull opened on the Forgejo mirror to trunk; a preview of the combined build with a crew-written resolution note; and running his code under the guest rule until a person approves (#8903 is not in this repository). Resolving conflicts on our side is today's resolve page (`docs/PULLS.md`).
src/actions/pulls.ts +28 −2
@@ -63,7 +63,8 @@ import { isProposed } from "../core/pull_trust.ts";
6363 import { grantRun } from "../core/run_grant.ts";
6464 import { commitIdentity } from "../core/commit_identity.ts";
6565 import { getUserById, getUserByName, type User } from "../core/users.ts";
66 import type { MergeStrategy } from "../git/merge.ts";
66 import { carriedSummary, resolveOid, type MergeStrategy } from "../git/merge.ts";
67 import { landingKind, thanksNote } from "../core/landing_kind.ts";
6768 import { repoDir } from "../git/repos.ts";
6869 import { importIssue, importReviewStates } from "../origins/import.ts";
6970 import { err, ok, type Result } from "../kernel/result.ts";
@@ -363,7 +364,7 @@ export async function mergePull(
363364 actor: User | undefined,
364365 repo: Repo,
365366 pull: Pull,
366 strategy: MergeStrategy,
367 requested: MergeStrategy,
367368 /** The merge queue's guard: land only onto this base, as this tree (#2036). */
368369 expect?: LandingExpect,
369370 /** `pastGate`: the owner's break-glass (`checks/bypass.ts`) skips the checks requirement. */
@@ -384,6 +385,8 @@ export async function mergePull(
384385 // reflects it, with the merge commit the origin actually made.
385386 if (pull.originHeadRepo !== null || repo.origin !== null)
386387 return err("mirrored");
388 // A fork someone keeps lands as a true merge whatever was asked (#8935).
389 const strategy = landingKind(app.db, pull, requested);
387390 // A fork may have pushed since the pull opened; merge what is being
388391 // proposed now, not the snapshot taken when it was.
389392 await refreshPullHead(app, repo, pull);
@@ -405,6 +408,14 @@ export async function mergePull(
405408 );
406409 const baseDir = repoDir(app.reposRoot, repo.ownerName, repo.name);
407410 const at = await pullDir(app.db, app.reposRoot, repo, pull);
411 const carriedBefore =
412 strategy === "merge" && pull.crossRepo
413 ? await carriedSummary(
414 baseDir,
415 (await resolveOid(baseDir, `refs/heads/${pull.baseBranch}`)) ?? pull.baseOid,
416 (getPull(app.db, repo.id, pull.number) ?? pull).headOid,
417 )
418 : undefined;
408419 const merged = await landPull(
409420 app.db,
410421 baseDir,
@@ -429,6 +440,21 @@ export async function mergePull(
429440 },
430441 );
431442 if (!merged.ok) return merged;
443 if (carriedBefore !== undefined) {
444 const thanks = thanksNote({ number: pull.number, ...carriedBefore });
445 const noted = addIssueComment(app.db, {
446 issueId: pull.id,
447 authorId: writer.value.id,
448 body: thanks,
449 });
450 if (noted.ok)
451 app.bus.append({
452 kind: "pull.commented",
453 repoId: repo.id,
454 actorId: writer.value.id,
455 payload: { number: pull.number, body: thanks, commentId: noted.value.id },
456 });
457 }
432458 // What came of the decision to show it, when there was one.
433459 resolveOpen(app.db, pull.id, "land", "shipped");
434460 app.bus.append({
src/core/landing_kind.ts added +49
@@ -0,0 +1,49 @@
1 /**
2 * How a pull lands, by where it came from. A fork someone keeps lands as a
3 * true merge commit: its commits stay ancestors of main, each author keeps
4 * their name, and the owner's next `git merge` of main is clean or a
5 * fast-forward. Squashed, a long fork would conflict with its own code on
6 * every pull back. A crew pull, or one made inside this repository, squashes.
7 */
8
9 import type { Database } from "bun:sqlite";
10 import { getRepoById } from "./repos.ts";
11 import { getUserById } from "./users.ts";
12 import type { Pull } from "./pulls.ts";
13 import type { MergeStrategy } from "../git/merge.ts";
14
15 /** A fork a person keeps proposed this pull: cross-repo, and no agent wrote or owns it. */
16 export function fromKeptFork(
17 db: Database,
18 pull: Pick<Pull, "crossRepo" | "headRepoId" | "authorId">,
19 ): boolean {
20 if (!pull.crossRepo) return false;
21 if (getUserById(db, pull.authorId)?.kind === "agent") return false;
22 const head = getRepoById(db, pull.headRepoId);
23 if (head === undefined) return false;
24 return getUserById(db, head.ownerId)?.kind !== "agent";
25 }
26
27 /** The strategy a pull lands with: a kept fork's is always a merge, whatever was asked. */
28 export function landingKind(
29 db: Database,
30 pull: Pick<Pull, "crossRepo" | "headRepoId" | "authorId">,
31 asked: MergeStrategy,
32 ): MergeStrategy {
33 return fromKeptFork(db, pull) ? "merge" : asked;
34 }
35
36 /** The note the author reads when their work landed: what came across and under which names. */
37 export function thanksNote(landed: {
38 readonly number: number;
39 readonly commits: number;
40 readonly authors: readonly string[];
41 readonly files: number;
42 }): string {
43 const who = landed.authors.length === 0 ? "you" : landed.authors.join(", ");
44 return [
45 `Thank you: pull #${landed.number} landed as a merge, so your commits are ancestors of main and \`git merge main\` on your side stays clean.`,
46 `${landed.commits} commit${landed.commits === 1 ? "" : "s"} by ${who} touched ${landed.files} file${landed.files === 1 ? "" : "s"}.`,
47 ]
48 .join("\n");
49 }
src/git/merge.ts +21
@@ -422,3 +422,24 @@ export async function writeRef(
422422 const result = await runGit(["update-ref", ref, oid], { cwd: dir });
423423 return result.ok;
424424 }
425
426 /** Who wrote the commits a pull carries, and how many files they touch — for the note an author is sent. */
427 export async function carriedSummary(
428 dir: string,
429 baseOid: string,
430 headOid: string,
431 ): Promise<{ commits: number; authors: string[]; files: number }> {
432 if (!isSafeOid(baseOid) || !isSafeOid(headOid))
433 return { commits: 0, authors: [], files: 0 };
434 const log = await runGit(
435 ["log", "--format=%an", `${baseOid}..${headOid}`],
436 { cwd: dir },
437 );
438 const names = log.ok ? log.value.split("\n").filter(Boolean) : [];
439 const diff = await runGit(
440 ["diff", "--name-only", `${baseOid}...${headOid}`],
441 { cwd: dir },
442 );
443 const files = diff.ok ? diff.value.split("\n").filter(Boolean).length : 0;
444 return { commits: names.length, authors: [...new Set(names)].slice(0, 10), files };
445 }
src/tui/screens/pull.ts +16 −6
@@ -66,6 +66,7 @@ import { oneLine } from "../../checks/words.ts";
6666 import { resolveOid } from "../../git/merge.ts";
6767 import { nextOfPull, type Next } from "../../flow/next_actor.ts";
6868 import { stepLine, stepOfPull, type PathStep } from "../../flow/path.ts";
69 import { fromKeptFork } from "../../core/landing_kind.ts";
6970 import { getPull, pullDir, variantSiblings, type Pull } from "../../core/pulls.ts";
7071 import { voteVariant } from "../../actions/variants.ts";
7172 import { getRepoById, type Repo } from "../../core/repos.ts";
@@ -1374,22 +1375,31 @@ export function pullScreen(
13741375 state() === "ready"
13751376 ) {
13761377 const base = facts.baseBranch;
1378 const pullRow = repo === undefined ? undefined : getPull(f.db, repo.id, number);
1379 const kept = pullRow !== undefined && fromKeptFork(f.db, pullRow);
13771380 ctx.push(
13781381 confirmScreen(ctx, screen, {
13791382 title: `Merge #${number}`,
13801383 body: [
13811384 `${view.summary()} into ${base}.`,
1385 kept ? "A kept fork lands as a merge commit; its authors keep their names." : "",
13821386 repo?.protectDefault === true && base === repo.defaultBranch
13831387 ? `${base} is protected; history is preserved.`
13841388 : "",
13851389 ],
1386 actions: [
1387 { label: "Cancel", variant: "ghost" },
1388 { label: "Squash", variant: "secondary" },
1389 { label: "Merge" },
1390 ],
1391 selected: 2,
1390 actions: kept
1391 ? [{ label: "Cancel", variant: "ghost" }, { label: "Merge" }]
1392 : [
1393 { label: "Cancel", variant: "ghost" },
1394 { label: "Squash", variant: "secondary" },
1395 { label: "Merge" },
1396 ],
1397 selected: kept ? 1 : 2,
13921398 onChoose: (choice) => {
1399 if (kept) {
1400 if (choice === 1) merge("merge");
1401 return;
1402 }
13931403 if (choice === 1) merge("squash");
13941404 if (choice === 2) merge("merge");
13951405 },
src/web/pages/pull.ts +9 −5
@@ -28,6 +28,7 @@ import { readFileAt } from "../../apps/checkout.ts";
2828 import { parseCheck } from "../../apps/manifest.ts";
2929 import { RED_ALONE_WAIT, redAloneAt, redMark, type RedMark } from "../../checks/queue.ts";
3030 import { tierLine, type TierState } from "../../checks/tiers.ts";
31 import { fromKeptFork } from "../../core/landing_kind.ts";
3132 import { getAppByRepo } from "../../core/apps.ts";
3233 import { labelsFor, listLabels } from "../../core/issues.ts";
3334 import { marksByName } from "../../core/marks.ts";
@@ -625,11 +626,14 @@ function mergeBox(
625626 ${checks}`,
626627 mayMerge
627628 ? html`<form method="post" action="${thread}/merge" class="row-fields">
628 <label class="sr-only" for="strategy">Merge strategy</label>
629 <select class="input" id="strategy" name="strategy">
630 <option value="merge">Merge commit</option>
631 <option value="squash">Squash and merge</option>
632 </select>
629 ${fromKeptFork(ctx.app.db, pull)
630 ? html`<input type="hidden" name="strategy" value="merge" />
631 <span class="muted small">Lands as a merge commit: a kept fork's commits keep their authors.</span>`
632 : html`<label class="sr-only" for="strategy">Merge strategy</label>
633 <select class="input" id="strategy" name="strategy">
634 <option value="merge">Merge commit</option>
635 <option value="squash">Squash and merge</option>
636 </select>`}
633637 ${btn("Merge pull request", {
634638 variant: "primary",
635639 icon: "merge",
test/kept-fork-landing.test.ts added +226
@@ -0,0 +1,226 @@
1 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2 import { mkdtemp, rm } from "node:fs/promises";
3 import { tmpdir } from "node:os";
4 import { join } from "node:path";
5 import { createApp, type App } from "../src/app.ts";
6 import { serve, type Servers } from "../src/http/server.ts";
7 import { gitClientEnv } from "./helpers/git-client.ts";
8 import { proveAll } from "./helpers/verified.ts";
9 import { promote } from "./helpers/members.ts";
10 import { mcpTool } from "./helpers/mcp.ts";
11 import { landingKind } from "../src/core/landing_kind.ts";
12 import { getRepo } from "../src/core/repos.ts";
13 import { getUserByName } from "../src/core/users.ts";
14 import { tokenFor } from "./helpers/tokens.ts";
15
16 const gitEnv = gitClientEnv({
17 GIT_AUTHOR_NAME: "Jay Moran",
18 GIT_AUTHOR_EMAIL: "[email protected]",
19 GIT_COMMITTER_NAME: "Jay Moran",
20 GIT_COMMITTER_EMAIL: "[email protected]",
21 });
22
23 describe("a kept fork lands as a true merge (#8935)", () => {
24 let dataDir: string;
25 let work: string;
26 let app: App;
27 let servers: Servers;
28 let base: string;
29 let alice = "";
30 let bob = "";
31
32 async function git(args: string[], cwd: string): Promise<string> {
33 const p = Bun.spawn(["git", ...args], { cwd, env: gitEnv, stdout: "pipe", stderr: "pipe" });
34 const [out, err, code] = await Promise.all([
35 new Response(p.stdout).text(),
36 new Response(p.stderr).text(),
37 p.exited,
38 ]);
39 if (code !== 0) throw new Error(`git ${args.join(" ")}: ${err}`);
40 return out.trim();
41 }
42 const call = (path: string, token: string, body?: unknown): Promise<Response> =>
43 fetch(`${base}${path}`, {
44 method: body === undefined ? "GET" : "POST",
45 headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
46 ...(body === undefined ? {} : { body: JSON.stringify(body) }),
47 });
48 const authed = (user: string, token: string) =>
49 `${base.replace("http://", `http://${user}:${token}@`)}`;
50
51 async function signup(username: string): Promise<string> {
52 await fetch(`${base}/signup`, {
53 method: "POST",
54 body: new URLSearchParams({ username, email: `${username}@f.dev`, password: "kept-fork-pass" }),
55 redirect: "manual",
56 });
57 return tokenFor(app, username, "admin", "k");
58 }
59
60 beforeAll(async () => {
61 dataDir = await mkdtemp(join(tmpdir(), "forge-kf-"));
62 work = await mkdtemp(join(tmpdir(), "forge-kf-work-"));
63 app = createApp({ dataDir, port: 0 });
64 servers = await serve(app);
65 base = servers.url;
66 alice = await signup("alice");
67 bob = await signup("bob");
68 proveAll(app);
69 promote(app.db);
70 await call("/api/v1/repos", alice, { name: "app", visibility: "public" });
71 await git(["clone", `${authed("alice", alice)}/alice/app.git`, "a"], work);
72 await Bun.write(join(work, "a/one.txt"), "one\n");
73 await git(["add", "."], join(work, "a"));
74 await git(["commit", "-m", "seed"], join(work, "a"));
75 await git(["push", "origin", "main"], join(work, "a"));
76 await call("/api/v1/repos/alice/app/forks", bob, {});
77 });
78
79 afterAll(async () => {
80 servers.stop();
81 app.close();
82 await rm(dataDir, { recursive: true, force: true });
83 await rm(work, { recursive: true, force: true });
84 });
85
86 test("squash is asked for, a merge commit lands, authors kept, and his next pull is a fast-forward", async () => {
87 const b = join(work, "b");
88 await git(["clone", `${authed("bob", bob)}/bob/app.git`, "b"], work);
89 for (const n of ["x", "y"]) {
90 await Bun.write(join(b, `${n}.txt`), `${n}\n`);
91 await git(["add", "."], b);
92 await git(["commit", "-m", `add ${n}`], b);
93 }
94 await git(["push", "origin", "main"], b);
95 const opened = await call("/api/v1/repos/alice/app/pulls", bob, {
96 title: "Jay's work",
97 base: "main",
98 head: "main",
99 headRepo: "bob/app",
100 });
101 expect(opened.status).toBe(201);
102 const { number } = (await opened.json()) as { number: number };
103
104 // Alice's main moves meanwhile, so the landing is a real merge.
105 const a = join(work, "a");
106 await Bun.write(join(a, "two.txt"), "two\n");
107 await git(["add", "."], a);
108 await git(["commit", "-m", "alice moves on"], a);
109 await git(["push", "origin", "main"], a);
110
111 const merged = await call(`/api/v1/repos/alice/app/pulls/${number}/merge`, alice, {
112 strategy: "squash",
113 });
114 expect(merged.status).toBe(200);
115
116 await git(["pull", "--no-rebase", "origin", "main"], a);
117 const parents = (await git(["log", "-1", "--format=%P"], a)).split(" ");
118 expect(parents).toHaveLength(2);
119 const authors = await git(["log", "--format=%an", "--no-merges", "-3"], a);
120 expect(authors).toContain("Jay Moran");
121 expect(await git(["log", "--format=%s", "--no-merges", "--author=Jay"], a)).toContain("add x");
122
123 // His side: pulling main back is clean (here a fast-forward of the merged tip).
124 await git(["fetch", `${authed("alice", alice)}/alice/app.git`, "main"], b);
125 const out = await git(["merge", "FETCH_HEAD", "--no-edit"], b);
126 expect(out).not.toContain("CONFLICT");
127 expect(await git(["rev-parse", "HEAD"], b)).toBe(await git(["rev-parse", "HEAD"], a));
128
129 // The author is thanked on the pull with what landed.
130 const notes = app.db
131 .query<{ body: string }, [number]>(
132 "SELECT body FROM comment WHERE issue_id = (SELECT id FROM issue WHERE kind = 'pull' AND number = ?1)",
133 )
134 .all(number);
135 expect(notes.map((n) => n.body).join("\n")).toContain("Thank you");
136 });
137
138 test("a pull inside the repository still squashes", async () => {
139 await call("/api/v1/repos/alice/app/edits", alice, {
140 newBranch: "topic",
141 base: "main",
142 message: "inside",
143 files: [{ path: "in.txt", content: "in\n" }],
144 });
145 const opened = await call("/api/v1/repos/alice/app/pulls", alice, {
146 title: "inside",
147 base: "main",
148 head: "topic",
149 });
150 const { number } = (await opened.json()) as { number: number };
151 const merged = await call(`/api/v1/repos/alice/app/pulls/${number}/merge`, alice, {
152 strategy: "squash",
153 });
154 expect(merged.status).toBe(200);
155 const a = join(work, "a");
156 await git(["pull", "--no-rebase", "origin", "main"], a);
157 expect((await git(["log", "-1", "--format=%P"], a)).split(" ")).toHaveLength(1);
158 });
159
160 describe("what is not a kept fork still squashes", () => {
161 const shape = (over: { crossRepo?: boolean; authorId?: string } = {}) => {
162 const head = getRepo(app.db, "bob", "app")!;
163 return {
164 crossRepo: over.crossRepo ?? true,
165 headRepoId: head.id,
166 authorId: over.authorId ?? getUserByName(app.db, "bob")!.id,
167 };
168 };
169 const asAgent = (name: string, on: 0 | 1) =>
170 app.db.query("UPDATE user SET is_agent = ?1 WHERE username = ?2").run(on, name);
171
172 test("a person's fork is a merge whatever is asked", () => {
173 expect(landingKind(app.db, shape(), "squash")).toBe("merge");
174 });
175 test("an agent who authors the pull", () => {
176 asAgent("bob", 1);
177 try {
178 expect(landingKind(app.db, shape(), "squash")).toBe("squash");
179 } finally {
180 asAgent("bob", 0);
181 }
182 });
183 test("an agent who owns the head repository", () => {
184 asAgent("bob", 1);
185 try {
186 const authorId = getUserByName(app.db, "alice")!.id;
187 expect(landingKind(app.db, shape({ authorId }), "squash")).toBe("squash");
188 } finally {
189 asAgent("bob", 0);
190 }
191 });
192 test("a pull inside one repository", () => {
193 expect(landingKind(app.db, shape({ crossRepo: false }), "squash")).toBe("squash");
194 });
195 });
196
197 test("the MCP door honours the rule too: squash asked, a merge commit lands", async () => {
198 const b = join(work, "b");
199 await git(["pull", "--no-rebase", "--no-edit", "origin", "main"], b);
200 await Bun.write(join(b, "z.txt"), "z\n");
201 await git(["add", "."], b);
202 await git(["commit", "-m", "add z"], b);
203 await git(["push", "origin", "main"], b);
204 const opened = await call("/api/v1/repos/alice/app/pulls", bob, {
205 title: "more",
206 base: "main",
207 head: "main",
208 headRepo: "bob/app",
209 });
210 const { number } = (await opened.json()) as { number: number };
211 const a = join(work, "a");
212 await Bun.write(join(a, "three.txt"), "three\n");
213 await git(["add", "."], a);
214 await git(["commit", "-m", "alice again"], a);
215 await git(["push", "origin", "main"], a);
216 const landed = await mcpTool(base, alice, "merge", {
217 repo: "alice/app",
218 number,
219 strategy: "squash",
220 dryRun: false,
221 });
222 expect(landed.error).toBeUndefined();
223 await git(["pull", "--no-rebase", "origin", "main"], a);
224 expect((await git(["log", "-1", "--format=%P"], a)).split(" ")).toHaveLength(2);
225 });
226 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs