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 : 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