SPB Git

spb/spbgit Public MIT

SPB Git — the platform hosting itself

JavaScript 73.9% CSS 11.7% Nunjucks 11.6% Shell 2.7%
26.7 KB · 788 lines javascript
Raw Blame History
1/**2 * ─────────────────────────────────────────────3 *  SPB Git — Personal Git Platform4 * ─────────────────────────────────────────────5 *  Author  : Simon-Pierre Boucher6 *  Contact : contact@spboucher.ai7 *  File    : src/git/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