feat: Add GPT-5 model variants and improve Codex execution logic. Addressed code review comments

This commit is contained in:
gsxdsm
2026-02-18 11:15:38 -08:00
parent d30296d559
commit 5c441f2313
64 changed files with 3628 additions and 2223 deletions

View File

@@ -3,57 +3,17 @@
*/
import { createLogger } from '@automaker/utils';
import { spawnProcess } from '@automaker/platform';
import { exec } from 'child_process';
import { promisify } from 'util';
import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';
// Re-export execGitCommand from the canonical shared module so any remaining
// consumers that import from this file continue to work.
export { execGitCommand } from '../../lib/git.js';
const logger = createLogger('Worktree');
export const execAsync = promisify(exec);
// ============================================================================
// Secure Command Execution
// ============================================================================
/**
* Execute git command with array arguments to prevent command injection.
* Uses spawnProcess from @automaker/platform for secure, cross-platform execution.
*
* @param args - Array of git command arguments (e.g., ['worktree', 'add', path])
* @param cwd - Working directory to execute the command in
* @returns Promise resolving to stdout output
* @throws Error with stderr/stdout message if command fails. The thrown error
* also has `stdout` and `stderr` string properties for structured access.
*
* @example
* ```typescript
* // Safe: no injection possible
* await execGitCommand(['branch', '-D', branchName], projectPath);
*
* // Instead of unsafe:
* // await execAsync(`git branch -D ${branchName}`, { cwd });
* ```
*/
export async function execGitCommand(args: string[], cwd: string): Promise<string> {
const result = await spawnProcess({
command: 'git',
args,
cwd,
});
// spawnProcess returns { stdout, stderr, exitCode }
if (result.exitCode === 0) {
return result.stdout;
} else {
const errorMessage =
result.stderr || result.stdout || `Git command failed with code ${result.exitCode}`;
throw Object.assign(new Error(errorMessage), {
stdout: result.stdout,
stderr: result.stderr,
});
}
}
// ============================================================================
// Constants
// ============================================================================
@@ -111,9 +71,12 @@ export const execEnv = {
* Validate branch name to prevent command injection.
* Git branch names cannot contain: space, ~, ^, :, ?, *, [, \, or control chars.
* We also reject shell metacharacters for safety.
* The first character must not be '-' to prevent git argument injection.
*/
export function isValidBranchName(name: string): boolean {
return /^[a-zA-Z0-9._\-/]+$/.test(name) && name.length < MAX_BRANCH_NAME_LENGTH;
// First char must be alphanumeric, dot, underscore, or slash (not dash)
// to prevent git option injection via names like "-flag" or "--option".
return /^[a-zA-Z0-9._/][a-zA-Z0-9._\-/]*$/.test(name) && name.length < MAX_BRANCH_NAME_LENGTH;
}
/**

View File

@@ -126,7 +126,7 @@ export function createWorktreeRoutes(
requireValidWorktree,
createListBranchesHandler()
);
router.post('/switch-branch', requireValidWorktree, createSwitchBranchHandler());
router.post('/switch-branch', requireValidWorktree, createSwitchBranchHandler(events));
router.post('/open-in-editor', validatePathParams('worktreePath'), createOpenInEditorHandler());
router.post(
'/open-in-terminal',
@@ -210,7 +210,7 @@ export function createWorktreeRoutes(
'/commit-log',
validatePathParams('worktreePath'),
requireValidWorktree,
createCommitLogHandler()
createCommitLogHandler(events)
);
// Stash routes
@@ -218,13 +218,13 @@ export function createWorktreeRoutes(
'/stash-push',
validatePathParams('worktreePath'),
requireGitRepoOnly,
createStashPushHandler()
createStashPushHandler(events)
);
router.post(
'/stash-list',
validatePathParams('worktreePath'),
requireGitRepoOnly,
createStashListHandler()
createStashListHandler(events)
);
router.post(
'/stash-apply',
@@ -236,7 +236,7 @@ export function createWorktreeRoutes(
'/stash-drop',
validatePathParams('worktreePath'),
requireGitRepoOnly,
createStashDropHandler()
createStashDropHandler(events)
);
// Cherry-pick route

View File

@@ -10,7 +10,8 @@
import type { Request, Response } from 'express';
import path from 'path';
import { stat } from 'fs/promises';
import { getErrorMessage, logError, isValidBranchName, execGitCommand } from '../common.js';
import { getErrorMessage, logError, isValidBranchName } from '../common.js';
import { execGitCommand } from '../../../lib/git.js';
export function createCheckoutBranchHandler() {
return async (req: Request, res: Response): Promise<void> => {

View File

@@ -6,6 +6,9 @@
*
* Git business logic is delegated to cherry-pick-service.ts.
* Events are emitted at key lifecycle points for WebSocket subscribers.
* The global event emitter is passed into the service so all lifecycle
* events (started, success, conflict, abort, verify-failed) are broadcast
* to WebSocket clients.
*
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
* the requireValidWorktree middleware in index.ts
@@ -58,8 +61,8 @@ export function createCherryPickHandler(events: EventEmitter) {
}
}
// Verify each commit exists via the service
const invalidHash = await verifyCommits(resolvedWorktreePath, commitHashes);
// Verify each commit exists via the service; emits cherry-pick:verify-failed if any hash is missing
const invalidHash = await verifyCommits(resolvedWorktreePath, commitHashes, events);
if (invalidHash !== null) {
res.status(400).json({
success: false,
@@ -68,24 +71,12 @@ export function createCherryPickHandler(events: EventEmitter) {
return;
}
// Emit started event
events.emit('cherry-pick:started', {
worktreePath: resolvedWorktreePath,
commitHashes,
options,
});
// Execute the cherry-pick via the service
const result = await runCherryPick(resolvedWorktreePath, commitHashes, options);
// Execute the cherry-pick via the service.
// The service emits: cherry-pick:started, cherry-pick:success, cherry-pick:conflict,
// and cherry-pick:abort at the appropriate lifecycle points.
const result = await runCherryPick(resolvedWorktreePath, commitHashes, options, events);
if (result.success) {
// Emit success event
events.emit('cherry-pick:success', {
worktreePath: resolvedWorktreePath,
commitHashes,
branch: result.branch,
});
res.json({
success: true,
result: {
@@ -96,13 +87,6 @@ export function createCherryPickHandler(events: EventEmitter) {
},
});
} else if (result.hasConflicts) {
// Emit conflict event
events.emit('cherry-pick:conflict', {
worktreePath: resolvedWorktreePath,
commitHashes,
aborted: result.aborted,
});
res.status(409).json({
success: false,
error: result.error,
@@ -111,7 +95,7 @@ export function createCherryPickHandler(events: EventEmitter) {
});
}
} catch (error) {
// Emit failure event
// Emit failure event for unexpected (non-conflict) errors
events.emit('cherry-pick:failure', {
error: getErrorMessage(error),
});

View File

@@ -1,29 +1,22 @@
/**
* POST /commit-log endpoint - Get recent commit history for a worktree
*
* Uses the same robust parsing approach as branch-commit-log-service:
* a single `git log --name-only` call with custom separators to fetch
* both commit metadata and file lists, avoiding N+1 git invocations.
* The handler only validates input, invokes the service, streams lifecycle
* events via the EventEmitter, and sends the final JSON response.
*
* Git business logic is delegated to commit-log-service.ts.
* Events are emitted at key lifecycle points for WebSocket subscribers.
*
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
* the requireValidWorktree middleware in index.ts
*/
import type { Request, Response } from 'express';
import { execGitCommand, getErrorMessage, logError } from '../common.js';
import type { EventEmitter } from '../../../lib/events.js';
import { getErrorMessage, logError } from '../common.js';
import { getCommitLog } from '../../../services/commit-log-service.js';
interface CommitResult {
hash: string;
shortHash: string;
author: string;
authorEmail: string;
date: string;
subject: string;
body: string;
files: string[];
}
export function createCommitLogHandler() {
export function createCommitLogHandler(events: EventEmitter) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath, limit = 20 } = req.body as {
@@ -39,112 +32,39 @@ export function createCommitLogHandler() {
return;
}
// Clamp limit to a reasonable range
const commitLimit = Math.min(Math.max(1, Number(limit) || 20), 100);
// Emit start event so the frontend can observe progress
events.emit('commitLog:start', {
worktreePath,
limit,
});
// Use custom separators to parse both metadata and file lists from
// a single git log invocation (same approach as branch-commit-log-service).
//
// -m causes merge commits to be diffed against each parent so all
// files touched by the merge are listed (without -m, --name-only
// produces no file output for merge commits because they have 2+ parents).
// This means merge commits appear multiple times in the output (once per
// parent), so we deduplicate by hash below and merge their file lists.
// We over-fetch (2x the limit) to compensate for -m duplicating merge
// commit entries, then trim the result to the requested limit.
const COMMIT_SEP = '---COMMIT---';
const META_END = '---META_END---';
const fetchLimit = commitLimit * 2;
// Delegate all Git work to the service
const result = await getCommitLog(worktreePath, limit);
const logOutput = await execGitCommand(
[
'log',
`--max-count=${fetchLimit}`,
'-m',
'--name-only',
`--format=${COMMIT_SEP}%n%H%n%h%n%an%n%ae%n%aI%n%s%n%b${META_END}`,
],
worktreePath
);
// Emit progress with the number of commits fetched
events.emit('commitLog:progress', {
worktreePath,
branch: result.branch,
commitsLoaded: result.total,
});
// Split output into per-commit blocks and drop the empty first chunk
// (the output starts with ---COMMIT---).
const commitBlocks = logOutput.split(COMMIT_SEP).filter((block) => block.trim());
// Use a Map to deduplicate merge commit entries (which appear once per
// parent when -m is used) while preserving insertion order.
const commitMap = new Map<string, CommitResult>();
for (const block of commitBlocks) {
const metaEndIdx = block.indexOf(META_END);
if (metaEndIdx === -1) continue; // malformed block, skip
// --- Parse metadata (everything before ---META_END---) ---
const metaRaw = block.substring(0, metaEndIdx);
const metaLines = metaRaw.split('\n');
// The first line may be empty (newline right after COMMIT_SEP), skip it
const nonEmptyStart = metaLines.findIndex((l) => l.trim() !== '');
if (nonEmptyStart === -1) continue;
const fields = metaLines.slice(nonEmptyStart);
if (fields.length < 6) continue; // need at least hash..subject
const hash = fields[0].trim();
const shortHash = fields[1].trim();
const author = fields[2].trim();
const authorEmail = fields[3].trim();
const date = fields[4].trim();
const subject = fields[5].trim();
const body = fields.slice(6).join('\n').trim();
// --- Parse file list (everything after ---META_END---) ---
const filesRaw = block.substring(metaEndIdx + META_END.length);
const blockFiles = filesRaw
.trim()
.split('\n')
.filter((f) => f.trim());
// Merge file lists for duplicate entries (merge commits with -m)
const existing = commitMap.get(hash);
if (existing) {
// Add new files to the existing entry's file set
const fileSet = new Set(existing.files);
for (const f of blockFiles) fileSet.add(f);
existing.files = [...fileSet];
} else {
commitMap.set(hash, {
hash,
shortHash,
author,
authorEmail,
date,
subject,
body,
files: [...new Set(blockFiles)],
});
}
}
// Trim to the requested limit (we over-fetched to account for -m duplicates)
const commits = [...commitMap.values()].slice(0, commitLimit);
// Get current branch name
const branchOutput = await execGitCommand(
['rev-parse', '--abbrev-ref', 'HEAD'],
worktreePath
);
const branch = branchOutput.trim();
// Emit complete event
events.emit('commitLog:complete', {
worktreePath,
branch: result.branch,
total: result.total,
});
res.json({
success: true,
result: {
branch,
commits,
total: commits.length,
},
result,
});
} catch (error) {
// Emit error event so the frontend can react
events.emit('commitLog:error', {
error: getErrorMessage(error),
});
logError(error, 'Get commit log failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}

View File

@@ -8,11 +8,11 @@ import {
logError,
execAsync,
execEnv,
execGitCommand,
isValidBranchName,
isValidRemoteName,
isGhCliAvailable,
} from '../common.js';
import { execGitCommand } from '../../../lib/git.js';
import { spawnProcess } from '@automaker/platform';
import { updateWorktreePRInfo } from '../../../lib/worktree-metadata.js';
import { createLogger } from '@automaker/utils';

View File

@@ -22,8 +22,8 @@ import {
normalizePath,
ensureInitialCommit,
isValidBranchName,
execGitCommand,
} from '../common.js';
import { execGitCommand } from '../../../lib/git.js';
import { trackBranch } from './branch-tracking.js';
import { createLogger } from '@automaker/utils';
import { runInitScript } from '../../../services/init-script-service.js';

View File

@@ -6,7 +6,8 @@ import type { Request, Response } from 'express';
import { exec } from 'child_process';
import { promisify } from 'util';
import { isGitRepo } from '@automaker/git-utils';
import { getErrorMessage, logError, isValidBranchName, execGitCommand } from '../common.js';
import { getErrorMessage, logError, isValidBranchName } from '../common.js';
import { execGitCommand } from '../../../lib/git.js';
import { createLogger } from '@automaker/utils';
const execAsync = promisify(exec);

View File

@@ -17,13 +17,10 @@
*/
import type { Request, Response } from 'express';
import { execFile } from 'child_process';
import { promisify } from 'util';
import * as path from 'path';
import * as fs from 'fs';
import { getErrorMessage, logError } from '../common.js';
const execFileAsync = promisify(execFile);
import { getErrorMessage, logError } from '@automaker/utils';
import { execGitCommand } from '../../../lib/git.js';
/**
* Validate that a file path does not escape the worktree directory.
@@ -72,9 +69,7 @@ export function createDiscardChangesHandler() {
}
// Check for uncommitted changes first
const { stdout: status } = await execFileAsync('git', ['status', '--porcelain'], {
cwd: worktreePath,
});
const status = await execGitCommand(['status', '--porcelain'], worktreePath);
if (!status.trim()) {
res.json({
@@ -88,12 +83,9 @@ export function createDiscardChangesHandler() {
}
// Get branch name before discarding
const { stdout: branchOutput } = await execFileAsync(
'git',
const branchOutput = await execGitCommand(
['rev-parse', '--abbrev-ref', 'HEAD'],
{
cwd: worktreePath,
}
worktreePath
);
const branchName = branchOutput.trim();
@@ -162,9 +154,7 @@ export function createDiscardChangesHandler() {
// 1. Unstage selected staged files (using execFile to bypass shell)
if (stagedFiles.length > 0) {
try {
await execFileAsync('git', ['reset', 'HEAD', '--', ...stagedFiles], {
cwd: worktreePath,
});
await execGitCommand(['reset', 'HEAD', '--', ...stagedFiles], worktreePath);
} catch (error) {
const msg = getErrorMessage(error);
logError(error, `Failed to unstage files: ${msg}`);
@@ -175,9 +165,7 @@ export function createDiscardChangesHandler() {
// 2. Revert selected tracked file changes
if (trackedModified.length > 0) {
try {
await execFileAsync('git', ['checkout', '--', ...trackedModified], {
cwd: worktreePath,
});
await execGitCommand(['checkout', '--', ...trackedModified], worktreePath);
} catch (error) {
const msg = getErrorMessage(error);
logError(error, `Failed to revert tracked files: ${msg}`);
@@ -188,9 +176,7 @@ export function createDiscardChangesHandler() {
// 3. Remove selected untracked files
if (untrackedFiles.length > 0) {
try {
await execFileAsync('git', ['clean', '-fd', '--', ...untrackedFiles], {
cwd: worktreePath,
});
await execGitCommand(['clean', '-fd', '--', ...untrackedFiles], worktreePath);
} catch (error) {
const msg = getErrorMessage(error);
logError(error, `Failed to clean untracked files: ${msg}`);
@@ -201,9 +187,7 @@ export function createDiscardChangesHandler() {
const fileCount = files.length;
// Verify the remaining state
const { stdout: finalStatus } = await execFileAsync('git', ['status', '--porcelain'], {
cwd: worktreePath,
});
const finalStatus = await execGitCommand(['status', '--porcelain'], worktreePath);
const remainingCount = finalStatus.trim()
? finalStatus.trim().split('\n').filter(Boolean).length
@@ -233,7 +217,7 @@ export function createDiscardChangesHandler() {
// 1. Reset any staged changes
try {
await execFileAsync('git', ['reset', 'HEAD'], { cwd: worktreePath });
await execGitCommand(['reset', 'HEAD'], worktreePath);
} catch (error) {
const msg = getErrorMessage(error);
logError(error, `git reset HEAD failed: ${msg}`);
@@ -242,7 +226,7 @@ export function createDiscardChangesHandler() {
// 2. Discard changes in tracked files
try {
await execFileAsync('git', ['checkout', '.'], { cwd: worktreePath });
await execGitCommand(['checkout', '.'], worktreePath);
} catch (error) {
const msg = getErrorMessage(error);
logError(error, `git checkout . failed: ${msg}`);
@@ -251,7 +235,7 @@ export function createDiscardChangesHandler() {
// 3. Remove untracked files and directories
try {
await execFileAsync('git', ['clean', '-fd'], { cwd: worktreePath });
await execGitCommand(['clean', '-fd'], worktreePath);
} catch (error) {
const msg = getErrorMessage(error);
logError(error, `git clean -fd failed: ${msg}`);
@@ -259,9 +243,7 @@ export function createDiscardChangesHandler() {
}
// Verify all changes were discarded
const { stdout: finalStatus } = await execFileAsync('git', ['status', '--porcelain'], {
cwd: worktreePath,
});
const finalStatus = await execGitCommand(['status', '--porcelain'], worktreePath);
if (finalStatus.trim()) {
const remainingCount = finalStatus.trim().split('\n').filter(Boolean).length;

View File

@@ -8,13 +8,8 @@
*/
import type { Request, Response } from 'express';
import { exec } from 'child_process';
import { promisify } from 'util';
import { getErrorMessage, logError, isValidBranchName, execGitCommand } from '../common.js';
import { createLogger } from '@automaker/utils';
const execAsync = promisify(exec);
const logger = createLogger('Worktree');
import { getErrorMessage, logError } from '../common.js';
import { performMerge } from '../../../services/merge-service.js';
export function createMergeHandler() {
return async (req: Request, res: Response): Promise<void> => {
@@ -38,118 +33,34 @@ export function createMergeHandler() {
// Determine the target branch (default to 'main')
const mergeTo = targetBranch || 'main';
// Validate source branch exists
try {
await execAsync(`git rev-parse --verify ${branchName}`, { cwd: projectPath });
} catch {
res.status(400).json({
success: false,
error: `Branch "${branchName}" does not exist`,
});
return;
}
// Validate target branch exists
try {
await execAsync(`git rev-parse --verify ${mergeTo}`, { cwd: projectPath });
} catch {
res.status(400).json({
success: false,
error: `Target branch "${mergeTo}" does not exist`,
});
return;
}
// Merge the feature branch into the target branch
const mergeCmd = options?.squash
? `git merge --squash ${branchName}`
: `git merge ${branchName} -m "${options?.message || `Merge ${branchName} into ${mergeTo}`}"`;
try {
await execAsync(mergeCmd, { cwd: projectPath });
} catch (mergeError: unknown) {
// Check if this is a merge conflict
const err = mergeError as { stdout?: string; stderr?: string; message?: string };
const output = `${err.stdout || ''} ${err.stderr || ''} ${err.message || ''}`;
const hasConflicts =
output.includes('CONFLICT') || output.includes('Automatic merge failed');
if (hasConflicts) {
// Get list of conflicted files
let conflictFiles: string[] = [];
try {
const diffOutput = await execGitCommand(
['diff', '--name-only', '--diff-filter=U'],
projectPath
);
conflictFiles = diffOutput
.trim()
.split('\n')
.filter((f: string) => f.trim().length > 0);
} catch {
// If we can't get the file list, that's okay
}
// Delegate all merge logic to the service
const result = await performMerge(projectPath, branchName, worktreePath, mergeTo, options);
if (!result.success) {
if (result.hasConflicts) {
// Return conflict-specific error message that frontend can detect
res.status(409).json({
success: false,
error: `Merge CONFLICT: Automatic merge of "${branchName}" into "${mergeTo}" failed. Please resolve conflicts manually.`,
error: result.error,
hasConflicts: true,
conflictFiles,
conflictFiles: result.conflictFiles,
});
return;
}
// Re-throw non-conflict errors to be handled by outer catch
throw mergeError;
}
// If squash merge, need to commit
if (options?.squash) {
await execAsync(`git commit -m "${options?.message || `Merge ${branchName} (squash)`}"`, {
cwd: projectPath,
// Non-conflict service errors (e.g. branch not found, invalid name)
res.status(400).json({
success: false,
error: result.error,
});
}
// Optionally delete the worktree and branch after merging
let worktreeDeleted = false;
let branchDeleted = false;
if (options?.deleteWorktreeAndBranch) {
// Remove the worktree
try {
await execGitCommand(['worktree', 'remove', worktreePath, '--force'], projectPath);
worktreeDeleted = true;
} catch {
// Try with prune if remove fails
try {
await execGitCommand(['worktree', 'prune'], projectPath);
worktreeDeleted = true;
} catch {
logger.warn(`Failed to remove worktree: ${worktreePath}`);
}
}
// Delete the branch (but not main/master)
if (branchName !== 'main' && branchName !== 'master') {
if (!isValidBranchName(branchName)) {
logger.warn(`Invalid branch name detected, skipping deletion: ${branchName}`);
} else {
try {
await execGitCommand(['branch', '-D', branchName], projectPath);
branchDeleted = true;
} catch {
logger.warn(`Failed to delete branch: ${branchName}`);
}
}
}
return;
}
res.json({
success: true,
mergedBranch: branchName,
targetBranch: mergeTo,
deleted: options?.deleteWorktreeAndBranch ? { worktreeDeleted, branchDeleted } : undefined,
mergedBranch: result.mergedBranch,
targetBranch: result.targetBranch,
deleted: result.deleted,
});
} catch (error) {
logError(error, 'Merge worktree failed');

View File

@@ -9,12 +9,16 @@
* 5. Detects merge conflicts from both pull and stash reapplication
* 6. Returns structured conflict information for AI-assisted resolution
*
* Git business logic is delegated to pull-service.ts.
*
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
* the requireValidWorktree middleware in index.ts
*/
import type { Request, Response } from 'express';
import { execGitCommand, getErrorMessage, logError } from '../common.js';
import { getErrorMessage, logError } from '../common.js';
import { performPull } from '../../../services/pull-service.js';
import type { PullResult } from '../../../services/pull-service.js';
export function createPullHandler() {
return async (req: Request, res: Response): Promise<void> => {
@@ -34,323 +38,66 @@ export function createPullHandler() {
return;
}
// Get current branch name
const branchOutput = await execGitCommand(
['rev-parse', '--abbrev-ref', 'HEAD'],
worktreePath
);
const branchName = branchOutput.trim();
// Execute the pull via the service
const result = await performPull(worktreePath, { remote, stashIfNeeded });
// Check for detached HEAD state
if (branchName === 'HEAD') {
res.status(400).json({
success: false,
error: 'Cannot pull in detached HEAD state. Please checkout a branch first.',
});
return;
}
// Use specified remote or default to 'origin'
const targetRemote = remote || 'origin';
// Fetch latest from remote
try {
await execGitCommand(['fetch', targetRemote], worktreePath);
} catch (fetchError) {
const errorMsg = getErrorMessage(fetchError);
res.status(500).json({
success: false,
error: `Failed to fetch from remote '${targetRemote}': ${errorMsg}`,
});
return;
}
// Check if there are local changes that would be overwritten
const statusOutput = await execGitCommand(['status', '--porcelain'], worktreePath);
const hasLocalChanges = statusOutput.trim().length > 0;
// Parse changed files for the response
let localChangedFiles: string[] = [];
if (hasLocalChanges) {
localChangedFiles = statusOutput
.trim()
.split('\n')
.filter((line) => line.trim().length > 0)
.map((line) => line.substring(3).trim());
}
// If there are local changes and stashIfNeeded is not requested, return info
if (hasLocalChanges && !stashIfNeeded) {
res.json({
success: true,
result: {
branch: branchName,
pulled: false,
hasLocalChanges: true,
localChangedFiles,
message:
'Local changes detected. Use stashIfNeeded to automatically stash and reapply changes.',
},
});
return;
}
// Stash local changes if needed
let didStash = false;
if (hasLocalChanges && stashIfNeeded) {
try {
const stashMessage = `automaker-pull-stash: Pre-pull stash on ${branchName}`;
await execGitCommand(
['stash', 'push', '--include-untracked', '-m', stashMessage],
worktreePath
);
didStash = true;
} catch (stashError) {
const errorMsg = getErrorMessage(stashError);
res.status(500).json({
success: false,
error: `Failed to stash local changes: ${errorMsg}`,
});
return;
}
}
// Check if the branch has upstream tracking
let hasUpstream = false;
try {
await execGitCommand(
['rev-parse', '--abbrev-ref', `${branchName}@{upstream}`],
worktreePath
);
hasUpstream = true;
} catch {
// No upstream tracking - check if the remote branch exists
try {
await execGitCommand(
['rev-parse', '--verify', `${targetRemote}/${branchName}`],
worktreePath
);
hasUpstream = true; // Remote branch exists, we can pull from it
} catch {
// Remote branch doesn't exist either
if (didStash) {
// Reapply stash since we won't be pulling
try {
await execGitCommand(['stash', 'pop'], worktreePath);
} catch {
// Stash pop failed - leave it in stash list for manual recovery
}
}
res.status(400).json({
success: false,
error: `Branch '${branchName}' has no upstream branch on remote '${targetRemote}'. Push it first or set upstream with: git branch --set-upstream-to=${targetRemote}/${branchName}`,
});
return;
}
}
// Pull latest changes
let pullConflict = false;
let pullConflictFiles: string[] = [];
try {
const pullOutput = await execGitCommand(['pull', targetRemote, branchName], worktreePath);
// Check if we pulled any changes
const alreadyUpToDate = pullOutput.includes('Already up to date');
// If no stash to reapply, return success
if (!didStash) {
res.json({
success: true,
result: {
branch: branchName,
pulled: !alreadyUpToDate,
hasLocalChanges: false,
stashed: false,
stashRestored: false,
message: alreadyUpToDate ? 'Already up to date' : 'Pulled latest changes',
},
});
return;
}
} catch (pullError: unknown) {
const err = pullError as { stderr?: string; stdout?: string; message?: string };
const errorOutput = `${err.stderr || ''} ${err.stdout || ''} ${err.message || ''}`;
// Check for merge conflicts from the pull itself
if (errorOutput.includes('CONFLICT') || errorOutput.includes('Automatic merge failed')) {
pullConflict = true;
// Get list of conflicted files
try {
const diffOutput = await execGitCommand(
['diff', '--name-only', '--diff-filter=U'],
worktreePath
);
pullConflictFiles = diffOutput
.trim()
.split('\n')
.filter((f) => f.trim().length > 0);
} catch {
// If we can't get the file list, that's okay
}
} else {
// Non-conflict pull error
if (didStash) {
// Try to restore stash since pull failed
try {
await execGitCommand(['stash', 'pop'], worktreePath);
} catch {
// Leave stash in place for manual recovery
}
}
// Check for common errors
const errorMsg = err.stderr || err.message || 'Pull failed';
if (errorMsg.includes('no tracking information')) {
res.status(400).json({
success: false,
error: `Branch '${branchName}' has no upstream branch. Push it first or set upstream with: git branch --set-upstream-to=${targetRemote}/${branchName}`,
});
return;
}
res.status(500).json({
success: false,
error: errorMsg,
});
return;
}
}
// If pull had conflicts, return conflict info (don't try stash pop)
if (pullConflict) {
res.json({
success: true,
result: {
branch: branchName,
pulled: true,
hasConflicts: true,
conflictSource: 'pull',
conflictFiles: pullConflictFiles,
stashed: didStash,
stashRestored: false,
message:
`Pull resulted in merge conflicts. ${didStash ? 'Your local changes are still stashed.' : ''}`.trim(),
},
});
return;
}
// Pull succeeded, now try to reapply stash
if (didStash) {
try {
const stashPopOutput = await execGitCommand(['stash', 'pop'], worktreePath);
const stashPopCombined = stashPopOutput || '';
// Check if stash pop had conflicts
if (
stashPopCombined.includes('CONFLICT') ||
stashPopCombined.includes('Merge conflict')
) {
// Get conflicted files
let stashConflictFiles: string[] = [];
try {
const diffOutput = await execGitCommand(
['diff', '--name-only', '--diff-filter=U'],
worktreePath
);
stashConflictFiles = diffOutput
.trim()
.split('\n')
.filter((f) => f.trim().length > 0);
} catch {
// If we can't get the file list, that's okay
}
res.json({
success: true,
result: {
branch: branchName,
pulled: true,
hasConflicts: true,
conflictSource: 'stash',
conflictFiles: stashConflictFiles,
stashed: true,
stashRestored: true, // Stash was applied but with conflicts
message:
'Pull succeeded but reapplying your stashed changes resulted in merge conflicts.',
},
});
return;
}
// Stash pop succeeded cleanly
res.json({
success: true,
result: {
branch: branchName,
pulled: true,
hasConflicts: false,
stashed: true,
stashRestored: true,
message: 'Pulled latest changes and restored your stashed changes.',
},
});
} catch (stashPopError: unknown) {
const err = stashPopError as { stderr?: string; stdout?: string; message?: string };
const errorOutput = `${err.stderr || ''} ${err.stdout || ''} ${err.message || ''}`;
// Check if stash pop failed due to conflicts
if (errorOutput.includes('CONFLICT') || errorOutput.includes('Merge conflict')) {
let stashConflictFiles: string[] = [];
try {
const diffOutput = await execGitCommand(
['diff', '--name-only', '--diff-filter=U'],
worktreePath
);
stashConflictFiles = diffOutput
.trim()
.split('\n')
.filter((f) => f.trim().length > 0);
} catch {
// If we can't get the file list, that's okay
}
res.json({
success: true,
result: {
branch: branchName,
pulled: true,
hasConflicts: true,
conflictSource: 'stash',
conflictFiles: stashConflictFiles,
stashed: true,
stashRestored: true,
message:
'Pull succeeded but reapplying your stashed changes resulted in merge conflicts.',
},
});
return;
}
// Non-conflict stash pop error - stash is still in the stash list
res.json({
success: true,
result: {
branch: branchName,
pulled: true,
hasConflicts: false,
stashed: true,
stashRestored: false,
message:
'Pull succeeded but failed to reapply stashed changes. Your changes are still in the stash list.',
},
});
}
}
// Map service result to HTTP response
mapResultToResponse(res, result);
} catch (error) {
logError(error, 'Pull failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}
/**
* Map a PullResult from the service to the appropriate HTTP response.
*
* - Successful results (including local-changes-detected info) → 200
* - Validation/state errors (detached HEAD, no upstream) → 400
* - Operational errors (fetch/stash/pull failures) → 500
*/
function mapResultToResponse(res: Response, result: PullResult): void {
if (!result.success && result.error) {
// Determine the appropriate HTTP status for errors
const statusCode = isClientError(result.error) ? 400 : 500;
res.status(statusCode).json({
success: false,
error: result.error,
...(result.stashRecoveryFailed && { stashRecoveryFailed: true }),
});
return;
}
// Success case (includes partial success like local changes detected, conflicts, etc.)
res.json({
success: true,
result: {
branch: result.branch,
pulled: result.pulled,
hasLocalChanges: result.hasLocalChanges,
localChangedFiles: result.localChangedFiles,
hasConflicts: result.hasConflicts,
conflictSource: result.conflictSource,
conflictFiles: result.conflictFiles,
stashed: result.stashed,
stashRestored: result.stashRestored,
message: result.message,
},
});
}
/**
* Determine whether an error message represents a client error (400)
* vs a server error (500).
*
* Client errors are validation issues or invalid git state that the user
* needs to resolve (e.g. detached HEAD, no upstream, no tracking info).
*/
function isClientError(errorMessage: string): boolean {
return (
errorMessage.includes('detached HEAD') ||
errorMessage.includes('has no upstream branch') ||
errorMessage.includes('no tracking information')
);
}

View File

@@ -96,6 +96,20 @@ export function createRebaseHandler(events: EventEmitter) {
conflictFiles: result.conflictFiles,
aborted: result.aborted,
});
} else {
// Emit failure event for non-conflict failures
events.emit('rebase:failed', {
worktreePath: resolvedWorktreePath,
branch: result.branch,
ontoBranch: result.ontoBranch,
error: result.error,
});
res.status(500).json({
success: false,
error: result.error ?? 'Rebase failed',
hasConflicts: false,
});
}
} catch (error) {
// Emit failure event

View File

@@ -4,34 +4,15 @@
* Applies a specific stash entry to the working directory.
* Can either "apply" (keep stash) or "pop" (remove stash after applying).
*
* All git operations and conflict detection are delegated to StashService.
*
* Note: Git repository validation (isGitRepo) is handled by
* the requireGitRepoOnly middleware in index.ts
*/
import type { Request, Response } from 'express';
import { execFile } from 'child_process';
import { promisify } from 'util';
import { getErrorMessage, logError } from '../common.js';
const execFileAsync = promisify(execFile);
/**
* Retrieves the list of files with unmerged (conflicted) entries using git diff.
*/
async function getConflictedFiles(worktreePath: string): Promise<string[]> {
try {
const { stdout } = await execFileAsync('git', ['diff', '--name-only', '--diff-filter=U'], {
cwd: worktreePath,
});
return stdout
.trim()
.split('\n')
.filter((f) => f.trim().length > 0);
} catch {
// If we can't get the file list, return empty array
return [];
}
}
import { applyOrPop } from '../../../services/stash-service.js';
export function createStashApplyHandler() {
return async (req: Request, res: Response): Promise<void> => {
@@ -68,65 +49,26 @@ export function createStashApplyHandler() {
return;
}
const stashRef = `stash@{${idx}}`;
const operation = pop ? 'pop' : 'apply';
// Delegate all stash apply/pop logic to the service
const result = await applyOrPop(worktreePath, idx, { pop });
try {
const { stdout, stderr } = await execFileAsync('git', ['stash', operation, stashRef], {
cwd: worktreePath,
});
const output = `${stdout}\n${stderr}`;
// Check for conflict markers in the output
if (output.includes('CONFLICT') || output.includes('Merge conflict')) {
const conflictFiles = await getConflictedFiles(worktreePath);
res.json({
success: true,
result: {
applied: true,
hasConflicts: true,
conflictFiles,
operation,
stashIndex,
message: `Stash ${operation === 'pop' ? 'popped' : 'applied'} with conflicts. Please resolve the conflicts.`,
},
});
return;
}
res.json({
success: true,
result: {
applied: true,
hasConflicts: false,
operation,
stashIndex,
message: `Stash ${operation === 'pop' ? 'popped' : 'applied'} successfully`,
},
});
} catch (error) {
const errorMsg = getErrorMessage(error);
// Check if the error is due to conflicts
if (errorMsg.includes('CONFLICT') || errorMsg.includes('Merge conflict')) {
const conflictFiles = await getConflictedFiles(worktreePath);
res.json({
success: true,
result: {
applied: true,
hasConflicts: true,
conflictFiles,
operation,
stashIndex,
message: `Stash ${operation === 'pop' ? 'popped' : 'applied'} with conflicts. Please resolve the conflicts.`,
},
});
return;
}
throw error;
if (!result.success) {
logError(new Error(result.error ?? 'Stash apply failed'), 'Stash apply failed');
res.status(500).json({ success: false, error: result.error });
return;
}
res.json({
success: true,
result: {
applied: result.applied,
hasConflicts: result.hasConflicts,
conflictFiles: result.conflictFiles,
operation: result.operation,
stashIndex: result.stashIndex,
message: result.message,
},
});
} catch (error) {
logError(error, 'Stash apply failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });

View File

@@ -1,20 +1,22 @@
/**
* POST /stash-drop endpoint - Drop (delete) a stash entry
*
* Removes a specific stash entry from the stash list.
* The handler only validates input, invokes the service, streams lifecycle
* events via the EventEmitter, and sends the final JSON response.
*
* Git business logic is delegated to stash-service.ts.
* Events are emitted at key lifecycle points for WebSocket subscribers.
*
* Note: Git repository validation (isGitRepo) is handled by
* the requireGitRepoOnly middleware in index.ts
*/
import type { Request, Response } from 'express';
import { execFile } from 'child_process';
import { promisify } from 'util';
import type { EventEmitter } from '../../../lib/events.js';
import { getErrorMessage, logError } from '../common.js';
import { dropStash } from '../../../services/stash-service.js';
const execFileAsync = promisify(execFile);
export function createStashDropHandler() {
export function createStashDropHandler(events: EventEmitter) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath, stashIndex } = req.body as {
@@ -38,21 +40,42 @@ export function createStashDropHandler() {
return;
}
const stashRef = `stash@{${stashIndex}}`;
// Emit start event so the frontend can observe progress
events.emit('stash:start', {
worktreePath,
stashIndex,
stashRef: `stash@{${stashIndex}}`,
operation: 'drop',
});
await execFileAsync('git', ['stash', 'drop', stashRef], {
cwd: worktreePath,
// Delegate all Git work to the service
const result = await dropStash(worktreePath, stashIndex);
// Emit success event
events.emit('stash:success', {
worktreePath,
stashIndex,
operation: 'drop',
dropped: result.dropped,
});
res.json({
success: true,
result: {
dropped: true,
stashIndex,
message: `Stash ${stashRef} dropped successfully`,
dropped: result.dropped,
stashIndex: result.stashIndex,
message: result.message,
},
});
} catch (error) {
// Emit error event so the frontend can react
events.emit('stash:failure', {
worktreePath: req.body?.worktreePath,
stashIndex: req.body?.stashIndex,
operation: 'drop',
error: getErrorMessage(error),
});
logError(error, 'Stash drop failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}

View File

@@ -1,29 +1,22 @@
/**
* POST /stash-list endpoint - List all stashes in a worktree
*
* Returns a list of all stash entries with their index, message, branch, and date.
* Also includes the list of files changed in each stash.
* The handler only validates input, invokes the service, streams lifecycle
* events via the EventEmitter, and sends the final JSON response.
*
* Git business logic is delegated to stash-service.ts.
* Events are emitted at key lifecycle points for WebSocket subscribers.
*
* Note: Git repository validation (isGitRepo) is handled by
* the requireGitRepoOnly middleware in index.ts
*/
import type { Request, Response } from 'express';
import { execFile } from 'child_process';
import { promisify } from 'util';
import type { EventEmitter } from '../../../lib/events.js';
import { getErrorMessage, logError } from '../common.js';
import { listStash } from '../../../services/stash-service.js';
const execFileAsync = promisify(execFile);
interface StashEntry {
index: number;
message: string;
branch: string;
date: string;
files: string[];
}
export function createStashListHandler() {
export function createStashListHandler(events: EventEmitter) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath } = req.body as {
@@ -38,84 +31,44 @@ export function createStashListHandler() {
return;
}
// Get stash list with format: index, message, date
// Use %aI (strict ISO 8601) instead of %ai to ensure cross-browser compatibility
const { stdout: stashOutput } = await execFileAsync(
'git',
['stash', 'list', '--format=%gd|||%s|||%aI'],
{ cwd: worktreePath }
);
// Emit start event so the frontend can observe progress
events.emit('stash:start', {
worktreePath,
operation: 'list',
});
if (!stashOutput.trim()) {
res.json({
success: true,
result: {
stashes: [],
total: 0,
},
});
return;
}
// Delegate all Git work to the service
const result = await listStash(worktreePath);
const stashLines = stashOutput
.trim()
.split('\n')
.filter((l) => l.trim());
const stashes: StashEntry[] = [];
// Emit progress with stash count
events.emit('stash:progress', {
worktreePath,
operation: 'list',
total: result.total,
});
for (const line of stashLines) {
const parts = line.split('|||');
if (parts.length < 3) continue;
const refSpec = parts[0].trim(); // e.g., "stash@{0}"
const message = parts[1].trim();
const date = parts[2].trim();
// Extract index from stash@{N}; skip entries that don't match the expected format
const indexMatch = refSpec.match(/stash@\{(\d+)\}/);
if (!indexMatch) continue;
const index = parseInt(indexMatch[1], 10);
// Extract branch name from message (format: "WIP on branch: hash message" or "On branch: hash message")
let branch = '';
const branchMatch = message.match(/^(?:WIP on|On) ([^:]+):/);
if (branchMatch) {
branch = branchMatch[1];
}
// Get list of files in this stash
let files: string[] = [];
try {
const { stdout: filesOutput } = await execFileAsync(
'git',
['stash', 'show', refSpec, '--name-only'],
{ cwd: worktreePath }
);
files = filesOutput
.trim()
.split('\n')
.filter((f) => f.trim());
} catch {
// Ignore errors getting file list
}
stashes.push({
index,
message,
branch,
date,
files,
});
}
// Emit success event
events.emit('stash:success', {
worktreePath,
operation: 'list',
total: result.total,
});
res.json({
success: true,
result: {
stashes,
total: stashes.length,
stashes: result.stashes,
total: result.total,
},
});
} catch (error) {
// Emit error event so the frontend can react
events.emit('stash:failure', {
worktreePath: req.body?.worktreePath,
operation: 'list',
error: getErrorMessage(error),
});
logError(error, 'Stash list failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}

View File

@@ -1,21 +1,22 @@
/**
* POST /stash-push endpoint - Stash changes in a worktree
*
* Stashes uncommitted changes (including untracked files) with an optional message.
* Supports selective file stashing when a files array is provided.
* The handler only validates input, invokes the service, streams lifecycle
* events via the EventEmitter, and sends the final JSON response.
*
* Git business logic is delegated to stash-service.ts.
* Events are emitted at key lifecycle points for WebSocket subscribers.
*
* Note: Git repository validation (isGitRepo) is handled by
* the requireGitRepoOnly middleware in index.ts
*/
import type { Request, Response } from 'express';
import { execFile } from 'child_process';
import { promisify } from 'util';
import type { EventEmitter } from '../../../lib/events.js';
import { getErrorMessage, logError } from '../common.js';
import { pushStash } from '../../../services/stash-service.js';
const execFileAsync = promisify(execFile);
export function createStashPushHandler() {
export function createStashPushHandler(events: EventEmitter) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath, message, files } = req.body as {
@@ -32,54 +33,47 @@ export function createStashPushHandler() {
return;
}
// Check for any changes to stash
const { stdout: status } = await execFileAsync('git', ['status', '--porcelain'], {
cwd: worktreePath,
// Emit start event so the frontend can observe progress
events.emit('stash:start', {
worktreePath,
operation: 'push',
});
if (!status.trim()) {
res.json({
success: true,
result: {
stashed: false,
message: 'No changes to stash',
},
});
return;
}
// Delegate all Git work to the service
const result = await pushStash(worktreePath, { message, files });
// Build stash push command args
const args = ['stash', 'push', '--include-untracked'];
if (message && message.trim()) {
args.push('-m', message.trim());
}
// Emit progress with stash result
events.emit('stash:progress', {
worktreePath,
operation: 'push',
stashed: result.stashed,
branch: result.branch,
});
// If specific files are provided, add them as pathspecs after '--'
if (files && files.length > 0) {
args.push('--');
args.push(...files);
}
// Execute stash push
await execFileAsync('git', args, { cwd: worktreePath });
// Get current branch name
const { stdout: branchOutput } = await execFileAsync(
'git',
['rev-parse', '--abbrev-ref', 'HEAD'],
{ cwd: worktreePath }
);
const branchName = branchOutput.trim();
// Emit success event
events.emit('stash:success', {
worktreePath,
operation: 'push',
stashed: result.stashed,
branch: result.branch,
});
res.json({
success: true,
result: {
stashed: true,
branch: branchName,
message: message?.trim() || `WIP on ${branchName}`,
stashed: result.stashed,
branch: result.branch,
message: result.message,
},
});
} catch (error) {
// Emit error event so the frontend can react
events.emit('stash:failure', {
worktreePath: req.body?.worktreePath,
operation: 'push',
error: getErrorMessage(error),
});
logError(error, 'Stash push failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}

View File

@@ -11,152 +11,19 @@
*
* Also fetches the latest remote refs after switching.
*
* Git business logic is delegated to worktree-branch-service.ts.
* Events are emitted at key lifecycle points for WebSocket subscribers.
*
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
* the requireValidWorktree middleware in index.ts
*/
import type { Request, Response } from 'express';
import { execFile } from 'child_process';
import { promisify } from 'util';
import { getErrorMessage, logError } from '../common.js';
import { getErrorMessage, logError, isValidBranchName } from '../common.js';
import type { EventEmitter } from '../../../lib/events.js';
import { performSwitchBranch } from '../../../services/worktree-branch-service.js';
const execFileAsync = promisify(execFile);
function isExcludedWorktreeLine(line: string): boolean {
return line.includes('.worktrees/') || line.endsWith('.worktrees');
}
/**
* Check if there are any changes at all (including untracked) that should be stashed
*/
async function hasAnyChanges(cwd: string): Promise<boolean> {
try {
const { stdout } = await execFileAsync('git', ['status', '--porcelain'], { cwd });
const lines = stdout
.trim()
.split('\n')
.filter((line) => {
if (!line.trim()) return false;
if (isExcludedWorktreeLine(line)) return false;
return true;
});
return lines.length > 0;
} catch {
return false;
}
}
/**
* Stash all local changes (including untracked files)
* Returns true if a stash was created, false if there was nothing to stash
*/
async function stashChanges(cwd: string, message: string): Promise<boolean> {
try {
// Get stash count before
const { stdout: beforeCount } = await execFileAsync('git', ['stash', 'list'], { cwd });
const countBefore = beforeCount
.trim()
.split('\n')
.filter((l) => l.trim()).length;
// Stash including untracked files
await execFileAsync('git', ['stash', 'push', '--include-untracked', '-m', message], { cwd });
// Get stash count after to verify something was stashed
const { stdout: afterCount } = await execFileAsync('git', ['stash', 'list'], { cwd });
const countAfter = afterCount
.trim()
.split('\n')
.filter((l) => l.trim()).length;
return countAfter > countBefore;
} catch {
return false;
}
}
/**
* Pop the most recent stash entry
* Returns an object indicating success and whether there were conflicts
*/
async function popStash(
cwd: string
): Promise<{ success: boolean; hasConflicts: boolean; error?: string }> {
try {
const { stdout, stderr } = await execFileAsync('git', ['stash', 'pop'], { cwd });
const output = `${stdout}\n${stderr}`;
// Check for conflict markers in the output
if (output.includes('CONFLICT') || output.includes('Merge conflict')) {
return { success: false, hasConflicts: true };
}
return { success: true, hasConflicts: false };
} catch (error) {
const errorMsg = getErrorMessage(error);
if (errorMsg.includes('CONFLICT') || errorMsg.includes('Merge conflict')) {
return { success: false, hasConflicts: true, error: errorMsg };
}
return { success: false, hasConflicts: false, error: errorMsg };
}
}
/**
* Fetch latest from all remotes (silently, with timeout)
*/
async function fetchRemotes(cwd: string): Promise<void> {
try {
await execFileAsync('git', ['fetch', '--all', '--quiet'], {
cwd,
timeout: 15000, // 15 second timeout
});
} catch {
// Ignore fetch errors - we may be offline
}
}
/**
* Parse a remote branch name like "origin/feature-branch" into its parts
*/
function parseRemoteBranch(branchName: string): { remote: string; branch: string } | null {
const slashIndex = branchName.indexOf('/');
if (slashIndex === -1) return null;
return {
remote: branchName.substring(0, slashIndex),
branch: branchName.substring(slashIndex + 1),
};
}
/**
* Check if a branch name refers to a remote branch
*/
async function isRemoteBranch(cwd: string, branchName: string): Promise<boolean> {
try {
const { stdout } = await execFileAsync('git', ['branch', '-r', '--format=%(refname:short)'], {
cwd,
});
const remoteBranches = stdout
.trim()
.split('\n')
.map((b) => b.trim().replace(/^['"]|['"]$/g, ''))
.filter((b) => b);
return remoteBranches.includes(branchName);
} catch {
return false;
}
}
/**
* Check if a local branch already exists
*/
async function localBranchExists(cwd: string, branchName: string): Promise<boolean> {
try {
await execFileAsync('git', ['rev-parse', '--verify', `refs/heads/${branchName}`], { cwd });
return true;
} catch {
return false;
}
}
export function createSwitchBranchHandler() {
export function createSwitchBranchHandler(events?: EventEmitter) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath, branchName } = req.body as {
@@ -180,186 +47,58 @@ export function createSwitchBranchHandler() {
return;
}
// Get current branch
const { stdout: currentBranchOutput } = await execFileAsync(
'git',
['rev-parse', '--abbrev-ref', 'HEAD'],
{ cwd: worktreePath }
);
const previousBranch = currentBranchOutput.trim();
// Determine the actual target branch name for checkout
let targetBranch = branchName;
let isRemote = false;
// Check if this is a remote branch (e.g., "origin/feature-branch")
let parsedRemote: { remote: string; branch: string } | null = null;
if (await isRemoteBranch(worktreePath, branchName)) {
isRemote = true;
parsedRemote = parseRemoteBranch(branchName);
if (parsedRemote) {
targetBranch = parsedRemote.branch;
} else {
res.status(400).json({
success: false,
error: `Failed to parse remote branch name '${branchName}'`,
});
return;
}
}
if (previousBranch === targetBranch) {
res.json({
success: true,
result: {
previousBranch,
currentBranch: targetBranch,
message: `Already on branch '${targetBranch}'`,
},
// Validate branch name using shared allowlist to prevent Git option injection
if (!isValidBranchName(branchName)) {
res.status(400).json({
success: false,
error: 'Invalid branch name',
});
return;
}
// Check if target branch exists (locally or as remote ref)
if (!isRemote) {
try {
await execFileAsync('git', ['rev-parse', '--verify', branchName], {
cwd: worktreePath,
});
} catch {
res.status(400).json({
success: false,
error: `Branch '${branchName}' does not exist`,
});
return;
}
// Execute the branch switch via the service
const result = await performSwitchBranch(worktreePath, branchName, events);
// Map service result to HTTP response
if (!result.success) {
// Determine status code based on error type
const statusCode = isBranchNotFoundError(result.error) ? 400 : 500;
res.status(statusCode).json({
success: false,
error: result.error,
...(result.stashPopConflicts !== undefined && {
stashPopConflicts: result.stashPopConflicts,
}),
...(result.stashPopConflictMessage && {
stashPopConflictMessage: result.stashPopConflictMessage,
}),
});
return;
}
// Stash local changes if any exist
const hadChanges = await hasAnyChanges(worktreePath);
let didStash = false;
if (hadChanges) {
const stashMessage = `automaker-branch-switch: ${previousBranch}${targetBranch}`;
didStash = await stashChanges(worktreePath, stashMessage);
}
try {
// Switch to the target branch
if (isRemote) {
if (!parsedRemote) {
throw new Error(`Failed to parse remote branch name '${branchName}'`);
}
if (await localBranchExists(worktreePath, parsedRemote.branch)) {
// Local branch exists, just checkout
await execFileAsync('git', ['checkout', parsedRemote.branch], { cwd: worktreePath });
} else {
// Create local tracking branch from remote
await execFileAsync('git', ['checkout', '-b', parsedRemote.branch, branchName], {
cwd: worktreePath,
});
}
} else {
await execFileAsync('git', ['checkout', targetBranch], { cwd: worktreePath });
}
// Fetch latest from remotes after switching
await fetchRemotes(worktreePath);
// Reapply stashed changes if we stashed earlier
let hasConflicts = false;
let conflictMessage = '';
let stashReapplied = false;
if (didStash) {
const popResult = await popStash(worktreePath);
hasConflicts = popResult.hasConflicts;
if (popResult.hasConflicts) {
conflictMessage = `Switched to branch '${targetBranch}' but merge conflicts occurred when reapplying your local changes. Please resolve the conflicts.`;
} else if (!popResult.success) {
// Stash pop failed for a non-conflict reason - the stash is still there
conflictMessage = `Switched to branch '${targetBranch}' but failed to reapply stashed changes: ${popResult.error}. Your changes are still in the stash.`;
} else {
stashReapplied = true;
}
}
if (hasConflicts) {
res.json({
success: true,
result: {
previousBranch,
currentBranch: targetBranch,
message: conflictMessage,
hasConflicts: true,
stashedChanges: true,
},
});
} else if (didStash && !stashReapplied) {
// Stash pop failed for a non-conflict reason — stash is still present
res.json({
success: true,
result: {
previousBranch,
currentBranch: targetBranch,
message: conflictMessage,
hasConflicts: false,
stashedChanges: true,
},
});
} else {
const stashNote = stashReapplied ? ' (local changes stashed and reapplied)' : '';
res.json({
success: true,
result: {
previousBranch,
currentBranch: targetBranch,
message: `Switched to branch '${targetBranch}'${stashNote}`,
hasConflicts: false,
stashedChanges: stashReapplied,
},
});
}
} catch (checkoutError) {
// If checkout failed and we stashed, try to restore the stash
if (didStash) {
const popResult = await popStash(worktreePath);
if (popResult.hasConflicts) {
// Stash pop itself produced merge conflicts — the working tree is now in a
// conflicted state even though the checkout failed. Surface this clearly so
// the caller can prompt the user (or AI) to resolve conflicts rather than
// simply retrying the branch switch.
const checkoutErrorMsg = getErrorMessage(checkoutError);
res.status(500).json({
success: false,
error: checkoutErrorMsg,
stashPopConflicts: true,
stashPopConflictMessage:
'Stash pop resulted in conflicts: your stashed changes were partially reapplied ' +
'but produced merge conflicts. Please resolve the conflicts before retrying the branch switch.',
});
return;
} else if (!popResult.success) {
// Stash pop failed for a non-conflict reason; the stash entry is still intact.
// Include this detail alongside the original checkout error.
const checkoutErrorMsg = getErrorMessage(checkoutError);
const combinedMessage =
`${checkoutErrorMsg}. Additionally, restoring your stashed changes failed: ` +
`${popResult.error ?? 'unknown error'} — your changes are still saved in the stash.`;
res.status(500).json({
success: false,
error: combinedMessage,
stashPopConflicts: false,
});
return;
}
// popResult.success === true: stash was cleanly restored, re-throw the checkout error
}
throw checkoutError;
}
res.json({
success: true,
result: result.result,
});
} catch (error) {
events?.emit('switch:error', {
error: getErrorMessage(error),
});
logError(error, 'Switch branch failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}
/**
* Determine whether an error message represents a client error (400)
* vs a server error (500).
*
* Client errors are validation issues like non-existent branches or
* unparseable remote branch names.
*/
function isBranchNotFoundError(error?: string): boolean {
if (!error) return false;
return error.includes('does not exist') || error.includes('Failed to parse remote branch name');
}