81 lines
3.1 KiB
TypeScript
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 };
|
|
}
|