Alex/refactor bb rebase (#824)

* Refactor task_attempt branch handling and enforce NOT NULL constraint on branch column

* Change backend rebase to no change base branch, add change target branch api

* Change frontend rebase on branch to change target branch

Change status to show ahead/behind, always show rebase

* Use target branch for everything except rebase

* Remove base_branch

* Remove base branch frontend

* add rebase dialog with target and upstream options

* Fix unused upstream arg

* Add i18n

* Remove stray ts-rs file

* dont show +0, -0

* Move upstream to foldable advanced rebase

* Move buttons around

* Move git state/actions into a component

* Add task/target labels

* Fix action buttons layout

* Fmt

* i18n

* remove branch origin removal

* Remove empty divs

* Remove [1fr_auto_1fr] class in favour if divs

* use theme colours, make gear icon bigger

* Fix plural i18n

* Remove legacy ui reducer
This commit is contained in:
Alex Netsch
2025-09-29 19:50:29 +01:00
committed by GitHub
parent bcd6bdbe05
commit 091e903cf6
44 changed files with 1415 additions and 802 deletions

View File

@@ -0,0 +1,508 @@
import {
ArrowRight,
GitBranch as GitBranchIcon,
GitPullRequest,
RefreshCw,
Settings,
AlertTriangle,
CheckCircle,
} from 'lucide-react';
import { Button } from '@/components/ui/button.tsx';
import { Card } from '@/components/ui/card';
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;
}
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 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<GitOperationError>) => {
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,
});
};
if (!branchStatus || mergeInfo.hasMergedPR) {
return null;
}
return (
<div>
<Card className="bg-background p-3 border border-dashed text-sm">
Git
</Card>
<div className="p-3 space-y-3">
{/* Branch Flow with Status Below */}
<div className="space-y-1 py-2">
{/* Labels Row */}
<div className="flex gap-4">
{/* Task Branch Label - Left Column */}
<div className="flex flex-1 justify-start">
<span className="text-xs text-muted-foreground">
{t('git.labels.taskBranch')}
</span>
</div>
{/* Center Column - Empty */}
{/* Target Branch Label - Right Column */}
<div className="flex flex-1 justify-end">
<span className="text-xs text-muted-foreground">
{t('rebase.dialog.targetLabel')}
</span>
</div>
</div>
{/* Branches Row */}
<div className="flex flex-1 gap-4 items-center">
{/* Task Branch - Left Column */}
<div className="flex flex-1 items-center justify-start gap-1.5 min-w-0">
<GitBranchIcon className="h-3 w-3 text-muted-foreground" />
<span className="text-sm font-medium truncate">
{selectedAttempt.branch}
</span>
</div>
{/* Arrow - Center Column */}
<div className="flex justify-center">
<ArrowRight className="h-4 w-4 text-muted-foreground" />
</div>
{/* Target Branch - Right Column */}
<div className="flex flex-1 items-center justify-end gap-1.5 min-w-0">
<GitBranchIcon className="h-3 w-3 text-muted-foreground" />
<span className="text-sm font-medium truncate">
{branchStatus?.target_branch_name ||
selectedBranch ||
t('git.branch.current')}
</span>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="xs"
onClick={handleChangeTargetBranchDialogOpen}
disabled={isAttemptRunning || hasConflictsCalculated}
className="h-4 w-4 p-0 hover:bg-muted ml-1"
>
<Settings className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t('branches.changeTarget.dialog.title')}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
{/* Bottom Row: Status Information */}
<div className="flex gap-4">
<div className="flex-1 flex justify-start">
{(() => {
const commitsAhead = branchStatus?.commits_ahead ?? 0;
const showAhead = commitsAhead > 0;
if (showAhead) {
return (
<span className="text-xs font-medium text-success">
{commitsAhead}{' '}
{t('git.status.commits', { count: commitsAhead })}{' '}
{t('git.status.ahead')}
</span>
);
}
return null;
})()}
</div>
<div className="flex justify-center">
{(() => {
const commitsAhead = branchStatus?.commits_ahead ?? 0;
const commitsBehind = branchStatus?.commits_behind ?? 0;
const showAhead = commitsAhead > 0;
const showBehind = commitsBehind > 0;
// Handle special states (PR, conflicts, etc.) - center under arrow
if (hasConflictsCalculated) {
return (
<div className="flex items-center gap-1 text-warning">
<AlertTriangle className="h-3 w-3" />
<span className="text-xs font-medium">
{t('git.status.conflicts')}
</span>
</div>
);
}
if (branchStatus?.is_rebase_in_progress) {
return (
<div className="flex items-center gap-1 text-warning">
<RefreshCw className="h-3 w-3 animate-spin" />
<span className="text-xs font-medium">
{t('git.states.rebasing')}
</span>
</div>
);
}
// Check for merged PR
if (mergeInfo.hasMergedPR) {
return (
<div className="flex items-center gap-1 text-success">
<CheckCircle className="h-3 w-3" />
<span className="text-xs font-medium">
{t('git.states.merged')}
</span>
</div>
);
}
// Check for open PR - center under arrow
if (mergeInfo.hasOpenPR && mergeInfo.openPR?.type === 'pr') {
const prMerge = mergeInfo.openPR;
return (
<button
onClick={() => window.open(prMerge.pr_info.url, '_blank')}
className="flex items-center gap-1 text-info hover:text-info hover:underline"
>
<GitPullRequest className="h-3 w-3" />
<span className="text-xs font-medium">
PR #{Number(prMerge.pr_info.number)}
</span>
</button>
);
}
// If showing ahead/behind, don't show anything in center
if (showAhead || showBehind) {
return null;
}
// Default: up to date - center under arrow
return (
<span className="text-xs text-muted-foreground">
{t('git.status.upToDate')}
</span>
);
})()}
</div>
<div className="flex-1 flex justify-end">
{(() => {
const commitsBehind = branchStatus?.commits_behind ?? 0;
const showBehind = commitsBehind > 0;
if (showBehind) {
return (
<span className="text-xs font-medium text-warning">
{commitsBehind}{' '}
{t('git.status.commits', { count: commitsBehind })}{' '}
{t('git.status.behind')}
</span>
);
}
return null;
})()}
</div>
</div>
</div>
{/* Git Operations */}
<div className="flex gap-2">
<Button
onClick={handleMergeClick}
disabled={
mergeInfo.hasOpenPR ||
merging ||
hasConflictsCalculated ||
Boolean((branchStatus.commits_behind ?? 0) > 0) ||
isAttemptRunning ||
((branchStatus.commits_ahead ?? 0) === 0 &&
!pushSuccess &&
!mergeSuccess)
}
variant="outline"
size="xs"
className="border-success text-success hover:bg-success gap-1 flex-1"
>
<GitBranchIcon className="h-3 w-3" />
{mergeButtonLabel}
</Button>
<Button
onClick={handlePRButtonClick}
disabled={
pushing ||
Boolean((branchStatus.commits_behind ?? 0) > 0) ||
isAttemptRunning ||
hasConflictsCalculated ||
(mergeInfo.hasOpenPR &&
branchStatus.remote_commits_ahead === 0) ||
((branchStatus.commits_ahead ?? 0) === 0 &&
(branchStatus.remote_commits_ahead ?? 0) === 0 &&
!pushSuccess &&
!mergeSuccess)
}
variant="outline"
size="xs"
className="border-info text-info hover:bg-info gap-1 flex-1"
>
<GitPullRequest className="h-3 w-3" />
{mergeInfo.hasOpenPR
? pushSuccess
? t('git.states.pushed')
: pushing
? t('git.states.pushing')
: t('git.states.push')
: t('git.states.createPr')}
</Button>
<Button
onClick={handleRebaseDialogOpen}
disabled={
rebasing ||
isAttemptRunning ||
hasConflictsCalculated ||
(branchStatus.commits_behind ?? 0) === 0
}
variant="outline"
size="xs"
className="border-warning text-warning hover:bg-warning gap-1 flex-1"
>
<RefreshCw
className={`h-3 w-3 ${rebasing ? 'animate-spin' : ''}`}
/>
{rebaseButtonLabel}
</Button>
</div>
</div>
</div>
);
}
export default GitOperations;