import { execFileSync, spawn } from 'node:child_process'; import { chmodSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; import { createServer, type Server } from 'node:http'; import type { AddressInfo } from 'node:net'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; export interface GitServer { url: string; bare: string; requests: { method: string; path: string; authorized: boolean; status: number; userAgent?: string }[]; git(...args: string[]): string; close(): Promise; } const gitEnv = { ...process.env, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_NOSYSTEM: '1', GIT_AUTHOR_NAME: 'Seed', GIT_AUTHOR_EMAIL: 'seed@example.com', GIT_AUTHOR_DATE: '2026-01-01T00:00:00Z', GIT_COMMITTER_NAME: 'Seed', GIT_COMMITTER_EMAIL: 'seed@example.com', GIT_COMMITTER_DATE: '2026-01-01T00:00:00Z', }; function run(args: string[], cwd?: string): string { return execFileSync('git', args, { cwd, env: gitEnv, encoding: 'utf8', timeout: 20_000 }).trim(); } /** * `git http-backend` over a bare repo at `/`, seeded with one commit of `seed`. * Pushes need HTTP Basic auth whose password is `password`; fetches are anonymous, like a knot. */ export async function startGitServer({ name, seed, executable = [], symlinks = {}, branches = {}, password }: { name: string; /** Files by path. Names are committed byte for byte, so an NFD name stays NFD even on macOS. */ seed: Record; executable?: string[]; /** Symlinks by path, to their targets. */ symlinks?: Record; /** Extra branches off `main`, each adding or replacing files by path on top of `seed`. */ branches?: Record>; password: string; }): Promise { const root = mkdtempSync(join(tmpdir(), 'nib-tangled-')); const bare = join(root, name); const work = join(root, 'work'); run(['init', '-q', '--bare', '-b', 'main', bare]); run(['config', 'http.receivepack', 'true'], bare); run(['init', '-q', '-b', 'main', work]); run(['config', 'core.precomposeunicode', 'false'], work); for (const [path, text] of Object.entries(seed)) { mkdirSync(dirname(join(work, path)), { recursive: true }); writeFileSync(join(work, path), text); } for (const path of executable) chmodSync(join(work, path), 0o755); for (const [path, target] of Object.entries(symlinks)) { mkdirSync(dirname(join(work, path)), { recursive: true }); symlinkSync(target, join(work, path)); } run(['add', '-A'], work); run(['commit', '-q', '-m', 'seed'], work); run(['push', '-q', bare, 'main'], work); for (const [branch, files] of Object.entries(branches)) { run(['checkout', '-q', '-b', branch, 'main'], work); for (const [path, text] of Object.entries(files)) { mkdirSync(dirname(join(work, path)), { recursive: true }); writeFileSync(join(work, path), text); } run(['add', '-A'], work); run(['commit', '-q', '-m', `seed ${branch}`], work); run(['push', '-q', bare, branch], work); run(['checkout', '-q', 'main'], work); } rmSync(work, { recursive: true, force: true }); const requests: GitServer['requests'] = []; const server: Server = createServer((req, res) => { const url = new URL(req.url!, 'http://localhost'); const pushing = url.searchParams.get('service') === 'git-receive-pack' || url.pathname.endsWith('/git-receive-pack'); const header = req.headers.authorization ?? ''; const given = header.startsWith('Basic ') ? Buffer.from(header.slice(6), 'base64').toString().split(':').slice(1).join(':') : undefined; const authorized = given === password; const log = (status: number) => requests.push({ method: req.method!, path: url.pathname + url.search, authorized, status, userAgent: req.headers['user-agent'] }); if (pushing && !authorized) { log(401); res.writeHead(401, { 'www-authenticate': 'Basic realm="knot"', 'content-type': 'text/plain' }); res.end('AuthenticationRequired'); return; } const cgi = spawn('git', ['http-backend'], { env: { ...gitEnv, GIT_PROJECT_ROOT: root, GIT_HTTP_EXPORT_ALL: '1', PATH_INFO: decodeURIComponent(url.pathname), QUERY_STRING: url.search.slice(1), REQUEST_METHOD: req.method!, CONTENT_TYPE: req.headers['content-type'] ?? '', REMOTE_USER: pushing ? 'pusher' : '', REMOTE_ADDR: '127.0.0.1', GATEWAY_INTERFACE: 'CGI/1.1', }, }); req.pipe(cgi.stdin); const chunks: Buffer[] = []; cgi.stdout.on('data', (chunk: Buffer) => chunks.push(chunk)); cgi.on('close', () => { const out = Buffer.concat(chunks); const split = out.indexOf('\r\n\r\n'); const head = out.subarray(0, split).toString().split('\r\n'); const headers: Record = {}; let status = 200; for (const line of head) { const i = line.indexOf(':'); const key = line.slice(0, i).toLowerCase(); const value = line.slice(i + 1).trim(); if (key === 'status') status = Number.parseInt(value, 10); else headers[key] = value; } log(status); res.writeHead(status, headers); res.end(out.subarray(split + 4)); }); }); await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); const { port } = server.address() as AddressInfo; return { url: `http://127.0.0.1:${port}/${name}`, bare, requests, git: (...args) => run(['--git-dir', bare, ...args]), async close() { server.closeAllConnections(); await new Promise((resolve) => server.close(() => resolve())); rmSync(root, { recursive: true, force: true }); }, }; }