// Server-only. Kept out of the package index so browser bundles never load `node:child_process`. import { spawn } from "node:child_process"; import { access } from "node:fs/promises"; import path from "node:path"; import { gitStatusArgv, gitUntrackedFilesArgv, parseGitNameOnly } from "./compose-update.js"; import { DEFAULT_UPDATE_REMOTE, parseGitStatusPorcelain } from "./self-update.js"; export interface GitCheckoutStatus { present: boolean; remoteUrl: string | null; defaultBranch: string | null; headCommit: string | null; dirty: boolean; dirtyPaths: string[]; } export interface GitRunnerResult { ok: boolean; output: string; } export type GitRunner = (args: string[], cwd: string) => Promise; export async function defaultGitRunner(args: string[], cwd: string): Promise { return new Promise((resolve) => { const child = spawn("git", args, { cwd, env: process.env }); let output = ""; child.stdout.on("data", (chunk: Buffer | string) => { output += chunk.toString(); }); child.stderr.on("data", (chunk: Buffer | string) => { output += chunk.toString(); }); child.on("error", () => resolve({ ok: false, output })); child.on("close", (code) => resolve({ ok: code === 0, output })); }); } async function hasGitMetadata(directory: string): Promise { try { await access(path.join(directory, ".git")); return true; } catch { return false; } } /** Reads local git metadata from an existing checkout without fetching remotes. */ export async function readGitCheckout( directory: string, runGit: GitRunner = defaultGitRunner, ): Promise { const root = path.resolve(directory); if (!(await hasGitMetadata(root))) { return { present: false, remoteUrl: null, defaultBranch: null, headCommit: null, dirty: false, dirtyPaths: [], }; } const [head, branch, remote, status, untracked] = await Promise.all([ runGit(["rev-parse", "HEAD"], root), runGit(["rev-parse", "--abbrev-ref", "HEAD"], root), runGit(["remote", "get-url", DEFAULT_UPDATE_REMOTE], root), runGit(["--no-optional-locks", ...gitStatusArgv()], root), runGit(["--no-optional-locks", ...gitUntrackedFilesArgv()], root), ]); const porcelain = parseGitStatusPorcelain(status.ok ? status.output : ""); const untrackedPaths = parseGitNameOnly(untracked.ok ? untracked.output : ""); const dirtyPaths = [...new Set([...porcelain.changed, ...untrackedPaths])]; const stateReadable = status.ok && untracked.ok; return { present: true, remoteUrl: remote.ok ? remote.output.trim() || null : null, defaultBranch: branch.ok ? branch.output.trim() || null : null, headCommit: head.ok ? head.output.trim() || null : null, dirty: stateReadable ? dirtyPaths.length > 0 : true, dirtyPaths: stateReadable ? dirtyPaths : [], }; }