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:
Cody Seibert
2025-12-16 12:12:10 -05:00
parent 54a102f029
commit a3c9c9cee5
52 changed files with 2969 additions and 588 deletions

View File

@@ -3,13 +3,13 @@
*/
import { query } from "@anthropic-ai/claude-agent-sdk";
import path from "path";
import fs from "fs/promises";
import type { EventEmitter } from "../../lib/events.js";
import { createLogger } from "../../lib/logger.js";
import { createFeatureGenerationOptions } from "../../lib/sdk-options.js";
import { logAuthStatus } from "./common.js";
import { parseAndCreateFeatures } from "./parse-and-create-features.js";
import { getAppSpecPath } from "../../lib/automaker-paths.js";
const logger = createLogger("SpecRegeneration");
@@ -26,8 +26,8 @@ export async function generateFeaturesFromSpec(
logger.debug("projectPath:", projectPath);
logger.debug("maxFeatures:", featureCount);
// Read existing spec
const specPath = path.join(projectPath, ".automaker", "app_spec.txt");
// Read existing spec from external automaker directory
const specPath = await getAppSpecPath(projectPath);
let spec: string;
logger.debug("Reading spec from:", specPath);

View File

@@ -11,6 +11,7 @@ import { createLogger } from "../../lib/logger.js";
import { createSpecGenerationOptions } from "../../lib/sdk-options.js";
import { logAuthStatus } from "./common.js";
import { generateFeaturesFromSpec } from "./generate-features-from-spec.js";
import { ensureAutomakerDir, getAppSpecPath } from "../../lib/automaker-paths.js";
const logger = createLogger("SpecRegeneration");
@@ -209,14 +210,13 @@ ${getAppSpecFormatInstruction()}`;
logger.error("❌ WARNING: responseText is empty! Nothing to save.");
}
// Save spec
const specDir = path.join(projectPath, ".automaker");
const specPath = path.join(specDir, "app_spec.txt");
// Save spec to external automaker directory
const specDir = await ensureAutomakerDir(projectPath);
const specPath = await getAppSpecPath(projectPath);
logger.info("Saving spec to:", specPath);
logger.info(`Content to save (${responseText.length} chars)`);
await fs.mkdir(specDir, { recursive: true });
await fs.writeFile(specPath, responseText);
// Verify the file was written

View File

@@ -6,6 +6,7 @@ import path from "path";
import fs from "fs/promises";
import type { EventEmitter } from "../../lib/events.js";
import { createLogger } from "../../lib/logger.js";
import { getFeaturesDir } from "../../lib/automaker-paths.js";
const logger = createLogger("SpecRegeneration");
@@ -41,7 +42,7 @@ export async function parseAndCreateFeatures(
logger.info(`Parsed ${parsed.features?.length || 0} features`);
logger.info("Parsed features:", JSON.stringify(parsed.features, null, 2));
const featuresDir = path.join(projectPath, ".automaker", "features");
const featuresDir = await getFeaturesDir(projectPath);
await fs.mkdir(featuresDir, { recursive: true });
const createdFeatures: Array<{ id: string; title: string }> = [];

View File

@@ -9,9 +9,10 @@ import { getErrorMessage, logError } from "../common.js";
export function createCommitFeatureHandler(autoModeService: AutoModeService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, featureId } = req.body as {
const { projectPath, featureId, worktreePath } = req.body as {
projectPath: string;
featureId: string;
worktreePath?: string;
};
if (!projectPath || !featureId) {
@@ -26,7 +27,8 @@ export function createCommitFeatureHandler(autoModeService: AutoModeService) {
const commitHash = await autoModeService.commitFeature(
projectPath,
featureId
featureId,
worktreePath
);
res.json({ success: true, commitHash });
} catch (error) {

View File

@@ -12,11 +12,12 @@ const logger = createLogger("AutoMode");
export function createFollowUpFeatureHandler(autoModeService: AutoModeService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, featureId, prompt, imagePaths } = req.body as {
const { projectPath, featureId, prompt, imagePaths, worktreePath } = req.body as {
projectPath: string;
featureId: string;
prompt: string;
imagePaths?: string[];
worktreePath?: string;
};
if (!projectPath || !featureId || !prompt) {
@@ -27,9 +28,9 @@ export function createFollowUpFeatureHandler(autoModeService: AutoModeService) {
return;
}
// Start follow-up in background
// Start follow-up in background, using the feature's worktreePath for correct branch
autoModeService
.followUpFeature(projectPath, featureId, prompt, imagePaths)
.followUpFeature(projectPath, featureId, prompt, imagePaths, worktreePath)
.catch((error) => {
logger.error(
`[AutoMode] Follow up feature ${featureId} error:`,

View File

@@ -12,10 +12,11 @@ const logger = createLogger("AutoMode");
export function createRunFeatureHandler(autoModeService: AutoModeService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, featureId, useWorktrees } = req.body as {
const { projectPath, featureId, useWorktrees, worktreePath } = req.body as {
projectPath: string;
featureId: string;
useWorktrees?: boolean;
worktreePath?: string;
};
if (!projectPath || !featureId) {
@@ -29,8 +30,9 @@ export function createRunFeatureHandler(autoModeService: AutoModeService) {
}
// Start execution in background
// If worktreePath is provided, use it directly; otherwise let the service decide
autoModeService
.executeFeature(projectPath, featureId, useWorktrees ?? true, false)
.executeFeature(projectPath, featureId, useWorktrees ?? true, false, worktreePath)
.catch((error) => {
logger.error(`[AutoMode] Feature ${featureId} error:`, error);
});

View File

@@ -6,6 +6,7 @@ import type { Request, Response } from "express";
import fs from "fs/promises";
import path from "path";
import { getErrorMessage, logError } from "../common.js";
import { getBoardDir } from "../../../lib/automaker-paths.js";
export function createDeleteBoardBackgroundHandler() {
return async (req: Request, res: Response): Promise<void> => {
@@ -20,10 +21,11 @@ export function createDeleteBoardBackgroundHandler() {
return;
}
const boardDir = path.join(projectPath, ".automaker", "board");
// Get external board directory
const boardDir = await getBoardDir(projectPath);
try {
// Try to remove all files in the board directory
// Try to remove all background files in the board directory
const files = await fs.readdir(boardDir);
for (const file of files) {
if (file.startsWith("background")) {

View File

@@ -1,5 +1,6 @@
/**
* POST /mkdir endpoint - Create directory
* Handles symlinks safely to avoid ELOOP errors
*/
import type { Request, Response } from "express";
@@ -20,13 +21,46 @@ export function createMkdirHandler() {
const resolvedPath = path.resolve(dirPath);
// Check if path already exists using lstat (doesn't follow symlinks)
try {
const stats = await fs.lstat(resolvedPath);
// Path exists - if it's a directory or symlink, consider it success
if (stats.isDirectory() || stats.isSymbolicLink()) {
addAllowedPath(resolvedPath);
res.json({ success: true });
return;
}
// It's a file - can't create directory
res.status(400).json({
success: false,
error: "Path exists and is not a directory",
});
return;
} catch (statError: any) {
// ENOENT means path doesn't exist - we should create it
if (statError.code !== "ENOENT") {
// Some other error (could be ELOOP in parent path)
throw statError;
}
}
// Path doesn't exist, create it
await fs.mkdir(resolvedPath, { recursive: true });
// Add the new directory to allowed paths for tracking
addAllowedPath(resolvedPath);
res.json({ success: true });
} catch (error) {
} catch (error: any) {
// Handle ELOOP specifically
if (error.code === "ELOOP") {
logError(error, "Create directory failed - symlink loop detected");
res.status(400).json({
success: false,
error: "Cannot create directory: symlink loop detected in path",
});
return;
}
logError(error, "Create directory failed");
res.status(500).json({ success: false, error: getErrorMessage(error) });
}

View File

@@ -7,6 +7,7 @@ import fs from "fs/promises";
import path from "path";
import { addAllowedPath } from "../../../lib/security.js";
import { getErrorMessage, logError } from "../common.js";
import { getBoardDir } from "../../../lib/automaker-paths.js";
export function createSaveBoardBackgroundHandler() {
return async (req: Request, res: Response): Promise<void> => {
@@ -26,8 +27,8 @@ export function createSaveBoardBackgroundHandler() {
return;
}
// Create .automaker/board directory if it doesn't exist
const boardDir = path.join(projectPath, ".automaker", "board");
// Get external board directory
const boardDir = await getBoardDir(projectPath);
await fs.mkdir(boardDir, { recursive: true });
// Decode base64 data (remove data URL prefix if present)
@@ -42,12 +43,11 @@ export function createSaveBoardBackgroundHandler() {
// Write file
await fs.writeFile(filePath, buffer);
// Add project path to allowed paths if not already
addAllowedPath(projectPath);
// Add board directory to allowed paths
addAllowedPath(boardDir);
// Return the relative path for storage
const relativePath = `.automaker/board/${uniqueFilename}`;
res.json({ success: true, path: relativePath });
// Return the absolute path
res.json({ success: true, path: filePath });
} catch (error) {
logError(error, "Save board background failed");
res.status(500).json({ success: false, error: getErrorMessage(error) });

View File

@@ -1,5 +1,5 @@
/**
* POST /save-image endpoint - Save image to .automaker/images directory
* POST /save-image endpoint - Save image to external automaker images directory
*/
import type { Request, Response } from "express";
@@ -7,6 +7,7 @@ import fs from "fs/promises";
import path from "path";
import { addAllowedPath } from "../../../lib/security.js";
import { getErrorMessage, logError } from "../common.js";
import { getImagesDir } from "../../../lib/automaker-paths.js";
export function createSaveImageHandler() {
return async (req: Request, res: Response): Promise<void> => {
@@ -26,8 +27,8 @@ export function createSaveImageHandler() {
return;
}
// Create .automaker/images directory if it doesn't exist
const imagesDir = path.join(projectPath, ".automaker", "images");
// Get external images directory
const imagesDir = await getImagesDir(projectPath);
await fs.mkdir(imagesDir, { recursive: true });
// Decode base64 data (remove data URL prefix if present)
@@ -44,9 +45,10 @@ export function createSaveImageHandler() {
// Write file
await fs.writeFile(filePath, buffer);
// Add project path to allowed paths if not already
addAllowedPath(projectPath);
// Add automaker directory to allowed paths
addAllowedPath(imagesDir);
// Return the absolute path
res.json({ success: true, path: filePath });
} catch (error) {
logError(error, "Save image failed");

View File

@@ -7,6 +7,7 @@ import fs from "fs/promises";
import path from "path";
import { validatePath } from "../../../lib/security.js";
import { getErrorMessage, logError } from "../common.js";
import { mkdirSafe } from "../../../lib/fs-utils.js";
export function createWriteHandler() {
return async (req: Request, res: Response): Promise<void> => {
@@ -23,8 +24,8 @@ export function createWriteHandler() {
const resolvedPath = validatePath(filePath);
// Ensure parent directory exists
await fs.mkdir(path.dirname(resolvedPath), { recursive: true });
// Ensure parent directory exists (symlink-safe)
await mkdirSafe(path.dirname(resolvedPath));
await fs.writeFile(resolvedPath, content, "utf-8");
res.json({ success: true });

View File

@@ -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;
}

View 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) });
}
};
}

View 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);
}

View File

@@ -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,

View File

@@ -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);

View File

@@ -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);

View File

@@ -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", {

View 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) });
}
};
}

View 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) });
}
};
}

View File

@@ -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;
}

View File

@@ -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(

View 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) });
}
};
}

View 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) });
}
};
}

View File

@@ -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;
}

View File

@@ -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

View 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) });
}
};
}

View File

@@ -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);

View 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) });
}
};
}

View File

@@ -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) });