mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-16 21:53:07 +00:00
Feature: Git sync, set-tracking, and push divergence handling (#796)
This commit is contained in:
@@ -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'),
|
||||||
|
|||||||
@@ -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')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
76
apps/server/src/routes/worktree/routes/set-tracking.ts
Normal file
76
apps/server/src/routes/worktree/routes/set-tracking.ts
Normal 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) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
66
apps/server/src/routes/worktree/routes/sync.ts
Normal file
66
apps/server/src/routes/worktree/routes/sync.ts
Normal 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) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
258
apps/server/src/services/push-service.ts
Normal file
258
apps/server/src/services/push-service.ts
Normal 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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
209
apps/server/src/services/sync-service.ts
Normal file
209
apps/server/src/services/sync-service.ts
Normal 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.',
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ export {
|
|||||||
useCommitWorktree,
|
useCommitWorktree,
|
||||||
usePushWorktree,
|
usePushWorktree,
|
||||||
usePullWorktree,
|
usePullWorktree,
|
||||||
|
useSyncWorktree,
|
||||||
|
useSetTracking,
|
||||||
useCreatePullRequest,
|
useCreatePullRequest,
|
||||||
useMergeWorktree,
|
useMergeWorktree,
|
||||||
useSwitchBranch,
|
useSwitchBranch,
|
||||||
|
|||||||
@@ -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
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
45
apps/ui/src/types/electron.d.ts
vendored
45
apps/ui/src/types/electron.d.ts
vendored
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user