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

a31_git_parse.ts 116 lines · 4134 bytes raw
// c31 — model: parsers for `git` plumbing output. Pure: a function
// from string to a typed object. The c14_git layer owns the actual
// `Bun.spawn` calls; this file makes their stdout/stderr legible.

export interface GitCommit {
  sha: string;
  parents: string[];
  authorName: string;
  authorEmail: string;
  authorDate: string;   // ISO 8601 with timezone
  committerName: string;
  committerEmail: string;
  committerDate: string;
  message: string;      // full message: subject + blank + body
}

// Format string for `git log` / `git show` that this parser consumes.
// Uses ASCII record separators so commit messages with newlines pass
// through unmangled. Mirrors the technique already used in
// scripts/p620/snapshot-git-history.ts.
//
//   %H  full sha
//   %P  parent shas (space-separated)
//   %an %ae %aI  author name/email/iso-strict-with-timezone
//   %cn %ce %cI  committer
//   %B  raw body (subject + blank + rest)
export const GIT_COMMIT_FORMAT =
  ["%H", "%P", "%an", "%ae", "%aI", "%cn", "%ce", "%cI", "%B"].join("%x1f") + "%x1e";

const RECORD_SEP = "\x1e";
const FIELD_SEP = "\x1f";

// Parse one or more commits emitted with GIT_COMMIT_FORMAT. Trailing
// record separator is fine (we trim before splitting).
export const parseGitCommits = (raw: string): GitCommit[] => {
  const records = raw.split(RECORD_SEP).map((s) => s.trim()).filter(Boolean);
  return records.map(parseOneCommit);
};

const parseOneCommit = (record: string): GitCommit => {
  const parts = record.split(FIELD_SEP);
  if (parts.length < 9) {
    throw new Error(`malformed git commit record: expected 9+ fields, got ${parts.length}`);
  }
  const [sha, parentsRaw, an, ae, aI, cn, ce, cI, ...rest] = parts;
  const message = (rest.join(FIELD_SEP) ?? "").replace(/\n+$/, "");
  return {
    sha: sha!,
    parents: (parentsRaw ?? "").trim().split(/\s+/).filter(Boolean),
    authorName: an!,
    authorEmail: ae!,
    authorDate: aI!,
    committerName: cn!,
    committerEmail: ce!,
    committerDate: cI!,
    message,
  };
};

// Parse `git ls-tree <ref> -- <path>` output: one tab-separated row of
// `<mode> <type> <sha>\t<path>`. Returns null when the path doesn't
// exist at that ref (empty stdout from git).
export interface LsTreeEntry {
  mode: string;
  type: "blob" | "tree" | "commit";
  sha: string;
  path: string;
}

export const parseLsTreeLine = (line: string): LsTreeEntry | null => {
  const trimmed = line.trim();
  if (!trimmed) return null;
  // `<mode> <type> <sha>\t<path>` — tab is mandatory between sha+path,
  // spaces before. Split on first tab to keep paths with spaces intact.
  const tabIdx = trimmed.indexOf("\t");
  if (tabIdx === -1) return null;
  const head = trimmed.slice(0, tabIdx).split(/\s+/);
  if (head.length !== 3) return null;
  const [mode, type, sha] = head;
  const path = trimmed.slice(tabIdx + 1);
  if (type !== "blob" && type !== "tree" && type !== "commit") return null;
  return { mode: mode!, type, sha: sha!, path };
};

// Tree-listing entry returned by c14_git.lsTree. Defined here in
// Layer 0 (Pure) per SAMA v2 §1.1 so c51 render code (and other
// readers) can reference the type without importing from Layer 2.
// Distinct from LsTreeEntry above: that's the raw parsed line; this
// is the cleaned-up shape c14_git exposes to callers.
export interface TreeEntry {
  name: string;            // basename, e.g. "skill.md" or "blog"
  type: "blob" | "tree" | "commit";
  sha: string;
  mode: string;
}

// Result types for c14_git.commitFile etc. Defined here in Layer 0
// (Pure) per SAMA v2 §1.1 so c51 render code can match against the
// discriminated union without crossing import direction.
export interface GitCommitOk {
  ok: true;
  commitSha: string;
}

export interface GitCommitFailure {
  ok: false;
  // "conflict"   → ref tip moved under us (someone else committed)
  // "not_found"  → branch doesn't exist
  // "permission" → fs perms on the bare repo
  // "other"      → anything else (look at .message)
  kind: "conflict" | "not_found" | "permission" | "other";
  message: string;
}

export type GitCommitOutcome = GitCommitOk | GitCommitFailure;