spb/spbgit Public MIT
SPB Git — the platform hosting itself
JavaScript 73.9%
CSS 11.7%
Nunjucks 11.6%
Shell 2.7%
1/**2 * ─────────────────────────────────────────────3 * SPB Git — Personal Git Platform4 * ─────────────────────────────────────────────5 * Author : Simon-Pierre Boucher6 * Contact : contact@spboucher.ai7 * File : src/git/repo.mjs8 * Purpose : Bare-repo model — refs, trees, blobs, log, diffs, blame9 * License : MIT © Simon-Pierre Boucher10 * ─────────────────────────────────────────────11 */1213import { execFile, spawn } from 'node:child_process';14import { promisify } from 'node:util';15import { readdirSync, existsSync, statSync, renameSync, mkdirSync } from 'node:fs';16import { join } from 'node:path';17import { isValidRepoName, safeJoin, isValidTreePath } from '../lib/util.mjs';1819const execFileAsync = promisify(execFile);20const MAX_BUFFER = 64 * 1024 * 1024;21const FS = '\x01';22const RS = '\x02';2324/** Hard ceiling for diff rendering (lines) before truncation. */25const DIFF_MAX_LINES = 20000;26/** Commits walked when computing "last commit per path" before falling back. */27const TREE_LOG_WALK_CAP = 600;2829/**30 * Repository model rooted at `gitRoot`. All read operations shell out to the31 * real `git` binary — parsing porcelain/plumbing output, never buffering32 * packfiles in memory.33 */34export class Repos {35 /**36 * @param {string} gitRoot directory containing the bare `<name>.git` repos37 * @param {string} trashDir soft-delete destination38 */39 constructor(gitRoot, trashDir) {40 this.gitRoot = gitRoot;41 this.trashDir = trashDir;42 }4344 /** @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 }4950 /** @param {string} name @returns {boolean} */51 exists(name) {52 if (!isValidRepoName(name)) return false;53 return existsSync(join(this.dir(name), 'HEAD'));54 }5556 /** @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 entries65 .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 }7071 /**72 * Run git in a repo, returning stdout as a string.73 * @param {string} name74 * @param {string[]} args75 * @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 }8586 /** 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 }9596 /** 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 }104105 /**106 * Initialize a new bare repository with HEAD on `main`.107 * @param {string} name108 * @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 }116117 /**118 * Soft-delete: move the bare repo into the trash with a timestamp suffix.119 * @param {string} name120 * @returns {string} the trash path121 */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 }131132 /** @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 }137138 /** @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 }143144 /** @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 }148149 /**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 }158159 /**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 out171 .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 }178179 /**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 out190 .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 }197198 /**199 * Given the wildcard part of a URL (`<ref>/<path...>`) figure out which200 * prefix is the ref — supports branch names containing slashes.201 * @param {string} name repo202 * @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 }231232 /**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 = out251 .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 }272273 /**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 }288289 /** 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 }296297 /**298 * Paginated commit log.299 * @param {string} name300 * @param {string} sha resolved commit301 * @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 }335336 /** 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 }366367 /** @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 }372373 /** @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 }380381 /** @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 }392393 /** @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 }402403 /**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 }437438 /**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 }498499 /**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} name504 * @param {string} sha505 * @param {string} dirPath '' for root506 * @param {string[]} entryNames names (not paths) of the directory entries507 * @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);515516 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 });557558 // 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 }569570 /**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 }587588 /**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 }618619 /**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 out627 .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 }637638 /** 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}647648/**649 * Unquote a git-quoted path ("dir/\303\251t\303\251.txt" style).650 * @param {string} raw651 * @returns {string}652 */653export 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}674675/**676 * Parse a unified diff (git show/diff output) into structured files + hunks.677 * @param {string} text678 * @returns {Array<object>}679 */680export 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;689690 const pushFile = () => {691 if (file) files.push(file);692 file = null;693 hunk = null;694 };695696 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}761762/**763 * Repo directory size on disk (recursive) — used by /healthz only.764 * @param {string} dir765 * @returns {number} bytes766 */767export 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}788