/** * Git worktree management for isolated agent runs. * * Each run optionally gets its own worktree on a dedicated branch off the * current `HEAD`. This keeps `main` clean, isolates heavy per-task build * artifacts (e.g. the Rust `target/` dir), and lets multiple runs proceed in * parallel without clobbering each other's working tree. */ import { execSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import { BRANCH_PREFIX, REPO_ROOT, WORKTREE_ROOT } from './config.js'; export interface Worktree { path: string; branch: string; } /** Run a git command, returning trimmed stdout. */ function git(args: string[], opts?: { cwd?: string }): string { return execSync(['git', ...args].join(' '), { encoding: 'utf8', cwd: opts?.cwd ?? REPO_ROOT, stdio: ['ignore', 'pipe', 'pipe'], }).trim(); } /** Filesystem path for a run's worktree. */ export function worktreePathFor(runId: string): string { return path.join(WORKTREE_ROOT, runId); } /** Stable branch name derived from the card id + run id. */ export function branchNameFor(cardId: string, runId: string): string { const safe = cardId.replace(/[^a-z0-9-]+/gi, '-').toLowerCase().replace(/^-+|-+$/g, '').slice(0, 24); return `${BRANCH_PREFIX}/${safe || 'card'}-${runId.slice(0, 8)}`; } /** * Create a new worktree on a fresh branch off `HEAD`. * * Throws if the branch already exists or the worktree path is taken; callers * are expected to use a unique run id. */ export function createWorktree(runId: string, cardId: string): Worktree { fs.mkdirSync(WORKTREE_ROOT, { recursive: true }); const wtPath = worktreePathFor(runId); const branch = branchNameFor(cardId, runId); git(['worktree', 'add', '-b', branch, JSON.stringify(wtPath)]); return { path: wtPath, branch }; } /** Short SHA at the head of a worktree (best-effort). */ export function headSha(wtPath: string): string | null { try { return git(['rev-parse', '--short', 'HEAD'], { cwd: wtPath }); } catch { return null; } } /** Best-effort uncommitted change summary in a worktree (`git status --short`). */ export function dirtySummary(wtPath: string): string { try { const out = git(['status', '--short'], { cwd: wtPath }); if (!out) return ''; const files = out.split('\n'); return `${files.length} changed file(s):\n${files.slice(0, 12).join('\n')}${files.length > 12 ? '\n…' : ''}`; } catch { return ''; } } /** Remove a worktree and delete its branch (best-effort, never throws). */ export function removeWorktree(runId: string, branch: string | null): void { const wtPath = worktreePathFor(runId); try { git(['worktree', 'remove', '--force', JSON.stringify(wtPath)]); } catch { /* already gone */ } if (branch) { try { git(['branch', '-D', branch]); } catch { /* branch may be checked out elsewhere or already deleted */ } } } // --- review & merge (human-triggered) -------------------------------------- /** Whether a worktree directory still exists on disk. */ export function isWorktreePresent(wtPath: string): boolean { return fs.existsSync(wtPath); } /** Current checked-out branch at the main worktree (REPO_ROOT). */ export function currentBranch(): string { return git(['rev-parse', '--abbrev-ref', 'HEAD']); } /** Empty when the main worktree is clean, else a short summary of changes. */ export function mainDirtySummary(): string { const out = git(['status', '--porcelain']); if (!out) return ''; const files = out.split('\n'); return `${files.length} uncommitted change(s) in the main worktree:\n${files.slice(0, 12).join('\n')}${files.length > 12 ? '\n…' : ''}`; } /** True if `branch` is already contained in HEAD (nothing to merge). */ export function isMergedIntoHead(branch: string): boolean { try { git(['merge-base', '--is-ancestor', branch, 'HEAD']); return true; } catch { return false; } } export interface CommitLog { sha: string; subject: string; } /** Commits on `branch` that are not yet on HEAD (what a merge would bring in). */ export function commitsAheadOfHead(branch: string): CommitLog[] { let out: string; try { out = git(['log', '--no-decorate', '--pretty=format:%h%x09%s', `HEAD..${branch}`]); } catch { return []; } if (!out) return []; return out.split('\n').map((line) => { const idx = line.indexOf('\t'); return idx >= 0 ? { sha: line.slice(0, idx), subject: line.slice(idx + 1) } : { sha: line, subject: '' }; }); } /** Compact `--stat` summary of what `branch` changed since it forked from HEAD. */ export function diffStat(branch: string): string { try { return git(['diff', '--stat', `HEAD...${branch}`]); } catch { return ''; } } /** Full diff of what `branch` changed since it forked from HEAD, size-capped. */ export function diffPatch(branch: string, maxBytes = 60_000): { patch: string; truncated: boolean } { let raw = ''; try { raw = execSync(`git diff HEAD...${branch}`, { encoding: 'utf8', cwd: REPO_ROOT, stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 10 * 1024 * 1024, }); } catch { return { patch: '', truncated: false }; } if (raw.length <= maxBytes) return { patch: raw, truncated: false }; return { patch: raw.slice(0, maxBytes), truncated: true }; } export interface MergeResult { ok: boolean; /** Branch was already an ancestor of HEAD — merge was a no-op. */ alreadyMerged: boolean; /** Branch the merge targeted (the main worktree's checked-out branch). */ target: string; branch: string; output: string; } /** * Merge an agent branch into the main worktree's checked-out branch. * * Refuses (returns `ok:false`) on conflict, aborting the merge so the main tree * is left clean for a manual resolution. Callers should check `mainDirtySummary` * first — git will refuse to merge a dirty tree and this returns that as a * failure too. */ export function mergeBranch(branch: string): MergeResult { const target = currentBranch(); if (isMergedIntoHead(branch)) { return { ok: true, alreadyMerged: true, target, branch, output: `${branch} is already in ${target}.` }; } try { const out = execSync( `git merge --no-ff --no-edit -m ${JSON.stringify(`Merge agent branch ${branch} into ${target}`)} ${branch}`, { encoding: 'utf8', cwd: REPO_ROOT, stdio: ['ignore', 'pipe', 'pipe'] }, ); return { ok: true, alreadyMerged: false, target, branch, output: out.trim() || `Merged ${branch} into ${target}.` }; } catch (err) { // Abort any in-progress merge so the main worktree stays clean. try { execSync('git merge --abort', { cwd: REPO_ROOT, stdio: 'ignore' }); } catch { /* not mid-merge */ } const stderr = (err as { stderr?: Buffer }).stderr?.toString().trim() ?? ''; const message = err instanceof Error ? err.message : String(err); return { ok: false, alreadyMerged: false, target, branch, output: stderr || `Merge of ${branch} failed (aborted; ${target} left clean): ${message}`, }; } }