import { compareBranches } from "./git/compare"; import { diffTreesCached } from "./git/diff"; import { compareCommits } from "./git/history"; import { isReachableCommit } from "./git/reachable"; import { listRefs } from "./git/refs"; import { safeDecode } from "~/lib/safe-decode"; import { withStore } from "./git/store"; import type { GitStore } from "./git/store"; import { HttpError, notFoundError } from "./errors"; import { limitHeavy } from "./limits"; import { loadRepoById } from "./repos"; import { loadPull } from "./pulls"; import { formatPatchSeries, patchResponse } from "./patch-format"; import type { PatchKind } from "./patch-format"; import type { RequestCredential } from "./token-scope"; import type { CommitSummary, RepoRow } from "~/lib/types"; const COMMIT_PATH = /^\/([^/]+)\/([^/]+)\/commit\/([0-9a-f]{4,40})\.(diff|patch)$/; const PULL_PATH = /^\/([^/]+)\/([^/]+)\/pull\/(\d+)\.(diff|patch)$/; const COMPARE_PATH = /^\/([^/]+)\/([^/]+)\/compare\/(.+)\.(diff|patch)$/; /** Each commit in a series is its own tree diff, so long series are refused rather than half-rendered. */ const MAX_SERIES_COMMITS = 100; const TOO_LARGE = 413; const FULL_SHA_LENGTH = 40; export type RepoAccess = (request: Request, owner: string, name: string) => Promise; function tooLarge(): never { throw new HttpError(TOO_LARGE, "This diff is too large to render; fetch the commits with git instead"); } async function commitPatch(store: GitStore, commit: CommitSummary): Promise { const parent = commit.parents[0] ? await store.commit(commit.parents[0]) : null; const diff = await diffTreesCached(store, parent?.treeSha ?? null, commit.treeSha); if (diff.truncated) tooLarge(); return diff.patch; } /** `git format-patch` output for a range: oldest first, merge commits skipped like format-patch does. */ async function series(store: GitStore, commits: CommitSummary[], truncated: boolean): Promise { const ordered = commits.filter((commit) => commit.parents.length <= 1).sort((a, b) => a.committedAt - b.committedAt); if (truncated || ordered.length > MAX_SERIES_COMMITS) tooLarge(); const entries = []; for (const commit of ordered) entries.push({ commit, patch: await commitPatch(store, commit) }); return formatPatchSeries(entries); } /** Short SHAs resolve against branch and tag heads, as on the commit page. */ async function fullSha(repo: RepoRow, sha: string): Promise { if (sha.length === FULL_SHA_LENGTH) return sha; const refs = await listRefs(repo); const match = [...refs.branches, ...refs.tags].find((ref) => ref.sha.startsWith(sha)); if (!match) throw notFoundError("Use the full commit SHA"); return match.sha; } async function renderCommit(repo: RepoRow, short: string, kind: PatchKind): Promise { const sha = await fullSha(repo, short); if (!(await isReachableCommit(repo, sha))) throw notFoundError("Commit not found"); const text = await withStore(repo.artifacts_name, async (store) => { const commit = await store.commit(sha); if (!commit) throw notFoundError("Commit not found"); const patch = await commitPatch(store, commit); return kind === "diff" ? patch : formatPatchSeries([{ commit, patch }]); }); return patchResponse(kind, text, sha); } async function renderPull(repo: RepoRow, number: number, kind: PatchKind): Promise { const pull = await loadPull(repo.id, number); if (!pull) throw notFoundError("Pull request not found"); const headRepo = pull.head_repo_id === repo.id ? repo : await loadRepoById(pull.head_repo_id); if (!headRepo) throw notFoundError("Head repository was deleted"); const baseSha = pull.merged_at && pull.merge_base_sha ? pull.merge_base_sha : pull.base_sha; const text = await withStore(repo.artifacts_name, (baseStore) => withStore(headRepo.artifacts_name, async (headStore) => { const comparison = await compareCommits(baseStore, baseSha, headStore, pull.head_sha); if (kind === "patch") return series(headStore, comparison.commits, comparison.truncated); const [mergeBase, head] = await Promise.all([ comparison.mergeBase ? headStore.commit(comparison.mergeBase) : null, headStore.commit(pull.head_sha), ]); if (!head) throw notFoundError("Head commit missing"); const diff = await diffTreesCached(headStore, mergeBase?.treeSha ?? null, head.treeSha); if (diff.truncated) tooLarge(); return diff.patch; }), ); return patchResponse(kind, text, String(number)); } async function renderCompare( repo: RepoRow, range: string, credential: RequestCredential, kind: PatchKind, ): Promise { const separator = range.indexOf("..."); const [base, head] = separator === -1 ? [repo.default_branch, range] : [range.slice(0, separator), range.slice(separator + 3)]; if (!base || !head) throw notFoundError("Unknown branch"); const compared = await compareBranches(repo, base, head, credential); if (kind === "diff") { if (compared.diff.truncated) tooLarge(); return patchResponse(kind, compared.diff.patch, range.replace(/[^\w.-]+/g, "-")); } const text = await withStore(compared.headRepo.artifacts_name, (store) => series(store, compared.comparison.commits, compared.comparison.truncated), ); return patchResponse(kind, text, range.replace(/[^\w.-]+/g, "-")); } /** * GitHub-compatible `.diff` / `.patch` suffixes on commit, pull request and compare URLs. `.diff` is a * unified diff; `.patch` is `git format-patch` mbox output that `git am` applies. */ export async function handlePatchRoutes( request: Request, pathname: string, access: RepoAccess, credentialFor: () => Promise, ): Promise { const commit = COMMIT_PATH.exec(pathname); const pull = commit ? null : PULL_PATH.exec(pathname); const compare = commit || pull ? null : COMPARE_PATH.exec(pathname); const match = commit ?? pull ?? compare; if (!match) return null; try { const repo = await access(request, match[1], match[2]); if (repo instanceof Response) return repo; const credential = await credentialFor(); await limitHeavy(credential.viewer, request); const kind: PatchKind = match[4] === "patch" ? "patch" : "diff"; if (commit) return await renderCommit(repo, commit[3], kind); if (pull) return await renderPull(repo, Number(pull[3]), kind); const range = safeDecode(match[3]); if (range === null) return new Response("Bad request", { status: 400 }); return await renderCompare(repo, range, credential, kind); } catch (error) { if (error instanceof HttpError) return new Response(error.message, { status: error.status }); throw error; } }