import { execFile, spawn } from "node:child_process";
import { access } from "node:fs/promises";
import path from "node:path";
import { 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", "v2",
]);
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 {}
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", `refs/heads/${name}`]).then(
() => true,
() => false,
);
}
export async function revParse(dir: string, rev: string) {
return (await git(dir, ["rev-parse", "--verify", "--quiet", `${rev}^{commit}`]).catch(() => "")).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 };
});
}
export async function listTags(dir: string) {
const out = await git(dir, ["for-each-ref", "--format=%(refname:short)", "refs/tags"]);
return out.split("\n").filter(Boolean);
}
/** 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", `${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", `${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", `${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", 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", `${ref}:${filePath}`])).trim());
const tooLarge = size > 2 * 1024 * 1024;
const content = tooLarge ? Buffer.alloc(0) : await gitBuffer(dir, ["cat-file", "blob", `${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}`,
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", 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", 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}`, 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", base, 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";
export function spawnService(
service: GitService,
dir: string,
opts: { advertise?: boolean; protocol?: string | null } = {},
) {
const args = [service.slice(4), "--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 } : {}) },
});
}