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: src/repository/git.ts4 * Description: GitService — read-only git awareness (status, diff, baselines, attribution) via workspace.exec.5 *6 * Author: Simon-Pierre Boucher7 * Contact: contact@spboucher.ai8 */910import { createHash } from "node:crypto";11import type { Workspace } from "../workspace/index.js";1213// ───────────────────────────── result types ─────────────────────────────1415/** Every GitService method returns a typed result — never a raw throw for "not a repo". */16export type GitResult<T> =17 | { kind: "ok"; value: T }18 | { kind: "not-a-repo" }19 | { kind: "error"; code: GitErrorCode; message: string };2021export type GitErrorCode = "git-unavailable" | "git-failed" | "no-baseline";2223function ok<T>(value: T): GitResult<T> {24 return { kind: "ok", value };25}2627// ───────────────────────────── domain types ─────────────────────────────2829export interface GitStatus {30 /** Branch name; `"HEAD"` when detached. */31 branch: string;32 detached: boolean;33 /** True for a freshly-initialized repository with no commits. */34 noCommits: boolean;35 ahead: number;36 behind: number;37 /** Tracked files with staged or unstaged changes (rename targets included). */38 dirtyFiles: string[];39 untrackedFiles: string[];40 renamed: { from: string; to: string }[];41}4243export type GitChangeKind =44 | "added"45 | "modified"46 | "deleted"47 | "renamed"48 | "copied"49 | "type-changed"50 | "unmerged"51 | "unknown";5253export interface GitDiffEntry {54 path: string;55 status: GitChangeKind;56 /** Present for renames/copies. */57 from?: string;58 /** Lines added; null for binary files. */59 added: number | null;60 /** Lines removed; null for binary files. */61 removed: number | null;62}6364export interface GitDiff {65 /** "HEAD" when the repo has commits; "index" for an empty repository. */66 base: "HEAD" | "index";67 entries: GitDiffEntry[];68}6970/** Structurally identical to the session event payload type (CLAUDE.md §16, ADR-15). */71export interface GitBaseline {72 branch: string;73 dirtyFiles: string[];74 untrackedFiles: string[];75 diffHash: string;76}7778export type BaselineWhen = "session-start" | "pre-first-edit";7980export interface ChangeAttribution {81 /** Files changed since the baseline — attributable to this KHAELOR session. */82 khaelor: string[];83 /** Files already dirty/untracked at baseline time — pre-existing user work. */84 preExisting: string[];85}8687export interface GitServiceOptions {88 /** Hard ceiling for every git invocation. Default 15 000 ms. */89 timeoutMs?: number;90}9192// ───────────────────────────── parsers (exported for tests) ─────────────────────────────9394export interface BranchHeader {95 branch: string;96 detached: boolean;97 noCommits: boolean;98 ahead: number;99 behind: number;100}101102/** Parse the `## …` header line of `git status --porcelain=v1 --branch`. */103export function parseBranchHeader(header: string): BranchHeader {104 const body = header.startsWith("## ") ? header.slice(3) : header;105 const result: BranchHeader = { branch: "", detached: false, noCommits: false, ahead: 0, behind: 0 };106107 if (body.startsWith("No commits yet on ")) {108 result.branch = body.slice("No commits yet on ".length).trim();109 result.noCommits = true;110 return result;111 }112 if (body.startsWith("HEAD (no branch)")) {113 result.branch = "HEAD";114 result.detached = true;115 return result;116 }117 const name = body.split("...")[0] ?? body;118 result.branch = name.trim();119 const bracket = /\[([^\]]+)\]/.exec(body);120 if (bracket !== null) {121 const ahead = /ahead (\d+)/.exec(bracket[1] ?? "");122 const behind = /behind (\d+)/.exec(bracket[1] ?? "");123 if (ahead?.[1] !== undefined) result.ahead = Number.parseInt(ahead[1], 10);124 if (behind?.[1] !== undefined) result.behind = Number.parseInt(behind[1], 10);125 }126 return result;127}128129export interface ParsedStatus {130 header: BranchHeader;131 dirty: string[];132 untracked: string[];133 renamed: { from: string; to: string }[];134}135136/** Parse NUL-separated `git status --porcelain=v1 -z --branch` output. */137export function parseStatusZ(raw: string): ParsedStatus {138 const tokens = raw.split("\0").filter((t) => t.length > 0);139 const parsed: ParsedStatus = {140 header: { branch: "", detached: false, noCommits: false, ahead: 0, behind: 0 },141 dirty: [],142 untracked: [],143 renamed: [],144 };145 for (let i = 0; i < tokens.length; i += 1) {146 const token = tokens[i];147 if (token === undefined) continue;148 if (token.startsWith("## ")) {149 parsed.header = parseBranchHeader(token);150 continue;151 }152 if (token.length < 4) continue;153 const xy = token.slice(0, 2);154 const filePath = token.slice(3);155 if (xy === "!!") continue; // ignored entries — never surfaced156 if (xy === "??") {157 parsed.untracked.push(filePath);158 continue;159 }160 parsed.dirty.push(filePath);161 // Renames/copies: the NEXT NUL token is the original path.162 if (xy.includes("R") || xy.includes("C")) {163 const original = tokens[i + 1];164 if (original !== undefined && !original.startsWith("## ")) {165 parsed.renamed.push({ from: original, to: filePath });166 i += 1;167 }168 }169 }170 parsed.dirty.sort();171 parsed.untracked.sort();172 return parsed;173}174175const NAME_STATUS_KINDS: Record<string, GitChangeKind> = {176 A: "added",177 M: "modified",178 D: "deleted",179 R: "renamed",180 C: "copied",181 T: "type-changed",182 U: "unmerged",183};184185/** Parse `git diff --name-status -z` output into (path, status, from?) records. */186export function parseNameStatusZ(raw: string): { path: string; status: GitChangeKind; from?: string }[] {187 const tokens = raw.split("\0").filter((t) => t.length > 0);188 const entries: { path: string; status: GitChangeKind; from?: string }[] = [];189 let i = 0;190 while (i < tokens.length) {191 const statusToken = tokens[i];192 if (statusToken === undefined) break;193 const kind = NAME_STATUS_KINDS[statusToken.charAt(0)] ?? "unknown";194 if (kind === "renamed" || kind === "copied") {195 const from = tokens[i + 1];196 const to = tokens[i + 2];197 if (from !== undefined && to !== undefined) entries.push({ path: to, status: kind, from });198 i += 3;199 } else {200 const filePath = tokens[i + 1];201 if (filePath !== undefined) entries.push({ path: filePath, status: kind });202 i += 2;203 }204 }205 return entries;206}207208/** Parse `git diff --numstat -z` output into per-path line counts (null = binary). */209export function parseNumstatZ(raw: string): Map<string, { added: number | null; removed: number | null }> {210 const tokens = raw.split("\0").filter((t) => t.length > 0);211 const counts = new Map<string, { added: number | null; removed: number | null }>();212 let i = 0;213 const toCount = (s: string): number | null => (s === "-" ? null : Number.parseInt(s, 10));214 while (i < tokens.length) {215 const record = tokens[i];216 if (record === undefined) break;217 const parts = record.split("\t");218 const added = toCount(parts[0] ?? "0");219 const removed = toCount(parts[1] ?? "0");220 const inlinePath = parts.slice(2).join("\t");221 if (inlinePath.length > 0) {222 counts.set(inlinePath, { added, removed });223 i += 1;224 } else {225 // Rename record: empty path field, then <from> NUL <to>.226 const to = tokens[i + 2];227 if (to !== undefined) counts.set(to, { added, removed });228 i += 3;229 }230 }231 return counts;232}233234/** Split a raw unified diff into per-file sections and hash each (attribution granularity). */235export function perFileDiffHashes(rawDiff: string): Map<string, string> {236 const hashes = new Map<string, string>();237 if (rawDiff.length === 0) return hashes;238 const sections = rawDiff.split(/^(?=diff --git )/m).filter((s) => s.startsWith("diff --git "));239 for (const section of sections) {240 const plusLine = /^\+\+\+ b\/(.+)$/m.exec(section);241 const renameLine = /^rename to (.+)$/m.exec(section);242 const headerLine = /^diff --git a\/.* b\/(.+)$/m.exec(section);243 const target = plusLine?.[1] ?? renameLine?.[1] ?? headerLine?.[1];244 if (target !== undefined) {245 hashes.set(target, createHash("sha256").update(section, "utf8").digest("hex"));246 }247 }248 return hashes;249}250251// ───────────────────────────── service ─────────────────────────────252253interface StoredBaseline {254 when: BaselineWhen;255 at: number;256 baseline: GitBaseline;257 perFile: Map<string, string>;258 untracked: Set<string>;259 dirty: Set<string>;260}261262const DEFAULT_GIT_TIMEOUT_MS = 15_000;263264/**265 * Read-only git awareness. Runs the real `git` binary through the workspace266 * (ADR-13 — no git library dependency) and NEVER executes a mutating command:267 * only `status`, `diff`, and `rev-parse` are issued (CLAUDE.md §16).268 */269export class GitService {270 private readonly workspace: Workspace;271 private readonly timeoutMs: number;272 private readonly baselines = new Map<BaselineWhen, StoredBaseline>();273274 constructor(workspace: Workspace, options: GitServiceOptions = {}) {275 this.workspace = workspace;276 this.timeoutMs = options.timeoutMs ?? DEFAULT_GIT_TIMEOUT_MS;277 }278279 // ── plumbing ──280281 private async run(282 args: string[],283 ): Promise<{ exitCode: number | null; stdout: string; stderr: string } | { kind: "error"; code: GitErrorCode; message: string }> {284 const quote = (s: string): string => `'${s.replaceAll("'", "'\\''")}'`;285 const cmd = `git ${args.map(quote).join(" ")}`;286 try {287 const result = await this.workspace.exec({ cmd, timeoutMs: this.timeoutMs });288 if (result.exitCode === 127) {289 return { kind: "error", code: "git-unavailable", message: "git binary not found on PATH" };290 }291 return result;292 } catch (cause) {293 const message = cause instanceof Error ? cause.message : String(cause);294 return { kind: "error", code: "git-unavailable", message };295 }296 }297298 private classify<T>(299 result: Awaited<ReturnType<GitService["run"]>>,300 onOk: (stdout: string) => GitResult<T>,301 ): GitResult<T> {302 if ("kind" in result) return result;303 if (result.exitCode === 0) return onOk(result.stdout);304 if (result.stderr.includes("not a git repository")) return { kind: "not-a-repo" };305 return {306 kind: "error",307 code: "git-failed",308 message: `git exited with ${String(result.exitCode)}: ${result.stderr.trim().slice(0, 400)}`,309 };310 }311312 // ── queries ──313314 /** True when the workspace cwd is inside a git work tree. */315 async isRepo(): Promise<boolean> {316 const result = await this.run(["rev-parse", "--is-inside-work-tree"]);317 return !("kind" in result) && result.exitCode === 0 && result.stdout.trim() === "true";318 }319320 /** Current branch name; `"HEAD"` when detached. Works in empty repositories. */321 async currentBranch(): Promise<GitResult<string>> {322 const result = await this.run(["rev-parse", "--abbrev-ref", "HEAD"]);323 const classified = this.classify(result, (stdout) => ok(stdout.trim()));324 if (classified.kind === "ok" || classified.kind === "not-a-repo") return classified;325 // Empty repository: HEAD is unborn — fall back to the status branch header.326 const status = await this.status();327 return status.kind === "ok" ? ok(status.value.branch) : status;328 }329330 /** Full working-tree status: branch, ahead/behind, dirty + untracked lists. */331 async status(): Promise<GitResult<GitStatus>> {332 const result = await this.run([333 "status",334 "--porcelain=v1",335 "-z",336 "--branch",337 "--untracked-files=all",338 ]);339 return this.classify(result, (stdout) => {340 const parsed = parseStatusZ(stdout);341 return ok({342 branch: parsed.header.branch,343 detached: parsed.header.detached,344 noCommits: parsed.header.noCommits,345 ahead: parsed.header.ahead,346 behind: parsed.header.behind,347 dirtyFiles: parsed.dirty,348 untrackedFiles: parsed.untracked,349 renamed: parsed.renamed,350 });351 });352 }353354 /** True when HEAD resolves to a commit (false in a freshly-initialized repository). */355 private async headExists(): Promise<GitResult<boolean>> {356 const result = await this.run(["rev-parse", "--verify", "--quiet", "HEAD"]);357 if ("kind" in result) return result;358 if (result.exitCode === 0) return ok(true);359 if (result.stderr.includes("not a git repository")) return { kind: "not-a-repo" };360 return ok(false); // unborn HEAD — empty repository361 }362363 /**364 * Typed diff of the working tree (staged + unstaged) against HEAD, or365 * against the index in an empty repository. Optionally limited to paths.366 */367 async diff(paths?: string[]): Promise<GitResult<GitDiff>> {368 const head = await this.headExists();369 if (head.kind !== "ok") return head;370 const base: "HEAD" | "index" = head.value ? "HEAD" : "index";371 const baseArgs = head.value ? ["HEAD"] : [];372 const pathArgs = paths !== undefined && paths.length > 0 ? ["--", ...paths] : [];373374 const nameStatus = await this.run(["diff", ...baseArgs, "--name-status", "-z", ...pathArgs]);375 const names = this.classify(nameStatus, (stdout) => ok(parseNameStatusZ(stdout)));376 if (names.kind !== "ok") return names;377378 const numstat = await this.run(["diff", ...baseArgs, "--numstat", "-z", ...pathArgs]);379 const counts = this.classify(numstat, (stdout) => ok(parseNumstatZ(stdout)));380 if (counts.kind !== "ok") return counts;381382 const entries: GitDiffEntry[] = names.value.map((entry) => {383 const count = counts.value.get(entry.path) ?? { added: 0, removed: 0 };384 const built: GitDiffEntry = {385 path: entry.path,386 status: entry.status,387 added: count.added,388 removed: count.removed,389 };390 if (entry.from !== undefined) built.from = entry.from;391 return built;392 });393 return ok({ base, entries });394 }395396 // ── baselines and attribution (CLAUDE.md §16, ADR-15) ──397398 /** Raw diff text for hashing. Returns "" when the tree is clean. */399 private async rawDiff(): Promise<GitResult<string>> {400 const head = await this.headExists();401 if (head.kind !== "ok") return head;402 const result = await this.run(["diff", ...(head.value ? ["HEAD"] : [])]);403 return this.classify(result, (stdout) => ok(stdout));404 }405406 /**407 * Capture the repository state so later changes can be attributed to this408 * session. Call at session start and again immediately before the first edit.409 */410 async recordBaseline(when: BaselineWhen): Promise<GitResult<GitBaseline>> {411 const status = await this.status();412 if (status.kind !== "ok") return status;413 const raw = await this.rawDiff();414 if (raw.kind !== "ok") return raw;415416 const untrackedSorted = [...status.value.untrackedFiles].sort();417 const diffHash = createHash("sha256")418 .update(raw.value, "utf8")419 .update("\0untracked\0", "utf8")420 .update(untrackedSorted.join("\0"), "utf8")421 .digest("hex");422423 const baseline: GitBaseline = {424 branch: status.value.branch,425 dirtyFiles: status.value.dirtyFiles,426 untrackedFiles: untrackedSorted,427 diffHash,428 };429 this.baselines.set(when, {430 when,431 at: Date.now(),432 baseline,433 perFile: perFileDiffHashes(raw.value),434 untracked: new Set(untrackedSorted),435 dirty: new Set(status.value.dirtyFiles),436 });437 return ok(baseline);438 }439440 /** The most relevant recorded baseline (pre-first-edit wins over session-start). */441 baseline(when?: BaselineWhen): GitBaseline | undefined {442 if (when !== undefined) return this.baselines.get(when)?.baseline;443 return (this.baselines.get("pre-first-edit") ?? this.baselines.get("session-start"))?.baseline;444 }445446 /**447 * Separate current working-tree changes into KHAELOR-attributable changes448 * and pre-existing user work (never assume a diff belongs to KHAELOR).449 * A baseline-dirty file that changed FURTHER appears in both lists.450 */451 async attributeChanges(): Promise<GitResult<ChangeAttribution>> {452 const stored = this.baselines.get("pre-first-edit") ?? this.baselines.get("session-start");453 if (stored === undefined) {454 return { kind: "error", code: "no-baseline", message: "no git baseline recorded for this session" };455 }456 const status = await this.status();457 if (status.kind !== "ok") return status;458 const raw = await this.rawDiff();459 if (raw.kind !== "ok") return raw;460 const currentPerFile = perFileDiffHashes(raw.value);461462 const khaelor = new Set<string>();463 const preExisting = new Set<string>();464465 for (const file of status.value.dirtyFiles) {466 if (!stored.dirty.has(file)) {467 khaelor.add(file);468 continue;469 }470 preExisting.add(file);471 const before = stored.perFile.get(file);472 const now = currentPerFile.get(file);473 if (before !== now) khaelor.add(file); // pre-existing dirt modified further this session474 }475 for (const file of status.value.untrackedFiles) {476 if (stored.untracked.has(file)) preExisting.add(file);477 else khaelor.add(file);478 }479 return ok({ khaelor: [...khaelor].sort(), preExisting: [...preExisting].sort() });480 }481}482