import { badRequest, conflict, forbidden, notFoundError } from "../errors"; import { triggerPush } from "../events/push-trigger"; import { directUpdateViolation, loadProtections, protectionFor } from "../protection"; import { signatureNow } from "./objects"; import { branchSha, listRefs } from "./refs"; import { withStore, ZERO_SHA } from "./store"; import { commitChanges, isValidRefName, PushRejectedError } from "./writer"; import type { FileChangeInput } from "./writer"; import { repoReadOnlyReason } from "../repos"; import type { Permission, RepoRow, Viewer } from "~/lib/types"; export async function assertDirectUpdate(repo: RepoRow, permission: Permission, branch: string, deletion: boolean) { const readOnly = repoReadOnlyReason(repo); if (readOnly) throw forbidden(readOnly); const violation = directUpdateViolation(protectionFor(await loadProtections(repo.id), branch), permission, deletion); if (violation) throw forbidden(violation); } export async function announcePush(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, }); } export function rethrowPush(error: unknown): never { if (error instanceof PushRejectedError) throw conflict(error.message); throw error; } /** * Commits a single-file create/edit/rename/delete as the web editor does, optionally onto a new branch. * Path safety (GIT-7) is enforced by commitChanges; rejections surface as 409 "Invalid path: ...". */ export async function commitFileChange(input: { repo: RepoRow; permission: Permission; viewer: Viewer; branch: string; path: string; previousPath: string | null; content: Uint8Array | null; message: string; expectedHeadSha: string | null; newBranch: string | null; }): Promise<{ sha: string; branch: string }> { const changes: FileChangeInput[] = []; if (input.previousPath && input.previousPath !== input.path) changes.push({ path: input.previousPath, content: null }); changes.push({ path: input.path, content: input.content }); return commitFileChanges({ ...input, changes }); } /** Multi-file form of commitFileChange, used by the workspace editor; same validation and protections. */ export async function commitFileChanges(input: { repo: RepoRow; permission: Permission; viewer: Viewer; branch: string; changes: FileChangeInput[]; message: string; expectedHeadSha: string | null; newBranch: string | null; }): Promise<{ sha: string; branch: string }> { const { repo, viewer, changes } = input; if (changes.length === 0) throw badRequest("Nothing to commit"); const refs = await listRefs(repo); const baseSha = branchSha(refs, input.branch); if (!baseSha && refs.branches.length > 0) throw notFoundError("Branch not found"); if (input.expectedHeadSha && baseSha !== input.expectedHeadSha) { throw conflict("The branch changed since you started editing; reload and reapply your change"); } const target = input.newBranch ?? input.branch; if (input.newBranch) { if (!isValidRefName(input.newBranch)) throw badRequest("Invalid branch name"); if (branchSha(refs, input.newBranch)) throw conflict("That branch already exists"); } await assertDirectUpdate(repo, input.permission, target, false); const sha = await withStore(repo.artifacts_name, (store) => commitChanges({ repo, store, branch: target, parentSha: baseSha, createsBranch: Boolean(input.newBranch), changes, message: input.message, author: signatureNow(viewer.displayName ?? viewer.name, viewer.email), }), ).catch(rethrowPush); await announcePush(repo, viewer, `refs/heads/${target}`, input.newBranch ? ZERO_SHA : (baseSha ?? ZERO_SHA), sha); return { sha, branch: target }; }