syntaxai/tdd.md · main · src / c14_git.ts
// 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<string, string>;
}
interface RunResult {
stdout: string;
stderr: string;
exitCode: number;
}
const runGit = async (args: string[], opts: RunOpts = {}): Promise<RunResult> => {
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<string> => {
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<string | null> => {
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 <ref>:<path>. Returns null when missing.
export const getFileBlobSha = async (ref: string, path: string): Promise<string | null> => {
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<string> => {
return await runGitOk(["cat-file", "-p", sha]);
};
// Read a file's contents at <ref>:<path>. 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<string | null> => {
const r = await runGit(["show", `${ref}:${path}`]);
if (r.exitCode !== 0) return null;
return r.stdout;
};
// List a directory at <ref>:<path>. 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<TreeEntry[] | null> => {
// `<ref>:<path>` — 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<typeof e> => 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<GitCommit | null> => {
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<string | null> => {
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 <branch>. 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 <branch>:<path>, 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<GitCommitOutcome> => {
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 };
};