adding branch switcher support

This commit is contained in:
Cody Seibert
2025-12-16 02:14:42 -05:00
parent 25044d40b9
commit 9a428eefe0
23 changed files with 2785 additions and 3 deletions

View File

@@ -10,6 +10,15 @@ import { createDiffsHandler } from "./routes/diffs.js";
import { createFileDiffHandler } from "./routes/file-diff.js";
import { createRevertHandler } from "./routes/revert.js";
import { createMergeHandler } from "./routes/merge.js";
import { createCreateHandler } from "./routes/create.js";
import { createDeleteHandler } from "./routes/delete.js";
import { createCreatePRHandler } from "./routes/create-pr.js";
import { createCommitHandler } from "./routes/commit.js";
import { createPushHandler } from "./routes/push.js";
import { createPullHandler } from "./routes/pull.js";
import { createCheckoutBranchHandler } from "./routes/checkout-branch.js";
import { createListBranchesHandler } from "./routes/list-branches.js";
import { createSwitchBranchHandler } from "./routes/switch-branch.js";
export function createWorktreeRoutes(): Router {
const router = Router();
@@ -21,6 +30,15 @@ export function createWorktreeRoutes(): Router {
router.post("/file-diff", createFileDiffHandler());
router.post("/revert", createRevertHandler());
router.post("/merge", createMergeHandler());
router.post("/create", createCreateHandler());
router.post("/delete", createDeleteHandler());
router.post("/create-pr", createCreatePRHandler());
router.post("/commit", createCommitHandler());
router.post("/push", createPushHandler());
router.post("/pull", createPullHandler());
router.post("/checkout-branch", createCheckoutBranchHandler());
router.post("/list-branches", createListBranchesHandler());
router.post("/switch-branch", createSwitchBranchHandler());
return router;
}

View File

@@ -0,0 +1,86 @@
/**
* POST /checkout-branch endpoint - Create and checkout a new branch
*/
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);
export function createCheckoutBranchHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath, branchName } = req.body as {
worktreePath: string;
branchName: string;
};
if (!worktreePath) {
res.status(400).json({
success: false,
error: "worktreePath required",
});
return;
}
if (!branchName) {
res.status(400).json({
success: false,
error: "branchName required",
});
return;
}
// Validate branch name (basic validation)
const invalidChars = /[\s~^:?*\[\\]/;
if (invalidChars.test(branchName)) {
res.status(400).json({
success: false,
error: "Branch name contains invalid characters",
});
return;
}
// Get current branch for reference
const { stdout: currentBranchOutput } = await execAsync(
"git rev-parse --abbrev-ref HEAD",
{ cwd: worktreePath }
);
const currentBranch = currentBranchOutput.trim();
// Check if branch already exists
try {
await execAsync(`git rev-parse --verify ${branchName}`, {
cwd: worktreePath,
});
// Branch exists
res.status(400).json({
success: false,
error: `Branch '${branchName}' already exists`,
});
return;
} catch {
// Branch doesn't exist, good to create
}
// Create and checkout the new branch
await execAsync(`git checkout -b ${branchName}`, {
cwd: worktreePath,
});
res.json({
success: true,
result: {
previousBranch: currentBranch,
newBranch: branchName,
message: `Created and checked out branch '${branchName}'`,
},
});
} catch (error) {
logError(error, "Checkout branch failed");
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,79 @@
/**
* POST /commit endpoint - Commit changes in a worktree
*/
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);
export function createCommitHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath, message } = req.body as {
worktreePath: string;
message: string;
};
if (!worktreePath || !message) {
res.status(400).json({
success: false,
error: "worktreePath and message required",
});
return;
}
// Check for uncommitted changes
const { stdout: status } = await execAsync("git status --porcelain", {
cwd: worktreePath,
});
if (!status.trim()) {
res.json({
success: true,
result: {
committed: false,
message: "No changes to commit",
},
});
return;
}
// Stage all changes
await execAsync("git add -A", { cwd: worktreePath });
// Create commit
await execAsync(`git commit -m "${message.replace(/"/g, '\\"')}"`, {
cwd: worktreePath,
});
// Get commit hash
const { stdout: hashOutput } = await execAsync("git rev-parse HEAD", {
cwd: worktreePath,
});
const commitHash = hashOutput.trim().substring(0, 8);
// Get branch name
const { stdout: branchOutput } = await execAsync(
"git rev-parse --abbrev-ref HEAD",
{ cwd: worktreePath }
);
const branchName = branchOutput.trim();
res.json({
success: true,
result: {
committed: true,
commitHash,
branch: branchName,
message,
},
});
} catch (error) {
logError(error, "Commit worktree failed");
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,198 @@
/**
* POST /create-pr endpoint - Commit changes and create a pull request from a worktree
*/
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);
// Extended PATH to include common tool installation locations
// This is needed because Electron apps don't inherit the user's shell PATH
const extendedPath = [
process.env.PATH,
"/opt/homebrew/bin", // Homebrew on Apple Silicon
"/usr/local/bin", // Homebrew on Intel Mac, common Linux location
"/home/linuxbrew/.linuxbrew/bin", // Linuxbrew
`${process.env.HOME}/.local/bin`, // pipx, other user installs
].filter(Boolean).join(":");
const execEnv = {
...process.env,
PATH: extendedPath,
};
export function createCreatePRHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath, commitMessage, prTitle, prBody, baseBranch, draft } = req.body as {
worktreePath: string;
commitMessage?: string;
prTitle?: string;
prBody?: string;
baseBranch?: string;
draft?: boolean;
};
if (!worktreePath) {
res.status(400).json({
success: false,
error: "worktreePath required",
});
return;
}
// Get current branch name
const { stdout: branchOutput } = await execAsync(
"git rev-parse --abbrev-ref HEAD",
{ cwd: worktreePath, env: execEnv }
);
const branchName = branchOutput.trim();
// Check for uncommitted changes
const { stdout: status } = await execAsync("git status --porcelain", {
cwd: worktreePath,
env: execEnv,
});
const hasChanges = status.trim().length > 0;
// If there are changes, commit them
let commitHash: string | null = null;
if (hasChanges) {
const message = commitMessage || `Changes from ${branchName}`;
// Stage all changes
await execAsync("git add -A", { cwd: worktreePath, env: execEnv });
// Create commit
await execAsync(`git commit -m "${message.replace(/"/g, '\\"')}"`, {
cwd: worktreePath,
env: execEnv,
});
// Get commit hash
const { stdout: hashOutput } = await execAsync("git rev-parse HEAD", {
cwd: worktreePath,
env: execEnv,
});
commitHash = hashOutput.trim().substring(0, 8);
}
// Push the branch to remote
let pushError: string | null = null;
try {
await execAsync(`git push -u origin ${branchName}`, {
cwd: worktreePath,
env: execEnv,
});
} catch (error: unknown) {
// If push fails, try with --set-upstream
try {
await execAsync(`git push --set-upstream origin ${branchName}`, {
cwd: worktreePath,
env: execEnv,
});
} catch (error2: unknown) {
// Capture push error for reporting
const err = error2 as { stderr?: string; message?: string };
pushError = err.stderr || err.message || "Push failed";
console.error("[CreatePR] Push failed:", pushError);
}
}
// If push failed, return error
if (pushError) {
res.status(500).json({
success: false,
error: `Failed to push branch: ${pushError}`,
});
return;
}
// Create PR using gh CLI
const base = baseBranch || "main";
const title = prTitle || branchName;
const body = prBody || `Changes from branch ${branchName}`;
const draftFlag = draft ? "--draft" : "";
let prUrl: string | null = null;
let prError: string | null = null;
try {
// Check if gh CLI is available (use extended PATH for Homebrew/etc)
await execAsync("command -v gh", { env: execEnv });
// Check if this is a fork by looking for upstream remote
let upstreamRepo: string | null = null;
let originOwner: string | null = null;
try {
const { stdout: remotes } = await execAsync("git remote -v", {
cwd: worktreePath,
env: execEnv,
});
// Parse remotes to detect fork workflow
const lines = remotes.split("\n");
for (const line of lines) {
const match = line.match(/^(\w+)\s+.*[:/]([^/]+)\/([^/\s]+?)(?:\.git)?\s+\(fetch\)/);
if (match) {
const [, remoteName, owner] = match;
if (remoteName === "upstream") {
upstreamRepo = line.match(/[:/]([^/]+\/[^/\s]+?)(?:\.git)?\s+\(fetch\)/)?.[1] || null;
} else if (remoteName === "origin") {
originOwner = owner;
}
}
}
} catch {
// Couldn't parse remotes, continue without fork detection
}
// Build gh pr create command
let prCmd = `gh pr create --base "${base}"`;
// If this is a fork (has upstream remote), specify the repo and head
if (upstreamRepo && originOwner) {
// For forks: --repo specifies where to create PR, --head specifies source
prCmd += ` --repo "${upstreamRepo}" --head "${originOwner}:${branchName}"`;
} else {
// Not a fork, just specify the head branch
prCmd += ` --head "${branchName}"`;
}
prCmd += ` --title "${title.replace(/"/g, '\\"')}" --body "${body.replace(/"/g, '\\"')}" ${draftFlag}`;
prCmd = prCmd.trim();
console.log("[CreatePR] Running:", prCmd);
const { stdout: prOutput } = await execAsync(prCmd, {
cwd: worktreePath,
env: execEnv,
});
prUrl = prOutput.trim();
} catch (ghError: unknown) {
// gh CLI not available or PR creation failed
const err = ghError as { stderr?: string; message?: string };
prError = err.stderr || err.message || "PR creation failed";
console.warn("[CreatePR] gh CLI error:", prError);
}
// Return result with any error info
res.json({
success: true,
result: {
branch: branchName,
committed: hasChanges,
commitHash,
pushed: true,
prUrl,
prCreated: !!prUrl,
prError: prError || undefined,
},
});
} catch (error) {
logError(error, "Create PR failed");
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,113 @@
/**
* POST /create endpoint - Create a new git worktree
*/
import type { Request, Response } from "express";
import { exec } from "child_process";
import { promisify } from "util";
import path from "path";
import fs from "fs/promises";
import { isGitRepo, getErrorMessage, logError } from "../common.js";
const execAsync = promisify(exec);
export function createCreateHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, branchName, baseBranch } = req.body as {
projectPath: string;
branchName: string;
baseBranch?: string; // Optional base branch to create from (defaults to current HEAD)
};
if (!projectPath || !branchName) {
res.status(400).json({
success: false,
error: "projectPath and branchName required",
});
return;
}
if (!(await isGitRepo(projectPath))) {
res.status(400).json({
success: false,
error: "Not a git repository",
});
return;
}
// Sanitize branch name for directory usage
const sanitizedName = branchName.replace(/[^a-zA-Z0-9_-]/g, "-");
const worktreesDir = path.join(projectPath, ".worktrees");
const worktreePath = path.join(worktreesDir, sanitizedName);
// Create worktrees directory if it doesn't exist
await fs.mkdir(worktreesDir, { recursive: true });
// Check if worktree already exists
try {
await fs.access(worktreePath);
res.status(400).json({
success: false,
error: `Worktree for branch '${branchName}' already exists`,
});
return;
} catch {
// Worktree doesn't exist, good to proceed
}
// Check if branch exists
let branchExists = false;
try {
await execAsync(`git rev-parse --verify ${branchName}`, {
cwd: projectPath,
});
branchExists = true;
} catch {
// Branch doesn't exist
}
// Create worktree
let createCmd: string;
if (branchExists) {
// Use existing branch
createCmd = `git worktree add "${worktreePath}" ${branchName}`;
} else {
// Create new branch from base or HEAD
const base = baseBranch || "HEAD";
createCmd = `git worktree add -b ${branchName} "${worktreePath}" ${base}`;
}
await execAsync(createCmd, { cwd: projectPath });
// Symlink .automaker directory to worktree so features are shared
const mainAutomaker = path.join(projectPath, ".automaker");
const worktreeAutomaker = path.join(worktreePath, ".automaker");
try {
// Check if .automaker exists in main project
await fs.access(mainAutomaker);
// Create symlink in worktree pointing to main .automaker
// Use 'junction' on Windows, 'dir' on other platforms
const symlinkType = process.platform === "win32" ? "junction" : "dir";
await fs.symlink(mainAutomaker, worktreeAutomaker, symlinkType);
} catch (symlinkError) {
// .automaker doesn't exist or symlink failed
// Log but don't fail - worktree is still usable without shared .automaker
console.warn("[Worktree] Could not create .automaker symlink:", symlinkError);
}
res.json({
success: true,
worktree: {
path: worktreePath,
branch: branchName,
isNew: !branchExists,
},
});
} catch (error) {
logError(error, "Create worktree failed");
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,79 @@
/**
* POST /delete endpoint - Delete a git worktree
*/
import type { Request, Response } from "express";
import { exec } from "child_process";
import { promisify } from "util";
import { isGitRepo, getErrorMessage, logError } from "../common.js";
const execAsync = promisify(exec);
export function createDeleteHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, worktreePath, deleteBranch } = req.body as {
projectPath: string;
worktreePath: string;
deleteBranch?: boolean; // Whether to also delete the branch
};
if (!projectPath || !worktreePath) {
res.status(400).json({
success: false,
error: "projectPath and worktreePath required",
});
return;
}
if (!(await isGitRepo(projectPath))) {
res.status(400).json({
success: false,
error: "Not a git repository",
});
return;
}
// Get branch name before removing worktree
let branchName: string | null = null;
try {
const { stdout } = await execAsync("git rev-parse --abbrev-ref HEAD", {
cwd: worktreePath,
});
branchName = stdout.trim();
} catch {
// Could not get branch name
}
// Remove the worktree
try {
await execAsync(`git worktree remove "${worktreePath}" --force`, {
cwd: projectPath,
});
} catch (error) {
// Try with prune if remove fails
await execAsync("git worktree prune", { cwd: projectPath });
}
// Optionally delete the branch
if (deleteBranch && branchName && branchName !== "main" && branchName !== "master") {
try {
await execAsync(`git branch -D ${branchName}`, { cwd: projectPath });
} catch {
// Branch deletion failed, not critical
}
}
res.json({
success: true,
deleted: {
worktreePath,
branch: deleteBranch ? branchName : null,
},
});
} catch (error) {
logError(error, "Delete worktree failed");
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,68 @@
/**
* POST /list-branches endpoint - List all local branches
*/
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);
interface BranchInfo {
name: string;
isCurrent: boolean;
isRemote: boolean;
}
export function createListBranchesHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath } = req.body as {
worktreePath: string;
};
if (!worktreePath) {
res.status(400).json({
success: false,
error: "worktreePath required",
});
return;
}
// Get current branch
const { stdout: currentBranchOutput } = await execAsync(
"git rev-parse --abbrev-ref HEAD",
{ cwd: worktreePath }
);
const currentBranch = currentBranchOutput.trim();
// List all local branches
const { stdout: branchesOutput } = await execAsync(
"git branch --format='%(refname:short)'",
{ cwd: worktreePath }
);
const branches: BranchInfo[] = branchesOutput
.trim()
.split("\n")
.filter((b) => b.trim())
.map((name) => ({
name: name.trim(),
isCurrent: name.trim() === currentBranch,
isRemote: false,
}));
res.json({
success: true,
result: {
currentBranch,
branches,
},
});
} catch (error) {
logError(error, "List branches failed");
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -9,10 +9,21 @@ import { isGitRepo, getErrorMessage, logError } from "../common.js";
const execAsync = promisify(exec);
interface WorktreeInfo {
path: string;
branch: string;
isMain: boolean;
hasChanges?: boolean;
changedFilesCount?: number;
}
export function createListHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath } = req.body as { projectPath: string };
const { projectPath, includeDetails } = req.body as {
projectPath: string;
includeDetails?: boolean;
};
if (!projectPath) {
res.status(400).json({ success: false, error: "projectPath required" });
@@ -28,9 +39,10 @@ export function createListHandler() {
cwd: projectPath,
});
const worktrees: Array<{ path: string; branch: string }> = [];
const worktrees: WorktreeInfo[] = [];
const lines = stdout.split("\n");
let current: { path?: string; branch?: string } = {};
let isFirst = true;
for (const line of lines) {
if (line.startsWith("worktree ")) {
@@ -39,12 +51,37 @@ export function createListHandler() {
current.branch = line.slice(7).replace("refs/heads/", "");
} else if (line === "") {
if (current.path && current.branch) {
worktrees.push({ path: current.path, branch: current.branch });
// The first worktree in the list is always the main worktree
worktrees.push({
path: current.path,
branch: current.branch,
isMain: isFirst
});
isFirst = false;
}
current = {};
}
}
// If includeDetails is requested, fetch change status for each worktree
if (includeDetails) {
for (const worktree of worktrees) {
try {
const { stdout: statusOutput } = await execAsync(
"git status --porcelain",
{ cwd: worktree.path }
);
const changedFiles = statusOutput.trim().split("\n").filter(line => line.trim());
worktree.hasChanges = changedFiles.length > 0;
worktree.changedFilesCount = changedFiles.length;
} catch {
// If we can't get status, assume no changes
worktree.hasChanges = false;
worktree.changedFilesCount = 0;
}
}
}
res.json({ success: true, worktrees });
} catch (error) {
logError(error, "List worktrees failed");

View File

@@ -0,0 +1,92 @@
/**
* POST /pull endpoint - Pull latest changes for a worktree/branch
*/
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);
export function createPullHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath } = req.body as {
worktreePath: string;
};
if (!worktreePath) {
res.status(400).json({
success: false,
error: "worktreePath required",
});
return;
}
// Get current branch name
const { stdout: branchOutput } = await execAsync(
"git rev-parse --abbrev-ref HEAD",
{ cwd: worktreePath }
);
const branchName = branchOutput.trim();
// Fetch latest from remote
await execAsync("git fetch origin", { cwd: worktreePath });
// Check if there are local changes that would be overwritten
const { stdout: status } = await execAsync("git status --porcelain", {
cwd: worktreePath,
});
const hasLocalChanges = status.trim().length > 0;
if (hasLocalChanges) {
res.status(400).json({
success: false,
error: "You have local changes. Please commit or stash them before pulling.",
});
return;
}
// Pull latest changes
try {
const { stdout: pullOutput } = await execAsync(
`git pull origin ${branchName}`,
{ cwd: worktreePath }
);
// Check if we pulled any changes
const alreadyUpToDate = pullOutput.includes("Already up to date");
res.json({
success: true,
result: {
branch: branchName,
pulled: !alreadyUpToDate,
message: alreadyUpToDate ? "Already up to date" : "Pulled latest changes",
},
});
} catch (pullError: unknown) {
const err = pullError as { stderr?: string; message?: string };
const errorMsg = err.stderr || err.message || "Pull failed";
// Check for common errors
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=origin/${branchName}`,
});
return;
}
res.status(500).json({
success: false,
error: errorMsg,
});
}
} catch (error) {
logError(error, "Pull failed");
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,60 @@
/**
* POST /push endpoint - Push a worktree branch to remote
*/
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);
export function createPushHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath, force } = req.body as {
worktreePath: string;
force?: boolean;
};
if (!worktreePath) {
res.status(400).json({
success: false,
error: "worktreePath required",
});
return;
}
// Get branch name
const { stdout: branchOutput } = await execAsync(
"git rev-parse --abbrev-ref HEAD",
{ cwd: worktreePath }
);
const branchName = branchOutput.trim();
// Push the branch
const forceFlag = force ? "--force" : "";
try {
await execAsync(`git push -u origin ${branchName} ${forceFlag}`, {
cwd: worktreePath,
});
} catch {
// Try setting upstream
await execAsync(`git push --set-upstream origin ${branchName} ${forceFlag}`, {
cwd: worktreePath,
});
}
res.json({
success: true,
result: {
branch: branchName,
pushed: true,
},
});
} catch (error) {
logError(error, "Push worktree failed");
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,151 @@
/**
* POST /switch-branch endpoint - Switch to an existing branch
* Automatically stashes uncommitted changes and pops them after switching
*/
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);
export function createSwitchBranchHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath, branchName } = req.body as {
worktreePath: string;
branchName: string;
};
if (!worktreePath) {
res.status(400).json({
success: false,
error: "worktreePath required",
});
return;
}
if (!branchName) {
res.status(400).json({
success: false,
error: "branchName required",
});
return;
}
// Get current branch for reference
const { stdout: currentBranchOutput } = await execAsync(
"git rev-parse --abbrev-ref HEAD",
{ cwd: worktreePath }
);
const previousBranch = currentBranchOutput.trim();
if (previousBranch === branchName) {
res.json({
success: true,
result: {
previousBranch,
currentBranch: branchName,
message: `Already on branch '${branchName}'`,
stashed: false,
},
});
return;
}
// Check if branch exists
try {
await execAsync(`git rev-parse --verify ${branchName}`, {
cwd: worktreePath,
});
} catch {
res.status(400).json({
success: false,
error: `Branch '${branchName}' does not exist`,
});
return;
}
// Check for uncommitted changes
const { stdout: statusOutput } = await execAsync(
"git status --porcelain",
{ cwd: worktreePath }
);
const hasChanges = statusOutput.trim().length > 0;
let stashed = false;
// Stash changes if there are any
if (hasChanges) {
await execAsync("git stash push -m \"auto-stash before branch switch\"", {
cwd: worktreePath,
});
stashed = true;
}
try {
// Switch to the branch
await execAsync(`git checkout ${branchName}`, {
cwd: worktreePath,
});
// Pop the stash if we stashed changes
if (stashed) {
try {
await execAsync("git stash pop", {
cwd: worktreePath,
});
} catch (stashPopError) {
// Stash pop might fail due to conflicts
const err = stashPopError as { stderr?: string; message?: string };
const errorMsg = err.stderr || err.message || "";
if (errorMsg.includes("CONFLICT") || errorMsg.includes("conflict")) {
res.json({
success: true,
result: {
previousBranch,
currentBranch: branchName,
message: `Switched to '${branchName}' but stash had conflicts. Please resolve manually.`,
stashed: true,
stashConflict: true,
},
});
return;
}
// Re-throw if it's not a conflict error
throw stashPopError;
}
}
const message = stashed
? `Switched to branch '${branchName}' (changes stashed and restored)`
: `Switched to branch '${branchName}'`;
res.json({
success: true,
result: {
previousBranch,
currentBranch: branchName,
message,
stashed,
},
});
} catch (checkoutError) {
// If checkout fails and we stashed, try to restore the stash
if (stashed) {
try {
await execAsync("git stash pop", { cwd: worktreePath });
} catch {
// Ignore stash pop errors during recovery
}
}
throw checkoutError;
}
} catch (error) {
logError(error, "Switch branch failed");
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}