feat: Add git log parsing and rebase endpoint with input validation

This commit is contained in:
gsxdsm
2026-02-18 00:31:31 -08:00
parent e6e04d57bc
commit d30296d559
42 changed files with 2826 additions and 376 deletions

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import {
Dialog,
DialogContent,
@@ -149,6 +149,9 @@ export function CherryPickDialog({
const [commitLimit, setCommitLimit] = useState(30);
const [hasMoreCommits, setHasMoreCommits] = useState(false);
// Ref to track the latest fetchCommits request and ignore stale responses
const fetchCommitsRequestRef = useRef(0);
// Cherry-pick state
const [isCherryPicking, setIsCherryPicking] = useState(false);
@@ -182,6 +185,8 @@ export function CherryPickDialog({
useEffect(() => {
if (!open || !worktree) return;
let mounted = true;
const fetchBranchData = async () => {
setLoadingBranches(true);
try {
@@ -193,6 +198,8 @@ export function CherryPickDialog({
api.worktree.listBranches(worktree.path, false),
]);
if (!mounted) return;
if (remotesResult.success && remotesResult.result) {
setRemotes(remotesResult.result.remotes);
// Default to first remote if available, otherwise local
@@ -212,13 +219,20 @@ export function CherryPickDialog({
setLocalBranches(branches);
}
} catch (err) {
if (!mounted) return;
console.error('Failed to fetch branch data:', err);
} finally {
setLoadingBranches(false);
if (mounted) {
setLoadingBranches(false);
}
}
};
fetchBranchData();
return () => {
mounted = false;
};
}, [open, worktree]);
// Fetch commits when branch is selected
@@ -226,6 +240,9 @@ export function CherryPickDialog({
async (limit: number = 30, append: boolean = false) => {
if (!worktree || !selectedBranch) return;
// Increment the request counter and capture the current request ID
const requestId = ++fetchCommitsRequestRef.current;
if (append) {
setLoadingMoreCommits(true);
} else {
@@ -239,18 +256,28 @@ export function CherryPickDialog({
const api = getHttpApiClient();
const result = await api.worktree.getBranchCommitLog(worktree.path, selectedBranch, limit);
// Ignore stale responses from superseded requests
if (requestId !== fetchCommitsRequestRef.current) return;
if (result.success && result.result) {
setCommits(result.result.commits);
// If we got exactly the limit, there may be more commits
setHasMoreCommits(result.result.commits.length >= limit);
} else {
} else if (!append) {
setCommitsError(result.error || 'Failed to load commits');
}
} catch (err) {
setCommitsError(err instanceof Error ? err.message : 'Failed to load commits');
// Ignore stale responses from superseded requests
if (requestId !== fetchCommitsRequestRef.current) return;
if (!append) {
setCommitsError(err instanceof Error ? err.message : 'Failed to load commits');
}
} finally {
setLoadingCommits(false);
setLoadingMoreCommits(false);
// Only update loading state if this is still the current request
if (requestId === fetchCommitsRequestRef.current) {
setLoadingCommits(false);
setLoadingMoreCommits(false);
}
}
},
[worktree, selectedBranch]
@@ -384,6 +411,7 @@ export function CherryPickDialog({
sourceBranch: selectedBranch,
targetBranch: conflictInfo.targetBranch,
targetWorktreePath: conflictInfo.targetWorktreePath,
operationType: 'merge',
});
onOpenChange(false);
}
@@ -703,7 +731,7 @@ export function CherryPickDialog({
<SelectTrigger className="w-full">
<SelectValue placeholder="Select source..." />
</SelectTrigger>
<SelectContent className="text-black dark:text-black">
<SelectContent className="text-foreground">
<SelectItem value="__local__">Local Branches</SelectItem>
{remotes.map((remote) => (
<SelectItem key={remote.name} value={remote.name}>
@@ -725,7 +753,7 @@ export function CherryPickDialog({
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a branch..." />
</SelectTrigger>
<SelectContent className="text-black dark:text-black">
<SelectContent className="text-foreground">
{branchOptions.map((branch) => (
<SelectItem key={branch} value={branch}>
{branch}

View File

@@ -0,0 +1,452 @@
import { useState, useEffect, useCallback } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import {
Download,
AlertTriangle,
Archive,
CheckCircle2,
XCircle,
FileWarning,
Wrench,
Sparkles,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import type { MergeConflictInfo } from '../worktree-panel/types';
interface WorktreeInfo {
path: string;
branch: string;
isMain: boolean;
hasChanges?: boolean;
changedFilesCount?: number;
}
type PullPhase =
| 'checking' // Initial check for local changes
| 'local-changes' // Local changes detected, asking user what to do
| 'pulling' // Actively pulling (with or without stash)
| 'success' // Pull completed successfully
| 'conflict' // Merge conflicts detected
| 'error'; // Something went wrong
interface PullResult {
branch: string;
pulled: boolean;
message: string;
hasLocalChanges?: boolean;
localChangedFiles?: string[];
hasConflicts?: boolean;
conflictSource?: 'pull' | 'stash';
conflictFiles?: string[];
stashed?: boolean;
stashRestored?: boolean;
}
interface GitPullDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
worktree: WorktreeInfo | null;
remote?: string;
onPulled?: () => void;
onCreateConflictResolutionFeature?: (conflictInfo: MergeConflictInfo) => void;
}
export function GitPullDialog({
open,
onOpenChange,
worktree,
remote,
onPulled,
onCreateConflictResolutionFeature,
}: GitPullDialogProps) {
const [phase, setPhase] = useState<PullPhase>('checking');
const [pullResult, setPullResult] = useState<PullResult | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
// Reset state when dialog opens
useEffect(() => {
if (open && worktree) {
setPhase('checking');
setPullResult(null);
setErrorMessage(null);
// Start the initial check
checkForLocalChanges();
}
}, [open, worktree]); // eslint-disable-line react-hooks/exhaustive-deps
const checkForLocalChanges = useCallback(async () => {
if (!worktree) return;
setPhase('checking');
try {
const api = getElectronAPI();
if (!api?.worktree?.pull) {
setErrorMessage('Pull API not available');
setPhase('error');
return;
}
// Call pull without stashIfNeeded to just check status
const result = await api.worktree.pull(worktree.path, remote);
if (!result.success) {
setErrorMessage(result.error || 'Failed to pull');
setPhase('error');
return;
}
if (result.result?.hasLocalChanges) {
// Local changes detected - ask user what to do
setPullResult(result.result);
setPhase('local-changes');
} else if (result.result?.pulled !== undefined) {
// No local changes, pull went through (or already up to date)
setPullResult(result.result);
setPhase('success');
onPulled?.();
}
} catch (err) {
setErrorMessage(err instanceof Error ? err.message : 'Failed to check for changes');
setPhase('error');
}
}, [worktree, remote, onPulled]);
const handlePullWithStash = useCallback(async () => {
if (!worktree) return;
setPhase('pulling');
try {
const api = getElectronAPI();
if (!api?.worktree?.pull) {
setErrorMessage('Pull API not available');
setPhase('error');
return;
}
// Call pull with stashIfNeeded
const result = await api.worktree.pull(worktree.path, remote, true);
if (!result.success) {
setErrorMessage(result.error || 'Failed to pull');
setPhase('error');
return;
}
setPullResult(result.result || null);
if (result.result?.hasConflicts) {
setPhase('conflict');
} else {
setPhase('success');
onPulled?.();
}
} catch (err) {
setErrorMessage(err instanceof Error ? err.message : 'Failed to pull');
setPhase('error');
}
}, [worktree, remote, onPulled]);
const handleResolveWithAI = useCallback(() => {
if (!worktree || !pullResult || !onCreateConflictResolutionFeature) return;
const conflictInfo: MergeConflictInfo = {
sourceBranch: `${remote || 'origin'}/${pullResult.branch}`,
targetBranch: pullResult.branch,
targetWorktreePath: worktree.path,
conflictFiles: pullResult.conflictFiles || [],
operationType: 'merge',
};
onCreateConflictResolutionFeature(conflictInfo);
onOpenChange(false);
}, [worktree, pullResult, remote, onCreateConflictResolutionFeature, onOpenChange]);
const handleClose = useCallback(() => {
onOpenChange(false);
}, [onOpenChange]);
if (!worktree) return null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[520px]">
{/* Checking Phase */}
{phase === 'checking' && (
<>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Download className="w-5 h-5" />
Pull Changes
</DialogTitle>
<DialogDescription>
Checking for local changes on{' '}
<code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code>...
</DialogDescription>
</DialogHeader>
<div className="flex items-center justify-center py-8">
<Spinner size="md" />
<span className="ml-3 text-sm text-muted-foreground">
Fetching remote and checking status...
</span>
</div>
</>
)}
{/* Local Changes Detected Phase */}
{phase === 'local-changes' && (
<>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-amber-500" />
Local Changes Detected
</DialogTitle>
<DialogDescription asChild>
<div className="space-y-3">
<span className="block">
You have uncommitted changes on{' '}
<code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code> that
need to be handled before pulling.
</span>
{pullResult?.localChangedFiles && pullResult.localChangedFiles.length > 0 && (
<div className="border border-border rounded-lg overflow-hidden max-h-[200px] overflow-y-auto scrollbar-visible">
{pullResult.localChangedFiles.map((file) => (
<div
key={file}
className="flex items-center gap-2 px-3 py-1.5 text-xs font-mono border-b border-border last:border-b-0 hover:bg-accent/30"
>
<FileWarning className="w-3.5 h-3.5 text-amber-500 flex-shrink-0" />
<span className="truncate">{file}</span>
</div>
))}
</div>
)}
</div>
</DialogDescription>
</DialogHeader>
<div className="flex items-start gap-2 p-3 rounded-md bg-blue-500/10 border border-blue-500/20">
<Archive className="w-4 h-4 text-blue-500 mt-0.5 flex-shrink-0" />
<span className="text-blue-500 text-sm">
Your changes will be automatically stashed before pulling and restored afterward. If
restoring causes conflicts, you&apos;ll be able to resolve them.
</span>
</div>
<DialogFooter>
<Button variant="ghost" onClick={handleClose}>
Cancel
</Button>
<Button onClick={handlePullWithStash}>
<Archive className="w-4 h-4 mr-2" />
Stash & Pull
</Button>
</DialogFooter>
</>
)}
{/* Pulling Phase */}
{phase === 'pulling' && (
<>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Download className="w-5 h-5 animate-pulse" />
Pulling Changes
</DialogTitle>
<DialogDescription>
{pullResult?.hasLocalChanges
? 'Stashing changes, pulling from remote, and restoring your changes...'
: 'Pulling latest changes from remote...'}
</DialogDescription>
</DialogHeader>
<div className="flex items-center justify-center py-8">
<Spinner size="md" />
<span className="ml-3 text-sm text-muted-foreground">This may take a moment...</span>
</div>
</>
)}
{/* Success Phase */}
{phase === 'success' && (
<>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<CheckCircle2 className="w-5 h-5 text-green-500" />
Pull Complete
</DialogTitle>
<DialogDescription asChild>
<div className="space-y-2">
<span className="block">
{pullResult?.message || 'Changes pulled successfully'}
</span>
{pullResult?.stashed && pullResult?.stashRestored && (
<div className="flex items-start gap-2 p-3 rounded-md bg-green-500/10 border border-green-500/20">
<Archive className="w-4 h-4 text-green-500 mt-0.5 flex-shrink-0" />
<span className="text-green-600 dark:text-green-400 text-sm">
Your stashed changes have been restored successfully.
</span>
</div>
)}
{pullResult?.stashed && !pullResult?.stashRestored && (
<div className="flex items-start gap-2 p-3 rounded-md bg-amber-500/10 border border-amber-500/20">
<AlertTriangle className="w-4 h-4 text-amber-500 mt-0.5 flex-shrink-0" />
<span className="text-amber-600 dark:text-amber-400 text-sm">
{pullResult.message}
</span>
</div>
)}
</div>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button onClick={handleClose}>Done</Button>
</DialogFooter>
</>
)}
{/* Conflict Phase */}
{phase === 'conflict' && (
<>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-orange-500" />
Merge Conflicts Detected
</DialogTitle>
<DialogDescription asChild>
<div className="space-y-3">
<span className="block">
{pullResult?.conflictSource === 'stash'
? 'Pull succeeded but reapplying your stashed changes resulted in merge conflicts.'
: 'The pull resulted in merge conflicts that need to be resolved.'}
</span>
{pullResult?.conflictFiles && pullResult.conflictFiles.length > 0 && (
<div className="space-y-1.5">
<span className="text-sm font-medium text-foreground">
Conflicting files ({pullResult.conflictFiles.length}):
</span>
<div className="border border-border rounded-lg overflow-hidden max-h-[200px] overflow-y-auto scrollbar-visible">
{pullResult.conflictFiles.map((file) => (
<div
key={file}
className="flex items-center gap-2 px-3 py-1.5 text-xs font-mono border-b border-border last:border-b-0 hover:bg-accent/30"
>
<XCircle className="w-3.5 h-3.5 text-red-500 flex-shrink-0" />
<span className="truncate">{file}</span>
</div>
))}
</div>
</div>
)}
<div className="mt-2 p-3 rounded-md bg-muted/50 border border-border">
<p className="text-sm text-muted-foreground font-medium mb-2">
Choose how to resolve:
</p>
<ul className="text-sm text-muted-foreground list-disc list-inside space-y-1">
<li>
<strong>Resolve with AI</strong> &mdash; Creates a task to analyze and
resolve conflicts automatically
</li>
<li>
<strong>Resolve Manually</strong> &mdash; Leaves conflict markers in place
for you to edit directly
</li>
</ul>
</div>
</div>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
toast.info('Conflict markers left in place', {
description: 'Edit the conflicting files to resolve conflicts manually.',
duration: 6000,
});
onPulled?.();
handleClose();
}}
>
<Wrench className="w-4 h-4 mr-2" />
Resolve Manually
</Button>
{onCreateConflictResolutionFeature && (
<Button
onClick={handleResolveWithAI}
className="bg-purple-600 hover:bg-purple-700 text-white"
>
<Sparkles className="w-4 h-4 mr-2" />
Resolve with AI
</Button>
)}
</DialogFooter>
</>
)}
{/* Error Phase */}
{phase === 'error' && (
<>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<XCircle className="w-5 h-5 text-destructive" />
Pull Failed
</DialogTitle>
<DialogDescription asChild>
<div className="space-y-2">
<span className="block">
Failed to pull changes for{' '}
<code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code>.
</span>
{errorMessage && (
<div
className={cn(
'flex items-start gap-2 p-3 rounded-md',
'bg-destructive/10 border border-destructive/20'
)}
>
<AlertTriangle className="w-4 h-4 text-destructive mt-0.5 flex-shrink-0" />
<span className="text-destructive text-sm break-words">{errorMessage}</span>
</div>
)}
</div>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="ghost" onClick={handleClose}>
Close
</Button>
<Button
variant="outline"
onClick={() => {
setErrorMessage(null);
checkForLocalChanges();
}}
>
Retry
</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
);
}

View File

@@ -21,4 +21,6 @@ export { ExportFeaturesDialog } from './export-features-dialog';
export { ImportFeaturesDialog } from './import-features-dialog';
export { StashChangesDialog } from './stash-changes-dialog';
export { ViewStashesDialog } from './view-stashes-dialog';
export { StashApplyConflictDialog } from './stash-apply-conflict-dialog';
export { CherryPickDialog } from './cherry-pick-dialog';
export { GitPullDialog } from './git-pull-dialog';

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useCallback } from 'react';
import { createLogger } from '@automaker/utils/logger';
import {
Dialog,
@@ -21,12 +21,28 @@ import {
} from '@/components/ui/select';
import { getHttpApiClient } from '@/lib/http-api-client';
import { toast } from 'sonner';
import { GitMerge, RefreshCw, AlertTriangle, GitBranch } from 'lucide-react';
import {
GitMerge,
RefreshCw,
AlertTriangle,
GitBranch,
Wrench,
Sparkles,
XCircle,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import type { WorktreeInfo } from '../worktree-panel/types';
import type { WorktreeInfo, MergeConflictInfo } from '../worktree-panel/types';
export type PullStrategy = 'merge' | 'rebase';
type DialogStep = 'select' | 'executing' | 'conflict' | 'success';
interface ConflictState {
conflictFiles: string[];
remoteBranch: string;
strategy: PullStrategy;
}
interface RemoteBranch {
name: string;
fullRef: string;
@@ -49,6 +65,7 @@ interface MergeRebaseDialogProps {
remoteBranch: string,
strategy: PullStrategy
) => void | Promise<void>;
onCreateConflictResolutionFeature?: (conflictInfo: MergeConflictInfo) => void;
}
export function MergeRebaseDialog({
@@ -56,6 +73,7 @@ export function MergeRebaseDialog({
onOpenChange,
worktree,
onConfirm,
onCreateConflictResolutionFeature,
}: MergeRebaseDialogProps) {
const [remotes, setRemotes] = useState<RemoteInfo[]>([]);
const [selectedRemote, setSelectedRemote] = useState<string>('');
@@ -64,13 +82,15 @@ export function MergeRebaseDialog({
const [isLoading, setIsLoading] = useState(false);
const [isRefreshing, setIsRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [step, setStep] = useState<DialogStep>('select');
const [conflictState, setConflictState] = useState<ConflictState | null>(null);
// Fetch remotes when dialog opens
useEffect(() => {
if (open && worktree) {
fetchRemotes();
}
}, [open, worktree]);
}, [open, worktree]); // eslint-disable-line react-hooks/exhaustive-deps
// Reset state when dialog closes
useEffect(() => {
@@ -79,6 +99,8 @@ export function MergeRebaseDialog({
setSelectedBranch('');
setSelectedStrategy('merge');
setError(null);
setStep('select');
setConflictState(null);
}
}, [open]);
@@ -167,20 +189,300 @@ export function MergeRebaseDialog({
}
};
const handleConfirm = async () => {
const handleExecuteOperation = useCallback(async () => {
if (!worktree || !selectedBranch) return;
setStep('executing');
try {
await onConfirm(worktree, selectedBranch, selectedStrategy);
onOpenChange(false);
const api = getHttpApiClient();
if (selectedStrategy === 'rebase') {
// First fetch the remote to ensure we have latest refs
try {
await api.worktree.pull(worktree.path, selectedRemote);
} catch {
// Fetch may fail if no upstream - that's okay, we'll try rebase anyway
}
// Attempt the rebase operation
const result = await api.worktree.rebase(worktree.path, selectedBranch);
if (result.success) {
toast.success(`Rebased onto ${selectedBranch}`, {
description: result.result?.message || 'Rebase completed successfully',
});
setStep('success');
onOpenChange(false);
} else if (result.hasConflicts) {
// Rebase had conflicts - show conflict resolution UI
setConflictState({
conflictFiles: result.conflictFiles || [],
remoteBranch: selectedBranch,
strategy: 'rebase',
});
setStep('conflict');
toast.error('Rebase conflicts detected', {
description: 'Choose how to resolve the conflicts below.',
});
} else {
toast.error('Rebase failed', {
description: result.error || 'Unknown error',
});
setStep('select');
}
} else {
// Merge strategy - attempt to merge the remote branch
// Use the pull endpoint for merging remote branches
const result = await api.worktree.pull(worktree.path, selectedRemote, true);
if (result.success && result.result) {
if (result.result.hasConflicts) {
// Pull had conflicts
setConflictState({
conflictFiles: result.result.conflictFiles || [],
remoteBranch: selectedBranch,
strategy: 'merge',
});
setStep('conflict');
toast.error('Merge conflicts detected', {
description: 'Choose how to resolve the conflicts below.',
});
} else {
toast.success(`Merged ${selectedBranch}`, {
description: result.result.message || 'Merge completed successfully',
});
setStep('success');
onOpenChange(false);
}
} else {
// Check for conflict indicators in error
const errorMessage = result.error || '';
const hasConflicts =
errorMessage.toLowerCase().includes('conflict') || errorMessage.includes('CONFLICT');
if (hasConflicts) {
setConflictState({
conflictFiles: [],
remoteBranch: selectedBranch,
strategy: 'merge',
});
setStep('conflict');
toast.error('Merge conflicts detected', {
description: 'Choose how to resolve the conflicts below.',
});
} else {
// Non-conflict failure - fall back to creating a feature task
toast.info('Direct operation failed, creating AI task instead', {
description: result.error || 'The operation will be handled by an AI agent.',
});
try {
await onConfirm(worktree, selectedBranch, selectedStrategy);
onOpenChange(false);
} catch (err) {
logger.error('Failed to create feature task:', err);
setStep('select');
}
}
}
}
} catch (err) {
logger.error('Failed to confirm merge/rebase:', err);
throw err;
logger.error('Failed to execute operation:', err);
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
const hasConflicts =
errorMessage.toLowerCase().includes('conflict') || errorMessage.includes('CONFLICT');
if (hasConflicts) {
setConflictState({
conflictFiles: [],
remoteBranch: selectedBranch,
strategy: selectedStrategy,
});
setStep('conflict');
} else {
// Fall back to creating a feature task
toast.info('Creating AI task to handle the operation', {
description: 'The operation will be performed by an AI agent.',
});
try {
await onConfirm(worktree, selectedBranch, selectedStrategy);
onOpenChange(false);
} catch (confirmErr) {
logger.error('Failed to create feature task:', confirmErr);
toast.error('Operation failed', { description: errorMessage });
setStep('select');
}
}
}
};
}, [worktree, selectedBranch, selectedStrategy, selectedRemote, onConfirm, onOpenChange]);
const handleResolveWithAI = useCallback(() => {
if (!worktree || !conflictState) return;
if (onCreateConflictResolutionFeature) {
const conflictInfo: MergeConflictInfo = {
sourceBranch: conflictState.remoteBranch,
targetBranch: worktree.branch,
targetWorktreePath: worktree.path,
conflictFiles: conflictState.conflictFiles,
operationType: conflictState.strategy,
};
onCreateConflictResolutionFeature(conflictInfo);
onOpenChange(false);
} else {
// Fallback: create via the onConfirm handler
onConfirm(worktree, conflictState.remoteBranch, conflictState.strategy);
onOpenChange(false);
}
}, [worktree, conflictState, onCreateConflictResolutionFeature, onConfirm, onOpenChange]);
const handleResolveManually = useCallback(() => {
toast.info('Conflict markers left in place', {
description: 'Edit the conflicting files to resolve conflicts manually.',
duration: 6000,
});
onOpenChange(false);
}, [onOpenChange]);
const selectedRemoteData = remotes.find((r) => r.name === selectedRemote);
const branches = selectedRemoteData?.branches || [];
if (!worktree) return null;
// Conflict resolution UI
if (step === 'conflict' && conflictState) {
const isRebase = conflictState.strategy === 'rebase';
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[520px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-orange-500" />
{isRebase ? 'Rebase' : 'Merge'} Conflicts Detected
</DialogTitle>
<DialogDescription asChild>
<div className="space-y-4">
<span className="block">
{isRebase ? (
<>
Conflicts detected when rebasing{' '}
<code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code>{' '}
onto{' '}
<code className="font-mono bg-muted px-1 rounded">
{conflictState.remoteBranch}
</code>
. The rebase was aborted and no changes were applied.
</>
) : (
<>
Conflicts detected when merging{' '}
<code className="font-mono bg-muted px-1 rounded">
{conflictState.remoteBranch}
</code>{' '}
into{' '}
<code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code>.
</>
)}
</span>
{conflictState.conflictFiles.length > 0 && (
<div className="space-y-1.5">
<span className="text-sm font-medium text-foreground">
Conflicting files ({conflictState.conflictFiles.length}):
</span>
<div className="border border-border rounded-lg overflow-hidden max-h-[200px] overflow-y-auto scrollbar-visible">
{conflictState.conflictFiles.map((file) => (
<div
key={file}
className="flex items-center gap-2 px-3 py-1.5 text-xs font-mono border-b border-border last:border-b-0 hover:bg-accent/30"
>
<XCircle className="w-3.5 h-3.5 text-red-500 flex-shrink-0" />
<span className="truncate">{file}</span>
</div>
))}
</div>
</div>
)}
<div className="mt-2 p-3 rounded-md bg-muted/50 border border-border">
<p className="text-sm text-muted-foreground font-medium mb-2">
Choose how to resolve:
</p>
<ul className="text-sm text-muted-foreground list-disc list-inside space-y-1">
<li>
<strong>Resolve with AI</strong> &mdash; Creates a task to{' '}
{isRebase ? 'rebase and ' : ''}resolve conflicts automatically
</li>
<li>
<strong>Resolve Manually</strong> &mdash;{' '}
{isRebase
? 'Leaves the branch unchanged for you to rebase manually'
: 'Leaves conflict markers in place for you to edit directly'}
</li>
</ul>
</div>
</div>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="ghost"
onClick={() => {
setStep('select');
setConflictState(null);
}}
>
Back
</Button>
<Button variant="outline" onClick={handleResolveManually}>
<Wrench className="w-4 h-4 mr-2" />
Resolve Manually
</Button>
<Button
onClick={handleResolveWithAI}
className="bg-purple-600 hover:bg-purple-700 text-white"
>
<Sparkles className="w-4 h-4 mr-2" />
Resolve with AI
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// Executing phase
if (step === 'executing') {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{selectedStrategy === 'rebase' ? (
<GitBranch className="w-5 h-5 text-blue-500 animate-pulse" />
) : (
<GitMerge className="w-5 h-5 text-purple-500 animate-pulse" />
)}
{selectedStrategy === 'rebase' ? 'Rebasing...' : 'Merging...'}
</DialogTitle>
<DialogDescription>
{selectedStrategy === 'rebase'
? `Rebasing ${worktree.branch} onto ${selectedBranch}...`
: `Merging ${selectedBranch} into ${worktree.branch}...`}
</DialogDescription>
</DialogHeader>
<div className="flex items-center justify-center py-8">
<Spinner size="md" />
<span className="ml-3 text-sm text-muted-foreground">This may take a moment...</span>
</div>
</DialogContent>
</Dialog>
);
}
// Selection UI
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]">
@@ -323,7 +625,7 @@ export function MergeRebaseDialog({
{selectedBranch && (
<div className="mt-2 p-3 rounded-md bg-muted/50 border border-border">
<p className="text-sm text-muted-foreground">
This will create a feature task to{' '}
This will attempt to{' '}
{selectedStrategy === 'rebase' ? (
<>
rebase <span className="font-mono text-foreground">{worktree?.branch}</span>{' '}
@@ -334,8 +636,8 @@ export function MergeRebaseDialog({
merge <span className="font-mono text-foreground">{selectedBranch}</span> into{' '}
<span className="font-mono text-foreground">{worktree?.branch}</span>
</>
)}{' '}
and resolve any conflicts.
)}
. If conflicts arise, you can choose to resolve them manually or with AI.
</p>
</div>
)}
@@ -347,16 +649,21 @@ export function MergeRebaseDialog({
Cancel
</Button>
<Button
onClick={handleConfirm}
onClick={handleExecuteOperation}
disabled={!selectedBranch || isLoading}
className="bg-purple-600 hover:bg-purple-700 text-white"
>
<GitMerge className="w-4 h-4 mr-2" />
{selectedStrategy === 'merge'
? 'Merge'
: selectedStrategy === 'rebase'
? 'Rebase'
: 'Merge & Rebase'}
{selectedStrategy === 'rebase' ? (
<>
<GitBranch className="w-4 h-4 mr-2" />
Rebase
</>
) : (
<>
<GitMerge className="w-4 h-4 mr-2" />
Merge
</>
)}
</Button>
</DialogFooter>
</DialogContent>

View File

@@ -10,7 +10,7 @@ import {
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import { GitMerge, AlertTriangle, Trash2, Wrench } from 'lucide-react';
import { GitMerge, AlertTriangle, Trash2, Wrench, Sparkles, XCircle } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
@@ -116,17 +116,20 @@ export function MergeWorktreeDialog({
const hasConflicts =
errorMessage.toLowerCase().includes('conflict') ||
errorMessage.toLowerCase().includes('merge failed') ||
errorMessage.includes('CONFLICT');
errorMessage.includes('CONFLICT') ||
result.hasConflicts;
if (hasConflicts && onCreateConflictResolutionFeature) {
if (hasConflicts) {
// Set merge conflict state to show the conflict resolution UI
setMergeConflict({
sourceBranch: worktree.branch,
targetBranch: targetBranch,
targetWorktreePath: projectPath, // The merge happens in the target branch's worktree
conflictFiles: result.conflictFiles || [],
operationType: 'merge',
});
toast.error('Merge conflicts detected', {
description: 'The merge has conflicts that need to be resolved manually.',
description: 'Choose how to resolve the conflicts below.',
});
} else {
toast.error('Failed to merge branch', {
@@ -142,14 +145,16 @@ export function MergeWorktreeDialog({
errorMessage.toLowerCase().includes('merge failed') ||
errorMessage.includes('CONFLICT');
if (hasConflicts && onCreateConflictResolutionFeature) {
if (hasConflicts) {
setMergeConflict({
sourceBranch: worktree.branch,
targetBranch: targetBranch,
targetWorktreePath: projectPath,
conflictFiles: [],
operationType: 'merge',
});
toast.error('Merge conflicts detected', {
description: 'The merge has conflicts that need to be resolved manually.',
description: 'Choose how to resolve the conflicts below.',
});
} else {
toast.error('Failed to merge branch', {
@@ -161,20 +166,28 @@ export function MergeWorktreeDialog({
}
};
const handleCreateConflictResolutionFeature = () => {
const handleResolveWithAI = () => {
if (mergeConflict && onCreateConflictResolutionFeature) {
onCreateConflictResolutionFeature(mergeConflict);
onOpenChange(false);
}
};
const handleResolveManually = () => {
toast.info('Conflict markers left in place', {
description: 'Edit the conflicting files to resolve conflicts manually.',
duration: 6000,
});
onOpenChange(false);
};
if (!worktree) return null;
// Show conflict resolution UI if there are merge conflicts
if (mergeConflict) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogContent className="sm:max-w-[520px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-orange-500" />
@@ -194,32 +207,38 @@ export function MergeWorktreeDialog({
.
</span>
<div className="flex items-start gap-2 p-3 rounded-md bg-orange-500/10 border border-orange-500/20">
<AlertTriangle className="w-4 h-4 text-orange-500 mt-0.5 flex-shrink-0" />
<span className="text-orange-500 text-sm">
The merge could not be completed automatically. You can create a feature task to
resolve the conflicts in the{' '}
<code className="font-mono bg-muted px-0.5 rounded">
{mergeConflict.targetBranch}
</code>{' '}
branch.
</span>
</div>
{mergeConflict.conflictFiles && mergeConflict.conflictFiles.length > 0 && (
<div className="space-y-1.5">
<span className="text-sm font-medium text-foreground">
Conflicting files ({mergeConflict.conflictFiles.length}):
</span>
<div className="border border-border rounded-lg overflow-hidden max-h-[200px] overflow-y-auto scrollbar-visible">
{mergeConflict.conflictFiles.map((file) => (
<div
key={file}
className="flex items-center gap-2 px-3 py-1.5 text-xs font-mono border-b border-border last:border-b-0 hover:bg-accent/30"
>
<XCircle className="w-3.5 h-3.5 text-red-500 flex-shrink-0" />
<span className="truncate">{file}</span>
</div>
))}
</div>
</div>
)}
<div className="mt-2 p-3 rounded-md bg-muted/50 border border-border">
<p className="text-sm text-muted-foreground">
This will create a high-priority feature task that will:
<p className="text-sm text-muted-foreground font-medium mb-2">
Choose how to resolve:
</p>
<ul className="text-sm text-muted-foreground mt-2 list-disc list-inside space-y-1">
<ul className="text-sm text-muted-foreground list-disc list-inside space-y-1">
<li>
Resolve merge conflicts in the{' '}
<code className="font-mono bg-muted px-0.5 rounded">
{mergeConflict.targetBranch}
</code>{' '}
branch
<strong>Resolve with AI</strong> &mdash; Creates a task to analyze and resolve
conflicts automatically
</li>
<li>
<strong>Resolve Manually</strong> &mdash; Leaves conflict markers in place for
you to edit directly
</li>
<li>Ensure the code compiles and tests pass</li>
<li>Complete the merge automatically</li>
</ul>
</div>
</div>
@@ -230,16 +249,19 @@ export function MergeWorktreeDialog({
<Button variant="ghost" onClick={() => setMergeConflict(null)}>
Back
</Button>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
onClick={handleCreateConflictResolutionFeature}
className="bg-purple-600 hover:bg-purple-700 text-white"
>
<Button variant="outline" onClick={handleResolveManually}>
<Wrench className="w-4 h-4 mr-2" />
Create Resolve Conflicts Feature
Resolve Manually
</Button>
{onCreateConflictResolutionFeature && (
<Button
onClick={handleResolveWithAI}
className="bg-purple-600 hover:bg-purple-700 text-white"
>
<Sparkles className="w-4 h-4 mr-2" />
Resolve with AI
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>

View File

@@ -0,0 +1,128 @@
/**
* Dialog shown when a stash apply/pop operation results in merge conflicts.
* Presents the user with two options:
* 1. Resolve Manually - leaves conflict markers in place
* 2. Resolve with AI - creates a feature task for AI-powered conflict resolution
*/
import { useCallback } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { AlertTriangle, XCircle, Wrench, Sparkles } from 'lucide-react';
import { toast } from 'sonner';
import type { StashApplyConflictInfo } from '../worktree-panel/types';
interface StashApplyConflictDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
conflictInfo: StashApplyConflictInfo | null;
onResolveWithAI?: (conflictInfo: StashApplyConflictInfo) => void;
}
export function StashApplyConflictDialog({
open,
onOpenChange,
conflictInfo,
onResolveWithAI,
}: StashApplyConflictDialogProps) {
const handleResolveManually = useCallback(() => {
toast.info('Conflict markers left in place', {
description: 'Edit the conflicting files to resolve conflicts manually.',
duration: 6000,
});
onOpenChange(false);
}, [onOpenChange]);
const handleResolveWithAI = useCallback(() => {
if (!conflictInfo || !onResolveWithAI) return;
onResolveWithAI(conflictInfo);
onOpenChange(false);
}, [conflictInfo, onResolveWithAI, onOpenChange]);
if (!conflictInfo) return null;
const operationLabel = conflictInfo.operation === 'pop' ? 'popped' : 'applied';
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[520px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-orange-500" />
Merge Conflicts Detected
</DialogTitle>
<DialogDescription asChild>
<div className="space-y-3">
<span className="block">
Stash{' '}
<code className="font-mono bg-muted px-1 rounded">{conflictInfo.stashRef}</code> was{' '}
{operationLabel} on branch{' '}
<code className="font-mono bg-muted px-1 rounded">{conflictInfo.branchName}</code>{' '}
but resulted in merge conflicts.
</span>
{conflictInfo.conflictFiles.length > 0 && (
<div className="space-y-1.5">
<span className="text-sm font-medium text-foreground">
Conflicting files ({conflictInfo.conflictFiles.length}):
</span>
<div className="border border-border rounded-lg overflow-hidden max-h-[200px] overflow-y-auto scrollbar-visible">
{conflictInfo.conflictFiles.map((file) => (
<div
key={file}
className="flex items-center gap-2 px-3 py-1.5 text-xs font-mono border-b border-border last:border-b-0 hover:bg-accent/30"
>
<XCircle className="w-3.5 h-3.5 text-red-500 flex-shrink-0" />
<span className="truncate">{file}</span>
</div>
))}
</div>
</div>
)}
<div className="mt-2 p-3 rounded-md bg-muted/50 border border-border">
<p className="text-sm text-muted-foreground font-medium mb-2">
Choose how to resolve:
</p>
<ul className="text-sm text-muted-foreground list-disc list-inside space-y-1">
<li>
<strong>Resolve with AI</strong> &mdash; Creates a task to analyze and resolve
conflicts automatically
</li>
<li>
<strong>Resolve Manually</strong> &mdash; Leaves conflict markers in place for
you to edit directly
</li>
</ul>
</div>
</div>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={handleResolveManually}>
<Wrench className="w-4 h-4 mr-2" />
Resolve Manually
</Button>
{onResolveWithAI && (
<Button
onClick={handleResolveWithAI}
className="bg-purple-600 hover:bg-purple-700 text-white"
>
<Sparkles className="w-4 h-4 mr-2" />
Resolve with AI
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -21,6 +21,8 @@ import { Spinner } from '@/components/ui/spinner';
import { getHttpApiClient } from '@/lib/http-api-client';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import { StashApplyConflictDialog } from './stash-apply-conflict-dialog';
import type { StashApplyConflictInfo } from '../worktree-panel/types';
interface WorktreeInfo {
path: string;
@@ -43,6 +45,7 @@ interface ViewStashesDialogProps {
onOpenChange: (open: boolean) => void;
worktree: WorktreeInfo | null;
onStashApplied?: () => void;
onStashApplyConflict?: (conflictInfo: StashApplyConflictInfo) => void;
}
function formatRelativeDate(dateStr: string): string {
@@ -213,12 +216,15 @@ export function ViewStashesDialog({
onOpenChange,
worktree,
onStashApplied,
onStashApplyConflict,
}: ViewStashesDialogProps) {
const [stashes, setStashes] = useState<StashEntry[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [applyingIndex, setApplyingIndex] = useState<number | null>(null);
const [droppingIndex, setDroppingIndex] = useState<number | null>(null);
const [conflictDialogOpen, setConflictDialogOpen] = useState(false);
const [conflictInfo, setConflictInfo] = useState<StashApplyConflictInfo | null>(null);
const fetchStashes = useCallback(async () => {
if (!worktree) return;
@@ -262,13 +268,20 @@ export function ViewStashesDialog({
if (result.success && result.result) {
if (result.result.hasConflicts) {
toast.warning('Stash applied with conflicts', {
description: 'Please resolve the merge conflicts.',
});
const info: StashApplyConflictInfo = {
worktreePath: worktree.path,
branchName: worktree.branch,
stashRef: `stash@{${stashIndex}}`,
operation: 'apply',
conflictFiles: result.result.conflictFiles || [],
};
setConflictInfo(info);
setConflictDialogOpen(true);
onStashApplied?.();
} else {
toast.success('Stash applied');
onStashApplied?.();
}
onStashApplied?.();
} else {
toast.error('Failed to apply stash', {
description: result.error || 'Unknown error',
@@ -293,9 +306,15 @@ export function ViewStashesDialog({
if (result.success && result.result) {
if (result.result.hasConflicts) {
toast.warning('Stash popped with conflicts', {
description: 'Please resolve the merge conflicts. The stash was removed.',
});
const info: StashApplyConflictInfo = {
worktreePath: worktree.path,
branchName: worktree.branch,
stashRef: `stash@{${stashIndex}}`,
operation: 'pop',
conflictFiles: result.result.conflictFiles || [],
};
setConflictInfo(info);
setConflictDialogOpen(true);
} else {
toast.success('Stash popped', {
description: 'Changes applied and stash removed.',
@@ -403,6 +422,14 @@ export function ViewStashesDialog({
</div>
</div>
</DialogContent>
{/* Stash Apply Conflict Resolution Dialog */}
<StashApplyConflictDialog
open={conflictDialogOpen}
onOpenChange={setConflictDialogOpen}
conflictInfo={conflictInfo}
onResolveWithAI={onStashApplyConflict}
/>
</Dialog>
);
}