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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/8935-a-fellow-forge-owner-s-work-lands-cleanl → main 4 commits fork
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"; | ||
| 63 | 63 | import { grantRun } from "../core/run_grant.ts"; |
| 64 | 64 | import { commitIdentity } from "../core/commit_identity.ts"; |
| 65 | 65 | 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"; | |
| 67 | 68 | import { repoDir } from "../git/repos.ts"; |
| 68 | 69 | import { importIssue, importReviewStates } from "../origins/import.ts"; |
| 69 | 70 | import { err, ok, type Result } from "../kernel/result.ts"; |
| @@ -363,7 +364,7 @@ export async function mergePull( | ||
| 363 | 364 | actor: User | undefined, |
| 364 | 365 | repo: Repo, |
| 365 | 366 | pull: Pull, |
| 366 | strategy: MergeStrategy, | |
| 367 | requested: MergeStrategy, | |
| 367 | 368 | /** The merge queue's guard: land only onto this base, as this tree (#2036). */ |
| 368 | 369 | expect?: LandingExpect, |
| 369 | 370 | /** `pastGate`: the owner's break-glass (`checks/bypass.ts`) skips the checks requirement. */ |
| @@ -384,6 +385,8 @@ export async function mergePull( | ||
| 384 | 385 | // reflects it, with the merge commit the origin actually made. |
| 385 | 386 | if (pull.originHeadRepo !== null || repo.origin !== null) |
| 386 | 387 | 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); | |
| 387 | 390 | // A fork may have pushed since the pull opened; merge what is being |
| 388 | 391 | // proposed now, not the snapshot taken when it was. |
| 389 | 392 | await refreshPullHead(app, repo, pull); |
| @@ -405,6 +408,14 @@ export async function mergePull( | ||
| 405 | 408 | ); |
| 406 | 409 | const baseDir = repoDir(app.reposRoot, repo.ownerName, repo.name); |
| 407 | 410 | 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; | |
| 408 | 419 | const merged = await landPull( |
| 409 | 420 | app.db, |
| 410 | 421 | baseDir, |
| @@ -429,6 +440,21 @@ export async function mergePull( | ||
| 429 | 440 | }, |
| 430 | 441 | ); |
| 431 | 442 | 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 | } | |
| 432 | 458 | // What came of the decision to show it, when there was one. |
| 433 | 459 | resolveOpen(app.db, pull.id, "land", "shipped"); |
| 434 | 460 | 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( | ||
| 422 | 422 | const result = await runGit(["update-ref", ref, oid], { cwd: dir }); |
| 423 | 423 | return result.ok; |
| 424 | 424 | } |
| 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"; | ||
| 66 | 66 | import { resolveOid } from "../../git/merge.ts"; |
| 67 | 67 | import { nextOfPull, type Next } from "../../flow/next_actor.ts"; |
| 68 | 68 | import { stepLine, stepOfPull, type PathStep } from "../../flow/path.ts"; |
| 69 | import { fromKeptFork } from "../../core/landing_kind.ts"; | |
| 69 | 70 | import { getPull, pullDir, variantSiblings, type Pull } from "../../core/pulls.ts"; |
| 70 | 71 | import { voteVariant } from "../../actions/variants.ts"; |
| 71 | 72 | import { getRepoById, type Repo } from "../../core/repos.ts"; |
| @@ -1374,22 +1375,31 @@ export function pullScreen( | ||
| 1374 | 1375 | state() === "ready" |
| 1375 | 1376 | ) { |
| 1376 | 1377 | 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); | |
| 1377 | 1380 | ctx.push( |
| 1378 | 1381 | confirmScreen(ctx, screen, { |
| 1379 | 1382 | title: `Merge #${number}`, |
| 1380 | 1383 | body: [ |
| 1381 | 1384 | `${view.summary()} into ${base}.`, |
| 1385 | kept ? "A kept fork lands as a merge commit; its authors keep their names." : "", | |
| 1382 | 1386 | repo?.protectDefault === true && base === repo.defaultBranch |
| 1383 | 1387 | ? `${base} is protected; history is preserved.` |
| 1384 | 1388 | : "", |
| 1385 | 1389 | ], |
| 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, | |
| 1392 | 1398 | onChoose: (choice) => { |
| 1399 | if (kept) { | |
| 1400 | if (choice === 1) merge("merge"); | |
| 1401 | return; | |
| 1402 | } | |
| 1393 | 1403 | if (choice === 1) merge("squash"); |
| 1394 | 1404 | if (choice === 2) merge("merge"); |
| 1395 | 1405 | }, |
src/web/pages/pull.ts +9 −5
| @@ -28,6 +28,7 @@ import { readFileAt } from "../../apps/checkout.ts"; | ||
| 28 | 28 | import { parseCheck } from "../../apps/manifest.ts"; |
| 29 | 29 | import { RED_ALONE_WAIT, redAloneAt, redMark, type RedMark } from "../../checks/queue.ts"; |
| 30 | 30 | import { tierLine, type TierState } from "../../checks/tiers.ts"; |
| 31 | import { fromKeptFork } from "../../core/landing_kind.ts"; | |
| 31 | 32 | import { getAppByRepo } from "../../core/apps.ts"; |
| 32 | 33 | import { labelsFor, listLabels } from "../../core/issues.ts"; |
| 33 | 34 | import { marksByName } from "../../core/marks.ts"; |
| @@ -625,11 +626,14 @@ function mergeBox( | ||
| 625 | 626 | ${checks}`, |
| 626 | 627 | mayMerge |
| 627 | 628 | ? 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>`} | |
| 633 | 637 | ${btn("Merge pull request", { |
| 634 | 638 | variant: "primary", |
| 635 | 639 | 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 | }); |
new above
offline