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 };
export const ZERO_SHA = "0".repeat(40);
export type Signature = { name: string; email: string };
/** Identity variables for git: the author, and the committer (an agent committing for them, or the author). */
function identity(author: Signature, committer?: Signature) {
const c = committer ?? author;
return { GIT_AUTHOR_NAME: author.name, GIT_AUTHOR_EMAIL: author.email, GIT_COMMITTER_NAME: c.name, GIT_COMMITTER_EMAIL: c.email };
}
/** A throwaway index file, removed once `fn` is done with it. */
async function withIndex<T>(fn: (indexFile: string) => Promise<T>) {
const indexFile = path.join(os.tmpdir(), `mng-index-${randomBytes(8).toString("hex")}`);
try {
return await fn(indexFile);
} finally {
await rm(indexFile, { force: true });
}
}
/** Creates a commit object for a tree and returns its sha. */
export async function writeCommit(dir: string, opts: { tree: string; parents: string[]; message: string; author: Signature; committer?: Signature }) {
const args = ["commit-tree", opts.tree];
for (const p of opts.parents) args.push("-p", p);
return (await git(dir, args, { env: identity(opts.author, opts.committer), input: opts.message })).trim();
}
/**
* Moves a branch from `expected` (null: it must not exist yet) to `commit`, atomically: if someone
* else moved it in the meantime, this fails instead of overwriting their work.
*/
export async function updateBranch(dir: string, branch: string, commit: string, expected: string | null, reason = "web edit") {
try {
await git(dir, ["update-ref", "-m", reason, `refs/heads/${branch}`, commit, expected ?? ZERO_SHA]);
} catch {
throw new GitError(expected ? `Branch ${branch} was updated by someone else. Reload and try again.` : `Branch ${branch} already exists.`);
}
}
/** Deletes a branch if it's still at `expected`. */
export async function deleteBranch(dir: string, branch: string, expected: string) {
try {
await git(dir, ["update-ref", "-m", "delete branch", "-d", `refs/heads/${branch}`, expected]);
} catch {
throw new GitError(`Branch ${branch} was updated by someone else, so it wasn't deleted.`);
}
}
/** 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: Signature;
/** Who commits, if not the author: an agent committing for its person. */
committer?: Signature;
},
) {
const indexFile = path.join(os.tmpdir(), `mng-index-${randomBytes(8).toString("hex")}`);
const env = { GIT_INDEX_FILE: indexFile, ...identity(opts.author, opts.committer) };
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,
);
}
export class PatchError extends GitError {}
/**
* Applies a unified diff (as `git diff` or `diff -u` writes it) to the tree of `parent` and returns
* the resulting tree, without a working tree. Hunk line counts are recounted, since hand-written
* patches often get them wrong. With `reverse`, the patch is undone instead, merging three ways
* when the files changed since (as `git revert` would).
*/
export async function applyPatchToTree(dir: string, parent: string | null, patch: string, opts: { reverse?: boolean } = {}) {
return withIndex(async (indexFile) => {
const env = { GIT_INDEX_FILE: indexFile };
if (parent) await git(dir, ["read-tree", "--end-of-options", parent], { env });
else await git(dir, ["read-tree", "--empty"], { env });
const args = ["apply", "--cached", "--whitespace=nowarn", ...(opts.reverse ? ["-R", "--3way"] : ["--recount"])];
try {
await git(dir, args, { env, input: patch });
} catch (e) {
const detail = (e as Error).message.split("\n").slice(0, 20).join("\n");
throw new PatchError(opts.reverse ? `The changes can't be undone cleanly:\n${detail}` : `The patch doesn't apply:\n${detail}`);
}
return (await git(dir, ["write-tree"], { env })).trim();
});
}
/** The tree a commit points to. */
export async function treeOf(dir: string, commit: string) {
return (await git(dir, ["rev-parse", "--verify", "--end-of-options", `${commit}^{tree}`])).trim();
}