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%
17.1 KB · 482 lines typescript
Raw Blame History
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