import { env, waitUntil } from "cloudflare:workers"; import { mergeRootTrees } from "./git/merge"; import type { MergeConflict, TreeMergeResult } from "./git/merge"; import { hasConflictMarkers, MergeTooComplexError } from "./git/merge3"; import { makeCommit, signatureNow } from "./git/objects"; import type { Signature } from "./git/objects"; import { pushToArtifacts, PushRejectedError } from "./git/writer"; import { compareCommits } from "./git/history"; import { branchSha, listRefs } from "./git/refs"; import { withStore, ZERO_SHA } from "./git/store"; import type { GitStore } from "./git/store"; import { bridgePullBase, bridgePullHead } from "./git/pull-bridge"; import { authenticatedRemote, mintRepoToken, revokeRepoTokens } from "./git/artifacts"; import { triggerPush } from "./events/push-trigger"; import { hasPermission, loadRepoById, repoPermission, repoReadOnlyReason } from "./repos"; import { directUpdateViolation, loadProtections, protectionFor } from "./protection"; import { forbidden } from "./errors"; import { MergeError } from "./pulls"; import type { PullRow } from "./pulls"; import type { RepoRow, Viewer } from "~/lib/types"; const UPDATE_TOKEN_TTL_SECONDS = 1800; const EXIT_CONFLICT = 2; const EXIT_HEAD_MOVED = 3; const MAX_RESOLUTION_FILES = 50; export type PullRepos = { baseRepo: RepoRow; headRepo: RepoRow }; export async function loadPullRepos(pull: PullRow): Promise { const [baseRepo, headRepo] = await Promise.all([loadRepoById(pull.repo_id), loadRepoById(pull.head_repo_id)]); if (!baseRepo || !headRepo) throw new MergeError("Pull request repositories are missing"); return { baseRepo, headRepo }; } export async function currentBaseSha(repo: RepoRow, branch: string): Promise { const sha = branchSha(await listRefs(repo), branch); if (!sha) throw new MergeError("The base branch no longer exists"); return sha; } /** * Runs `fn` against one side's store once it can read every commit. For fork pull requests, missing * commits are first relayed in from the other repository under a hidden ref; null means the Worker * still cannot read them (visibility, size or relay failure) and callers fall back to the git container. */ export async function withPullStore( pull: PullRow, side: "base" | "head", shas: string[], fn: (store: GitStore) => Promise, options: { bridge: boolean } = { bridge: true }, ): Promise { const { baseRepo, headRepo } = await loadPullRepos(pull); const repo = side === "base" ? baseRepo : headRepo; const missing = async () => withStore(repo.artifacts_name, async (store) => { const commits = await Promise.all(shas.map((sha) => store.commit(sha))); return shas.filter((_, index) => commits[index] === null); }); const absent = await missing(); if (absent.length > 0) { if (baseRepo.id === headRepo.id || !options.bridge) return null; for (const sha of absent) { const bridged = side === "base" ? sha === pull.head_sha && (await bridgePullHead(pull, baseRepo, headRepo)) : await bridgePullBase(pull, baseRepo, headRepo, sha); if (!bridged) return null; } if ((await missing()).length > 0) return null; } return withStore(repo.artifacts_name, fn); } type MergePlan = | { kind: "up_to_date" } | { kind: "fast_forward" } | { kind: "merged"; result: TreeMergeResult; oursTree: string }; async function planMerge( store: GitStore, ours: string, theirs: string, labels: { ours: string; theirs: string }, resolutions?: Map, ): Promise { const comparison = await compareCommits(store, ours, store, theirs); if (comparison.mergeBase === null) { throw new MergeTooComplexError("No merge base found in the Worker; deferring to git"); } if (comparison.mergeBase === theirs) return { kind: "up_to_date" }; if (comparison.mergeBase === ours) return { kind: "fast_forward" }; const [baseCommit, oursCommit, theirsCommit] = await Promise.all([ comparison.mergeBase ? store.commit(comparison.mergeBase) : null, store.commit(ours), store.commit(theirs), ]); if (!oursCommit || !theirsCommit) throw new MergeTooComplexError("Commit missing"); const result = await mergeRootTrees({ source: store, baseTree: baseCommit?.treeSha ?? null, oursTree: oursCommit.treeSha, theirsTree: theirsCommit.treeSha, labels, resolutions, }); return { kind: "merged", result, oursTree: oursCommit.treeSha }; } async function pushCommit(input: { repo: RepoRow; ref: string; oldSha: string; tree: string; objects: TreeMergeResult["objects"]; parents: string[]; message: string; author: Signature; committer: Signature; }): Promise { const commit = await makeCommit({ tree: input.tree, parents: input.parents, author: input.author, committer: input.committer, message: input.message, }); await pushToArtifacts( input.repo, [{ ref: `refs/heads/${input.ref}`, oldSha: input.oldSha, newSha: commit.sha }], [...input.objects, commit], ); return commit.sha; } /** Mergeability via the in-Worker tree merge; null when the Worker cannot decide and git must. */ export async function inWorkerMergeability(pull: PullRow): Promise<"clean" | "conflict" | null> { const { baseRepo } = await loadPullRepos(pull); const baseSha = branchSha(await listRefs(baseRepo), pull.base_ref); if (!baseSha) return null; try { const plan = await withPullStore(pull, "base", [baseSha, pull.head_sha], (store) => planMerge(store, baseSha, pull.head_sha, { ours: pull.base_ref, theirs: pull.head_ref }), ); if (!plan) return null; if (plan.kind !== "merged" || plan.result.conflicts.length === 0) return "clean"; return onlyTextConflicts(plan.result.conflicts) ? "conflict" : null; } catch (error) { if (error instanceof MergeTooComplexError) return null; throw error; } } export type Landed = { before: string; after: string }; /** * Lands a merge or squash entirely in the Worker with a compare-and-swap push to the base branch. * Returns null when the Worker cannot perform it (fork objects, budgets), so callers use the container. */ export async function landInWorker(input: { pull: PullRow; method: "merge" | "squash"; message: string; merger: { name: string; email: string }; author: { name: string; email: string }; }): Promise { const { pull } = input; const { baseRepo } = await loadPullRepos(pull); const before = await currentBaseSha(baseRepo, pull.base_ref); try { return await withPullStore(pull, "base", [before, pull.head_sha], async (store) => { const plan = await planMerge(store, before, pull.head_sha, { ours: pull.base_ref, theirs: pull.head_ref }); if (plan.kind === "up_to_date") throw new MergeError("The base branch already contains these changes"); if (plan.kind === "merged" && plan.result.conflicts.length > 0) return null; const head = await store.commit(pull.head_sha); if (!head) return null; const tree = plan.kind === "merged" ? plan.result.treeSha : head.treeSha; const committer = signatureNow(input.merger.name, input.merger.email); const after = await pushCommit({ repo: baseRepo, ref: pull.base_ref, oldSha: before, tree, objects: plan.kind === "merged" ? plan.result.objects : [], parents: input.method === "squash" ? [before] : [before, pull.head_sha], message: input.message, author: input.method === "squash" ? signatureNow(input.author.name, input.author.email) : committer, committer, }); return { before, after }; }); } catch (error) { if (error instanceof MergeTooComplexError) return null; if (error instanceof PushRejectedError) throw new MergeError("The base branch moved during the merge; try again"); throw error; } } /** Pushes a commit built by the merge queue onto the base branch (fast-forward with CAS). */ export async function fastForward(repo: RepoRow, branch: string, before: string, after: string): Promise { try { await pushToArtifacts(repo, [{ ref: `refs/heads/${branch}`, oldSha: before, newSha: after }], []); return true; } catch (error) { if (error instanceof PushRejectedError) return false; throw error; } } /** Builds (and pushes to a scratch ref) a speculative merge of the pull request onto `baseSha`. */ export async function buildSpeculativeMerge(input: { pull: PullRow; baseSha: string; ref: string; method: "merge" | "squash"; message: string; merger: { name: string; email: string }; author: { name: string; email: string }; }): Promise { const { pull } = input; const { baseRepo } = await loadPullRepos(pull); try { return await withPullStore(pull, "base", [input.baseSha, pull.head_sha], async (store) => { const plan = await planMerge(store, input.baseSha, pull.head_sha, { ours: pull.base_ref, theirs: pull.head_ref }); if (plan.kind === "up_to_date") throw new MergeError("The base branch already contains these changes"); if (plan.kind === "merged" && plan.result.conflicts.length > 0) return null; const head = await store.commit(pull.head_sha); if (!head) return null; const committer = signatureNow(input.merger.name, input.merger.email); const existing = branchSha(await listRefs(baseRepo), input.ref); return pushCommit({ repo: baseRepo, ref: input.ref, oldSha: existing ?? ZERO_SHA, tree: plan.kind === "merged" ? plan.result.treeSha : head.treeSha, objects: plan.kind === "merged" ? plan.result.objects : [], parents: input.method === "squash" ? [input.baseSha] : [input.baseSha, pull.head_sha], message: input.message, author: input.method === "squash" ? signatureNow(input.author.name, input.author.email) : committer, committer, }); }); } catch (error) { if (error instanceof MergeTooComplexError) return null; throw error; } } /** * Overlapping text edits are reported the same way by git; structural conflicts (renames, deletes, * type changes) may be resolved by git's rename detection, so those defer to the container. */ function onlyTextConflicts(conflicts: MergeConflict[]): boolean { return conflicts.every((conflict) => conflict.kind === "content" || conflict.kind === "add_add"); } export type BranchConflicts = { conflicts: MergeConflict[]; resolvable: boolean; baseSha: string } | null; type UpdateOutcome = { sha: string; conflicts?: undefined } | { conflicts: MergeConflict[]; sha?: undefined }; function updateMessage(pull: PullRow, trailers: string[] = []): string { const subject = `Merge branch '${pull.base_ref}' into ${pull.head_ref}`; return trailers.length > 0 ? `${subject}\n\n${trailers.join("\n")}\n` : subject; } /** Conflicts from merging the base branch into the head branch, for the web conflict editor. */ export async function headBranchConflicts(pull: PullRow): Promise { const { baseRepo } = await loadPullRepos(pull); const baseSha = await currentBaseSha(baseRepo, pull.base_ref); try { const plan = await withPullStore( pull, "head", [pull.head_sha, baseSha], (store) => planMerge(store, pull.head_sha, baseSha, { ours: pull.head_ref, theirs: pull.base_ref }), { bridge: false }, ); if (!plan) return null; const conflicts = plan.kind === "merged" ? plan.result.conflicts : []; return { conflicts, resolvable: onlyTextConflicts(conflicts), baseSha, }; } catch (error) { if (error instanceof MergeTooComplexError) return null; throw error; } } async function announceHeadPush(headRepo: RepoRow, pull: PullRow, after: string, viewer: Viewer) { await triggerPush({ source: "internal", repoId: headRepo.id, artifactsName: headRepo.artifacts_name, ref: `refs/heads/${pull.head_ref}`, before: pull.head_sha, after, pusherId: viewer.id, }); } /** * Merges the base branch into the head branch. Clean merges (and web-resolved conflicts) are committed * by the Worker; when the Worker cannot read both sides it falls back to `git merge` in the container. */ export async function updateHeadBranch( pull: PullRow, viewer: Viewer, resolutions?: Array<{ path: string; content: string }>, expectedBase?: string, /** Extra commit trailers, e.g. to mark a machine-resolved merge. */ trailers?: string[], ): Promise { const { baseRepo, headRepo } = await loadPullRepos(pull); const baseSha = await currentBaseSha(baseRepo, pull.base_ref); if (expectedBase && baseSha !== expectedBase) throw new MergeError("The base branch moved; reload and try again"); if (resolutions && resolutions.length > MAX_RESOLUTION_FILES) throw new MergeError("Too many files to resolve"); for (const resolution of resolutions ?? []) { if (hasConflictMarkers(resolution.content)) { throw new MergeError(`${resolution.path} still contains conflict markers`); } } const signature = signatureNow(viewer.displayName ?? viewer.name, viewer.email); try { const outcome = await withPullStore( pull, "head", [pull.head_sha, baseSha], async (store): Promise => { const plan = await planMerge( store, pull.head_sha, baseSha, { ours: pull.head_ref, theirs: pull.base_ref }, new Map((resolutions ?? []).map((resolution) => [resolution.path, resolution.content])), ); if (plan.kind === "up_to_date") throw new MergeError("This branch is already up to date with the base branch"); if (plan.kind === "merged" && plan.result.conflicts.length > 0) { if (!resolutions?.length && !onlyTextConflicts(plan.result.conflicts)) return null; return { conflicts: plan.result.conflicts }; } const ref = `refs/heads/${pull.head_ref}`; const push = plan.kind === "fast_forward" ? pushToArtifacts(headRepo, [{ ref, oldSha: pull.head_sha, newSha: baseSha }], []).then(() => baseSha) : pushCommit({ repo: headRepo, ref: pull.head_ref, oldSha: pull.head_sha, tree: plan.result.treeSha, objects: plan.result.objects, parents: [pull.head_sha, baseSha], message: updateMessage(pull, trailers), author: signature, committer: signature, }); const sha = await push.catch((error: unknown) => { if (error instanceof PushRejectedError) throw new MergeError("The head branch changed; reload and try again"); throw error; }); return { sha }; }, ); if (outcome?.sha) await announceHeadPush(headRepo, pull, outcome.sha, viewer); if (outcome) return outcome; } catch (error) { if (!(error instanceof MergeTooComplexError)) throw error; } if (resolutions && resolutions.length > 0) { throw new MergeError("These conflicts are too large to resolve on the web; resolve them locally"); } const sha = await updateInContainer(pull, baseRepo, headRepo, viewer); await announceHeadPush(headRepo, pull, sha, viewer); return { sha }; } const UPDATE_SCRIPT = `set -uo pipefail work="$(mktemp -d)" trap 'rm -rf "$work"' EXIT cd "$work" git init -q . git config user.name "$MERGER_NAME" git config user.email "$MERGER_EMAIL" git fetch -q "$BASE_URL" "+refs/heads/$BASE_REF:refs/gitfor/base" || { echo "failed to fetch base" >&2; exit 4; } git fetch -q "$HEAD_URL" "+refs/heads/$HEAD_REF:refs/gitfor/head" || { echo "failed to fetch head" >&2; exit 4; } if [ "$(git rev-parse refs/gitfor/head)" != "$EXPECTED_HEAD" ]; then echo "head branch changed" >&2; exit 3; fi git checkout -q -B work refs/gitfor/head git merge -q --no-ff -m "$MERGE_MESSAGE" refs/gitfor/base || { git merge --abort; echo "merge conflict" >&2; exit 2; } git push -q "$HEAD_URL" "HEAD:refs/heads/$HEAD_REF" || { echo "push rejected" >&2; exit 3; } echo "AFTER $(git rev-parse HEAD)" `; async function updateInContainer(pull: PullRow, baseRepo: RepoRow, headRepo: RepoRow, viewer: Viewer) { const minted = await Promise.allSettled([ mintRepoToken(baseRepo.artifacts_name, "read", UPDATE_TOKEN_TTL_SECONDS), mintRepoToken(headRepo.artifacts_name, "write", UPDATE_TOKEN_TTL_SECONDS), ]); const tokens = minted.flatMap((outcome) => (outcome.status === "fulfilled" ? [outcome.value] : [])); let result: Awaited["runGitTask"]>>; try { const [base, head] = minted; if (base.status === "rejected") throw base.reason; if (head.status === "rejected") throw head.reason; const runner = env.GIT_RUNNER.get(env.GIT_RUNNER.idFromName(`git:${headRepo.id}`)); result = await runner.runGitTask(UPDATE_SCRIPT, { MERGER_NAME: viewer.displayName ?? viewer.name, MERGER_EMAIL: viewer.email, BASE_URL: authenticatedRemote(baseRepo.artifacts_remote, base.value.secret), HEAD_URL: authenticatedRemote(headRepo.artifacts_remote, head.value.secret), BASE_REF: pull.base_ref, HEAD_REF: pull.head_ref, EXPECTED_HEAD: pull.head_sha, MERGE_MESSAGE: updateMessage(pull), }); } finally { waitUntil(revokeRepoTokens(tokens)); } const after = /AFTER ([0-9a-f]{40})/.exec(result.stdout)?.[1]; if (result.code === EXIT_CONFLICT) throw new MergeError("This branch has conflicts that must be resolved locally"); if (result.code === EXIT_HEAD_MOVED) throw new MergeError("The head branch changed; reload and try again"); if (result.code !== 0 || !after) throw new MergeError(`Update failed: ${result.stderr.trim() || `exit ${result.code}`}`); return after; } /** Rejects writes to the head branch unless the viewer can push there and branch protection allows it. */ export async function requireHeadPush(headRepo: RepoRow, branch: string, viewer: Viewer): Promise { const permission = await repoPermission(headRepo, viewer); if (!hasPermission(permission, "write")) throw forbidden("You cannot push to the head branch"); if (repoReadOnlyReason(headRepo)) throw forbidden("The head repository is read-only"); const violation = directUpdateViolation(protectionFor(await loadProtections(headRepo.id), branch), permission, false); if (violation) throw forbidden(violation); }