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

@@ -67,6 +67,8 @@ import { createAbortOperationHandler } from './routes/abort-operation.js';
import { createContinueOperationHandler } from './routes/continue-operation.js'; import { createContinueOperationHandler } from './routes/continue-operation.js';
import { createStageFilesHandler } from './routes/stage-files.js'; import { createStageFilesHandler } from './routes/stage-files.js';
import { createCheckChangesHandler } from './routes/check-changes.js'; import { createCheckChangesHandler } from './routes/check-changes.js';
import { createSetTrackingHandler } from './routes/set-tracking.js';
import { createSyncHandler } from './routes/sync.js';
import type { SettingsService } from '../../services/settings-service.js'; import type { SettingsService } from '../../services/settings-service.js';
export function createWorktreeRoutes( export function createWorktreeRoutes(
@@ -118,6 +120,18 @@ export function createWorktreeRoutes(
requireValidWorktree, requireValidWorktree,
createPullHandler() createPullHandler()
); );
router.post(
'/sync',
validatePathParams('worktreePath'),
requireValidWorktree,
createSyncHandler()
);
router.post(
'/set-tracking',
validatePathParams('worktreePath'),
requireValidWorktree,
createSetTrackingHandler()
);
router.post( router.post(
'/checkout-branch', '/checkout-branch',
validatePathParams('worktreePath'), validatePathParams('worktreePath'),

View File

@@ -1,24 +1,24 @@
/** /**
* POST /push endpoint - Push a worktree branch to remote * POST /push endpoint - Push a worktree branch to remote
* *
* Git business logic is delegated to push-service.ts.
*
* Note: Git repository validation (isGitRepo, hasCommits) is handled by * Note: Git repository validation (isGitRepo, hasCommits) is handled by
* the requireValidWorktree middleware in index.ts * the requireValidWorktree middleware in index.ts
*/ */
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import { exec } from 'child_process';
import { promisify } from 'util';
import { getErrorMessage, logError } from '../common.js'; import { getErrorMessage, logError } from '../common.js';
import { performPush } from '../../../services/push-service.js';
const execAsync = promisify(exec);
export function createPushHandler() { export function createPushHandler() {
return async (req: Request, res: Response): Promise<void> => { return async (req: Request, res: Response): Promise<void> => {
try { try {
const { worktreePath, force, remote } = req.body as { const { worktreePath, force, remote, autoResolve } = req.body as {
worktreePath: string; worktreePath: string;
force?: boolean; force?: boolean;
remote?: string; remote?: string;
autoResolve?: boolean;
}; };
if (!worktreePath) { if (!worktreePath) {
@@ -29,34 +29,28 @@ export function createPushHandler() {
return; return;
} }
// Get branch name const result = await performPush(worktreePath, { remote, force, autoResolve });
const { stdout: branchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', {
cwd: worktreePath,
});
const branchName = branchOutput.trim();
// Use specified remote or default to 'origin' if (!result.success) {
const targetRemote = remote || 'origin'; const statusCode = isClientError(result.error ?? '') ? 400 : 500;
res.status(statusCode).json({
// Push the branch success: false,
const forceFlag = force ? '--force' : ''; error: result.error,
try { diverged: result.diverged,
await execAsync(`git push -u ${targetRemote} ${branchName} ${forceFlag}`, { hasConflicts: result.hasConflicts,
cwd: worktreePath, conflictFiles: result.conflictFiles,
});
} catch {
// Try setting upstream
await execAsync(`git push --set-upstream ${targetRemote} ${branchName} ${forceFlag}`, {
cwd: worktreePath,
}); });
return;
} }
res.json({ res.json({
success: true, success: true,
result: { result: {
branch: branchName, branch: result.branch,
pushed: true, pushed: result.pushed,
message: `Successfully pushed ${branchName} to ${targetRemote}`, diverged: result.diverged,
autoResolved: result.autoResolved,
message: result.message,
}, },
}); });
} catch (error) { } catch (error) {
@@ -65,3 +59,15 @@ export function createPushHandler() {
} }
}; };
} }
/**
* Determine whether an error message represents a client error (400)
* vs a server error (500).
*/
function isClientError(errorMessage: string): boolean {
return (
errorMessage.includes('detached HEAD') ||
errorMessage.includes('rejected') ||
errorMessage.includes('diverged')
);
}

View File

@@ -0,0 +1,76 @@
/**
* POST /set-tracking endpoint - Set the upstream tracking branch for a worktree
*
* Sets `git branch --set-upstream-to=<remote>/<branch>` for the current branch.
*
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
* the requireValidWorktree middleware in index.ts
*/
import type { Request, Response } from 'express';
import { execGitCommand } from '@automaker/git-utils';
import { getErrorMessage, logError } from '../common.js';
import { getCurrentBranch } from '../../../lib/git.js';
export function createSetTrackingHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath, remote, branch } = req.body as {
worktreePath: string;
remote: string;
branch?: string;
};
if (!worktreePath) {
res.status(400).json({ success: false, error: 'worktreePath required' });
return;
}
if (!remote) {
res.status(400).json({ success: false, error: 'remote required' });
return;
}
// Get current branch if not provided
let targetBranch = branch;
if (!targetBranch) {
try {
targetBranch = await getCurrentBranch(worktreePath);
} catch (err) {
res.status(400).json({
success: false,
error: `Failed to get current branch: ${getErrorMessage(err)}`,
});
return;
}
if (targetBranch === 'HEAD') {
res.status(400).json({
success: false,
error: 'Cannot set tracking in detached HEAD state.',
});
return;
}
}
// Set upstream tracking (pass local branch name as final arg to be explicit)
await execGitCommand(
['branch', '--set-upstream-to', `${remote}/${targetBranch}`, targetBranch],
worktreePath
);
res.json({
success: true,
result: {
branch: targetBranch,
remote,
upstream: `${remote}/${targetBranch}`,
message: `Set tracking branch to ${remote}/${targetBranch}`,
},
});
} catch (error) {
logError(error, 'Set tracking branch failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,66 @@
/**
* POST /sync endpoint - Pull then push a worktree branch
*
* Performs a full sync operation: pull latest from remote, then push
* local commits. Handles divergence automatically.
*
* Git business logic is delegated to sync-service.ts.
*
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
* the requireValidWorktree middleware in index.ts
*/
import type { Request, Response } from 'express';
import { getErrorMessage, logError } from '../common.js';
import { performSync } from '../../../services/sync-service.js';
export function createSyncHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath, remote } = req.body as {
worktreePath: string;
remote?: string;
};
if (!worktreePath) {
res.status(400).json({
success: false,
error: 'worktreePath required',
});
return;
}
const result = await performSync(worktreePath, { remote });
if (!result.success) {
const statusCode = result.hasConflicts ? 409 : 500;
res.status(statusCode).json({
success: false,
error: result.error,
hasConflicts: result.hasConflicts,
conflictFiles: result.conflictFiles,
conflictSource: result.conflictSource,
pulled: result.pulled,
pushed: result.pushed,
});
return;
}
res.json({
success: true,
result: {
branch: result.branch,
pulled: result.pulled,
pushed: result.pushed,
isFastForward: result.isFastForward,
isMerge: result.isMerge,
autoResolved: result.autoResolved,
message: result.message,
},
});
} catch (error) {
logError(error, 'Sync worktree failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,258 @@
/**
* PushService - Push git operations without HTTP
*
* Encapsulates the full git push workflow including:
* - Branch name and detached HEAD detection
* - Safe array-based command execution (no shell interpolation)
* - Divergent branch detection and auto-resolution via pull-then-retry
* - Structured result reporting
*
* Mirrors the pull-service.ts pattern for consistency.
*/
import { createLogger, getErrorMessage } from '@automaker/utils';
import { execGitCommand } from '@automaker/git-utils';
import { getCurrentBranch } from '../lib/git.js';
import { performPull } from './pull-service.js';
const logger = createLogger('PushService');
// ============================================================================
// Types
// ============================================================================
export interface PushOptions {
/** Remote name to push to (defaults to 'origin') */
remote?: string;
/** Force push */
force?: boolean;
/** When true and push is rejected due to divergence, pull then retry push */
autoResolve?: boolean;
}
export interface PushResult {
success: boolean;
error?: string;
branch?: string;
pushed?: boolean;
/** Whether the push was initially rejected because the branches diverged */
diverged?: boolean;
/** Whether divergence was automatically resolved via pull-then-retry */
autoResolved?: boolean;
/** Whether the auto-resolve pull resulted in merge conflicts */
hasConflicts?: boolean;
/** Files with merge conflicts (only when hasConflicts is true) */
conflictFiles?: string[];
message?: string;
}
// ============================================================================
// Helper Functions
// ============================================================================
/**
* Detect whether push error output indicates a diverged/non-fast-forward rejection.
*/
function isDivergenceError(errorOutput: string): boolean {
const lower = errorOutput.toLowerCase();
// Require specific divergence indicators rather than just 'rejected' alone,
// which could match pre-receive hook rejections or protected branch errors.
const hasNonFastForward = lower.includes('non-fast-forward');
const hasFetchFirst = lower.includes('fetch first');
const hasFailedToPush = lower.includes('failed to push some refs');
const hasRejected = lower.includes('rejected');
return hasNonFastForward || hasFetchFirst || (hasRejected && hasFailedToPush);
}
// ============================================================================
// Main Service Function
// ============================================================================
/**
* Perform a git push on the given worktree.
*
* The workflow:
* 1. Get current branch name (detect detached HEAD)
* 2. Attempt `git push <remote> <branch>` with safe array args
* 3. If push fails with divergence and autoResolve is true:
* a. Pull from the same remote (with stash support)
* b. If pull succeeds without conflicts, retry push
* 4. If push fails with "no upstream" error, retry with --set-upstream
* 5. Return structured result
*
* @param worktreePath - Path to the git worktree
* @param options - Push options (remote, force, autoResolve)
* @returns PushResult with detailed status information
*/
export async function performPush(
worktreePath: string,
options?: PushOptions
): Promise<PushResult> {
const targetRemote = options?.remote || 'origin';
const force = options?.force ?? false;
const autoResolve = options?.autoResolve ?? false;
// 1. Get current branch name
let branchName: string;
try {
branchName = await getCurrentBranch(worktreePath);
} catch (err) {
return {
success: false,
error: `Failed to get current branch: ${getErrorMessage(err)}`,
};
}
// 2. Check for detached HEAD state
if (branchName === 'HEAD') {
return {
success: false,
error: 'Cannot push in detached HEAD state. Please checkout a branch first.',
};
}
// 3. Build push args (no -u flag; upstream is set in the fallback path only when needed)
const pushArgs = ['push', targetRemote, branchName];
if (force) {
pushArgs.push('--force');
}
// 4. Attempt push
try {
await execGitCommand(pushArgs, worktreePath);
return {
success: true,
branch: branchName,
pushed: true,
message: `Successfully pushed ${branchName} to ${targetRemote}`,
};
} catch (pushError: unknown) {
const err = pushError as { stderr?: string; stdout?: string; message?: string };
const errorOutput = `${err.stderr || ''} ${err.stdout || ''} ${err.message || ''}`;
// 5. Check if the error is a divergence rejection
if (isDivergenceError(errorOutput)) {
if (!autoResolve) {
return {
success: false,
branch: branchName,
pushed: false,
diverged: true,
error: `Push rejected: remote has changes not present locally. Use sync or pull first, or enable auto-resolve.`,
message: `Push to ${targetRemote} was rejected because the remote branch has diverged.`,
};
}
// 6. Auto-resolve: pull then retry push
logger.info('Push rejected due to divergence, attempting auto-resolve via pull', {
worktreePath,
remote: targetRemote,
branch: branchName,
});
try {
const pullResult = await performPull(worktreePath, {
remote: targetRemote,
stashIfNeeded: true,
});
if (!pullResult.success) {
return {
success: false,
branch: branchName,
pushed: false,
diverged: true,
autoResolved: false,
error: `Auto-resolve failed during pull: ${pullResult.error}`,
};
}
if (pullResult.hasConflicts) {
return {
success: false,
branch: branchName,
pushed: false,
diverged: true,
autoResolved: false,
hasConflicts: true,
conflictFiles: pullResult.conflictFiles,
error:
'Auto-resolve pull resulted in merge conflicts. Resolve conflicts and push again.',
};
}
// 7. Retry push after successful pull
try {
await execGitCommand(pushArgs, worktreePath);
return {
success: true,
branch: branchName,
pushed: true,
diverged: true,
autoResolved: true,
message: `Push succeeded after auto-resolving divergence (pulled from ${targetRemote} first).`,
};
} catch (retryError: unknown) {
const retryErr = retryError as { stderr?: string; message?: string };
return {
success: false,
branch: branchName,
pushed: false,
diverged: true,
autoResolved: false,
error: `Push failed after auto-resolve pull: ${retryErr.stderr || retryErr.message || 'Unknown error'}`,
};
}
} catch (pullError) {
return {
success: false,
branch: branchName,
pushed: false,
diverged: true,
autoResolved: false,
error: `Auto-resolve pull failed: ${getErrorMessage(pullError)}`,
};
}
}
// 6b. Non-divergence error (e.g. no upstream configured) - retry with --set-upstream
const isNoUpstreamError =
errorOutput.toLowerCase().includes('no upstream') ||
errorOutput.toLowerCase().includes('has no upstream branch') ||
errorOutput.toLowerCase().includes('set-upstream');
if (isNoUpstreamError) {
try {
const setUpstreamArgs = ['push', '--set-upstream', targetRemote, branchName];
if (force) {
setUpstreamArgs.push('--force');
}
await execGitCommand(setUpstreamArgs, worktreePath);
return {
success: true,
branch: branchName,
pushed: true,
message: `Successfully pushed ${branchName} to ${targetRemote} (set upstream)`,
};
} catch (upstreamError: unknown) {
const upstreamErr = upstreamError as { stderr?: string; message?: string };
return {
success: false,
branch: branchName,
pushed: false,
error: upstreamErr.stderr || upstreamErr.message || getErrorMessage(pushError),
};
}
}
// 6c. Other push error - return as-is
return {
success: false,
branch: branchName,
pushed: false,
error: err.stderr || err.message || getErrorMessage(pushError),
};
}
}

View File

@@ -0,0 +1,209 @@
/**
* SyncService - Pull then push in a single operation
*
* Composes performPull() and performPush() to synchronize a branch
* with its remote. Always uses stashIfNeeded for the pull step.
* If push fails with divergence after pull, retries once.
*
* Follows the same pattern as pull-service.ts and push-service.ts.
*/
import { createLogger, getErrorMessage } from '@automaker/utils';
import { performPull } from './pull-service.js';
import { performPush } from './push-service.js';
import type { PullResult } from './pull-service.js';
import type { PushResult } from './push-service.js';
const logger = createLogger('SyncService');
// ============================================================================
// Types
// ============================================================================
export interface SyncOptions {
/** Remote name (defaults to 'origin') */
remote?: string;
}
export interface SyncResult {
success: boolean;
error?: string;
branch?: string;
/** Whether the pull step was performed */
pulled?: boolean;
/** Whether the push step was performed */
pushed?: boolean;
/** Pull resulted in conflicts */
hasConflicts?: boolean;
/** Files with merge conflicts */
conflictFiles?: string[];
/** Source of conflicts ('pull' | 'stash') */
conflictSource?: 'pull' | 'stash';
/** Whether the pull was a fast-forward */
isFastForward?: boolean;
/** Whether the pull resulted in a merge commit */
isMerge?: boolean;
/** Whether push divergence was auto-resolved */
autoResolved?: boolean;
message?: string;
}
// ============================================================================
// Main Service Function
// ============================================================================
/**
* Perform a sync operation (pull then push) on the given worktree.
*
* The workflow:
* 1. Pull from remote with stashIfNeeded: true
* 2. If pull has conflicts, stop and return conflict info
* 3. Push to remote
* 4. If push fails with divergence after pull, retry once
*
* @param worktreePath - Path to the git worktree
* @param options - Sync options (remote)
* @returns SyncResult with detailed status information
*/
export async function performSync(
worktreePath: string,
options?: SyncOptions
): Promise<SyncResult> {
const targetRemote = options?.remote || 'origin';
// 1. Pull from remote
logger.info('Sync: starting pull', { worktreePath, remote: targetRemote });
let pullResult: PullResult;
try {
pullResult = await performPull(worktreePath, {
remote: targetRemote,
stashIfNeeded: true,
});
} catch (pullError) {
return {
success: false,
error: `Sync pull failed: ${getErrorMessage(pullError)}`,
};
}
if (!pullResult.success) {
return {
success: false,
branch: pullResult.branch,
pulled: false,
pushed: false,
error: `Sync pull failed: ${pullResult.error}`,
hasConflicts: pullResult.hasConflicts,
conflictFiles: pullResult.conflictFiles,
conflictSource: pullResult.conflictSource,
};
}
// 2. If pull had conflicts, stop and return conflict info
if (pullResult.hasConflicts) {
return {
success: false,
branch: pullResult.branch,
pulled: true,
pushed: false,
hasConflicts: true,
conflictFiles: pullResult.conflictFiles,
conflictSource: pullResult.conflictSource,
isFastForward: pullResult.isFastForward,
isMerge: pullResult.isMerge,
error: 'Sync stopped: pull resulted in merge conflicts. Resolve conflicts and try again.',
message: pullResult.message,
};
}
// 3. Push to remote
logger.info('Sync: pull succeeded, starting push', { worktreePath, remote: targetRemote });
let pushResult: PushResult;
try {
pushResult = await performPush(worktreePath, {
remote: targetRemote,
});
} catch (pushError) {
return {
success: false,
branch: pullResult.branch,
pulled: true,
pushed: false,
isFastForward: pullResult.isFastForward,
isMerge: pullResult.isMerge,
error: `Sync push failed: ${getErrorMessage(pushError)}`,
};
}
if (!pushResult.success) {
// 4. If push diverged after pull, retry once with autoResolve
if (pushResult.diverged) {
logger.info('Sync: push diverged after pull, retrying with autoResolve', {
worktreePath,
remote: targetRemote,
});
try {
const retryResult = await performPush(worktreePath, {
remote: targetRemote,
autoResolve: true,
});
if (retryResult.success) {
return {
success: true,
branch: retryResult.branch,
pulled: true,
pushed: true,
autoResolved: true,
isFastForward: pullResult.isFastForward,
isMerge: pullResult.isMerge,
message: 'Sync completed (push required auto-resolve).',
};
}
return {
success: false,
branch: retryResult.branch,
pulled: true,
pushed: false,
hasConflicts: retryResult.hasConflicts,
conflictFiles: retryResult.conflictFiles,
error: retryResult.error,
};
} catch (retryError) {
return {
success: false,
branch: pullResult.branch,
pulled: true,
pushed: false,
error: `Sync push retry failed: ${getErrorMessage(retryError)}`,
};
}
}
return {
success: false,
branch: pushResult.branch,
pulled: true,
pushed: false,
isFastForward: pullResult.isFastForward,
isMerge: pullResult.isMerge,
error: `Sync push failed: ${pushResult.error}`,
};
}
return {
success: true,
branch: pushResult.branch,
pulled: pullResult.pulled ?? true,
pushed: true,
isFastForward: pullResult.isFastForward,
isMerge: pullResult.isMerge,
message: pullResult.pulled
? 'Sync completed: pulled latest changes and pushed.'
: 'Sync completed: already up to date, pushed local commits.',
};
}

View File

@@ -14,6 +14,7 @@ import {
import { import {
Trash2, Trash2,
MoreHorizontal, MoreHorizontal,
GitBranch,
GitCommit, GitCommit,
GitPullRequest, GitPullRequest,
Download, Download,
@@ -138,6 +139,85 @@ interface WorktreeActionsDropdownProps {
onRunTerminalScript?: (worktree: WorktreeInfo, command: string) => void; onRunTerminalScript?: (worktree: WorktreeInfo, command: string) => void;
/** Callback to open the script editor UI */ /** Callback to open the script editor UI */
onEditScripts?: () => void; 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({ export function WorktreeActionsDropdown({
@@ -198,6 +278,10 @@ export function WorktreeActionsDropdown({
terminalScripts, terminalScripts,
onRunTerminalScript, onRunTerminalScript,
onEditScripts, onEditScripts,
isSyncing = false,
onSync,
onSyncWithRemote,
onSetTracking,
}: WorktreeActionsDropdownProps) { }: WorktreeActionsDropdownProps) {
// Get available editors for the "Open In" submenu // Get available editors for the "Open In" submenu
const { editors } = useAvailableEditors(); const { editors } = useAvailableEditors();
@@ -719,18 +803,20 @@ export function WorktreeActionsDropdown({
</DropdownMenuLabel> </DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
{remotes.map((remote) => ( {remotes.map((remote) => (
<DropdownMenuItem <RemoteActionMenuItem
key={remote.name} key={remote.name}
onClick={() => isGitOpsAvailable && onPullWithRemote(worktree, remote.name)} remote={remote}
disabled={isPulling || !isGitOpsAvailable} icon={Download}
className="text-xs" trackingRemote={trackingRemote}
> isDisabled={isPulling}
<Download className="w-3.5 h-3.5 mr-2" /> isGitOpsAvailable={isGitOpsAvailable}
{remote.name} onAction={() => isGitOpsAvailable && onPullWithRemote(worktree, remote.name)}
<span className="ml-auto text-[10px] text-muted-foreground max-w-[100px] truncate"> onSetTracking={
{remote.url} onSetTracking
</span> ? () => isGitOpsAvailable && onSetTracking(worktree, remote.name)
</DropdownMenuItem> : undefined
}
/>
))} ))}
</DropdownMenuSubContent> </DropdownMenuSubContent>
</DropdownMenuSub> </DropdownMenuSub>
@@ -818,18 +904,20 @@ export function WorktreeActionsDropdown({
</DropdownMenuLabel> </DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
{remotes.map((remote) => ( {remotes.map((remote) => (
<DropdownMenuItem <RemoteActionMenuItem
key={remote.name} key={remote.name}
onClick={() => isGitOpsAvailable && onPushWithRemote(worktree, remote.name)} remote={remote}
disabled={isPushing || !isGitOpsAvailable} icon={Upload}
className="text-xs" trackingRemote={trackingRemote}
> isDisabled={isPushing}
<Upload className="w-3.5 h-3.5 mr-2" /> isGitOpsAvailable={isGitOpsAvailable}
{remote.name} onAction={() => isGitOpsAvailable && onPushWithRemote(worktree, remote.name)}
<span className="ml-auto text-[10px] text-muted-foreground max-w-[100px] truncate"> onSetTracking={
{remote.url} onSetTracking
</span> ? () => isGitOpsAvailable && onSetTracking(worktree, remote.name)
</DropdownMenuItem> : undefined
}
/>
))} ))}
</DropdownMenuSubContent> </DropdownMenuSubContent>
</DropdownMenuSub> </DropdownMenuSub>
@@ -876,6 +964,72 @@ export function WorktreeActionsDropdown({
</DropdownMenuItem> </DropdownMenuItem>
)} )}
</TooltipWrapper> </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}> <TooltipWrapper showTooltip={!!gitOpsDisabledReason} tooltipContent={gitOpsDisabledReason}>
<DropdownMenuItem <DropdownMenuItem
onClick={() => isGitOpsAvailable && onResolveConflicts(worktree)} onClick={() => isGitOpsAvailable && onResolveConflicts(worktree)}

View File

@@ -138,6 +138,14 @@ export interface WorktreeDropdownProps {
onRunTerminalScript?: (worktree: WorktreeInfo, command: string) => void; onRunTerminalScript?: (worktree: WorktreeInfo, command: string) => void;
/** Callback to open the script editor UI */ /** Callback to open the script editor UI */
onEditScripts?: () => void; 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, terminalScripts,
onRunTerminalScript, onRunTerminalScript,
onEditScripts, onEditScripts,
isSyncing = false,
onSync,
onSyncWithRemote,
onSetTracking,
}: WorktreeDropdownProps) { }: WorktreeDropdownProps) {
// Find the currently selected worktree to display in the trigger // Find the currently selected worktree to display in the trigger
const selectedWorktree = worktrees.find((w) => isWorktreeSelected(w)); const selectedWorktree = worktrees.find((w) => isWorktreeSelected(w));
@@ -549,6 +561,10 @@ export function WorktreeDropdown({
terminalScripts={terminalScripts} terminalScripts={terminalScripts}
onRunTerminalScript={onRunTerminalScript} onRunTerminalScript={onRunTerminalScript}
onEditScripts={onEditScripts} onEditScripts={onEditScripts}
isSyncing={isSyncing}
onSync={onSync}
onSyncWithRemote={onSyncWithRemote}
onSetTracking={onSetTracking}
/> />
)} )}
</div> </div>

View File

@@ -108,6 +108,14 @@ interface WorktreeTabProps {
onRunTerminalScript?: (worktree: WorktreeInfo, command: string) => void; onRunTerminalScript?: (worktree: WorktreeInfo, command: string) => void;
/** Callback to open the script editor UI */ /** Callback to open the script editor UI */
onEditScripts?: () => void; 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({ export function WorktreeTab({
@@ -181,6 +189,10 @@ export function WorktreeTab({
terminalScripts, terminalScripts,
onRunTerminalScript, onRunTerminalScript,
onEditScripts, onEditScripts,
isSyncing = false,
onSync,
onSyncWithRemote,
onSetTracking,
}: WorktreeTabProps) { }: WorktreeTabProps) {
// Make the worktree tab a drop target for feature cards // Make the worktree tab a drop target for feature cards
const { setNodeRef, isOver } = useDroppable({ const { setNodeRef, isOver } = useDroppable({
@@ -550,6 +562,10 @@ export function WorktreeTab({
terminalScripts={terminalScripts} terminalScripts={terminalScripts}
onRunTerminalScript={onRunTerminalScript} onRunTerminalScript={onRunTerminalScript}
onEditScripts={onEditScripts} onEditScripts={onEditScripts}
isSyncing={isSyncing}
onSync={onSync}
onSyncWithRemote={onSyncWithRemote}
onSetTracking={onSetTracking}
/> />
</div> </div>
); );

View File

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

View File

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

View File

@@ -46,6 +46,8 @@ export {
useCommitWorktree, useCommitWorktree,
usePushWorktree, usePushWorktree,
usePullWorktree, usePullWorktree,
useSyncWorktree,
useSetTracking,
useCreatePullRequest, useCreatePullRequest,
useMergeWorktree, useMergeWorktree,
useSwitchBranch, 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 * 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'; const targetRemote = remote || 'origin';
console.log('[Mock] Pushing worktree:', { worktreePath, force, remote: targetRemote }); console.log('[Mock] Pushing worktree:', { worktreePath, force, remote: targetRemote });
return { 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) => { createPR: async (worktreePath: string, options?: CreatePROptions) => {
console.log('[Mock] Creating PR:', { worktreePath, options }); console.log('[Mock] Creating PR:', { worktreePath, options });
return { return {

View File

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

View File

@@ -980,18 +980,61 @@ export interface WorktreeAPI {
push: ( push: (
worktreePath: string, worktreePath: string,
force?: boolean, force?: boolean,
remote?: string remote?: string,
autoResolve?: boolean
) => Promise<{ ) => Promise<{
success: boolean; success: boolean;
result?: { result?: {
branch: string; branch: string;
pushed: boolean; pushed: boolean;
diverged?: boolean;
autoResolved?: boolean;
message: string; message: string;
}; };
error?: string; error?: string;
diverged?: boolean;
hasConflicts?: boolean;
conflictFiles?: string[];
code?: 'NOT_GIT_REPO' | 'NO_COMMITS'; 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 // Create a pull request from a worktree
createPR: ( createPR: (
worktreePath: string, worktreePath: string,