spb/spbgit Public MIT
SPB Git — the platform hosting itself
JavaScript 73.9%
CSS 11.7%
Nunjucks 11.6%
Shell 2.7%
1/**2 * ─────────────────────────────────────────────3 * SPB Git — Personal Git Platform4 * ─────────────────────────────────────────────5 * Author : Simon-Pierre Boucher6 * Contact : contact@spboucher.ai7 * File : src/git/hooks.mjs8 * Purpose : post-receive hook installer + localhost-only hook endpoint9 * License : MIT © Simon-Pierre Boucher10 * ─────────────────────────────────────────────11 */1213import { writeFileSync, chmodSync, mkdirSync } from 'node:fs';14import { join } from 'node:path';1516const LOCALHOST = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);17const ZERO_SHA = /^0+$/;1819/**20 * Render the post-receive hook script for a given server port.21 * @param {number} port22 * @returns {string}23 */24export function hookScript(port) {25 return `#!/bin/sh26# ─────────────────────────────────────────────27# SPB Git — Personal Git Platform28# ─────────────────────────────────────────────29# Author : Simon-Pierre Boucher30# Contact : contact@spboucher.ai31# File : hooks/post-receive (installed by SPB Git)32# Purpose : Notify the server of pushes (cache bust + activity feed)33# License : MIT © Simon-Pierre Boucher34# ─────────────────────────────────────────────35input="$(cat)"36repo="$(basename "$PWD" .git)"37printf '%s' "$input" | curl -s -m 15 -X POST \\38 -H 'Content-Type: text/plain' \\39 --data-binary @- \\40 "http://127.0.0.1:${port}/internal/hooks/post-receive?repo=$repo" \\41 >/dev/null 2>&1 || true42exit 043`;44}4546/**47 * Install (or refresh) the post-receive hook in one bare repo. Idempotent.48 * @param {string} repoDir absolute bare repo path49 * @param {number} port server port the hook reports to50 */51export function installHooks(repoDir, port) {52 const hooksDir = join(repoDir, 'hooks');53 mkdirSync(hooksDir, { recursive: true });54 const path = join(hooksDir, 'post-receive');55 writeFileSync(path, hookScript(port));56 chmodSync(path, 0o755);57}5859/**60 * Refresh hooks in every existing repo (run at boot so port changes stick).61 * @param {{repos: object, config: object}} ctx62 */63export function ensureHooksInstalled(ctx) {64 for (const name of ctx.repos.list()) {65 installHooks(ctx.repos.dir(name), ctx.config.port);66 }67}6869/**70 * Register the localhost-only internal hook endpoint.71 * @param {import('fastify').FastifyInstance} app72 * @param {{config, repos, cache, meta, activity, warmers}} ctx73 * `warmers` is an async fn (repo) => void that re-primes caches after a push.74 */75export function registerHookRoutes(app, ctx) {76 app.post('/internal/hooks/post-receive', async (request, reply) => {77 if (!LOCALHOST.has(request.socket.remoteAddress)) {78 return reply.code(403).send({ error: { code: 'forbidden', message: 'internal endpoint' } });79 }80 const repo = String(request.query.repo ?? '');81 if (!ctx.repos.exists(repo)) {82 return reply.code(404).send({ error: { code: 'not_found', message: 'unknown repository' } });83 }8485 const body = typeof request.body === 'string' ? request.body : '';86 const updates = body87 .split('\n')88 .map((l) => l.trim())89 .filter(Boolean)90 .map((line) => {91 const [oldSha, newSha, ref] = line.split(/\s+/);92 return { oldSha, newSha, ref };93 })94 .filter((u) => u.oldSha && u.newSha && u.ref);9596 ctx.cache.bustRepo(repo);9798 for (const update of updates) {99 let commits = 0;100 if (ZERO_SHA.test(update.newSha)) {101 // branch deletion — record nothing countable102 } else if (ZERO_SHA.test(update.oldSha)) {103 const out = await ctx.repos.tryGit(repo, ['rev-list', '--count', update.newSha]);104 commits = out ? Number(out.trim()) : 0;105 } else {106 const out = await ctx.repos.tryGit(repo, ['rev-list', '--count', `${update.oldSha}..${update.newSha}`]);107 commits = out ? Number(out.trim()) : 0;108 }109 ctx.activity.append({110 repo,111 ref: update.ref.replace(/^refs\/(heads|tags)\//, ''),112 refType: update.ref.startsWith('refs/tags/') ? 'tag' : 'branch',113 deleted: ZERO_SHA.test(update.newSha),114 commits,115 sha: ZERO_SHA.test(update.newSha) ? null : update.newSha,116 });117 }118119 // Warm caches in the background — the pushing client should not wait.120 queueMicrotask(() => {121 Promise.resolve(ctx.warmers?.(repo)).catch((err) => {122 request.log.warn({ err, repo }, 'cache warm failed');123 });124 });125126 return { ok: true, updates: updates.length };127 });128}129