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",
]);
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));
}
/** 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;
}
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 } : {}) },
});
}