option to stash changes before merge

This commit is contained in:
AndyMik90
2025-12-17 11:25:00 +01:00
parent e6d6cea9e4
commit 7e09739f17
5 changed files with 183 additions and 41 deletions
@@ -224,10 +224,9 @@ export function registerWorktreeHandlers(
ipcMain.handle(
IPC_CHANNELS.TASK_WORKTREE_MERGE,
async (_, taskId: string, options?: { noCommit?: boolean }): Promise<IPCResult<WorktreeMergeResult>> => {
// Enable debug logging via DEBUG_MERGE or DEBUG environment variables
const DEBUG_MERGE = process.env.DEBUG_MERGE === 'true' || process.env.DEBUG === 'true';
// Always log merge operations for debugging
const debug = (...args: unknown[]) => {
if (DEBUG_MERGE) console.log('[MERGE DEBUG]', ...args);
console.log('[MERGE DEBUG]', ...args);
};
try {
@@ -274,15 +273,13 @@ export function registerWorktreeHandlers(
debug('Worktree path:', worktreePath, 'exists:', existsSync(worktreePath));
// Get git status before merge
if (DEBUG_MERGE) {
try {
const gitStatusBefore = execSync('git status --short', { cwd: project.path, encoding: 'utf-8' });
debug('Git status BEFORE merge in main project:\n', gitStatusBefore || '(clean)');
const gitBranch = execSync('git branch --show-current', { cwd: project.path, encoding: 'utf-8' }).trim();
debug('Current branch:', gitBranch);
} catch (e) {
debug('Failed to get git status before:', e);
}
try {
const gitStatusBefore = execSync('git status --short', { cwd: project.path, encoding: 'utf-8' });
debug('Git status BEFORE merge in main project:\n', gitStatusBefore || '(clean)');
const gitBranch = execSync('git branch --show-current', { cwd: project.path, encoding: 'utf-8' }).trim();
debug('Current branch:', gitBranch);
} catch (e) {
debug('Failed to get git status before:', e);
}
const args = [
@@ -309,18 +306,65 @@ export function registerWorktreeHandlers(
});
return new Promise((resolve) => {
const MERGE_TIMEOUT_MS = 120000; // 2 minutes timeout for merge operations
let timeoutId: NodeJS.Timeout | null = null;
let resolved = false;
const mergeProcess = spawn(pythonPath, args, {
cwd: sourcePath,
env: {
...process.env,
...profileEnv, // Include active Claude profile OAuth token
PYTHONUNBUFFERED: '1'
}
},
stdio: ['ignore', 'pipe', 'pipe'] // Don't connect stdin to avoid blocking
});
let stdout = '';
let stderr = '';
// Set up timeout to kill hung processes
timeoutId = setTimeout(() => {
if (!resolved) {
debug('TIMEOUT: Merge process exceeded', MERGE_TIMEOUT_MS, 'ms, killing...');
resolved = true;
mergeProcess.kill('SIGTERM');
// Give it a moment to clean up, then force kill
setTimeout(() => {
try {
mergeProcess.kill('SIGKILL');
} catch {
// Process may already be dead
}
}, 5000);
// Check if merge might have succeeded before the hang
// Look for success indicators in the output
const mayHaveSucceeded = stdout.includes('staged') ||
stdout.includes('Successfully merged') ||
stdout.includes('Changes from');
if (mayHaveSucceeded) {
debug('TIMEOUT: Process hung but merge may have succeeded based on output');
const isStageOnly = options?.noCommit === true;
resolve({
success: true,
data: {
success: true,
message: 'Changes staged (process timed out but merge appeared successful)',
staged: isStageOnly,
projectPath: isStageOnly ? project.path : undefined
}
});
} else {
resolve({
success: false,
error: 'Merge process timed out. Check git status to see if merge completed.'
});
}
}
}, MERGE_TIMEOUT_MS);
mergeProcess.stdout.on('data', (data: Buffer) => {
const chunk = data.toString();
stdout += chunk;
@@ -333,21 +377,24 @@ export function registerWorktreeHandlers(
debug('STDERR:', chunk);
});
mergeProcess.on('close', (code: number) => {
debug('Process exited with code:', code);
// Handler for when process exits
const handleProcessExit = (code: number | null, signal: string | null = null) => {
if (resolved) return; // Prevent double-resolution
resolved = true;
if (timeoutId) clearTimeout(timeoutId);
debug('Process exited with code:', code, 'signal:', signal);
debug('Full stdout:', stdout);
debug('Full stderr:', stderr);
// Get git status after merge
if (DEBUG_MERGE) {
try {
const gitStatusAfter = execSync('git status --short', { cwd: project.path, encoding: 'utf-8' });
debug('Git status AFTER merge in main project:\n', gitStatusAfter || '(clean)');
const gitDiffStaged = execSync('git diff --staged --stat', { cwd: project.path, encoding: 'utf-8' });
debug('Staged changes:\n', gitDiffStaged || '(none)');
} catch (e) {
debug('Failed to get git status after:', e);
}
try {
const gitStatusAfter = execSync('git status --short', { cwd: project.path, encoding: 'utf-8' });
debug('Git status AFTER merge in main project:\n', gitStatusAfter || '(clean)');
const gitDiffStaged = execSync('git diff --staged --stat', { cwd: project.path, encoding: 'utf-8' });
debug('Staged changes:\n', gitDiffStaged || '(none)');
} catch (e) {
debug('Failed to get git status after:', e);
}
if (code === 0) {
@@ -412,9 +459,22 @@ export function registerWorktreeHandlers(
}
});
}
};
mergeProcess.on('close', (code: number | null, signal: string | null) => {
handleProcessExit(code, signal);
});
// Also listen to 'exit' event in case 'close' doesn't fire
mergeProcess.on('exit', (code: number | null, signal: string | null) => {
// Give close event a chance to fire first with complete output
setTimeout(() => handleProcessExit(code, signal), 100);
});
mergeProcess.on('error', (err: Error) => {
if (resolved) return;
resolved = true;
if (timeoutId) clearTimeout(timeoutId);
console.error('[MERGE] Process spawn error:', err);
resolve({
success: false,
@@ -464,6 +524,27 @@ export function registerWorktreeHandlers(
}
console.log('[IPC] Found task:', task.specId, 'project:', project.name);
// Check for uncommitted changes in the main project
let hasUncommittedChanges = false;
let uncommittedFiles: string[] = [];
try {
const gitStatus = execSync('git status --porcelain', {
cwd: project.path,
encoding: 'utf-8'
}).trim();
if (gitStatus) {
// Parse the status output to get file names
uncommittedFiles = gitStatus.split('\n')
.filter(line => line.trim())
.map(line => line.substring(3).trim()); // Remove status prefix (e.g., "M ", " M ", "?? ")
hasUncommittedChanges = uncommittedFiles.length > 0;
console.log('[IPC] Uncommitted changes detected:', uncommittedFiles.length, 'files');
}
} catch (e) {
console.error('[IPC] Failed to check git status:', e);
}
const sourcePath = getEffectiveSourcePath();
if (!sourcePath) {
console.error('[IPC] Auto Claude source not found');
@@ -527,7 +608,13 @@ export function registerWorktreeHandlers(
autoMergeable: 0,
hasGitConflicts: false
},
gitConflicts: result.gitConflicts || null
gitConflicts: result.gitConflicts || null,
// Include uncommitted changes info for the frontend
uncommittedChanges: hasUncommittedChanges ? {
hasChanges: true,
files: uncommittedFiles,
count: uncommittedFiles.length
} : null
}
}
});
@@ -68,24 +68,37 @@ export function TaskDetailPanel({ task, onClose }: TaskDetailPanelProps) {
};
const handleMerge = async () => {
console.log('[TaskDetailPanel] handleMerge called, stageOnly:', state.stageOnly);
state.setIsMerging(true);
state.setWorkspaceError(null);
const result = await window.electronAPI.mergeWorktree(task.id, { noCommit: state.stageOnly });
if (result.success && result.data?.success) {
// For stage-only: don't close the panel, show success message
// For full merge: close the panel
if (state.stageOnly && result.data.staged) {
// Changes are staged in main project - show success but keep panel open
state.setWorkspaceError(null);
state.setStagedSuccess(result.data.message || 'Changes staged in main project');
state.setStagedProjectPath(result.data.projectPath);
try {
console.log('[TaskDetailPanel] Calling mergeWorktree...');
const result = await window.electronAPI.mergeWorktree(task.id, { noCommit: state.stageOnly });
console.log('[TaskDetailPanel] mergeWorktree result:', JSON.stringify(result, null, 2));
if (result.success && result.data?.success) {
// For stage-only: don't close the panel, show success message
// For full merge: close the panel
if (state.stageOnly && result.data.staged) {
// Changes are staged in main project - show success but keep panel open
console.log('[TaskDetailPanel] Stage-only success, showing success message');
state.setWorkspaceError(null);
state.setStagedSuccess(result.data.message || 'Changes staged in main project');
state.setStagedProjectPath(result.data.projectPath);
} else {
console.log('[TaskDetailPanel] Full merge success, closing panel');
onClose();
}
} else {
onClose();
console.log('[TaskDetailPanel] Merge failed:', result.data?.message || result.error);
state.setWorkspaceError(result.data?.message || result.error || 'Failed to merge changes');
}
} else {
state.setWorkspaceError(result.data?.message || result.error || 'Failed to merge changes');
} catch (error) {
console.error('[TaskDetailPanel] handleMerge exception:', error);
state.setWorkspaceError(error instanceof Error ? error.message : 'Unknown error during merge');
} finally {
console.log('[TaskDetailPanel] Setting isMerging to false');
state.setIsMerging(false);
}
state.setIsMerging(false);
};
const handleDiscard = async () => {
@@ -26,7 +26,7 @@ interface TaskReviewProps {
stageOnly: boolean;
stagedSuccess: string | null;
stagedProjectPath: string | undefined;
mergePreview: { files: string[]; conflicts: MergeConflict[]; summary: MergeStats; gitConflicts?: GitConflictInfo } | null;
mergePreview: { files: string[]; conflicts: MergeConflict[]; summary: MergeStats; gitConflicts?: GitConflictInfo; uncommittedChanges?: { hasChanges: boolean; files: string[]; count: number } | null } | null;
isLoadingPreview: boolean;
showConflictDialog: boolean;
onFeedbackChange: (value: string) => void;
@@ -8,7 +8,8 @@ import {
GitMerge,
FolderX,
Loader2,
RotateCcw
RotateCcw,
AlertTriangle
} from 'lucide-react';
import { Button } from '../../ui/button';
import { MergePreviewSummary } from './MergePreviewSummary';
@@ -19,7 +20,7 @@ interface WorkspaceStatusProps {
worktreeStatus: WorktreeStatus;
workspaceError: string | null;
stageOnly: boolean;
mergePreview: { files: string[]; conflicts: MergeConflict[]; summary: MergeStats; gitConflicts?: GitConflictInfo } | null;
mergePreview: { files: string[]; conflicts: MergeConflict[]; summary: MergeStats; gitConflicts?: GitConflictInfo; uncommittedChanges?: { hasChanges: boolean; files: string[]; count: number } | null } | null;
isLoadingPreview: boolean;
isMerging: boolean;
isDiscarding: boolean;
@@ -51,6 +52,8 @@ export function WorkspaceStatus({
onMerge
}: WorkspaceStatusProps) {
const hasGitConflicts = mergePreview?.gitConflicts?.hasConflicts;
const hasUncommittedChanges = mergePreview?.uncommittedChanges?.hasChanges;
const uncommittedCount = mergePreview?.uncommittedChanges?.count || 0;
return (
<div className="review-section-highlight">
@@ -99,6 +102,39 @@ export function WorkspaceStatus({
</div>
)}
{/* Uncommitted Changes Warning */}
{hasUncommittedChanges && (
<div className="bg-warning/10 border border-warning/30 rounded-lg p-3 mb-3">
<div className="flex items-start gap-2">
<AlertTriangle className="h-4 w-4 text-warning mt-0.5 flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-warning">
Uncommitted Changes Detected
</p>
<p className="text-xs text-muted-foreground mt-1">
Your main project has {uncommittedCount} uncommitted {uncommittedCount === 1 ? 'change' : 'changes'}.
Please commit or stash them before staging to avoid conflicts.
</p>
<div className="flex gap-2 mt-2">
<Button
variant="outline"
size="sm"
onClick={() => {
window.electronAPI.createTerminal({
id: `stash-${task.id}`,
cwd: worktreeStatus.worktreePath?.replace('.worktrees/' + task.specId, '') || undefined
});
}}
className="text-xs h-7"
>
Open Terminal to Stash
</Button>
</div>
</div>
</div>
</div>
)}
{/* Action Buttons */}
<div className="flex gap-2 mb-3">
<Button
+6
View File
@@ -352,6 +352,12 @@ export interface WorktreeMergeResult {
conflicts: MergeConflict[];
summary: MergeStats;
gitConflicts?: GitConflictInfo;
// Uncommitted changes in the main project that could block merge
uncommittedChanges?: {
hasChanges: boolean;
files: string[];
count: number;
} | null;
};
}