UNPKG

dmux

Version:

Tmux pane manager with AI agent integration for parallel development workflows

310 lines 14.6 kB
import React, { useState, useEffect } from 'react'; import { Box, Text, useInput, useApp } from 'ink'; import { execSync } from 'child_process'; import CleanTextInput from './CleanTextInput.js'; import chalk from 'chalk'; export default function MergePane({ pane, onComplete, onCancel, mainBranch }) { const { exit } = useApp(); const [status, setStatus] = useState('checking'); const [commandHistory, setCommandHistory] = useState([]); const [error, setError] = useState(null); const [conflictFiles, setConflictFiles] = useState([]); const [showResolutionPrompt, setShowResolutionPrompt] = useState(false); const [agentPrompt, setAgentPrompt] = useState(''); const [showAgentPromptInput, setShowAgentPromptInput] = useState(false); const [commitMessage, setCommitMessage] = useState(''); const [showCommitInput, setShowCommitInput] = useState(false); const addCommandOutput = (command, output, error) => { setCommandHistory(prev => [...prev, { command, output, error, timestamp: new Date() }]); }; const runCommand = (command, cwd) => { try { const output = execSync(command, { cwd: cwd || pane.worktreePath, encoding: 'utf8', stdio: 'pipe' }); addCommandOutput(command, output); return { success: true, output }; } catch (err) { const errorMsg = err.stderr || err.message; addCommandOutput(command, '', errorMsg); return { success: false, output: '', error: errorMsg }; } }; const checkForConflicts = (cwd) => { const result = runCommand('git status --porcelain', cwd); if (result.success) { const conflicts = result.output .split('\n') .filter(line => line.startsWith('UU ') || line.startsWith('AA ')) .map(line => line.substring(3).trim()); if (conflicts.length > 0) { setConflictFiles(conflicts); return true; } } return false; }; const generateCommitMessage = async () => { const apiKey = process.env.OPENROUTER_API_KEY; if (!apiKey) { return `chore: merge ${pane.slug} into ${mainBranch}`; } try { const diff = runCommand('git diff --staged'); if (!diff.success) { return `chore: merge ${pane.slug} into ${mainBranch}`; } const response = await fetch('https://openrouter.ai/api/v1/chat/completions', { method: 'POST', headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ model: 'openai/gpt-4o-mini', messages: [{ role: 'user', content: `Generate a concise, semantic commit message for these changes. Follow conventional commits format (feat:, fix:, chore:, etc). Be specific about what changed:\n\n${diff.output.substring(0, 4000)}` }], max_tokens: 100, temperature: 0.3, }), }); const data = await response.json(); return data.choices?.[0]?.message?.content?.trim() || `chore: merge ${pane.slug} into ${mainBranch}`; } catch { return `chore: merge ${pane.slug} into ${mainBranch}`; } }; const performMerge = async () => { setStatus('checking'); // Step 1: Check for uncommitted changes in the worktree const statusResult = runCommand('git status --porcelain'); if (statusResult.success && statusResult.output.trim()) { setStatus('uncommitted-changes'); // Generate commit message setStatus('committing'); const message = await generateCommitMessage(); setCommitMessage(message); // Stage and commit changes in the worktree runCommand('git add -A'); const commitResult = runCommand(`git commit -m "${message}"`); if (!commitResult.success) { setError('Failed to commit changes'); setStatus('error'); return; } } // Step 2: Get the main repository path (parent of .dmux/worktrees) const mainRepoPath = pane.worktreePath?.replace(/\/\.dmux\/worktrees\/[^/]+$/, ''); if (!mainRepoPath) { setError('Could not determine main repository path'); setStatus('error'); return; } // Step 3: Switch to main repository and checkout main branch setStatus('switching-branch'); const checkoutResult = runCommand(`git checkout ${mainBranch}`, mainRepoPath); if (!checkoutResult.success) { // If main is already checked out, that's fine if (!checkoutResult.error?.includes('already on')) { setError(`Failed to switch to ${mainBranch} branch: ${checkoutResult.error}`); setStatus('error'); return; } } // Step 4: Attempt merge in the main repository setStatus('merging'); const mergeResult = runCommand(`git merge ${pane.slug} --no-ff`, mainRepoPath); if (!mergeResult.success) { // Check if it's a merge conflict if (mergeResult.error?.includes('Automatic merge failed') || checkForConflicts(mainRepoPath)) { setStatus('merge-conflict'); setShowResolutionPrompt(true); return; } else { setError('Merge failed: ' + mergeResult.error); setStatus('error'); return; } } // Step 5: Clean up worktree and branch setStatus('completing'); runCommand(`git worktree remove ${pane.worktreePath} --force`, mainRepoPath); runCommand(`git branch -d ${pane.slug}`, mainRepoPath); setStatus('success'); }; const handleAgentResolution = () => { setShowResolutionPrompt(false); setShowAgentPromptInput(true); }; const submitAgentResolution = () => { setShowAgentPromptInput(false); setStatus('resolving-with-agent'); // Get the main repository path const mainRepoPath = pane.worktreePath?.replace(/\/\.dmux\/worktrees\/[^/]+$/, ''); // Exit the app and launch agent with conflict resolution prompt const fullPrompt = agentPrompt || `Fix the merge conflicts in the following files: ${conflictFiles.join(', ')}. Resolve them appropriately based on the changes from branch ${pane.slug} (${pane.prompt}) and ensure the code remains functional.`; // Clear screen and exit process.stdout.write('\x1b[2J\x1b[H'); // Launch Claude to resolve conflicts in the main repository try { execSync(`claude "${fullPrompt}" --permission-mode=acceptEdits`, { stdio: 'inherit', cwd: mainRepoPath || process.cwd() }); } catch { // Try opencode as fallback try { execSync(`echo "${fullPrompt}" | opencode`, { stdio: 'inherit', cwd: mainRepoPath || process.cwd() }); } catch { } } exit(); }; const handleManualResolution = () => { setShowResolutionPrompt(false); setStatus('manual-resolution'); // Show instructions and exit process.stdout.write('\x1b[2J\x1b[H'); console.log(chalk.yellow('\nManual merge conflict resolution required:\n')); console.log(chalk.white('Conflicted files:')); conflictFiles.forEach(file => console.log(chalk.red(` - ${file}`))); console.log(chalk.white('\nTo resolve manually:')); console.log(chalk.gray('1. Edit the conflicted files to resolve merge markers')); console.log(chalk.gray('2. Stage the resolved files: git add <files>')); console.log(chalk.gray('3. Complete the merge: git commit')); console.log(chalk.gray('4. Clean up the worktree manually if needed\n')); exit(); }; useEffect(() => { performMerge(); }, []); useInput((input, key) => { if (key.escape) { onCancel(); return; } if (showResolutionPrompt && !showAgentPromptInput) { if (input === 'a' || input === 'A') { handleAgentResolution(); } else if (input === 'm' || input === 'M') { handleManualResolution(); } else if (input === 'c' || input === 'C') { onCancel(); } } if (status === 'success') { if (input === 'y' || input === 'Y' || key.return) { onComplete(); } else if (input === 'n' || input === 'N') { onCancel(); } } if (status === 'error' && key.return) { onCancel(); } }); const getStatusColor = (s) => { switch (s) { case 'error': case 'merge-conflict': return 'red'; case 'success': return 'green'; case 'checking': case 'committing': case 'switching-branch': case 'merging': case 'completing': return 'yellow'; default: return 'white'; } }; const getStatusText = (s) => { switch (s) { case 'checking': return 'Checking repository status...'; case 'uncommitted-changes': return 'Found uncommitted changes, committing...'; case 'committing': return `Committing changes: ${commitMessage}`; case 'switching-branch': return `Switching to ${mainBranch} branch...`; case 'merging': return `Merging ${pane.slug} into ${mainBranch}...`; case 'merge-conflict': return 'Merge conflict detected!'; case 'conflict-resolution-prompt': return 'Choose conflict resolution method'; case 'resolving-with-agent': return 'Launching agent to resolve conflicts...'; case 'manual-resolution': return 'Manual resolution selected'; case 'completing': return 'Cleaning up worktree and branch...'; case 'success': return 'Merge completed successfully!'; case 'error': return `Error: ${error}`; default: return ''; } }; return (React.createElement(Box, { flexDirection: "column", padding: 1 }, React.createElement(Box, { marginBottom: 1 }, React.createElement(Text, { bold: true, color: "cyan" }, "Merging: ", pane.slug, " \u2192 ", mainBranch)), React.createElement(Box, { borderStyle: "round", borderColor: "gray", flexDirection: "column", padding: 1, marginBottom: 1 }, React.createElement(Text, { color: getStatusColor(status), bold: true }, "Status: ", getStatusText(status))), commandHistory.length > 0 && (React.createElement(Box, { borderStyle: "single", borderColor: "gray", flexDirection: "column", padding: 1, marginBottom: 1, height: 10 }, React.createElement(Text, { dimColor: true }, "Command Output:"), commandHistory.slice(-5).map((cmd, i) => (React.createElement(Box, { key: i, flexDirection: "column" }, React.createElement(Text, { color: "blue" }, "$ ", cmd.command), cmd.output && React.createElement(Text, { dimColor: true }, cmd.output.substring(0, 200)), cmd.error && React.createElement(Text, { color: "red" }, cmd.error.substring(0, 200))))))), showResolutionPrompt && !showAgentPromptInput && (React.createElement(Box, { borderStyle: "double", borderColor: "yellow", flexDirection: "column", padding: 1 }, React.createElement(Text, { color: "yellow", bold: true }, "Merge Conflict Resolution Required"), React.createElement(Text, null, "Conflicted files:"), conflictFiles.map(file => (React.createElement(Text, { key: file, color: "red" }, " \u2022 ", file))), React.createElement(Text, null, " "), React.createElement(Text, null, "Choose resolution method:"), React.createElement(Text, { color: "cyan" }, " (A) Resolve with AI agent"), React.createElement(Text, { color: "green" }, " (M) Resolve manually"), React.createElement(Text, { color: "gray" }, " (C) Cancel merge"))), showAgentPromptInput && (React.createElement(Box, { borderStyle: "round", borderColor: "cyan", flexDirection: "column", padding: 1 }, React.createElement(Text, null, "Enter prompt for agent (or press Enter for default):"), React.createElement(CleanTextInput, { value: agentPrompt, onChange: setAgentPrompt, onSubmit: submitAgentResolution, placeholder: `Fix merge conflicts from ${pane.slug} branch` }))), status === 'success' && (React.createElement(Box, { borderStyle: "double", borderColor: "green", flexDirection: "column", padding: 1 }, React.createElement(Text, { color: "green", bold: true }, "\u2713 Merge completed successfully!"), React.createElement(Text, null, "Branch ", pane.slug, " has been merged into ", mainBranch), React.createElement(Text, null, " "), React.createElement(Text, null, "Close the pane \"", pane.slug, "\"? (Y/n)"))), status === 'error' && (React.createElement(Box, { borderStyle: "double", borderColor: "red", flexDirection: "column", padding: 1 }, React.createElement(Text, { color: "red", bold: true }, "\u2717 Merge failed"), React.createElement(Text, null, error), React.createElement(Text, null, " "), React.createElement(Text, { dimColor: true }, "Press Enter to return to main menu"))))); } //# sourceMappingURL=MergePane.js.map