// 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 -- ` output: one tab-separated row of // ` \t`. 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; // ` \t` — 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;