dmux
Version:
Tmux pane manager with AI agent integration for parallel development workflows
310 lines • 14.6 kB
JavaScript
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