From 7e09739f170046cf881e5896d4ec9048acbd2f3e Mon Sep 17 00:00:00 2001 From: AndyMik90 Date: Wed, 17 Dec 2025 11:25:00 +0100 Subject: [PATCH] option to stash changes before merge --- .../ipc-handlers/task/worktree-handlers.ts | 137 ++++++++++++++---- .../task-detail/TaskDetailPanel.tsx | 39 +++-- .../components/task-detail/TaskReview.tsx | 2 +- .../task-review/WorkspaceStatus.tsx | 40 ++++- auto-claude-ui/src/shared/types/task.ts | 6 + 5 files changed, 183 insertions(+), 41 deletions(-) diff --git a/auto-claude-ui/src/main/ipc-handlers/task/worktree-handlers.ts b/auto-claude-ui/src/main/ipc-handlers/task/worktree-handlers.ts index 2fdd91dd..92371e5d 100644 --- a/auto-claude-ui/src/main/ipc-handlers/task/worktree-handlers.ts +++ b/auto-claude-ui/src/main/ipc-handlers/task/worktree-handlers.ts @@ -224,10 +224,9 @@ export function registerWorktreeHandlers( ipcMain.handle( IPC_CHANNELS.TASK_WORKTREE_MERGE, async (_, taskId: string, options?: { noCommit?: boolean }): Promise> => { - // 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 } } }); diff --git a/auto-claude-ui/src/renderer/components/task-detail/TaskDetailPanel.tsx b/auto-claude-ui/src/renderer/components/task-detail/TaskDetailPanel.tsx index abae8243..eda7fa60 100644 --- a/auto-claude-ui/src/renderer/components/task-detail/TaskDetailPanel.tsx +++ b/auto-claude-ui/src/renderer/components/task-detail/TaskDetailPanel.tsx @@ -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 () => { diff --git a/auto-claude-ui/src/renderer/components/task-detail/TaskReview.tsx b/auto-claude-ui/src/renderer/components/task-detail/TaskReview.tsx index 6fab7349..3df18b1b 100644 --- a/auto-claude-ui/src/renderer/components/task-detail/TaskReview.tsx +++ b/auto-claude-ui/src/renderer/components/task-detail/TaskReview.tsx @@ -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; diff --git a/auto-claude-ui/src/renderer/components/task-detail/task-review/WorkspaceStatus.tsx b/auto-claude-ui/src/renderer/components/task-detail/task-review/WorkspaceStatus.tsx index 8f849097..2bd9c729 100644 --- a/auto-claude-ui/src/renderer/components/task-detail/task-review/WorkspaceStatus.tsx +++ b/auto-claude-ui/src/renderer/components/task-detail/task-review/WorkspaceStatus.tsx @@ -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 (
@@ -99,6 +102,39 @@ export function WorkspaceStatus({
)} + {/* Uncommitted Changes Warning */} + {hasUncommittedChanges && ( +
+
+ +
+

+ Uncommitted Changes Detected +

+

+ Your main project has {uncommittedCount} uncommitted {uncommittedCount === 1 ? 'change' : 'changes'}. + Please commit or stash them before staging to avoid conflicts. +

+
+ +
+
+
+
+ )} + {/* Action Buttons */}