option to stash changes before merge
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user