/** * Git worktree management for external agent dispatch. * * Each dispatched task gets its own git worktree so the external agent * can modify files freely without touching the main working tree. * After the agent completes, we diff the worktree against HEAD and * queue the diff into pending_changes. */ import { sshExec } from './ssh.js'; const WORKTREE_BASE = '/tmp/booworktrees'; /** * Create a git worktree for a task on the host. * Returns the absolute path to the worktree directory. */ export async function createWorktree( projectPath: string, taskId: string, opts?: { signal?: AbortSignal }, ): Promise { const worktreePath = `${WORKTREE_BASE}/${taskId}`; const branchName = `task-${taskId}`; // Ensure the base directory exists await sshExec(`mkdir -p ${WORKTREE_BASE}`, { signal: opts?.signal }); // Create the worktree with a new branch from HEAD const result = await sshExec( `git -C ${shellEscape(projectPath)} worktree add ${shellEscape(worktreePath)} -b ${shellEscape(branchName)} HEAD`, { signal: opts?.signal, timeoutMs: 30_000 }, ); if (result.exitCode !== 0) { throw new Error(`Failed to create worktree: ${result.stderr.trim() || result.stdout.trim()}`); } return worktreePath; } /** * Get the unified diff of changes made in the worktree vs the parent branch (HEAD). * Returns an empty string if there are no changes. */ export async function diffWorktree( worktreePath: string, projectPath: string, opts?: { signal?: AbortSignal }, ): Promise { // First, commit any uncommitted changes in the worktree so we can diff branches // Stage all changes const addResult = await sshExec( `cd ${shellEscape(worktreePath)} && git add -A`, { signal: opts?.signal, timeoutMs: 30_000 }, ); if (addResult.exitCode !== 0) { throw new Error(`Failed to stage worktree changes: ${addResult.stderr.trim()}`); } // Check if there are staged changes const statusResult = await sshExec( `cd ${shellEscape(worktreePath)} && git diff --cached --quiet`, { signal: opts?.signal, timeoutMs: 10_000 }, ); if (statusResult.exitCode === 0) { // No changes return ''; } // Commit staged changes (needed to produce a clean branch diff) await sshExec( `cd ${shellEscape(worktreePath)} && git -c user.email=boocoder@local -c user.name=BooCoder commit -m "task changes" --allow-empty`, { signal: opts?.signal, timeoutMs: 15_000 }, ); // Diff the worktree branch against the parent commit (HEAD of main tree) const diffResult = await sshExec( `git -C ${shellEscape(projectPath)} diff HEAD...$(git -C ${shellEscape(worktreePath)} rev-parse HEAD)`, { signal: opts?.signal, timeoutMs: 60_000 }, ); if (diffResult.exitCode !== 0) { throw new Error(`Failed to diff worktree: ${diffResult.stderr.trim()}`); } return diffResult.stdout; } /** * Remove a worktree and its associated branch. * Best-effort — does not throw on failure (task may have already been cleaned up). */ export async function cleanupWorktree( projectPath: string, taskId: string, ): Promise { const worktreePath = `${WORKTREE_BASE}/${taskId}`; const branchName = `task-${taskId}`; // Remove the worktree (--force handles dirty state) await sshExec( `git -C ${shellEscape(projectPath)} worktree remove ${shellEscape(worktreePath)} --force`, { timeoutMs: 15_000 }, ).catch(() => {}); // Delete the task branch await sshExec( `git -C ${shellEscape(projectPath)} branch -D ${shellEscape(branchName)}`, { timeoutMs: 10_000 }, ).catch(() => {}); } /** Minimal shell escape for paths (single-quote wrapping). */ function shellEscape(s: string): string { // Replace single quotes with escaped version, wrap in single quotes return "'" + s.replace(/'/g, "'\\''") + "'"; }