SPB Git

spb/khaelor Public

KHAELOR — a terminal-native autonomous engineering agent powered by Anthropic.

TypeScript 82.9% HTML 14.9% CSS 1.1% JavaScript 0.7%
8.4 KB · 220 lines typescript
Raw Blame History
1/**2 * KHAELOR3 * File: tests/repository/git.test.ts4 * Description: GitService tests — status/diff parsing against real git, baselines, attribution, non-repo handling.5 *6 * Author: Simon-Pierre Boucher7 * Contact: contact@spboucher.ai8 */910import { appendFileSync, rmSync, writeFileSync } from "node:fs";11import * as path from "node:path";12import { afterEach, beforeEach, describe, expect, it } from "vitest";13import {14  GitService,15  parseBranchHeader,16  parseNumstatZ,17  parseStatusZ,18} from "../../src/repository/index.js";19import type { GitResult } from "../../src/repository/index.js";20import { LocalWorkspace } from "../../src/workspace/index.js";21import { commitAll, initRepo, makeTempDir, runGit, writeFixtureFile } from "./fixtures.js";2223let dir: string;24let git: GitService;2526beforeEach(() => {27  dir = makeTempDir("khaelor-git-");28  git = new GitService(new LocalWorkspace(dir));29});3031afterEach(() => {32  rmSync(dir, { recursive: true, force: true });33});3435function expectOk<T>(result: GitResult<T>): T {36  if (result.kind !== "ok") throw new Error(`expected ok, got ${JSON.stringify(result)}`);37  return result.value;38}3940describe("GitService in a non-repo directory", () => {41  it("returns typed not-a-repo results from every method, never throwing", async () => {42    expect(await git.isRepo()).toBe(false);43    expect((await git.status()).kind).toBe("not-a-repo");44    expect((await git.diff()).kind).toBe("not-a-repo");45    expect((await git.currentBranch()).kind).toBe("not-a-repo");46    expect((await git.recordBaseline("session-start")).kind).toBe("not-a-repo");47  });48});4950describe("GitService status", () => {51  it("parses branch, dirty, untracked, and renamed files from real git output", async () => {52    initRepo(dir);53    writeFixtureFile(dir, "a.txt", "one\n");54    writeFixtureFile(dir, "b.txt", "two\n");55    commitAll(dir, "init");5657    writeFileSync(path.join(dir, "a.txt"), "one!\n"); // modified58    runGit(dir, ["mv", "b.txt", "b2.txt"]); // staged rename59    writeFixtureFile(dir, "sub/new.txt", "new\n"); // untracked (nested, -uall)6061    expect(await git.isRepo()).toBe(true);62    const status = expectOk(await git.status());63    expect(status.branch).toBe("main");64    expect(status.detached).toBe(false);65    expect(status.noCommits).toBe(false);66    expect(status.dirtyFiles).toContain("a.txt");67    expect(status.dirtyFiles).toContain("b2.txt");68    expect(status.untrackedFiles).toEqual(["sub/new.txt"]);69    expect(status.renamed).toEqual([{ from: "b.txt", to: "b2.txt" }]);70    expect(expectOk(await git.currentBranch())).toBe("main");71  });7273  it("handles an empty repository (no commits yet) gracefully", async () => {74    initRepo(dir);75    writeFixtureFile(dir, "a.txt", "hello\n");76    const status = expectOk(await git.status());77    expect(status.branch).toBe("main");78    expect(status.noCommits).toBe(true);79    expect(status.untrackedFiles).toEqual(["a.txt"]);80    expect(expectOk(await git.currentBranch())).toBe("main");81    const diff = expectOk(await git.diff());82    expect(diff.base).toBe("index");83  });84});8586describe("GitService diff", () => {87  it("parses name-status + numstat into typed entries (modified/added/renamed/binary)", async () => {88    initRepo(dir);89    writeFixtureFile(dir, "a.txt", "one\ntwo\n");90    writeFixtureFile(dir, "b.bin", Buffer.from([0x00, 0x01, 0x02, 0x03]));91    writeFixtureFile(dir, "d.txt", "keep\n");92    commitAll(dir, "init");9394    appendFileSync(path.join(dir, "a.txt"), "three\n");95    writeFixtureFile(dir, "b.bin", Buffer.from([0x00, 0xff, 0xfe]));96    writeFixtureFile(dir, "c.txt", "brand new\n");97    runGit(dir, ["add", "c.txt"]);98    runGit(dir, ["mv", "d.txt", "d2.txt"]);99100    const diff = expectOk(await git.diff());101    expect(diff.base).toBe("HEAD");102    const byPath = new Map(diff.entries.map((e) => [e.path, e]));103104    expect(byPath.get("a.txt")).toMatchObject({ status: "modified", added: 1, removed: 0 });105    expect(byPath.get("b.bin")).toMatchObject({ status: "modified", added: null, removed: null });106    expect(byPath.get("c.txt")).toMatchObject({ status: "added", added: 1 });107    expect(byPath.get("d2.txt")).toMatchObject({ status: "renamed", from: "d.txt" });108  });109110  it("limits the diff to requested paths", async () => {111    initRepo(dir);112    writeFixtureFile(dir, "a.txt", "one\n");113    writeFixtureFile(dir, "b.txt", "two\n");114    commitAll(dir, "init");115    appendFileSync(path.join(dir, "a.txt"), "more\n");116    appendFileSync(path.join(dir, "b.txt"), "more\n");117118    const diff = expectOk(await git.diff(["a.txt"]));119    expect(diff.entries.map((e) => e.path)).toEqual(["a.txt"]);120  });121});122123describe("status header parsing", () => {124  it("parses ahead/behind, detached, and no-commit headers", () => {125    expect(parseBranchHeader("## main...origin/main [ahead 3, behind 2]")).toMatchObject({126      branch: "main",127      ahead: 3,128      behind: 2,129    });130    expect(parseBranchHeader("## main...origin/main [behind 7]")).toMatchObject({131      branch: "main",132      ahead: 0,133      behind: 7,134    });135    expect(parseBranchHeader("## HEAD (no branch)")).toMatchObject({ branch: "HEAD", detached: true });136    expect(parseBranchHeader("## No commits yet on trunk")).toMatchObject({137      branch: "trunk",138      noCommits: true,139    });140  });141142  it("parses NUL-separated porcelain entries including rename pairs", () => {143    const raw = "## main\0 M a.txt\0R  b2.txt\0b.txt\0?? u.txt\0!! ignored.txt\0";144    const parsed = parseStatusZ(raw);145    expect(parsed.dirty).toEqual(["a.txt", "b2.txt"]);146    expect(parsed.untracked).toEqual(["u.txt"]);147    expect(parsed.renamed).toEqual([{ from: "b.txt", to: "b2.txt" }]);148  });149150  it("parses numstat rename records with an empty inline path", () => {151    const raw = "1\t1\ta.txt\u00000\t0\t\u0000b.txt\u0000b2.txt\u0000";152    const counts = parseNumstatZ(raw);153    expect(counts.get("a.txt")).toEqual({ added: 1, removed: 1 });154    expect(counts.get("b2.txt")).toEqual({ added: 0, removed: 0 });155  });156});157158describe("GitService baselines and attribution (CLAUDE.md §16)", () => {159  it("separates pre-existing dirt from changes made after the baseline", async () => {160    initRepo(dir);161    writeFixtureFile(dir, "a.txt", "one\n");162    writeFixtureFile(dir, "b.txt", "two\n");163    commitAll(dir, "init");164165    // Pre-existing user work — present BEFORE the session baseline.166    appendFileSync(path.join(dir, "a.txt"), "user change\n");167    writeFixtureFile(dir, "pre.txt", "user file\n");168169    const baseline = expectOk(await git.recordBaseline("session-start"));170    expect(baseline.branch).toBe("main");171    expect(baseline.dirtyFiles).toEqual(["a.txt"]);172    expect(baseline.untrackedFiles).toEqual(["pre.txt"]);173    expect(baseline.diffHash).toMatch(/^[0-9a-f]{64}$/);174175    // Changes made after the baseline — attributable to KHAELOR.176    appendFileSync(path.join(dir, "b.txt"), "khaelor change\n");177    writeFixtureFile(dir, "new.txt", "khaelor file\n");178179    const attribution = expectOk(await git.attributeChanges());180    expect(attribution.khaelor).toEqual(["b.txt", "new.txt"]);181    expect(attribution.preExisting).toEqual(["a.txt", "pre.txt"]);182  });183184  it("flags a pre-existing dirty file that changed further after the baseline", async () => {185    initRepo(dir);186    writeFixtureFile(dir, "a.txt", "one\n");187    commitAll(dir, "init");188    appendFileSync(path.join(dir, "a.txt"), "user dirt\n");189190    expectOk(await git.recordBaseline("session-start"));191    appendFileSync(path.join(dir, "a.txt"), "khaelor addition\n");192193    const attribution = expectOk(await git.attributeChanges());194    expect(attribution.preExisting).toContain("a.txt");195    expect(attribution.khaelor).toContain("a.txt");196  });197198  it("prefers the pre-first-edit baseline over the session-start baseline", async () => {199    initRepo(dir);200    writeFixtureFile(dir, "a.txt", "one\n");201    commitAll(dir, "init");202203    expectOk(await git.recordBaseline("session-start"));204    // The user edits between session start and KHAELOR's first edit.205    appendFileSync(path.join(dir, "a.txt"), "user change\n");206    expectOk(await git.recordBaseline("pre-first-edit"));207208    const attribution = expectOk(await git.attributeChanges());209    expect(attribution.khaelor).toEqual([]);210    expect(attribution.preExisting).toEqual(["a.txt"]);211    expect(git.baseline()?.dirtyFiles).toEqual(["a.txt"]);212  });213214  it("returns a typed no-baseline error when attribution is requested too early", async () => {215    initRepo(dir);216    const result = await git.attributeChanges();217    expect(result).toMatchObject({ kind: "error", code: "no-baseline" });218  });219});220