Vectrum
Sign In
615 lines · 24.3 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 { hookScript } from "./hook-script";
import { DATA_ROOT, REPOS_ROOT, WIKIS_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", "v2",
  "mcp", "oauth", "organizations", "lists",
]);

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`);
}

/** On-disk location of a repository's wiki (PocketBase record ids are lowercase alphanumeric). */
export function wikiPath(repoId: string) {
  if (!/^[a-z0-9]+$/.test(repoId)) throw new GitError("Invalid repository id");
  return path.join(WIKIS_ROOT, `${repoId}.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);
  });
}

/** Runs git and resolves with its exit code instead of failing, for commands whose exit code is an answer. */
export function gitStatus(dir: string, args: string[]) {
  return new Promise<{ code: number; stdout: string; stderr: string }>((resolve) => {
    execFile("git", ["--git-dir", dir, ...args], { maxBuffer: 64 * 1024 * 1024, encoding: "utf8" }, (err, stdout, stderr) => {
      const code = err ? (typeof err.code === "number" ? err.code : -1) : 0;
      resolve({ code, stdout, stderr });
    });
  });
}

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"]);
}

/**
 * A fork: a new bare repository with the branches (or only `branch`) and tags of `source`. It keeps
 * no remote, so nothing points back at the source's directory.
 */
export async function cloneBare(source: string, dir: string, branch: string | null) {
  const args = ["clone", "--bare", "--quiet", ...(branch ? ["--single-branch", `--branch=${rev(branch)}`] : []), "--", source, dir];
  await new Promise<void>((resolve, reject) => {
    execFile("git", args, (err, _o, stderr) => (err ? reject(new GitError(stderr || err.message)) : resolve()));
  });
  await git(dir, ["remote", "remove", "origin"]).catch(() => {});
  await git(dir, ["config", "http.receivepack", "true"]);
}

/**
 * Copies a branch's commits from another repository on disk (a fork, or the repository it was
 * forked from) into this one. With `ref` they're kept there; without, they're only stored, which
 * is enough to compare with them (git removes unreferenced objects after two weeks). Returns the
 * commit fetched.
 */
export async function fetchBranch(dir: string, source: string, branch: string, ref?: string) {
  const sha = await revParse(source, `refs/heads/${branch}`);
  if (!sha) throw new GitError(`There's no branch named ${branch}.`);
  if (!ref && (await commitExists(dir, sha))) return sha;
  const refspec = `+refs/heads/${rev(branch)}${ref ? `:${ref}` : ""}`;
  await git(dir, ["fetch", "--quiet", "--no-tags", "--no-write-fetch-head", "--end-of-options", source, refspec]);
  return ref ? ((await revParse(dir, ref)) ?? sha) : sha;
}

/** 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));
}

/** Every branch and tag with the commit it points to, to tell what a push changed. */
export async function snapshotRefs(dir: string) {
  const out = await git(dir, ["for-each-ref", "--format=%(refname) %(objectname)", "refs/heads", "refs/tags"]).catch(() => "");
  return new Map(
    out
      .split("\n")
      .filter(Boolean)
      .map((line) => line.split(" ") as [string, string]),
  );
}

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));
}

/** Where HEAD points, if that's a branch name (whether or not the branch exists yet). */
async function headBranch(dir: string) {
  const head = (await git(dir, ["symbolic-ref", "HEAD"]).catch(() => "")).trim();
  return head.startsWith("refs/heads/") ? head.slice(11) : null;
}

/** The default branch when HEAD dangles: main, then master, then the most recently changed branch. */
const fallbackBranch = (branches: Branch[]) =>
  branches.find((b) => b.name === "main")?.name ?? branches.find((b) => b.name === "master")?.name ?? branches[0]?.name ?? null;

/** The branch HEAD points to, or (if HEAD dangles) main, master or the most recently changed branch. */
export async function defaultBranch(dir: string): Promise<string | null> {
  const branches = await listBranches(dir);
  if (branches.length === 0) return null;
  const head = await headBranch(dir);
  if (branches.some((b) => b.name === head)) return head;
  return fallbackBranch(branches);
}

/** Makes `branch` (which must exist) the default branch. */
export async function setDefaultBranch(dir: string, branch: string) {
  if (!(await listBranches(dir)).some((b) => b.name === branch)) throw new GitError(`There's no branch named ${branch}.`);
  await git(dir, ["symbolic-ref", "HEAD", `refs/heads/${branch}`]);
}

/**
 * Points a dangling HEAD (a new repository whose first push had no main, or a deleted default
 * branch) at main or master once one exists, so the default branch stops following whichever branch
 * was pushed last. Without either it stays dangling, and defaultBranch() falls back as above.
 */
export async function pinDefaultBranch(dir: string) {
  const branches = await listBranches(dir);
  const head = await headBranch(dir);
  if (branches.length === 0 || branches.some((b) => b.name === head)) return;
  const branch = fallbackBranch(branches);
  if (branch === "main" || branch === "master") await git(dir, ["symbolic-ref", "HEAD", `refs/heads/${branch}`]);
}

/**
 * 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;
}

export 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 };
}

export type ArchiveFormat = "zip" | "tar.gz";

/** Streams the tree of a commit as a zip or gzipped tarball, with its files under `prefix/`. */
export function spawnArchive(dir: string, sha: string, format: ArchiveFormat, prefix: string) {
  return spawn("git", ["--git-dir", dir, "archive", `--format=${format}`, `--prefix=${prefix}/`, "--end-of-options", rev(sha)]);
}

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

export type Commit = {
  sha: string;
  parents: string[];
  authorName: string;
  authorEmail: string;
  date: number;
  /** Who made the commit object: the author, or e.g. the agent that committed for them. */
  committerName: string;
  committerEmail: string;
  subject: string;
  body: string;
};

const COMMIT_FORMAT = "%H%x1f%P%x1f%an%x1f%ae%x1f%at%x1f%cn%x1f%ce%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, committerName, committerEmail, subject, body] = record.split("\x1f");
      return {
        sha,
        parents: parents ? parents.split(" ") : [],
        authorName,
        authorEmail,
        date: Number(date) * 1000,
        committerName,
        committerEmail,
        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);
}

/** The changes between two commits, as `git diff from to` shows them. */
export async function diffRange(dir: string, from: string, to: string, paths: string[] = []) {
  return parseDiff(await diffRangeText(dir, from, to, paths));
}

/** The same as unified diff text; with `binary`, one that can be applied (or reversed) with binary files too. */
export function diffRangeText(dir: string, from: string, to: string, paths: string[] = [], opts: { binary?: boolean } = {}) {
  const extra = opts.binary ? ["--binary", "--full-index"] : [];
  return git(dir, ["diff", "--no-color", "-M", "--no-ext-diff", ...extra, "--end-of-options", rev(from), rev(to), "--", ...paths]);
}

/** Whether `ancestor` is reachable from `descendant` (or is it). */
export async function isAncestor(dir: string, ancestor: string, descendant: string) {
  return git(dir, ["merge-base", "--is-ancestor", "--end-of-options", rev(ancestor), rev(descendant)]).then(
    () => true,
    () => false,
  );
}

/** The best common ancestor of two commits, or null if their histories are unrelated. */
export async function mergeBase(dir: string, a: string, b: string) {
  const out = await git(dir, ["merge-base", "--end-of-options", rev(a), rev(b)]).catch(() => "");
  return out.trim() || null;
}

/** Commits reachable from `head` but not from `base` (what a merge of head into base brings), newest first. */
export async function commitsBetween(dir: string, base: string, head: string, limit = 250) {
  return parseCommits(await git(dir, ["log", `--format=${COMMIT_FORMAT}`, `-n${limit}`, "--end-of-options", `${rev(base)}..${rev(head)}`, "--"]));
}

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

/** Branches whose history contains a commit. */
export async function branchesContaining(dir: string, sha: string) {
  const out = await git(dir, ["for-each-ref", "--format=%(refname:short)", `--contains=${rev(sha)}`, "refs/heads"]).catch(() => "");
  return out.split("\n").filter((b) => b && isSafeRefName(b));
}

/** The commit on `ref` that reverted `sha` (its message says "This reverts commit <sha>."), or null. */
export async function findRevertOf(dir: string, ref: string, sha: string) {
  const out = await git(dir, ["log", "--format=%H", "-n1", "--fixed-strings", `--grep=This reverts commit ${sha}`, "--end-of-options", rev(ref), "--"]).catch(() => "");
  return out.trim() || null;
}

export type MergeResult = { clean: true; tree: string } | { clean: false; conflicts: string[] };

/** Merges two commits in memory (no working tree): the merged tree, or the files that conflict. */
export async function mergeTrees(dir: string, base: string, head: string): Promise<MergeResult> {
  const { code, stdout, stderr } = await gitStatus(dir, ["merge-tree", "--write-tree", "--name-only", "--no-messages", "-z", "--end-of-options", rev(base), rev(head)]);
  // 0: merged cleanly; 1: the (conflicted) tree, then the files with conflicts.
  if (code === 0) return { clean: true, tree: stdout.split("\0")[0].trim() };
  if (/unrelated histories/i.test(stderr)) return { clean: false, conflicts: ["(the branches have no common history)"] };
  const [, ...files] = stdout.split("\0").filter(Boolean);
  if (code !== 1 || files.length === 0) throw new GitError(stderr.trim() || "git merge-tree failed");
  return { clean: false, conflicts: [...new Set(files)] };
}

export type GrepMatch = { path: string; line: number; text: string };

/**
 * Lines matching a pattern in the files of a commit, like `git grep`. Binary files are skipped.
 * Stops at `limit` matches; `truncated` says whether there were more.
 */
export async function grep(
  dir: string,
  sha: string,
  pattern: string,
  opts: { regex?: boolean; caseSensitive?: boolean; paths?: string[]; limit?: number } = {},
) {
  const limit = opts.limit ?? 200;
  const args = ["grep", "-n", "-I", "--null", "--full-name", "--no-color", opts.regex ? "-E" : "-F"];
  if (!opts.caseSensitive) args.push("-i");
  args.push("-e", pattern, "--end-of-options", rev(sha), "--", ...(opts.paths ?? []));
  const { code, stdout: out, stderr } = await gitStatus(dir, args);
  // Exit code 1 means no matches.
  if (code !== 0 && code !== 1) throw new GitError(stderr.trim() || "git grep failed");
  const matches: GrepMatch[] = [];
  const prefix = `${sha}:`;
  for (const record of out.split("\n")) {
    if (!record) continue;
    const [file, line, ...rest] = record.split("\0");
    if (line === undefined) continue;
    if (matches.length === limit) return { matches, truncated: true };
    matches.push({ path: file.startsWith(prefix) ? file.slice(prefix.length) : file, line: Number(line), text: rest.join("\0") });
  }
  return { matches, truncated: false };
}

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");

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"), hookScript(), { 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; env?: Record<string, string> } = {},
) {
  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.env, ...(opts.protocol ? { GIT_PROTOCOL: opts.protocol } : {}) },
  });
}