Minimale git-remote met publiek/prive-toggle
Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.
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<boolean> {
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<string> {
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<Map<string, string>> {
const out = await gitText(dir, ["for-each-ref", "--format=%(refname) %(objectname)", prefix]);
const map = new Map<string, string>();
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<void> {
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<void> {
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<string[]> {
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<string | null> {
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 `<branch>/<pad>`, 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<CommitSummary | null> {
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<TreeEntry[]> {
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<number> {
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<Buffer> {
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);
}