initial commit
This commit is contained in:
80
lib/git.ts
Normal file
80
lib/git.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user