import { ArrowRight, GitBranch as GitBranchIcon, GitPullRequest, RefreshCw, Settings, AlertTriangle, CheckCircle, ExternalLink, } from 'lucide-react'; import { Button } from '@/components/ui/button.tsx'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from '@/components/ui/tooltip.tsx'; import { useMemo, useState } from 'react'; import type { BranchStatus, GitBranch, TaskAttempt, TaskWithAttemptStatus, } from 'shared/types'; import { useRebase } from '@/hooks/useRebase'; import { useMerge } from '@/hooks/useMerge'; import { usePush } from '@/hooks/usePush'; import { useChangeTargetBranch } from '@/hooks/useChangeTargetBranch'; import NiceModal from '@ebay/nice-modal-react'; import { Err } from '@/lib/api'; import type { GitOperationError } from 'shared/types'; import { showModal } from '@/lib/modals'; import { useTranslation } from 'react-i18next'; interface GitOperationsProps { selectedAttempt: TaskAttempt; task: TaskWithAttemptStatus; projectId: string; branchStatus: BranchStatus | null; branches: GitBranch[]; isAttemptRunning: boolean; setError: (error: string | null) => void; selectedBranch: string | null; } export type GitOperationsInputs = Omit; function GitOperations({ selectedAttempt, task, projectId, branchStatus, branches, isAttemptRunning, setError, selectedBranch, }: GitOperationsProps) { const { t } = useTranslation('tasks'); // Git operation hooks const rebaseMutation = useRebase(selectedAttempt.id, projectId); const mergeMutation = useMerge(selectedAttempt.id); const pushMutation = usePush(selectedAttempt.id); const changeTargetBranchMutation = useChangeTargetBranch( selectedAttempt.id, projectId ); const isChangingTargetBranch = changeTargetBranchMutation.isPending; // Git status calculations const hasConflictsCalculated = useMemo( () => Boolean((branchStatus?.conflicted_files?.length ?? 0) > 0), [branchStatus?.conflicted_files] ); // Local state for git operations const [merging, setMerging] = useState(false); const [pushing, setPushing] = useState(false); const [rebasing, setRebasing] = useState(false); const [mergeSuccess, setMergeSuccess] = useState(false); const [pushSuccess, setPushSuccess] = useState(false); // Target branch change handlers const handleChangeTargetBranchClick = async (newBranch: string) => { await changeTargetBranchMutation .mutateAsync(newBranch) .then(() => setError(null)) .catch((error) => { setError(error.message || t('git.errors.changeTargetBranch')); }); }; const handleChangeTargetBranchDialogOpen = async () => { try { const result = await showModal<{ action: 'confirmed' | 'canceled'; branchName: string; }>('change-target-branch-dialog', { branches, isChangingTargetBranch: isChangingTargetBranch, }); if (result.action === 'confirmed' && result.branchName) { await handleChangeTargetBranchClick(result.branchName); } } catch (error) { // User cancelled - do nothing } }; // Memoize merge status information to avoid repeated calculations const mergeInfo = useMemo(() => { if (!branchStatus?.merges) return { hasOpenPR: false, openPR: null, hasMergedPR: false, mergedPR: null, hasMerged: false, latestMerge: null, }; const openPR = branchStatus.merges.find( (m: any) => m.type === 'pr' && m.pr_info.status === 'open' ); const mergedPR = branchStatus.merges.find( (m: any) => m.type === 'pr' && m.pr_info.status === 'merged' ); const merges = branchStatus.merges.filter( (m: any) => m.type === 'direct' || (m.type === 'pr' && m.pr_info.status === 'merged') ); return { hasOpenPR: !!openPR, openPR, hasMergedPR: !!mergedPR, mergedPR, hasMerged: merges.length > 0, latestMerge: branchStatus.merges[0] || null, // Most recent merge }; }, [branchStatus?.merges]); const mergeButtonLabel = useMemo(() => { if (mergeSuccess) return t('git.states.merged'); if (merging) return t('git.states.merging'); return t('git.states.merge'); }, [mergeSuccess, merging, t]); const rebaseButtonLabel = useMemo(() => { if (rebasing) return t('git.states.rebasing'); return t('git.states.rebase'); }, [rebasing, t]); const prButtonLabel = useMemo(() => { if (mergeInfo.hasOpenPR) { return pushSuccess ? t('git.states.pushed') : pushing ? t('git.states.pushing') : t('git.states.push'); } return t('git.states.createPr'); }, [mergeInfo.hasOpenPR, pushSuccess, pushing, t]); const handleMergeClick = async () => { // Directly perform merge without checking branch status await performMerge(); }; const handlePushClick = async () => { try { setPushing(true); await pushMutation.mutateAsync(); setError(null); // Clear any previous errors on success setPushSuccess(true); setTimeout(() => setPushSuccess(false), 2000); } catch (error: any) { setError(error.message || t('git.errors.pushChanges')); } finally { setPushing(false); } }; const performMerge = async () => { try { setMerging(true); await mergeMutation.mutateAsync(); setError(null); // Clear any previous errors on success setMergeSuccess(true); setTimeout(() => setMergeSuccess(false), 2000); } catch (error) { // @ts-expect-error it is type ApiError setError(error.message || t('git.errors.mergeChanges')); } finally { setMerging(false); } }; const handleRebaseWithNewBranchAndUpstream = async ( newBaseBranch: string, selectedUpstream: string ) => { setRebasing(true); await rebaseMutation .mutateAsync({ newBaseBranch: newBaseBranch, oldBaseBranch: selectedUpstream, }) .then(() => setError(null)) .catch((err: Err) => { const data = err?.error; const isConflict = data?.type === 'merge_conflicts' || data?.type === 'rebase_in_progress'; if (!isConflict) setError(err.message || t('git.errors.rebaseBranch')); }); setRebasing(false); }; const handleRebaseDialogOpen = async () => { try { const defaultTargetBranch = selectedAttempt.target_branch; const result = await showModal<{ action: 'confirmed' | 'canceled'; branchName?: string; upstreamBranch?: string; }>('rebase-dialog', { branches, isRebasing: rebasing, initialTargetBranch: defaultTargetBranch, initialUpstreamBranch: defaultTargetBranch, }); if ( result.action === 'confirmed' && result.branchName && result.upstreamBranch ) { await handleRebaseWithNewBranchAndUpstream( result.branchName, result.upstreamBranch ); } } catch (error) { // User cancelled - do nothing } }; const handlePRButtonClick = async () => { // If PR already exists, push to it if (mergeInfo.hasOpenPR) { await handlePushClick(); return; } NiceModal.show('create-pr', { attempt: selectedAttempt, task, projectId, }); }; // Hide entire panel only if PR is merged if (mergeInfo.hasMergedPR) { return null; } return (
{/* Left: Branch flow */}
{/* Task branch chip */} {selectedAttempt.branch} {t('git.labels.taskBranch')} {/* Target branch chip + change button */}
{branchStatus?.target_branch_name || selectedAttempt.target_branch || selectedBranch || t('git.branch.current')} {t('rebase.dialog.targetLabel')} {t('branches.changeTarget.dialog.title')}
{/* Center: Status chips */}
{(() => { const commitsAhead = branchStatus?.commits_ahead ?? 0; const commitsBehind = branchStatus?.commits_behind ?? 0; if (hasConflictsCalculated) { return ( {t('git.status.conflicts')} ); } if (branchStatus?.is_rebase_in_progress) { return ( {t('git.states.rebasing')} ); } if (mergeInfo.hasMergedPR) { return ( {t('git.states.merged')} ); } if (mergeInfo.hasOpenPR && mergeInfo.openPR?.type === 'pr') { const prMerge = mergeInfo.openPR; return ( ); } const chips: React.ReactNode[] = []; if (commitsAhead > 0) { chips.push( +{commitsAhead}{' '} {t('git.status.commits', { count: commitsAhead })}{' '} {t('git.status.ahead')} ); } if (commitsBehind > 0) { chips.push( {commitsBehind}{' '} {t('git.status.commits', { count: commitsBehind })}{' '} {t('git.status.behind')} ); } if (chips.length > 0) return
{chips}
; return ( {t('git.status.upToDate')} ); })()}
{/* Right: Actions (compact, right-aligned) */} {branchStatus && (
)}
); } export default GitOperations;