/** * KHAELOR * File: src/tasks/worktree.ts * Description: Git worktree isolation for parallel subtasks — create, inspect, merge, remove (v2 design §6). * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ import { join } from "node:path"; import { KhaelorError } from "../shared/index.js"; /** Minimal exec seam — satisfied by an adapter over Workspace.exec. */ export type ExecFn = ( cmd: string, cwd?: string, ) => Promise<{ exitCode: number | null; stdout: string; stderr: string }>; export interface WorktreeInfo { taskId: string; path: string; branch: string; } /** Where subtask worktrees live, relative to the project root (v2 §6). */ export const WORKTREES_DIR = ".khaelor/worktrees"; export function worktreeBranch(taskId: string): string { return `khaelor/${taskId}`; } /** `git worktree add .khaelor/worktrees/ -b khaelor/` */ export async function createWorktree( exec: ExecFn, projectRoot: string, taskId: string, ): Promise { const path = join(projectRoot, WORKTREES_DIR, taskId); const branch = worktreeBranch(taskId); const result = await exec( `git worktree add ${JSON.stringify(path)} -b ${JSON.stringify(branch)}`, projectRoot, ); if (result.exitCode !== 0) { throw new KhaelorError("internal", `git worktree add failed: ${result.stderr.trim()}`, { taskId, }); } return { taskId, path, branch }; } /** Remove a worktree and its branch (used after merge or abandon). */ export async function removeWorktree( exec: ExecFn, projectRoot: string, info: WorktreeInfo, options: { deleteBranch?: boolean } = {}, ): Promise { await exec(`git worktree remove --force ${JSON.stringify(info.path)}`, projectRoot); if (options.deleteBranch === true) { await exec(`git branch -D ${JSON.stringify(info.branch)}`, projectRoot); } } export interface WorktreeDiff { added: number; removed: number; files: string[]; } /** Diff stats of a subtask branch against the fork point (merge-base with HEAD). */ export async function worktreeDiffStats( exec: ExecFn, projectRoot: string, branch: string, ): Promise { const base = await exec(`git merge-base HEAD ${JSON.stringify(branch)}`, projectRoot); const baseRef = base.exitCode === 0 ? base.stdout.trim() : "HEAD"; const numstat = await exec( `git diff --numstat ${JSON.stringify(baseRef)} ${JSON.stringify(branch)}`, projectRoot, ); const diff: WorktreeDiff = { added: 0, removed: 0, files: [] }; if (numstat.exitCode !== 0) return diff; for (const line of numstat.stdout.split("\n")) { const parts = line.split("\t"); if (parts.length < 3) continue; const added = Number.parseInt(parts[0] as string, 10); const removed = Number.parseInt(parts[1] as string, 10); if (!Number.isNaN(added)) diff.added += added; if (!Number.isNaN(removed)) diff.removed += removed; diff.files.push(parts[2] as string); } return diff; } export interface MergeResult { ok: boolean; conflict: boolean; detail: string; } /** Supervised merge: `git merge --no-ff` of the subtask branch into the current branch (v2 §6). */ export async function mergeSubtaskBranch( exec: ExecFn, projectRoot: string, branch: string, message: string, ): Promise { const result = await exec( `git merge --no-ff -m ${JSON.stringify(message)} ${JSON.stringify(branch)}`, projectRoot, ); if (result.exitCode === 0) return { ok: true, conflict: false, detail: result.stdout.trim() }; const conflict = /conflict/i.test(result.stdout + result.stderr); if (conflict) await exec("git merge --abort", projectRoot); return { ok: false, conflict, detail: (result.stderr || result.stdout).trim().slice(0, 2000), }; }