feat: git core — repo model, smart HTTP, hooks, PAT auth, archives, stats
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Showing 13 changed files with +2,367 and −0
added
src/auth/token.mjs
+165 −0
@@ -0,0 +1,165 @@ | ||
| 1 | +/** | |
| 2 | + * ───────────────────────────────────────────── | |
| 3 | + * SPB Git — Personal Git Platform | |
| 4 | + * ───────────────────────────────────────────── | |
| 5 | + * Author : Simon-Pierre Boucher | |
| 6 | + * Contact : contact@spboucher.ai | |
| 7 | + * File : src/auth/token.mjs | |
| 8 | + * Purpose : Owner Personal Access Tokens — argon2-hashed, revocable | |
| 9 | + * License : MIT © Simon-Pierre Boucher | |
| 10 | + * ───────────────────────────────────────────── | |
| 11 | + */ | |
| 12 | + | |
| 13 | +import { readFileSync, existsSync } from 'node:fs'; | |
| 14 | +import { join } from 'node:path'; | |
| 15 | +import { randomBytes } from 'node:crypto'; | |
| 16 | +import argon2 from 'argon2'; | |
| 17 | +import { atomicWriteJSON } from '../lib/util.mjs'; | |
| 18 | +import { OWNER } from '../config.mjs'; | |
| 19 | + | |
| 20 | +const TOKENS_FILE = 'tokens.json'; | |
| 21 | +const TOKEN_PREFIX = 'spbgit'; | |
| 22 | +const TOKEN_RE = /^spbgit_([0-9a-f]{8})_([0-9a-f]{40})$/; | |
| 23 | + | |
| 24 | +/** | |
| 25 | + * PAT store backed by `data/tokens.json`. | |
| 26 | + * Token string format: `spbgit_<id:8hex>_<secret:40hex>` — only the argon2 | |
| 27 | + * hash of the secret is ever written to disk. | |
| 28 | + */ | |
| 29 | +export class TokenStore { | |
| 30 | + /** @param {string} dataDir */ | |
| 31 | + constructor(dataDir) { | |
| 32 | + this.path = join(dataDir, TOKENS_FILE); | |
| 33 | + } | |
| 34 | + | |
| 35 | + /** @returns {{tokens: object[]}} */ | |
| 36 | + #load() { | |
| 37 | + if (!existsSync(this.path)) return { tokens: [] }; | |
| 38 | + try { | |
| 39 | + const parsed = JSON.parse(readFileSync(this.path, 'utf8')); | |
| 40 | + if (parsed && Array.isArray(parsed.tokens)) return parsed; | |
| 41 | + } catch { | |
| 42 | + /* corrupted token file treated as empty (locked out until new bootstrap) */ | |
| 43 | + } | |
| 44 | + return { tokens: [] }; | |
| 45 | + } | |
| 46 | + | |
| 47 | + #save(db) { | |
| 48 | + atomicWriteJSON(this.path, db); | |
| 49 | + } | |
| 50 | + | |
| 51 | + /** | |
| 52 | + * Mint a new PAT. The full token string is returned exactly once. | |
| 53 | + * @param {string} [label] | |
| 54 | + * @returns {Promise<{token: string, record: object}>} | |
| 55 | + */ | |
| 56 | + async create(label = 'unnamed') { | |
| 57 | + const id = randomBytes(4).toString('hex'); | |
| 58 | + const secret = randomBytes(20).toString('hex'); | |
| 59 | + const hash = await argon2.hash(secret, { type: argon2.argon2id }); | |
| 60 | + const record = { | |
| 61 | + id, | |
| 62 | + label: String(label).slice(0, 80), | |
| 63 | + hash, | |
| 64 | + created: new Date().toISOString(), | |
| 65 | + lastUsed: null, | |
| 66 | + }; | |
| 67 | + const db = this.#load(); | |
| 68 | + db.tokens.push(record); | |
| 69 | + this.#save(db); | |
| 70 | + return { token: `${TOKEN_PREFIX}_${id}_${secret}`, record }; | |
| 71 | + } | |
| 72 | + | |
| 73 | + /** | |
| 74 | + * Verify a raw PAT string. Updates `lastUsed` on success. | |
| 75 | + * @param {string} token | |
| 76 | + * @returns {Promise<object|null>} the token record (sans hash) or null | |
| 77 | + */ | |
| 78 | + async verify(token) { | |
| 79 | + const match = TOKEN_RE.exec(String(token ?? '').trim()); | |
| 80 | + if (!match) return null; | |
| 81 | + const [, id, secret] = match; | |
| 82 | + const db = this.#load(); | |
| 83 | + const record = db.tokens.find((t) => t.id === id); | |
| 84 | + if (!record) { | |
| 85 | + // Burn comparable time so unknown ids are indistinguishable from bad secrets. | |
| 86 | + await argon2.verify( | |
| 87 | + '$argon2id$v=19$m=65536,t=3,p=4$AAAAAAAAAAAAAAAAAAAAAA$mM48eTLB1F0hV8H8xToKffdOTM7ZbnDAJGdO+3l2gGA', | |
| 88 | + secret, | |
| 89 | + ).catch(() => false); | |
| 90 | + return null; | |
| 91 | + } | |
| 92 | + const ok = await argon2.verify(record.hash, secret).catch(() => false); | |
| 93 | + if (!ok) return null; | |
| 94 | + record.lastUsed = new Date().toISOString(); | |
| 95 | + this.#save(db); | |
| 96 | + const { hash: _hash, ...safe } = record; | |
| 97 | + return safe; | |
| 98 | + } | |
| 99 | + | |
| 100 | + /** @returns {object[]} records without hashes */ | |
| 101 | + list() { | |
| 102 | + return this.#load().tokens.map(({ hash: _hash, ...safe }) => safe); | |
| 103 | + } | |
| 104 | + | |
| 105 | + /** | |
| 106 | + * @param {string} id | |
| 107 | + * @returns {boolean} true when a token was revoked | |
| 108 | + */ | |
| 109 | + revoke(id) { | |
| 110 | + const db = this.#load(); | |
| 111 | + const before = db.tokens.length; | |
| 112 | + db.tokens = db.tokens.filter((t) => t.id !== id); | |
| 113 | + if (db.tokens.length === before) return false; | |
| 114 | + this.#save(db); | |
| 115 | + return true; | |
| 116 | + } | |
| 117 | +} | |
| 118 | + | |
| 119 | +/** | |
| 120 | + * Extract a PAT from an HTTP request that may use Basic (git client) or | |
| 121 | + * Bearer (API) authentication. | |
| 122 | + * @param {string|undefined} authorization the Authorization header | |
| 123 | + * @returns {string|null} the raw token candidate | |
| 124 | + */ | |
| 125 | +export function extractToken(authorization) { | |
| 126 | + if (!authorization) return null; | |
| 127 | + const [scheme, value] = authorization.split(' ', 2); | |
| 128 | + if (!scheme || !value) return null; | |
| 129 | + if (scheme.toLowerCase() === 'bearer') return value.trim(); | |
| 130 | + if (scheme.toLowerCase() === 'basic') { | |
| 131 | + let decoded; | |
| 132 | + try { | |
| 133 | + decoded = Buffer.from(value, 'base64').toString('utf8'); | |
| 134 | + } catch { | |
| 135 | + return null; | |
| 136 | + } | |
| 137 | + const colon = decoded.indexOf(':'); | |
| 138 | + if (colon === -1) return null; | |
| 139 | + const username = decoded.slice(0, colon); | |
| 140 | + const password = decoded.slice(colon + 1); | |
| 141 | + if (username !== OWNER.username && username !== '') return null; | |
| 142 | + return password; | |
| 143 | + } | |
| 144 | + return null; | |
| 145 | +} | |
| 146 | + | |
| 147 | +/** | |
| 148 | + * Fastify helper — resolve the authenticated owner or reply 401. | |
| 149 | + * @param {TokenStore} tokens | |
| 150 | + * @returns {(request: any, reply: any) => Promise<object|null>} | |
| 151 | + */ | |
| 152 | +export function makeRequireAuth(tokens) { | |
| 153 | + return async function requireAuth(request, reply) { | |
| 154 | + const candidate = extractToken(request.headers.authorization); | |
| 155 | + const record = candidate ? await tokens.verify(candidate) : null; | |
| 156 | + if (!record) { | |
| 157 | + reply | |
| 158 | + .code(401) | |
| 159 | + .header('WWW-Authenticate', 'Basic realm="SPB Git", charset="UTF-8"') | |
| 160 | + .send({ error: { code: 'unauthorized', message: 'A valid personal access token is required.' } }); | |
| 161 | + return null; | |
| 162 | + } | |
| 163 | + return record; | |
| 164 | + }; | |
| 165 | +} | |
added
src/config.mjs
+115 −0
@@ -0,0 +1,115 @@ | ||
| 1 | +/** | |
| 2 | + * ───────────────────────────────────────────── | |
| 3 | + * SPB Git — Personal Git Platform | |
| 4 | + * ───────────────────────────────────────────── | |
| 5 | + * Author : Simon-Pierre Boucher | |
| 6 | + * Contact : contact@spboucher.ai | |
| 7 | + * File : src/config.mjs | |
| 8 | + * Purpose : Environment loading + zod-validated configuration | |
| 9 | + * License : MIT © Simon-Pierre Boucher | |
| 10 | + * ───────────────────────────────────────────── | |
| 11 | + */ | |
| 12 | + | |
| 13 | +import { readFileSync, mkdirSync, existsSync } from 'node:fs'; | |
| 14 | +import { resolve, join, dirname } from 'node:path'; | |
| 15 | +import { fileURLToPath } from 'node:url'; | |
| 16 | +import process from 'node:process'; | |
| 17 | +import { z } from 'zod'; | |
| 18 | + | |
| 19 | +export const PROJECT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); | |
| 20 | + | |
| 21 | +/** The sole owner of this platform. Hard-coded on purpose — see CLAUDE.md §0. */ | |
| 22 | +export const OWNER = Object.freeze({ | |
| 23 | + name: 'Simon-Pierre Boucher', | |
| 24 | + email: 'contact@spboucher.ai', | |
| 25 | + username: 'spb', | |
| 26 | + site: 'https://spboucher.ai', | |
| 27 | + tagline: 'Builder of models, clusters, and the tools that run them.', | |
| 28 | +}); | |
| 29 | + | |
| 30 | +const schema = z.object({ | |
| 31 | + SPBGIT_PORT: z.coerce.number().int().min(1).max(65535).default(7420), | |
| 32 | + SPBGIT_HOST: z.string().min(1).default('127.0.0.1'), | |
| 33 | + SPBGIT_PUBLIC_URL: z.string().url().default('https://git.spboucher.ai'), | |
| 34 | + SPBGIT_GIT_ROOT: z.string().min(1).default('/srv/git'), | |
| 35 | + SPBGIT_DATA_DIR: z.string().min(1).default('/srv/spbgit/data'), | |
| 36 | + SPBGIT_CACHE_DIR: z.string().min(1).default('/srv/spbgit/cache'), | |
| 37 | + SPBGIT_LOG_LEVEL: z.enum(['trace', 'debug', 'info', 'warn', 'error', 'fatal']).default('info'), | |
| 38 | + SPBGIT_ENV: z.enum(['development', 'production', 'test']).default('production'), | |
| 39 | +}); | |
| 40 | + | |
| 41 | +/** | |
| 42 | + * Parse a `.env`-style file into key/value pairs. Tiny on purpose — no dep. | |
| 43 | + * @param {string} path | |
| 44 | + * @returns {Record<string, string>} | |
| 45 | + */ | |
| 46 | +function parseEnvFile(path) { | |
| 47 | + const out = {}; | |
| 48 | + let raw; | |
| 49 | + try { | |
| 50 | + raw = readFileSync(path, 'utf8'); | |
| 51 | + } catch { | |
| 52 | + return out; | |
| 53 | + } | |
| 54 | + for (const line of raw.split('\n')) { | |
| 55 | + const trimmed = line.trim(); | |
| 56 | + if (!trimmed || trimmed.startsWith('#')) continue; | |
| 57 | + const eq = trimmed.indexOf('='); | |
| 58 | + if (eq === -1) continue; | |
| 59 | + const key = trimmed.slice(0, eq).trim(); | |
| 60 | + let value = trimmed.slice(eq + 1).trim(); | |
| 61 | + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { | |
| 62 | + value = value.slice(1, -1); | |
| 63 | + } | |
| 64 | + out[key] = value; | |
| 65 | + } | |
| 66 | + return out; | |
| 67 | +} | |
| 68 | + | |
| 69 | +/** | |
| 70 | + * Load, validate and freeze the runtime configuration. | |
| 71 | + * Values in the real environment win over `.env` file values. | |
| 72 | + * @param {Record<string, string|undefined>} [overrides] test-only overrides (highest precedence) | |
| 73 | + * @returns {Readonly<object>} resolved config | |
| 74 | + */ | |
| 75 | +export function loadConfig(overrides = {}) { | |
| 76 | + const fileEnv = parseEnvFile(join(PROJECT_ROOT, '.env')); | |
| 77 | + const merged = { ...fileEnv, ...process.env, ...overrides }; | |
| 78 | + const parsed = schema.safeParse(merged); | |
| 79 | + if (!parsed.success) { | |
| 80 | + const issues = parsed.error.issues.map((i) => ` ${i.path.join('.')}: ${i.message}`).join('\n'); | |
| 81 | + throw new Error(`Invalid configuration:\n${issues}`); | |
| 82 | + } | |
| 83 | + const env = parsed.data; | |
| 84 | + const abs = (p) => resolve(PROJECT_ROOT, p); | |
| 85 | + const config = Object.freeze({ | |
| 86 | + env: env.SPBGIT_ENV, | |
| 87 | + isDev: env.SPBGIT_ENV === 'development', | |
| 88 | + port: env.SPBGIT_PORT, | |
| 89 | + host: env.SPBGIT_HOST, | |
| 90 | + publicUrl: env.SPBGIT_PUBLIC_URL.replace(/\/+$/, ''), | |
| 91 | + logLevel: env.SPBGIT_LOG_LEVEL, | |
| 92 | + gitRoot: abs(env.SPBGIT_GIT_ROOT), | |
| 93 | + trashDir: join(abs(env.SPBGIT_GIT_ROOT), '.trash'), | |
| 94 | + dataDir: abs(env.SPBGIT_DATA_DIR), | |
| 95 | + cacheDir: abs(env.SPBGIT_CACHE_DIR), | |
| 96 | + owner: OWNER, | |
| 97 | + }); | |
| 98 | + return config; | |
| 99 | +} | |
| 100 | + | |
| 101 | +/** | |
| 102 | + * Create every directory the platform needs. Idempotent, fails loudly. | |
| 103 | + * @param {ReturnType<typeof loadConfig>} config | |
| 104 | + */ | |
| 105 | +export function ensureDirs(config) { | |
| 106 | + for (const dir of [config.gitRoot, config.trashDir, config.dataDir, config.cacheDir]) { | |
| 107 | + if (!existsSync(dir)) { | |
| 108 | + try { | |
| 109 | + mkdirSync(dir, { recursive: true }); | |
| 110 | + } catch (err) { | |
| 111 | + throw new Error(`Cannot create required directory ${dir}: ${err.message}`); | |
| 112 | + } | |
| 113 | + } | |
| 114 | + } | |
| 115 | +} | |
added
src/git/archive.mjs
+71 −0
@@ -0,0 +1,71 @@ | ||
| 1 | +/** | |
| 2 | + * ───────────────────────────────────────────── | |
| 3 | + * SPB Git — Personal Git Platform | |
| 4 | + * ───────────────────────────────────────────── | |
| 5 | + * Author : Simon-Pierre Boucher | |
| 6 | + * Contact : contact@spboucher.ai | |
| 7 | + * File : src/git/archive.mjs | |
| 8 | + * Purpose : zip / tar.gz snapshot generation with per-sha caching | |
| 9 | + * License : MIT © Simon-Pierre Boucher | |
| 10 | + * ───────────────────────────────────────────── | |
| 11 | + */ | |
| 12 | + | |
| 13 | +import { spawn } from 'node:child_process'; | |
| 14 | +import { createWriteStream, createReadStream, existsSync, renameSync, statSync, rmSync } from 'node:fs'; | |
| 15 | +import { mkdirSync } from 'node:fs'; | |
| 16 | +import { dirname } from 'node:path'; | |
| 17 | +import { randomBytes } from 'node:crypto'; | |
| 18 | + | |
| 19 | +export const ARCHIVE_FORMATS = Object.freeze({ | |
| 20 | + zip: { ext: 'zip', mime: 'application/zip', gitFormat: 'zip' }, | |
| 21 | + 'tar.gz': { ext: 'tar.gz', mime: 'application/gzip', gitFormat: 'tar.gz' }, | |
| 22 | +}); | |
| 23 | + | |
| 24 | +/** | |
| 25 | + * Produce (or reuse) a snapshot archive for `<repo>@<sha>` and stream it. | |
| 26 | + * @param {{repos: object, cache: object}} ctx | |
| 27 | + * @param {string} repo | |
| 28 | + * @param {string} sha resolved commit sha | |
| 29 | + * @param {'zip'|'tar.gz'} format | |
| 30 | + * @param {import('fastify').FastifyReply} reply | |
| 31 | + * @param {string} downloadName filename presented to the client (no extension) | |
| 32 | + */ | |
| 33 | +export async function sendArchive(ctx, repo, sha, format, reply, downloadName) { | |
| 34 | + const spec = ARCHIVE_FORMATS[format]; | |
| 35 | + if (!spec) { | |
| 36 | + return reply.code(400).send({ error: { code: 'bad_format', message: 'zip or tar.gz only' } }); | |
| 37 | + } | |
| 38 | + const cachePath = ctx.cache.repoPath(repo, sha, `archive.${spec.ext}`); | |
| 39 | + | |
| 40 | + if (!existsSync(cachePath)) { | |
| 41 | + mkdirSync(dirname(cachePath), { recursive: true }); | |
| 42 | + const tmp = `${cachePath}.${randomBytes(4).toString('hex')}.tmp`; | |
| 43 | + const ok = await new Promise((resolve) => { | |
| 44 | + const child = spawn( | |
| 45 | + 'git', | |
| 46 | + ['archive', `--format=${spec.gitFormat}`, `--prefix=${downloadName}/`, sha], | |
| 47 | + { cwd: ctx.repos.dir(repo) }, | |
| 48 | + ); | |
| 49 | + const out = createWriteStream(tmp); | |
| 50 | + child.stdout.pipe(out); | |
| 51 | + child.on('error', () => resolve(false)); | |
| 52 | + child.on('close', (code) => { | |
| 53 | + out.close(() => resolve(code === 0)); | |
| 54 | + }); | |
| 55 | + }); | |
| 56 | + if (!ok) { | |
| 57 | + rmSync(tmp, { force: true }); | |
| 58 | + return reply.code(500).send({ error: { code: 'archive_failed', message: 'could not create archive' } }); | |
| 59 | + } | |
| 60 | + renameSync(tmp, cachePath); | |
| 61 | + } | |
| 62 | + | |
| 63 | + const size = statSync(cachePath).size; | |
| 64 | + reply | |
| 65 | + .header('Content-Type', spec.mime) | |
| 66 | + .header('Content-Length', size) | |
| 67 | + .header('Content-Disposition', `attachment; filename="${downloadName}.${spec.ext}"`) | |
| 68 | + .header('Cache-Control', 'public, max-age=31536000, immutable') | |
| 69 | + .header('X-Content-Type-Options', 'nosniff'); | |
| 70 | + return reply.send(createReadStream(cachePath)); | |
| 71 | +} | |
added
src/git/hooks.mjs
+128 −0
@@ -0,0 +1,128 @@ | ||
| 1 | +/** | |
| 2 | + * ───────────────────────────────────────────── | |
| 3 | + * SPB Git — Personal Git Platform | |
| 4 | + * ───────────────────────────────────────────── | |
| 5 | + * Author : Simon-Pierre Boucher | |
| 6 | + * Contact : contact@spboucher.ai | |
| 7 | + * File : src/git/hooks.mjs | |
| 8 | + * Purpose : post-receive hook installer + localhost-only hook endpoint | |
| 9 | + * License : MIT © Simon-Pierre Boucher | |
| 10 | + * ───────────────────────────────────────────── | |
| 11 | + */ | |
| 12 | + | |
| 13 | +import { writeFileSync, chmodSync, mkdirSync } from 'node:fs'; | |
| 14 | +import { join } from 'node:path'; | |
| 15 | + | |
| 16 | +const LOCALHOST = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']); | |
| 17 | +const ZERO_SHA = /^0+$/; | |
| 18 | + | |
| 19 | +/** | |
| 20 | + * Render the post-receive hook script for a given server port. | |
| 21 | + * @param {number} port | |
| 22 | + * @returns {string} | |
| 23 | + */ | |
| 24 | +export function hookScript(port) { | |
| 25 | + return `#!/bin/sh | |
| 26 | +# ───────────────────────────────────────────── | |
| 27 | +# SPB Git — Personal Git Platform | |
| 28 | +# ───────────────────────────────────────────── | |
| 29 | +# Author : Simon-Pierre Boucher | |
| 30 | +# Contact : contact@spboucher.ai | |
| 31 | +# File : hooks/post-receive (installed by SPB Git) | |
| 32 | +# Purpose : Notify the server of pushes (cache bust + activity feed) | |
| 33 | +# License : MIT © Simon-Pierre Boucher | |
| 34 | +# ───────────────────────────────────────────── | |
| 35 | +input="$(cat)" | |
| 36 | +repo="$(basename "$PWD" .git)" | |
| 37 | +printf '%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 || true | |
| 42 | +exit 0 | |
| 43 | +`; | |
| 44 | +} | |
| 45 | + | |
| 46 | +/** | |
| 47 | + * Install (or refresh) the post-receive hook in one bare repo. Idempotent. | |
| 48 | + * @param {string} repoDir absolute bare repo path | |
| 49 | + * @param {number} port server port the hook reports to | |
| 50 | + */ | |
| 51 | +export 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 | +} | |
| 58 | + | |
| 59 | +/** | |
| 60 | + * Refresh hooks in every existing repo (run at boot so port changes stick). | |
| 61 | + * @param {{repos: object, config: object}} ctx | |
| 62 | + */ | |
| 63 | +export function ensureHooksInstalled(ctx) { | |
| 64 | + for (const name of ctx.repos.list()) { | |
| 65 | + installHooks(ctx.repos.dir(name), ctx.config.port); | |
| 66 | + } | |
| 67 | +} | |
| 68 | + | |
| 69 | +/** | |
| 70 | + * Register the localhost-only internal hook endpoint. | |
| 71 | + * @param {import('fastify').FastifyInstance} app | |
| 72 | + * @param {{config, repos, cache, meta, activity, warmers}} ctx | |
| 73 | + * `warmers` is an async fn (repo) => void that re-primes caches after a push. | |
| 74 | + */ | |
| 75 | +export 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 | + } | |
| 84 | + | |
| 85 | + const body = typeof request.body === 'string' ? request.body : ''; | |
| 86 | + const updates = body | |
| 87 | + .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); | |
| 95 | + | |
| 96 | + ctx.cache.bustRepo(repo); | |
| 97 | + | |
| 98 | + for (const update of updates) { | |
| 99 | + let commits = 0; | |
| 100 | + if (ZERO_SHA.test(update.newSha)) { | |
| 101 | + // branch deletion — record nothing countable | |
| 102 | + } 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 | + } | |
| 118 | + | |
| 119 | + // 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 | + }); | |
| 125 | + | |
| 126 | + return { ok: true, updates: updates.length }; | |
| 127 | + }); | |
| 128 | +} | |
added
src/git/repo.mjs
+787 −0
@@ -0,0 +1,787 @@ | ||
| 1 | +/** | |
| 2 | + * ───────────────────────────────────────────── | |
| 3 | + * SPB Git — Personal Git Platform | |
| 4 | + * ───────────────────────────────────────────── | |
| 5 | + * Author : Simon-Pierre Boucher | |
| 6 | + * Contact : contact@spboucher.ai | |
| 7 | + * File : src/git/repo.mjs | |
| 8 | + * Purpose : Bare-repo model — refs, trees, blobs, log, diffs, blame | |
| 9 | + * License : MIT © Simon-Pierre Boucher | |
| 10 | + * ───────────────────────────────────────────── | |
| 11 | + */ | |
| 12 | + | |
| 13 | +import { execFile, spawn } from 'node:child_process'; | |
| 14 | +import { promisify } from 'node:util'; | |
| 15 | +import { readdirSync, existsSync, statSync, renameSync, mkdirSync } from 'node:fs'; | |
| 16 | +import { join } from 'node:path'; | |
| 17 | +import { isValidRepoName, safeJoin, isValidTreePath } from '../lib/util.mjs'; | |
| 18 | + | |
| 19 | +const execFileAsync = promisify(execFile); | |
| 20 | +const MAX_BUFFER = 64 * 1024 * 1024; | |
| 21 | +const FS = '\x01'; | |
| 22 | +const RS = '\x02'; | |
| 23 | + | |
| 24 | +/** Hard ceiling for diff rendering (lines) before truncation. */ | |
| 25 | +const DIFF_MAX_LINES = 20000; | |
| 26 | +/** Commits walked when computing "last commit per path" before falling back. */ | |
| 27 | +const TREE_LOG_WALK_CAP = 600; | |
| 28 | + | |
| 29 | +/** | |
| 30 | + * Repository model rooted at `gitRoot`. All read operations shell out to the | |
| 31 | + * real `git` binary — parsing porcelain/plumbing output, never buffering | |
| 32 | + * packfiles in memory. | |
| 33 | + */ | |
| 34 | +export class Repos { | |
| 35 | + /** | |
| 36 | + * @param {string} gitRoot directory containing the bare `<name>.git` repos | |
| 37 | + * @param {string} trashDir soft-delete destination | |
| 38 | + */ | |
| 39 | + constructor(gitRoot, trashDir) { | |
| 40 | + this.gitRoot = gitRoot; | |
| 41 | + this.trashDir = trashDir; | |
| 42 | + } | |
| 43 | + | |
| 44 | + /** @param {string} name @returns {string} absolute path of the bare repo */ | |
| 45 | + dir(name) { | |
| 46 | + if (!isValidRepoName(name)) throw new Error(`invalid repo name: ${name}`); | |
| 47 | + return safeJoin(this.gitRoot, `${name}.git`); | |
| 48 | + } | |
| 49 | + | |
| 50 | + /** @param {string} name @returns {boolean} */ | |
| 51 | + exists(name) { | |
| 52 | + if (!isValidRepoName(name)) return false; | |
| 53 | + return existsSync(join(this.dir(name), 'HEAD')); | |
| 54 | + } | |
| 55 | + | |
| 56 | + /** @returns {string[]} sorted repo names found on disk */ | |
| 57 | + list() { | |
| 58 | + let entries; | |
| 59 | + try { | |
| 60 | + entries = readdirSync(this.gitRoot); | |
| 61 | + } catch { | |
| 62 | + return []; | |
| 63 | + } | |
| 64 | + return entries | |
| 65 | + .filter((e) => e.endsWith('.git') && !e.startsWith('.')) | |
| 66 | + .map((e) => e.slice(0, -4)) | |
| 67 | + .filter((name) => isValidRepoName(name) && this.exists(name)) | |
| 68 | + .sort(); | |
| 69 | + } | |
| 70 | + | |
| 71 | + /** | |
| 72 | + * Run git in a repo, returning stdout as a string. | |
| 73 | + * @param {string} name | |
| 74 | + * @param {string[]} args | |
| 75 | + * @returns {Promise<string>} | |
| 76 | + */ | |
| 77 | + async git(name, args) { | |
| 78 | + const { stdout } = await execFileAsync('git', args, { | |
| 79 | + cwd: this.dir(name), | |
| 80 | + maxBuffer: MAX_BUFFER, | |
| 81 | + encoding: 'utf8', | |
| 82 | + }); | |
| 83 | + return stdout; | |
| 84 | + } | |
| 85 | + | |
| 86 | + /** Same as {@link Repos#git} but stdout stays a Buffer (blob content). */ | |
| 87 | + async gitBuffer(name, args) { | |
| 88 | + const { stdout } = await execFileAsync('git', args, { | |
| 89 | + cwd: this.dir(name), | |
| 90 | + maxBuffer: MAX_BUFFER, | |
| 91 | + encoding: 'buffer', | |
| 92 | + }); | |
| 93 | + return stdout; | |
| 94 | + } | |
| 95 | + | |
| 96 | + /** git that returns null instead of throwing (missing ref/path lookups). */ | |
| 97 | + async tryGit(name, args) { | |
| 98 | + try { | |
| 99 | + return await this.git(name, args); | |
| 100 | + } catch { | |
| 101 | + return null; | |
| 102 | + } | |
| 103 | + } | |
| 104 | + | |
| 105 | + /** | |
| 106 | + * Initialize a new bare repository with HEAD on `main`. | |
| 107 | + * @param {string} name | |
| 108 | + * @param {string} [defaultBranch] | |
| 109 | + */ | |
| 110 | + async create(name, defaultBranch = 'main') { | |
| 111 | + if (!isValidRepoName(name)) throw new Error('invalid repo name'); | |
| 112 | + if (this.exists(name)) throw new Error('repo already exists'); | |
| 113 | + const dir = this.dir(name); | |
| 114 | + await execFileAsync('git', ['init', '--bare', '--initial-branch', defaultBranch, dir]); | |
| 115 | + } | |
| 116 | + | |
| 117 | + /** | |
| 118 | + * Soft-delete: move the bare repo into the trash with a timestamp suffix. | |
| 119 | + * @param {string} name | |
| 120 | + * @returns {string} the trash path | |
| 121 | + */ | |
| 122 | + softDelete(name) { | |
| 123 | + const src = this.dir(name); | |
| 124 | + if (!existsSync(src)) throw new Error('repo not found'); | |
| 125 | + mkdirSync(this.trashDir, { recursive: true }); | |
| 126 | + const stamp = new Date().toISOString().replaceAll(/[:.]/g, '-'); | |
| 127 | + const dest = join(this.trashDir, `${name}-${stamp}.git`); | |
| 128 | + renameSync(src, dest); | |
| 129 | + return dest; | |
| 130 | + } | |
| 131 | + | |
| 132 | + /** @returns {Promise<string|null>} sha of HEAD, or null for an empty repo */ | |
| 133 | + async head(name) { | |
| 134 | + const out = await this.tryGit(name, ['rev-parse', '--verify', 'HEAD']); | |
| 135 | + return out ? out.trim() : null; | |
| 136 | + } | |
| 137 | + | |
| 138 | + /** @returns {Promise<string>} short name of the default branch */ | |
| 139 | + async defaultBranch(name) { | |
| 140 | + const out = await this.tryGit(name, ['symbolic-ref', '--short', 'HEAD']); | |
| 141 | + return out ? out.trim() : 'main'; | |
| 142 | + } | |
| 143 | + | |
| 144 | + /** @param {string} branch set HEAD to refs/heads/<branch> */ | |
| 145 | + async setDefaultBranch(name, branch) { | |
| 146 | + await this.git(name, ['symbolic-ref', 'HEAD', `refs/heads/${branch}`]); | |
| 147 | + } | |
| 148 | + | |
| 149 | + /** | |
| 150 | + * Resolve any ref-ish (branch, tag, sha, sha-prefix) to a commit sha. | |
| 151 | + * @returns {Promise<string|null>} | |
| 152 | + */ | |
| 153 | + async resolveRef(name, ref) { | |
| 154 | + if (!/^[\w./@^~-]+$/.test(ref) || ref.startsWith('-')) return null; | |
| 155 | + const out = await this.tryGit(name, ['rev-parse', '--verify', `${ref}^{commit}`]); | |
| 156 | + return out ? out.trim() : null; | |
| 157 | + } | |
| 158 | + | |
| 159 | + /** | |
| 160 | + * @returns {Promise<Array<{name: string, sha: string, date: string, subject: string, isDefault: boolean}>>} | |
| 161 | + */ | |
| 162 | + async branches(name) { | |
| 163 | + const out = await this.tryGit(name, [ | |
| 164 | + 'for-each-ref', '--sort=-committerdate', | |
| 165 | + `--format=%(refname:short)${FS}%(objectname)${FS}%(committerdate:iso-strict)${FS}%(contents:subject)`, | |
| 166 | + 'refs/heads', | |
| 167 | + ]); | |
| 168 | + if (!out) return []; | |
| 169 | + const def = await this.defaultBranch(name); | |
| 170 | + return out | |
| 171 | + .split('\n') | |
| 172 | + .filter(Boolean) | |
| 173 | + .map((line) => { | |
| 174 | + const [branch, sha, date, subject] = line.split(FS); | |
| 175 | + return { name: branch, sha, date, subject: subject ?? '', isDefault: branch === def }; | |
| 176 | + }); | |
| 177 | + } | |
| 178 | + | |
| 179 | + /** | |
| 180 | + * @returns {Promise<Array<{name: string, sha: string, date: string, subject: string}>>} | |
| 181 | + */ | |
| 182 | + async tags(name) { | |
| 183 | + const out = await this.tryGit(name, [ | |
| 184 | + 'for-each-ref', '--sort=-creatordate', | |
| 185 | + `--format=%(refname:short)${FS}%(*objectname)%(objectname)${FS}%(creatordate:iso-strict)${FS}%(contents:subject)`, | |
| 186 | + 'refs/tags', | |
| 187 | + ]); | |
| 188 | + if (!out) return []; | |
| 189 | + return out | |
| 190 | + .split('\n') | |
| 191 | + .filter(Boolean) | |
| 192 | + .map((line) => { | |
| 193 | + const [tag, sha, date, subject] = line.split(FS); | |
| 194 | + return { name: tag, sha: sha.slice(0, 40), date, subject: subject ?? '' }; | |
| 195 | + }); | |
| 196 | + } | |
| 197 | + | |
| 198 | + /** | |
| 199 | + * Given the wildcard part of a URL (`<ref>/<path...>`) figure out which | |
| 200 | + * prefix is the ref — supports branch names containing slashes. | |
| 201 | + * @param {string} name repo | |
| 202 | + * @param {string} splat e.g. `feature/x/src/index.js` | |
| 203 | + * @returns {Promise<{ref: string, sha: string, path: string}|null>} | |
| 204 | + */ | |
| 205 | + async resolveRefAndPath(name, splat) { | |
| 206 | + const clean = String(splat ?? '').replace(/^\/+|\/+$/g, ''); | |
| 207 | + if (clean === '') { | |
| 208 | + const def = await this.defaultBranch(name); | |
| 209 | + const sha = await this.resolveRef(name, def); | |
| 210 | + return sha ? { ref: def, sha, path: '' } : null; | |
| 211 | + } | |
| 212 | + const segments = clean.split('/'); | |
| 213 | + const refNames = [ | |
| 214 | + ...(await this.branches(name)).map((b) => b.name), | |
| 215 | + ...(await this.tags(name)).map((t) => t.name), | |
| 216 | + ]; | |
| 217 | + for (let take = segments.length; take >= 1; take -= 1) { | |
| 218 | + const candidate = segments.slice(0, take).join('/'); | |
| 219 | + if (refNames.includes(candidate)) { | |
| 220 | + const sha = await this.resolveRef(name, candidate); | |
| 221 | + const path = segments.slice(take).join('/'); | |
| 222 | + if (sha && (path === '' || isValidTreePath(path))) return { ref: candidate, sha, path }; | |
| 223 | + } | |
| 224 | + } | |
| 225 | + // Fall back to first segment as a sha / sha prefix. | |
| 226 | + const sha = await this.resolveRef(name, segments[0]); | |
| 227 | + const path = segments.slice(1).join('/'); | |
| 228 | + if (sha && (path === '' || isValidTreePath(path))) return { ref: segments[0], sha, path }; | |
| 229 | + return null; | |
| 230 | + } | |
| 231 | + | |
| 232 | + /** | |
| 233 | + * List one directory level of a tree. | |
| 234 | + * @returns {Promise<Array<{mode: string, type: string, sha: string, size: number|null, name: string, path: string}>|null>} | |
| 235 | + */ | |
| 236 | + async tree(name, sha, path = '') { | |
| 237 | + if (path !== '' && !isValidTreePath(path)) return null; | |
| 238 | + const spec = path === '' ? sha : `${sha}:${path}`; | |
| 239 | + let out; | |
| 240 | + try { | |
| 241 | + const { stdout } = await execFileAsync('git', ['ls-tree', '-l', '-z', spec], { | |
| 242 | + cwd: this.dir(name), | |
| 243 | + maxBuffer: MAX_BUFFER, | |
| 244 | + encoding: 'utf8', | |
| 245 | + }); | |
| 246 | + out = stdout; | |
| 247 | + } catch { | |
| 248 | + return null; | |
| 249 | + } | |
| 250 | + const entries = out | |
| 251 | + .split('\0') | |
| 252 | + .filter(Boolean) | |
| 253 | + .map((record) => { | |
| 254 | + const tab = record.indexOf('\t'); | |
| 255 | + const [mode, type, entrySha, sizeRaw] = record.slice(0, tab).split(/\s+/); | |
| 256 | + const entryName = record.slice(tab + 1); | |
| 257 | + return { | |
| 258 | + mode, | |
| 259 | + type, | |
| 260 | + sha: entrySha, | |
| 261 | + size: sizeRaw === '-' ? null : Number(sizeRaw), | |
| 262 | + name: entryName, | |
| 263 | + path: path === '' ? entryName : `${path}/${entryName}`, | |
| 264 | + }; | |
| 265 | + }); | |
| 266 | + entries.sort((a, b) => { | |
| 267 | + if (a.type !== b.type) return a.type === 'tree' ? -1 : 1; | |
| 268 | + return a.name.localeCompare(b.name); | |
| 269 | + }); | |
| 270 | + return entries; | |
| 271 | + } | |
| 272 | + | |
| 273 | + /** | |
| 274 | + * Read a blob at `<sha>:<path>`. | |
| 275 | + * @returns {Promise<{content: Buffer, size: number, binary: boolean}|null>} | |
| 276 | + */ | |
| 277 | + async blob(name, sha, path) { | |
| 278 | + if (!isValidTreePath(path)) return null; | |
| 279 | + try { | |
| 280 | + const content = await this.gitBuffer(name, ['cat-file', 'blob', `${sha}:${path}`]); | |
| 281 | + const probe = content.subarray(0, 8000); | |
| 282 | + const binary = probe.includes(0); | |
| 283 | + return { content, size: content.length, binary }; | |
| 284 | + } catch { | |
| 285 | + return null; | |
| 286 | + } | |
| 287 | + } | |
| 288 | + | |
| 289 | + /** Type of the object at `<sha>:<path>` — 'blob' | 'tree' | null. */ | |
| 290 | + async objectType(name, sha, path) { | |
| 291 | + if (path === '') return 'tree'; | |
| 292 | + if (!isValidTreePath(path)) return null; | |
| 293 | + const out = await this.tryGit(name, ['cat-file', '-t', `${sha}:${path}`]); | |
| 294 | + return out ? out.trim() : null; | |
| 295 | + } | |
| 296 | + | |
| 297 | + /** | |
| 298 | + * Paginated commit log. | |
| 299 | + * @param {string} name | |
| 300 | + * @param {string} sha resolved commit | |
| 301 | + * @param {{page?: number, perPage?: number, path?: string}} [opts] | |
| 302 | + * @returns {Promise<{commits: object[], hasNext: boolean}>} | |
| 303 | + */ | |
| 304 | + async log(name, sha, opts = {}) { | |
| 305 | + const page = Math.max(1, opts.page ?? 1); | |
| 306 | + const perPage = opts.perPage ?? 40; | |
| 307 | + const args = [ | |
| 308 | + 'log', | |
| 309 | + `--skip=${(page - 1) * perPage}`, | |
| 310 | + `--max-count=${perPage + 1}`, | |
| 311 | + `--format=${RS}%H${FS}%h${FS}%an${FS}%ae${FS}%aI${FS}%s${FS}%b`, | |
| 312 | + sha, | |
| 313 | + ]; | |
| 314 | + if (opts.path) args.push('--', opts.path); | |
| 315 | + const out = await this.tryGit(name, args); | |
| 316 | + if (out == null) return { commits: [], hasNext: false }; | |
| 317 | + const records = out.split(RS).filter((r) => r.trim() !== ''); | |
| 318 | + const commits = records.map((record) => { | |
| 319 | + const [full, short, authorName, authorEmail, date, subject, body] = record.replace(/^\n/, '').split(FS); | |
| 320 | + return { | |
| 321 | + sha: full, | |
| 322 | + shortSha: short, | |
| 323 | + authorName, | |
| 324 | + authorEmail, | |
| 325 | + date, | |
| 326 | + subject, | |
| 327 | + body: (body ?? '').trim(), | |
| 328 | + }; | |
| 329 | + }); | |
| 330 | + const hasNext = commits.length > perPage; | |
| 331 | + const pageCommits = commits.slice(0, perPage); | |
| 332 | + await this.#attachStats(name, sha, pageCommits, opts); | |
| 333 | + return { commits: pageCommits, hasNext }; | |
| 334 | + } | |
| 335 | + | |
| 336 | + /** Attach filesChanged/additions/deletions to a page of commits. */ | |
| 337 | + async #attachStats(name, sha, commits, opts) { | |
| 338 | + if (commits.length === 0) return; | |
| 339 | + const args = [ | |
| 340 | + 'log', `--skip=0`, `--max-count=${commits.length}`, `--shortstat`, `--format=${RS}%H`, | |
| 341 | + commits[0].sha, | |
| 342 | + ]; | |
| 343 | + if (opts.path) args.push('--', opts.path); | |
| 344 | + const out = await this.tryGit(name, args); | |
| 345 | + if (out == null) return; | |
| 346 | + const bySha = new Map(); | |
| 347 | + for (const chunk of out.split(RS)) { | |
| 348 | + const lines = chunk.trim().split('\n'); | |
| 349 | + const chunkSha = lines[0]?.trim(); | |
| 350 | + const stat = lines.slice(1).join(' '); | |
| 351 | + const files = /(\d+) files? changed/.exec(stat); | |
| 352 | + const add = /(\d+) insertions?\(\+\)/.exec(stat); | |
| 353 | + const del = /(\d+) deletions?\(-\)/.exec(stat); | |
| 354 | + if (chunkSha) { | |
| 355 | + bySha.set(chunkSha, { | |
| 356 | + filesChanged: files ? Number(files[1]) : 0, | |
| 357 | + additions: add ? Number(add[1]) : 0, | |
| 358 | + deletions: del ? Number(del[1]) : 0, | |
| 359 | + }); | |
| 360 | + } | |
| 361 | + } | |
| 362 | + for (const commit of commits) { | |
| 363 | + Object.assign(commit, bySha.get(commit.sha) ?? { filesChanged: 0, additions: 0, deletions: 0 }); | |
| 364 | + } | |
| 365 | + } | |
| 366 | + | |
| 367 | + /** @returns {Promise<number>} total commits reachable from sha */ | |
| 368 | + async commitCount(name, sha) { | |
| 369 | + const out = await this.tryGit(name, ['rev-list', '--count', sha]); | |
| 370 | + return out ? Number(out.trim()) : 0; | |
| 371 | + } | |
| 372 | + | |
| 373 | + /** @returns {Promise<{behind: number, ahead: number}>} vs base */ | |
| 374 | + async aheadBehind(name, base, branch) { | |
| 375 | + const out = await this.tryGit(name, ['rev-list', '--left-right', '--count', `${base}...${branch}`]); | |
| 376 | + if (!out) return { behind: 0, ahead: 0 }; | |
| 377 | + const [behind, ahead] = out.trim().split('\t').map(Number); | |
| 378 | + return { behind: behind || 0, ahead: ahead || 0 }; | |
| 379 | + } | |
| 380 | + | |
| 381 | + /** @returns {Promise<number>} on-disk size in bytes (packed + loose) */ | |
| 382 | + async sizeBytes(name) { | |
| 383 | + const out = await this.tryGit(name, ['count-objects', '-v']); | |
| 384 | + if (!out) return 0; | |
| 385 | + let kb = 0; | |
| 386 | + for (const line of out.split('\n')) { | |
| 387 | + const m = /^(size|size-pack): (\d+)$/.exec(line.trim()); | |
| 388 | + if (m) kb += Number(m[2]); | |
| 389 | + } | |
| 390 | + return kb * 1024; | |
| 391 | + } | |
| 392 | + | |
| 393 | + /** @returns {Promise<string|null>} ISO date of the most recent commit on any ref */ | |
| 394 | + async lastPushDate(name) { | |
| 395 | + const out = await this.tryGit(name, [ | |
| 396 | + 'for-each-ref', '--sort=-committerdate', '--count=1', '--format=%(committerdate:iso-strict)', | |
| 397 | + 'refs/heads', | |
| 398 | + ]); | |
| 399 | + const trimmed = out?.trim(); | |
| 400 | + return trimmed || null; | |
| 401 | + } | |
| 402 | + | |
| 403 | + /** | |
| 404 | + * Single commit with parsed diff. | |
| 405 | + * @returns {Promise<object|null>} | |
| 406 | + */ | |
| 407 | + async commit(name, sha) { | |
| 408 | + const resolved = await this.resolveRef(name, sha); | |
| 409 | + if (!resolved) return null; | |
| 410 | + const metaOut = await this.tryGit(name, [ | |
| 411 | + 'show', '-s', `--format=%H${FS}%h${FS}%an${FS}%ae${FS}%aI${FS}%cn${FS}%ce${FS}%cI${FS}%P${FS}%s${FS}%b`, resolved, | |
| 412 | + ]); | |
| 413 | + if (!metaOut) return null; | |
| 414 | + const [full, short, authorName, authorEmail, authorDate, committerName, committerEmail, committerDate, parents, subject, body] = | |
| 415 | + metaOut.trim().split(FS); | |
| 416 | + const patch = await this.tryGit(name, ['show', '--format=', '--patch', '-M', '--no-color', resolved]) ?? ''; | |
| 417 | + const files = parseUnifiedDiff(patch); | |
| 418 | + const additions = files.reduce((n, f) => n + f.additions, 0); | |
| 419 | + const deletions = files.reduce((n, f) => n + f.deletions, 0); | |
| 420 | + return { | |
| 421 | + sha: full, | |
| 422 | + shortSha: short, | |
| 423 | + authorName, | |
| 424 | + authorEmail, | |
| 425 | + date: authorDate, | |
| 426 | + committerName, | |
| 427 | + committerEmail, | |
| 428 | + committerDate, | |
| 429 | + parents: (parents ?? '').split(' ').filter(Boolean), | |
| 430 | + subject, | |
| 431 | + body: (body ?? '').trim(), | |
| 432 | + files, | |
| 433 | + additions, | |
| 434 | + deletions, | |
| 435 | + }; | |
| 436 | + } | |
| 437 | + | |
| 438 | + /** | |
| 439 | + * Blame a file — hunks grouped by commit. | |
| 440 | + * @returns {Promise<{hunks: object[]}|null>} | |
| 441 | + */ | |
| 442 | + async blame(name, sha, path) { | |
| 443 | + if (!isValidTreePath(path)) return null; | |
| 444 | + const out = await this.tryGit(name, ['blame', '--porcelain', sha, '--', path]); | |
| 445 | + if (out == null) return null; | |
| 446 | + const commits = new Map(); | |
| 447 | + const lines = []; | |
| 448 | + const rows = out.split('\n'); | |
| 449 | + let i = 0; | |
| 450 | + while (i < rows.length) { | |
| 451 | + const header = /^([0-9a-f]{40}) (\d+) (\d+)(?: (\d+))?$/.exec(rows[i]); | |
| 452 | + if (!header) { | |
| 453 | + i += 1; | |
| 454 | + continue; | |
| 455 | + } | |
| 456 | + const [, commitSha, , finalLine] = header; | |
| 457 | + i += 1; | |
| 458 | + if (!commits.has(commitSha)) commits.set(commitSha, { sha: commitSha }); | |
| 459 | + const info = commits.get(commitSha); | |
| 460 | + while (i < rows.length && !rows[i].startsWith('\t')) { | |
| 461 | + const [key, ...rest] = rows[i].split(' '); | |
| 462 | + const value = rest.join(' '); | |
| 463 | + if (key === 'author') info.authorName = value; | |
| 464 | + else if (key === 'author-mail') info.authorEmail = value.replace(/^<|>$/g, ''); | |
| 465 | + else if (key === 'author-time') info.date = new Date(Number(value) * 1000).toISOString(); | |
| 466 | + else if (key === 'summary') info.subject = value; | |
| 467 | + i += 1; | |
| 468 | + } | |
| 469 | + if (i < rows.length && rows[i].startsWith('\t')) { | |
| 470 | + lines.push({ line: Number(finalLine), text: rows[i].slice(1), sha: commitSha }); | |
| 471 | + i += 1; | |
| 472 | + } | |
| 473 | + } | |
| 474 | + // Group consecutive lines that share a commit into hunks. | |
| 475 | + const hunks = []; | |
| 476 | + for (const line of lines) { | |
| 477 | + const info = commits.get(line.sha); | |
| 478 | + const last = hunks[hunks.length - 1]; | |
| 479 | + if (last && last.sha === line.sha && last.endLine === line.line - 1) { | |
| 480 | + last.endLine = line.line; | |
| 481 | + last.lines.push(line); | |
| 482 | + } else { | |
| 483 | + hunks.push({ | |
| 484 | + sha: line.sha, | |
| 485 | + shortSha: line.sha.slice(0, 7), | |
| 486 | + authorName: info?.authorName ?? '', | |
| 487 | + authorEmail: info?.authorEmail ?? '', | |
| 488 | + date: info?.date ?? '', | |
| 489 | + subject: info?.subject ?? '', | |
| 490 | + startLine: line.line, | |
| 491 | + endLine: line.line, | |
| 492 | + lines: [line], | |
| 493 | + }); | |
| 494 | + } | |
| 495 | + } | |
| 496 | + return { hunks }; | |
| 497 | + } | |
| 498 | + | |
| 499 | + /** | |
| 500 | + * For each entry of a directory, find the most recent commit touching it. | |
| 501 | + * One streamed `git log --name-only` walk, capped, with `git log -1` | |
| 502 | + * fallback for stragglers. | |
| 503 | + * @param {string} name | |
| 504 | + * @param {string} sha | |
| 505 | + * @param {string} dirPath '' for root | |
| 506 | + * @param {string[]} entryNames names (not paths) of the directory entries | |
| 507 | + * @returns {Promise<Record<string, {sha: string, date: string, subject: string}>>} | |
| 508 | + */ | |
| 509 | + async lastCommits(name, sha, dirPath, entryNames) { | |
| 510 | + const remaining = new Set(entryNames); | |
| 511 | + const result = {}; | |
| 512 | + const prefix = dirPath === '' ? '' : `${dirPath}/`; | |
| 513 | + const args = ['log', `--format=${RS}%H${FS}%aI${FS}%s`, '--name-only', sha]; | |
| 514 | + if (dirPath !== '') args.push('--', dirPath); | |
| 515 | + | |
| 516 | + await new Promise((resolvePromise) => { | |
| 517 | + const child = spawn('git', args, { cwd: this.dir(name) }); | |
| 518 | + let buffer = ''; | |
| 519 | + let commitsSeen = 0; | |
| 520 | + let current = null; | |
| 521 | + const processLine = (line) => { | |
| 522 | + if (line.startsWith(RS)) { | |
| 523 | + commitsSeen += 1; | |
| 524 | + if (commitsSeen > TREE_LOG_WALK_CAP || remaining.size === 0) { | |
| 525 | + child.kill('SIGTERM'); | |
| 526 | + return; | |
| 527 | + } | |
| 528 | + const [commitSha, date, subject] = line.slice(1).split(FS); | |
| 529 | + current = { sha: commitSha, date, subject }; | |
| 530 | + return; | |
| 531 | + } | |
| 532 | + if (!current || line === '') return; | |
| 533 | + const path = unquoteGitPath(line); | |
| 534 | + if (!path.startsWith(prefix)) return; | |
| 535 | + const rest = path.slice(prefix.length); | |
| 536 | + const entry = rest.split('/')[0]; | |
| 537 | + if (remaining.has(entry)) { | |
| 538 | + result[entry] = current; | |
| 539 | + remaining.delete(entry); | |
| 540 | + } | |
| 541 | + }; | |
| 542 | + child.stdout.setEncoding('utf8'); | |
| 543 | + child.stdout.on('data', (chunk) => { | |
| 544 | + buffer += chunk; | |
| 545 | + let nl; | |
| 546 | + while ((nl = buffer.indexOf('\n')) !== -1) { | |
| 547 | + processLine(buffer.slice(0, nl)); | |
| 548 | + buffer = buffer.slice(nl + 1); | |
| 549 | + } | |
| 550 | + }); | |
| 551 | + child.on('close', () => { | |
| 552 | + if (buffer) processLine(buffer); | |
| 553 | + resolvePromise(); | |
| 554 | + }); | |
| 555 | + child.on('error', () => resolvePromise()); | |
| 556 | + }); | |
| 557 | + | |
| 558 | + // Fallback for anything the capped walk missed. | |
| 559 | + for (const entry of remaining) { | |
| 560 | + const path = prefix + entry; | |
| 561 | + const out = await this.tryGit(name, ['log', '-1', `--format=%H${FS}%aI${FS}%s`, sha, '--', path]); | |
| 562 | + if (out && out.trim()) { | |
| 563 | + const [commitSha, date, subject] = out.trim().split(FS); | |
| 564 | + result[entry] = { sha: commitSha, date, subject }; | |
| 565 | + } | |
| 566 | + } | |
| 567 | + return result; | |
| 568 | + } | |
| 569 | + | |
| 570 | + /** | |
| 571 | + * Locate the README blob in the root tree (case-insensitive, md first). | |
| 572 | + * @returns {Promise<{path: string, content: Buffer}|null>} | |
| 573 | + */ | |
| 574 | + async readme(name, sha) { | |
| 575 | + const entries = await this.tree(name, sha, ''); | |
| 576 | + if (!entries) return null; | |
| 577 | + const candidates = entries.filter((e) => e.type === 'blob' && /^readme(\.(md|markdown|rst|txt))?$/i.test(e.name)); | |
| 578 | + candidates.sort((a, b) => { | |
| 579 | + const rank = (n) => (/\.(md|markdown)$/i.test(n) ? 0 : /\.rst$/i.test(n) ? 1 : /\.txt$/i.test(n) ? 2 : 3); | |
| 580 | + return rank(a.name) - rank(b.name); | |
| 581 | + }); | |
| 582 | + if (candidates.length === 0) return null; | |
| 583 | + const blob = await this.blob(name, sha, candidates[0].path); | |
| 584 | + if (!blob || blob.binary) return null; | |
| 585 | + return { path: candidates[0].path, content: blob.content }; | |
| 586 | + } | |
| 587 | + | |
| 588 | + /** | |
| 589 | + * Detect a license from root LICENSE/COPYING files. | |
| 590 | + * @returns {Promise<{name: string, path: string}|null>} | |
| 591 | + */ | |
| 592 | + async license(name, sha) { | |
| 593 | + const entries = await this.tree(name, sha, ''); | |
| 594 | + if (!entries) return null; | |
| 595 | + const file = entries.find((e) => e.type === 'blob' && /^(license|licence|copying)(\.(md|txt))?$/i.test(e.name)); | |
| 596 | + if (!file) return null; | |
| 597 | + const blob = await this.blob(name, sha, file.path); | |
| 598 | + if (!blob || blob.binary) return null; | |
| 599 | + const text = blob.content.toString('utf8', 0, 2000); | |
| 600 | + const detections = [ | |
| 601 | + [/MIT License/i, 'MIT'], | |
| 602 | + [/Apache License,?\s+Version 2\.0/i, 'Apache-2.0'], | |
| 603 | + [/GNU AFFERO GENERAL PUBLIC LICENSE.*Version 3/is, 'AGPL-3.0'], | |
| 604 | + [/GNU GENERAL PUBLIC LICENSE\s+Version 3/i, 'GPL-3.0'], | |
| 605 | + [/GNU GENERAL PUBLIC LICENSE\s+Version 2/i, 'GPL-2.0'], | |
| 606 | + [/GNU LESSER GENERAL PUBLIC LICENSE/i, 'LGPL'], | |
| 607 | + [/Mozilla Public License,?\s+v(ersion)?\.?\s*2\.0/i, 'MPL-2.0'], | |
| 608 | + [/BSD 3-Clause|Redistribution and use in source and binary forms.*neither the name/is, 'BSD-3-Clause'], | |
| 609 | + [/BSD 2-Clause/i, 'BSD-2-Clause'], | |
| 610 | + [/ISC License/i, 'ISC'], | |
| 611 | + [/This is free and unencumbered software released into the public domain/i, 'Unlicense'], | |
| 612 | + ]; | |
| 613 | + for (const [re, id] of detections) { | |
| 614 | + if (re.test(text)) return { name: id, path: file.path }; | |
| 615 | + } | |
| 616 | + return { name: 'License', path: file.path }; | |
| 617 | + } | |
| 618 | + | |
| 619 | + /** | |
| 620 | + * Full recursive file listing with sizes — feeds language stats. | |
| 621 | + * @returns {Promise<Array<{path: string, size: number}>>} | |
| 622 | + */ | |
| 623 | + async allFiles(name, sha) { | |
| 624 | + const out = await this.tryGit(name, ['ls-tree', '-r', '-l', '-z', sha]); | |
| 625 | + if (!out) return []; | |
| 626 | + return out | |
| 627 | + .split('\0') | |
| 628 | + .filter(Boolean) | |
| 629 | + .map((record) => { | |
| 630 | + const tab = record.indexOf('\t'); | |
| 631 | + const [, type, , sizeRaw] = record.slice(0, tab).split(/\s+/); | |
| 632 | + if (type !== 'blob') return null; | |
| 633 | + return { path: record.slice(tab + 1), size: sizeRaw === '-' ? 0 : Number(sizeRaw) }; | |
| 634 | + }) | |
| 635 | + .filter(Boolean); | |
| 636 | + } | |
| 637 | + | |
| 638 | + /** All commit timestamps+subjects on the default branch (for the heatmap). */ | |
| 639 | + async commitTimestamps(name) { | |
| 640 | + const head = await this.head(name); | |
| 641 | + if (!head) return []; | |
| 642 | + const out = await this.tryGit(name, ['rev-list', '--format=%ct', '--no-commit-header', head]); | |
| 643 | + if (!out) return []; | |
| 644 | + return out.split('\n').filter(Boolean).map(Number); | |
| 645 | + } | |
| 646 | +} | |
| 647 | + | |
| 648 | +/** | |
| 649 | + * Unquote a git-quoted path ("dir/\303\251t\303\251.txt" style). | |
| 650 | + * @param {string} raw | |
| 651 | + * @returns {string} | |
| 652 | + */ | |
| 653 | +export function unquoteGitPath(raw) { | |
| 654 | + if (!raw.startsWith('"') || !raw.endsWith('"')) return raw; | |
| 655 | + const inner = raw.slice(1, -1); | |
| 656 | + const bytes = []; | |
| 657 | + for (let i = 0; i < inner.length; i += 1) { | |
| 658 | + if (inner[i] !== '\\') { | |
| 659 | + bytes.push(inner.charCodeAt(i)); | |
| 660 | + continue; | |
| 661 | + } | |
| 662 | + const next = inner[i + 1]; | |
| 663 | + if (/[0-7]/.test(next)) { | |
| 664 | + bytes.push(parseInt(inner.slice(i + 1, i + 4), 8)); | |
| 665 | + i += 3; | |
| 666 | + } else { | |
| 667 | + const map = { n: 10, t: 9, r: 13, '\\': 92, '"': 34, a: 7, b: 8, f: 12, v: 11 }; | |
| 668 | + bytes.push(map[next] ?? next.charCodeAt(0)); | |
| 669 | + i += 1; | |
| 670 | + } | |
| 671 | + } | |
| 672 | + return Buffer.from(bytes).toString('utf8'); | |
| 673 | +} | |
| 674 | + | |
| 675 | +/** | |
| 676 | + * Parse a unified diff (git show/diff output) into structured files + hunks. | |
| 677 | + * @param {string} text | |
| 678 | + * @returns {Array<object>} | |
| 679 | + */ | |
| 680 | +export function parseUnifiedDiff(text) { | |
| 681 | + const files = []; | |
| 682 | + if (!text) return files; | |
| 683 | + const lines = text.split('\n'); | |
| 684 | + let file = null; | |
| 685 | + let hunk = null; | |
| 686 | + let oldLine = 0; | |
| 687 | + let newLine = 0; | |
| 688 | + let totalLines = 0; | |
| 689 | + | |
| 690 | + const pushFile = () => { | |
| 691 | + if (file) files.push(file); | |
| 692 | + file = null; | |
| 693 | + hunk = null; | |
| 694 | + }; | |
| 695 | + | |
| 696 | + for (const line of lines) { | |
| 697 | + if (totalLines > DIFF_MAX_LINES) { | |
| 698 | + if (file) file.truncated = true; | |
| 699 | + break; | |
| 700 | + } | |
| 701 | + if (line.startsWith('diff --git ')) { | |
| 702 | + pushFile(); | |
| 703 | + const m = /^diff --git (?:"?a\/)(.*?)"? (?:"?b\/)(.*?)"?$/.exec(line); | |
| 704 | + file = { | |
| 705 | + oldPath: m ? unquoteGitPath(m[1].startsWith('"') ? m[1] : m[1]) : '', | |
| 706 | + newPath: m ? m[2] : '', | |
| 707 | + status: 'modified', | |
| 708 | + binary: false, | |
| 709 | + additions: 0, | |
| 710 | + deletions: 0, | |
| 711 | + hunks: [], | |
| 712 | + truncated: false, | |
| 713 | + }; | |
| 714 | + hunk = null; | |
| 715 | + continue; | |
| 716 | + } | |
| 717 | + if (!file) continue; | |
| 718 | + if (line.startsWith('new file mode')) file.status = 'added'; | |
| 719 | + else if (line.startsWith('deleted file mode')) file.status = 'deleted'; | |
| 720 | + else if (line.startsWith('rename from ')) { | |
| 721 | + file.status = 'renamed'; | |
| 722 | + file.oldPath = unquoteGitPath(line.slice('rename from '.length)); | |
| 723 | + } else if (line.startsWith('rename to ')) { | |
| 724 | + file.newPath = unquoteGitPath(line.slice('rename to '.length)); | |
| 725 | + } else if (line.startsWith('Binary files ') || line === 'GIT binary patch') { | |
| 726 | + file.binary = true; | |
| 727 | + } else if (line.startsWith('--- ')) { | |
| 728 | + /* path already known */ | |
| 729 | + } else if (line.startsWith('+++ ')) { | |
| 730 | + /* path already known */ | |
| 731 | + } else if (line.startsWith('@@')) { | |
| 732 | + const m = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@ ?(.*)$/.exec(line); | |
| 733 | + if (m) { | |
| 734 | + oldLine = Number(m[1]); | |
| 735 | + newLine = Number(m[3]); | |
| 736 | + hunk = { header: line, context: m[5] ?? '', lines: [] }; | |
| 737 | + file.hunks.push(hunk); | |
| 738 | + } | |
| 739 | + } else if (hunk && (line.startsWith('+') || line.startsWith('-') || line.startsWith(' ') || line === '')) { | |
| 740 | + totalLines += 1; | |
| 741 | + if (line.startsWith('+')) { | |
| 742 | + hunk.lines.push({ type: 'add', old: null, new: newLine, text: line.slice(1) }); | |
| 743 | + newLine += 1; | |
| 744 | + file.additions += 1; | |
| 745 | + } else if (line.startsWith('-')) { | |
| 746 | + hunk.lines.push({ type: 'del', old: oldLine, new: null, text: line.slice(1) }); | |
| 747 | + oldLine += 1; | |
| 748 | + file.deletions += 1; | |
| 749 | + } else if (line.startsWith(' ') || line === '') { | |
| 750 | + hunk.lines.push({ type: 'ctx', old: oldLine, new: newLine, text: line.slice(1) }); | |
| 751 | + oldLine += 1; | |
| 752 | + newLine += 1; | |
| 753 | + } | |
| 754 | + } else if (line.startsWith('\\ No newline')) { | |
| 755 | + if (hunk) hunk.lines.push({ type: 'meta', old: null, new: null, text: line }); | |
| 756 | + } | |
| 757 | + } | |
| 758 | + pushFile(); | |
| 759 | + return files; | |
| 760 | +} | |
| 761 | + | |
| 762 | +/** | |
| 763 | + * Repo directory size on disk (recursive) — used by /healthz only. | |
| 764 | + * @param {string} dir | |
| 765 | + * @returns {number} bytes | |
| 766 | + */ | |
| 767 | +export function dirSizeBytes(dir) { | |
| 768 | + let total = 0; | |
| 769 | + let entries; | |
| 770 | + try { | |
| 771 | + entries = readdirSync(dir); | |
| 772 | + } catch { | |
| 773 | + return 0; | |
| 774 | + } | |
| 775 | + for (const entry of entries) { | |
| 776 | + const full = join(dir, entry); | |
| 777 | + let st; | |
| 778 | + try { | |
| 779 | + st = statSync(full); | |
| 780 | + } catch { | |
| 781 | + continue; | |
| 782 | + } | |
| 783 | + if (st.isDirectory()) total += dirSizeBytes(full); | |
| 784 | + else total += st.size; | |
| 785 | + } | |
| 786 | + return total; | |
| 787 | +} | |
added
src/git/smart-http.mjs
+161 −0
@@ -0,0 +1,161 @@ | ||
| 1 | +/** | |
| 2 | + * ───────────────────────────────────────────── | |
| 3 | + * SPB Git — Personal Git Platform | |
| 4 | + * ───────────────────────────────────────────── | |
| 5 | + * Author : Simon-Pierre Boucher | |
| 6 | + * Contact : contact@spboucher.ai | |
| 7 | + * File : src/git/smart-http.mjs | |
| 8 | + * Purpose : Git Smart HTTP — streamed upload-pack / receive-pack | |
| 9 | + * License : MIT © Simon-Pierre Boucher | |
| 10 | + * ───────────────────────────────────────────── | |
| 11 | + */ | |
| 12 | + | |
| 13 | +import { spawn } from 'node:child_process'; | |
| 14 | +import { createGunzip } from 'node:zlib'; | |
| 15 | +import { readFileSync, existsSync } from 'node:fs'; | |
| 16 | +import { join } from 'node:path'; | |
| 17 | +import { atomicWriteJSON } from '../lib/util.mjs'; | |
| 18 | +import { extractToken } from '../auth/token.mjs'; | |
| 19 | + | |
| 20 | +const SERVICES = new Set(['git-upload-pack', 'git-receive-pack']); | |
| 21 | + | |
| 22 | +/** | |
| 23 | + * Encode a git pkt-line. | |
| 24 | + * @param {string} payload | |
| 25 | + * @returns {Buffer} | |
| 26 | + */ | |
| 27 | +export function pktLine(payload) { | |
| 28 | + const length = (payload.length + 4).toString(16).padStart(4, '0'); | |
| 29 | + return Buffer.from(length + payload, 'utf8'); | |
| 30 | +} | |
| 31 | + | |
| 32 | +/** The pkt-line flush packet. */ | |
| 33 | +export const FLUSH = Buffer.from('0000', 'utf8'); | |
| 34 | + | |
| 35 | +/** | |
| 36 | + * Parse `/:name.git` style params into a validated repo name. | |
| 37 | + * @param {object} ctx registration context | |
| 38 | + * @param {string} raw the `:repo` param | |
| 39 | + * @returns {string|null} | |
| 40 | + */ | |
| 41 | +function repoFromParam(ctx, raw) { | |
| 42 | + if (typeof raw !== 'string' || !raw.endsWith('.git')) return null; | |
| 43 | + const name = raw.slice(0, -4); | |
| 44 | + return ctx.repos.exists(name) ? name : null; | |
| 45 | +} | |
| 46 | + | |
| 47 | +/** Track clone/fetch counts in data/clones.json (best-effort). */ | |
| 48 | +function bumpCloneCount(ctx, repo) { | |
| 49 | + const path = join(ctx.config.dataDir, 'clones.json'); | |
| 50 | + let db = {}; | |
| 51 | + try { | |
| 52 | + if (existsSync(path)) db = JSON.parse(readFileSync(path, 'utf8')); | |
| 53 | + } catch { | |
| 54 | + db = {}; | |
| 55 | + } | |
| 56 | + db[repo] = (db[repo] ?? 0) + 1; | |
| 57 | + try { | |
| 58 | + atomicWriteJSON(path, db); | |
| 59 | + } catch { | |
| 60 | + /* non-critical */ | |
| 61 | + } | |
| 62 | +} | |
| 63 | + | |
| 64 | +/** | |
| 65 | + * Register Smart HTTP routes on the Fastify app. | |
| 66 | + * @param {import('fastify').FastifyInstance} app | |
| 67 | + * @param {{config: object, repos: object, tokens: object, log?: object}} ctx | |
| 68 | + */ | |
| 69 | +export function registerSmartHttp(app, ctx) { | |
| 70 | + // Pass git request bodies through untouched — never buffer packfiles. | |
| 71 | + app.addContentTypeParser( | |
| 72 | + ['application/x-git-upload-pack-request', 'application/x-git-receive-pack-request'], | |
| 73 | + (_request, payload, done) => done(null, payload), | |
| 74 | + ); | |
| 75 | + | |
| 76 | + /** Authenticate a push request (HTTP Basic, username `spb`, password = PAT). */ | |
| 77 | + async function authorizePush(request, reply) { | |
| 78 | + const candidate = extractToken(request.headers.authorization); | |
| 79 | + const record = candidate ? await ctx.tokens.verify(candidate) : null; | |
| 80 | + if (!record) { | |
| 81 | + reply | |
| 82 | + .code(401) | |
| 83 | + .header('WWW-Authenticate', 'Basic realm="SPB Git", charset="UTF-8"') | |
| 84 | + .type('text/plain') | |
| 85 | + .send('Authentication required: username "spb", password = personal access token.'); | |
| 86 | + return false; | |
| 87 | + } | |
| 88 | + return true; | |
| 89 | + } | |
| 90 | + | |
| 91 | + app.get('/:repo/info/refs', async (request, reply) => { | |
| 92 | + const repo = repoFromParam(ctx, request.params.repo); | |
| 93 | + const service = request.query.service; | |
| 94 | + if (!repo) return reply.code(404).type('text/plain').send('repository not found'); | |
| 95 | + if (!SERVICES.has(service)) { | |
| 96 | + return reply.code(400).type('text/plain').send('smart HTTP only — dumb protocol is disabled'); | |
| 97 | + } | |
| 98 | + if (service === 'git-receive-pack' && !(await authorizePush(request, reply))) return reply; | |
| 99 | + | |
| 100 | + reply.hijack(); | |
| 101 | + const res = reply.raw; | |
| 102 | + res.writeHead(200, { | |
| 103 | + 'Content-Type': `application/x-${service}-advertisement`, | |
| 104 | + 'Cache-Control': 'no-cache, max-age=0, must-revalidate', | |
| 105 | + 'X-Content-Type-Options': 'nosniff', | |
| 106 | + }); | |
| 107 | + res.write(pktLine(`# service=${service}\n`)); | |
| 108 | + res.write(FLUSH); | |
| 109 | + const child = spawn('git', [service.replace(/^git-/, ''), '--stateless-rpc', '--advertise-refs', ctx.repos.dir(repo)], { | |
| 110 | + env: { ...process.env, GIT_PROTOCOL: request.headers['git-protocol'] ?? '' }, | |
| 111 | + }); | |
| 112 | + child.stdout.pipe(res); | |
| 113 | + child.stderr.on('data', (d) => request.log.warn({ repo, service }, d.toString().trim())); | |
| 114 | + child.on('close', () => res.end()); | |
| 115 | + child.on('error', (err) => { | |
| 116 | + request.log.error({ err }, 'info/refs spawn failed'); | |
| 117 | + res.end(); | |
| 118 | + }); | |
| 119 | + return reply; | |
| 120 | + }); | |
| 121 | + | |
| 122 | + for (const service of SERVICES) { | |
| 123 | + app.post(`/:repo/${service}`, async (request, reply) => { | |
| 124 | + const repo = repoFromParam(ctx, request.params.repo); | |
| 125 | + if (!repo) return reply.code(404).type('text/plain').send('repository not found'); | |
| 126 | + if (service === 'git-receive-pack' && !(await authorizePush(request, reply))) return reply; | |
| 127 | + if (service === 'git-upload-pack') bumpCloneCount(ctx, repo); | |
| 128 | + | |
| 129 | + reply.hijack(); | |
| 130 | + const res = reply.raw; | |
| 131 | + res.writeHead(200, { | |
| 132 | + 'Content-Type': `application/x-${service}-result`, | |
| 133 | + 'Cache-Control': 'no-cache, max-age=0, must-revalidate', | |
| 134 | + 'X-Content-Type-Options': 'nosniff', | |
| 135 | + }); | |
| 136 | + const child = spawn('git', [service.replace(/^git-/, ''), '--stateless-rpc', ctx.repos.dir(repo)], { | |
| 137 | + env: { ...process.env, GIT_PROTOCOL: request.headers['git-protocol'] ?? '' }, | |
| 138 | + }); | |
| 139 | + | |
| 140 | + let body = request.body ?? request.raw; | |
| 141 | + if ((request.headers['content-encoding'] ?? '').includes('gzip')) { | |
| 142 | + const gunzip = createGunzip(); | |
| 143 | + body.pipe(gunzip); | |
| 144 | + body = gunzip; | |
| 145 | + } | |
| 146 | + body.pipe(child.stdin); | |
| 147 | + child.stdout.pipe(res); | |
| 148 | + child.stderr.on('data', (d) => request.log.info({ repo, service }, d.toString().trim())); | |
| 149 | + child.on('close', (code) => { | |
| 150 | + if (code !== 0) request.log.warn({ repo, service, code }, 'git service exited non-zero'); | |
| 151 | + res.end(); | |
| 152 | + }); | |
| 153 | + child.on('error', (err) => { | |
| 154 | + request.log.error({ err }, 'git service spawn failed'); | |
| 155 | + res.end(); | |
| 156 | + }); | |
| 157 | + request.raw.on('aborted', () => child.kill('SIGTERM')); | |
| 158 | + return reply; | |
| 159 | + }); | |
| 160 | + } | |
| 161 | +} | |
added
src/lib/cache.mjs
+124 −0
@@ -0,0 +1,124 @@ | ||
| 1 | +/** | |
| 2 | + * ───────────────────────────────────────────── | |
| 3 | + * SPB Git — Personal Git Platform | |
| 4 | + * ───────────────────────────────────────────── | |
| 5 | + * Author : Simon-Pierre Boucher | |
| 6 | + * Contact : contact@spboucher.ai | |
| 7 | + * File : src/lib/cache.mjs | |
| 8 | + * Purpose : Filesystem cache keyed by <repo>@<sha> — busted on push | |
| 9 | + * License : MIT © Simon-Pierre Boucher | |
| 10 | + * ───────────────────────────────────────────── | |
| 11 | + */ | |
| 12 | + | |
| 13 | +import { | |
| 14 | + readFileSync, writeFileSync, mkdirSync, rmSync, existsSync, | |
| 15 | + readdirSync, statSync, renameSync, | |
| 16 | +} from 'node:fs'; | |
| 17 | +import { join, dirname } from 'node:path'; | |
| 18 | +import { randomBytes } from 'node:crypto'; | |
| 19 | +import { safeJoin } from './util.mjs'; | |
| 20 | + | |
| 21 | +/** | |
| 22 | + * Content cache under `cacheDir`. Layout: | |
| 23 | + * <cacheDir>/repos/<repo>/<sha>/<kind> per-commit artifacts | |
| 24 | + * <cacheDir>/global/<kind> cross-repo artifacts (search index, heatmap) | |
| 25 | + * Push hooks call {@link Cache#bustRepo} which nukes the repo subtree. | |
| 26 | + */ | |
| 27 | +export class Cache { | |
| 28 | + /** @param {string} cacheDir */ | |
| 29 | + constructor(cacheDir) { | |
| 30 | + this.root = cacheDir; | |
| 31 | + } | |
| 32 | + | |
| 33 | + /** @param {...string} segments @returns {string} */ | |
| 34 | + path(...segments) { | |
| 35 | + const cleaned = segments.map((s) => String(s).replaceAll('/', '_')); | |
| 36 | + return safeJoin(this.root, ...cleaned); | |
| 37 | + } | |
| 38 | + | |
| 39 | + /** Per-repo, per-commit key. @returns {string} */ | |
| 40 | + repoPath(repo, sha, kind) { | |
| 41 | + return safeJoin(this.root, 'repos', repo, sha, kind.replaceAll('/', '_')); | |
| 42 | + } | |
| 43 | + | |
| 44 | + /** @returns {Buffer|null} */ | |
| 45 | + getBuffer(path) { | |
| 46 | + try { | |
| 47 | + return readFileSync(path); | |
| 48 | + } catch { | |
| 49 | + return null; | |
| 50 | + } | |
| 51 | + } | |
| 52 | + | |
| 53 | + /** @returns {any|null} */ | |
| 54 | + getJSON(path) { | |
| 55 | + const buf = this.getBuffer(path); | |
| 56 | + if (buf === null) return null; | |
| 57 | + try { | |
| 58 | + return JSON.parse(buf.toString('utf8')); | |
| 59 | + } catch { | |
| 60 | + return null; | |
| 61 | + } | |
| 62 | + } | |
| 63 | + | |
| 64 | + /** Atomic write (tmp + rename). */ | |
| 65 | + set(path, data) { | |
| 66 | + mkdirSync(dirname(path), { recursive: true }); | |
| 67 | + const tmp = `${path}.${randomBytes(4).toString('hex')}.tmp`; | |
| 68 | + writeFileSync(tmp, data); | |
| 69 | + renameSync(tmp, path); | |
| 70 | + } | |
| 71 | + | |
| 72 | + setJSON(path, value) { | |
| 73 | + this.set(path, JSON.stringify(value)); | |
| 74 | + } | |
| 75 | + | |
| 76 | + /** | |
| 77 | + * Read-through helper for JSON artifacts. | |
| 78 | + * @param {string} path | |
| 79 | + * @param {() => Promise<any>|any} compute | |
| 80 | + */ | |
| 81 | + async remember(path, compute) { | |
| 82 | + const hit = this.getJSON(path); | |
| 83 | + if (hit !== null) return hit; | |
| 84 | + const value = await compute(); | |
| 85 | + if (value !== undefined) this.setJSON(path, value); | |
| 86 | + return value; | |
| 87 | + } | |
| 88 | + | |
| 89 | + /** Drop every cached artifact for a repo (all shas). @param {string} repo */ | |
| 90 | + bustRepo(repo) { | |
| 91 | + rmSync(safeJoin(this.root, 'repos', repo), { recursive: true, force: true }); | |
| 92 | + rmSync(safeJoin(this.root, 'global'), { recursive: true, force: true }); | |
| 93 | + } | |
| 94 | + | |
| 95 | + /** @returns {{files: number, bytes: number}} recursive cache footprint */ | |
| 96 | + footprint() { | |
| 97 | + let files = 0; | |
| 98 | + let bytes = 0; | |
| 99 | + const walk = (dir) => { | |
| 100 | + let entries; | |
| 101 | + try { | |
| 102 | + entries = readdirSync(dir); | |
| 103 | + } catch { | |
| 104 | + return; | |
| 105 | + } | |
| 106 | + for (const entry of entries) { | |
| 107 | + const full = join(dir, entry); | |
| 108 | + let st; | |
| 109 | + try { | |
| 110 | + st = statSync(full); | |
| 111 | + } catch { | |
| 112 | + continue; | |
| 113 | + } | |
| 114 | + if (st.isDirectory()) walk(full); | |
| 115 | + else { | |
| 116 | + files += 1; | |
| 117 | + bytes += st.size; | |
| 118 | + } | |
| 119 | + } | |
| 120 | + }; | |
| 121 | + if (existsSync(this.root)) walk(this.root); | |
| 122 | + return { files, bytes }; | |
| 123 | + } | |
| 124 | +} | |
added
src/lib/overview.mjs
+113 −0
@@ -0,0 +1,113 @@ | ||
| 1 | +/** | |
| 2 | + * ───────────────────────────────────────────── | |
| 3 | + * SPB Git — Personal Git Platform | |
| 4 | + * ───────────────────────────────────────────── | |
| 5 | + * Author : Simon-Pierre Boucher | |
| 6 | + * Contact : contact@spboucher.ai | |
| 7 | + * File : src/lib/overview.mjs | |
| 8 | + * Purpose : Aggregated per-repo overview (meta + git facts), cached | |
| 9 | + * License : MIT © Simon-Pierre Boucher | |
| 10 | + * ───────────────────────────────────────────── | |
| 11 | + */ | |
| 12 | + | |
| 13 | +import { readFileSync, existsSync } from 'node:fs'; | |
| 14 | +import { join } from 'node:path'; | |
| 15 | +import { computeLanguages } from '../stats/languages.mjs'; | |
| 16 | +import { mapLimit } from './util.mjs'; | |
| 17 | + | |
| 18 | +/** @returns {Record<string, number>} clone counts by repo */ | |
| 19 | +function cloneCounts(ctx) { | |
| 20 | + const path = join(ctx.config.dataDir, 'clones.json'); | |
| 21 | + try { | |
| 22 | + if (existsSync(path)) return JSON.parse(readFileSync(path, 'utf8')); | |
| 23 | + } catch { | |
| 24 | + /* ignore */ | |
| 25 | + } | |
| 26 | + return {}; | |
| 27 | +} | |
| 28 | + | |
| 29 | +/** | |
| 30 | + * Build (or read from cache) the full overview of one repository. | |
| 31 | + * @param {{config, repos, meta, cache}} ctx | |
| 32 | + * @param {string} name | |
| 33 | + * @returns {Promise<object|null>} | |
| 34 | + */ | |
| 35 | +export async function repoOverview(ctx, name) { | |
| 36 | + if (!ctx.repos.exists(name)) return null; | |
| 37 | + const cachePath = ctx.cache.path('repos', name, '_repo', 'overview.json'); | |
| 38 | + const cached = ctx.cache.getJSON(cachePath); | |
| 39 | + const clones = cloneCounts(ctx)[name] ?? 0; | |
| 40 | + if (cached) return { ...cached, cloneCount: clones }; | |
| 41 | + | |
| 42 | + const meta = ctx.meta.get(name) ?? {}; | |
| 43 | + const head = await ctx.repos.head(name); | |
| 44 | + const defaultBranch = await ctx.repos.defaultBranch(name); | |
| 45 | + const branches = await ctx.repos.branches(name); | |
| 46 | + const tags = await ctx.repos.tags(name); | |
| 47 | + let commitCount = 0; | |
| 48 | + let languages = { languages: [], totalBytes: 0 }; | |
| 49 | + let license = null; | |
| 50 | + if (head) { | |
| 51 | + commitCount = await ctx.repos.commitCount(name, head); | |
| 52 | + languages = computeLanguages(await ctx.repos.allFiles(name, head)); | |
| 53 | + license = await ctx.repos.license(name, head); | |
| 54 | + } | |
| 55 | + const overview = { | |
| 56 | + name, | |
| 57 | + description: meta.description ?? '', | |
| 58 | + topics: meta.topics ?? [], | |
| 59 | + homepage: meta.homepage ?? '', | |
| 60 | + pinned: Boolean(meta.pinned), | |
| 61 | + created: meta.created ?? null, | |
| 62 | + defaultBranch, | |
| 63 | + head, | |
| 64 | + empty: head === null, | |
| 65 | + lastPush: await ctx.repos.lastPushDate(name), | |
| 66 | + commitCount, | |
| 67 | + branchCount: branches.length, | |
| 68 | + tagCount: tags.length, | |
| 69 | + sizeBytes: await ctx.repos.sizeBytes(name), | |
| 70 | + languages: languages.languages, | |
| 71 | + languageBytes: languages.totalBytes, | |
| 72 | + topLanguage: languages.languages[0]?.name ?? null, | |
| 73 | + license: license?.name ?? null, | |
| 74 | + licensePath: license?.path ?? null, | |
| 75 | + cloneUrl: `${ctx.config.publicUrl}/${name}.git`, | |
| 76 | + url: `${ctx.config.publicUrl}/${name}`, | |
| 77 | + }; | |
| 78 | + ctx.cache.setJSON(cachePath, overview); | |
| 79 | + return { ...overview, cloneCount: clones }; | |
| 80 | +} | |
| 81 | + | |
| 82 | +/** | |
| 83 | + * Overviews for every repo, most recently pushed first. | |
| 84 | + * @param {{config, repos, meta, cache}} ctx | |
| 85 | + * @returns {Promise<object[]>} | |
| 86 | + */ | |
| 87 | +export async function allOverviews(ctx) { | |
| 88 | + const names = ctx.repos.list(); | |
| 89 | + const overviews = await mapLimit(names, 8, (name) => repoOverview(ctx, name)); | |
| 90 | + return overviews | |
| 91 | + .filter(Boolean) | |
| 92 | + .sort((a, b) => String(b.lastPush ?? '').localeCompare(String(a.lastPush ?? ''))); | |
| 93 | +} | |
| 94 | + | |
| 95 | +/** | |
| 96 | + * Site-wide stats for the home hero + /api/v1/stats. | |
| 97 | + * @param {{config, repos, meta, cache}} ctx | |
| 98 | + */ | |
| 99 | +export async function siteStats(ctx) { | |
| 100 | + const overviews = await allOverviews(ctx); | |
| 101 | + const languages = new Set(); | |
| 102 | + let commits = 0; | |
| 103 | + for (const o of overviews) { | |
| 104 | + commits += o.commitCount; | |
| 105 | + for (const lang of o.languages) languages.add(lang.name); | |
| 106 | + } | |
| 107 | + return { | |
| 108 | + repos: overviews.length, | |
| 109 | + commits, | |
| 110 | + languages: languages.size, | |
| 111 | + languageNames: [...languages].sort(), | |
| 112 | + }; | |
| 113 | +} | |
added
src/lib/search.mjs
+107 −0
@@ -0,0 +1,107 @@ | ||
| 1 | +/** | |
| 2 | + * ───────────────────────────────────────────── | |
| 3 | + * SPB Git — Personal Git Platform | |
| 4 | + * ───────────────────────────────────────────── | |
| 5 | + * Author : Simon-Pierre Boucher | |
| 6 | + * Contact : contact@spboucher.ai | |
| 7 | + * File : src/lib/search.mjs | |
| 8 | + * Purpose : Tiny inverted index over names, topics, descriptions, READMEs | |
| 9 | + * License : MIT © Simon-Pierre Boucher | |
| 10 | + * ───────────────────────────────────────────── | |
| 11 | + */ | |
| 12 | + | |
| 13 | +import { allOverviews } from './overview.mjs'; | |
| 14 | + | |
| 15 | +/** Strip markdown/code noise from a README for indexing + snippets. */ | |
| 16 | +export function stripMarkdown(source) { | |
| 17 | + return String(source) | |
| 18 | + .replace(/```[\s\S]*?```/g, ' ') | |
| 19 | + .replace(/`[^`]*`/g, ' ') | |
| 20 | + .replace(/!\[[^\]]*\]\([^)]*\)/g, ' ') | |
| 21 | + .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') | |
| 22 | + .replace(/<[^>]+>/g, ' ') | |
| 23 | + .replace(/[#>*_~|-]{1,}/g, ' ') | |
| 24 | + .replace(/\s+/g, ' ') | |
| 25 | + .trim(); | |
| 26 | +} | |
| 27 | + | |
| 28 | +/** | |
| 29 | + * Build the search index (cached globally, refreshed on push). | |
| 30 | + * @param {{config, repos, meta, cache}} ctx | |
| 31 | + * @returns {Promise<{entries: object[]}>} | |
| 32 | + */ | |
| 33 | +export async function buildSearchIndex(ctx) { | |
| 34 | + const cachePath = ctx.cache.path('global', 'search-index.json'); | |
| 35 | + const cached = ctx.cache.getJSON(cachePath); | |
| 36 | + if (cached) return cached; | |
| 37 | + const overviews = await allOverviews(ctx); | |
| 38 | + const entries = []; | |
| 39 | + for (const o of overviews) { | |
| 40 | + let readmeText = ''; | |
| 41 | + if (o.head) { | |
| 42 | + const readme = await ctx.repos.readme(o.name, o.head); | |
| 43 | + if (readme) readmeText = stripMarkdown(readme.content.toString('utf8')).slice(0, 20000); | |
| 44 | + } | |
| 45 | + entries.push({ | |
| 46 | + name: o.name, | |
| 47 | + description: o.description, | |
| 48 | + topics: o.topics, | |
| 49 | + topLanguage: o.topLanguage, | |
| 50 | + lastPush: o.lastPush, | |
| 51 | + readmeText, | |
| 52 | + }); | |
| 53 | + } | |
| 54 | + const index = { entries, builtAt: new Date().toISOString() }; | |
| 55 | + ctx.cache.setJSON(cachePath, index); | |
| 56 | + return index; | |
| 57 | +} | |
| 58 | + | |
| 59 | +/** | |
| 60 | + * Query the index. Returns grouped, scored results. | |
| 61 | + * @param {{config, repos, meta, cache}} ctx | |
| 62 | + * @param {string} query | |
| 63 | + * @returns {Promise<{repos: object[], readmes: object[]}>} | |
| 64 | + */ | |
| 65 | +export async function search(ctx, query) { | |
| 66 | + const terms = String(query ?? '') | |
| 67 | + .toLowerCase() | |
| 68 | + .split(/\s+/) | |
| 69 | + .filter((t) => t.length >= 2) | |
| 70 | + .slice(0, 8); | |
| 71 | + if (terms.length === 0) return { repos: [], readmes: [] }; | |
| 72 | + const { entries } = await buildSearchIndex(ctx); | |
| 73 | + const repoHits = []; | |
| 74 | + const readmeHits = []; | |
| 75 | + for (const entry of entries) { | |
| 76 | + const name = entry.name.toLowerCase(); | |
| 77 | + const description = (entry.description ?? '').toLowerCase(); | |
| 78 | + const topics = (entry.topics ?? []).map((t) => t.toLowerCase()); | |
| 79 | + const readme = (entry.readmeText ?? '').toLowerCase(); | |
| 80 | + let score = 0; | |
| 81 | + let readmeMatch = false; | |
| 82 | + for (const term of terms) { | |
| 83 | + if (name === term) score += 100; | |
| 84 | + else if (name.includes(term)) score += 40; | |
| 85 | + if (description.includes(term)) score += 15; | |
| 86 | + if (topics.some((t) => t.includes(term))) score += 25; | |
| 87 | + if (readme.includes(term)) { | |
| 88 | + score += 5; | |
| 89 | + readmeMatch = true; | |
| 90 | + } | |
| 91 | + } | |
| 92 | + if (score === 0) continue; | |
| 93 | + const hit = { ...entry, score }; | |
| 94 | + if (name.includes(terms[0]) || description.includes(terms[0]) || topics.some((t) => t.includes(terms[0]))) { | |
| 95 | + repoHits.push(hit); | |
| 96 | + } | |
| 97 | + if (readmeMatch) { | |
| 98 | + const idx = readme.indexOf(terms[0]); | |
| 99 | + const start = Math.max(0, idx - 80); | |
| 100 | + const snippet = entry.readmeText.slice(start, start + 220).trim(); | |
| 101 | + readmeHits.push({ ...hit, snippet: (start > 0 ? '…' : '') + snippet + '…' }); | |
| 102 | + } | |
| 103 | + } | |
| 104 | + repoHits.sort((a, b) => b.score - a.score); | |
| 105 | + readmeHits.sort((a, b) => b.score - a.score); | |
| 106 | + return { repos: repoHits.slice(0, 20), readmes: readmeHits.slice(0, 20) }; | |
| 107 | +} | |
added
src/lib/store.mjs
+123 −0
@@ -0,0 +1,123 @@ | ||
| 1 | +/** | |
| 2 | + * ───────────────────────────────────────────── | |
| 3 | + * SPB Git — Personal Git Platform | |
| 4 | + * ───────────────────────────────────────────── | |
| 5 | + * Author : Simon-Pierre Boucher | |
| 6 | + * Contact : contact@spboucher.ai | |
| 7 | + * File : src/lib/store.mjs | |
| 8 | + * Purpose : meta.json repo index + activity feed (filesystem is the database) | |
| 9 | + * License : MIT © Simon-Pierre Boucher | |
| 10 | + * ───────────────────────────────────────────── | |
| 11 | + */ | |
| 12 | + | |
| 13 | +import { readFileSync, appendFileSync, existsSync, mkdirSync } from 'node:fs'; | |
| 14 | +import { join } from 'node:path'; | |
| 15 | +import { atomicWriteJSON } from './util.mjs'; | |
| 16 | + | |
| 17 | +const META_FILE = 'meta.json'; | |
| 18 | +const ACTIVITY_FILE = 'activity.jsonl'; | |
| 19 | +const ACTIVITY_MAX_READ = 500; | |
| 20 | + | |
| 21 | +/** | |
| 22 | + * Repo metadata store backed by `data/meta.json`. | |
| 23 | + * Shape: { version: 1, repos: { [name]: { description, topics, homepage, pinned, created, defaultBranch } } } | |
| 24 | + */ | |
| 25 | +export class MetaStore { | |
| 26 | + /** @param {string} dataDir */ | |
| 27 | + constructor(dataDir) { | |
| 28 | + this.dataDir = dataDir; | |
| 29 | + this.path = join(dataDir, META_FILE); | |
| 30 | + } | |
| 31 | + | |
| 32 | + /** @returns {{version: number, repos: Record<string, object>}} */ | |
| 33 | + load() { | |
| 34 | + if (!existsSync(this.path)) return { version: 1, repos: {} }; | |
| 35 | + try { | |
| 36 | + const parsed = JSON.parse(readFileSync(this.path, 'utf8')); | |
| 37 | + if (parsed && typeof parsed.repos === 'object') return parsed; | |
| 38 | + } catch { | |
| 39 | + /* corrupted meta falls back to empty — bare repos remain the truth */ | |
| 40 | + } | |
| 41 | + return { version: 1, repos: {} }; | |
| 42 | + } | |
| 43 | + | |
| 44 | + /** @param {object} meta */ | |
| 45 | + save(meta) { | |
| 46 | + atomicWriteJSON(this.path, meta); | |
| 47 | + } | |
| 48 | + | |
| 49 | + /** | |
| 50 | + * @param {string} name | |
| 51 | + * @returns {object|null} | |
| 52 | + */ | |
| 53 | + get(name) { | |
| 54 | + return this.load().repos[name] ?? null; | |
| 55 | + } | |
| 56 | + | |
| 57 | + /** | |
| 58 | + * Create or merge a repo entry. | |
| 59 | + * @param {string} name | |
| 60 | + * @param {object} fields | |
| 61 | + * @returns {object} the updated entry | |
| 62 | + */ | |
| 63 | + upsert(name, fields = {}) { | |
| 64 | + const meta = this.load(); | |
| 65 | + const existing = meta.repos[name] ?? { | |
| 66 | + description: '', | |
| 67 | + topics: [], | |
| 68 | + homepage: '', | |
| 69 | + pinned: false, | |
| 70 | + created: new Date().toISOString(), | |
| 71 | + defaultBranch: 'main', | |
| 72 | + }; | |
| 73 | + meta.repos[name] = { ...existing, ...fields }; | |
| 74 | + this.save(meta); | |
| 75 | + return meta.repos[name]; | |
| 76 | + } | |
| 77 | + | |
| 78 | + /** @param {string} name */ | |
| 79 | + remove(name) { | |
| 80 | + const meta = this.load(); | |
| 81 | + delete meta.repos[name]; | |
| 82 | + this.save(meta); | |
| 83 | + } | |
| 84 | +} | |
| 85 | + | |
| 86 | +/** | |
| 87 | + * Append-only push activity feed backed by `data/activity.jsonl`. | |
| 88 | + */ | |
| 89 | +export class ActivityFeed { | |
| 90 | + /** @param {string} dataDir */ | |
| 91 | + constructor(dataDir) { | |
| 92 | + this.path = join(dataDir, ACTIVITY_FILE); | |
| 93 | + this.dataDir = dataDir; | |
| 94 | + } | |
| 95 | + | |
| 96 | + /** | |
| 97 | + * @param {{repo: string, ref: string, commits: number, sha: string}} event | |
| 98 | + */ | |
| 99 | + append(event) { | |
| 100 | + mkdirSync(this.dataDir, { recursive: true }); | |
| 101 | + const record = { ...event, at: new Date().toISOString() }; | |
| 102 | + appendFileSync(this.path, JSON.stringify(record) + '\n'); | |
| 103 | + } | |
| 104 | + | |
| 105 | + /** | |
| 106 | + * @param {number} [limit] | |
| 107 | + * @returns {object[]} newest-first events | |
| 108 | + */ | |
| 109 | + recent(limit = 15) { | |
| 110 | + if (!existsSync(this.path)) return []; | |
| 111 | + const lines = readFileSync(this.path, 'utf8').trim().split('\n'); | |
| 112 | + const slice = lines.slice(-ACTIVITY_MAX_READ); | |
| 113 | + const events = []; | |
| 114 | + for (const line of slice) { | |
| 115 | + try { | |
| 116 | + events.push(JSON.parse(line)); | |
| 117 | + } catch { | |
| 118 | + /* skip torn line */ | |
| 119 | + } | |
| 120 | + } | |
| 121 | + return events.reverse().slice(0, limit); | |
| 122 | + } | |
| 123 | +} | |
added
src/lib/util.mjs
+167 −0
@@ -0,0 +1,167 @@ | ||
| 1 | +/** | |
| 2 | + * ───────────────────────────────────────────── | |
| 3 | + * SPB Git — Personal Git Platform | |
| 4 | + * ───────────────────────────────────────────── | |
| 5 | + * Author : Simon-Pierre Boucher | |
| 6 | + * Contact : contact@spboucher.ai | |
| 7 | + * File : src/lib/util.mjs | |
| 8 | + * Purpose : Small shared helpers (validation, formatting, atomic IO) | |
| 9 | + * License : MIT © Simon-Pierre Boucher | |
| 10 | + * ───────────────────────────────────────────── | |
| 11 | + */ | |
| 12 | + | |
| 13 | +import { writeFileSync, renameSync, mkdirSync } from 'node:fs'; | |
| 14 | +import { dirname, join, normalize } from 'node:path'; | |
| 15 | +import { randomBytes, createHash } from 'node:crypto'; | |
| 16 | + | |
| 17 | +/** Repo naming rule from CLAUDE.md §3.1 — reject everything else hard. */ | |
| 18 | +export const REPO_NAME_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/; | |
| 19 | + | |
| 20 | +/** | |
| 21 | + * @param {string} name | |
| 22 | + * @returns {boolean} true when the name is a safe, valid repository name | |
| 23 | + */ | |
| 24 | +export function isValidRepoName(name) { | |
| 25 | + if (typeof name !== 'string' || !REPO_NAME_RE.test(name)) return false; | |
| 26 | + if (name === '.' || name === '..' || name.includes('..')) return false; | |
| 27 | + if (name.endsWith('.git')) return false; | |
| 28 | + return true; | |
| 29 | +} | |
| 30 | + | |
| 31 | +/** | |
| 32 | + * Join `child` under `root` and guarantee the result stays inside `root`. | |
| 33 | + * @param {string} root absolute base directory | |
| 34 | + * @param {...string} segments path pieces (may come from user input) | |
| 35 | + * @returns {string} normalized absolute path | |
| 36 | + * @throws when the resolved path escapes the root | |
| 37 | + */ | |
| 38 | +export function safeJoin(root, ...segments) { | |
| 39 | + const joined = normalize(join(root, ...segments)); | |
| 40 | + const normalizedRoot = normalize(root).replace(/\/+$/, ''); | |
| 41 | + if (joined !== normalizedRoot && !joined.startsWith(normalizedRoot + '/')) { | |
| 42 | + throw new Error('path traversal rejected'); | |
| 43 | + } | |
| 44 | + return joined; | |
| 45 | +} | |
| 46 | + | |
| 47 | +/** | |
| 48 | + * Validate a repo-relative tree path (no traversal, no absolute, no NUL). | |
| 49 | + * @param {string} path | |
| 50 | + * @returns {boolean} | |
| 51 | + */ | |
| 52 | +export function isValidTreePath(path) { | |
| 53 | + if (typeof path !== 'string') return false; | |
| 54 | + if (path.includes('\0') || path.startsWith('/')) return false; | |
| 55 | + const parts = path.split('/'); | |
| 56 | + return parts.every((p) => p !== '' && p !== '.' && p !== '..'); | |
| 57 | +} | |
| 58 | + | |
| 59 | +/** | |
| 60 | + * Write JSON atomically (tmp file + rename) so readers never see partial data. | |
| 61 | + * @param {string} path | |
| 62 | + * @param {unknown} value | |
| 63 | + */ | |
| 64 | +export function atomicWriteJSON(path, value) { | |
| 65 | + mkdirSync(dirname(path), { recursive: true }); | |
| 66 | + const tmp = `${path}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`; | |
| 67 | + writeFileSync(tmp, JSON.stringify(value, null, 2) + '\n', { mode: 0o600 }); | |
| 68 | + renameSync(tmp, path); | |
| 69 | +} | |
| 70 | + | |
| 71 | +/** | |
| 72 | + * @param {number} bytes | |
| 73 | + * @returns {string} human size, e.g. "4.2 MB" | |
| 74 | + */ | |
| 75 | +export function formatBytes(bytes) { | |
| 76 | + if (!Number.isFinite(bytes) || bytes < 0) return '0 B'; | |
| 77 | + const units = ['B', 'KB', 'MB', 'GB', 'TB']; | |
| 78 | + let i = 0; | |
| 79 | + let value = bytes; | |
| 80 | + while (value >= 1024 && i < units.length - 1) { | |
| 81 | + value /= 1024; | |
| 82 | + i += 1; | |
| 83 | + } | |
| 84 | + return `${i === 0 ? value : value.toFixed(1)} ${units[i]}`; | |
| 85 | +} | |
| 86 | + | |
| 87 | +/** | |
| 88 | + * @param {Date|number|string} input | |
| 89 | + * @returns {string} GitHub-style relative time ("3 h ago") | |
| 90 | + */ | |
| 91 | +export function relativeTime(input) { | |
| 92 | + const date = input instanceof Date ? input : new Date(input); | |
| 93 | + const seconds = Math.floor((Date.now() - date.getTime()) / 1000); | |
| 94 | + if (!Number.isFinite(seconds)) return ''; | |
| 95 | + if (seconds < 0) return 'just now'; | |
| 96 | + if (seconds < 45) return 'just now'; | |
| 97 | + if (seconds < 90) return '1 min ago'; | |
| 98 | + const minutes = Math.floor(seconds / 60); | |
| 99 | + if (minutes < 60) return `${minutes} min ago`; | |
| 100 | + const hours = Math.floor(minutes / 60); | |
| 101 | + if (hours < 24) return `${hours} h ago`; | |
| 102 | + const days = Math.floor(hours / 24); | |
| 103 | + if (days < 30) return days === 1 ? 'yesterday' : `${days} days ago`; | |
| 104 | + const months = Math.floor(days / 30); | |
| 105 | + if (months < 12) return `${months} mo ago`; | |
| 106 | + const years = Math.floor(days / 365); | |
| 107 | + return years <= 1 ? '1 year ago' : `${years} years ago`; | |
| 108 | +} | |
| 109 | + | |
| 110 | +/** | |
| 111 | + * @param {string} text | |
| 112 | + * @returns {string} HTML-escaped text | |
| 113 | + */ | |
| 114 | +export function escapeHtml(text) { | |
| 115 | + return String(text) | |
| 116 | + .replaceAll('&', '&') | |
| 117 | + .replaceAll('<', '<') | |
| 118 | + .replaceAll('>', '>') | |
| 119 | + .replaceAll('"', '"') | |
| 120 | + .replaceAll("'", '''); | |
| 121 | +} | |
| 122 | + | |
| 123 | +/** | |
| 124 | + * Deterministic 5×5 identicon SVG for an author email. | |
| 125 | + * @param {string} email | |
| 126 | + * @param {number} [size] rendered square size in px | |
| 127 | + * @returns {string} inline SVG markup | |
| 128 | + */ | |
| 129 | +export function identiconSvg(email, size = 32) { | |
| 130 | + const hash = createHash('sha256').update(email.trim().toLowerCase()).digest(); | |
| 131 | + const hue = ((hash[0] << 8) | hash[1]) % 360; | |
| 132 | + const fg = `hsl(${hue} 55% 55%)`; | |
| 133 | + const bg = 'transparent'; | |
| 134 | + const cell = size / 5; | |
| 135 | + let rects = ''; | |
| 136 | + for (let x = 0; x < 3; x += 1) { | |
| 137 | + for (let y = 0; y < 5; y += 1) { | |
| 138 | + if (hash[2 + x * 5 + y] % 2 === 0) continue; | |
| 139 | + for (const cx of x === 2 ? [2] : [x, 4 - x]) { | |
| 140 | + rects += `<rect x="${(cx * cell).toFixed(2)}" y="${(y * cell).toFixed(2)}" width="${cell.toFixed(2)}" height="${cell.toFixed(2)}"/>`; | |
| 141 | + } | |
| 142 | + } | |
| 143 | + } | |
| 144 | + return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" role="img" aria-label="identicon"><rect width="${size}" height="${size}" fill="${bg}"/><g fill="${fg}">${rects}</g></svg>`; | |
| 145 | +} | |
| 146 | + | |
| 147 | +/** | |
| 148 | + * Run at most `limit` async jobs concurrently. | |
| 149 | + * @template T,R | |
| 150 | + * @param {T[]} items | |
| 151 | + * @param {number} limit | |
| 152 | + * @param {(item: T, index: number) => Promise<R>} worker | |
| 153 | + * @returns {Promise<R[]>} results in input order | |
| 154 | + */ | |
| 155 | +export async function mapLimit(items, limit, worker) { | |
| 156 | + const results = new Array(items.length); | |
| 157 | + let next = 0; | |
| 158 | + const runners = Array.from({ length: Math.min(limit, items.length) }, async () => { | |
| 159 | + while (next < items.length) { | |
| 160 | + const index = next; | |
| 161 | + next += 1; | |
| 162 | + results[index] = await worker(items[index], index); | |
| 163 | + } | |
| 164 | + }); | |
| 165 | + await Promise.all(runners); | |
| 166 | + return results; | |
| 167 | +} | |
added
src/stats/activity.mjs
+148 −0
@@ -0,0 +1,148 @@ | ||
| 1 | +/** | |
| 2 | + * ───────────────────────────────────────────── | |
| 3 | + * SPB Git — Personal Git Platform | |
| 4 | + * ───────────────────────────────────────────── | |
| 5 | + * Author : Simon-Pierre Boucher | |
| 6 | + * Contact : contact@spboucher.ai | |
| 7 | + * File : src/stats/activity.mjs | |
| 8 | + * Purpose : Contribution heatmap (52 weeks) + commit calendar data | |
| 9 | + * License : MIT © Simon-Pierre Boucher | |
| 10 | + * ───────────────────────────────────────────── | |
| 11 | + */ | |
| 12 | + | |
| 13 | +const DAY_MS = 86400000; | |
| 14 | +const WEEKS = 52; | |
| 15 | +const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; | |
| 16 | + | |
| 17 | +/** | |
| 18 | + * Bucket unix commit timestamps (seconds) into per-day counts. | |
| 19 | + * @param {number[]} timestamps | |
| 20 | + * @returns {Map<string, number>} ISO date (YYYY-MM-DD) → count | |
| 21 | + */ | |
| 22 | +export function bucketByDay(timestamps) { | |
| 23 | + const days = new Map(); | |
| 24 | + for (const ts of timestamps) { | |
| 25 | + const iso = new Date(ts * 1000).toISOString().slice(0, 10); | |
| 26 | + days.set(iso, (days.get(iso) ?? 0) + 1); | |
| 27 | + } | |
| 28 | + return days; | |
| 29 | +} | |
| 30 | + | |
| 31 | +/** | |
| 32 | + * Build the GitHub-style contribution calendar grid for the last 52 weeks. | |
| 33 | + * @param {Map<string, number>} dayCounts | |
| 34 | + * @param {Date} [today] injection point for tests | |
| 35 | + * @returns {{weeks: Array<Array<{date: string, count: number, level: 0|1|2|3|4}|null>>, months: Array<{index: number, label: string}>, total: number, max: number}} | |
| 36 | + */ | |
| 37 | +export function buildCalendar(dayCounts, today = new Date()) { | |
| 38 | + const end = new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate())); | |
| 39 | + // Grid ends on the current day; columns are weeks starting Sunday. | |
| 40 | + const endDow = end.getUTCDay(); | |
| 41 | + const start = new Date(end.getTime() - ((WEEKS - 1) * 7 + endDow) * DAY_MS); | |
| 42 | + | |
| 43 | + let max = 0; | |
| 44 | + let total = 0; | |
| 45 | + const weeks = []; | |
| 46 | + const months = []; | |
| 47 | + let lastMonth = -1; | |
| 48 | + for (let w = 0; w < WEEKS; w += 1) { | |
| 49 | + const week = []; | |
| 50 | + for (let d = 0; d < 7; d += 1) { | |
| 51 | + const date = new Date(start.getTime() + (w * 7 + d) * DAY_MS); | |
| 52 | + if (date.getTime() > end.getTime()) { | |
| 53 | + week.push(null); | |
| 54 | + continue; | |
| 55 | + } | |
| 56 | + const iso = date.toISOString().slice(0, 10); | |
| 57 | + const count = dayCounts.get(iso) ?? 0; | |
| 58 | + total += count; | |
| 59 | + if (count > max) max = count; | |
| 60 | + week.push({ date: iso, count }); | |
| 61 | + if (d === 0) { | |
| 62 | + const month = date.getUTCMonth(); | |
| 63 | + if (month !== lastMonth) { | |
| 64 | + months.push({ index: w, label: MONTHS[month] }); | |
| 65 | + lastMonth = month; | |
| 66 | + } | |
| 67 | + } | |
| 68 | + } | |
| 69 | + weeks.push(week); | |
| 70 | + } | |
| 71 | + // Quantize to 5 levels the way GitHub does (quartiles of the max). | |
| 72 | + for (const week of weeks) { | |
| 73 | + for (const day of week) { | |
| 74 | + if (!day) continue; | |
| 75 | + day.level = day.count === 0 ? 0 : Math.min(4, Math.ceil((day.count / Math.max(1, max)) * 4)); | |
| 76 | + } | |
| 77 | + } | |
| 78 | + // Drop a leading month label crammed against the second one. | |
| 79 | + if (months.length >= 2 && months[1].index - months[0].index < 3) months.shift(); | |
| 80 | + return { weeks, months, total, max }; | |
| 81 | +} | |
| 82 | + | |
| 83 | +/** | |
| 84 | + * Render the contribution calendar as an accessible SVG. | |
| 85 | + * @param {ReturnType<typeof buildCalendar>} calendar | |
| 86 | + * @returns {string} SVG markup | |
| 87 | + */ | |
| 88 | +export function calendarSvg(calendar) { | |
| 89 | + const cell = 11; | |
| 90 | + const gap = 3; | |
| 91 | + const left = 30; | |
| 92 | + const top = 20; | |
| 93 | + const width = left + WEEKS * (cell + gap) + 4; | |
| 94 | + const height = top + 7 * (cell + gap) + 4; | |
| 95 | + const parts = []; | |
| 96 | + parts.push( | |
| 97 | + `<svg xmlns="http://www.w3.org/2000/svg" class="heatmap-svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}" role="img" aria-label="Commit activity, last 52 weeks">`, | |
| 98 | + ); | |
| 99 | + for (const month of calendar.months) { | |
| 100 | + parts.push( | |
| 101 | + `<text x="${left + month.index * (cell + gap)}" y="12" class="heatmap-label">${month.label}</text>`, | |
| 102 | + ); | |
| 103 | + } | |
| 104 | + const dayLabels = [ | |
| 105 | + [1, 'Mon'], | |
| 106 | + [3, 'Wed'], | |
| 107 | + [5, 'Fri'], | |
| 108 | + ]; | |
| 109 | + for (const [row, label] of dayLabels) { | |
| 110 | + parts.push( | |
| 111 | + `<text x="0" y="${top + row * (cell + gap) + cell - 2}" class="heatmap-label">${label}</text>`, | |
| 112 | + ); | |
| 113 | + } | |
| 114 | + calendar.weeks.forEach((week, w) => { | |
| 115 | + week.forEach((day, d) => { | |
| 116 | + if (!day) return; | |
| 117 | + const x = left + w * (cell + gap); | |
| 118 | + const y = top + d * (cell + gap); | |
| 119 | + const plural = day.count === 1 ? 'commit' : 'commits'; | |
| 120 | + parts.push( | |
| 121 | + `<rect x="${x}" y="${y}" width="${cell}" height="${cell}" rx="2" class="heatmap-cell" data-level="${day.level}"><title>${day.count} ${plural} on ${day.date}</title></rect>`, | |
| 122 | + ); | |
| 123 | + }); | |
| 124 | + }); | |
| 125 | + parts.push('</svg>'); | |
| 126 | + return parts.join(''); | |
| 127 | +} | |
| 128 | + | |
| 129 | +/** | |
| 130 | + * Aggregate commit timestamps across every repo, then build calendar + SVG. | |
| 131 | + * Cached globally, refreshed on push. | |
| 132 | + * @param {{repos: object, cache: object}} ctx | |
| 133 | + * @returns {Promise<{svg: string, total: number}>} | |
| 134 | + */ | |
| 135 | +export async function contributionCalendar(ctx) { | |
| 136 | + const cachePath = ctx.cache.path('global', 'heatmap.json'); | |
| 137 | + const cached = ctx.cache.getJSON(cachePath); | |
| 138 | + if (cached) return cached; | |
| 139 | + const all = []; | |
| 140 | + for (const name of ctx.repos.list()) { | |
| 141 | + const stamps = await ctx.repos.commitTimestamps(name); | |
| 142 | + all.push(...stamps); | |
| 143 | + } | |
| 144 | + const calendar = buildCalendar(bucketByDay(all)); | |
| 145 | + const result = { svg: calendarSvg(calendar), total: calendar.total }; | |
| 146 | + ctx.cache.setJSON(cachePath, result); | |
| 147 | + return result; | |
| 148 | +} | |
added
src/stats/languages.mjs
+158 −0
@@ -0,0 +1,158 @@ | ||
| 1 | +/** | |
| 2 | + * ───────────────────────────────────────────── | |
| 3 | + * SPB Git — Personal Git Platform | |
| 4 | + * ───────────────────────────────────────────── | |
| 5 | + * Author : Simon-Pierre Boucher | |
| 6 | + * Contact : contact@spboucher.ai | |
| 7 | + * File : src/stats/languages.mjs | |
| 8 | + * Purpose : Linguist-style language detection, byte percentages, colors | |
| 9 | + * License : MIT © Simon-Pierre Boucher | |
| 10 | + * ───────────────────────────────────────────── | |
| 11 | + */ | |
| 12 | + | |
| 13 | +/** | |
| 14 | + * Curated subset of GitHub Linguist languages. | |
| 15 | + * type: 'programming' | 'markup' | 'data' | 'prose' | |
| 16 | + * Only programming + markup count toward the language bar (Linguist behavior). | |
| 17 | + */ | |
| 18 | +export const LANGUAGES = Object.freeze({ | |
| 19 | + JavaScript: { color: '#f1e05a', type: 'programming', extensions: ['.js', '.mjs', '.cjs', '.jsx'] }, | |
| 20 | + TypeScript: { color: '#3178c6', type: 'programming', extensions: ['.ts', '.tsx', '.mts', '.cts'] }, | |
| 21 | + Python: { color: '#3572A5', type: 'programming', extensions: ['.py', '.pyw', '.pyi'] }, | |
| 22 | + Go: { color: '#00ADD8', type: 'programming', extensions: ['.go'] }, | |
| 23 | + Rust: { color: '#dea584', type: 'programming', extensions: ['.rs'] }, | |
| 24 | + C: { color: '#555555', type: 'programming', extensions: ['.c', '.h'] }, | |
| 25 | + 'C++': { color: '#f34b7d', type: 'programming', extensions: ['.cpp', '.cc', '.cxx', '.hpp', '.hh', '.hxx'] }, | |
| 26 | + 'C#': { color: '#178600', type: 'programming', extensions: ['.cs'] }, | |
| 27 | + Java: { color: '#b07219', type: 'programming', extensions: ['.java'] }, | |
| 28 | + Kotlin: { color: '#A97BFF', type: 'programming', extensions: ['.kt', '.kts'] }, | |
| 29 | + Swift: { color: '#F05138', type: 'programming', extensions: ['.swift'] }, | |
| 30 | + 'Objective-C': { color: '#438eff', type: 'programming', extensions: ['.m', '.mm'] }, | |
| 31 | + Ruby: { color: '#701516', type: 'programming', extensions: ['.rb', '.rake', '.gemspec'] }, | |
| 32 | + PHP: { color: '#4F5D95', type: 'programming', extensions: ['.php'] }, | |
| 33 | + Shell: { color: '#89e051', type: 'programming', extensions: ['.sh', '.bash', '.zsh', '.fish'] }, | |
| 34 | + PowerShell: { color: '#012456', type: 'programming', extensions: ['.ps1', '.psm1'] }, | |
| 35 | + Perl: { color: '#0298c3', type: 'programming', extensions: ['.pl', '.pm'] }, | |
| 36 | + Lua: { color: '#000080', type: 'programming', extensions: ['.lua'] }, | |
| 37 | + R: { color: '#198CE7', type: 'programming', extensions: ['.r', '.R'] }, | |
| 38 | + Julia: { color: '#a270ba', type: 'programming', extensions: ['.jl'] }, | |
| 39 | + Scala: { color: '#c22d40', type: 'programming', extensions: ['.scala', '.sc'] }, | |
| 40 | + Haskell: { color: '#5e5086', type: 'programming', extensions: ['.hs'] }, | |
| 41 | + Elixir: { color: '#6e4a7e', type: 'programming', extensions: ['.ex', '.exs'] }, | |
| 42 | + Erlang: { color: '#B83998', type: 'programming', extensions: ['.erl', '.hrl'] }, | |
| 43 | + Clojure: { color: '#db5855', type: 'programming', extensions: ['.clj', '.cljs', '.cljc'] }, | |
| 44 | + Dart: { color: '#00B4AB', type: 'programming', extensions: ['.dart'] }, | |
| 45 | + Zig: { color: '#ec915c', type: 'programming', extensions: ['.zig'] }, | |
| 46 | + Nim: { color: '#ffc200', type: 'programming', extensions: ['.nim'] }, | |
| 47 | + OCaml: { color: '#ef7a08', type: 'programming', extensions: ['.ml', '.mli'] }, | |
| 48 | + 'F#': { color: '#b845fc', type: 'programming', extensions: ['.fs', '.fsx'] }, | |
| 49 | + Fortran: { color: '#4d41b1', type: 'programming', extensions: ['.f90', '.f95', '.f03', '.f'] }, | |
| 50 | + MATLAB: { color: '#e16737', type: 'programming', extensions: ['.mat'] }, | |
| 51 | + Solidity: { color: '#AA6746', type: 'programming', extensions: ['.sol'] }, | |
| 52 | + Assembly: { color: '#6E4C13', type: 'programming', extensions: ['.asm', '.s'] }, | |
| 53 | + Cuda: { color: '#3A4E3A', type: 'programming', extensions: ['.cu', '.cuh'] }, | |
| 54 | + Vue: { color: '#41b883', type: 'programming', extensions: ['.vue'] }, | |
| 55 | + Svelte: { color: '#ff3e00', type: 'programming', extensions: ['.svelte'] }, | |
| 56 | + HTML: { color: '#e34c26', type: 'markup', extensions: ['.html', '.htm', '.xhtml'] }, | |
| 57 | + CSS: { color: '#663399', type: 'markup', extensions: ['.css'] }, | |
| 58 | + SCSS: { color: '#c6538c', type: 'markup', extensions: ['.scss', '.sass'] }, | |
| 59 | + Less: { color: '#1d365d', type: 'markup', extensions: ['.less'] }, | |
| 60 | + XML: { color: '#0060ac', type: 'data', extensions: ['.xml', '.xsl', '.plist'] }, | |
| 61 | + SVG: { color: '#ff9900', type: 'data', extensions: ['.svg'] }, | |
| 62 | + JSON: { color: '#292929', type: 'data', extensions: ['.json', '.jsonc'] }, | |
| 63 | + YAML: { color: '#cb171e', type: 'data', extensions: ['.yml', '.yaml'] }, | |
| 64 | + TOML: { color: '#9c4221', type: 'data', extensions: ['.toml'] }, | |
| 65 | + SQL: { color: '#e38c00', type: 'programming', extensions: ['.sql'] }, | |
| 66 | + GraphQL: { color: '#e10098', type: 'data', extensions: ['.graphql', '.gql'] }, | |
| 67 | + Markdown: { color: '#083fa1', type: 'prose', extensions: ['.md', '.markdown', '.mdx'] }, | |
| 68 | + reStructuredText: { color: '#141414', type: 'prose', extensions: ['.rst'] }, | |
| 69 | + TeX: { color: '#3D6117', type: 'markup', extensions: ['.tex', '.sty', '.bib'] }, | |
| 70 | + 'Jupyter Notebook': { color: '#DA5B0B', type: 'programming', extensions: ['.ipynb'] }, | |
| 71 | + Dockerfile: { color: '#384d54', type: 'programming', extensions: ['.dockerfile'], filenames: ['Dockerfile', 'Containerfile'] }, | |
| 72 | + Makefile: { color: '#427819', type: 'programming', extensions: ['.mk'], filenames: ['Makefile', 'GNUmakefile', 'makefile'] }, | |
| 73 | + CMake: { color: '#DA3434', type: 'programming', extensions: ['.cmake'], filenames: ['CMakeLists.txt'] }, | |
| 74 | + Nix: { color: '#7e7eff', type: 'programming', extensions: ['.nix'] }, | |
| 75 | + Terraform: { color: '#844FBA', type: 'programming', extensions: ['.tf', '.tfvars'] }, | |
| 76 | + Groovy: { color: '#4298b8', type: 'programming', extensions: ['.groovy', '.gradle'] }, | |
| 77 | + 'Vim Script': { color: '#199f4b', type: 'programming', extensions: ['.vim'], filenames: ['.vimrc'] }, | |
| 78 | + Nunjucks: { color: '#3d8137', type: 'markup', extensions: ['.njk'] }, | |
| 79 | + EJS: { color: '#a91e50', type: 'markup', extensions: ['.ejs'] }, | |
| 80 | + Handlebars: { color: '#f7931e', type: 'markup', extensions: ['.hbs'] }, | |
| 81 | + Astro: { color: '#ff5a03', type: 'programming', extensions: ['.astro'] }, | |
| 82 | + WebAssembly: { color: '#04133b', type: 'programming', extensions: ['.wat', '.wasm'] }, | |
| 83 | + Protobuf: { color: '#4a575d', type: 'data', extensions: ['.proto'] }, | |
| 84 | +}); | |
| 85 | + | |
| 86 | +/** Path patterns Linguist treats as vendored — excluded from stats. */ | |
| 87 | +const VENDORED_RE = [ | |
| 88 | + /(^|\/)node_modules\//, | |
| 89 | + /(^|\/)vendor\//, | |
| 90 | + /(^|\/)third[_-]party\//, | |
| 91 | + /(^|\/)dist\//, | |
| 92 | + /(^|\/)build\//, | |
| 93 | + /(^|\/)\.git\//, | |
| 94 | + /(^|\/)coverage\//, | |
| 95 | + /\.min\.(js|css)$/, | |
| 96 | + /(^|\/)(package-lock\.json|yarn\.lock|pnpm-lock\.yaml|Cargo\.lock|poetry\.lock|uv\.lock|composer\.lock|Gemfile\.lock)$/, | |
| 97 | + /\.(woff2?|ttf|otf|eot|png|jpe?g|gif|ico|webp|mp4|mp3|zip|gz|zst|pdf|bin|model|pt|onnx|safetensors)$/i, | |
| 98 | +]; | |
| 99 | + | |
| 100 | +const extIndex = new Map(); | |
| 101 | +const filenameIndex = new Map(); | |
| 102 | +for (const [lang, def] of Object.entries(LANGUAGES)) { | |
| 103 | + for (const ext of def.extensions ?? []) { | |
| 104 | + if (!extIndex.has(ext)) extIndex.set(ext, lang); | |
| 105 | + } | |
| 106 | + for (const fn of def.filenames ?? []) filenameIndex.set(fn, lang); | |
| 107 | +} | |
| 108 | + | |
| 109 | +/** | |
| 110 | + * Detect the language of a single path. | |
| 111 | + * @param {string} path | |
| 112 | + * @returns {string|null} | |
| 113 | + */ | |
| 114 | +export function detectLanguage(path) { | |
| 115 | + const base = path.split('/').pop() ?? path; | |
| 116 | + if (filenameIndex.has(base)) return filenameIndex.get(base); | |
| 117 | + const dot = base.lastIndexOf('.'); | |
| 118 | + if (dot <= 0) return null; | |
| 119 | + const ext = base.slice(dot).toLowerCase(); | |
| 120 | + // .R is case-sensitive in the table; try raw first, then lowercase. | |
| 121 | + return extIndex.get(base.slice(dot)) ?? extIndex.get(ext) ?? null; | |
| 122 | +} | |
| 123 | + | |
| 124 | +/** | |
| 125 | + * Compute language byte totals + percentages for a file listing. | |
| 126 | + * @param {Array<{path: string, size: number}>} files | |
| 127 | + * @returns {{languages: Array<{name: string, bytes: number, percent: number, color: string}>, totalBytes: number}} | |
| 128 | + */ | |
| 129 | +export function computeLanguages(files) { | |
| 130 | + const totals = new Map(); | |
| 131 | + for (const file of files) { | |
| 132 | + if (VENDORED_RE.some((re) => re.test(file.path))) continue; | |
| 133 | + const lang = detectLanguage(file.path); | |
| 134 | + if (!lang) continue; | |
| 135 | + const def = LANGUAGES[lang]; | |
| 136 | + if (def.type !== 'programming' && def.type !== 'markup') continue; | |
| 137 | + totals.set(lang, (totals.get(lang) ?? 0) + (file.size || 0)); | |
| 138 | + } | |
| 139 | + const totalBytes = [...totals.values()].reduce((a, b) => a + b, 0); | |
| 140 | + const languages = [...totals.entries()] | |
| 141 | + .filter(([, bytes]) => bytes > 0) | |
| 142 | + .sort((a, b) => b[1] - a[1]) | |
| 143 | + .map(([name, bytes]) => ({ | |
| 144 | + name, | |
| 145 | + bytes, | |
| 146 | + percent: totalBytes === 0 ? 0 : Math.round((bytes / totalBytes) * 1000) / 10, | |
| 147 | + color: LANGUAGES[name].color, | |
| 148 | + })); | |
| 149 | + return { languages, totalBytes }; | |
| 150 | +} | |
| 151 | + | |
| 152 | +/** | |
| 153 | + * @param {string} name | |
| 154 | + * @returns {string} linguist color (fallback grey) | |
| 155 | + */ | |
| 156 | +export function languageColor(name) { | |
| 157 | + return LANGUAGES[name]?.color ?? '#8b93a3'; | |
| 158 | +} | |
| 159 | ||