Vectrum
Sign In
186 lines · 7.9 KB · TypeScript
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();
}