import { randomBytes } from "node:crypto"; import { rm } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { git, GitError } from "./git"; export type FileChange = | { path: string; content: Buffer } // create or overwrite | { path: string; delete: true }; const ZERO_SHA = "0".repeat(40); /** Validates a repository-relative file path typed by a user. */ export function cleanRepoPath(input: string) { const parts = input.trim().replace(/^\/+|\/+$/g, "").split("/"); if (parts.length === 0 || parts.some((p) => !p || p === "." || p === ".." || p.toLowerCase() === ".git")) { return null; } const clean = parts.join("/"); return clean.length <= 1024 && !/[\0\n\r]/.test(clean) ? clean : null; } /** * Creates a commit on `branch` directly in a bare repository using a throwaway * index, without needing a working tree. `parent` is the commit the changes are * based on (null for the first commit or a new orphan branch). The branch ref * is updated atomically, so a concurrent push makes this fail instead of being * overwritten. */ export async function commitFiles( dir: string, opts: { branch: string; parent: string | null; /** Expected current value of the branch; defaults to `parent`. Use null to require a new branch. */ expectedHead?: string | null; changes: FileChange[]; message: string; author: { name: string; email: string }; }, ) { const indexFile = path.join(os.tmpdir(), `mng-index-${randomBytes(8).toString("hex")}`); const env = { GIT_INDEX_FILE: indexFile, GIT_AUTHOR_NAME: opts.author.name, GIT_AUTHOR_EMAIL: opts.author.email, GIT_COMMITTER_NAME: opts.author.name, GIT_COMMITTER_EMAIL: opts.author.email, }; try { if (opts.parent) await git(dir, ["read-tree", "--end-of-options", opts.parent], { env }); else await git(dir, ["read-tree", "--empty"], { env }); for (const change of opts.changes) { if ("delete" in change) { // Mode 0 removes the entry; unlike --force-remove this works without a work tree. await git(dir, ["update-index", "--index-info"], { env, input: `0 ${ZERO_SHA}\t${change.path}\n` }); continue; } const sha = (await git(dir, ["hash-object", "-w", "--stdin"], { input: change.content })).trim(); // Keep the executable bit if the file already exists with it. const existing = await git(dir, ["ls-files", "-s", "--", change.path], { env }); const mode = existing.startsWith("100755") ? "100755" : "100644"; await git(dir, ["update-index", "--add", "--cacheinfo", `${mode},${sha},${change.path}`], { env }); } const tree = (await git(dir, ["write-tree"], { env })).trim(); if (opts.parent) { const parentTree = (await git(dir, ["rev-parse", "--verify", "--end-of-options", `${opts.parent}^{tree}`])).trim(); if (parentTree === tree) throw new GitError("No changes to commit."); } const args = ["commit-tree", tree]; if (opts.parent) args.push("-p", opts.parent); const commit = (await git(dir, args, { env, input: opts.message })).trim(); const expected = opts.expectedHead === undefined ? opts.parent : opts.expectedHead; try { await git(dir, ["update-ref", "-m", "web edit", `refs/heads/${opts.branch}`, commit, expected ?? ZERO_SHA]); } catch { throw new GitError( expected ? `Branch ${opts.branch} was updated by someone else. Reload and try again.` : `Branch ${opts.branch} already exists.`, ); } return commit; } finally { await rm(indexFile, { force: true }); } } /** A tag name git accepts, without path components starting with "-" (pushes of those are refused too). */ export async function isValidTagName(dir: string, name: string) { if (!name || name.length > 255 || name.split("/").some((c) => c.startsWith("-"))) return false; return git(dir, ["check-ref-format", `refs/tags/${name}`]).then( () => true, () => false, ); } /** Creates a lightweight tag at a commit, failing if the tag already exists. */ export async function createTag(dir: string, name: string, sha: string) { try { await git(dir, ["update-ref", "-m", "release", `refs/tags/${name}`, sha, ZERO_SHA]); } catch { throw new GitError(`Tag ${name} already exists.`); } } export async function isValidBranchName(dir: string, name: string) { if (!name || name.startsWith("-")) return false; return git(dir, ["check-ref-format", "--branch", name]).then( () => true, () => false, ); }