Files
blog-manager/lib/git.ts
2026-11-01 13:23:44 -05:00

81 lines
3.1 KiB
TypeScript

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<GitResult> {
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<GitStatus> {
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 };
}