mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-01 08:13:37 +00:00
adding branch switcher support
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
86
apps/server/src/routes/worktree/routes/checkout-branch.ts
Normal file
86
apps/server/src/routes/worktree/routes/checkout-branch.ts
Normal 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) });
|
||||
}
|
||||
};
|
||||
}
|
||||
79
apps/server/src/routes/worktree/routes/commit.ts
Normal file
79
apps/server/src/routes/worktree/routes/commit.ts
Normal 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) });
|
||||
}
|
||||
};
|
||||
}
|
||||
198
apps/server/src/routes/worktree/routes/create-pr.ts
Normal file
198
apps/server/src/routes/worktree/routes/create-pr.ts
Normal 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) });
|
||||
}
|
||||
};
|
||||
}
|
||||
113
apps/server/src/routes/worktree/routes/create.ts
Normal file
113
apps/server/src/routes/worktree/routes/create.ts
Normal 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) });
|
||||
}
|
||||
};
|
||||
}
|
||||
79
apps/server/src/routes/worktree/routes/delete.ts
Normal file
79
apps/server/src/routes/worktree/routes/delete.ts
Normal 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) });
|
||||
}
|
||||
};
|
||||
}
|
||||
68
apps/server/src/routes/worktree/routes/list-branches.ts
Normal file
68
apps/server/src/routes/worktree/routes/list-branches.ts
Normal 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) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
92
apps/server/src/routes/worktree/routes/pull.ts
Normal file
92
apps/server/src/routes/worktree/routes/pull.ts
Normal 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) });
|
||||
}
|
||||
};
|
||||
}
|
||||
60
apps/server/src/routes/worktree/routes/push.ts
Normal file
60
apps/server/src/routes/worktree/routes/push.ts
Normal 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) });
|
||||
}
|
||||
};
|
||||
}
|
||||
151
apps/server/src/routes/worktree/routes/switch-branch.ts
Normal file
151
apps/server/src/routes/worktree/routes/switch-branch.ts
Normal 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) });
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user