From 91bff21d58f5d0a5555b9a28b36ded27e46cd3ab Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 21 Feb 2026 18:54:16 -0800 Subject: [PATCH] Feature: Git sync, set-tracking, and push divergence handling (#796) --- apps/server/src/routes/worktree/index.ts | 14 + .../server/src/routes/worktree/routes/push.ts | 58 ++-- .../routes/worktree/routes/set-tracking.ts | 76 ++++++ .../server/src/routes/worktree/routes/sync.ts | 66 +++++ apps/server/src/services/push-service.ts | 258 ++++++++++++++++++ apps/server/src/services/sync-service.ts | 209 ++++++++++++++ .../components/worktree-actions-dropdown.tsx | 198 ++++++++++++-- .../components/worktree-dropdown.tsx | 16 ++ .../components/worktree-tab.tsx | 16 ++ .../hooks/use-worktree-actions.ts | 29 ++ .../worktree-panel/worktree-panel.tsx | 43 +++ apps/ui/src/hooks/mutations/index.ts | 2 + .../hooks/mutations/use-worktree-mutations.ts | 70 +++++ apps/ui/src/lib/electron.ts | 39 ++- apps/ui/src/lib/http-api-client.ts | 8 +- apps/ui/src/types/electron.d.ts | 45 ++- 16 files changed, 1095 insertions(+), 52 deletions(-) create mode 100644 apps/server/src/routes/worktree/routes/set-tracking.ts create mode 100644 apps/server/src/routes/worktree/routes/sync.ts create mode 100644 apps/server/src/services/push-service.ts create mode 100644 apps/server/src/services/sync-service.ts diff --git a/apps/server/src/routes/worktree/index.ts b/apps/server/src/routes/worktree/index.ts index a788bb48..492264cd 100644 --- a/apps/server/src/routes/worktree/index.ts +++ b/apps/server/src/routes/worktree/index.ts @@ -67,6 +67,8 @@ import { createAbortOperationHandler } from './routes/abort-operation.js'; import { createContinueOperationHandler } from './routes/continue-operation.js'; import { createStageFilesHandler } from './routes/stage-files.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'; export function createWorktreeRoutes( @@ -118,6 +120,18 @@ export function createWorktreeRoutes( requireValidWorktree, createPullHandler() ); + router.post( + '/sync', + validatePathParams('worktreePath'), + requireValidWorktree, + createSyncHandler() + ); + router.post( + '/set-tracking', + validatePathParams('worktreePath'), + requireValidWorktree, + createSetTrackingHandler() + ); router.post( '/checkout-branch', validatePathParams('worktreePath'), diff --git a/apps/server/src/routes/worktree/routes/push.ts b/apps/server/src/routes/worktree/routes/push.ts index 0e082b3f..0bf7bc3c 100644 --- a/apps/server/src/routes/worktree/routes/push.ts +++ b/apps/server/src/routes/worktree/routes/push.ts @@ -1,24 +1,24 @@ /** * 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 * the requireValidWorktree middleware in index.ts */ import type { Request, Response } from 'express'; -import { exec } from 'child_process'; -import { promisify } from 'util'; import { getErrorMessage, logError } from '../common.js'; - -const execAsync = promisify(exec); +import { performPush } from '../../../services/push-service.js'; export function createPushHandler() { return async (req: Request, res: Response): Promise => { try { - const { worktreePath, force, remote } = req.body as { + const { worktreePath, force, remote, autoResolve } = req.body as { worktreePath: string; force?: boolean; remote?: string; + autoResolve?: boolean; }; if (!worktreePath) { @@ -29,34 +29,28 @@ export function createPushHandler() { return; } - // Get branch name - const { stdout: branchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', { - cwd: worktreePath, - }); - const branchName = branchOutput.trim(); + const result = await performPush(worktreePath, { remote, force, autoResolve }); - // Use specified remote or default to 'origin' - const targetRemote = remote || 'origin'; - - // Push the branch - const forceFlag = force ? '--force' : ''; - try { - await execAsync(`git push -u ${targetRemote} ${branchName} ${forceFlag}`, { - cwd: worktreePath, - }); - } catch { - // Try setting upstream - await execAsync(`git push --set-upstream ${targetRemote} ${branchName} ${forceFlag}`, { - cwd: worktreePath, + if (!result.success) { + const statusCode = isClientError(result.error ?? '') ? 400 : 500; + res.status(statusCode).json({ + success: false, + error: result.error, + diverged: result.diverged, + hasConflicts: result.hasConflicts, + conflictFiles: result.conflictFiles, }); + return; } res.json({ success: true, result: { - branch: branchName, - pushed: true, - message: `Successfully pushed ${branchName} to ${targetRemote}`, + branch: result.branch, + pushed: result.pushed, + diverged: result.diverged, + autoResolved: result.autoResolved, + message: result.message, }, }); } 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') + ); +} diff --git a/apps/server/src/routes/worktree/routes/set-tracking.ts b/apps/server/src/routes/worktree/routes/set-tracking.ts new file mode 100644 index 00000000..9d63e013 --- /dev/null +++ b/apps/server/src/routes/worktree/routes/set-tracking.ts @@ -0,0 +1,76 @@ +/** + * POST /set-tracking endpoint - Set the upstream tracking branch for a worktree + * + * Sets `git branch --set-upstream-to=/` 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 => { + 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) }); + } + }; +} diff --git a/apps/server/src/routes/worktree/routes/sync.ts b/apps/server/src/routes/worktree/routes/sync.ts new file mode 100644 index 00000000..acd2ec3b --- /dev/null +++ b/apps/server/src/routes/worktree/routes/sync.ts @@ -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 => { + 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) }); + } + }; +} diff --git a/apps/server/src/services/push-service.ts b/apps/server/src/services/push-service.ts new file mode 100644 index 00000000..f1619f5b --- /dev/null +++ b/apps/server/src/services/push-service.ts @@ -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 ` 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 { + 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), + }; + } +} diff --git a/apps/server/src/services/sync-service.ts b/apps/server/src/services/sync-service.ts new file mode 100644 index 00000000..f47055c9 --- /dev/null +++ b/apps/server/src/services/sync-service.ts @@ -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 { + 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.', + }; +} diff --git a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx index e15a63af..0f6914db 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx @@ -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 ( + +
+ + + {remote.name} + {trackingRemote === remote.name && ( + tracking + )} + + +
+ + + + Set as Tracking Branch + + +
+ ); + } + + return ( + + + {remote.name} + + {remote.url} + + + ); } 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({ {remotes.map((remote) => ( - isGitOpsAvailable && onPullWithRemote(worktree, remote.name)} - disabled={isPulling || !isGitOpsAvailable} - className="text-xs" - > - - {remote.name} - - {remote.url} - - + remote={remote} + icon={Download} + trackingRemote={trackingRemote} + isDisabled={isPulling} + isGitOpsAvailable={isGitOpsAvailable} + onAction={() => isGitOpsAvailable && onPullWithRemote(worktree, remote.name)} + onSetTracking={ + onSetTracking + ? () => isGitOpsAvailable && onSetTracking(worktree, remote.name) + : undefined + } + /> ))} @@ -818,18 +904,20 @@ export function WorktreeActionsDropdown({ {remotes.map((remote) => ( - isGitOpsAvailable && onPushWithRemote(worktree, remote.name)} - disabled={isPushing || !isGitOpsAvailable} - className="text-xs" - > - - {remote.name} - - {remote.url} - - + remote={remote} + icon={Upload} + trackingRemote={trackingRemote} + isDisabled={isPushing} + isGitOpsAvailable={isGitOpsAvailable} + onAction={() => isGitOpsAvailable && onPushWithRemote(worktree, remote.name)} + onSetTracking={ + onSetTracking + ? () => isGitOpsAvailable && onSetTracking(worktree, remote.name) + : undefined + } + /> ))} @@ -876,6 +964,72 @@ export function WorktreeActionsDropdown({ )} + {onSync && ( + + {remotes && remotes.length > 1 && onSyncWithRemote ? ( + +
+ isGitOpsAvailable && onSync(worktree)} + disabled={isSyncing || !isGitOpsAvailable} + className={cn( + 'text-xs flex-1 pr-0 rounded-r-none', + !isGitOpsAvailable && 'opacity-50 cursor-not-allowed' + )} + > + + {isSyncing ? 'Syncing...' : 'Sync'} + {!isGitOpsAvailable && ( + + )} + + +
+ + + Sync with remote + + + {remotes.map((remote) => ( + isGitOpsAvailable && onSyncWithRemote(worktree, remote.name)} + disabled={isSyncing || !isGitOpsAvailable} + className="text-xs" + > + + {remote.name} + + {remote.url} + + + ))} + +
+ ) : ( + isGitOpsAvailable && onSync(worktree)} + disabled={isSyncing || !isGitOpsAvailable} + className={cn('text-xs', !isGitOpsAvailable && 'opacity-50 cursor-not-allowed')} + > + + {isSyncing ? 'Syncing...' : 'Sync'} + {!isGitOpsAvailable && ( + + )} + + )} +
+ )} isGitOpsAvailable && onResolveConflicts(worktree)} diff --git a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-dropdown.tsx b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-dropdown.tsx index a791ae63..dd8fadb3 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-dropdown.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-dropdown.tsx @@ -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} /> )} diff --git a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-tab.tsx b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-tab.tsx index b94ae959..7733d45b 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-tab.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-tab.tsx @@ -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} /> ); diff --git a/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-worktree-actions.ts b/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-worktree-actions.ts index afc7df78..9b36317a 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-worktree-actions.ts +++ b/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-worktree-actions.ts @@ -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, diff --git a/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx b/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx index a85ace57..9cc98fe1 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx @@ -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} diff --git a/apps/ui/src/hooks/mutations/index.ts b/apps/ui/src/hooks/mutations/index.ts index e0d591bf..50a537ad 100644 --- a/apps/ui/src/hooks/mutations/index.ts +++ b/apps/ui/src/hooks/mutations/index.ts @@ -46,6 +46,8 @@ export { useCommitWorktree, usePushWorktree, usePullWorktree, + useSyncWorktree, + useSetTracking, useCreatePullRequest, useMergeWorktree, useSwitchBranch, diff --git a/apps/ui/src/hooks/mutations/use-worktree-mutations.ts b/apps/ui/src/hooks/mutations/use-worktree-mutations.ts index 10dab0ac..467f6606 100644 --- a/apps/ui/src/hooks/mutations/use-worktree-mutations.ts +++ b/apps/ui/src/hooks/mutations/use-worktree-mutations.ts @@ -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 * diff --git a/apps/ui/src/lib/electron.ts b/apps/ui/src/lib/electron.ts index cecaf3bf..b93c2dca 100644 --- a/apps/ui/src/lib/electron.ts +++ b/apps/ui/src/lib/electron.ts @@ -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 { diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index 8e6ebb05..5be4b459 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -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) => diff --git a/apps/ui/src/types/electron.d.ts b/apps/ui/src/types/electron.d.ts index b2a34600..e29e4871 100644 --- a/apps/ui/src/types/electron.d.ts +++ b/apps/ui/src/types/electron.d.ts @@ -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,