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