Feature: Git sync, set-tracking, and push divergence handling (#796)

This commit is contained in:
gsxdsm
2026-02-21 18:54:16 -08:00
committed by GitHub
parent dfa719079f
commit 91bff21d58
16 changed files with 1095 additions and 52 deletions

View File

@@ -14,6 +14,7 @@ import {
import {
Trash2,
MoreHorizontal,
GitBranch,
GitCommit,
GitPullRequest,
Download,
@@ -138,6 +139,85 @@ interface WorktreeActionsDropdownProps {
onRunTerminalScript?: (worktree: WorktreeInfo, command: string) => void;
/** Callback to open the script editor UI */
onEditScripts?: () => void;
/** Whether sync is in progress */
isSyncing?: boolean;
/** Sync (pull + push) callback */
onSync?: (worktree: WorktreeInfo) => void;
/** Sync with a specific remote */
onSyncWithRemote?: (worktree: WorktreeInfo, remote: string) => void;
/** Set tracking branch to a specific remote */
onSetTracking?: (worktree: WorktreeInfo, remote: string) => void;
}
/**
* A remote item that either renders as a split-button with "Set as Tracking Branch"
* sub-action, or a plain menu item if onSetTracking is not provided.
*/
function RemoteActionMenuItem({
remote,
icon: Icon,
trackingRemote,
isDisabled,
isGitOpsAvailable,
onAction,
onSetTracking,
}: {
remote: { name: string; url: string };
icon: typeof Download;
trackingRemote?: string;
isDisabled: boolean;
isGitOpsAvailable: boolean;
onAction: () => void;
onSetTracking?: () => void;
}) {
if (onSetTracking) {
return (
<DropdownMenuSub key={remote.name}>
<div className="flex items-center">
<DropdownMenuItem
onClick={onAction}
disabled={isDisabled || !isGitOpsAvailable}
className="text-xs flex-1 pr-0 rounded-r-none"
>
<Icon className="w-3.5 h-3.5 mr-2" />
{remote.name}
{trackingRemote === remote.name && (
<span className="ml-auto text-[10px] text-muted-foreground mr-1">tracking</span>
)}
</DropdownMenuItem>
<DropdownMenuSubTrigger
className="text-xs px-1 rounded-l-none border-l border-border/30 h-8"
disabled={!isGitOpsAvailable}
/>
</div>
<DropdownMenuSubContent>
<DropdownMenuItem
onClick={onSetTracking}
disabled={!isGitOpsAvailable}
className="text-xs"
>
<GitBranch className="w-3.5 h-3.5 mr-2" />
Set as Tracking Branch
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
);
}
return (
<DropdownMenuItem
key={remote.name}
onClick={onAction}
disabled={isDisabled || !isGitOpsAvailable}
className="text-xs"
>
<Icon className="w-3.5 h-3.5 mr-2" />
{remote.name}
<span className="ml-auto text-[10px] text-muted-foreground max-w-[100px] truncate">
{remote.url}
</span>
</DropdownMenuItem>
);
}
export function WorktreeActionsDropdown({
@@ -198,6 +278,10 @@ export function WorktreeActionsDropdown({
terminalScripts,
onRunTerminalScript,
onEditScripts,
isSyncing = false,
onSync,
onSyncWithRemote,
onSetTracking,
}: WorktreeActionsDropdownProps) {
// Get available editors for the "Open In" submenu
const { editors } = useAvailableEditors();
@@ -719,18 +803,20 @@ export function WorktreeActionsDropdown({
</DropdownMenuLabel>
<DropdownMenuSeparator />
{remotes.map((remote) => (
<DropdownMenuItem
<RemoteActionMenuItem
key={remote.name}
onClick={() => isGitOpsAvailable && onPullWithRemote(worktree, remote.name)}
disabled={isPulling || !isGitOpsAvailable}
className="text-xs"
>
<Download className="w-3.5 h-3.5 mr-2" />
{remote.name}
<span className="ml-auto text-[10px] text-muted-foreground max-w-[100px] truncate">
{remote.url}
</span>
</DropdownMenuItem>
remote={remote}
icon={Download}
trackingRemote={trackingRemote}
isDisabled={isPulling}
isGitOpsAvailable={isGitOpsAvailable}
onAction={() => isGitOpsAvailable && onPullWithRemote(worktree, remote.name)}
onSetTracking={
onSetTracking
? () => isGitOpsAvailable && onSetTracking(worktree, remote.name)
: undefined
}
/>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
@@ -818,18 +904,20 @@ export function WorktreeActionsDropdown({
</DropdownMenuLabel>
<DropdownMenuSeparator />
{remotes.map((remote) => (
<DropdownMenuItem
<RemoteActionMenuItem
key={remote.name}
onClick={() => isGitOpsAvailable && onPushWithRemote(worktree, remote.name)}
disabled={isPushing || !isGitOpsAvailable}
className="text-xs"
>
<Upload className="w-3.5 h-3.5 mr-2" />
{remote.name}
<span className="ml-auto text-[10px] text-muted-foreground max-w-[100px] truncate">
{remote.url}
</span>
</DropdownMenuItem>
remote={remote}
icon={Upload}
trackingRemote={trackingRemote}
isDisabled={isPushing}
isGitOpsAvailable={isGitOpsAvailable}
onAction={() => isGitOpsAvailable && onPushWithRemote(worktree, remote.name)}
onSetTracking={
onSetTracking
? () => isGitOpsAvailable && onSetTracking(worktree, remote.name)
: undefined
}
/>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
@@ -876,6 +964,72 @@ export function WorktreeActionsDropdown({
</DropdownMenuItem>
)}
</TooltipWrapper>
{onSync && (
<TooltipWrapper
showTooltip={!!gitOpsDisabledReason}
tooltipContent={gitOpsDisabledReason}
>
{remotes && remotes.length > 1 && onSyncWithRemote ? (
<DropdownMenuSub>
<div className="flex items-center">
<DropdownMenuItem
onClick={() => isGitOpsAvailable && onSync(worktree)}
disabled={isSyncing || !isGitOpsAvailable}
className={cn(
'text-xs flex-1 pr-0 rounded-r-none',
!isGitOpsAvailable && 'opacity-50 cursor-not-allowed'
)}
>
<RefreshCw className={cn('w-3.5 h-3.5 mr-2', isSyncing && 'animate-spin')} />
{isSyncing ? 'Syncing...' : 'Sync'}
{!isGitOpsAvailable && (
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
)}
</DropdownMenuItem>
<DropdownMenuSubTrigger
className={cn(
'text-xs px-1 rounded-l-none border-l border-border/30 h-8',
(!isGitOpsAvailable || isSyncing) && 'opacity-50 cursor-not-allowed'
)}
disabled={!isGitOpsAvailable || isSyncing}
/>
</div>
<DropdownMenuSubContent>
<DropdownMenuLabel className="text-xs text-muted-foreground font-normal">
Sync with remote
</DropdownMenuLabel>
<DropdownMenuSeparator />
{remotes.map((remote) => (
<DropdownMenuItem
key={`sync-${remote.name}`}
onClick={() => isGitOpsAvailable && onSyncWithRemote(worktree, remote.name)}
disabled={isSyncing || !isGitOpsAvailable}
className="text-xs"
>
<RefreshCw className="w-3.5 h-3.5 mr-2" />
{remote.name}
<span className="ml-auto text-[10px] text-muted-foreground max-w-[100px] truncate">
{remote.url}
</span>
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
) : (
<DropdownMenuItem
onClick={() => isGitOpsAvailable && onSync(worktree)}
disabled={isSyncing || !isGitOpsAvailable}
className={cn('text-xs', !isGitOpsAvailable && 'opacity-50 cursor-not-allowed')}
>
<RefreshCw className={cn('w-3.5 h-3.5 mr-2', isSyncing && 'animate-spin')} />
{isSyncing ? 'Syncing...' : 'Sync'}
{!isGitOpsAvailable && (
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
)}
</DropdownMenuItem>
)}
</TooltipWrapper>
)}
<TooltipWrapper showTooltip={!!gitOpsDisabledReason} tooltipContent={gitOpsDisabledReason}>
<DropdownMenuItem
onClick={() => isGitOpsAvailable && onResolveConflicts(worktree)}

View File

@@ -138,6 +138,14 @@ export interface WorktreeDropdownProps {
onRunTerminalScript?: (worktree: WorktreeInfo, command: string) => void;
/** Callback to open the script editor UI */
onEditScripts?: () => void;
/** Whether sync is in progress */
isSyncing?: boolean;
/** Sync (pull + push) callback */
onSync?: (worktree: WorktreeInfo) => void;
/** Sync with a specific remote */
onSyncWithRemote?: (worktree: WorktreeInfo, remote: string) => void;
/** Set tracking branch to a specific remote */
onSetTracking?: (worktree: WorktreeInfo, remote: string) => void;
}
/**
@@ -230,6 +238,10 @@ export function WorktreeDropdown({
terminalScripts,
onRunTerminalScript,
onEditScripts,
isSyncing = false,
onSync,
onSyncWithRemote,
onSetTracking,
}: WorktreeDropdownProps) {
// Find the currently selected worktree to display in the trigger
const selectedWorktree = worktrees.find((w) => isWorktreeSelected(w));
@@ -549,6 +561,10 @@ export function WorktreeDropdown({
terminalScripts={terminalScripts}
onRunTerminalScript={onRunTerminalScript}
onEditScripts={onEditScripts}
isSyncing={isSyncing}
onSync={onSync}
onSyncWithRemote={onSyncWithRemote}
onSetTracking={onSetTracking}
/>
)}
</div>

View File

@@ -108,6 +108,14 @@ interface WorktreeTabProps {
onRunTerminalScript?: (worktree: WorktreeInfo, command: string) => void;
/** Callback to open the script editor UI */
onEditScripts?: () => void;
/** Whether sync is in progress */
isSyncing?: boolean;
/** Sync (pull + push) callback */
onSync?: (worktree: WorktreeInfo) => void;
/** Sync with a specific remote */
onSyncWithRemote?: (worktree: WorktreeInfo, remote: string) => void;
/** Set tracking branch to a specific remote */
onSetTracking?: (worktree: WorktreeInfo, remote: string) => void;
}
export function WorktreeTab({
@@ -181,6 +189,10 @@ export function WorktreeTab({
terminalScripts,
onRunTerminalScript,
onEditScripts,
isSyncing = false,
onSync,
onSyncWithRemote,
onSetTracking,
}: WorktreeTabProps) {
// Make the worktree tab a drop target for feature cards
const { setNodeRef, isOver } = useDroppable({
@@ -550,6 +562,10 @@ export function WorktreeTab({
terminalScripts={terminalScripts}
onRunTerminalScript={onRunTerminalScript}
onEditScripts={onEditScripts}
isSyncing={isSyncing}
onSync={onSync}
onSyncWithRemote={onSyncWithRemote}
onSetTracking={onSetTracking}
/>
</div>
);

View File

@@ -8,6 +8,8 @@ import {
useSwitchBranch,
usePullWorktree,
usePushWorktree,
useSyncWorktree,
useSetTracking,
useOpenInEditor,
} from '@/hooks/mutations';
import type { WorktreeInfo } from '../types';
@@ -51,6 +53,8 @@ export function useWorktreeActions(options?: UseWorktreeActionsOptions) {
});
const pullMutation = usePullWorktree();
const pushMutation = usePushWorktree();
const syncMutation = useSyncWorktree();
const setTrackingMutation = useSetTracking();
const openInEditorMutation = useOpenInEditor();
/**
@@ -150,6 +154,28 @@ export function useWorktreeActions(options?: UseWorktreeActionsOptions) {
[pushMutation]
);
const handleSync = useCallback(
async (worktree: WorktreeInfo, remote?: string) => {
if (syncMutation.isPending) return;
syncMutation.mutate({
worktreePath: worktree.path,
remote,
});
},
[syncMutation]
);
const handleSetTracking = useCallback(
async (worktree: WorktreeInfo, remote: string) => {
if (setTrackingMutation.isPending) return;
setTrackingMutation.mutate({
worktreePath: worktree.path,
remote,
});
},
[setTrackingMutation]
);
const handleOpenInIntegratedTerminal = useCallback(
(worktree: WorktreeInfo, mode?: 'tab' | 'split') => {
// Navigate to the terminal view with the worktree path and branch name
@@ -215,12 +241,15 @@ export function useWorktreeActions(options?: UseWorktreeActionsOptions) {
return {
isPulling: pullMutation.isPending,
isPushing: pushMutation.isPending,
isSyncing: syncMutation.isPending,
isSwitching: switchBranchMutation.isPending,
isActivating,
setIsActivating,
handleSwitchBranch,
handlePull,
handlePush,
handleSync,
handleSetTracking,
handleOpenInIntegratedTerminal,
handleRunTerminalScript,
handleOpenInEditor,

View File

@@ -113,11 +113,14 @@ export function WorktreePanel({
const {
isPulling,
isPushing,
isSyncing,
isSwitching,
isActivating,
handleSwitchBranch,
handlePull: _handlePull,
handlePush,
handleSync,
handleSetTracking,
handleOpenInIntegratedTerminal,
handleRunTerminalScript,
handleOpenInEditor,
@@ -828,6 +831,30 @@ export function WorktreePanel({
[handlePush, fetchBranches, fetchWorktrees]
);
// Handle sync (pull + push) with optional remote selection
const handleSyncWithRemoteSelection = useCallback(
(worktree: WorktreeInfo) => {
handleSync(worktree);
},
[handleSync]
);
// Handle sync with a specific remote selected from the submenu
const handleSyncWithSpecificRemote = useCallback(
(worktree: WorktreeInfo, remote: string) => {
handleSync(worktree, remote);
},
[handleSync]
);
// Handle set tracking branch for a specific remote
const handleSetTrackingForRemote = useCallback(
(worktree: WorktreeInfo, remote: string) => {
handleSetTracking(worktree, remote);
},
[handleSetTracking]
);
// Handle confirming the push to remote dialog
const handleConfirmPushToRemote = useCallback(
async (worktree: WorktreeInfo, remote: string) => {
@@ -936,6 +963,10 @@ export function WorktreePanel({
onPushNewBranch={handlePushNewBranch}
onPullWithRemote={handlePullWithSpecificRemote}
onPushWithRemote={handlePushWithSpecificRemote}
isSyncing={isSyncing}
onSync={handleSyncWithRemoteSelection}
onSyncWithRemote={handleSyncWithSpecificRemote}
onSetTracking={handleSetTrackingForRemote}
onOpenInEditor={handleOpenInEditor}
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
onOpenInExternalTerminal={handleOpenInExternalTerminal}
@@ -1179,6 +1210,10 @@ export function WorktreePanel({
onPushNewBranch={handlePushNewBranch}
onPullWithRemote={handlePullWithSpecificRemote}
onPushWithRemote={handlePushWithSpecificRemote}
isSyncing={isSyncing}
onSync={handleSyncWithRemoteSelection}
onSyncWithRemote={handleSyncWithSpecificRemote}
onSetTracking={handleSetTrackingForRemote}
remotesCache={remotesCache}
onOpenInEditor={handleOpenInEditor}
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
@@ -1286,6 +1321,10 @@ export function WorktreePanel({
onPushNewBranch={handlePushNewBranch}
onPullWithRemote={handlePullWithSpecificRemote}
onPushWithRemote={handlePushWithSpecificRemote}
isSyncing={isSyncing}
onSync={handleSyncWithRemoteSelection}
onSyncWithRemote={handleSyncWithSpecificRemote}
onSetTracking={handleSetTrackingForRemote}
remotes={remotesCache[mainWorktree.path]}
onOpenInEditor={handleOpenInEditor}
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
@@ -1373,6 +1412,10 @@ export function WorktreePanel({
onPushNewBranch={handlePushNewBranch}
onPullWithRemote={handlePullWithSpecificRemote}
onPushWithRemote={handlePushWithSpecificRemote}
isSyncing={isSyncing}
onSync={handleSyncWithRemoteSelection}
onSyncWithRemote={handleSyncWithSpecificRemote}
onSetTracking={handleSetTrackingForRemote}
remotes={remotesCache[worktree.path]}
onOpenInEditor={handleOpenInEditor}
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}

View File

@@ -46,6 +46,8 @@ export {
useCommitWorktree,
usePushWorktree,
usePullWorktree,
useSyncWorktree,
useSetTracking,
useCreatePullRequest,
useMergeWorktree,
useSwitchBranch,

View File

@@ -197,6 +197,76 @@ export function usePullWorktree() {
});
}
/**
* Sync worktree branch (pull then push)
*
* @returns Mutation for syncing changes
*/
export function useSyncWorktree() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ worktreePath, remote }: { worktreePath: string; remote?: string }) => {
const api = getElectronAPI();
if (!api.worktree) throw new Error('Worktree API not available');
const result = await api.worktree.sync(worktreePath, remote);
if (!result.success) {
throw new Error(result.error || 'Failed to sync');
}
return result.result;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['worktrees'] });
toast.success('Branch synced with remote');
},
onError: (error: Error) => {
toast.error('Failed to sync', {
description: error.message,
});
},
});
}
/**
* Set upstream tracking branch
*
* @returns Mutation for setting tracking branch
*/
export function useSetTracking() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
worktreePath,
remote,
branch,
}: {
worktreePath: string;
remote: string;
branch?: string;
}) => {
const api = getElectronAPI();
if (!api.worktree) throw new Error('Worktree API not available');
const result = await api.worktree.setTracking(worktreePath, remote, branch);
if (!result.success) {
throw new Error(result.error || 'Failed to set tracking branch');
}
return result.result;
},
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: ['worktrees'] });
toast.success('Tracking branch set', {
description: result?.message,
});
},
onError: (error: Error) => {
toast.error('Failed to set tracking branch', {
description: error.message,
});
},
});
}
/**
* Create a pull request from a worktree
*

View File

@@ -2268,7 +2268,12 @@ function createMockWorktreeAPI(): WorktreeAPI {
};
},
push: async (worktreePath: string, force?: boolean, remote?: string) => {
push: async (
worktreePath: string,
force?: boolean,
remote?: string,
_autoResolve?: boolean
) => {
const targetRemote = remote || 'origin';
console.log('[Mock] Pushing worktree:', { worktreePath, force, remote: targetRemote });
return {
@@ -2281,6 +2286,38 @@ function createMockWorktreeAPI(): WorktreeAPI {
};
},
sync: async (worktreePath: string, remote?: string) => {
const targetRemote = remote || 'origin';
console.log('[Mock] Syncing worktree:', { worktreePath, remote: targetRemote });
return {
success: true,
result: {
branch: 'feature-branch',
pulled: true,
pushed: true,
message: `Synced with ${targetRemote}`,
},
};
},
setTracking: async (worktreePath: string, remote: string, branch?: string) => {
const targetBranch = branch || 'feature-branch';
console.log('[Mock] Setting tracking branch:', {
worktreePath,
remote,
branch: targetBranch,
});
return {
success: true,
result: {
branch: targetBranch,
remote,
upstream: `${remote}/${targetBranch}`,
message: `Set tracking branch to ${remote}/${targetBranch}`,
},
};
},
createPR: async (worktreePath: string, options?: CreatePROptions) => {
console.log('[Mock] Creating PR:', { worktreePath, options });
return {

View File

@@ -2208,8 +2208,12 @@ export class HttpApiClient implements ElectronAPI {
this.post('/api/worktree/generate-commit-message', { worktreePath }),
generatePRDescription: (worktreePath: string, baseBranch?: string) =>
this.post('/api/worktree/generate-pr-description', { worktreePath, baseBranch }),
push: (worktreePath: string, force?: boolean, remote?: string) =>
this.post('/api/worktree/push', { worktreePath, force, remote }),
push: (worktreePath: string, force?: boolean, remote?: string, autoResolve?: boolean) =>
this.post('/api/worktree/push', { worktreePath, force, remote, autoResolve }),
sync: (worktreePath: string, remote?: string) =>
this.post('/api/worktree/sync', { worktreePath, remote }),
setTracking: (worktreePath: string, remote: string, branch?: string) =>
this.post('/api/worktree/set-tracking', { worktreePath, remote, branch }),
createPR: (worktreePath: string, options?: CreatePROptions) =>
this.post('/api/worktree/create-pr', { worktreePath, ...options }),
getDiffs: (projectPath: string, featureId: string) =>

View File

@@ -980,18 +980,61 @@ export interface WorktreeAPI {
push: (
worktreePath: string,
force?: boolean,
remote?: string
remote?: string,
autoResolve?: boolean
) => Promise<{
success: boolean;
result?: {
branch: string;
pushed: boolean;
diverged?: boolean;
autoResolved?: boolean;
message: string;
};
error?: string;
diverged?: boolean;
hasConflicts?: boolean;
conflictFiles?: string[];
code?: 'NOT_GIT_REPO' | 'NO_COMMITS';
}>;
// Sync a worktree branch (pull then push)
sync: (
worktreePath: string,
remote?: string
) => Promise<{
success: boolean;
result?: {
branch: string;
pulled: boolean;
pushed: boolean;
isFastForward?: boolean;
isMerge?: boolean;
autoResolved?: boolean;
message: string;
};
error?: string;
hasConflicts?: boolean;
conflictFiles?: string[];
conflictSource?: 'pull' | 'stash';
}>;
// Set the upstream tracking branch
setTracking: (
worktreePath: string,
remote: string,
branch?: string
) => Promise<{
success: boolean;
result?: {
branch: string;
remote: string;
upstream: string;
message: string;
};
error?: string;
}>;
// Create a pull request from a worktree
createPR: (
worktreePath: string,