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%
3.7 KB · 123 lines typescript
Raw Blame History
1/**2 * KHAELOR3 * File: src/tasks/worktree.ts4 * Description: Git worktree isolation for parallel subtasks — create, inspect, merge, remove (v2 design §6).5 *6 * Author: Simon-Pierre Boucher7 * Contact: contact@spboucher.ai8 */910import { join } from "node:path";11import { KhaelorError } from "../shared/index.js";1213/** Minimal exec seam — satisfied by an adapter over Workspace.exec. */14export type ExecFn = (15  cmd: string,16  cwd?: string,17) => Promise<{ exitCode: number | null; stdout: string; stderr: string }>;1819export interface WorktreeInfo {20  taskId: string;21  path: string;22  branch: string;23}2425/** Where subtask worktrees live, relative to the project root (v2 §6). */26export const WORKTREES_DIR = ".khaelor/worktrees";2728export function worktreeBranch(taskId: string): string {29  return `khaelor/${taskId}`;30}3132/** `git worktree add .khaelor/worktrees/<taskId> -b khaelor/<taskId>` */33export async function createWorktree(34  exec: ExecFn,35  projectRoot: string,36  taskId: string,37): Promise<WorktreeInfo> {38  const path = join(projectRoot, WORKTREES_DIR, taskId);39  const branch = worktreeBranch(taskId);40  const result = await exec(41    `git worktree add ${JSON.stringify(path)} -b ${JSON.stringify(branch)}`,42    projectRoot,43  );44  if (result.exitCode !== 0) {45    throw new KhaelorError("internal", `git worktree add failed: ${result.stderr.trim()}`, {46      taskId,47    });48  }49  return { taskId, path, branch };50}5152/** Remove a worktree and its branch (used after merge or abandon). */53export async function removeWorktree(54  exec: ExecFn,55  projectRoot: string,56  info: WorktreeInfo,57  options: { deleteBranch?: boolean } = {},58): Promise<void> {59  await exec(`git worktree remove --force ${JSON.stringify(info.path)}`, projectRoot);60  if (options.deleteBranch === true) {61    await exec(`git branch -D ${JSON.stringify(info.branch)}`, projectRoot);62  }63}6465export interface WorktreeDiff {66  added: number;67  removed: number;68  files: string[];69}7071/** Diff stats of a subtask branch against the fork point (merge-base with HEAD). */72export async function worktreeDiffStats(73  exec: ExecFn,74  projectRoot: string,75  branch: string,76): Promise<WorktreeDiff> {77  const base = await exec(`git merge-base HEAD ${JSON.stringify(branch)}`, projectRoot);78  const baseRef = base.exitCode === 0 ? base.stdout.trim() : "HEAD";79  const numstat = await exec(80    `git diff --numstat ${JSON.stringify(baseRef)} ${JSON.stringify(branch)}`,81    projectRoot,82  );83  const diff: WorktreeDiff = { added: 0, removed: 0, files: [] };84  if (numstat.exitCode !== 0) return diff;85  for (const line of numstat.stdout.split("\n")) {86    const parts = line.split("\t");87    if (parts.length < 3) continue;88    const added = Number.parseInt(parts[0] as string, 10);89    const removed = Number.parseInt(parts[1] as string, 10);90    if (!Number.isNaN(added)) diff.added += added;91    if (!Number.isNaN(removed)) diff.removed += removed;92    diff.files.push(parts[2] as string);93  }94  return diff;95}9697export interface MergeResult {98  ok: boolean;99  conflict: boolean;100  detail: string;101}102103/** Supervised merge: `git merge --no-ff` of the subtask branch into the current branch (v2 §6). */104export async function mergeSubtaskBranch(105  exec: ExecFn,106  projectRoot: string,107  branch: string,108  message: string,109): Promise<MergeResult> {110  const result = await exec(111    `git merge --no-ff -m ${JSON.stringify(message)} ${JSON.stringify(branch)}`,112    projectRoot,113  );114  if (result.exitCode === 0) return { ok: true, conflict: false, detail: result.stdout.trim() };115  const conflict = /conflict/i.test(result.stdout + result.stderr);116  if (conflict) await exec("git merge --abort", projectRoot);117  return {118    ok: false,119    conflict,120    detail: (result.stderr || result.stdout).trim().slice(0, 2000),121  };122}123