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; input?: string | Buffer; maxBuffer?: number }; function run(dir: string, args: string[], encoding: T, opts: GitOptions) { return new Promise((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((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 { 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 { 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 { 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 { 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 " " 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 } : {}) }, }); }