SPB Git

spb/spbgit Public MIT

SPB Git — the platform hosting itself

JavaScript 73.9% CSS 11.7% Nunjucks 11.6% Shell 2.7%

feat: git core — repo model, smart HTTP, hooks, PAT auth, archives, stats

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed yesterday (Aug 10, 2026) parent 0b0a29f

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('&', '&amp;')
117 + .replaceAll('<', '&lt;')
118 + .replaceAll('>', '&gt;')
119 + .replaceAll('"', '&quot;')
120 + .replaceAll("'", '&#39;');
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