SPB Git

spb/spbgit Public MIT

SPB Git — the platform hosting itself

JavaScript 73.9% CSS 11.7% Nunjucks 11.6% Shell 2.7%
3.3 KB · 88 lines javascript
Raw Blame History
1/**2 * ─────────────────────────────────────────────3 *  SPB Git — Personal Git Platform4 * ─────────────────────────────────────────────5 *  Author  : Simon-Pierre Boucher6 *  Contact : contact@spboucher.ai7 *  File    : cli/lib/git.mjs8 *  Purpose : Local git helpers — workspace scan, authed push, status9 *  License : MIT © Simon-Pierre Boucher10 * ─────────────────────────────────────────────11 */1213import { execFile } from 'node:child_process';14import { promisify } from 'node:util';15import { readdirSync, existsSync, chmodSync } from 'node:fs';16import { join, dirname } from 'node:path';17import { fileURLToPath } from 'node:url';1819const execFileAsync = promisify(execFile);20const ASKPASS = join(dirname(fileURLToPath(import.meta.url)), 'askpass.sh');2122/**23 * Run git in a directory.24 * @param {string} cwd25 * @param {string[]} args26 * @param {{token?: string}} [opts] token → authenticated via GIT_ASKPASS (never argv)27 * @returns {Promise<{ok: boolean, stdout: string, stderr: string}>}28 */29export async function git(cwd, args, opts = {}) {30  const env = { ...process.env, GIT_TERMINAL_PROMPT: '0' };31  if (opts.token) {32    try {33      chmodSync(ASKPASS, 0o755);34    } catch {35      /* read-only install — askpass already executable from packaging */36    }37    env.GIT_ASKPASS = ASKPASS;38    env.SPBGIT_TOKEN = opts.token;39    env.SPBGIT_USERNAME = 'spb';40  }41  try {42    const { stdout, stderr } = await execFileAsync('git', args, { cwd, env, maxBuffer: 32 * 1024 * 1024 });43    return { ok: true, stdout, stderr };44  } catch (err) {45    return { ok: false, stdout: err.stdout ?? '', stderr: err.stderr ?? err.message };46  }47}4849/**50 * List workspace repositories (immediate subdirectories containing .git).51 * @param {string} workspace52 * @returns {string[]} absolute paths53 */54export function workspaceRepos(workspace) {55  if (!existsSync(workspace)) return [];56  return readdirSync(workspace)57    .filter((entry) => !entry.startsWith('.'))58    .map((entry) => join(workspace, entry))59    .filter((dir) => existsSync(join(dir, '.git')))60    .sort();61}6263/**64 * Status snapshot of one repo.65 * @param {string} dir66 * @returns {Promise<{branch: string, ahead: number, behind: number, dirty: number, hasUpstream: boolean}>}67 */68export async function repoStatus(dir) {69  const branchRes = await git(dir, ['rev-parse', '--abbrev-ref', 'HEAD']);70  const branch = branchRes.ok ? branchRes.stdout.trim() : '?';71  const dirtyRes = await git(dir, ['status', '--porcelain']);72  const dirty = dirtyRes.ok ? dirtyRes.stdout.split('\n').filter(Boolean).length : 0;73  let ahead = 0;74  let behind = 0;75  let hasUpstream = false;76  const upstream = await git(dir, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}']);77  if (upstream.ok) {78    hasUpstream = true;79    const counts = await git(dir, ['rev-list', '--left-right', '--count', '@{u}...HEAD']);80    if (counts.ok) {81      const [b, a] = counts.stdout.trim().split('\t').map(Number);82      behind = b || 0;83      ahead = a || 0;84    }85  }86  return { branch, ahead, behind, dirty, hasUpstream };87}88