import { createServerFn } from "@tanstack/react-start"; import { limitHeavy, limitWrite } from "~/server/limits"; import { z } from "zod"; import { requireViewer, viewerMiddleware } from "./middleware"; import { first } from "~/server/db"; import { loadRepo, repoPermission, requireRepoAccess } from "~/server/repos"; import { branchSha, listRefs, resolveRevision } from "~/server/git/refs"; import { withStore } from "~/server/git/store"; import { commitChanges, createBranch as pushBranch, deleteRef, isValidRefName, PushRejectedError, } from "~/server/git/writer"; import type { FileChangeInput } from "~/server/git/writer"; import { signatureNow } from "~/server/git/objects"; import { diffTreesCached } from "~/server/git/diff"; import { compareCommits } from "~/server/git/history"; import { directUpdateViolation, loadProtections, protectionFor } from "~/server/protection"; import { triggerPush } from "~/server/events/push-trigger"; import { badRequest, conflict, forbidden, notFoundError } from "~/server/errors"; import { ZERO_SHA } from "~/server/git/store"; import { repoRef } from "~/lib/validation"; import type { Permission, RepoRow, Viewer } from "~/lib/types"; const MAX_EDIT_BYTES = 5 * 1024 * 1024; const encoder = new TextEncoder(); async function assertDirectUpdate(repo: RepoRow, permission: Permission, branch: string, deletion: boolean) { if (repo.is_archived === 1) throw forbidden("This repository is archived"); const violation = directUpdateViolation(protectionFor(await loadProtections(repo.id), branch), permission, deletion); if (violation) throw forbidden(violation); } async function announce(repo: RepoRow, viewer: Viewer, ref: string, before: string, after: string) { await triggerPush({ source: "internal", repoId: repo.id, artifactsName: repo.artifacts_name, ref, before, after, pusherId: viewer.id, }); } function rethrowPush(error: unknown): never { if (error instanceof PushRejectedError) throw conflict(error.message); throw error; } export const createBranch = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator(repoRef.extend({ name: z.string().min(1), from: z.string().min(1) })) .handler(async ({ data, context }) => { const viewer = requireViewer(context.viewer); await limitWrite(viewer); const { repo, permission } = await requireRepoAccess(data.owner, data.repo, viewer, "write"); if (!isValidRefName(data.name)) throw badRequest("Invalid branch name"); const refs = await listRefs(repo); if (branchSha(refs, data.name)) throw conflict("Branch already exists"); const source = resolveRevision(refs, data.from); if (!source) throw notFoundError("Source ref not found"); await assertDirectUpdate(repo, permission, data.name, false); await pushBranch(repo, data.name, source.sha).catch(rethrowPush); await announce(repo, viewer, `refs/heads/${data.name}`, ZERO_SHA, source.sha); return { ok: true }; }); export const deleteBranch = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator(repoRef.extend({ name: z.string().min(1) })) .handler(async ({ data, context }) => { const viewer = requireViewer(context.viewer); const { repo, permission } = await requireRepoAccess(data.owner, data.repo, viewer, "write"); if (data.name === repo.default_branch) throw badRequest("The default branch cannot be deleted"); const sha = branchSha(await listRefs(repo), data.name); if (!sha) throw notFoundError("Branch not found"); await assertDirectUpdate(repo, permission, data.name, true); await deleteRef(repo, `refs/heads/${data.name}`, sha).catch(rethrowPush); await announce(repo, viewer, `refs/heads/${data.name}`, sha, ZERO_SHA); return { ok: true }; }); const editInput = repoRef.extend({ branch: z.string().min(1), path: z.string().min(1).max(4096), previousPath: z.string().nullable(), content: z.string().max(MAX_EDIT_BYTES).nullable(), message: z.string().trim().min(1).max(10_000), expectedHeadSha: z.string().nullable(), newBranch: z.string().nullable(), }); /** Commits a single-file create/edit/rename/delete from the web editor, optionally onto a new branch. */ export const commitFile = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator(editInput) .handler(async ({ data, context }) => { const viewer = requireViewer(context.viewer); await limitWrite(viewer); const { repo, permission } = await requireRepoAccess(data.owner, data.repo, viewer, "write"); const refs = await listRefs(repo); const baseSha = branchSha(refs, data.branch); if (!baseSha && refs.branches.length > 0) throw notFoundError("Branch not found"); if (data.expectedHeadSha && baseSha !== data.expectedHeadSha) { throw conflict("The branch changed since you started editing; reload and reapply your change"); } const target = data.newBranch ?? data.branch; if (data.newBranch) { if (!isValidRefName(data.newBranch)) throw badRequest("Invalid branch name"); if (branchSha(refs, data.newBranch)) throw conflict("That branch already exists"); } await assertDirectUpdate(repo, permission, target, false); const changes: FileChangeInput[] = []; if (data.previousPath && data.previousPath !== data.path) changes.push({ path: data.previousPath, content: null }); changes.push({ path: data.path, content: data.content === null ? null : encoder.encode(data.content) }); const sha = await withStore(repo.artifacts_name, (store) => commitChanges({ repo, store, branch: target, parentSha: baseSha, changes, message: data.message, author: signatureNow(viewer.displayName ?? viewer.name, viewer.email), }), ).catch(rethrowPush); await announce(repo, viewer, `refs/heads/${target}`, data.newBranch ? ZERO_SHA : (baseSha ?? ZERO_SHA), sha); return { sha, branch: target }; }); async function resolveHead(base: RepoRow, head: string, viewer: Viewer | null) { const separator = head.indexOf(":"); if (separator === -1) return { repo: base, ref: head }; const owner = head.slice(0, separator); const ref = head.slice(separator + 1); const forks = await first<{ name: string }>( `SELECT r.name FROM repos r JOIN accounts a ON a.id = r.owner_id WHERE a.name = ? AND (r.id = ? OR r.fork_of_id = ? OR r.id = ?)`, owner, base.id, base.id, base.fork_of_id ?? "", ); if (!forks) throw notFoundError("No fork found for that owner"); const repo = await loadRepo(owner, forks.name); if (!repo || (await repoPermission(repo, viewer)) === "none") throw notFoundError("Head repository not found"); return { repo, ref }; } export const compareRefs = createServerFn({ method: "GET" }) .middleware([viewerMiddleware]) .validator(repoRef.extend({ base: z.string().min(1), head: z.string().min(1) })) .handler(async ({ data, context }) => { await limitHeavy(context.viewer); const { repo } = await requireRepoAccess(data.owner, data.repo, context.viewer, "read"); const head = await resolveHead(repo, data.head, context.viewer); const [baseRefs, headRefs] = await Promise.all([ listRefs(repo), head.repo.id === repo.id ? null : listRefs(head.repo), ]); const baseRevision = resolveRevision(baseRefs, data.base); const headRevision = resolveRevision(headRefs ?? baseRefs, head.ref); if (!baseRevision || !headRevision) throw notFoundError("Unknown branch"); const result = await withStore(repo.artifacts_name, (baseStore) => withStore(head.repo.artifacts_name, async (headStore) => { const comparison = await compareCommits(baseStore, baseRevision.sha, headStore, headRevision.sha); const [mergeBase, headCommit] = await Promise.all([ comparison.mergeBase ? headStore.commit(comparison.mergeBase) : null, headStore.commit(headRevision.sha), ]); if (!headCommit) throw notFoundError("Head commit not found"); const diff = await diffTreesCached(headStore, mergeBase?.treeSha ?? null, headCommit.treeSha); return { comparison, diff }; }), ); const existing = await first<{ number: number }>( `SELECT i.number FROM pull_requests p JOIN issues i ON i.id = p.issue_id WHERE i.repo_id = ? AND p.head_repo_id = ? AND p.head_ref = ? AND p.base_ref = ? AND i.state = 'open'`, repo.id, head.repo.id, head.ref, baseRevision.name, ); return { base: baseRevision, head: { ...headRevision, owner: head.repo.owner_name, repo: head.repo.name }, commits: result.comparison.commits, behindBy: result.comparison.behindBy, mergeBase: result.comparison.mergeBase, diff: result.diff, existingPull: existing?.number ?? null, branches: baseRefs.branches.map((branch) => branch.name), headBranches: (headRefs ?? baseRefs).branches.map((branch) => branch.name), }; });