syntaxai/tdd.md · main · src / c14_git.ts

c14_git.ts 242 lines · 9027 bytes raw
// 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 };
};