import { $ } from "bun"; export interface Identity { name: string; email: string } export interface GitResult { code: number; stdout: string; stderr: string } /** Settings that make studio git runs deterministic, whatever the machine's global config says. */ const BASE = [ "-c", "core.hooksPath=/dev/null", "-c", "commit.gpgsign=false", "-c", "core.quotepath=false", "-c", "merge.conflictstyle=merge", "-c", "pull.rebase=false", "-c", "init.defaultBranch=main", "-c", "protocol.ext.allow=never", // author-supplied remote URLs must never run commands ]; function env(who?: Identity): Record { const e: Record = {}; for (const [k, v] of Object.entries(process.env)) { if (v !== undefined && !k.startsWith("GIT_")) e[k] = v; // never inherit GIT_DIR, GIT_INDEX_FILE, ... } e.GIT_TERMINAL_PROMPT = "0"; // Ignore the machine's global/system git config (url.*.insteadOf, credential helpers, ...). e.GIT_CONFIG_GLOBAL = "/dev/null"; e.GIT_CONFIG_NOSYSTEM = "1"; e.GIT_SSH_COMMAND = "ssh -o BatchMode=yes"; // A fallback identity so git never refuses a merge/commit; real saves always pass `who`. const id = who ?? { name: "STCourses", email: "studio@stcourses.invalid" }; e.GIT_AUTHOR_NAME = id.name; e.GIT_AUTHOR_EMAIL = id.email; e.GIT_COMMITTER_NAME = id.name; e.GIT_COMMITTER_EMAIL = id.email; return e; } /** Runs `git ` in `cwd`. Never throws on a non-zero exit; use `gitOk` for that. */ export async function git(cwd: string, args: string[], who?: Identity): Promise { const r = await $`git ${BASE} ${args}`.cwd(cwd).env(env(who)).quiet().nothrow(); return { code: r.exitCode, stdout: r.stdout.toString(), stderr: r.stderr.toString() }; } export class GitError extends Error { constructor(public args: string[], public result: GitResult) { super(`git ${args.join(" ")} failed (${result.code}): ${result.stderr.trim() || result.stdout.trim()}`); } } export async function gitOk(cwd: string, args: string[], who?: Identity): Promise { const r = await git(cwd, args, who); if (r.code !== 0) throw new GitError(args, r); return r.stdout; } /** NUL-separated output (`-z`) to a list, so paths with spaces or newlines survive. */ export function splitZ(out: string): string[] { return out.split("\0").filter(Boolean); }