import { execFile } from "node:child_process"; import fs from "node:fs/promises"; import path from "node:path"; import { promisify } from "node:util"; import { config } from "./config.ts"; const execFileAsync = promisify(execFile); /** * In jouw eigen repository houden we per branch bij welke momentopname er * gepubliceerd is. Die commit heeft geen ouders en wordt naar een losse bare * repository geduwd; alleen die tweede repository wordt anoniem geserveerd. * * Afschermen binnen één repository werkt niet: refs verbergen (via namespaces of * `transfer.hideRefs`) houdt objecten alleen uit de advertentie, maar een client * kan ze daarna alsnog per object-id opvragen. Zie gitnamespaces(7). */ const PUBLISHED_PREFIX = "refs/published/"; const BRANCH_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._\/-]*$/; export interface TreeEntry { mode: string; type: "blob" | "tree" | "commit"; oid: string; size: number | null; name: string; } export class GitError extends Error {} async function exists(target: string): Promise { try { await fs.stat(target); return true; } catch { return false; } } export function isValidBranch(name: string): boolean { if (!BRANCH_PATTERN.test(name)) return false; if (name.includes("..")) return false; if (name.endsWith("/") || name.endsWith(".lock")) return false; return true; } export function normalizePath(input: string): string { const segments = input .split("/") .filter((segment) => segment.length > 0 && segment !== "." && segment !== ".."); return segments.join("/"); } async function git( dir: string, args: string[], options: { maxBuffer?: number; env?: NodeJS.ProcessEnv } = {}, ) { try { return await execFileAsync("git", ["-C", dir, ...args], { maxBuffer: options.maxBuffer ?? 8 * 1024 * 1024, encoding: "buffer", windowsHide: true, ...(options.env ? { env: { ...process.env, ...options.env } } : {}), }); } catch (error) { const stderr = (error as { stderr?: Buffer }).stderr?.toString("utf8") ?? ""; throw new GitError(stderr.trim() || `git ${args[0]} mislukt`); } } async function gitText(dir: string, args: string[]): Promise { const { stdout } = await git(dir, args); return stdout.toString("utf8"); } // --- publiceren --------------------------------------------------------------- /** * Engels, in tegenstelling tot de rest van dit project: deze tekst staat straks in * de repository van iedereen die je code cloont, niet op jouw site. */ const SNAPSHOT_MESSAGE = (branch: string) => `Snapshot of ${branch}\n\n` + `This repository is published without its history. This commit has no parents\n` + `and contains the full contents as currently published.\n`; async function refMap(dir: string, prefix: string): Promise> { const out = await gitText(dir, ["for-each-ref", "--format=%(refname) %(objectname)", prefix]); const map = new Map(); for (const line of out.split("\n")) { if (!line) continue; const space = line.indexOf(" "); if (space === -1) continue; map.set(line.slice(0, space), line.slice(space + 1)); } return map; } /** * Bouwt de publieke repository opnieuw op uit `refs/heads/` van je eigen * repository: per branch één commit zonder ouders met exact dezelfde boom, die * naar `publishedDir` geduwd wordt. Omdat die commit geen ouders heeft, gaan * alleen de boom en de bestanden mee — de geschiedenis komt er nooit in. * * Draait na elke push, en bij het starten voor repositories die nog geen * momentopname hebben. */ export async function publishSnapshot(dir: string, publishedDir: string): Promise { await fs.mkdir(path.dirname(publishedDir), { recursive: true }); if (!(await exists(path.join(publishedDir, "HEAD")))) { await execFileAsync("git", ["init", "--bare", "--initial-branch=main", publishedDir]); } const heads = await refMap(dir, "refs/heads"); const alreadyPublished = await refMap(dir, PUBLISHED_PREFIX); const refspecs: string[] = []; for (const [refName, tip] of heads) { const branch = refName.slice("refs/heads/".length); if (!isValidBranch(branch)) continue; const marker = `${PUBLISHED_PREFIX}${branch}`; const tree = (await gitText(dir, ["rev-parse", `${tip}^{tree}`])).trim(); const message = SNAPSHOT_MESSAGE(branch); // Ongewijzigde inhoud levert geen nieuwe momentopname op, zodat de gepubliceerde // commit-id stabiel blijft als er niets veranderd is. Auteur en boodschap horen // bij die vergelijking: verandert het formaat, dan moeten bestaande // momentopnames opnieuw gemaakt worden zonder dat iemand dat met de hand doet. const existing = alreadyPublished.get(marker); let reusable = false; if (existing) { const [existingTree, existingEmail, existingMessage] = ( await gitText(dir, ["show", "-s", "--format=%T%x1f%ae%x1f%B", existing]) ).split("\x1f"); reusable = existingTree?.trim() === tree && existingEmail === config.snapshotAuthor.email && existingMessage === message; } if (!reusable) { const date = (await gitText(dir, ["show", "-s", "--format=%cI", tip])).trim(); const { stdout } = await git(dir, ["commit-tree", tree, "-m", message], { env: { GIT_AUTHOR_NAME: config.snapshotAuthor.name, GIT_AUTHOR_EMAIL: config.snapshotAuthor.email, GIT_COMMITTER_NAME: config.snapshotAuthor.name, GIT_COMMITTER_EMAIL: config.snapshotAuthor.email, GIT_AUTHOR_DATE: date, GIT_COMMITTER_DATE: date, }, }); await git(dir, ["update-ref", marker, stdout.toString("utf8").trim()]); } refspecs.push(`+${marker}:refs/heads/${branch}`); } // Branches die je verwijderd hebt, verdwijnen ook uit de publieke repository. for (const marker of alreadyPublished.keys()) { const branch = marker.slice(PUBLISHED_PREFIX.length); if (!heads.has(`refs/heads/${branch}`)) { await git(dir, ["update-ref", "-d", marker]); } } for (const refName of (await refMap(publishedDir, "refs/heads")).keys()) { const branch = refName.slice("refs/heads/".length); if (!heads.has(`refs/heads/${branch}`)) refspecs.push(`:refs/heads/${branch}`); } if (refspecs.length > 0) { await git(dir, ["push", "--force", publishedDir, ...refspecs]); } await syncPublishedHead(dir, publishedDir); // Oude momentopnames zijn ook geschiedenis. Ze zijn na de force-push // onbereikbaar, maar blijven per object-id opvraagbaar tot ze echt weg zijn. await git(publishedDir, ["reflog", "expire", "--expire=now", "--all"]).catch(() => undefined); await git(publishedDir, ["gc", "--prune=now", "--quiet"]).catch(() => undefined); } /** HEAD van de publieke repository bepaalt welke branch een clone uitcheckt. */ async function syncPublishedHead(dir: string, publishedDir: string): Promise { const branches = await listPublishedBranches(publishedDir); if (branches.length === 0) return; let head = "main"; try { head = (await gitText(dir, ["symbolic-ref", "--short", "HEAD"])).trim() || "main"; } catch { /* verse repo zonder HEAD-symref */ } const branch = branches.includes(head) ? head : branches[0]!; await git(publishedDir, ["symbolic-ref", "HEAD", `refs/heads/${branch}`]); } // --- lezen voor de webweergave ------------------------------------------------ /** * De blader-UI leest uitsluitend uit de gepubliceerde repository, ook als jij zelf * ingelogd bent. Zo kan er via het web niets uit de geschiedenis komen, en zie je * precies wat een bezoeker ziet. */ export async function listPublishedBranches(publishedDir: string): Promise { try { const out = await gitText(publishedDir, ["for-each-ref", "--format=%(refname)", "refs/heads"]); return out .split("\n") .filter(Boolean) .map((refName) => refName.slice("refs/heads/".length)) .sort(); } catch { // Nog niets gepubliceerd: de repository bestaat wel, maar is leeg. return []; } } export async function publishedHeadBranch(publishedDir: string): Promise { const branches = await listPublishedBranches(publishedDir); if (branches.length === 0) return null; try { const branch = (await gitText(publishedDir, ["symbolic-ref", "--short", "HEAD"])).trim(); if (branches.includes(branch)) return branch; } catch { /* geen bruikbare HEAD */ } return branches[0]!; } /** * Bouwt de revspec zelf op uit een branch die aantoonbaar bestaat. Een bezoeker * kan dus geen willekeurige commit-sha opvragen — losse objecten uit de * geschiedenis zijn via het web onbereikbaar. */ function revspec(branch: string, filePath: string): string { return `refs/heads/${branch}:${filePath}`; } /** * `rest` uit de URL is `/`, en een branchnaam mag zelf schuine * strepen bevatten. We matchen daarom op de langst passende gepubliceerde branch; * matcht er geen, dan bestaat de pagina niet. */ export async function splitBranchAndPath( publishedDir: string, rest: string, ): Promise<{ branch: string; filePath: string } | null> { const cleaned = rest.replace(/^\/+/, ""); const branches = (await listPublishedBranches(publishedDir)).sort((a, b) => b.length - a.length); for (const branch of branches) { if (cleaned === branch) return { branch, filePath: "" }; if (cleaned.startsWith(`${branch}/`)) { return { branch, filePath: normalizePath(cleaned.slice(branch.length + 1)) }; } } return null; } export interface CommitSummary { date: string; } export async function snapshotDate(publishedDir: string, branch: string): Promise { if (!isValidBranch(branch)) return null; try { const out = await gitText(publishedDir, [ "show", "-s", "--format=%cI", `refs/heads/${branch}`, ]); return { date: out.trim() }; } catch { return null; } } export async function objectType( publishedDir: string, branch: string, filePath: string, ): Promise<"blob" | "tree" | null> { if (!isValidBranch(branch)) return null; try { const out = (await gitText(publishedDir, ["cat-file", "-t", revspec(branch, filePath)])).trim(); if (out === "blob" || out === "tree") return out; return null; } catch { return null; } } export async function listTree( publishedDir: string, branch: string, filePath: string, ): Promise { if (!isValidBranch(branch)) throw new GitError("Ongeldige branch"); const { stdout } = await git(publishedDir, ["ls-tree", "-l", "-z", revspec(branch, filePath)]); const entries: TreeEntry[] = []; for (const record of stdout.toString("utf8").split("\0")) { if (!record) continue; const tab = record.indexOf("\t"); if (tab === -1) continue; const fields = record.slice(0, tab).split(/\s+/); const [mode, type, oid, size] = fields; if (!mode || !type || !oid) continue; entries.push({ mode, type: type as TreeEntry["type"], oid, size: size && size !== "-" ? Number.parseInt(size, 10) : null, name: record.slice(tab + 1), }); } // Mappen eerst, daarna bestanden, elk alfabetisch. return entries.sort((a, b) => { const aDir = a.type === "tree" ? 0 : 1; const bDir = b.type === "tree" ? 0 : 1; if (aDir !== bDir) return aDir - bDir; return a.name.localeCompare(b.name); }); } export async function blobSize(publishedDir: string, branch: string, filePath: string): Promise { if (!isValidBranch(branch)) throw new GitError("Ongeldige branch"); const out = await gitText(publishedDir, ["cat-file", "-s", revspec(branch, filePath)]); return Number.parseInt(out.trim(), 10); } export async function readBlob( publishedDir: string, branch: string, filePath: string, maxBytes: number, ): Promise { if (!isValidBranch(branch)) throw new GitError("Ongeldige branch"); const { stdout } = await git(publishedDir, ["cat-file", "blob", revspec(branch, filePath)], { maxBuffer: maxBytes + 1, }); return stdout; } /** Heuristiek van git zelf: een NUL-byte in het begin betekent binair. */ export function looksBinary(buffer: Buffer): boolean { const window = buffer.subarray(0, 8000); return window.includes(0); }