/** * KHAELOR * File: src/repository/git.ts * Description: GitService — read-only git awareness (status, diff, baselines, attribution) via workspace.exec. * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ import { createHash } from "node:crypto"; import type { Workspace } from "../workspace/index.js"; // ───────────────────────────── result types ───────────────────────────── /** Every GitService method returns a typed result — never a raw throw for "not a repo". */ export type GitResult = | { kind: "ok"; value: T } | { kind: "not-a-repo" } | { kind: "error"; code: GitErrorCode; message: string }; export type GitErrorCode = "git-unavailable" | "git-failed" | "no-baseline"; function ok(value: T): GitResult { return { kind: "ok", value }; } // ───────────────────────────── domain types ───────────────────────────── export interface GitStatus { /** Branch name; `"HEAD"` when detached. */ branch: string; detached: boolean; /** True for a freshly-initialized repository with no commits. */ noCommits: boolean; ahead: number; behind: number; /** Tracked files with staged or unstaged changes (rename targets included). */ dirtyFiles: string[]; untrackedFiles: string[]; renamed: { from: string; to: string }[]; } export type GitChangeKind = | "added" | "modified" | "deleted" | "renamed" | "copied" | "type-changed" | "unmerged" | "unknown"; export interface GitDiffEntry { path: string; status: GitChangeKind; /** Present for renames/copies. */ from?: string; /** Lines added; null for binary files. */ added: number | null; /** Lines removed; null for binary files. */ removed: number | null; } export interface GitDiff { /** "HEAD" when the repo has commits; "index" for an empty repository. */ base: "HEAD" | "index"; entries: GitDiffEntry[]; } /** Structurally identical to the session event payload type (CLAUDE.md §16, ADR-15). */ export interface GitBaseline { branch: string; dirtyFiles: string[]; untrackedFiles: string[]; diffHash: string; } export type BaselineWhen = "session-start" | "pre-first-edit"; export interface ChangeAttribution { /** Files changed since the baseline — attributable to this KHAELOR session. */ khaelor: string[]; /** Files already dirty/untracked at baseline time — pre-existing user work. */ preExisting: string[]; } export interface GitServiceOptions { /** Hard ceiling for every git invocation. Default 15 000 ms. */ timeoutMs?: number; } // ───────────────────────────── parsers (exported for tests) ───────────────────────────── export interface BranchHeader { branch: string; detached: boolean; noCommits: boolean; ahead: number; behind: number; } /** Parse the `## …` header line of `git status --porcelain=v1 --branch`. */ export function parseBranchHeader(header: string): BranchHeader { const body = header.startsWith("## ") ? header.slice(3) : header; const result: BranchHeader = { branch: "", detached: false, noCommits: false, ahead: 0, behind: 0 }; if (body.startsWith("No commits yet on ")) { result.branch = body.slice("No commits yet on ".length).trim(); result.noCommits = true; return result; } if (body.startsWith("HEAD (no branch)")) { result.branch = "HEAD"; result.detached = true; return result; } const name = body.split("...")[0] ?? body; result.branch = name.trim(); const bracket = /\[([^\]]+)\]/.exec(body); if (bracket !== null) { const ahead = /ahead (\d+)/.exec(bracket[1] ?? ""); const behind = /behind (\d+)/.exec(bracket[1] ?? ""); if (ahead?.[1] !== undefined) result.ahead = Number.parseInt(ahead[1], 10); if (behind?.[1] !== undefined) result.behind = Number.parseInt(behind[1], 10); } return result; } export interface ParsedStatus { header: BranchHeader; dirty: string[]; untracked: string[]; renamed: { from: string; to: string }[]; } /** Parse NUL-separated `git status --porcelain=v1 -z --branch` output. */ export function parseStatusZ(raw: string): ParsedStatus { const tokens = raw.split("\0").filter((t) => t.length > 0); const parsed: ParsedStatus = { header: { branch: "", detached: false, noCommits: false, ahead: 0, behind: 0 }, dirty: [], untracked: [], renamed: [], }; for (let i = 0; i < tokens.length; i += 1) { const token = tokens[i]; if (token === undefined) continue; if (token.startsWith("## ")) { parsed.header = parseBranchHeader(token); continue; } if (token.length < 4) continue; const xy = token.slice(0, 2); const filePath = token.slice(3); if (xy === "!!") continue; // ignored entries — never surfaced if (xy === "??") { parsed.untracked.push(filePath); continue; } parsed.dirty.push(filePath); // Renames/copies: the NEXT NUL token is the original path. if (xy.includes("R") || xy.includes("C")) { const original = tokens[i + 1]; if (original !== undefined && !original.startsWith("## ")) { parsed.renamed.push({ from: original, to: filePath }); i += 1; } } } parsed.dirty.sort(); parsed.untracked.sort(); return parsed; } const NAME_STATUS_KINDS: Record = { A: "added", M: "modified", D: "deleted", R: "renamed", C: "copied", T: "type-changed", U: "unmerged", }; /** Parse `git diff --name-status -z` output into (path, status, from?) records. */ export function parseNameStatusZ(raw: string): { path: string; status: GitChangeKind; from?: string }[] { const tokens = raw.split("\0").filter((t) => t.length > 0); const entries: { path: string; status: GitChangeKind; from?: string }[] = []; let i = 0; while (i < tokens.length) { const statusToken = tokens[i]; if (statusToken === undefined) break; const kind = NAME_STATUS_KINDS[statusToken.charAt(0)] ?? "unknown"; if (kind === "renamed" || kind === "copied") { const from = tokens[i + 1]; const to = tokens[i + 2]; if (from !== undefined && to !== undefined) entries.push({ path: to, status: kind, from }); i += 3; } else { const filePath = tokens[i + 1]; if (filePath !== undefined) entries.push({ path: filePath, status: kind }); i += 2; } } return entries; } /** Parse `git diff --numstat -z` output into per-path line counts (null = binary). */ export function parseNumstatZ(raw: string): Map { const tokens = raw.split("\0").filter((t) => t.length > 0); const counts = new Map(); let i = 0; const toCount = (s: string): number | null => (s === "-" ? null : Number.parseInt(s, 10)); while (i < tokens.length) { const record = tokens[i]; if (record === undefined) break; const parts = record.split("\t"); const added = toCount(parts[0] ?? "0"); const removed = toCount(parts[1] ?? "0"); const inlinePath = parts.slice(2).join("\t"); if (inlinePath.length > 0) { counts.set(inlinePath, { added, removed }); i += 1; } else { // Rename record: empty path field, then NUL . const to = tokens[i + 2]; if (to !== undefined) counts.set(to, { added, removed }); i += 3; } } return counts; } /** Split a raw unified diff into per-file sections and hash each (attribution granularity). */ export function perFileDiffHashes(rawDiff: string): Map { const hashes = new Map(); if (rawDiff.length === 0) return hashes; const sections = rawDiff.split(/^(?=diff --git )/m).filter((s) => s.startsWith("diff --git ")); for (const section of sections) { const plusLine = /^\+\+\+ b\/(.+)$/m.exec(section); const renameLine = /^rename to (.+)$/m.exec(section); const headerLine = /^diff --git a\/.* b\/(.+)$/m.exec(section); const target = plusLine?.[1] ?? renameLine?.[1] ?? headerLine?.[1]; if (target !== undefined) { hashes.set(target, createHash("sha256").update(section, "utf8").digest("hex")); } } return hashes; } // ───────────────────────────── service ───────────────────────────── interface StoredBaseline { when: BaselineWhen; at: number; baseline: GitBaseline; perFile: Map; untracked: Set; dirty: Set; } const DEFAULT_GIT_TIMEOUT_MS = 15_000; /** * Read-only git awareness. Runs the real `git` binary through the workspace * (ADR-13 — no git library dependency) and NEVER executes a mutating command: * only `status`, `diff`, and `rev-parse` are issued (CLAUDE.md §16). */ export class GitService { private readonly workspace: Workspace; private readonly timeoutMs: number; private readonly baselines = new Map(); constructor(workspace: Workspace, options: GitServiceOptions = {}) { this.workspace = workspace; this.timeoutMs = options.timeoutMs ?? DEFAULT_GIT_TIMEOUT_MS; } // ── plumbing ── private async run( args: string[], ): Promise<{ exitCode: number | null; stdout: string; stderr: string } | { kind: "error"; code: GitErrorCode; message: string }> { const quote = (s: string): string => `'${s.replaceAll("'", "'\\''")}'`; const cmd = `git ${args.map(quote).join(" ")}`; try { const result = await this.workspace.exec({ cmd, timeoutMs: this.timeoutMs }); if (result.exitCode === 127) { return { kind: "error", code: "git-unavailable", message: "git binary not found on PATH" }; } return result; } catch (cause) { const message = cause instanceof Error ? cause.message : String(cause); return { kind: "error", code: "git-unavailable", message }; } } private classify( result: Awaited>, onOk: (stdout: string) => GitResult, ): GitResult { if ("kind" in result) return result; if (result.exitCode === 0) return onOk(result.stdout); if (result.stderr.includes("not a git repository")) return { kind: "not-a-repo" }; return { kind: "error", code: "git-failed", message: `git exited with ${String(result.exitCode)}: ${result.stderr.trim().slice(0, 400)}`, }; } // ── queries ── /** True when the workspace cwd is inside a git work tree. */ async isRepo(): Promise { const result = await this.run(["rev-parse", "--is-inside-work-tree"]); return !("kind" in result) && result.exitCode === 0 && result.stdout.trim() === "true"; } /** Current branch name; `"HEAD"` when detached. Works in empty repositories. */ async currentBranch(): Promise> { const result = await this.run(["rev-parse", "--abbrev-ref", "HEAD"]); const classified = this.classify(result, (stdout) => ok(stdout.trim())); if (classified.kind === "ok" || classified.kind === "not-a-repo") return classified; // Empty repository: HEAD is unborn — fall back to the status branch header. const status = await this.status(); return status.kind === "ok" ? ok(status.value.branch) : status; } /** Full working-tree status: branch, ahead/behind, dirty + untracked lists. */ async status(): Promise> { const result = await this.run([ "status", "--porcelain=v1", "-z", "--branch", "--untracked-files=all", ]); return this.classify(result, (stdout) => { const parsed = parseStatusZ(stdout); return ok({ branch: parsed.header.branch, detached: parsed.header.detached, noCommits: parsed.header.noCommits, ahead: parsed.header.ahead, behind: parsed.header.behind, dirtyFiles: parsed.dirty, untrackedFiles: parsed.untracked, renamed: parsed.renamed, }); }); } /** True when HEAD resolves to a commit (false in a freshly-initialized repository). */ private async headExists(): Promise> { const result = await this.run(["rev-parse", "--verify", "--quiet", "HEAD"]); if ("kind" in result) return result; if (result.exitCode === 0) return ok(true); if (result.stderr.includes("not a git repository")) return { kind: "not-a-repo" }; return ok(false); // unborn HEAD — empty repository } /** * Typed diff of the working tree (staged + unstaged) against HEAD, or * against the index in an empty repository. Optionally limited to paths. */ async diff(paths?: string[]): Promise> { const head = await this.headExists(); if (head.kind !== "ok") return head; const base: "HEAD" | "index" = head.value ? "HEAD" : "index"; const baseArgs = head.value ? ["HEAD"] : []; const pathArgs = paths !== undefined && paths.length > 0 ? ["--", ...paths] : []; const nameStatus = await this.run(["diff", ...baseArgs, "--name-status", "-z", ...pathArgs]); const names = this.classify(nameStatus, (stdout) => ok(parseNameStatusZ(stdout))); if (names.kind !== "ok") return names; const numstat = await this.run(["diff", ...baseArgs, "--numstat", "-z", ...pathArgs]); const counts = this.classify(numstat, (stdout) => ok(parseNumstatZ(stdout))); if (counts.kind !== "ok") return counts; const entries: GitDiffEntry[] = names.value.map((entry) => { const count = counts.value.get(entry.path) ?? { added: 0, removed: 0 }; const built: GitDiffEntry = { path: entry.path, status: entry.status, added: count.added, removed: count.removed, }; if (entry.from !== undefined) built.from = entry.from; return built; }); return ok({ base, entries }); } // ── baselines and attribution (CLAUDE.md §16, ADR-15) ── /** Raw diff text for hashing. Returns "" when the tree is clean. */ private async rawDiff(): Promise> { const head = await this.headExists(); if (head.kind !== "ok") return head; const result = await this.run(["diff", ...(head.value ? ["HEAD"] : [])]); return this.classify(result, (stdout) => ok(stdout)); } /** * Capture the repository state so later changes can be attributed to this * session. Call at session start and again immediately before the first edit. */ async recordBaseline(when: BaselineWhen): Promise> { const status = await this.status(); if (status.kind !== "ok") return status; const raw = await this.rawDiff(); if (raw.kind !== "ok") return raw; const untrackedSorted = [...status.value.untrackedFiles].sort(); const diffHash = createHash("sha256") .update(raw.value, "utf8") .update("\0untracked\0", "utf8") .update(untrackedSorted.join("\0"), "utf8") .digest("hex"); const baseline: GitBaseline = { branch: status.value.branch, dirtyFiles: status.value.dirtyFiles, untrackedFiles: untrackedSorted, diffHash, }; this.baselines.set(when, { when, at: Date.now(), baseline, perFile: perFileDiffHashes(raw.value), untracked: new Set(untrackedSorted), dirty: new Set(status.value.dirtyFiles), }); return ok(baseline); } /** The most relevant recorded baseline (pre-first-edit wins over session-start). */ baseline(when?: BaselineWhen): GitBaseline | undefined { if (when !== undefined) return this.baselines.get(when)?.baseline; return (this.baselines.get("pre-first-edit") ?? this.baselines.get("session-start"))?.baseline; } /** * Separate current working-tree changes into KHAELOR-attributable changes * and pre-existing user work (never assume a diff belongs to KHAELOR). * A baseline-dirty file that changed FURTHER appears in both lists. */ async attributeChanges(): Promise> { const stored = this.baselines.get("pre-first-edit") ?? this.baselines.get("session-start"); if (stored === undefined) { return { kind: "error", code: "no-baseline", message: "no git baseline recorded for this session" }; } const status = await this.status(); if (status.kind !== "ok") return status; const raw = await this.rawDiff(); if (raw.kind !== "ok") return raw; const currentPerFile = perFileDiffHashes(raw.value); const khaelor = new Set(); const preExisting = new Set(); for (const file of status.value.dirtyFiles) { if (!stored.dirty.has(file)) { khaelor.add(file); continue; } preExisting.add(file); const before = stored.perFile.get(file); const now = currentPerFile.get(file); if (before !== now) khaelor.add(file); // pre-existing dirt modified further this session } for (const file of status.value.untrackedFiles) { if (stored.untracked.has(file)) preExisting.add(file); else khaelor.add(file); } return ok({ khaelor: [...khaelor].sort(), preExisting: [...preExisting].sort() }); } }