/** * ───────────────────────────────────────────── * SPB Git — Personal Git Platform * ───────────────────────────────────────────── * Author : Simon-Pierre Boucher * Contact : contact@spboucher.ai * File : src/git/hooks.mjs * Purpose : post-receive hook installer + localhost-only hook endpoint * License : MIT © Simon-Pierre Boucher * ───────────────────────────────────────────── */ import { writeFileSync, chmodSync, mkdirSync } from 'node:fs'; import { join } from 'node:path'; const LOCALHOST = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']); const ZERO_SHA = /^0+$/; /** * Render the post-receive hook script for a given server port. * @param {number} port * @returns {string} */ export function hookScript(port) { return `#!/bin/sh # ───────────────────────────────────────────── # SPB Git — Personal Git Platform # ───────────────────────────────────────────── # Author : Simon-Pierre Boucher # Contact : contact@spboucher.ai # File : hooks/post-receive (installed by SPB Git) # Purpose : Notify the server of pushes (cache bust + activity feed) # License : MIT © Simon-Pierre Boucher # ───────────────────────────────────────────── input="$(cat)" repo="$(basename "$PWD" .git)" printf '%s' "$input" | curl -s -m 15 -X POST \\ -H 'Content-Type: text/plain' \\ --data-binary @- \\ "http://127.0.0.1:${port}/internal/hooks/post-receive?repo=$repo" \\ >/dev/null 2>&1 || true exit 0 `; } /** * Install (or refresh) the post-receive hook in one bare repo. Idempotent. * @param {string} repoDir absolute bare repo path * @param {number} port server port the hook reports to */ export function installHooks(repoDir, port) { const hooksDir = join(repoDir, 'hooks'); mkdirSync(hooksDir, { recursive: true }); const path = join(hooksDir, 'post-receive'); writeFileSync(path, hookScript(port)); chmodSync(path, 0o755); } /** * Refresh hooks in every existing repo (run at boot so port changes stick). * @param {{repos: object, config: object}} ctx */ export function ensureHooksInstalled(ctx) { for (const name of ctx.repos.list()) { installHooks(ctx.repos.dir(name), ctx.config.port); } } /** * Register the localhost-only internal hook endpoint. * @param {import('fastify').FastifyInstance} app * @param {{config, repos, cache, meta, activity, warmers}} ctx * `warmers` is an async fn (repo) => void that re-primes caches after a push. */ export function registerHookRoutes(app, ctx) { app.post('/internal/hooks/post-receive', async (request, reply) => { if (!LOCALHOST.has(request.socket.remoteAddress)) { return reply.code(403).send({ error: { code: 'forbidden', message: 'internal endpoint' } }); } const repo = String(request.query.repo ?? ''); if (!ctx.repos.exists(repo)) { return reply.code(404).send({ error: { code: 'not_found', message: 'unknown repository' } }); } const body = typeof request.body === 'string' ? request.body : ''; const updates = body .split('\n') .map((l) => l.trim()) .filter(Boolean) .map((line) => { const [oldSha, newSha, ref] = line.split(/\s+/); return { oldSha, newSha, ref }; }) .filter((u) => u.oldSha && u.newSha && u.ref); ctx.cache.bustRepo(repo); for (const update of updates) { let commits = 0; if (ZERO_SHA.test(update.newSha)) { // branch deletion — record nothing countable } else if (ZERO_SHA.test(update.oldSha)) { const out = await ctx.repos.tryGit(repo, ['rev-list', '--count', update.newSha]); commits = out ? Number(out.trim()) : 0; } else { const out = await ctx.repos.tryGit(repo, ['rev-list', '--count', `${update.oldSha}..${update.newSha}`]); commits = out ? Number(out.trim()) : 0; } ctx.activity.append({ repo, ref: update.ref.replace(/^refs\/(heads|tags)\//, ''), refType: update.ref.startsWith('refs/tags/') ? 'tag' : 'branch', deleted: ZERO_SHA.test(update.newSha), commits, sha: ZERO_SHA.test(update.newSha) ? null : update.newSha, }); } // Warm caches in the background — the pushing client should not wait. queueMicrotask(() => { Promise.resolve(ctx.warmers?.(repo)).catch((err) => { request.log.warn({ err, repo }, 'cache warm failed'); }); }); return { ok: true, updates: updates.length }; }); }