SPB Git

spb/spbgit Public MIT

SPB Git — the platform hosting itself

JavaScript 73.9% CSS 11.7% Nunjucks 11.6% Shell 2.7%
4.9 KB · 129 lines javascript
Raw Blame History
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