/** * ───────────────────────────────────────────── * SPB Git — Personal Git Platform * ───────────────────────────────────────────── * Author : Simon-Pierre Boucher * Contact : contact@spboucher.ai * File : cli/lib/git.mjs * Purpose : Local git helpers — workspace scan, authed push, status * License : MIT © Simon-Pierre Boucher * ───────────────────────────────────────────── */ import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; import { readdirSync, existsSync, chmodSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; const execFileAsync = promisify(execFile); const ASKPASS = join(dirname(fileURLToPath(import.meta.url)), 'askpass.sh'); /** * Run git in a directory. * @param {string} cwd * @param {string[]} args * @param {{token?: string}} [opts] token → authenticated via GIT_ASKPASS (never argv) * @returns {Promise<{ok: boolean, stdout: string, stderr: string}>} */ export async function git(cwd, args, opts = {}) { const env = { ...process.env, GIT_TERMINAL_PROMPT: '0' }; if (opts.token) { try { chmodSync(ASKPASS, 0o755); } catch { /* read-only install — askpass already executable from packaging */ } env.GIT_ASKPASS = ASKPASS; env.SPBGIT_TOKEN = opts.token; env.SPBGIT_USERNAME = 'spb'; } try { const { stdout, stderr } = await execFileAsync('git', args, { cwd, env, maxBuffer: 32 * 1024 * 1024 }); return { ok: true, stdout, stderr }; } catch (err) { return { ok: false, stdout: err.stdout ?? '', stderr: err.stderr ?? err.message }; } } /** * List workspace repositories (immediate subdirectories containing .git). * @param {string} workspace * @returns {string[]} absolute paths */ export function workspaceRepos(workspace) { if (!existsSync(workspace)) return []; return readdirSync(workspace) .filter((entry) => !entry.startsWith('.')) .map((entry) => join(workspace, entry)) .filter((dir) => existsSync(join(dir, '.git'))) .sort(); } /** * Status snapshot of one repo. * @param {string} dir * @returns {Promise<{branch: string, ahead: number, behind: number, dirty: number, hasUpstream: boolean}>} */ export async function repoStatus(dir) { const branchRes = await git(dir, ['rev-parse', '--abbrev-ref', 'HEAD']); const branch = branchRes.ok ? branchRes.stdout.trim() : '?'; const dirtyRes = await git(dir, ['status', '--porcelain']); const dirty = dirtyRes.ok ? dirtyRes.stdout.split('\n').filter(Boolean).length : 0; let ahead = 0; let behind = 0; let hasUpstream = false; const upstream = await git(dir, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}']); if (upstream.ok) { hasUpstream = true; const counts = await git(dir, ['rev-list', '--left-right', '--count', '@{u}...HEAD']); if (counts.ok) { const [b, a] = counts.stdout.trim().split('\t').map(Number); behind = b || 0; ahead = a || 0; } } return { branch, ahead, behind, dirty, hasUpstream }; }