Vectrum
Sign In
439 lines · 15.1 KB · TypeScript
import { execFile, spawn } from "node:child_process";
import { mkdirSync, writeFileSync } from "node:fs";
import { access } from "node:fs/promises";
import path from "node:path";
import { DATA_ROOT, REPOS_ROOT } from "./paths";

// Note: this module is also used by the SSH server (outside of React), so it
// must not import "server-only" or anything from next/*.

const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
const RESERVED = new Set([
  "new", "api", "_next", "favicon.ico", "login", "logout", "signup", "settings", "explore", "about", "admin",
]);

export function isValidName(name: string) {
  return NAME_RE.test(name) && !name.toLowerCase().endsWith(".git") && !name.includes("..");
}

export function isValidOwner(owner: string) {
  return /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/.test(owner) && !RESERVED.has(owner.toLowerCase());
}

/** Accepts "repo" or "repo.git" (as used in clone URLs). */
export function normalizeRepoName(repo: string) {
  return repo.toLowerCase().endsWith(".git") ? repo.slice(0, -4) : repo;
}

/** On-disk location of a repository. Callers pass canonical names from the database. */
export function repoPath(owner: string, repo: string) {
  if (!isValidOwner(owner) || !isValidName(repo)) throw new GitError("Invalid repository path");
  return path.join(REPOS_ROOT, owner.toLowerCase(), `${repo.toLowerCase()}.git`);
}

export async function pathExists(p: string) {
  try {
    await access(p);
    return true;
  } catch {
    return false;
  }
}

export class GitError extends Error {}

/**
 * Ref names may start with "-" (e.g. a pushed branch named "--output=/etc/x"),
 * and git would parse such a revision as an option. Every revision argument
 * goes through this and follows "--end-of-options". The check is needed on top
 * of the marker: `git shortlog` consumes the marker itself and still parses a
 * following "--output=…" as an option.
 */
function rev(revision: string) {
  if (revision.startsWith("-")) throw new GitError(`Invalid revision: ${revision}`);
  return revision;
}

/** Ref names whose short form starts with "-" are refused on push and ignored when reading. */
const isSafeRefName = (name: string) => !name.startsWith("-");

type GitOptions = { env?: Record<string, string>; input?: string | Buffer; maxBuffer?: number };

function run<T extends "utf8" | "buffer">(dir: string, args: string[], encoding: T, opts: GitOptions) {
  return new Promise<T extends "utf8" ? string : Buffer>((resolve, reject) => {
    const child = execFile(
      "git",
      ["--git-dir", dir, ...args],
      {
        maxBuffer: opts.maxBuffer ?? 64 * 1024 * 1024,
        encoding,
        env: opts.env ? { ...process.env, ...opts.env } : process.env,
      },
      (err, stdout, stderr) => {
        if (err) reject(new GitError(stderr.toString().trim() || err.message));
        else resolve(stdout as T extends "utf8" ? string : Buffer);
      },
    );
    if (opts.input !== undefined) child.stdin?.end(opts.input);
  });
}

export function git(dir: string, args: string[], opts: GitOptions = {}) {
  return run(dir, args, "utf8", opts);
}

export function gitBuffer(dir: string, args: string[], opts: GitOptions = {}) {
  return run(dir, args, "buffer", opts);
}

export async function initBareRepo(dir: string) {
  await new Promise<void>((resolve, reject) => {
    execFile("git", ["init", "--quiet", "--bare", "--initial-branch=main", dir], (err, _o, stderr) =>
      err ? reject(new GitError(stderr || err.message)) : resolve(),
    );
  });
  await git(dir, ["config", "http.receivepack", "true"]);
}

/** Time of the most recent commit on any branch, or null for empty repositories. */
export async function lastActivity(dir: string) {
  const out = await git(dir, [
    "for-each-ref", "--sort=-committerdate", "--count=1", "--format=%(committerdate:unix)", "refs/heads",
  ]).catch(() => "");
  return out.trim() ? Number(out.trim()) * 1000 : null;
}

export async function isBranch(dir: string, name: string) {
  return git(dir, ["show-ref", "--verify", "--quiet", "--end-of-options", `refs/heads/${name}`]).then(
    () => true,
    () => false,
  );
}

export async function revParse(dir: string, revision: string) {
  const out = await git(dir, ["rev-parse", "--verify", "--quiet", "--end-of-options", `${rev(revision)}^{commit}`]).catch(
    () => "",
  );
  return out.trim() || null;
}

// ---------------------------------------------------------------------------
// Refs

export type Branch = { name: string; sha: string; date: number; subject: string };

export async function listBranches(dir: string): Promise<Branch[]> {
  const out = await git(dir, [
    "for-each-ref",
    "--sort=-committerdate",
    "--format=%(refname:short)%1f%(objectname)%1f%(committerdate:unix)%1f%(subject)",
    "refs/heads",
  ]);
  return out
    .split("\n")
    .filter(Boolean)
    .map((line) => {
      const [name, sha, date, subject] = line.split("\x1f");
      return { name, sha, date: Number(date) * 1000, subject };
    })
    .filter((b) => isSafeRefName(b.name));
}

export async function listTags(dir: string) {
  const out = await git(dir, ["for-each-ref", "--format=%(refname:short)", "refs/tags"]);
  return out.split("\n").filter((name) => name && isSafeRefName(name));
}

/** The branch HEAD points to, or the first existing branch if HEAD dangles. */
export async function defaultBranch(dir: string): Promise<string | null> {
  const branches = await listBranches(dir);
  if (branches.length === 0) return null;
  const head = (await git(dir, ["symbolic-ref", "--short", "HEAD"]).catch(() => "")).trim();
  if (branches.some((b) => b.name === head)) return head;
  return branches[0].name;
}

/**
 * Splits URL segments like ["feature", "x", "src", "a.ts"] into a ref and a path,
 * preferring the longest prefix that names an existing branch or tag (like GitHub).
 */
export async function resolveRefAndPath(dir: string, segments: string[]) {
  const refs = new Set([...(await listBranches(dir)).map((b) => b.name), ...(await listTags(dir))]);
  for (let i = segments.length; i > 0; i--) {
    const candidate = segments.slice(0, i).join("/");
    if (refs.has(candidate)) return { ref: candidate, path: segments.slice(i).join("/") };
  }
  const [ref, ...rest] = segments;
  if (ref && /^[0-9a-f]{4,40}$/i.test(ref) && (await commitExists(dir, ref))) {
    return { ref, path: rest.join("/") };
  }
  return null;
}

async function commitExists(dir: string, ref: string) {
  return git(dir, ["cat-file", "-e", "--end-of-options", `${rev(ref)}^{commit}`]).then(
    () => true,
    () => false,
  );
}

// ---------------------------------------------------------------------------
// Trees and blobs

export type TreeEntry = {
  mode: string;
  type: "blob" | "tree" | "commit";
  sha: string;
  size: number | null;
  name: string;
  path: string;
};

export async function objectType(dir: string, ref: string, filePath: string) {
  return (await git(dir, ["cat-file", "-t", "--end-of-options", `${rev(ref)}:${filePath}`]).catch(() => "")).trim() || null;
}

export async function listTree(dir: string, ref: string, treePath: string): Promise<TreeEntry[]> {
  const out = await git(dir, ["ls-tree", "-z", "-l", "--end-of-options", `${rev(ref)}:${treePath}`]);
  const entries = out
    .split("\0")
    .filter(Boolean)
    .map((record) => {
      const tab = record.indexOf("\t");
      const [mode, type, sha, size] = record.slice(0, tab).split(/\s+/);
      const name = record.slice(tab + 1);
      return {
        mode,
        type: type as TreeEntry["type"],
        sha,
        size: size === "-" ? null : Number(size),
        name,
        path: treePath ? `${treePath}/${name}` : name,
      };
    });
  return entries.sort((a, b) => {
    if ((a.type === "tree") !== (b.type === "tree")) return a.type === "tree" ? -1 : 1;
    return a.name.localeCompare(b.name);
  });
}

/** Every file in the tree at `ref`, with its size, for language statistics. */
export async function listFilesWithSizes(dir: string, ref: string) {
  const out = await git(dir, ["ls-tree", "-r", "-z", "-l", "--end-of-options", rev(ref)]);
  return out
    .split("\0")
    .filter(Boolean)
    .flatMap((record) => {
      const tab = record.indexOf("\t");
      const [, type, , size] = record.slice(0, tab).split(/\s+/);
      return type === "blob" ? [{ path: record.slice(tab + 1), size: Number(size) || 0 }] : [];
    });
}

export async function readBlob(dir: string, ref: string, filePath: string) {
  const size = Number((await git(dir, ["cat-file", "-s", "--end-of-options", `${rev(ref)}:${filePath}`])).trim());
  const tooLarge = size > 2 * 1024 * 1024;
  const content = tooLarge
    ? Buffer.alloc(0)
    : await gitBuffer(dir, ["cat-file", "blob", "--end-of-options", `${rev(ref)}:${filePath}`]);
  const binary = content.subarray(0, 8000).includes(0);
  return { size, tooLarge, binary, content };
}

// ---------------------------------------------------------------------------
// Commits

export type Commit = {
  sha: string;
  parents: string[];
  authorName: string;
  authorEmail: string;
  date: number;
  subject: string;
  body: string;
};

const COMMIT_FORMAT = "%H%x1f%P%x1f%an%x1f%ae%x1f%at%x1f%s%x1f%b%x1e";

function parseCommits(out: string): Commit[] {
  return out
    .split("\x1e")
    .map((r) => r.replace(/^\n/, ""))
    .filter(Boolean)
    .map((record) => {
      const [sha, parents, authorName, authorEmail, date, subject, body] = record.split("\x1f");
      return {
        sha,
        parents: parents ? parents.split(" ") : [],
        authorName,
        authorEmail,
        date: Number(date) * 1000,
        subject,
        body: (body ?? "").trim(),
      };
    });
}

export async function log(
  dir: string,
  ref: string,
  opts: { path?: string; limit?: number; skip?: number } = {},
) {
  const args = [
    "log",
    `--format=${COMMIT_FORMAT}`,
    `-n${opts.limit ?? 30}`,
    `--skip=${opts.skip ?? 0}`,
    "--end-of-options",
    rev(ref),
    "--",
  ];
  if (opts.path) args.push(opts.path);
  return parseCommits(await git(dir, args));
}

export async function lastCommit(dir: string, ref: string, filePath?: string) {
  return (await log(dir, ref, { path: filePath, limit: 1 }))[0] ?? null;
}

export async function commitCount(dir: string, ref: string) {
  return Number((await git(dir, ["rev-list", "--count", "--end-of-options", rev(ref)])).trim());
}

export type Contributor = { name: string; email: string; commits: number };

/** Commit authors reachable from `ref`, most active first. */
export async function listContributors(dir: string, ref: string): Promise<Contributor[]> {
  const out = await git(dir, ["shortlog", "-sne", "--end-of-options", rev(ref), "--"]);
  return out
    .split("\n")
    .map((line) => line.match(/^\s*(\d+)\t(.*?)\s*<([^>]*)>$/))
    .filter((m) => m !== null)
    .map(([, commits, name, email]) => ({ name, email, commits: Number(commits) }));
}

export async function getCommit(dir: string, sha: string) {
  if (!/^[0-9a-f]{4,40}$/i.test(sha)) return null;
  const out = await git(dir, ["show", "-s", `--format=${COMMIT_FORMAT}`, "--end-of-options", sha, "--"]).catch(() => null);
  return out ? (parseCommits(out)[0] ?? null) : null;
}

// ---------------------------------------------------------------------------
// Diffs

export type DiffLine = { kind: "add" | "del" | "ctx" | "hunk"; text: string; oldNo?: number; newNo?: number };
export type FileDiff = {
  oldPath: string;
  newPath: string;
  status: "added" | "deleted" | "modified" | "renamed";
  binary: boolean;
  additions: number;
  deletions: number;
  lines: DiffLine[];
};

export async function diffCommit(dir: string, commit: Commit) {
  // Diff against the first parent, or the empty tree for root commits.
  const base = commit.parents[0] ?? (await git(dir, ["hash-object", "-t", "tree", "/dev/null"])).trim();
  const out = await git(dir, ["diff", "--no-color", "-M", "--no-ext-diff", "--end-of-options", rev(base), rev(commit.sha)]);
  return parseDiff(out);
}

export function parseDiff(out: string): FileDiff[] {
  const files: FileDiff[] = [];
  let file: FileDiff | null = null;
  let oldNo = 0;
  let newNo = 0;
  for (const line of out.split("\n")) {
    if (line.startsWith("diff --git ")) {
      const m = /^diff --git a\/(.*) b\/(.*)$/.exec(line);
      file = {
        oldPath: m?.[1] ?? "",
        newPath: m?.[2] ?? "",
        status: "modified",
        binary: false,
        additions: 0,
        deletions: 0,
        lines: [],
      };
      files.push(file);
      continue;
    }
    if (!file) continue;
    if (line.startsWith("new file mode")) file.status = "added";
    else if (line.startsWith("deleted file mode")) file.status = "deleted";
    else if (line.startsWith("rename from ")) {
      file.status = "renamed";
      file.oldPath = line.slice(12);
    } else if (line.startsWith("rename to ")) file.newPath = line.slice(10);
    else if (line.startsWith("Binary files")) file.binary = true;
    else if (line.startsWith("@@")) {
      const m = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line);
      oldNo = Number(m?.[1] ?? 0);
      newNo = Number(m?.[2] ?? 0);
      file.lines.push({ kind: "hunk", text: line });
    } else if (file.lines.length > 0) {
      if (line.startsWith("+")) {
        file.additions++;
        file.lines.push({ kind: "add", text: line.slice(1), newNo: newNo++ });
      } else if (line.startsWith("-")) {
        file.deletions++;
        file.lines.push({ kind: "del", text: line.slice(1), oldNo: oldNo++ });
      } else if (line.startsWith(" ")) {
        file.lines.push({ kind: "ctx", text: line.slice(1), oldNo: oldNo++, newNo: newNo++ });
      }
    }
  }
  return files;
}

// ---------------------------------------------------------------------------
// Smart HTTP transport

export type GitService = "git-upload-pack" | "git-receive-pack";

const HOOKS_DIR = path.join(DATA_ROOT, "hooks");

// Receives "<old> <new> <ref>" lines; refuses refs with a path component
// starting with "-", which git commands could mistake for options.
const PRE_RECEIVE_HOOK = `#!/bin/sh
status=0
while read -r old new ref; do
  case "/$ref" in
    */-*) echo "Vectrum: refusing $ref (ref name components must not start with '-')" >&2; status=1 ;;
  esac
done
exit $status
`;

let hooksInstalled = false;

/** Server-side hooks for every repository, (re)written once per process so updates apply. */
function hooksDir() {
  if (!hooksInstalled) {
    mkdirSync(HOOKS_DIR, { recursive: true });
    writeFileSync(path.join(HOOKS_DIR, "pre-receive"), PRE_RECEIVE_HOOK, { mode: 0o755 });
    hooksInstalled = true;
  }
  return HOOKS_DIR;
}

/**
 * Starts upload-pack or receive-pack for a transport. HTTP uses stateless RPC;
 * SSH streams a whole session. Pushes always run Vectrum's hooks, whatever the
 * repository's own hook configuration.
 */
export function spawnService(
  service: GitService,
  dir: string,
  opts: { stateless?: boolean; advertise?: boolean; protocol?: string | null } = {},
) {
  const args = service === "git-receive-pack" ? ["-c", `core.hooksPath=${hooksDir()}`] : [];
  args.push(service.slice(4));
  if (opts.stateless ?? true) args.push("--stateless-rpc");
  if (opts.advertise) args.push("--advertise-refs");
  args.push(dir);
  return spawn("git", args, {
    env: { ...process.env, ...(opts.protocol ? { GIT_PROTOCOL: opts.protocol } : {}) },
  });
}