"use server";
import { redirect } from "next/navigation";
import { onRefsUpdated, startWorkflowsInBackground } from "@/lib/ci";
import { git, GitError, objectType, revParse } from "@/lib/git";
import { cleanRepoPath, commitFiles, isValidBranchName, type FileChange } from "@/lib/git-write";
import { encodePath } from "@/lib/format";
import { markPushed, type RepoRecord } from "@/lib/repos";
import type { User } from "@/lib/users";
import { ActionError, repoForAction } from "./guard";
import type { FormState } from "./types";
const str = (formData: FormData, key: string) => String(formData.get(key) ?? "").trim();
/** Blob sha of a path at a commit, or null if it doesn't exist there. */
async function blobAt(dir: string, commit: string, path: string) {
return (await git(dir, ["rev-parse", "--verify", "--quiet", `${commit}:${path}`]).catch(() => "")).trim() || null;
}
/**
* Commits `changes` from the web UI, either directly to `branch` or to a new
* branch, and returns the branch that received the commit. `touched` lists the
* paths whose content the user saw; if any changed on the branch since the page
* was loaded (`baseSha`), the commit is refused instead of overwriting them.
*/
async function commitFromForm(
repo: RepoRecord,
viewer: User,
formData: FormData,
changes: FileChange[],
touched: string[],
defaultMessage: string,
) {
const branch = str(formData, "branch");
const baseSha = str(formData, "baseSha");
const head = await revParse(repo.dir, `refs/heads/${branch}`);
if (baseSha) {
if (!head) throw new ActionError(`Branch ${branch} no longer exists.`);
if (head !== baseSha) {
for (const path of touched) {
if ((await blobAt(repo.dir, baseSha, path)) !== (await blobAt(repo.dir, head, path))) {
throw new ActionError(`${path} was changed on ${branch} since you started editing. Reload to get the latest version.`);
}
}
}
} else if (head) {
throw new ActionError(`Branch ${branch} was created by someone else in the meantime. Reload and try again.`);
}
const summary = str(formData, "message") || defaultMessage;
const description = str(formData, "description");
const message = `${summary}${description ? `\n\n${description}` : ""}\n`;
const author = { name: viewer.name || viewer.username, email: viewer.email };
let target = branch;
let commit: string;
if (formData.get("target") === "branch") {
target = str(formData, "newBranch");
if (!(await isValidBranchName(repo.dir, target))) throw new ActionError(`${target || "That"} is not a valid branch name.`);
commit = await commitFiles(repo.dir, { branch: target, parent: head, expectedHead: null, changes, message, author });
} else {
commit = await commitFiles(repo.dir, { branch, parent: head, changes, message, author });
}
await markPushed(repo.id);
// A commit from the browser starts workflows just like a push of it would.
const ref = `refs/heads/${target}`;
const before = new Map<string, string>();
if (head && target === branch) before.set(ref, head);
startWorkflowsInBackground(onRefsUpdated(repo, before, new Map([[ref, commit]]), viewer.id));
return target;
}
async function handle(fn: () => Promise<string>): Promise<FormState> {
let destination: string;
try {
destination = await fn();
} catch (e) {
if (e instanceof ActionError || e instanceof GitError) return { error: e.message };
throw e;
}
redirect(destination);
}
export async function saveFileAction(owner: string, name: string, _prev: FormState, formData: FormData) {
return handle(async () => {
const { repo, viewer } = await repoForAction(owner, name, "write");
const path = cleanRepoPath(str(formData, "path"));
if (!path) throw new ActionError("That file name is invalid.");
const originalPath = str(formData, "originalPath");
const content = Buffer.from(String(formData.get("content") ?? ""));
const head = await revParse(repo.dir, `refs/heads/${str(formData, "branch")}`);
// Refuse to silently overwrite a different existing file.
if (path !== originalPath && head && (await objectType(repo.dir, head, path))) {
throw new ActionError(`A file named ${path} already exists.`);
}
const changes: FileChange[] = [{ path, content }];
if (originalPath && originalPath !== path) changes.unshift({ path: originalPath, delete: true });
const filename = path.split("/").pop()!;
const verb = !originalPath ? "Create" : originalPath !== path ? "Rename" : "Update";
const branch = await commitFromForm(repo, viewer, formData, changes, originalPath ? [originalPath] : [], `${verb} ${filename}`);
return `/${repo.owner}/${repo.name}/blob/${encodePath(branch)}/${encodePath(path)}`;
});
}
export async function deleteFileAction(owner: string, name: string, _prev: FormState, formData: FormData) {
return handle(async () => {
const { repo, viewer } = await repoForAction(owner, name, "write");
const path = cleanRepoPath(str(formData, "path"));
if (!path) throw new ActionError("That file name is invalid.");
const branch = await commitFromForm(repo, viewer, formData, [{ path, delete: true }], [path], `Delete ${path.split("/").pop()}`);
// Go to the closest directory that still exists.
const parts = path.split("/").slice(0, -1);
while (parts.length && (await objectType(repo.dir, `refs/heads/${branch}`, parts.join("/"))) !== "tree") parts.pop();
const suffix = parts.length ? `/${encodePath(parts.join("/"))}` : "";
const hasFiles = await revParse(repo.dir, `refs/heads/${branch}`);
return hasFiles ? `/${repo.owner}/${repo.name}/tree/${encodePath(branch)}${suffix}` : `/${repo.owner}/${repo.name}`;
});
}
const MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
export async function uploadFilesAction(owner: string, name: string, _prev: FormState, formData: FormData) {
return handle(async () => {
const { repo, viewer } = await repoForAction(owner, name, "write");
const dir = str(formData, "dir");
const files = formData.getAll("files").filter((f): f is File => f instanceof File && f.size > 0);
if (files.length === 0) throw new ActionError("Choose at least one file to upload.");
const changes: FileChange[] = [];
for (const file of files) {
if (file.size > MAX_UPLOAD_BYTES) throw new ActionError(`${file.name} is larger than 10 MB.`);
const path = cleanRepoPath(dir ? `${dir}/${file.name}` : file.name);
if (!path) throw new ActionError(`${file.name} is not a valid file name.`);
changes.push({ path, content: Buffer.from(await file.arrayBuffer()) });
}
const branch = await commitFromForm(
repo,
viewer,
formData,
changes,
changes.map((c) => c.path),
files.length === 1 ? `Add ${files[0].name}` : `Add ${files.length} files`,
);
return `/${repo.owner}/${repo.name}/tree/${encodePath(branch)}${dir ? `/${encodePath(dir)}` : ""}`;
});
}