import { execa } from "execa"; /** * Parse `git status --porcelain` (v1) output into repo-relative paths. * * Each line is `XY `, where `XY` is the two-character status code. For * rename/copy entries (`R`/`C`) the path is `old -> new`; we return the new * path. Pure so it can be unit-tested without a real repository. */ export function parsePorcelain(output: string): string[] { return output .split("\n") .map((line) => line.replace(/\r$/, "")) .filter((line) => line.length > 0) .map((line) => { // Strip the "XY " status prefix (status chars + a single space). const path = line.slice(3); const arrow = path.indexOf(" -> "); return arrow === -1 ? path : path.slice(arrow + 4); }); } /** Repo-relative paths that differ from HEAD (modified, staged, or untracked). */ export async function listDirtyPaths(repoRoot: string): Promise { const { stdout } = await execa("git", ["status", "--porcelain"], { cwd: repoRoot, }); return parsePorcelain(stdout); } /** * Create a path-limited commit of a single file. Committing the path directly * (`git commit -- `) records only that file's working-tree changes, * regardless of what else is staged. */ export async function commitPath( repoRoot: string, relPath: string, message: string, ): Promise<{ ok: true } | { ok: false; error: string }> { const result = await execa( "git", ["commit", "-m", message, "--", relPath], { cwd: repoRoot, reject: false }, ); if (result.exitCode === 0) return { ok: true }; const error = (result.stderr || result.stdout || "").trim() || `git commit exited with code ${result.exitCode}`; return { ok: false, error }; } /** The full sha HEAD points at, or null when it cannot be read. */ export async function headSha(repoRoot: string): Promise { const result = await execa("git", ["rev-parse", "HEAD"], { cwd: repoRoot, reject: false, }); const sha = result.exitCode === 0 ? String(result.stdout).trim() : ""; return /^[0-9a-f]{40,64}$/.test(sha) ? sha : null; }