/** * hive_execute_merge tool — called by the LLM after pre-merge checks. * Handles: test merge, execute merge, cleanup worktree + branch. */ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import { Type } from "@sinclair/typebox"; import * as fs from "node:fs"; import * as path from "node:path"; import { execFileSync } from "node:child_process"; import { testMerge, executeMerge, removeWorktree, deleteBranch, getCurrentBranch, getDiffStats, listWorktrees, } from "../lib/git-worktree.js"; const MergeParams = Type.Object({ worktreePath: Type.String({ description: "Absolute path to the worktree directory" }), branch: Type.String({ description: "Branch name of the worktree to merge" }), featureTitles: Type.Optional( Type.Array(Type.String(), { description: "Feature titles that were in this worktree" }), ), }); export function registerWorktreeMergeTool(pi: ExtensionAPI) { pi.registerTool({ name: "hive_execute_merge", label: "Hive Merge", description: "Merge a worktree branch into the main branch. Only call this after verifying the worktree is ready (no uncommitted changes, features complete). Tests for conflicts before merging.", parameters: MergeParams, async execute(_toolCallId, params, _signal, _onUpdate, ctx) { // Find the main project directory (not the worktree) const configEntry = getLatestEntry(ctx, "hive-merge-config"); const projectDir = configEntry?.projectDir ?? ctx.cwd; const mainBranch = await getCurrentBranch(projectDir); const { worktreePath, branch } = params; const featureTitles = params.featureTitles ?? []; // ── Step 1: Test merge ───────────────────────────────────── const mergeTest = await testMerge(projectDir, branch); if (!mergeTest.clean) { return { content: [{ type: "text" as const, text: `Conflicts detected! Merge aborted.\n\nConflicting files:\n${mergeTest.conflicts.map((f) => ` ${f}`).join("\n")}\n\nHow to resolve:\n 1. cd ${worktreePath}\n 2. git merge ${mainBranch}\n 3. Resolve conflicts manually\n 4. git add . && git commit\n 5. /hive:worktree-merge ${path.basename(worktreePath)}`, }], details: {}, isError: true, }; } // ── Step 2: Execute merge ────────────────────────────────── const featureList = featureTitles.length > 0 ? featureTitles.join(", ") : path.basename(worktreePath); const commitMsg = `merge: ${branch} — Features: ${featureList}`; try { await executeMerge(projectDir, branch, commitMsg); } catch (err: any) { return { content: [{ type: "text" as const, text: `Merge failed: ${err.message}`, }], details: {}, isError: true, }; } // ── Step 3: Get diff stats ───────────────────────────────── const diffStats = await getDiffStats(projectDir, branch); // ── Step 3.5: Sandbox cleanup ────────────────────────────── try { const mapPath = path.join(projectDir, "territory_map.json"); if (fs.existsSync(mapPath)) { const map = JSON.parse(fs.readFileSync(mapPath, "utf-8")); const wtEntry = (map.worktrees || []).find((w: any) => w.path === worktreePath || w.branch === branch); if (wtEntry?.sandbox?.enabled && wtEntry.sandbox.name) { try { execFileSync("docker", ["sandbox", "rm", wtEntry.sandbox.name], { stdio: "ignore" }); } catch { /* already removed or not running */ } } } } catch { /* ignore territory_map read errors */ } // ── Step 4: Cleanup ──────────────────────────────────────── const cleanupErrors: string[] = []; try { await removeWorktree(projectDir, worktreePath); } catch (err: any) { cleanupErrors.push(`Worktree removal: ${err.message}`); } try { await deleteBranch(projectDir, branch); } catch (err: any) { cleanupErrors.push(`Branch deletion: ${err.message}`); } // ── Step 5: List remaining worktrees ─────────────────────── const remaining = await listWorktrees(projectDir); const otherWorktrees = remaining.filter( (wt) => wt.path !== projectDir && !wt.isBare, ); // ── Step 6: Report ───────────────────────────────────────── let report = `Merge complete!\n\n`; report += `Branch: ${branch} → ${mainBranch}\n`; if (featureTitles.length > 0) { report += `Features merged:\n${featureTitles.map((t) => ` ${t}`).join("\n")}\n`; } if (diffStats) { report += `\n${diffStats}\n`; } if (cleanupErrors.length > 0) { report += `\nCleanup warnings:\n${cleanupErrors.map((e) => ` ⚠ ${e}`).join("\n")}\n`; } if (otherWorktrees.length > 0) { report += `\nWorktrees remaining:\n`; for (const wt of otherWorktrees) { report += ` ${path.basename(wt.path)} (branch: ${wt.branch})\n`; } report += `\nNext: /hive:worktree-merge ${path.basename(otherWorktrees[0].path)}`; } else { report += `\nAll worktrees merged! 🎉`; } return { content: [{ type: "text" as const, text: report }], details: {}, }; }, }); } function getLatestEntry(ctx: any, customType: string): any { const entries = ctx.sessionManager.getEntries(); for (let i = entries.length - 1; i >= 0; i--) { const e = entries[i] as any; if (e.type === "custom" && e.customType === customType) { return e.data; } } return null; }