import { execFile } from "node:child_process"; import path from "node:path"; import { IMG_REL, POSTS_REL, REPO_DIR } from "./paths"; /* Thin wrapper over the git CLI, run against the portfolio checkout. No * shell is involved (execFile), and every command is scoped to the paths the * manager owns, so unrelated working-tree changes are never committed. */ export const MANAGED_PATHS = [POSTS_REL, IMG_REL]; export type GitResult = { ok: boolean; code: number; stdout: string; stderr: string }; function gitEnv(): NodeJS.ProcessEnv { const env: NodeJS.ProcessEnv = { ...process.env, GIT_TERMINAL_PROMPT: "0" }; if (process.env.GIT_PUSH_TOKEN) { env.GIT_ASKPASS = process.env.GIT_ASKPASS_PATH ?? path.join(/*turbopackIgnore: true*/ process.cwd(), "scripts", "git-askpass.sh"); } return env; } function configArgs(): string[] { // The bind-mounted repo is usually owned by a different uid than the // container user; safe.directory from the command line is honored. const args = ["-c", `safe.directory=${REPO_DIR}`]; if (process.env.GIT_AUTHOR_NAME) args.push("-c", `user.name=${process.env.GIT_AUTHOR_NAME}`); if (process.env.GIT_AUTHOR_EMAIL) args.push("-c", `user.email=${process.env.GIT_AUTHOR_EMAIL}`); return args; } export function git(args: string[], timeoutMs = 60_000): Promise { return new Promise((resolve) => { execFile( "git", [...configArgs(), ...args], { cwd: REPO_DIR, env: gitEnv(), timeout: timeoutMs, maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => { const code = err ? (typeof err.code === "number" ? err.code : 1) : 0; resolve({ ok: !err, code, stdout: String(stdout), stderr: String(stderr || err?.message || "") }); } ); }); } export type ChangedFile = { code: string; path: string }; export type GitStatus = { available: boolean; error?: string; branch?: string; /** Commits relative to the upstream as of the last fetch. */ ahead?: number; behind?: number; changes: ChangedFile[]; diffStat?: string; }; export async function gitStatus(): Promise { const status = await git(["status", "--porcelain=v1", "-z", "-uall", "--", ...MANAGED_PATHS]); if (!status.ok) return { available: false, error: status.stderr.trim(), changes: [] }; const changes: ChangedFile[] = []; const entries = status.stdout.split("\0"); for (let i = 0; i < entries.length; i++) { const entry = entries[i]; if (entry.length < 4) continue; const code = entry.slice(0, 2); changes.push({ code: code.trim() || code, path: entry.slice(3) }); if (code[0] === "R" || code[0] === "C") i++; // skip the rename source } const branch = (await git(["rev-parse", "--abbrev-ref", "HEAD"])).stdout.trim(); let ahead: number | undefined; let behind: number | undefined; const counts = await git(["rev-list", "--left-right", "--count", "@{upstream}...HEAD"]); if (counts.ok) [behind, ahead] = counts.stdout.trim().split(/\s+/).map(Number); const diffStat = (await git(["diff", "--stat", "HEAD", "--", ...MANAGED_PATHS])).stdout.trim(); return { available: true, branch, ahead, behind, changes, diffStat }; }