/** * KHAELOR * File: tests/repository/git.test.ts * Description: GitService tests โ€” status/diff parsing against real git, baselines, attribution, non-repo handling. * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ import { appendFileSync, rmSync, writeFileSync } from "node:fs"; import * as path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { GitService, parseBranchHeader, parseNumstatZ, parseStatusZ, } from "../../src/repository/index.js"; import type { GitResult } from "../../src/repository/index.js"; import { LocalWorkspace } from "../../src/workspace/index.js"; import { commitAll, initRepo, makeTempDir, runGit, writeFixtureFile } from "./fixtures.js"; let dir: string; let git: GitService; beforeEach(() => { dir = makeTempDir("khaelor-git-"); git = new GitService(new LocalWorkspace(dir)); }); afterEach(() => { rmSync(dir, { recursive: true, force: true }); }); function expectOk(result: GitResult): T { if (result.kind !== "ok") throw new Error(`expected ok, got ${JSON.stringify(result)}`); return result.value; } describe("GitService in a non-repo directory", () => { it("returns typed not-a-repo results from every method, never throwing", async () => { expect(await git.isRepo()).toBe(false); expect((await git.status()).kind).toBe("not-a-repo"); expect((await git.diff()).kind).toBe("not-a-repo"); expect((await git.currentBranch()).kind).toBe("not-a-repo"); expect((await git.recordBaseline("session-start")).kind).toBe("not-a-repo"); }); }); describe("GitService status", () => { it("parses branch, dirty, untracked, and renamed files from real git output", async () => { initRepo(dir); writeFixtureFile(dir, "a.txt", "one\n"); writeFixtureFile(dir, "b.txt", "two\n"); commitAll(dir, "init"); writeFileSync(path.join(dir, "a.txt"), "one!\n"); // modified runGit(dir, ["mv", "b.txt", "b2.txt"]); // staged rename writeFixtureFile(dir, "sub/new.txt", "new\n"); // untracked (nested, -uall) expect(await git.isRepo()).toBe(true); const status = expectOk(await git.status()); expect(status.branch).toBe("main"); expect(status.detached).toBe(false); expect(status.noCommits).toBe(false); expect(status.dirtyFiles).toContain("a.txt"); expect(status.dirtyFiles).toContain("b2.txt"); expect(status.untrackedFiles).toEqual(["sub/new.txt"]); expect(status.renamed).toEqual([{ from: "b.txt", to: "b2.txt" }]); expect(expectOk(await git.currentBranch())).toBe("main"); }); it("handles an empty repository (no commits yet) gracefully", async () => { initRepo(dir); writeFixtureFile(dir, "a.txt", "hello\n"); const status = expectOk(await git.status()); expect(status.branch).toBe("main"); expect(status.noCommits).toBe(true); expect(status.untrackedFiles).toEqual(["a.txt"]); expect(expectOk(await git.currentBranch())).toBe("main"); const diff = expectOk(await git.diff()); expect(diff.base).toBe("index"); }); }); describe("GitService diff", () => { it("parses name-status + numstat into typed entries (modified/added/renamed/binary)", async () => { initRepo(dir); writeFixtureFile(dir, "a.txt", "one\ntwo\n"); writeFixtureFile(dir, "b.bin", Buffer.from([0x00, 0x01, 0x02, 0x03])); writeFixtureFile(dir, "d.txt", "keep\n"); commitAll(dir, "init"); appendFileSync(path.join(dir, "a.txt"), "three\n"); writeFixtureFile(dir, "b.bin", Buffer.from([0x00, 0xff, 0xfe])); writeFixtureFile(dir, "c.txt", "brand new\n"); runGit(dir, ["add", "c.txt"]); runGit(dir, ["mv", "d.txt", "d2.txt"]); const diff = expectOk(await git.diff()); expect(diff.base).toBe("HEAD"); const byPath = new Map(diff.entries.map((e) => [e.path, e])); expect(byPath.get("a.txt")).toMatchObject({ status: "modified", added: 1, removed: 0 }); expect(byPath.get("b.bin")).toMatchObject({ status: "modified", added: null, removed: null }); expect(byPath.get("c.txt")).toMatchObject({ status: "added", added: 1 }); expect(byPath.get("d2.txt")).toMatchObject({ status: "renamed", from: "d.txt" }); }); it("limits the diff to requested paths", async () => { initRepo(dir); writeFixtureFile(dir, "a.txt", "one\n"); writeFixtureFile(dir, "b.txt", "two\n"); commitAll(dir, "init"); appendFileSync(path.join(dir, "a.txt"), "more\n"); appendFileSync(path.join(dir, "b.txt"), "more\n"); const diff = expectOk(await git.diff(["a.txt"])); expect(diff.entries.map((e) => e.path)).toEqual(["a.txt"]); }); }); describe("status header parsing", () => { it("parses ahead/behind, detached, and no-commit headers", () => { expect(parseBranchHeader("## main...origin/main [ahead 3, behind 2]")).toMatchObject({ branch: "main", ahead: 3, behind: 2, }); expect(parseBranchHeader("## main...origin/main [behind 7]")).toMatchObject({ branch: "main", ahead: 0, behind: 7, }); expect(parseBranchHeader("## HEAD (no branch)")).toMatchObject({ branch: "HEAD", detached: true }); expect(parseBranchHeader("## No commits yet on trunk")).toMatchObject({ branch: "trunk", noCommits: true, }); }); it("parses NUL-separated porcelain entries including rename pairs", () => { const raw = "## main\0 M a.txt\0R b2.txt\0b.txt\0?? u.txt\0!! ignored.txt\0"; const parsed = parseStatusZ(raw); expect(parsed.dirty).toEqual(["a.txt", "b2.txt"]); expect(parsed.untracked).toEqual(["u.txt"]); expect(parsed.renamed).toEqual([{ from: "b.txt", to: "b2.txt" }]); }); it("parses numstat rename records with an empty inline path", () => { const raw = "1\t1\ta.txt\u00000\t0\t\u0000b.txt\u0000b2.txt\u0000"; const counts = parseNumstatZ(raw); expect(counts.get("a.txt")).toEqual({ added: 1, removed: 1 }); expect(counts.get("b2.txt")).toEqual({ added: 0, removed: 0 }); }); }); describe("GitService baselines and attribution (CLAUDE.md ยง16)", () => { it("separates pre-existing dirt from changes made after the baseline", async () => { initRepo(dir); writeFixtureFile(dir, "a.txt", "one\n"); writeFixtureFile(dir, "b.txt", "two\n"); commitAll(dir, "init"); // Pre-existing user work โ€” present BEFORE the session baseline. appendFileSync(path.join(dir, "a.txt"), "user change\n"); writeFixtureFile(dir, "pre.txt", "user file\n"); const baseline = expectOk(await git.recordBaseline("session-start")); expect(baseline.branch).toBe("main"); expect(baseline.dirtyFiles).toEqual(["a.txt"]); expect(baseline.untrackedFiles).toEqual(["pre.txt"]); expect(baseline.diffHash).toMatch(/^[0-9a-f]{64}$/); // Changes made after the baseline โ€” attributable to KHAELOR. appendFileSync(path.join(dir, "b.txt"), "khaelor change\n"); writeFixtureFile(dir, "new.txt", "khaelor file\n"); const attribution = expectOk(await git.attributeChanges()); expect(attribution.khaelor).toEqual(["b.txt", "new.txt"]); expect(attribution.preExisting).toEqual(["a.txt", "pre.txt"]); }); it("flags a pre-existing dirty file that changed further after the baseline", async () => { initRepo(dir); writeFixtureFile(dir, "a.txt", "one\n"); commitAll(dir, "init"); appendFileSync(path.join(dir, "a.txt"), "user dirt\n"); expectOk(await git.recordBaseline("session-start")); appendFileSync(path.join(dir, "a.txt"), "khaelor addition\n"); const attribution = expectOk(await git.attributeChanges()); expect(attribution.preExisting).toContain("a.txt"); expect(attribution.khaelor).toContain("a.txt"); }); it("prefers the pre-first-edit baseline over the session-start baseline", async () => { initRepo(dir); writeFixtureFile(dir, "a.txt", "one\n"); commitAll(dir, "init"); expectOk(await git.recordBaseline("session-start")); // The user edits between session start and KHAELOR's first edit. appendFileSync(path.join(dir, "a.txt"), "user change\n"); expectOk(await git.recordBaseline("pre-first-edit")); const attribution = expectOk(await git.attributeChanges()); expect(attribution.khaelor).toEqual([]); expect(attribution.preExisting).toEqual(["a.txt"]); expect(git.baseline()?.dirtyFiles).toEqual(["a.txt"]); }); it("returns a typed no-baseline error when attribution is requested too early", async () => { initRepo(dir); const result = await git.attributeChanges(); expect(result).toMatchObject({ kind: "error", code: "no-baseline" }); }); });