From 432304e1466c952eb79b1d79694e34154ef4cbeb Mon Sep 17 00:00:00 2001 From: Jack Felke Date: Wed, 4 Mar 2026 13:46:34 -0700 Subject: [PATCH] test: add 12 unit tests for git-extractor parseGitOutput - Export parseGitOutput for direct testing - Test single/multi commit parsing, stat extraction, body inclusion - Test edge cases: empty input, missing hash, malformed blocks - Test truncation of long subjects, unique ID generation, timezone handling - All 55 tests pass (12 new + 43 existing) --- src/lib/git-extractor.ts | 4 +- tests/lib/git-extractor.test.ts | 155 ++++++++++++++++++++++++++++++++ 2 files changed, 157 insertions(+), 2 deletions(-) create mode 100644 tests/lib/git-extractor.test.ts diff --git a/src/lib/git-extractor.ts b/src/lib/git-extractor.ts index 3caec97..ee62015 100644 --- a/src/lib/git-extractor.ts +++ b/src/lib/git-extractor.ts @@ -74,9 +74,9 @@ export function extractGitHistory( return parseGitOutput(output, projectDir, projectName); } -// ── Internal ─────────────────────────────────────────────────────────────── +// ── Parsing (exported for testing) ────────────────────────────────────────── -function parseGitOutput(raw: string, project: string, projectName: string): TimelineEvent[] { +export function parseGitOutput(raw: string, project: string, projectName: string): TimelineEvent[] { const events: TimelineEvent[] = []; // Split on COMMIT_START markers diff --git a/tests/lib/git-extractor.test.ts b/tests/lib/git-extractor.test.ts new file mode 100644 index 0000000..98c96c2 --- /dev/null +++ b/tests/lib/git-extractor.test.ts @@ -0,0 +1,155 @@ +import { describe, it, expect, vi } from "vitest"; +import { parseGitOutput } from "../../src/lib/git-extractor.js"; + +// Mirrors the delimiters in git-extractor.ts after git processes %% → % +const SEP = "%COMMIT_START%"; +const END = "%COMMIT_END%"; +const F = "%F%"; + +function fakeCommitBlock(opts: { + hash?: string; + date?: string; + author?: string; + subject?: string; + body?: string; + stat?: string; +}): string { + const h = opts.hash ?? "abc1234567890abcdef1234567890abcdef123456"; + const d = opts.date ?? "2026-03-04T12:00:00-07:00"; + const a = opts.author ?? "dev"; + const s = opts.subject ?? "fix: something"; + const b = opts.body ?? ""; + const statBlock = opts.stat ?? " 2 files changed, 10 insertions(+), 3 deletions(-)"; + return `${SEP}${F}${h}${F}${d}${F}${a}${F}${s}${F}${b}${F}${END}\n${statBlock}`; +} + +describe("parseGitOutput", () => { + it("parses a single commit with stat summary", () => { + const raw = fakeCommitBlock({ + hash: "deadbeef1234567890abcdef1234567890deadbe", + date: "2026-01-15T09:30:00Z", + author: "Alice", + subject: "feat: add widget", + stat: " src/widget.ts | 42 ++++\n 1 file changed, 42 insertions(+)", + }); + + const events = parseGitOutput(raw, "/proj", "proj"); + expect(events).toHaveLength(1); + expect(events[0].type).toBe("commit"); + expect(events[0].project).toBe("/proj"); + expect(events[0].project_name).toBe("proj"); + expect(events[0].content_preview).toBe("feat: add widget"); + expect(events[0].source_file).toBe("git:deadbeef1234567890abcdef1234567890deadbe"); + + const meta = JSON.parse(events[0].metadata); + expect(meta.author).toBe("Alice"); + expect(meta.files_changed).toBe(1); + expect(meta.insertions).toBe(42); + expect(meta.deletions).toBe(0); + }); + + it("parses multiple commits", () => { + const raw = [ + fakeCommitBlock({ hash: "aaa", subject: "first" }), + fakeCommitBlock({ hash: "bbb", subject: "second" }), + fakeCommitBlock({ hash: "ccc", subject: "third" }), + ].join("\n"); + + const events = parseGitOutput(raw, "/p", "p"); + expect(events).toHaveLength(3); + expect(events.map((e) => e.content_preview)).toEqual(["first", "second", "third"]); + }); + + it("includes body in content when present", () => { + const raw = fakeCommitBlock({ + subject: "fix: crash on startup", + body: "The null check was missing for config.timeout", + }); + + const events = parseGitOutput(raw, "/p", "p"); + expect(events[0].content).toContain("fix: crash on startup"); + expect(events[0].content).toContain("The null check was missing"); + }); + + it("handles commit with no stat summary", () => { + const raw = `${SEP}${F}abc123${F}2026-01-01T00:00:00Z${F}dev${F}initial commit${F}${F}${END}`; + + const events = parseGitOutput(raw, "/p", "p"); + expect(events).toHaveLength(1); + const meta = JSON.parse(events[0].metadata); + expect(meta.files_changed).toBe(0); + expect(meta.insertions).toBe(0); + expect(meta.deletions).toBe(0); + }); + + it("handles stat with deletions only", () => { + const raw = fakeCommitBlock({ + stat: " 5 files changed, 100 deletions(-)", + }); + + const events = parseGitOutput(raw, "/p", "p"); + const meta = JSON.parse(events[0].metadata); + expect(meta.files_changed).toBe(5); + expect(meta.insertions).toBe(0); + expect(meta.deletions).toBe(100); + }); + + it("handles stat with insertions and deletions", () => { + const raw = fakeCommitBlock({ + stat: " 3 files changed, 20 insertions(+), 8 deletions(-)", + }); + + const events = parseGitOutput(raw, "/p", "p"); + const meta = JSON.parse(events[0].metadata); + expect(meta.files_changed).toBe(3); + expect(meta.insertions).toBe(20); + expect(meta.deletions).toBe(8); + }); + + it("truncates long subjects in content_preview", () => { + const longSubject = "x".repeat(200); + const raw = fakeCommitBlock({ subject: longSubject }); + + const events = parseGitOutput(raw, "/p", "p"); + expect(events[0].content_preview.length).toBeLessThanOrEqual(121); // 120 + "…" + expect(events[0].content_preview).toMatch(/…$/); + }); + + it("returns empty array for empty input", () => { + expect(parseGitOutput("", "/p", "p")).toEqual([]); + expect(parseGitOutput(" \n ", "/p", "p")).toEqual([]); + }); + + it("skips blocks with missing hash", () => { + const raw = `${SEP}${F}${F}2026-01-01T00:00:00Z${F}dev${F}no hash${F}${F}${END}`; + const events = parseGitOutput(raw, "/p", "p"); + expect(events).toHaveLength(0); + }); + + it("skips malformed blocks without crashing", () => { + const raw = `${SEP}garbage without end marker\n${fakeCommitBlock({ hash: "good", subject: "valid" })}`; + // The garbage block lacks COMMIT_END so it's filtered out by the split+filter + const events = parseGitOutput(raw, "/p", "p"); + expect(events).toHaveLength(1); + expect(events[0].content_preview).toBe("valid"); + }); + + it("generates unique ids for each event", () => { + const raw = [ + fakeCommitBlock({ hash: "a1" }), + fakeCommitBlock({ hash: "b2" }), + ].join("\n"); + + const events = parseGitOutput(raw, "/p", "p"); + expect(events[0].id).not.toBe(events[1].id); + // UUIDs should be valid format + expect(events[0].id).toMatch(/^[0-9a-f-]{36}$/); + }); + + it("preserves ISO timestamp from git", () => { + const raw = fakeCommitBlock({ date: "2026-06-15T14:30:00+05:30" }); + const events = parseGitOutput(raw, "/p", "p"); + // Should be converted to ISO string + expect(events[0].timestamp).toBe(new Date("2026-06-15T14:30:00+05:30").toISOString()); + }); +});