syntaxai/tdd.md · main · src / a31_git_parse.ts
// 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;