// c14 — secondary I/O: shell-out to the `git` binary against a bare // repository on the container's filesystem (mounted from the host at // /app/repo via Quadlet). Replaces c14_forgejo for tdd.md's own repo // — admin web-edits commit straight to disk, the deploy script reads // from the same bare repo. No HTTP, no Forgejo, no SSH inside the // container; just `git` operating on local objects. // // SAMA placement: c14 because we shell out to an external binary. // Pure helpers (parsers for git's output) live in c31_git_parse with // sibling tests. The wrapper here is integration-tested via Playwright. // // Knobs: // TDD_GIT_DIR — absolute path to the bare repo. Defaults to /app/repo // which matches the Quadlet bind mount. In dev tests // you can point this at any bare repo. import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { GIT_COMMIT_FORMAT, parseGitCommits, parseLsTreeLine, type GitCommit, } from "./a31_git_parse.ts"; export const GIT_DIR = process.env.TDD_GIT_DIR ?? "/app/repo"; // GitCommitOk / GitCommitFailure / GitCommitOutcome are defined in // Layer 0 (c31_git_parse) per SAMA v2 §1.1. Imported here so the // adapter's typed return signatures match what callers in Layer 1 // also import directly. import type { GitCommitOk, GitCommitFailure, GitCommitOutcome, } from "./a31_git_parse.ts"; interface RunOpts { stdin?: string; env?: Record; } interface RunResult { stdout: string; stderr: string; exitCode: number; } const runGit = async (args: string[], opts: RunOpts = {}): Promise => { const proc = Bun.spawn(["git", "--git-dir", GIT_DIR, ...args], { stdin: opts.stdin !== undefined ? "pipe" : "ignore", stdout: "pipe", stderr: "pipe", env: { ...process.env, ...(opts.env ?? {}) }, }); if (opts.stdin !== undefined) { proc.stdin!.write(opts.stdin); proc.stdin!.end(); } const [stdout, stderr, exitCode] = await Promise.all([ new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited, ]); return { stdout, stderr, exitCode }; }; const runGitOk = async (args: string[], opts: RunOpts = {}): Promise => { const r = await runGit(args, opts); if (r.exitCode !== 0) { throw new Error(`git ${args.join(" ")} failed (${r.exitCode}): ${r.stderr.trim()}`); } return r.stdout; }; // Resolve a ref/sha to its full SHA. Returns null when the ref is missing. export const resolveRef = async (ref: string): Promise => { const r = await runGit(["rev-parse", "--verify", `${ref}^{commit}`]); if (r.exitCode !== 0) return null; return r.stdout.trim(); }; // Get the blob SHA of a file at :. Returns null when missing. export const getFileBlobSha = async (ref: string, path: string): Promise => { const r = await runGit(["ls-tree", ref, "--", path]); if (r.exitCode !== 0) return null; const entry = parseLsTreeLine(r.stdout.split("\n")[0] ?? ""); return entry && entry.type === "blob" ? entry.sha : null; }; // Read a blob's contents as a UTF-8 string. Throws on missing/binary. export const readBlob = async (sha: string): Promise => { return await runGitOk(["cat-file", "-p", sha]); }; // Read a file's contents at :. Returns null when the path // doesn't exist at that ref. UTF-8 — c14_git doesn't try to handle // binary content (the site is markdown-only). export const readBlobAtRef = async (ref: string, path: string): Promise => { const r = await runGit(["show", `${ref}:${path}`]); if (r.exitCode !== 0) return null; return r.stdout; }; // List a directory at :. Empty string for path = root of tree. // Returns null when the path doesn't exist at that ref. Each entry // keeps the relative name (basename), not the full path — the caller // builds full paths from `${path}/${entry.name}`. // TreeEntry is defined in Layer 0 (c31_git_parse) per SAMA v2 §1.1. // Callers import it directly from c31_git_parse, not through this // adapter — that's what keeps the import direction Layer N → Layer M < N. import type { TreeEntry } from "./a31_git_parse.ts"; export const lsTree = async (ref: string, path: string): Promise => { // `:` — git lists what's at that tree. For path="" it's // the repo root. const target = path === "" ? ref : `${ref}:${path}`; const r = await runGit(["ls-tree", target]); if (r.exitCode !== 0) return null; return r.stdout .split("\n") .map((line) => parseLsTreeLine(line)) .filter((e): e is NonNullable => e !== null) .map((e) => ({ name: e.path, // ls-tree without -r emits basename type: e.type, sha: e.sha, mode: e.mode, })); }; // Detail for a single commit (one parsed GitCommit). Returns null on // missing — same shape as c14_forgejo.getCommitDetail used to expose. export const getCommit = async (sha: string): Promise => { const resolved = await resolveRef(sha); if (resolved === null) return null; const out = await runGitOk(["show", "-s", `--format=${GIT_COMMIT_FORMAT}`, resolved]); const commits = parseGitCommits(out); return commits[0] ?? null; }; // Unified-diff text for a single commit. Empty string for an empty // commit. Null if the commit doesn't exist. export const getCommitDiff = async (sha: string): Promise => { const resolved = await resolveRef(sha); if (resolved === null) return null; // --no-renames: keep the diff format consistent with what // c31_diff_parse expects ("diff --git a/X b/X" rather than rename // headers we don't yet render specially). // --first-parent: for merge commits, show only the diff vs the first // parent (matches what most CI/web UIs show). const out = await runGitOk([ "diff-tree", "--no-color", "--no-renames", "--patch", "--first-parent", "--full-index", "-r", resolved, ]); return out; }; // Commit a single file's new content to . Optimistic concurrency: // when priorSha is set we pass it as the `oldvalue` to update-ref so a // concurrent commit fails with kind:"conflict". priorSha:null means // "create new file" — same flow except update-index has no prior entry // to replace. export interface CommitFileParams { branch: string; path: string; content: string; // Expected current blob SHA at :, or null when the file // is brand new. The caller is responsible for the optimistic check — // we just feed it to update-index. priorBlobSha: string | null; message: string; authorName: string; authorEmail: string; } export const commitFile = async (params: CommitFileParams): Promise => { const branchRef = `refs/heads/${params.branch}`; // 1. Resolve the current ref tip — this is the parent for the new commit. const parentSha = await resolveRef(branchRef); if (parentSha === null) { return { ok: false, kind: "not_found", message: `branch ${params.branch} not found` }; } // 2. Hash the new content as a blob. const blobSha = (await runGitOk(["hash-object", "-w", "--stdin"], { stdin: params.content })).trim(); // 3. Build the new tree by reading parent's tree into a temp index, // swapping our path, writing the tree. const tmpDir = mkdtempSync(join(tmpdir(), "tdd-md-git-")); const indexPath = join(tmpDir, "index"); let treeSha: string; try { const env = { GIT_INDEX_FILE: indexPath }; await runGitOk(["read-tree", parentSha], { env }); await runGitOk( ["update-index", "--add", "--cacheinfo", `100644,${blobSha},${params.path}`], { env }, ); treeSha = (await runGitOk(["write-tree"], { env })).trim(); } finally { try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ } } // 4. Create the commit object. const commitDate = new Date().toISOString(); const commitSha = (await runGitOk( ["commit-tree", treeSha, "-p", parentSha, "-F", "-"], { stdin: params.message, env: { GIT_AUTHOR_NAME: params.authorName, GIT_AUTHOR_EMAIL: params.authorEmail, GIT_AUTHOR_DATE: commitDate, GIT_COMMITTER_NAME: params.authorName, GIT_COMMITTER_EMAIL: params.authorEmail, GIT_COMMITTER_DATE: commitDate, }, }, )).trim(); // 5. Move the ref forward, atomically. The 4th arg to update-ref is // the expected old value; if the ref tip moved under us, this // fails and we surface kind:"conflict". const updateRes = await runGit(["update-ref", branchRef, commitSha, parentSha]); if (updateRes.exitCode !== 0) { const stderr = updateRes.stderr.trim(); if (/cannot lock|is at .* but expected/.test(stderr)) { return { ok: false, kind: "conflict", message: stderr }; } return { ok: false, kind: "other", message: stderr }; } return { ok: true, commitSha }; };