Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions apps/server/src/routes/worktree/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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'),
Expand Down
58 changes: 32 additions & 26 deletions apps/server/src/routes/worktree/routes/push.ts
Original file line number Diff line number Diff line change
@@ -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<void> => {
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) {
Expand All @@ -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();

// Use specified remote or default to 'origin'
const targetRemote = remote || 'origin';
const result = await performPush(worktreePath, { remote, force, autoResolve });

// 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) {
Expand All @@ -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 changes: 76 additions & 0 deletions apps/server/src/routes/worktree/routes/set-tracking.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 66 additions & 0 deletions apps/server/src/routes/worktree/routes/sync.ts
Original file line number Diff line number Diff line change
@@ -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) });
}
};
}
Loading