mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-19 10:43:08 +00:00
feat: Add git log parsing and rebase endpoint with input validation
This commit is contained in:
@@ -69,6 +69,7 @@ import type {
|
||||
MergeConflictInfo,
|
||||
BranchSwitchConflictInfo,
|
||||
StashPopConflictInfo,
|
||||
StashApplyConflictInfo,
|
||||
} from './board-view/worktree-panel/types';
|
||||
import { COLUMNS, getColumnsWithPipeline } from './board-view/constants';
|
||||
import {
|
||||
@@ -984,14 +985,26 @@ export function BoardView() {
|
||||
[handleAddFeature, handleStartImplementation, defaultSkipTests]
|
||||
);
|
||||
|
||||
// Handler called when merge fails due to conflicts and user wants to create a feature to resolve them
|
||||
// Handler called when merge/rebase fails due to conflicts and user wants to create a feature to resolve them
|
||||
const handleCreateMergeConflictResolutionFeature = useCallback(
|
||||
async (conflictInfo: MergeConflictInfo) => {
|
||||
const description = `Resolve merge conflicts when merging "${conflictInfo.sourceBranch}" into "${conflictInfo.targetBranch}". The merge was started but encountered conflicts that need to be resolved manually. After resolving all conflicts, ensure the code compiles and tests pass, then complete the merge by committing the resolved changes.`;
|
||||
const isRebase = conflictInfo.operationType === 'rebase';
|
||||
const conflictFilesInfo =
|
||||
conflictInfo.conflictFiles && conflictInfo.conflictFiles.length > 0
|
||||
? `\n\nConflicting files:\n${conflictInfo.conflictFiles.map((f) => `- ${f}`).join('\n')}`
|
||||
: '';
|
||||
|
||||
const description = isRebase
|
||||
? `Fetch the latest changes from ${conflictInfo.sourceBranch} and rebase the current branch (${conflictInfo.targetBranch}) onto ${conflictInfo.sourceBranch}. Use "git fetch" followed by "git rebase ${conflictInfo.sourceBranch}" to replay commits on top of the remote branch for a linear history. If rebase conflicts arise, resolve them one commit at a time using "git rebase --continue" after fixing each conflict. After completing the rebase, ensure the code compiles and tests pass.${conflictFilesInfo}`
|
||||
: `Resolve merge conflicts when merging "${conflictInfo.sourceBranch}" into "${conflictInfo.targetBranch}". The merge was started but encountered conflicts that need to be resolved manually. After resolving all conflicts, ensure the code compiles and tests pass, then complete the merge by committing the resolved changes.${conflictFilesInfo}`;
|
||||
|
||||
const title = isRebase
|
||||
? `Rebase & Resolve Conflicts: ${conflictInfo.targetBranch} onto ${conflictInfo.sourceBranch}`
|
||||
: `Resolve Merge Conflicts: ${conflictInfo.sourceBranch} → ${conflictInfo.targetBranch}`;
|
||||
|
||||
// Create the feature
|
||||
const featureData = {
|
||||
title: `Resolve Merge Conflicts: ${conflictInfo.sourceBranch} → ${conflictInfo.targetBranch}`,
|
||||
title,
|
||||
category: 'Maintenance',
|
||||
description,
|
||||
images: [],
|
||||
@@ -1142,6 +1155,70 @@ export function BoardView() {
|
||||
[handleAddFeature, handleStartImplementation, defaultSkipTests]
|
||||
);
|
||||
|
||||
// Handler called when stash apply/pop results in merge conflicts and user wants AI resolution
|
||||
const handleStashApplyConflict = useCallback(
|
||||
async (conflictInfo: StashApplyConflictInfo) => {
|
||||
const operationLabel = conflictInfo.operation === 'pop' ? 'popping' : 'applying';
|
||||
const conflictFilesList =
|
||||
conflictInfo.conflictFiles.length > 0
|
||||
? `\n\nConflicted files:\n${conflictInfo.conflictFiles.map((f) => `- ${f}`).join('\n')}`
|
||||
: '';
|
||||
|
||||
const description =
|
||||
`Resolve merge conflicts that occurred when ${operationLabel} stash "${conflictInfo.stashRef}" ` +
|
||||
`on branch "${conflictInfo.branchName}". ` +
|
||||
`The stash was ${conflictInfo.operation === 'pop' ? 'popped' : 'applied'} but resulted in merge conflicts ` +
|
||||
`that need to be resolved. Please review all conflicted files, resolve the conflicts, ` +
|
||||
`ensure the code compiles and tests pass, then commit the resolved changes.` +
|
||||
conflictFilesList;
|
||||
|
||||
// Create the feature
|
||||
const featureData = {
|
||||
title: `Resolve Stash Apply Conflicts: ${conflictInfo.stashRef} on ${conflictInfo.branchName}`,
|
||||
category: 'Maintenance',
|
||||
description,
|
||||
images: [],
|
||||
imagePaths: [],
|
||||
skipTests: defaultSkipTests,
|
||||
model: 'opus' as const,
|
||||
thinkingLevel: 'none' as const,
|
||||
branchName: conflictInfo.branchName,
|
||||
workMode: 'custom' as const,
|
||||
priority: 1, // High priority for conflict resolution
|
||||
planningMode: 'skip' as const,
|
||||
requirePlanApproval: false,
|
||||
};
|
||||
|
||||
// Capture existing feature IDs before adding
|
||||
const featuresBeforeIds = new Set(useAppStore.getState().features.map((f) => f.id));
|
||||
try {
|
||||
await handleAddFeature(featureData);
|
||||
} catch (error) {
|
||||
logger.error('Failed to create stash apply conflict resolution feature:', error);
|
||||
toast.error('Failed to create feature', {
|
||||
description: error instanceof Error ? error.message : 'An error occurred',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the newly created feature by looking for an ID that wasn't in the original set
|
||||
const latestFeatures = useAppStore.getState().features;
|
||||
const newFeature = latestFeatures.find((f) => !featuresBeforeIds.has(f.id));
|
||||
|
||||
if (newFeature) {
|
||||
await handleStartImplementation(newFeature);
|
||||
} else {
|
||||
logger.error(
|
||||
'Could not find newly created stash apply conflict feature to start it automatically.'
|
||||
);
|
||||
toast.error('Failed to auto-start feature', {
|
||||
description: 'The feature was created but could not be started automatically.',
|
||||
});
|
||||
}
|
||||
},
|
||||
[handleAddFeature, handleStartImplementation, defaultSkipTests]
|
||||
);
|
||||
|
||||
// Handler for "Make" button - creates a feature and immediately starts it
|
||||
const handleAddAndStartFeature = useCallback(
|
||||
async (featureData: Parameters<typeof handleAddFeature>[0]) => {
|
||||
@@ -1583,6 +1660,7 @@ export function BoardView() {
|
||||
onCreateMergeConflictResolutionFeature={handleCreateMergeConflictResolutionFeature}
|
||||
onBranchSwitchConflict={handleBranchSwitchConflict}
|
||||
onStashPopConflict={handleStashPopConflict}
|
||||
onStashApplyConflict={handleStashApplyConflict}
|
||||
onBranchDeletedDuringMerge={(branchName) => {
|
||||
// Reset features that were assigned to the deleted branch (same logic as onDeleted in DeleteWorktreeDialog)
|
||||
hookFeatures.forEach((feature) => {
|
||||
@@ -1995,6 +2073,7 @@ export function BoardView() {
|
||||
onOpenChange={setShowMergeRebaseDialog}
|
||||
worktree={selectedWorktreeForAction}
|
||||
onConfirm={handleConfirmResolveConflicts}
|
||||
onCreateConflictResolutionFeature={handleCreateMergeConflictResolutionFeature}
|
||||
/>
|
||||
|
||||
{/* Commit Worktree Dialog */}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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'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> — Creates a task to analyze and
|
||||
resolve conflicts automatically
|
||||
</li>
|
||||
<li>
|
||||
<strong>Resolve Manually</strong> — 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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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> — 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]">
|
||||
@@ -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>
|
||||
|
||||
@@ -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> — Creates a task to analyze and resolve
|
||||
conflicts automatically
|
||||
</li>
|
||||
<li>
|
||||
<strong>Resolve Manually</strong> — 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>
|
||||
|
||||
@@ -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> — Creates a task to analyze and resolve
|
||||
conflicts automatically
|
||||
</li>
|
||||
<li>
|
||||
<strong>Resolve Manually</strong> — 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ import { createLogger } from '@automaker/utils/logger';
|
||||
|
||||
const logger = createLogger('BoardActions');
|
||||
|
||||
const MAX_DUPLICATES = 50;
|
||||
|
||||
interface UseBoardActionsProps {
|
||||
currentProject: { path: string; id: string } | null;
|
||||
features: Feature[];
|
||||
@@ -1199,10 +1201,22 @@ export function useBoardActions({
|
||||
|
||||
const handleDuplicateAsChildMultiple = useCallback(
|
||||
async (feature: Feature, count: number) => {
|
||||
// Guard: reject non-positive counts
|
||||
if (count <= 0) {
|
||||
toast.error('Invalid duplicate count', {
|
||||
description: 'Count must be a positive number.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Cap count to prevent runaway API calls
|
||||
const effectiveCount = Math.min(count, MAX_DUPLICATES);
|
||||
|
||||
// Create a chain of duplicates, each a child of the previous, so they execute sequentially
|
||||
let parentFeature = feature;
|
||||
let successCount = 0;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
for (let i = 0; i < effectiveCount; i++) {
|
||||
const {
|
||||
id: _id,
|
||||
status: _status,
|
||||
@@ -1223,18 +1237,39 @@ export function useBoardActions({
|
||||
dependencies: [parentFeature.id],
|
||||
};
|
||||
|
||||
const newFeature = await handleAddFeature(duplicatedFeatureData);
|
||||
try {
|
||||
const newFeature = await handleAddFeature(duplicatedFeatureData);
|
||||
|
||||
// Use the returned feature directly as the parent for the next iteration,
|
||||
// avoiding a fragile assumption that the newest feature is the last item in the store
|
||||
if (newFeature) {
|
||||
parentFeature = newFeature;
|
||||
// Use the returned feature directly as the parent for the next iteration,
|
||||
// avoiding a fragile assumption that the newest feature is the last item in the store
|
||||
if (newFeature) {
|
||||
parentFeature = newFeature;
|
||||
}
|
||||
successCount++;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
toast.error(
|
||||
`Failed after creating ${successCount} of ${effectiveCount} duplicate${effectiveCount !== 1 ? 's' : ''}`,
|
||||
{
|
||||
description: errorMessage,
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
toast.success(`Created ${count} chained duplicates`, {
|
||||
description: `Created ${count} sequential copies of: ${truncateDescription(feature.description || feature.title || '')}`,
|
||||
});
|
||||
if (successCount === effectiveCount) {
|
||||
toast.success(`Created ${successCount} chained duplicate${successCount !== 1 ? 's' : ''}`, {
|
||||
description: `Created ${successCount} sequential ${successCount !== 1 ? 'copies' : 'copy'} of: ${truncateDescription(feature.description || feature.title || '')}`,
|
||||
});
|
||||
} else {
|
||||
toast.info(
|
||||
`Partially created ${successCount} of ${effectiveCount} chained duplicate${effectiveCount !== 1 ? 's' : ''}`,
|
||||
{
|
||||
description: `Created ${successCount} sequential ${successCount !== 1 ? 'copies' : 'copy'} of: ${truncateDescription(feature.description || feature.title || '')}`,
|
||||
}
|
||||
);
|
||||
}
|
||||
},
|
||||
[handleAddFeature]
|
||||
);
|
||||
|
||||
@@ -449,51 +449,68 @@ export function WorktreeActionsDropdown({
|
||||
</DropdownMenuItem>
|
||||
</TooltipWrapper>
|
||||
)}
|
||||
{/* Stash operations - combined submenu */}
|
||||
{/* Stash operations - combined submenu or simple item */}
|
||||
{(onStashChanges || onViewStashes) && (
|
||||
<TooltipWrapper showTooltip={!canPerformGitOps} tooltipContent={gitOpsDisabledReason}>
|
||||
<DropdownMenuSub>
|
||||
<div className="flex items-center">
|
||||
{/* Main clickable area - stash changes (primary action) */}
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
if (!canPerformGitOps) return;
|
||||
if (worktree.hasChanges && onStashChanges) {
|
||||
{onViewStashes && worktree.hasChanges && onStashChanges ? (
|
||||
// Both "Stash Changes" (primary) and "View Stashes" (secondary) are available - show split submenu
|
||||
<DropdownMenuSub>
|
||||
<div className="flex items-center">
|
||||
{/* Main clickable area - stash changes (primary action) */}
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
if (!canPerformGitOps) return;
|
||||
onStashChanges(worktree);
|
||||
} else if (onViewStashes) {
|
||||
onViewStashes(worktree);
|
||||
}
|
||||
}}
|
||||
disabled={!canPerformGitOps}
|
||||
className={cn(
|
||||
'text-xs flex-1 pr-0 rounded-r-none',
|
||||
!canPerformGitOps && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
<Archive className="w-3.5 h-3.5 mr-2" />
|
||||
{worktree.hasChanges && onStashChanges ? 'Stash Changes' : 'Stashes'}
|
||||
{!canPerformGitOps && (
|
||||
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
{/* Chevron trigger for submenu with stash options */}
|
||||
<DropdownMenuSubTrigger
|
||||
className={cn(
|
||||
'text-xs px-1 rounded-l-none border-l border-border/30 h-8',
|
||||
!canPerformGitOps && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
disabled={!canPerformGitOps}
|
||||
/>
|
||||
</div>
|
||||
<DropdownMenuSubContent>
|
||||
{onViewStashes && (
|
||||
}}
|
||||
disabled={!canPerformGitOps}
|
||||
className={cn(
|
||||
'text-xs flex-1 pr-0 rounded-r-none',
|
||||
!canPerformGitOps && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
<Archive className="w-3.5 h-3.5 mr-2" />
|
||||
Stash Changes
|
||||
{!canPerformGitOps && (
|
||||
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
{/* Chevron trigger for submenu with stash options */}
|
||||
<DropdownMenuSubTrigger
|
||||
className={cn(
|
||||
'text-xs px-1 rounded-l-none border-l border-border/30 h-8',
|
||||
!canPerformGitOps && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
disabled={!canPerformGitOps}
|
||||
/>
|
||||
</div>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem onClick={() => onViewStashes(worktree)} className="text-xs">
|
||||
<Eye className="w-3.5 h-3.5 mr-2" />
|
||||
View Stashes
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
) : (
|
||||
// Only one action is meaningful - render a simple menu item without submenu
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
if (!canPerformGitOps) return;
|
||||
if (worktree.hasChanges && onStashChanges) {
|
||||
onStashChanges(worktree);
|
||||
} else if (onViewStashes) {
|
||||
onViewStashes(worktree);
|
||||
}
|
||||
}}
|
||||
disabled={!canPerformGitOps}
|
||||
className={cn('text-xs', !canPerformGitOps && 'opacity-50 cursor-not-allowed')}
|
||||
>
|
||||
<Archive className="w-3.5 h-3.5 mr-2" />
|
||||
{worktree.hasChanges && onStashChanges ? 'Stash Changes' : 'Stashes'}
|
||||
{!canPerformGitOps && (
|
||||
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
||||
)}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</TooltipWrapper>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
@@ -78,6 +78,10 @@ export interface MergeConflictInfo {
|
||||
sourceBranch: string;
|
||||
targetBranch: string;
|
||||
targetWorktreePath: string;
|
||||
/** List of files with conflicts, if available */
|
||||
conflictFiles?: string[];
|
||||
/** Type of operation that caused the conflict */
|
||||
operationType?: 'merge' | 'rebase';
|
||||
}
|
||||
|
||||
export interface BranchSwitchConflictInfo {
|
||||
@@ -93,6 +97,15 @@ export interface StashPopConflictInfo {
|
||||
stashPopConflictMessage: string;
|
||||
}
|
||||
|
||||
/** Info passed when a stash apply/pop operation results in merge conflicts */
|
||||
export interface StashApplyConflictInfo {
|
||||
worktreePath: string;
|
||||
branchName: string;
|
||||
stashRef: string;
|
||||
operation: 'apply' | 'pop';
|
||||
conflictFiles: string[];
|
||||
}
|
||||
|
||||
export interface WorktreePanelProps {
|
||||
projectPath: string;
|
||||
onCreateWorktree: () => void;
|
||||
@@ -107,6 +120,8 @@ export interface WorktreePanelProps {
|
||||
onBranchSwitchConflict?: (conflictInfo: BranchSwitchConflictInfo) => void;
|
||||
/** Called when checkout fails and the stash-pop restoration itself produces merge conflicts */
|
||||
onStashPopConflict?: (conflictInfo: StashPopConflictInfo) => void;
|
||||
/** Called when stash apply/pop results in merge conflicts and user wants AI resolution */
|
||||
onStashApplyConflict?: (conflictInfo: StashApplyConflictInfo) => void;
|
||||
/** Called when a branch is deleted during merge - features should be reassigned to main */
|
||||
onBranchDeletedDuringMerge?: (branchName: string) => void;
|
||||
onRemovedWorktrees?: (removedWorktrees: Array<{ path: string; branch: string }>) => void;
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
StashChangesDialog,
|
||||
ViewStashesDialog,
|
||||
CherryPickDialog,
|
||||
GitPullDialog,
|
||||
} from '../dialogs';
|
||||
import type { SelectRemoteOperation } from '../dialogs';
|
||||
import { TestLogsPanel } from '@/components/ui/test-logs-panel';
|
||||
@@ -61,6 +62,7 @@ export function WorktreePanel({
|
||||
onCreateMergeConflictResolutionFeature,
|
||||
onBranchSwitchConflict,
|
||||
onStashPopConflict,
|
||||
onStashApplyConflict,
|
||||
onBranchDeletedDuringMerge,
|
||||
onRemovedWorktrees,
|
||||
runningFeatureIds = [],
|
||||
@@ -107,7 +109,7 @@ export function WorktreePanel({
|
||||
isSwitching,
|
||||
isActivating,
|
||||
handleSwitchBranch,
|
||||
handlePull,
|
||||
handlePull: _handlePull,
|
||||
handlePush,
|
||||
handleOpenInIntegratedTerminal,
|
||||
handleOpenInEditor,
|
||||
@@ -423,6 +425,11 @@ export function WorktreePanel({
|
||||
const [cherryPickDialogOpen, setCherryPickDialogOpen] = useState(false);
|
||||
const [cherryPickWorktree, setCherryPickWorktree] = useState<WorktreeInfo | null>(null);
|
||||
|
||||
// Pull dialog states
|
||||
const [pullDialogOpen, setPullDialogOpen] = useState(false);
|
||||
const [pullDialogWorktree, setPullDialogWorktree] = useState<WorktreeInfo | null>(null);
|
||||
const [pullDialogRemote, setPullDialogRemote] = useState<string | undefined>(undefined);
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
// Periodic interval check (30 seconds) to detect branch changes on disk
|
||||
@@ -553,33 +560,42 @@ export function WorktreePanel({
|
||||
setPushToRemoteDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
// Handle pull with remote selection when multiple remotes exist
|
||||
const handlePullWithRemoteSelection = useCallback(
|
||||
async (worktree: WorktreeInfo) => {
|
||||
try {
|
||||
const api = getHttpApiClient();
|
||||
const result = await api.worktree.listRemotes(worktree.path);
|
||||
// Handle pull completed - refresh worktrees
|
||||
const handlePullCompleted = useCallback(() => {
|
||||
fetchWorktrees({ silent: true });
|
||||
}, [fetchWorktrees]);
|
||||
|
||||
if (result.success && result.result && result.result.remotes.length > 1) {
|
||||
// Multiple remotes - show selection dialog
|
||||
setSelectRemoteWorktree(worktree);
|
||||
setSelectRemoteOperation('pull');
|
||||
setSelectRemoteDialogOpen(true);
|
||||
} else if (result.success && result.result && result.result.remotes.length === 1) {
|
||||
// Exactly one remote - use it directly
|
||||
const remoteName = result.result.remotes[0].name;
|
||||
handlePull(worktree, remoteName);
|
||||
} else {
|
||||
// No remotes - proceed with default behavior
|
||||
handlePull(worktree);
|
||||
}
|
||||
} catch {
|
||||
// If listing remotes fails, fall back to default behavior
|
||||
handlePull(worktree);
|
||||
// Handle pull with remote selection when multiple remotes exist
|
||||
// Now opens the pull dialog which handles stash management and conflict resolution
|
||||
const handlePullWithRemoteSelection = useCallback(async (worktree: WorktreeInfo) => {
|
||||
try {
|
||||
const api = getHttpApiClient();
|
||||
const result = await api.worktree.listRemotes(worktree.path);
|
||||
|
||||
if (result.success && result.result && result.result.remotes.length > 1) {
|
||||
// Multiple remotes - show selection dialog first
|
||||
setSelectRemoteWorktree(worktree);
|
||||
setSelectRemoteOperation('pull');
|
||||
setSelectRemoteDialogOpen(true);
|
||||
} else if (result.success && result.result && result.result.remotes.length === 1) {
|
||||
// Exactly one remote - open pull dialog directly with that remote
|
||||
const remoteName = result.result.remotes[0].name;
|
||||
setPullDialogRemote(remoteName);
|
||||
setPullDialogWorktree(worktree);
|
||||
setPullDialogOpen(true);
|
||||
} else {
|
||||
// No remotes - open pull dialog with default
|
||||
setPullDialogRemote(undefined);
|
||||
setPullDialogWorktree(worktree);
|
||||
setPullDialogOpen(true);
|
||||
}
|
||||
},
|
||||
[handlePull]
|
||||
);
|
||||
} catch {
|
||||
// If listing remotes fails, open pull dialog with default
|
||||
setPullDialogRemote(undefined);
|
||||
setPullDialogWorktree(worktree);
|
||||
setPullDialogOpen(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Handle push with remote selection when multiple remotes exist
|
||||
const handlePushWithRemoteSelection = useCallback(
|
||||
@@ -613,6 +629,10 @@ export function WorktreePanel({
|
||||
const handleConfirmSelectRemote = useCallback(
|
||||
async (worktree: WorktreeInfo, remote: string) => {
|
||||
if (selectRemoteOperation === 'pull') {
|
||||
// Open the pull dialog with the selected remote
|
||||
setPullDialogRemote(remote);
|
||||
setPullDialogWorktree(worktree);
|
||||
setPullDialogOpen(true);
|
||||
await handlePull(worktree, remote);
|
||||
} else {
|
||||
await handlePush(worktree, remote);
|
||||
@@ -620,7 +640,7 @@ export function WorktreePanel({
|
||||
fetchBranches(worktree.path);
|
||||
fetchWorktrees();
|
||||
},
|
||||
[selectRemoteOperation, handlePull, handlePush, fetchBranches, fetchWorktrees]
|
||||
[selectRemoteOperation, handlePush, fetchBranches, fetchWorktrees]
|
||||
);
|
||||
|
||||
// Handle confirming the push to remote dialog
|
||||
@@ -822,6 +842,7 @@ export function WorktreePanel({
|
||||
onOpenChange={setViewStashesDialogOpen}
|
||||
worktree={viewStashesWorktree}
|
||||
onStashApplied={handleStashApplied}
|
||||
onStashApplyConflict={onStashApplyConflict}
|
||||
/>
|
||||
|
||||
{/* Cherry Pick Dialog */}
|
||||
@@ -833,6 +854,16 @@ export function WorktreePanel({
|
||||
onCreateConflictResolutionFeature={onCreateMergeConflictResolutionFeature}
|
||||
/>
|
||||
|
||||
{/* Git Pull Dialog */}
|
||||
<GitPullDialog
|
||||
open={pullDialogOpen}
|
||||
onOpenChange={setPullDialogOpen}
|
||||
worktree={pullDialogWorktree}
|
||||
remote={pullDialogRemote}
|
||||
onPulled={handlePullCompleted}
|
||||
onCreateConflictResolutionFeature={onCreateMergeConflictResolutionFeature}
|
||||
/>
|
||||
|
||||
{/* Dev Server Logs Panel */}
|
||||
<DevServerLogsPanel
|
||||
open={logPanelOpen}
|
||||
@@ -1054,6 +1085,7 @@ export function WorktreePanel({
|
||||
onViewTestLogs={handleViewTestLogs}
|
||||
onStashChanges={handleStashChanges}
|
||||
onViewStashes={handleViewStashes}
|
||||
onCherryPick={handleCherryPick}
|
||||
hasInitScript={hasInitScript}
|
||||
hasTestCommand={hasTestCommand}
|
||||
/>
|
||||
@@ -1130,6 +1162,7 @@ export function WorktreePanel({
|
||||
onViewTestLogs={handleViewTestLogs}
|
||||
onStashChanges={handleStashChanges}
|
||||
onViewStashes={handleViewStashes}
|
||||
onCherryPick={handleCherryPick}
|
||||
hasInitScript={hasInitScript}
|
||||
hasTestCommand={hasTestCommand}
|
||||
/>
|
||||
@@ -1261,6 +1294,16 @@ export function WorktreePanel({
|
||||
onCherryPicked={handleCherryPicked}
|
||||
onCreateConflictResolutionFeature={onCreateMergeConflictResolutionFeature}
|
||||
/>
|
||||
|
||||
{/* Git Pull Dialog */}
|
||||
<GitPullDialog
|
||||
open={pullDialogOpen}
|
||||
onOpenChange={setPullDialogOpen}
|
||||
worktree={pullDialogWorktree}
|
||||
remote={pullDialogRemote}
|
||||
onPulled={handlePullCompleted}
|
||||
onCreateConflictResolutionFeature={onCreateMergeConflictResolutionFeature}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -158,16 +158,28 @@ export function usePushWorktree() {
|
||||
/**
|
||||
* Pull changes from remote
|
||||
*
|
||||
* Enhanced to support stash management. When stashIfNeeded is true,
|
||||
* local changes will be automatically stashed before pulling and
|
||||
* reapplied afterward.
|
||||
*
|
||||
* @returns Mutation for pulling changes
|
||||
*/
|
||||
export function usePullWorktree() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ worktreePath, remote }: { worktreePath: string; remote?: string }) => {
|
||||
mutationFn: async ({
|
||||
worktreePath,
|
||||
remote,
|
||||
stashIfNeeded,
|
||||
}: {
|
||||
worktreePath: string;
|
||||
remote?: string;
|
||||
stashIfNeeded?: boolean;
|
||||
}) => {
|
||||
const api = getElectronAPI();
|
||||
if (!api.worktree) throw new Error('Worktree API not available');
|
||||
const result = await api.worktree.pull(worktreePath, remote);
|
||||
const result = await api.worktree.pull(worktreePath, remote, stashIfNeeded);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to pull changes');
|
||||
}
|
||||
|
||||
@@ -210,18 +210,14 @@ export function useProviderAuthInit() {
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
// Only initialize once per session if not already set
|
||||
if (
|
||||
initialized.current ||
|
||||
(claudeAuthStatus !== null &&
|
||||
codexAuthStatus !== null &&
|
||||
zaiAuthStatus !== null &&
|
||||
geminiAuthStatus !== null)
|
||||
) {
|
||||
// Skip if already initialized in this session
|
||||
if (initialized.current) {
|
||||
return;
|
||||
}
|
||||
initialized.current = true;
|
||||
|
||||
// Always call refreshStatuses() to background re-validate on app restart,
|
||||
// even when statuses are pre-populated from persisted storage (cache case).
|
||||
void refreshStatuses();
|
||||
}, [refreshStatuses, claudeAuthStatus, codexAuthStatus, zaiAuthStatus, geminiAuthStatus]);
|
||||
}
|
||||
|
||||
@@ -2259,15 +2259,23 @@ function createMockWorktreeAPI(): WorktreeAPI {
|
||||
};
|
||||
},
|
||||
|
||||
pull: async (worktreePath: string, remote?: string) => {
|
||||
pull: async (worktreePath: string, remote?: string, stashIfNeeded?: boolean) => {
|
||||
const targetRemote = remote || 'origin';
|
||||
console.log('[Mock] Pulling latest changes for:', { worktreePath, remote: targetRemote });
|
||||
console.log('[Mock] Pulling latest changes for:', {
|
||||
worktreePath,
|
||||
remote: targetRemote,
|
||||
stashIfNeeded,
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
branch: 'main',
|
||||
pulled: true,
|
||||
message: `Pulled latest changes from ${targetRemote}`,
|
||||
hasLocalChanges: false,
|
||||
hasConflicts: false,
|
||||
stashed: false,
|
||||
stashRestored: false,
|
||||
},
|
||||
};
|
||||
},
|
||||
@@ -2696,6 +2704,7 @@ function createMockWorktreeAPI(): WorktreeAPI {
|
||||
result: {
|
||||
applied: true,
|
||||
hasConflicts: false,
|
||||
conflictFiles: [] as string[],
|
||||
operation: pop ? ('pop' as const) : ('apply' as const),
|
||||
stashIndex,
|
||||
message: `Stash ${pop ? 'popped' : 'applied'} successfully`,
|
||||
@@ -2740,6 +2749,17 @@ function createMockWorktreeAPI(): WorktreeAPI {
|
||||
},
|
||||
};
|
||||
},
|
||||
rebase: async (worktreePath: string, ontoBranch: string) => {
|
||||
console.log('[Mock] Rebase:', { worktreePath, ontoBranch });
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
branch: 'current-branch',
|
||||
ontoBranch,
|
||||
message: `Successfully rebased onto ${ontoBranch}`,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -2135,8 +2135,8 @@ export class HttpApiClient implements ElectronAPI {
|
||||
featureId,
|
||||
filePath,
|
||||
}),
|
||||
pull: (worktreePath: string, remote?: string) =>
|
||||
this.post('/api/worktree/pull', { worktreePath, remote }),
|
||||
pull: (worktreePath: string, remote?: string, stashIfNeeded?: boolean) =>
|
||||
this.post('/api/worktree/pull', { worktreePath, remote, stashIfNeeded }),
|
||||
checkoutBranch: (worktreePath: string, branchName: string, baseBranch?: string) =>
|
||||
this.post('/api/worktree/checkout-branch', { worktreePath, branchName, baseBranch }),
|
||||
listBranches: (worktreePath: string, includeRemote?: boolean) =>
|
||||
@@ -2230,6 +2230,8 @@ export class HttpApiClient implements ElectronAPI {
|
||||
this.post('/api/worktree/stash-drop', { worktreePath, stashIndex }),
|
||||
cherryPick: (worktreePath: string, commitHashes: string[], options?: { noCommit?: boolean }) =>
|
||||
this.post('/api/worktree/cherry-pick', { worktreePath, commitHashes, options }),
|
||||
rebase: (worktreePath: string, ontoBranch: string) =>
|
||||
this.post('/api/worktree/rebase', { worktreePath, ontoBranch }),
|
||||
getBranchCommitLog: (worktreePath: string, branchName?: string, limit?: number) =>
|
||||
this.post('/api/worktree/branch-commit-log', { worktreePath, branchName, limit }),
|
||||
getTestLogs: (worktreePath?: string, sessionId?: string): Promise<TestLogsResponse> => {
|
||||
|
||||
32
apps/ui/src/types/electron.d.ts
vendored
32
apps/ui/src/types/electron.d.ts
vendored
@@ -793,6 +793,25 @@ export interface WorktreeAPI {
|
||||
branchDeleted: boolean;
|
||||
};
|
||||
error?: string;
|
||||
hasConflicts?: boolean;
|
||||
conflictFiles?: string[];
|
||||
}>;
|
||||
|
||||
// Rebase the current branch onto a target branch
|
||||
rebase: (
|
||||
worktreePath: string,
|
||||
ontoBranch: string
|
||||
) => Promise<{
|
||||
success: boolean;
|
||||
result?: {
|
||||
branch: string;
|
||||
ontoBranch: string;
|
||||
message: string;
|
||||
};
|
||||
error?: string;
|
||||
hasConflicts?: boolean;
|
||||
conflictFiles?: string[];
|
||||
aborted?: boolean;
|
||||
}>;
|
||||
|
||||
// Get worktree info for a feature
|
||||
@@ -966,16 +985,24 @@ export interface WorktreeAPI {
|
||||
filePath: string
|
||||
) => Promise<FileDiffResult>;
|
||||
|
||||
// Pull latest changes from remote
|
||||
// Pull latest changes from remote with optional stash management
|
||||
pull: (
|
||||
worktreePath: string,
|
||||
remote?: string
|
||||
remote?: string,
|
||||
stashIfNeeded?: boolean
|
||||
) => Promise<{
|
||||
success: boolean;
|
||||
result?: {
|
||||
branch: string;
|
||||
pulled: boolean;
|
||||
message: string;
|
||||
hasLocalChanges?: boolean;
|
||||
localChangedFiles?: string[];
|
||||
hasConflicts?: boolean;
|
||||
conflictSource?: 'pull' | 'stash';
|
||||
conflictFiles?: string[];
|
||||
stashed?: boolean;
|
||||
stashRestored?: boolean;
|
||||
};
|
||||
error?: string;
|
||||
code?: 'NOT_GIT_REPO' | 'NO_COMMITS';
|
||||
@@ -1490,6 +1517,7 @@ export interface WorktreeAPI {
|
||||
result?: {
|
||||
applied: boolean;
|
||||
hasConflicts: boolean;
|
||||
conflictFiles?: string[];
|
||||
operation: 'apply' | 'pop';
|
||||
stashIndex: number;
|
||||
message: string;
|
||||
|
||||
Reference in New Issue
Block a user