mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-24 00:13:07 +00:00
635 lines
22 KiB
TypeScript
635 lines
22 KiB
TypeScript
import { useState, useEffect, useCallback } from 'react';
|
|
import { createLogger } from '@automaker/utils/logger';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Label } from '@/components/ui/label';
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectGroup,
|
|
SelectItem,
|
|
SelectLabel,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select';
|
|
import { getHttpApiClient } from '@/lib/http-api-client';
|
|
import { toast } from 'sonner';
|
|
import {
|
|
GitMerge,
|
|
RefreshCw,
|
|
AlertTriangle,
|
|
GitBranch,
|
|
Wrench,
|
|
Sparkles,
|
|
XCircle,
|
|
} from 'lucide-react';
|
|
import { Spinner } from '@/components/ui/spinner';
|
|
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;
|
|
}
|
|
|
|
interface RemoteInfo {
|
|
name: string;
|
|
url: string;
|
|
branches: RemoteBranch[];
|
|
}
|
|
|
|
const logger = createLogger('MergeRebaseDialog');
|
|
|
|
interface MergeRebaseDialogProps {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
worktree: WorktreeInfo | null;
|
|
onCreateConflictResolutionFeature?: (conflictInfo: MergeConflictInfo) => void;
|
|
}
|
|
|
|
export function MergeRebaseDialog({
|
|
open,
|
|
onOpenChange,
|
|
worktree,
|
|
onCreateConflictResolutionFeature,
|
|
}: MergeRebaseDialogProps) {
|
|
const [remotes, setRemotes] = useState<RemoteInfo[]>([]);
|
|
const [selectedRemote, setSelectedRemote] = useState<string>('');
|
|
const [selectedBranch, setSelectedBranch] = useState<string>('');
|
|
const [selectedStrategy, setSelectedStrategy] = useState<PullStrategy>('merge');
|
|
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]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
// Reset state when dialog closes
|
|
useEffect(() => {
|
|
if (!open) {
|
|
setSelectedRemote('');
|
|
setSelectedBranch('');
|
|
setSelectedStrategy('merge');
|
|
setError(null);
|
|
setStep('select');
|
|
setConflictState(null);
|
|
}
|
|
}, [open]);
|
|
|
|
// Auto-select default remote and branch when remotes are loaded
|
|
useEffect(() => {
|
|
if (remotes.length > 0 && !selectedRemote) {
|
|
// Default to 'origin' if available, otherwise first remote
|
|
const defaultRemote = remotes.find((r) => r.name === 'origin') || remotes[0];
|
|
setSelectedRemote(defaultRemote.name);
|
|
|
|
// Try to select a matching branch name or default to main/master
|
|
if (defaultRemote.branches.length > 0 && worktree) {
|
|
const matchingBranch = defaultRemote.branches.find((b) => b.name === worktree.branch);
|
|
const mainBranch = defaultRemote.branches.find(
|
|
(b) => b.name === 'main' || b.name === 'master'
|
|
);
|
|
const defaultBranch = matchingBranch || mainBranch || defaultRemote.branches[0];
|
|
setSelectedBranch(defaultBranch.fullRef);
|
|
}
|
|
}
|
|
}, [remotes, selectedRemote, worktree]);
|
|
|
|
// Update selected branch when remote changes
|
|
useEffect(() => {
|
|
if (selectedRemote && remotes.length > 0 && worktree) {
|
|
const remote = remotes.find((r) => r.name === selectedRemote);
|
|
if (remote && remote.branches.length > 0) {
|
|
// Try to select a matching branch name or default to main/master
|
|
const matchingBranch = remote.branches.find((b) => b.name === worktree.branch);
|
|
const mainBranch = remote.branches.find((b) => b.name === 'main' || b.name === 'master');
|
|
const defaultBranch = matchingBranch || mainBranch || remote.branches[0];
|
|
setSelectedBranch(defaultBranch.fullRef);
|
|
} else {
|
|
setSelectedBranch('');
|
|
}
|
|
}
|
|
}, [selectedRemote, remotes, worktree]);
|
|
|
|
const fetchRemotes = async () => {
|
|
if (!worktree) return;
|
|
|
|
setIsLoading(true);
|
|
setError(null);
|
|
|
|
try {
|
|
const api = getHttpApiClient();
|
|
const result = await api.worktree.listRemotes(worktree.path);
|
|
|
|
if (result.success && result.result) {
|
|
setRemotes(result.result.remotes);
|
|
if (result.result.remotes.length === 0) {
|
|
setError('No remotes found in this repository');
|
|
}
|
|
} else {
|
|
setError(result.error || 'Failed to fetch remotes');
|
|
}
|
|
} catch (err) {
|
|
logger.error('Failed to fetch remotes:', err);
|
|
setError('Failed to fetch remotes');
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleRefresh = async () => {
|
|
if (!worktree) return;
|
|
|
|
setIsRefreshing(true);
|
|
setError(null);
|
|
|
|
try {
|
|
const api = getHttpApiClient();
|
|
const result = await api.worktree.listRemotes(worktree.path);
|
|
|
|
if (result.success && result.result) {
|
|
setRemotes(result.result.remotes);
|
|
toast.success('Remotes refreshed');
|
|
} else {
|
|
toast.error(result.error || 'Failed to refresh remotes');
|
|
}
|
|
} catch (err) {
|
|
logger.error('Failed to refresh remotes:', err);
|
|
toast.error('Failed to refresh remotes');
|
|
} finally {
|
|
setIsRefreshing(false);
|
|
}
|
|
};
|
|
|
|
const handleExecuteOperation = useCallback(async () => {
|
|
if (!worktree || !selectedBranch) return;
|
|
|
|
setStep('executing');
|
|
|
|
try {
|
|
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');
|
|
} 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');
|
|
} 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');
|
|
} else {
|
|
// Non-conflict failure - show conflict resolution UI so user can choose
|
|
// how to handle it (resolve manually or with AI) rather than auto-creating a task
|
|
setConflictState({
|
|
conflictFiles: [],
|
|
remoteBranch: selectedBranch,
|
|
strategy: 'merge',
|
|
});
|
|
setStep('conflict');
|
|
}
|
|
}
|
|
}
|
|
} catch (err) {
|
|
logger.error('Failed to execute operation:', err);
|
|
|
|
// Show conflict resolution UI so user can choose how to handle it
|
|
setConflictState({
|
|
conflictFiles: [],
|
|
remoteBranch: selectedBranch,
|
|
strategy: selectedStrategy,
|
|
});
|
|
setStep('conflict');
|
|
}
|
|
}, [worktree, selectedBranch, selectedStrategy, selectedRemote, 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);
|
|
}, [worktree, conflictState, onCreateConflictResolutionFeature, 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> — Creates a task to{' '}
|
|
{isRebase ? 'rebase and ' : ''}resolve conflicts automatically
|
|
</li>
|
|
<li>
|
|
<strong>Resolve Manually</strong> —{' '}
|
|
{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]">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2">
|
|
<GitMerge className="w-5 h-5 text-purple-500" />
|
|
Merge & Rebase
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
Select a remote branch to merge or rebase with{' '}
|
|
<span className="font-mono text-foreground">
|
|
{worktree?.branch || 'current branch'}
|
|
</span>
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
{isLoading ? (
|
|
<div className="flex items-center justify-center py-8">
|
|
<Spinner size="lg" />
|
|
</div>
|
|
) : error ? (
|
|
<div className="flex flex-col items-center gap-4 py-6">
|
|
<div className="flex items-center gap-2 text-destructive">
|
|
<AlertTriangle className="w-5 h-5" />
|
|
<span className="text-sm">{error}</span>
|
|
</div>
|
|
<Button variant="outline" size="sm" onClick={fetchRemotes}>
|
|
<RefreshCw className="w-4 h-4 mr-2" />
|
|
Retry
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<div className="grid gap-4 py-4">
|
|
<div className="grid gap-2">
|
|
<div className="flex items-center justify-between">
|
|
<Label htmlFor="remote-select">Remote</Label>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={handleRefresh}
|
|
disabled={isRefreshing}
|
|
className="h-6 px-2 text-xs"
|
|
>
|
|
{isRefreshing ? (
|
|
<Spinner size="xs" className="mr-1" />
|
|
) : (
|
|
<RefreshCw className="w-3 h-3 mr-1" />
|
|
)}
|
|
Refresh
|
|
</Button>
|
|
</div>
|
|
<Select value={selectedRemote} onValueChange={setSelectedRemote}>
|
|
<SelectTrigger id="remote-select">
|
|
<SelectValue placeholder="Select a remote" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{remotes.map((remote) => (
|
|
<SelectItem
|
|
key={remote.name}
|
|
value={remote.name}
|
|
description={
|
|
<span className="text-xs text-muted-foreground truncate max-w-[300px]">
|
|
{remote.url}
|
|
</span>
|
|
}
|
|
>
|
|
<span className="font-medium">{remote.name}</span>
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="branch-select">Branch</Label>
|
|
<Select
|
|
value={selectedBranch}
|
|
onValueChange={setSelectedBranch}
|
|
disabled={!selectedRemote || branches.length === 0}
|
|
>
|
|
<SelectTrigger id="branch-select">
|
|
<SelectValue placeholder="Select a branch" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectGroup>
|
|
<SelectLabel>{selectedRemote} branches</SelectLabel>
|
|
{branches.map((branch) => (
|
|
<SelectItem key={branch.fullRef} value={branch.fullRef}>
|
|
{branch.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectGroup>
|
|
</SelectContent>
|
|
</Select>
|
|
{selectedRemote && branches.length === 0 && (
|
|
<p className="text-sm text-muted-foreground">No branches found for this remote</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="strategy-select">Strategy</Label>
|
|
<Select
|
|
value={selectedStrategy}
|
|
onValueChange={(value) => setSelectedStrategy(value as PullStrategy)}
|
|
>
|
|
<SelectTrigger id="strategy-select">
|
|
<SelectValue placeholder="Select a strategy" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem
|
|
value="merge"
|
|
description={
|
|
<span className="text-xs text-muted-foreground">
|
|
Creates a merge commit preserving history
|
|
</span>
|
|
}
|
|
>
|
|
<span className="flex items-center gap-2">
|
|
<GitMerge className="w-3.5 h-3.5 text-purple-500" />
|
|
<span className="font-medium">Merge</span>
|
|
</span>
|
|
</SelectItem>
|
|
<SelectItem
|
|
value="rebase"
|
|
description={
|
|
<span className="text-xs text-muted-foreground">
|
|
Replays commits on top for linear history
|
|
</span>
|
|
}
|
|
>
|
|
<span className="flex items-center gap-2">
|
|
<GitBranch className="w-3.5 h-3.5 text-blue-500" />
|
|
<span className="font-medium">Rebase</span>
|
|
</span>
|
|
</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{selectedBranch && (
|
|
<div className="mt-2 p-3 rounded-md bg-muted/50 border border-border">
|
|
<p className="text-sm text-muted-foreground">
|
|
This will attempt to{' '}
|
|
{selectedStrategy === 'rebase' ? (
|
|
<>
|
|
rebase <span className="font-mono text-foreground">{worktree?.branch}</span>{' '}
|
|
onto <span className="font-mono text-foreground">{selectedBranch}</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
merge <span className="font-mono text-foreground">{selectedBranch}</span> into{' '}
|
|
<span className="font-mono text-foreground">{worktree?.branch}</span>
|
|
</>
|
|
)}
|
|
. If conflicts arise, you can choose to resolve them manually or with AI.
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
onClick={handleExecuteOperation}
|
|
disabled={!selectedBranch || isLoading}
|
|
className="bg-purple-600 hover:bg-purple-700 text-white"
|
|
>
|
|
{selectedStrategy === 'rebase' ? (
|
|
<>
|
|
<GitBranch className="w-4 h-4 mr-2" />
|
|
Rebase
|
|
</>
|
|
) : (
|
|
<>
|
|
<GitMerge className="w-4 h-4 mr-2" />
|
|
Merge
|
|
</>
|
|
)}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|