mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-01 08:13:37 +00:00
Implement branch selection and worktree management features
- Added a new BranchAutocomplete component for selecting branches in feature dialogs. - Enhanced BoardView to fetch and display branch suggestions. - Updated CreateWorktreeDialog and EditFeatureDialog to include branch selection. - Modified worktree management to ensure proper handling of branch-specific worktrees. - Refactored related components and hooks to support the new branch management functionality. - Removed unused revert and merge handlers from Kanban components for cleaner code.
This commit is contained in:
@@ -19,6 +19,13 @@ 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";
|
||||
import { createOpenInEditorHandler } from "./routes/open-in-editor.js";
|
||||
import { createInitGitHandler } from "./routes/init-git.js";
|
||||
import { createActivateHandler } from "./routes/activate.js";
|
||||
import { createMigrateHandler } from "./routes/migrate.js";
|
||||
import { createStartDevHandler } from "./routes/start-dev.js";
|
||||
import { createStopDevHandler } from "./routes/stop-dev.js";
|
||||
import { createListDevServersHandler } from "./routes/list-dev-servers.js";
|
||||
|
||||
export function createWorktreeRoutes(): Router {
|
||||
const router = Router();
|
||||
@@ -39,6 +46,13 @@ export function createWorktreeRoutes(): Router {
|
||||
router.post("/checkout-branch", createCheckoutBranchHandler());
|
||||
router.post("/list-branches", createListBranchesHandler());
|
||||
router.post("/switch-branch", createSwitchBranchHandler());
|
||||
router.post("/open-in-editor", createOpenInEditorHandler());
|
||||
router.post("/init-git", createInitGitHandler());
|
||||
router.post("/activate", createActivateHandler());
|
||||
router.post("/migrate", createMigrateHandler());
|
||||
router.post("/start-dev", createStartDevHandler());
|
||||
router.post("/stop-dev", createStopDevHandler());
|
||||
router.post("/list-dev-servers", createListDevServersHandler());
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
149
apps/server/src/routes/worktree/routes/activate.ts
Normal file
149
apps/server/src/routes/worktree/routes/activate.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* POST /activate endpoint - Switch main project to a worktree's branch
|
||||
*
|
||||
* This allows users to "activate" a worktree so their running dev server
|
||||
* (like Vite) shows the worktree's files. It does this by:
|
||||
* 1. Checking for uncommitted changes (fails if found)
|
||||
* 2. Removing the worktree (unlocks the branch)
|
||||
* 3. Checking out that branch in the main directory
|
||||
*
|
||||
* Users should commit their changes before activating 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);
|
||||
|
||||
async function hasUncommittedChanges(cwd: string): Promise<boolean> {
|
||||
try {
|
||||
const { stdout } = await execAsync("git status --porcelain", { cwd });
|
||||
// Filter out our own .worktrees directory from the check
|
||||
const lines = stdout.trim().split("\n").filter((line) => {
|
||||
if (!line.trim()) return false;
|
||||
// Exclude .worktrees/ directory (created by automaker)
|
||||
if (line.includes(".worktrees/") || line.endsWith(".worktrees")) return false;
|
||||
return true;
|
||||
});
|
||||
return lines.length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function getCurrentBranch(cwd: string): Promise<string> {
|
||||
const { stdout } = await execAsync("git branch --show-current", { cwd });
|
||||
return stdout.trim();
|
||||
}
|
||||
|
||||
async function getWorktreeBranch(worktreePath: string): Promise<string> {
|
||||
const { stdout } = await execAsync("git branch --show-current", {
|
||||
cwd: worktreePath,
|
||||
});
|
||||
return stdout.trim();
|
||||
}
|
||||
|
||||
async function getChangesSummary(cwd: string): Promise<string> {
|
||||
try {
|
||||
const { stdout } = await execAsync("git status --short", { cwd });
|
||||
const lines = stdout.trim().split("\n").filter((line) => {
|
||||
if (!line.trim()) return false;
|
||||
// Exclude .worktrees/ directory
|
||||
if (line.includes(".worktrees/") || line.endsWith(".worktrees")) return false;
|
||||
return true;
|
||||
});
|
||||
if (lines.length === 0) return "";
|
||||
if (lines.length <= 5) return lines.join(", ");
|
||||
return `${lines.slice(0, 5).join(", ")} and ${lines.length - 5} more files`;
|
||||
} catch {
|
||||
return "unknown changes";
|
||||
}
|
||||
}
|
||||
|
||||
export function createActivateHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, worktreePath } = req.body as {
|
||||
projectPath: string;
|
||||
worktreePath: string | null; // null means switch back to main branch
|
||||
};
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: "projectPath is required",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const currentBranch = await getCurrentBranch(projectPath);
|
||||
let targetBranch: string;
|
||||
|
||||
// Check for uncommitted changes in main directory
|
||||
if (await hasUncommittedChanges(projectPath)) {
|
||||
const summary = await getChangesSummary(projectPath);
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: `Cannot switch: you have uncommitted changes in the main directory (${summary}). Please commit your changes first.`,
|
||||
code: "UNCOMMITTED_CHANGES",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (worktreePath) {
|
||||
// Switching to a worktree's branch
|
||||
targetBranch = await getWorktreeBranch(worktreePath);
|
||||
|
||||
// Check for uncommitted changes in the worktree
|
||||
if (await hasUncommittedChanges(worktreePath)) {
|
||||
const summary = await getChangesSummary(worktreePath);
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: `Cannot switch: you have uncommitted changes in the worktree (${summary}). Please commit your changes first.`,
|
||||
code: "UNCOMMITTED_CHANGES",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove the worktree (unlocks the branch)
|
||||
console.log(`[activate] Removing worktree at ${worktreePath}...`);
|
||||
await execAsync(`git worktree remove "${worktreePath}" --force`, {
|
||||
cwd: projectPath,
|
||||
});
|
||||
|
||||
// Checkout the branch in main directory
|
||||
console.log(`[activate] Checking out branch ${targetBranch}...`);
|
||||
await execAsync(`git checkout "${targetBranch}"`, { cwd: projectPath });
|
||||
} else {
|
||||
// Switching back to main branch
|
||||
try {
|
||||
const { stdout: mainBranch } = await execAsync(
|
||||
"git symbolic-ref refs/remotes/origin/HEAD --short 2>/dev/null | sed 's@origin/@@' || echo 'main'",
|
||||
{ cwd: projectPath }
|
||||
);
|
||||
targetBranch = mainBranch.trim() || "main";
|
||||
} catch {
|
||||
targetBranch = "main";
|
||||
}
|
||||
|
||||
// Checkout main branch
|
||||
console.log(`[activate] Checking out main branch ${targetBranch}...`);
|
||||
await execAsync(`git checkout "${targetBranch}"`, { cwd: projectPath });
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
result: {
|
||||
previousBranch: currentBranch,
|
||||
currentBranch: targetBranch,
|
||||
message: `Switched from ${currentBranch} to ${targetBranch}`,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, "Activate worktree failed");
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
125
apps/server/src/routes/worktree/routes/branch-tracking.ts
Normal file
125
apps/server/src/routes/worktree/routes/branch-tracking.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Branch tracking utilities
|
||||
*
|
||||
* Tracks active branches in external automaker storage so users
|
||||
* can switch between branches even after worktrees are removed.
|
||||
*
|
||||
* Data is stored outside the git repo to avoid worktree/symlink conflicts.
|
||||
*/
|
||||
|
||||
import { readFile, writeFile } from "fs/promises";
|
||||
import path from "path";
|
||||
import {
|
||||
getBranchTrackingPath,
|
||||
ensureAutomakerDir,
|
||||
} from "../../../lib/automaker-paths.js";
|
||||
|
||||
export interface TrackedBranch {
|
||||
name: string;
|
||||
createdAt: string;
|
||||
lastActivatedAt?: string;
|
||||
}
|
||||
|
||||
interface BranchTrackingData {
|
||||
branches: TrackedBranch[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Read tracked branches from file
|
||||
*/
|
||||
export async function getTrackedBranches(
|
||||
projectPath: string
|
||||
): Promise<TrackedBranch[]> {
|
||||
try {
|
||||
const filePath = await getBranchTrackingPath(projectPath);
|
||||
const content = await readFile(filePath, "utf-8");
|
||||
const data: BranchTrackingData = JSON.parse(content);
|
||||
return data.branches || [];
|
||||
} catch (error: any) {
|
||||
if (error.code === "ENOENT") {
|
||||
return [];
|
||||
}
|
||||
console.warn("[branch-tracking] Failed to read tracked branches:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save tracked branches to file
|
||||
*/
|
||||
async function saveTrackedBranches(
|
||||
projectPath: string,
|
||||
branches: TrackedBranch[]
|
||||
): Promise<void> {
|
||||
const automakerDir = await ensureAutomakerDir(projectPath);
|
||||
const filePath = path.join(automakerDir, "active-branches.json");
|
||||
const data: BranchTrackingData = { branches };
|
||||
await writeFile(filePath, JSON.stringify(data, null, 2), "utf-8");
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a branch to tracking
|
||||
*/
|
||||
export async function trackBranch(
|
||||
projectPath: string,
|
||||
branchName: string
|
||||
): Promise<void> {
|
||||
const branches = await getTrackedBranches(projectPath);
|
||||
|
||||
// Check if already tracked
|
||||
const existing = branches.find((b) => b.name === branchName);
|
||||
if (existing) {
|
||||
return; // Already tracked
|
||||
}
|
||||
|
||||
branches.push({
|
||||
name: branchName,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
await saveTrackedBranches(projectPath, branches);
|
||||
console.log(`[branch-tracking] Now tracking branch: ${branchName}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a branch from tracking
|
||||
*/
|
||||
export async function untrackBranch(
|
||||
projectPath: string,
|
||||
branchName: string
|
||||
): Promise<void> {
|
||||
const branches = await getTrackedBranches(projectPath);
|
||||
const filtered = branches.filter((b) => b.name !== branchName);
|
||||
|
||||
if (filtered.length !== branches.length) {
|
||||
await saveTrackedBranches(projectPath, filtered);
|
||||
console.log(`[branch-tracking] Stopped tracking branch: ${branchName}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update last activated timestamp for a branch
|
||||
*/
|
||||
export async function updateBranchActivation(
|
||||
projectPath: string,
|
||||
branchName: string
|
||||
): Promise<void> {
|
||||
const branches = await getTrackedBranches(projectPath);
|
||||
const branch = branches.find((b) => b.name === branchName);
|
||||
|
||||
if (branch) {
|
||||
branch.lastActivatedAt = new Date().toISOString();
|
||||
await saveTrackedBranches(projectPath, branches);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a branch is tracked
|
||||
*/
|
||||
export async function isBranchTracked(
|
||||
projectPath: string,
|
||||
branchName: string
|
||||
): Promise<boolean> {
|
||||
const branches = await getTrackedBranches(projectPath);
|
||||
return branches.some((b) => b.name === branchName);
|
||||
}
|
||||
@@ -6,8 +6,9 @@ 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 { mkdir, access } from "fs/promises";
|
||||
import { isGitRepo, getErrorMessage, logError } from "../common.js";
|
||||
import { trackBranch } from "./branch-tracking.js";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
@@ -42,14 +43,19 @@ export function createCreateHandler() {
|
||||
const worktreePath = path.join(worktreesDir, sanitizedName);
|
||||
|
||||
// Create worktrees directory if it doesn't exist
|
||||
await fs.mkdir(worktreesDir, { recursive: true });
|
||||
await 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`,
|
||||
await access(worktreePath);
|
||||
// Worktree already exists, return it instead of error
|
||||
res.json({
|
||||
success: true,
|
||||
worktree: {
|
||||
path: worktreePath,
|
||||
branch: branchName,
|
||||
isNew: false,
|
||||
},
|
||||
});
|
||||
return;
|
||||
} catch {
|
||||
@@ -80,22 +86,12 @@ export function createCreateHandler() {
|
||||
|
||||
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");
|
||||
// Note: We intentionally do NOT symlink .automaker to worktrees
|
||||
// Features and config are always accessed from the main project path
|
||||
// This avoids symlink loop issues when activating worktrees
|
||||
|
||||
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);
|
||||
}
|
||||
// Track the branch so it persists in the UI even after worktree is removed
|
||||
await trackBranch(projectPath, branchName);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
|
||||
@@ -29,12 +29,8 @@ export function createDiffsHandler() {
|
||||
return;
|
||||
}
|
||||
|
||||
const worktreePath = path.join(
|
||||
projectPath,
|
||||
".automaker",
|
||||
"worktrees",
|
||||
featureId
|
||||
);
|
||||
// Git worktrees are stored in project directory
|
||||
const worktreePath = path.join(projectPath, ".worktrees", featureId);
|
||||
|
||||
try {
|
||||
await fs.access(worktreePath);
|
||||
|
||||
@@ -28,12 +28,8 @@ export function createFileDiffHandler() {
|
||||
return;
|
||||
}
|
||||
|
||||
const worktreePath = path.join(
|
||||
projectPath,
|
||||
".automaker",
|
||||
"worktrees",
|
||||
featureId
|
||||
);
|
||||
// Git worktrees are stored in project directory
|
||||
const worktreePath = path.join(projectPath, ".worktrees", featureId);
|
||||
|
||||
try {
|
||||
await fs.access(worktreePath);
|
||||
|
||||
@@ -29,13 +29,8 @@ export function createInfoHandler() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if worktree exists
|
||||
const worktreePath = path.join(
|
||||
projectPath,
|
||||
".automaker",
|
||||
"worktrees",
|
||||
featureId
|
||||
);
|
||||
// Check if worktree exists (git worktrees are stored in project directory)
|
||||
const worktreePath = path.join(projectPath, ".worktrees", featureId);
|
||||
try {
|
||||
await fs.access(worktreePath);
|
||||
const { stdout } = await execAsync("git rev-parse --abbrev-ref HEAD", {
|
||||
|
||||
60
apps/server/src/routes/worktree/routes/init-git.ts
Normal file
60
apps/server/src/routes/worktree/routes/init-git.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* POST /init-git endpoint - Initialize a git repository in a directory
|
||||
*/
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import { existsSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { getErrorMessage, logError } from "../common.js";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
export function createInitGitHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath } = req.body as {
|
||||
projectPath: string;
|
||||
};
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: "projectPath required",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if .git already exists
|
||||
const gitDirPath = join(projectPath, ".git");
|
||||
if (existsSync(gitDirPath)) {
|
||||
res.json({
|
||||
success: true,
|
||||
result: {
|
||||
initialized: false,
|
||||
message: "Git repository already exists",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize git and create an initial empty commit
|
||||
await execAsync(
|
||||
`git init && git commit --allow-empty -m "Initial commit"`,
|
||||
{ cwd: projectPath }
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
result: {
|
||||
initialized: true,
|
||||
message: "Git repository initialized with initial commit",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, "Init git failed");
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
29
apps/server/src/routes/worktree/routes/list-dev-servers.ts
Normal file
29
apps/server/src/routes/worktree/routes/list-dev-servers.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* POST /list-dev-servers endpoint - List all running dev servers
|
||||
*
|
||||
* Returns information about all worktree dev servers currently running,
|
||||
* including their ports and URLs.
|
||||
*/
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import { getDevServerService } from "../../../services/dev-server-service.js";
|
||||
import { getErrorMessage, logError } from "../common.js";
|
||||
|
||||
export function createListDevServersHandler() {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const devServerService = getDevServerService();
|
||||
const result = devServerService.listDevServers();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
result: {
|
||||
servers: result.result.servers,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, "List dev servers failed");
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
/**
|
||||
* POST /list endpoint - List all worktrees
|
||||
* POST /list endpoint - List all git worktrees
|
||||
*
|
||||
* Returns actual git worktrees from `git worktree list`.
|
||||
* Does NOT include tracked branches - only real worktrees with separate directories.
|
||||
*/
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
@@ -13,10 +16,21 @@ interface WorktreeInfo {
|
||||
path: string;
|
||||
branch: string;
|
||||
isMain: boolean;
|
||||
isCurrent: boolean; // Is this the currently checked out branch in main?
|
||||
hasWorktree: boolean; // Always true for items in this list
|
||||
hasChanges?: boolean;
|
||||
changedFilesCount?: number;
|
||||
}
|
||||
|
||||
async function getCurrentBranch(cwd: string): Promise<string> {
|
||||
try {
|
||||
const { stdout } = await execAsync("git branch --show-current", { cwd });
|
||||
return stdout.trim();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export function createListHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
@@ -35,6 +49,10 @@ export function createListHandler() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get current branch in main directory
|
||||
const currentBranch = await getCurrentBranch(projectPath);
|
||||
|
||||
// Get actual worktrees from git
|
||||
const { stdout } = await execAsync("git worktree list --porcelain", {
|
||||
cwd: projectPath,
|
||||
});
|
||||
@@ -51,11 +69,12 @@ export function createListHandler() {
|
||||
current.branch = line.slice(7).replace("refs/heads/", "");
|
||||
} else if (line === "") {
|
||||
if (current.path && current.branch) {
|
||||
// The first worktree in the list is always the main worktree
|
||||
worktrees.push({
|
||||
path: current.path,
|
||||
branch: current.branch,
|
||||
isMain: isFirst
|
||||
isMain: isFirst,
|
||||
isCurrent: current.branch === currentBranch,
|
||||
hasWorktree: true,
|
||||
});
|
||||
isFirst = false;
|
||||
}
|
||||
@@ -71,11 +90,13 @@ export function createListHandler() {
|
||||
"git status --porcelain",
|
||||
{ cwd: worktree.path }
|
||||
);
|
||||
const changedFiles = statusOutput.trim().split("\n").filter(line => line.trim());
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -30,12 +30,8 @@ export function createMergeHandler() {
|
||||
}
|
||||
|
||||
const branchName = `feature/${featureId}`;
|
||||
const worktreePath = path.join(
|
||||
projectPath,
|
||||
".automaker",
|
||||
"worktrees",
|
||||
featureId
|
||||
);
|
||||
// Git worktrees are stored in project directory
|
||||
const worktreePath = path.join(projectPath, ".worktrees", featureId);
|
||||
|
||||
// Get current branch
|
||||
const { stdout: currentBranch } = await execAsync(
|
||||
|
||||
63
apps/server/src/routes/worktree/routes/migrate.ts
Normal file
63
apps/server/src/routes/worktree/routes/migrate.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* POST /migrate endpoint - Migrate legacy .automaker data to external storage
|
||||
*
|
||||
* This endpoint checks if there's legacy .automaker data in the project directory
|
||||
* and migrates it to the external ~/.automaker/projects/{project-id}/ location.
|
||||
*/
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import { getErrorMessage, logError } from "../common.js";
|
||||
import {
|
||||
hasLegacyAutomakerDir,
|
||||
migrateLegacyData,
|
||||
getAutomakerDir,
|
||||
getLegacyAutomakerDir,
|
||||
} from "../../../lib/automaker-paths.js";
|
||||
|
||||
export function createMigrateHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath } = req.body as { projectPath: string };
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: "projectPath is required",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if migration is needed
|
||||
const hasLegacy = await hasLegacyAutomakerDir(projectPath);
|
||||
|
||||
if (!hasLegacy) {
|
||||
const automakerDir = await getAutomakerDir(projectPath);
|
||||
res.json({
|
||||
success: true,
|
||||
migrated: false,
|
||||
message: "No legacy .automaker directory found - nothing to migrate",
|
||||
externalPath: automakerDir,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Perform migration
|
||||
console.log(`[migrate] Starting migration for project: ${projectPath}`);
|
||||
const legacyPath = await getLegacyAutomakerDir(projectPath);
|
||||
const externalPath = await getAutomakerDir(projectPath);
|
||||
|
||||
await migrateLegacyData(projectPath);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
migrated: true,
|
||||
message: "Successfully migrated .automaker data to external storage",
|
||||
legacyPath,
|
||||
externalPath,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, "Migration failed");
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
73
apps/server/src/routes/worktree/routes/open-in-editor.ts
Normal file
73
apps/server/src/routes/worktree/routes/open-in-editor.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* POST /open-in-editor endpoint - Open a worktree directory in VS Code
|
||||
*/
|
||||
|
||||
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 createOpenInEditorHandler() {
|
||||
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;
|
||||
}
|
||||
|
||||
// Try to open in VS Code
|
||||
try {
|
||||
await execAsync(`code "${worktreePath}"`);
|
||||
res.json({
|
||||
success: true,
|
||||
result: {
|
||||
message: `Opened ${worktreePath} in VS Code`,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// If 'code' command fails, try 'cursor' (for Cursor editor)
|
||||
try {
|
||||
await execAsync(`cursor "${worktreePath}"`);
|
||||
res.json({
|
||||
success: true,
|
||||
result: {
|
||||
message: `Opened ${worktreePath} in Cursor`,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// If both fail, try opening in default file manager
|
||||
const platform = process.platform;
|
||||
let openCommand: string;
|
||||
|
||||
if (platform === "darwin") {
|
||||
openCommand = `open "${worktreePath}"`;
|
||||
} else if (platform === "win32") {
|
||||
openCommand = `explorer "${worktreePath}"`;
|
||||
} else {
|
||||
openCommand = `xdg-open "${worktreePath}"`;
|
||||
}
|
||||
|
||||
await execAsync(openCommand);
|
||||
res.json({
|
||||
success: true,
|
||||
result: {
|
||||
message: `Opened ${worktreePath} in file manager`,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logError(error, "Open in editor failed");
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -43,7 +43,7 @@ export function createPullHandler() {
|
||||
if (hasLocalChanges) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: "You have local changes. Please commit or stash them before pulling.",
|
||||
error: "You have local changes. Please commit them before pulling.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -28,12 +28,8 @@ export function createRevertHandler() {
|
||||
return;
|
||||
}
|
||||
|
||||
const worktreePath = path.join(
|
||||
projectPath,
|
||||
".automaker",
|
||||
"worktrees",
|
||||
featureId
|
||||
);
|
||||
// Git worktrees are stored in project directory
|
||||
const worktreePath = path.join(projectPath, ".worktrees", featureId);
|
||||
|
||||
try {
|
||||
// Remove worktree
|
||||
|
||||
61
apps/server/src/routes/worktree/routes/start-dev.ts
Normal file
61
apps/server/src/routes/worktree/routes/start-dev.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* POST /start-dev endpoint - Start a dev server for a worktree
|
||||
*
|
||||
* Spins up a development server (npm run dev) in the worktree directory
|
||||
* on a unique port, allowing preview of the worktree's changes without
|
||||
* affecting the main dev server.
|
||||
*/
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import { getDevServerService } from "../../../services/dev-server-service.js";
|
||||
import { getErrorMessage, logError } from "../common.js";
|
||||
|
||||
export function createStartDevHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, worktreePath } = req.body as {
|
||||
projectPath: string;
|
||||
worktreePath: string;
|
||||
};
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: "projectPath is required",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!worktreePath) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: "worktreePath is required",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const devServerService = getDevServerService();
|
||||
const result = await devServerService.startDevServer(projectPath, worktreePath);
|
||||
|
||||
if (result.success && result.result) {
|
||||
res.json({
|
||||
success: true,
|
||||
result: {
|
||||
worktreePath: result.result.worktreePath,
|
||||
port: result.result.port,
|
||||
url: result.result.url,
|
||||
message: result.result.message,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: result.error || "Failed to start dev server",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logError(error, "Start dev server failed");
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -29,12 +29,8 @@ export function createStatusHandler() {
|
||||
return;
|
||||
}
|
||||
|
||||
const worktreePath = path.join(
|
||||
projectPath,
|
||||
".automaker",
|
||||
"worktrees",
|
||||
featureId
|
||||
);
|
||||
// Git worktrees are stored in project directory
|
||||
const worktreePath = path.join(projectPath, ".worktrees", featureId);
|
||||
|
||||
try {
|
||||
await fs.access(worktreePath);
|
||||
|
||||
49
apps/server/src/routes/worktree/routes/stop-dev.ts
Normal file
49
apps/server/src/routes/worktree/routes/stop-dev.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* POST /stop-dev endpoint - Stop a dev server for a worktree
|
||||
*
|
||||
* Stops the development server running for a specific worktree,
|
||||
* freeing up the ports for reuse.
|
||||
*/
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import { getDevServerService } from "../../../services/dev-server-service.js";
|
||||
import { getErrorMessage, logError } from "../common.js";
|
||||
|
||||
export function createStopDevHandler() {
|
||||
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 is required",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const devServerService = getDevServerService();
|
||||
const result = await devServerService.stopDevServer(worktreePath);
|
||||
|
||||
if (result.success && result.result) {
|
||||
res.json({
|
||||
success: true,
|
||||
result: {
|
||||
worktreePath: result.result.worktreePath,
|
||||
message: result.result.message,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: result.error || "Failed to stop dev server",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logError(error, "Stop dev server failed");
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
/**
|
||||
* POST /switch-branch endpoint - Switch to an existing branch
|
||||
* Automatically stashes uncommitted changes and pops them after switching
|
||||
*
|
||||
* Simple branch switching.
|
||||
* If there are uncommitted changes, the switch will fail and
|
||||
* the user should commit first.
|
||||
*/
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
@@ -10,6 +13,46 @@ import { getErrorMessage, logError } from "../common.js";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
/**
|
||||
* Check if there are uncommitted changes in the working directory
|
||||
* Excludes .worktrees/ directory which is created by automaker
|
||||
*/
|
||||
async function hasUncommittedChanges(cwd: string): Promise<boolean> {
|
||||
try {
|
||||
const { stdout } = await execAsync("git status --porcelain", { cwd });
|
||||
const lines = stdout.trim().split("\n").filter((line) => {
|
||||
if (!line.trim()) return false;
|
||||
// Exclude .worktrees/ directory (created by automaker)
|
||||
if (line.includes(".worktrees/") || line.endsWith(".worktrees")) return false;
|
||||
return true;
|
||||
});
|
||||
return lines.length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a summary of uncommitted changes for user feedback
|
||||
* Excludes .worktrees/ directory
|
||||
*/
|
||||
async function getChangesSummary(cwd: string): Promise<string> {
|
||||
try {
|
||||
const { stdout } = await execAsync("git status --short", { cwd });
|
||||
const lines = stdout.trim().split("\n").filter((line) => {
|
||||
if (!line.trim()) return false;
|
||||
// Exclude .worktrees/ directory
|
||||
if (line.includes(".worktrees/") || line.endsWith(".worktrees")) return false;
|
||||
return true;
|
||||
});
|
||||
if (lines.length === 0) return "";
|
||||
if (lines.length <= 5) return lines.join(", ");
|
||||
return `${lines.slice(0, 5).join(", ")} and ${lines.length - 5} more files`;
|
||||
} catch {
|
||||
return "unknown changes";
|
||||
}
|
||||
}
|
||||
|
||||
export function createSwitchBranchHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
@@ -34,7 +77,7 @@ export function createSwitchBranchHandler() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get current branch for reference
|
||||
// Get current branch
|
||||
const { stdout: currentBranchOutput } = await execAsync(
|
||||
"git rev-parse --abbrev-ref HEAD",
|
||||
{ cwd: worktreePath }
|
||||
@@ -48,7 +91,6 @@ export function createSwitchBranchHandler() {
|
||||
previousBranch,
|
||||
currentBranch: branchName,
|
||||
message: `Already on branch '${branchName}'`,
|
||||
stashed: false,
|
||||
},
|
||||
});
|
||||
return;
|
||||
@@ -68,81 +110,27 @@ export function createSwitchBranchHandler() {
|
||||
}
|
||||
|
||||
// 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,
|
||||
if (await hasUncommittedChanges(worktreePath)) {
|
||||
const summary = await getChangesSummary(worktreePath);
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: `Cannot switch branches: you have uncommitted changes (${summary}). Please commit your changes first.`,
|
||||
code: "UNCOMMITTED_CHANGES",
|
||||
});
|
||||
stashed = true;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Switch to the branch
|
||||
await execAsync(`git checkout ${branchName}`, {
|
||||
cwd: worktreePath,
|
||||
});
|
||||
// Switch to the target 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;
|
||||
}
|
||||
res.json({
|
||||
success: true,
|
||||
result: {
|
||||
previousBranch,
|
||||
currentBranch: branchName,
|
||||
message: `Switched to branch '${branchName}'`,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, "Switch branch failed");
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
|
||||
Reference in New Issue
Block a user