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%
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