diff --git a/apps/app/src/components/views/board-view.tsx b/apps/app/src/components/views/board-view.tsx index ec2a3d18..040afb51 100644 --- a/apps/app/src/components/views/board-view.tsx +++ b/apps/app/src/components/views/board-view.tsx @@ -10,6 +10,7 @@ import { } from "@dnd-kit/core"; import { useAppStore, Feature } from "@/store/app-store"; import { getElectronAPI } from "@/lib/electron"; +import { pathsEqual } from "@/lib/utils"; import { BoardBackgroundModal } from "@/components/dialogs/board-background-modal"; import { RefreshCw } from "lucide-react"; import { useAutoMode } from "@/hooks/use-auto-mode"; @@ -51,7 +52,9 @@ import { } from "./board-view/hooks"; // Stable empty array to avoid infinite loop in selector -const EMPTY_WORKTREES: ReturnType['getWorktrees']> = []; +const EMPTY_WORKTREES: ReturnType< + ReturnType["getWorktrees"] +> = []; export function BoardView() { const { @@ -95,9 +98,12 @@ export function BoardView() { useState(null); // Worktree dialog states - const [showCreateWorktreeDialog, setShowCreateWorktreeDialog] = useState(false); - const [showDeleteWorktreeDialog, setShowDeleteWorktreeDialog] = useState(false); - const [showCommitWorktreeDialog, setShowCommitWorktreeDialog] = useState(false); + const [showCreateWorktreeDialog, setShowCreateWorktreeDialog] = + useState(false); + const [showDeleteWorktreeDialog, setShowDeleteWorktreeDialog] = + useState(false); + const [showCommitWorktreeDialog, setShowCommitWorktreeDialog] = + useState(false); const [showCreatePRDialog, setShowCreatePRDialog] = useState(false); const [showCreateBranchDialog, setShowCreateBranchDialog] = useState(false); const [selectedWorktreeForAction, setSelectedWorktreeForAction] = useState<{ @@ -251,31 +257,25 @@ export function BoardView() { }, [currentProject, worktreeRefreshKey]); // Custom collision detection that prioritizes columns over cards - const collisionDetectionStrategy = useCallback( - (args: any) => { - // First, check if pointer is within a column - const pointerCollisions = pointerWithin(args); - const columnCollisions = pointerCollisions.filter((collision: any) => - COLUMNS.some((col) => col.id === collision.id) - ); + const collisionDetectionStrategy = useCallback((args: any) => { + // First, check if pointer is within a column + const pointerCollisions = pointerWithin(args); + const columnCollisions = pointerCollisions.filter((collision: any) => + COLUMNS.some((col) => col.id === collision.id) + ); - // If we found a column collision, use that - if (columnCollisions.length > 0) { - return columnCollisions; - } + // If we found a column collision, use that + if (columnCollisions.length > 0) { + return columnCollisions; + } - // Otherwise, use rectangle intersection for cards - return rectIntersection(args); - }, - [] - ); + // Otherwise, use rectangle intersection for cards + return rectIntersection(args); + }, []); // Use persistence hook - const { - persistFeatureCreate, - persistFeatureUpdate, - persistFeatureDelete, - } = useBoardPersistence({ currentProject }); + const { persistFeatureCreate, persistFeatureUpdate, persistFeatureDelete } = + useBoardPersistence({ currentProject }); // Get in-progress features for keyboard shortcuts (needed before actions hook) const inProgressFeaturesForShortcuts = useMemo(() => { @@ -287,20 +287,24 @@ export function BoardView() { // Get current worktree info (path and branch) for filtering features // This needs to be before useBoardActions so we can pass currentWorktreeBranch - const currentWorktreeInfo = currentProject ? getCurrentWorktree(currentProject.path) : null; + const currentWorktreeInfo = currentProject + ? getCurrentWorktree(currentProject.path) + : null; const currentWorktreePath = currentWorktreeInfo?.path ?? null; const currentWorktreeBranch = currentWorktreeInfo?.branch ?? null; const worktreesByProject = useAppStore((s) => s.worktreesByProject); const worktrees = useMemo( - () => (currentProject ? (worktreesByProject[currentProject.path] ?? EMPTY_WORKTREES) : EMPTY_WORKTREES), + () => + currentProject + ? worktreesByProject[currentProject.path] ?? EMPTY_WORKTREES + : EMPTY_WORKTREES, [currentProject, worktreesByProject] ); // Get the branch for the currently selected worktree (for defaulting new features) // Use the branch from currentWorktreeInfo, or fall back to main worktree's branch - const selectedWorktreeBranch = currentWorktreeBranch - || worktrees.find(w => w.isMain)?.branch - || "main"; + const selectedWorktreeBranch = + currentWorktreeBranch || worktrees.find((w) => w.isMain)?.branch || "main"; // Extract all action handlers into a hook const { @@ -315,7 +319,6 @@ export function BoardView() { handleOpenFollowUp, handleSendFollowUp, handleCommitFeature, - handleRevertFeature, handleMergeFeature, handleCompleteFeature, handleUnarchiveFeature, @@ -452,7 +455,11 @@ export function BoardView() { setShowCreateBranchDialog(true); }} runningFeatureIds={runningAutoTasks} - features={hookFeatures.map(f => ({ id: f.id, worktreePath: f.worktreePath, branchName: f.branchName }))} + features={hookFeatures.map((f) => ({ + id: f.id, + worktreePath: f.worktreePath, + branchName: f.branchName, + }))} /> {/* Main Content Area */} @@ -626,10 +633,17 @@ export function BoardView() { isCurrent: false, hasWorktree: true, }; - setWorktrees(currentProject.path, [...currentWorktrees, newWorktreeInfo]); + setWorktrees(currentProject.path, [ + ...currentWorktrees, + newWorktreeInfo, + ]); // Now set the current worktree with both path and branch - setCurrentWorktree(currentProject.path, newWorktree.path, newWorktree.branch); + setCurrentWorktree( + currentProject.path, + newWorktree.path, + newWorktree.branch + ); // Trigger refresh to get full worktree details (hasChanges, etc.) setWorktreeRefreshKey((k) => k + 1); @@ -642,7 +656,24 @@ export function BoardView() { onOpenChange={setShowDeleteWorktreeDialog} projectPath={currentProject.path} worktree={selectedWorktreeForAction} - onDeleted={() => { + onDeleted={(deletedWorktree, _deletedBranch) => { + // Reset features that were assigned to the deleted worktree + hookFeatures.forEach((feature) => { + const matchesByPath = + feature.worktreePath && + pathsEqual(feature.worktreePath, deletedWorktree.path); + const matchesByBranch = + feature.branchName === deletedWorktree.branch; + + if (matchesByPath || matchesByBranch) { + // Reset the feature's worktree assignment + persistFeatureUpdate(feature.id, { + branchName: null as unknown as string | undefined, + worktreePath: null as unknown as string | undefined, + }); + } + }); + setWorktreeRefreshKey((k) => k + 1); setSelectedWorktreeForAction(null); }} diff --git a/apps/app/src/components/views/board-view/components/kanban-card.tsx b/apps/app/src/components/views/board-view/components/kanban-card.tsx index 2257ccbc..480f7ac8 100644 --- a/apps/app/src/components/views/board-view/components/kanban-card.tsx +++ b/apps/app/src/components/views/board-view/components/kanban-card.tsx @@ -328,8 +328,8 @@ export const KanbanCard = memo(function KanbanCard({
- P{feature.priority} + {Array.from({ length: 4 - feature.priority }).map((_, i) => ( + + ))}
@@ -347,8 +360,8 @@ export const KanbanCard = memo(function KanbanCard({ {feature.priority === 1 ? "High Priority" : feature.priority === 2 - ? "Medium Priority" - : "Low Priority"} + ? "Medium Priority" + : "Low Priority"}

@@ -1095,7 +1108,6 @@ export const KanbanCard = memo(function KanbanCard({ - ); diff --git a/apps/app/src/components/views/board-view/components/worktree-selector.tsx b/apps/app/src/components/views/board-view/components/worktree-selector.tsx index 3cd49fe9..a4856dc2 100644 --- a/apps/app/src/components/views/board-view/components/worktree-selector.tsx +++ b/apps/app/src/components/views/board-view/components/worktree-selector.tsx @@ -101,7 +101,9 @@ export function WorktreeSelector({ const [behindCount, setBehindCount] = useState(0); const [isLoadingBranches, setIsLoadingBranches] = useState(false); const [branchFilter, setBranchFilter] = useState(""); - const [runningDevServers, setRunningDevServers] = useState>(new Map()); + const [runningDevServers, setRunningDevServers] = useState< + Map + >(new Map()); const [defaultEditorName, setDefaultEditorName] = useState("Editor"); const currentWorktree = useAppStore((s) => s.getCurrentWorktree(projectPath)); const setCurrentWorktree = useAppStore((s) => s.setCurrentWorktree); @@ -197,18 +199,37 @@ export function WorktreeSelector({ } }, [refreshTrigger, fetchWorktrees]); - // Initialize selection to main if not set + // Initialize selection to main if not set OR if the stored worktree no longer exists + // This handles stale data (e.g., a worktree that was deleted) useEffect(() => { - if (worktrees.length > 0 && currentWorktree === undefined) { - const mainWorktree = worktrees.find(w => w.isMain); - const mainBranch = mainWorktree?.branch || "main"; - setCurrentWorktree(projectPath, null, mainBranch); // null = main worktree + if (worktrees.length > 0) { + const currentPath = currentWorktree?.path; + + // Check if the currently selected worktree still exists + // null path means main (which always exists if worktrees has items) + // Non-null path means we need to verify it exists in the worktrees list + const currentWorktreeExists = currentPath === null + ? true + : worktrees.some((w) => !w.isMain && pathsEqual(w.path, currentPath)); + + // Reset to main if: + // 1. No worktree is set (currentWorktree is null/undefined) + // 2. Current worktree has a path that doesn't exist in the list (stale data) + if (currentWorktree == null || (currentPath !== null && !currentWorktreeExists)) { + const mainWorktree = worktrees.find((w) => w.isMain); + const mainBranch = mainWorktree?.branch || "main"; + setCurrentWorktree(projectPath, null, mainBranch); // null = main worktree + } } }, [worktrees, currentWorktree, projectPath, setCurrentWorktree]); const handleSelectWorktree = async (worktree: WorktreeInfo) => { // Simply select the worktree in the UI with both path and branch - setCurrentWorktree(projectPath, worktree.isMain ? null : worktree.path, worktree.branch); + setCurrentWorktree( + projectPath, + worktree.isMain ? null : worktree.path, + worktree.branch + ); }; const handleStartDevServer = async (worktree: WorktreeInfo) => { @@ -326,57 +347,6 @@ export function WorktreeSelector({ }); }; - const handleActivateWorktree = async (worktree: WorktreeInfo) => { - if (isActivating) return; - setIsActivating(true); - try { - const api = getElectronAPI(); - if (!api?.worktree?.activate) { - toast.error("Activate worktree API not available"); - return; - } - const result = await api.worktree.activate(projectPath, worktree.path); - if (result.success && result.result) { - toast.success(result.result.message); - // After activation, refresh to show updated state - fetchWorktrees(); - } else { - toast.error(result.error || "Failed to activate worktree"); - } - } catch (error) { - console.error("Activate worktree failed:", error); - toast.error("Failed to activate worktree"); - } finally { - setIsActivating(false); - } - }; - - const handleSwitchToBranch = async (branchName: string) => { - if (isActivating) return; - setIsActivating(true); - try { - const api = getElectronAPI(); - if (!api?.worktree?.activate) { - toast.error("Activate API not available"); - return; - } - // Pass null as worktreePath to switch to a branch without a worktree - // We'll need to update the activate endpoint to handle this case - const result = await api.worktree.switchBranch(projectPath, branchName); - if (result.success && result.result) { - toast.success(result.result.message); - fetchWorktrees(); - } else { - toast.error(result.error || "Failed to switch branch"); - } - } catch (error) { - console.error("Switch branch failed:", error); - toast.error("Failed to switch branch"); - } finally { - setIsActivating(false); - } - }; - const handleOpenInEditor = async (worktree: WorktreeInfo) => { try { const api = getElectronAPI(); @@ -395,7 +365,10 @@ export function WorktreeSelector({ } }; - const handleSwitchBranch = async (worktree: WorktreeInfo, branchName: string) => { + const handleSwitchBranch = async ( + worktree: WorktreeInfo, + branchName: string + ) => { if (isSwitching || branchName === worktree.branch) return; setIsSwitching(true); try { @@ -478,13 +451,14 @@ export function WorktreeSelector({ ? worktrees.find((w) => pathsEqual(w.path, currentWorktreePath)) : worktrees.find((w) => w.isMain); - // Render a worktree tab with branch selector (for main) and actions dropdown const renderWorktreeTab = (worktree: WorktreeInfo) => { // Selection is based on UI state, not git's current branch // Default to main selected if currentWorktree is null/undefined or path is null const isSelected = worktree.isMain - ? currentWorktree === null || currentWorktree === undefined || currentWorktree.path === null + ? currentWorktree === null || + currentWorktree === undefined || + currentWorktree.path === null : pathsEqual(worktree.path, currentWorktreePath); const isRunning = hasRunningFeatures(worktree); @@ -508,7 +482,9 @@ export function WorktreeSelector({ title="Click to preview main" > {isRunning && } - {isActivating && !isRunning && } + {isActivating && !isRunning && ( + + )} {worktree.branch} {worktree.hasChanges && ( @@ -517,12 +493,14 @@ export function WorktreeSelector({ )} {/* Branch switch dropdown button */} - { - if (open) { - fetchBranches(worktree.path); - setBranchFilter(""); - } - }}> + { + if (open) { + fetchBranches(worktree.path); + setBranchFilter(""); + } + }} + > )} {/* Actions dropdown */} - { - if (open) { - fetchBranches(worktree.path); - } - }}> + { + if (open) { + fetchBranches(worktree.path); + } + }} + > diff --git a/apps/app/src/components/views/board-view/dialogs/delete-worktree-dialog.tsx b/apps/app/src/components/views/board-view/dialogs/delete-worktree-dialog.tsx index 6b095ca7..0e228ab2 100644 --- a/apps/app/src/components/views/board-view/dialogs/delete-worktree-dialog.tsx +++ b/apps/app/src/components/views/board-view/dialogs/delete-worktree-dialog.tsx @@ -29,7 +29,7 @@ interface DeleteWorktreeDialogProps { onOpenChange: (open: boolean) => void; projectPath: string; worktree: WorktreeInfo | null; - onDeleted: () => void; + onDeleted: (deletedWorktree: WorktreeInfo, deletedBranch: boolean) => void; } export function DeleteWorktreeDialog({ @@ -64,7 +64,7 @@ export function DeleteWorktreeDialog({ ? `Branch "${worktree.branch}" was also deleted` : `Branch "${worktree.branch}" was kept`, }); - onDeleted(); + onDeleted(worktree, deleteBranch); onOpenChange(false); setDeleteBranch(false); } else { diff --git a/apps/app/src/components/views/board-view/hooks/use-board-actions.ts b/apps/app/src/components/views/board-view/hooks/use-board-actions.ts index 787b2ed2..b5b16b16 100644 --- a/apps/app/src/components/views/board-view/hooks/use-board-actions.ts +++ b/apps/app/src/components/views/board-view/hooks/use-board-actions.ts @@ -137,7 +137,7 @@ export function useBoardActions({ ); const handleAddFeature = useCallback( - (featureData: { + async (featureData: { category: string; description: string; steps: string[]; @@ -149,19 +149,38 @@ export function useBoardActions({ branchName: string; priority: number; }) => { + let worktreePath: string | undefined; + + // If worktrees are enabled and a non-main branch is selected, create the worktree + if (useWorktrees && featureData.branchName) { + const branchName = featureData.branchName; + if (branchName !== "main" && branchName !== "master") { + // Create a temporary feature-like object for getOrCreateWorktreeForFeature + const tempFeature = { branchName } as Feature; + const path = await getOrCreateWorktreeForFeature(tempFeature); + if (path && path !== projectPath) { + worktreePath = path; + // Refresh worktree selector after creating worktree + onWorktreeCreated?.(); + } + } + } + const newFeatureData = { ...featureData, status: "backlog" as const, + worktreePath, }; const createdFeature = addFeature(newFeatureData); - persistFeatureCreate(createdFeature); + // Must await to ensure feature exists on server before user can drag it + await persistFeatureCreate(createdFeature); saveCategory(featureData.category); }, - [addFeature, persistFeatureCreate, saveCategory] + [addFeature, persistFeatureCreate, saveCategory, useWorktrees, getOrCreateWorktreeForFeature, projectPath, onWorktreeCreated] ); const handleUpdateFeature = useCallback( - ( + async ( featureId: string, updates: { category: string; @@ -175,14 +194,53 @@ export function useBoardActions({ priority: number; } ) => { - updateFeature(featureId, updates); - persistFeatureUpdate(featureId, updates); + // Get the current feature to check if branch is changing + const currentFeature = features.find((f) => f.id === featureId); + const currentBranch = currentFeature?.branchName || "main"; + const newBranch = updates.branchName || "main"; + const branchIsChanging = currentBranch !== newBranch; + + let worktreePath: string | undefined; + let shouldClearWorktreePath = false; + + // If worktrees are enabled and branch is changing to a non-main branch, create worktree + if (useWorktrees && branchIsChanging) { + if (newBranch === "main" || newBranch === "master") { + // Changing to main - clear the worktreePath + shouldClearWorktreePath = true; + } else { + // Changing to a feature branch - create worktree if needed + const tempFeature = { branchName: newBranch } as Feature; + const path = await getOrCreateWorktreeForFeature(tempFeature); + if (path && path !== projectPath) { + worktreePath = path; + // Refresh worktree selector after creating worktree + onWorktreeCreated?.(); + } + } + } + + // Build final updates with worktreePath if it was changed + let finalUpdates: typeof updates & { worktreePath?: string }; + if (branchIsChanging && useWorktrees) { + if (shouldClearWorktreePath) { + // Use null to clear the value in persistence (cast to work around type system) + finalUpdates = { ...updates, worktreePath: null as unknown as string | undefined }; + } else { + finalUpdates = { ...updates, worktreePath }; + } + } else { + finalUpdates = updates; + } + + updateFeature(featureId, finalUpdates); + persistFeatureUpdate(featureId, finalUpdates); if (updates.category) { saveCategory(updates.category); } setEditingFeature(null); }, - [updateFeature, persistFeatureUpdate, saveCategory, setEditingFeature] + [updateFeature, persistFeatureUpdate, saveCategory, setEditingFeature, features, useWorktrees, getOrCreateWorktreeForFeature, projectPath, onWorktreeCreated] ); const handleDeleteFeature = useCallback( @@ -291,7 +349,8 @@ export function useBoardActions({ startedAt: new Date().toISOString(), }; updateFeature(feature.id, updates); - persistFeatureUpdate(feature.id, updates); + // Must await to ensure feature status is persisted before starting agent + await persistFeatureUpdate(feature.id, updates); console.log("[Board] Feature moved to in_progress, starting agent..."); await handleRunFeature(feature); return true; @@ -535,50 +594,6 @@ export function useBoardActions({ ] ); - const handleRevertFeature = useCallback( - async (feature: Feature) => { - if (!currentProject) return; - - try { - const api = getElectronAPI(); - if (!api?.worktree?.revertFeature) { - console.error("Worktree API not available"); - toast.error("Revert not available", { - description: - "This feature is not available in the current version.", - }); - return; - } - - const result = await api.worktree.revertFeature( - currentProject.path, - feature.id - ); - - if (result.success) { - await loadFeatures(); - toast.success("Feature reverted", { - description: `All changes discarded. Moved back to backlog: ${truncateDescription( - feature.description - )}`, - }); - } else { - console.error("[Board] Failed to revert feature:", result.error); - toast.error("Failed to revert feature", { - description: result.error || "An error occurred", - }); - } - } catch (error) { - console.error("[Board] Error reverting feature:", error); - toast.error("Failed to revert feature", { - description: - error instanceof Error ? error.message : "An error occurred", - }); - } - }, - [currentProject, loadFeatures] - ); - const handleMergeFeature = useCallback( async (feature: Feature) => { if (!currentProject) return; @@ -698,7 +713,8 @@ export function useBoardActions({ if (targetStatus !== feature.status) { moveFeature(feature.id, targetStatus); - persistFeatureUpdate(feature.id, { status: targetStatus }); + // Must await to ensure file is written before user can restart + await persistFeatureUpdate(feature.id, { status: targetStatus }); } toast.success("Agent stopped", { @@ -733,8 +749,16 @@ export function useBoardActions({ // If no worktree is selected (currentWorktreeBranch is null or main-like), // show features with no branch or "main"/"master" branch - if (!currentWorktreeBranch || currentWorktreeBranch === "main" || currentWorktreeBranch === "master") { - return !f.branchName || featureBranch === "main" || featureBranch === "master"; + if ( + !currentWorktreeBranch || + currentWorktreeBranch === "main" || + currentWorktreeBranch === "master" + ) { + return ( + !f.branchName || + featureBranch === "main" || + featureBranch === "master" + ); } // Otherwise, only show features matching the selected worktree branch @@ -754,9 +778,12 @@ export function useBoardActions({ if (backlogFeatures.length === 0) { toast.info("Backlog empty", { - description: currentWorktreeBranch && currentWorktreeBranch !== "main" && currentWorktreeBranch !== "master" - ? `No features in backlog for branch "${currentWorktreeBranch}".` - : "No features in backlog to start.", + description: + currentWorktreeBranch && + currentWorktreeBranch !== "main" && + currentWorktreeBranch !== "master" + ? `No features in backlog for branch "${currentWorktreeBranch}".` + : "No features in backlog to start.", }); return; } @@ -833,7 +860,6 @@ export function useBoardActions({ handleOpenFollowUp, handleSendFollowUp, handleCommitFeature, - handleRevertFeature, handleMergeFeature, handleCompleteFeature, handleUnarchiveFeature, diff --git a/apps/app/src/components/views/board-view/hooks/use-board-column-features.ts b/apps/app/src/components/views/board-view/hooks/use-board-column-features.ts index c14ba074..1bde18be 100644 --- a/apps/app/src/components/views/board-view/hooks/use-board-column-features.ts +++ b/apps/app/src/components/views/board-view/hooks/use-board-column-features.ts @@ -57,22 +57,22 @@ export function useBoardColumnFeatures({ // Check if feature matches the current worktree // Match by worktreePath if set, OR by branchName if set - // Features with neither are considered unassigned (show on main only) + // Features with neither are considered unassigned (show on ALL worktrees) const featureBranch = f.branchName || "main"; const hasWorktreeAssigned = f.worktreePath || f.branchName; let matchesWorktree: boolean; if (!hasWorktreeAssigned) { - // No worktree or branch assigned - show only on main - matchesWorktree = !currentWorktreePath; + // No worktree or branch assigned - show on ALL worktrees (unassigned) + matchesWorktree = true; } else if (f.worktreePath) { // Has worktreePath - match by path (use pathsEqual for cross-platform compatibility) matchesWorktree = pathsEqual(f.worktreePath, effectiveWorktreePath); } else if (effectiveBranch === null) { - // We're selecting a non-main worktree but can't determine its branch yet - // (worktrees haven't loaded). Don't show branch-only features until we know. - // This prevents showing wrong features during loading. - matchesWorktree = false; + // We're viewing main but branch hasn't been initialized yet + // (worktrees disabled or haven't loaded yet). + // Show features assigned to main/master branch since we're on the main worktree. + matchesWorktree = featureBranch === "main" || featureBranch === "master"; } else { // Has branchName but no worktreePath - match by branch name matchesWorktree = featureBranch === effectiveBranch; diff --git a/apps/app/src/hooks/use-keyboard-shortcuts.ts b/apps/app/src/hooks/use-keyboard-shortcuts.ts index 1f1c7a95..ed81bbbb 100644 --- a/apps/app/src/hooks/use-keyboard-shortcuts.ts +++ b/apps/app/src/hooks/use-keyboard-shortcuts.ts @@ -68,6 +68,13 @@ function isInputFocused(): boolean { return true; } + // Check for any open dropdown menus (Radix UI uses role="menu") + // This prevents shortcuts from firing when user is typing in dropdown filters + const dropdownMenu = document.querySelector('[role="menu"]'); + if (dropdownMenu) { + return true; + } + return false; } diff --git a/apps/app/src/lib/electron.ts b/apps/app/src/lib/electron.ts index 4787462e..5fff0d55 100644 --- a/apps/app/src/lib/electron.ts +++ b/apps/app/src/lib/electron.ts @@ -158,7 +158,10 @@ export interface SpecRegenerationAPI { analyzeProject?: boolean, maxFeatures?: number ) => Promise<{ success: boolean; error?: string }>; - generateFeatures: (projectPath: string, maxFeatures?: number) => Promise<{ + generateFeatures: ( + projectPath: string, + maxFeatures?: number + ) => Promise<{ success: boolean; error?: string; }>; @@ -321,7 +324,11 @@ export interface ElectronAPI { features?: FeaturesAPI; runningAgents?: RunningAgentsAPI; enhancePrompt?: { - enhance: (originalText: string, enhancementMode: string, model?: string) => Promise<{ + enhance: ( + originalText: string, + enhancementMode: string, + model?: string + ) => Promise<{ success: boolean; enhancedText?: string; error?: string; @@ -1042,11 +1049,6 @@ function createMockSetupAPI(): SetupAPI { // Mock Worktree API implementation function createMockWorktreeAPI(): WorktreeAPI { return { - revertFeature: async (projectPath: string, featureId: string) => { - console.log("[Mock] Reverting feature:", { projectPath, featureId }); - return { success: true, removedPath: `/mock/worktree/${featureId}` }; - }, - mergeFeature: async ( projectPath: string, featureId: string, @@ -1093,17 +1095,36 @@ function createMockWorktreeAPI(): WorktreeAPI { }, listAll: async (projectPath: string, includeDetails?: boolean) => { - console.log("[Mock] Listing all worktrees:", { projectPath, includeDetails }); + console.log("[Mock] Listing all worktrees:", { + projectPath, + includeDetails, + }); return { success: true, worktrees: [ - { path: projectPath, branch: "main", isMain: true, isCurrent: true, hasWorktree: true, hasChanges: false, changedFilesCount: 0 }, + { + path: projectPath, + branch: "main", + isMain: true, + isCurrent: true, + hasWorktree: true, + hasChanges: false, + changedFilesCount: 0, + }, ], }; }, - create: async (projectPath: string, branchName: string, baseBranch?: string) => { - console.log("[Mock] Creating worktree:", { projectPath, branchName, baseBranch }); + create: async ( + projectPath: string, + branchName: string, + baseBranch?: string + ) => { + console.log("[Mock] Creating worktree:", { + projectPath, + branchName, + baseBranch, + }); return { success: true, worktree: { @@ -1114,8 +1135,16 @@ function createMockWorktreeAPI(): WorktreeAPI { }; }, - delete: async (projectPath: string, worktreePath: string, deleteBranch?: boolean) => { - console.log("[Mock] Deleting worktree:", { projectPath, worktreePath, deleteBranch }); + delete: async ( + projectPath: string, + worktreePath: string, + deleteBranch?: boolean + ) => { + console.log("[Mock] Deleting worktree:", { + projectPath, + worktreePath, + deleteBranch, + }); return { success: true, deleted: { @@ -1208,7 +1237,10 @@ function createMockWorktreeAPI(): WorktreeAPI { }, checkoutBranch: async (worktreePath: string, branchName: string) => { - console.log("[Mock] Creating and checking out branch:", { worktreePath, branchName }); + console.log("[Mock] Creating and checking out branch:", { + worktreePath, + branchName, + }); return { success: true, result: { @@ -1281,18 +1313,6 @@ function createMockWorktreeAPI(): WorktreeAPI { }; }, - activate: async (projectPath: string, worktreePath: string | null) => { - console.log("[Mock] Activating worktree:", { projectPath, worktreePath }); - return { - success: true, - result: { - previousBranch: "main", - currentBranch: worktreePath ? "feature-branch" : "main", - message: worktreePath ? "Switched to worktree branch" : "Switched to main", - }, - }; - }, - startDevServer: async (projectPath: string, worktreePath: string) => { console.log("[Mock] Starting dev server:", { projectPath, worktreePath }); return { @@ -1465,7 +1485,11 @@ function createMockAutoModeAPI(): AutoModeAPI { return { success: true, passes: true }; }, - resumeFeature: async (projectPath: string, featureId: string, useWorktrees?: boolean) => { + resumeFeature: async ( + projectPath: string, + featureId: string, + useWorktrees?: boolean + ) => { if (mockRunningFeatures.has(featureId)) { return { success: false, @@ -1629,8 +1653,16 @@ function createMockAutoModeAPI(): AutoModeAPI { return { success: true }; }, - commitFeature: async (projectPath: string, featureId: string, worktreePath?: string) => { - console.log("[Mock] Committing feature:", { projectPath, featureId, worktreePath }); + commitFeature: async ( + projectPath: string, + featureId: string, + worktreePath?: string + ) => { + console.log("[Mock] Committing feature:", { + projectPath, + featureId, + worktreePath, + }); // Simulate commit operation emitAutoModeEvent({ diff --git a/apps/app/src/lib/http-api-client.ts b/apps/app/src/lib/http-api-client.ts index d43d8322..d007b806 100644 --- a/apps/app/src/lib/http-api-client.ts +++ b/apps/app/src/lib/http-api-client.ts @@ -468,7 +468,9 @@ export class HttpApiClient implements ElectronAPI { isLinux: boolean; }> => this.get("/api/setup/platform"), - verifyClaudeAuth: (authMethod?: "cli" | "api_key"): Promise<{ + verifyClaudeAuth: ( + authMethod?: "cli" | "api_key" + ): Promise<{ success: boolean; authenticated: boolean; error?: string; @@ -536,8 +538,16 @@ export class HttpApiClient implements ElectronAPI { }), verifyFeature: (projectPath: string, featureId: string) => this.post("/api/auto-mode/verify-feature", { projectPath, featureId }), - resumeFeature: (projectPath: string, featureId: string, useWorktrees?: boolean) => - this.post("/api/auto-mode/resume-feature", { projectPath, featureId, useWorktrees }), + resumeFeature: ( + projectPath: string, + featureId: string, + useWorktrees?: boolean + ) => + this.post("/api/auto-mode/resume-feature", { + projectPath, + featureId, + useWorktrees, + }), contextExists: (projectPath: string, featureId: string) => this.post("/api/auto-mode/context-exists", { projectPath, featureId }), analyzeProject: (projectPath: string) => @@ -556,8 +566,16 @@ export class HttpApiClient implements ElectronAPI { imagePaths, worktreePath, }), - commitFeature: (projectPath: string, featureId: string, worktreePath?: string) => - this.post("/api/auto-mode/commit-feature", { projectPath, featureId, worktreePath }), + commitFeature: ( + projectPath: string, + featureId: string, + worktreePath?: string + ) => + this.post("/api/auto-mode/commit-feature", { + projectPath, + featureId, + worktreePath, + }), onEvent: (callback: (event: AutoModeEvent) => void) => { return this.subscribeToEvent( "auto-mode:event", @@ -582,8 +600,6 @@ export class HttpApiClient implements ElectronAPI { // Worktree API worktree: WorktreeAPI = { - revertFeature: (projectPath: string, featureId: string) => - this.post("/api/worktree/revert", { projectPath, featureId }), mergeFeature: (projectPath: string, featureId: string, options?: object) => this.post("/api/worktree/merge", { projectPath, featureId, options }), getInfo: (projectPath: string, featureId: string) => @@ -595,9 +611,21 @@ export class HttpApiClient implements ElectronAPI { listAll: (projectPath: string, includeDetails?: boolean) => this.post("/api/worktree/list", { projectPath, includeDetails }), create: (projectPath: string, branchName: string, baseBranch?: string) => - this.post("/api/worktree/create", { projectPath, branchName, baseBranch }), - delete: (projectPath: string, worktreePath: string, deleteBranch?: boolean) => - this.post("/api/worktree/delete", { projectPath, worktreePath, deleteBranch }), + this.post("/api/worktree/create", { + projectPath, + branchName, + baseBranch, + }), + delete: ( + projectPath: string, + worktreePath: string, + deleteBranch?: boolean + ) => + this.post("/api/worktree/delete", { + projectPath, + worktreePath, + deleteBranch, + }), commit: (worktreePath: string, message: string) => this.post("/api/worktree/commit", { worktreePath, message }), push: (worktreePath: string, force?: boolean) => @@ -622,18 +650,14 @@ export class HttpApiClient implements ElectronAPI { this.post("/api/worktree/switch-branch", { worktreePath, branchName }), openInEditor: (worktreePath: string) => this.post("/api/worktree/open-in-editor", { worktreePath }), - getDefaultEditor: () => - this.get("/api/worktree/default-editor"), + getDefaultEditor: () => this.get("/api/worktree/default-editor"), initGit: (projectPath: string) => this.post("/api/worktree/init-git", { projectPath }), - activate: (projectPath: string, worktreePath: string | null) => - this.post("/api/worktree/activate", { projectPath, worktreePath }), startDevServer: (projectPath: string, worktreePath: string) => this.post("/api/worktree/start-dev", { projectPath, worktreePath }), stopDevServer: (worktreePath: string) => this.post("/api/worktree/stop-dev", { worktreePath }), - listDevServers: () => - this.post("/api/worktree/list-dev-servers", {}), + listDevServers: () => this.post("/api/worktree/list-dev-servers", {}), }; // Git API @@ -689,7 +713,10 @@ export class HttpApiClient implements ElectronAPI { maxFeatures, }), generateFeatures: (projectPath: string, maxFeatures?: number) => - this.post("/api/spec-regeneration/generate-features", { projectPath, maxFeatures }), + this.post("/api/spec-regeneration/generate-features", { + projectPath, + maxFeatures, + }), stop: () => this.post("/api/spec-regeneration/stop"), status: () => this.get("/api/spec-regeneration/status"), onEvent: (callback: (event: SpecRegenerationEvent) => void) => { diff --git a/apps/app/src/types/electron.d.ts b/apps/app/src/types/electron.d.ts index aa244fbe..4a0f973b 100644 --- a/apps/app/src/types/electron.d.ts +++ b/apps/app/src/types/electron.d.ts @@ -286,7 +286,10 @@ export interface SpecRegenerationAPI { error?: string; }>; - generateFeatures: (projectPath: string, maxFeatures?: number) => Promise<{ + generateFeatures: ( + projectPath: string, + maxFeatures?: number + ) => Promise<{ success: boolean; error?: string; }>; @@ -574,16 +577,6 @@ export interface FileDiffResult { } export interface WorktreeAPI { - // Revert feature changes by removing the worktree - revertFeature: ( - projectPath: string, - featureId: string - ) => Promise<{ - success: boolean; - removedPath?: string; - error?: string; - }>; - // Merge feature worktree changes back to main branch mergeFeature: ( projectPath: string, @@ -824,20 +817,6 @@ export interface WorktreeAPI { error?: string; }>; - // Activate a worktree (switch main project to that branch) - activate: ( - projectPath: string, - worktreePath: string | null - ) => Promise<{ - success: boolean; - result?: { - previousBranch: string; - currentBranch: string; - message: string; - }; - error?: string; - }>; - // Start a dev server for a worktree startDevServer: ( projectPath: string, @@ -854,9 +833,7 @@ export interface WorktreeAPI { }>; // Stop a dev server for a worktree - stopDevServer: ( - worktreePath: string - ) => Promise<{ + stopDevServer: (worktreePath: string) => Promise<{ success: boolean; result?: { worktreePath: string; diff --git a/apps/app/tests/feature-lifecycle.spec.ts b/apps/app/tests/feature-lifecycle.spec.ts index f27f4e01..0e3ad3e4 100644 --- a/apps/app/tests/feature-lifecycle.spec.ts +++ b/apps/app/tests/feature-lifecycle.spec.ts @@ -26,7 +26,7 @@ import { createTestGitRepo, cleanupTempDir, createTempDirPath, - setupProjectWithPath, + setupProjectWithPathNoWorktrees, waitForBoardView, clickAddFeature, fillAddFeatureDialog, @@ -84,7 +84,8 @@ test.describe("Feature Lifecycle Tests", () => { // ========================================================================== // Step 1: Setup and create a feature in backlog // ========================================================================== - await setupProjectWithPath(page, testRepo.path); + // Use no-worktrees setup to avoid worktree-related filtering/initialization issues + await setupProjectWithPathNoWorktrees(page, testRepo.path); await page.goto("/"); await waitForNetworkIdle(page); await waitForBoardView(page); @@ -291,4 +292,153 @@ test.describe("Feature Lifecycle Tests", () => { const featureDirExists = fs.existsSync(path.join(featuresDir, featureId)); expect(featureDirExists).toBe(false); }); + + test("stop and restart feature: create -> in_progress -> stop -> restart should work without 'Feature not found' error", async ({ + page, + }) => { + // This test verifies that stopping a feature and restarting it works correctly + // Bug: Previously, stopping a feature and immediately restarting could cause + // "Feature not found" error due to race conditions + test.setTimeout(120000); + + // ========================================================================== + // Step 1: Setup and create a feature in backlog + // ========================================================================== + await setupProjectWithPathNoWorktrees(page, testRepo.path); + await page.goto("/"); + await waitForNetworkIdle(page); + await waitForBoardView(page); + await page.waitForTimeout(1000); + + // Click add feature button + await clickAddFeature(page); + + // Fill in the feature details + const featureDescription = "Create a file named test-restart.txt"; + const descriptionInput = page.locator('[data-testid="add-feature-dialog"] textarea').first(); + await descriptionInput.fill(featureDescription); + + // Confirm the feature creation + await confirmAddFeature(page); + + // Wait for the feature to be created in the filesystem + const featuresDir = path.join(testRepo.path, ".automaker", "features"); + await expect(async () => { + const dirs = fs.readdirSync(featuresDir); + expect(dirs.length).toBeGreaterThan(0); + }).toPass({ timeout: 10000 }); + + // Get the feature ID + const featureDirs = fs.readdirSync(featuresDir); + const testFeatureId = featureDirs[0]; + + // Reload to ensure features are loaded + await page.reload(); + await waitForNetworkIdle(page); + await waitForBoardView(page); + + // Wait for the feature card to appear + const featureCard = page.locator(`[data-testid="kanban-card-${testFeatureId}"]`); + await expect(featureCard).toBeVisible({ timeout: 10000 }); + + // ========================================================================== + // Step 2: Drag feature to in_progress (first start) + // ========================================================================== + const dragHandle = page.locator(`[data-testid="drag-handle-${testFeatureId}"]`); + const inProgressColumn = page.locator('[data-testid="kanban-column-in_progress"]'); + + await dragAndDropWithDndKit(page, dragHandle, inProgressColumn); + + // Wait for the feature to be in in_progress + await page.waitForTimeout(500); + + // Verify feature file still exists and is readable + const featureFilePath = path.join(featuresDir, testFeatureId, "feature.json"); + expect(fs.existsSync(featureFilePath)).toBe(true); + + // Wait a bit for the agent to start + await page.waitForTimeout(1000); + + // ========================================================================== + // Step 3: Wait for the mock agent to complete (it's fast in mock mode) + // ========================================================================== + // The mock agent completes quickly, so we wait for it to finish + await expect(async () => { + const featureData = JSON.parse(fs.readFileSync(featureFilePath, "utf-8")); + expect(featureData.status).toBe("waiting_approval"); + }).toPass({ timeout: 30000 }); + + // Verify feature file still exists after completion + expect(fs.existsSync(featureFilePath)).toBe(true); + const featureDataAfterComplete = JSON.parse(fs.readFileSync(featureFilePath, "utf-8")); + console.log("Feature status after first run:", featureDataAfterComplete.status); + + // Reload to ensure clean state + await page.reload(); + await waitForNetworkIdle(page); + await waitForBoardView(page); + + // ========================================================================== + // Step 4: Move feature back to backlog to simulate stop scenario + // ========================================================================== + // Feature is in waiting_approval, drag it back to backlog + const backlogColumn = page.locator('[data-testid="kanban-column-backlog"]'); + const currentCard = page.locator(`[data-testid="kanban-card-${testFeatureId}"]`); + const currentDragHandle = page.locator(`[data-testid="drag-handle-${testFeatureId}"]`); + + await expect(currentCard).toBeVisible({ timeout: 10000 }); + await dragAndDropWithDndKit(page, currentDragHandle, backlogColumn); + await page.waitForTimeout(500); + + // Verify feature is in backlog + await expect(async () => { + const data = JSON.parse(fs.readFileSync(featureFilePath, "utf-8")); + expect(data.status).toBe("backlog"); + }).toPass({ timeout: 10000 }); + + // Reload to ensure clean state + await page.reload(); + await waitForNetworkIdle(page); + await waitForBoardView(page); + + // ========================================================================== + // Step 5: Restart the feature (drag to in_progress again) + // ========================================================================== + const restartCard = page.locator(`[data-testid="kanban-card-${testFeatureId}"]`); + await expect(restartCard).toBeVisible({ timeout: 10000 }); + + const restartDragHandle = page.locator(`[data-testid="drag-handle-${testFeatureId}"]`); + const inProgressColumnRestart = page.locator('[data-testid="kanban-column-in_progress"]'); + + // Listen for console errors to catch "Feature not found" + const consoleErrors: string[] = []; + page.on("console", (msg) => { + if (msg.type() === "error") { + consoleErrors.push(msg.text()); + } + }); + + // Drag to in_progress to restart + await dragAndDropWithDndKit(page, restartDragHandle, inProgressColumnRestart); + + // Wait for the feature to be processed + await page.waitForTimeout(2000); + + // Verify no "Feature not found" errors in console + const featureNotFoundErrors = consoleErrors.filter( + (err) => err.includes("not found") || err.includes("Feature") + ); + expect(featureNotFoundErrors).toEqual([]); + + // Verify the feature file still exists + expect(fs.existsSync(featureFilePath)).toBe(true); + + // Wait for the mock agent to complete and move to waiting_approval + await expect(async () => { + const data = JSON.parse(fs.readFileSync(featureFilePath, "utf-8")); + expect(data.status).toBe("waiting_approval"); + }).toPass({ timeout: 30000 }); + + console.log("Feature successfully restarted after stop!"); + }); }); diff --git a/apps/app/tests/utils/core/constants.ts b/apps/app/tests/utils/core/constants.ts index cd1a49b3..935436c0 100644 --- a/apps/app/tests/utils/core/constants.ts +++ b/apps/app/tests/utils/core/constants.ts @@ -24,7 +24,6 @@ export const API_ENDPOINTS = { switchBranch: `${API_BASE_URL}/api/worktree/switch-branch`, listBranches: `${API_BASE_URL}/api/worktree/list-branches`, status: `${API_BASE_URL}/api/worktree/status`, - revert: `${API_BASE_URL}/api/worktree/revert`, info: `${API_BASE_URL}/api/worktree/info`, }, fs: { diff --git a/apps/app/tests/utils/git/worktree.ts b/apps/app/tests/utils/git/worktree.ts index 8f4f3ab6..3873777a 100644 --- a/apps/app/tests/utils/git/worktree.ts +++ b/apps/app/tests/utils/git/worktree.ts @@ -352,6 +352,106 @@ export async function setupProjectWithPath(page: Page, projectPath: string): Pro }, projectPath); } +/** + * Set up localStorage with a project pointing to a test repo with worktrees DISABLED + * Use this to test scenarios where the worktree feature flag is off + */ +export async function setupProjectWithPathNoWorktrees(page: Page, projectPath: string): Promise { + await page.addInitScript((pathArg: string) => { + const mockProject = { + id: "test-project-no-worktree", + name: "Test Project (No Worktrees)", + path: pathArg, + lastOpened: new Date().toISOString(), + }; + + const mockState = { + state: { + projects: [mockProject], + currentProject: mockProject, + currentView: "board", + theme: "dark", + sidebarOpen: true, + apiKeys: { anthropic: "", google: "" }, + chatSessions: [], + chatHistoryOpen: false, + maxConcurrency: 3, + aiProfiles: [], + useWorktrees: false, // Worktree feature DISABLED + currentWorktreeByProject: {}, + worktreesByProject: {}, + }, + version: 0, + }; + + localStorage.setItem("automaker-storage", JSON.stringify(mockState)); + + // Mark setup as complete to skip the setup wizard + const setupState = { + state: { + isFirstRun: false, + setupComplete: true, + currentStep: "complete", + skipClaudeSetup: false, + }, + version: 0, + }; + localStorage.setItem("automaker-setup", JSON.stringify(setupState)); + }, projectPath); +} + +/** + * Set up localStorage with a project that has STALE worktree data + * The currentWorktreeByProject points to a worktree path that no longer exists + * This simulates the scenario where a user previously selected a worktree that was later deleted + */ +export async function setupProjectWithStaleWorktree(page: Page, projectPath: string): Promise { + await page.addInitScript((pathArg: string) => { + const mockProject = { + id: "test-project-stale-worktree", + name: "Stale Worktree Test Project", + path: pathArg, + lastOpened: new Date().toISOString(), + }; + + const mockState = { + state: { + projects: [mockProject], + currentProject: mockProject, + currentView: "board", + theme: "dark", + sidebarOpen: true, + apiKeys: { anthropic: "", google: "" }, + chatSessions: [], + chatHistoryOpen: false, + maxConcurrency: 3, + aiProfiles: [], + useWorktrees: true, // Enable worktree feature for tests + currentWorktreeByProject: { + // This is STALE data - pointing to a worktree path that doesn't exist + [pathArg]: { path: "/non/existent/worktree/path", branch: "feature/deleted-branch" }, + }, + worktreesByProject: {}, + }, + version: 0, + }; + + localStorage.setItem("automaker-storage", JSON.stringify(mockState)); + + // Mark setup as complete to skip the setup wizard + const setupState = { + state: { + isFirstRun: false, + setupComplete: true, + currentStep: "complete", + skipClaudeSetup: false, + }, + version: 0, + }; + localStorage.setItem("automaker-setup", JSON.stringify(setupState)); + }, projectPath); +} + // ============================================================================ // Wait Utilities // ============================================================================ diff --git a/apps/app/tests/worktree-integration.spec.ts b/apps/app/tests/worktree-integration.spec.ts index b2c062e6..f1eccbb9 100644 --- a/apps/app/tests/worktree-integration.spec.ts +++ b/apps/app/tests/worktree-integration.spec.ts @@ -32,6 +32,8 @@ import { listWorktrees, listBranches, setupProjectWithPath, + setupProjectWithPathNoWorktrees, + setupProjectWithStaleWorktree, waitForBoardView, clickAddFeature, fillAddFeatureDialog, @@ -106,6 +108,30 @@ test.describe("Worktree Integration Tests", () => { await expect(mainBranchButton).toBeVisible({ timeout: 10000 }); }); + test("should select main branch by default when app loads with stale worktree data", async ({ + page, + }) => { + // Set up project with STALE worktree data in localStorage + // This simulates a user who previously selected a worktree that was later deleted + await setupProjectWithStaleWorktree(page, testRepo.path); + await page.goto("/"); + await waitForNetworkIdle(page); + await waitForBoardView(page); + + // Wait for the worktree selector to load + const branchLabel = page.getByText("Branch:"); + await expect(branchLabel).toBeVisible({ timeout: 10000 }); + + // Verify main branch button is displayed + const mainBranchButton = page.getByRole("button", { name: "main" }).first(); + await expect(mainBranchButton).toBeVisible({ timeout: 10000 }); + + // CRITICAL: Verify the main branch button is SELECTED (has primary variant styling) + // The button should have the "bg-primary" class indicating it's selected + // When the bug exists, this will fail because stale data prevents initialization + await expect(mainBranchButton).toHaveClass(/bg-primary/, { timeout: 5000 }); + }); + test("should create a worktree via API and verify filesystem", async ({ page, }) => { @@ -749,6 +775,175 @@ test.describe("Worktree Integration Tests", () => { expect(featureData.status).toBe("backlog"); }); + test("should create worktree automatically when adding feature with new branch", async ({ + page, + }) => { + await setupProjectWithPath(page, testRepo.path); + await page.goto("/"); + await waitForNetworkIdle(page); + await waitForBoardView(page); + + // Use a branch name that doesn't exist yet - NO worktree is pre-created + const branchName = "feature/auto-create-worktree"; + const expectedWorktreePath = getWorktreePath(testRepo.path, branchName); + + // Verify worktree does NOT exist before we create the feature + expect(fs.existsSync(expectedWorktreePath)).toBe(false); + + // Click add feature button + await clickAddFeature(page); + + // Fill in the feature details with the new branch + await fillAddFeatureDialog(page, "Feature that should auto-create worktree", { + branch: branchName, + category: "Testing", + }); + + // Confirm + await confirmAddFeature(page); + + // Wait for the worktree to be created + await page.waitForTimeout(2000); + + // Verify worktree was automatically created when feature was added + expect(fs.existsSync(expectedWorktreePath)).toBe(true); + + // Verify the branch was created + const branches = await listBranches(testRepo.path); + expect(branches).toContain(branchName); + + // Verify feature was created with correct branch + const featuresDir = path.join(testRepo.path, ".automaker", "features"); + const featureDirs = fs.readdirSync(featuresDir); + expect(featureDirs.length).toBeGreaterThan(0); + + const featureDir = featureDirs.find((dir) => { + const featureFilePath = path.join(featuresDir, dir, "feature.json"); + if (fs.existsSync(featureFilePath)) { + const data = JSON.parse(fs.readFileSync(featureFilePath, "utf-8")); + return data.description === "Feature that should auto-create worktree"; + } + return false; + }); + expect(featureDir).toBeDefined(); + + const featureFilePath = path.join(featuresDir, featureDir!, "feature.json"); + const featureData = JSON.parse(fs.readFileSync(featureFilePath, "utf-8")); + expect(featureData.branchName).toBe(branchName); + expect(featureData.worktreePath).toBe(expectedWorktreePath); + }); + + test("should reset feature branch and worktree when worktree is deleted", async ({ + page, + }) => { + await setupProjectWithPath(page, testRepo.path); + await page.goto("/"); + await waitForNetworkIdle(page); + await waitForBoardView(page); + + // Create a worktree + const branchName = "feature/to-be-deleted"; + const worktreePath = getWorktreePath(testRepo.path, branchName); + await apiCreateWorktree(page, testRepo.path, branchName); + expect(fs.existsSync(worktreePath)).toBe(true); + + // Refresh worktrees in the UI + const refreshButton = page.locator('button[title="Refresh worktrees"]'); + await refreshButton.click(); + await page.waitForTimeout(1000); + + // Select the worktree in the UI + const worktreeButton = page.getByRole("button", { + name: /feature\/to-be-deleted/i, + }); + await expect(worktreeButton).toBeVisible({ timeout: 5000 }); + await worktreeButton.click(); + await page.waitForTimeout(500); + + // Create a feature assigned to this worktree + await clickAddFeature(page); + await fillAddFeatureDialog(page, "Feature on deletable worktree", { + branch: branchName, + category: "Testing", + }); + await confirmAddFeature(page); + await page.waitForTimeout(1000); + + // Verify feature was created with the branch + const featuresDir = path.join(testRepo.path, ".automaker", "features"); + let featureDirs = fs.readdirSync(featuresDir); + expect(featureDirs.length).toBeGreaterThan(0); + + // Find the feature + let featureDir = featureDirs.find((dir) => { + const featureFilePath = path.join(featuresDir, dir, "feature.json"); + if (fs.existsSync(featureFilePath)) { + const data = JSON.parse(fs.readFileSync(featureFilePath, "utf-8")); + return data.description === "Feature on deletable worktree"; + } + return false; + }); + expect(featureDir).toBeDefined(); + + let featureFilePath = path.join(featuresDir, featureDir!, "feature.json"); + let featureData = JSON.parse(fs.readFileSync(featureFilePath, "utf-8")); + expect(featureData.branchName).toBe(branchName); + expect(featureData.worktreePath).toBe(worktreePath); + + // Delete the worktree via UI + // Open the worktree actions menu + const actionsButton = page + .locator(`button:has-text("${branchName}")`) + .locator("xpath=following-sibling::button") + .last(); + await actionsButton.click(); + await page.waitForTimeout(300); + + // Click "Delete Worktree" + await page.getByText("Delete Worktree").click(); + await page.waitForTimeout(300); + + // Confirm deletion in the dialog + const deleteButton = page.getByRole("button", { name: "Delete" }); + await deleteButton.click(); + await page.waitForTimeout(1000); + + // Verify worktree is deleted + expect(fs.existsSync(worktreePath)).toBe(false); + + // Verify feature's branchName and worktreePath are reset to null + featureData = JSON.parse(fs.readFileSync(featureFilePath, "utf-8")); + expect(featureData.branchName).toBeNull(); + expect(featureData.worktreePath).toBeNull(); + + // Verify the feature appears in the backlog when main is selected + const mainButton = page.getByRole("button", { name: "main" }).first(); + await mainButton.click(); + await page.waitForTimeout(500); + + const featureText = page.getByText("Feature on deletable worktree"); + await expect(featureText).toBeVisible({ timeout: 5000 }); + + // Verify the feature also appears when switching to a different worktree + // Create another worktree + await apiCreateWorktree(page, testRepo.path, "feature/other-branch"); + await page.waitForTimeout(500); + + // Refresh worktrees + await refreshButton.click(); + await page.waitForTimeout(500); + + // Select the other worktree + const otherWorktreeButton = page.getByRole("button", { + name: /feature\/other-branch/i, + }); + await otherWorktreeButton.click(); + await page.waitForTimeout(500); + + // Unassigned features should still be visible in the backlog + await expect(featureText).toBeVisible({ timeout: 5000 }); + }); + test("should filter features by selected worktree", async ({ page }) => { // Create the worktrees first (using git directly for setup) await execAsync( @@ -946,4 +1141,1451 @@ test.describe("Worktree Integration Tests", () => { expect(result2.success).toBe(false); expect(result2.error).toContain("branchName"); }); + + // ========================================================================== + // Keyboard Input in Dropdowns + // ========================================================================== + + test("should allow typing in branch filter input without triggering navigation shortcuts", async ({ + page, + }) => { + await setupProjectWithPath(page, testRepo.path); + await page.goto("/"); + await waitForNetworkIdle(page); + await waitForBoardView(page); + + // Find the main branch button and its associated branch switch dropdown trigger + // The branch switch dropdown has a GitBranch icon button next to the main button + const branchSwitchButton = page.locator('button[title="Switch branch"]'); + await expect(branchSwitchButton).toBeVisible({ timeout: 10000 }); + + // Click to open the branch switch dropdown + await branchSwitchButton.click(); + + // Wait for the dropdown to open and the filter input to appear + const filterInput = page.getByPlaceholder("Filter branches..."); + await expect(filterInput).toBeVisible({ timeout: 5000 }); + + // DON'T explicitly focus the input - rely on autoFocus + // This tests the real scenario where user opens dropdown and types immediately + + // Use keyboard.press() to simulate a single key press + // "m" is the keyboard shortcut for profiles view, so this should test the bug + await page.keyboard.press("m"); + + // Verify the "m" was typed into the input (not triggering navigation) + await expect(filterInput).toHaveValue("m"); + + // Verify we're still on the board view (not navigated to profiles) + await expect(page.locator('[data-testid="board-view"]')).toBeVisible(); + + // Type more characters to ensure all navigation shortcut letters work + // k=board, a=agent, d=spec, c=context, s=settings, t=terminal + await page.keyboard.press("a"); + await page.keyboard.press("i"); + await page.keyboard.press("n"); + await expect(filterInput).toHaveValue("main"); + }); + + // ========================================================================== + // Worktree Feature Flag Disabled + // ========================================================================== + + test("should not show worktree panel when useWorktrees is disabled", async ({ + page, + }) => { + // Use the setup function that disables worktrees + await setupProjectWithPathNoWorktrees(page, testRepo.path); + await page.goto("/"); + await waitForNetworkIdle(page); + await waitForBoardView(page); + + // The worktree selector should NOT be visible + const branchLabel = page.getByText("Branch:"); + await expect(branchLabel).not.toBeVisible({ timeout: 5000 }); + + // The switch branch button should NOT be visible + const branchSwitchButton = page.locator('button[title="Switch branch"]'); + await expect(branchSwitchButton).not.toBeVisible(); + }); + + test("should allow creating and moving features when worktrees are disabled", async ({ + page, + }) => { + // Use the setup function that disables worktrees + await setupProjectWithPathNoWorktrees(page, testRepo.path); + await page.goto("/"); + await waitForNetworkIdle(page); + await waitForBoardView(page); + + // Verify worktree selector is NOT visible + const branchLabel = page.getByText("Branch:"); + await expect(branchLabel).not.toBeVisible({ timeout: 5000 }); + + // Create a feature + await clickAddFeature(page); + + // Fill in the feature details (without branch since worktrees are disabled) + const descriptionInput = page + .locator('[data-testid="add-feature-dialog"] textarea') + .first(); + await descriptionInput.fill("Test feature without worktrees"); + + // Confirm + await confirmAddFeature(page); + + // Wait for the feature to appear in the backlog + const featureCard = page.getByText("Test feature without worktrees"); + await expect(featureCard).toBeVisible({ timeout: 10000 }); + + // Verify the feature was created on the filesystem + const featuresDir = path.join(testRepo.path, ".automaker", "features"); + const featureDirs = fs.readdirSync(featuresDir); + expect(featureDirs.length).toBeGreaterThan(0); + + // Find the feature file and verify it exists + const featureDir = featureDirs.find((dir) => { + const featureFilePath = path.join(featuresDir, dir, "feature.json"); + if (fs.existsSync(featureFilePath)) { + const data = JSON.parse(fs.readFileSync(featureFilePath, "utf-8")); + return data.description === "Test feature without worktrees"; + } + return false; + }); + expect(featureDir).toBeDefined(); + + // Read the feature data + const featureFilePath = path.join( + featuresDir, + featureDir!, + "feature.json" + ); + const featureData = JSON.parse(fs.readFileSync(featureFilePath, "utf-8")); + expect(featureData.status).toBe("backlog"); + + // Now move the feature to in_progress using the "Make" button + // Find the feature card (uses kanban-card-{id} test id) + const featureCardLocator = page + .locator('[data-testid^="kanban-card-"]') + .filter({ hasText: "Test feature without worktrees" }); + await expect(featureCardLocator).toBeVisible(); + + // Click the "Make" button to start working on the feature + const makeButton = featureCardLocator.getByRole("button", { name: "Make" }); + await makeButton.click(); + + // Wait for the feature to move to in_progress column + await expect(async () => { + const updatedData = JSON.parse( + fs.readFileSync(featureFilePath, "utf-8") + ); + expect(updatedData.status).toBe("in_progress"); + }).toPass({ timeout: 10000 }); + + // Verify the UI shows the feature in the in_progress column + const inProgressColumn = page.locator( + '[data-testid="kanban-column-in_progress"]' + ); + await expect( + inProgressColumn.getByText("Test feature without worktrees") + ).toBeVisible({ timeout: 10000 }); + }); + + // ========================================================================== + // Status Endpoint Tests + // ========================================================================== + + test("should get status for worktree with changes", async ({ page }) => { + await setupProjectWithPath(page, testRepo.path); + await page.goto("/"); + await waitForNetworkIdle(page); + await waitForBoardView(page); + + const branchName = "feature/status-changes"; + const worktreePath = getWorktreePath(testRepo.path, branchName); + + // Create worktree + await apiCreateWorktree(page, testRepo.path, branchName); + + // Create modified files in the worktree + fs.writeFileSync(path.join(worktreePath, "changed1.txt"), "Change 1"); + fs.writeFileSync(path.join(worktreePath, "changed2.txt"), "Change 2"); + + // Call status endpoint + // The featureId is the sanitized directory name + const featureId = branchName.replace(/[^a-zA-Z0-9_-]/g, "-"); + const response = await page.request.post( + "http://localhost:3008/api/worktree/status", + { + data: { + projectPath: testRepo.path, + featureId, + }, + } + ); + + expect(response.ok()).toBe(true); + const data = await response.json(); + + expect(data.success).toBe(true); + expect(data.modifiedFiles).toBeGreaterThanOrEqual(2); + expect(data.files).toContain("changed1.txt"); + expect(data.files).toContain("changed2.txt"); + }); + + test("should get status for worktree without changes", async ({ page }) => { + await setupProjectWithPath(page, testRepo.path); + await page.goto("/"); + await waitForNetworkIdle(page); + await waitForBoardView(page); + + const branchName = "feature/status-no-changes"; + + // Create worktree (no modifications) + await apiCreateWorktree(page, testRepo.path, branchName); + + const featureId = branchName.replace(/[^a-zA-Z0-9_-]/g, "-"); + const response = await page.request.post( + "http://localhost:3008/api/worktree/status", + { + data: { + projectPath: testRepo.path, + featureId, + }, + } + ); + + expect(response.ok()).toBe(true); + const data = await response.json(); + + expect(data.success).toBe(true); + expect(data.modifiedFiles).toBe(0); + expect(data.files).toHaveLength(0); + }); + + test("should verify diff stat in status response", async ({ page }) => { + await setupProjectWithPath(page, testRepo.path); + await page.goto("/"); + await waitForNetworkIdle(page); + await waitForBoardView(page); + + const branchName = "feature/status-diffstat"; + const worktreePath = getWorktreePath(testRepo.path, branchName); + + await apiCreateWorktree(page, testRepo.path, branchName); + + // Add a tracked file and modify it + fs.writeFileSync(path.join(worktreePath, "tracked.txt"), "initial content"); + await execAsync("git add tracked.txt", { cwd: worktreePath }); + await execAsync('git commit -m "Add tracked file"', { cwd: worktreePath }); + + // Modify the file + fs.writeFileSync( + path.join(worktreePath, "tracked.txt"), + "modified content" + ); + + const featureId = branchName.replace(/[^a-zA-Z0-9_-]/g, "-"); + const response = await page.request.post( + "http://localhost:3008/api/worktree/status", + { + data: { + projectPath: testRepo.path, + featureId, + }, + } + ); + + const data = await response.json(); + + expect(data.success).toBe(true); + expect(data.diffStat).toBeDefined(); + // diffStat should contain info about tracked.txt + expect(data.diffStat).toContain("tracked.txt"); + }); + + test("should verify recent commits in status response", async ({ page }) => { + await setupProjectWithPath(page, testRepo.path); + await page.goto("/"); + await waitForNetworkIdle(page); + await waitForBoardView(page); + + const branchName = "feature/status-commits"; + const worktreePath = getWorktreePath(testRepo.path, branchName); + + await apiCreateWorktree(page, testRepo.path, branchName); + + // Make some commits + fs.writeFileSync(path.join(worktreePath, "file1.txt"), "content1"); + await execAsync("git add file1.txt", { cwd: worktreePath }); + await execAsync('git commit -m "First status test commit"', { + cwd: worktreePath, + }); + + fs.writeFileSync(path.join(worktreePath, "file2.txt"), "content2"); + await execAsync("git add file2.txt", { cwd: worktreePath }); + await execAsync('git commit -m "Second status test commit"', { + cwd: worktreePath, + }); + + const featureId = branchName.replace(/[^a-zA-Z0-9_-]/g, "-"); + const response = await page.request.post( + "http://localhost:3008/api/worktree/status", + { + data: { + projectPath: testRepo.path, + featureId, + }, + } + ); + + const data = await response.json(); + + expect(data.success).toBe(true); + expect(data.recentCommits).toBeDefined(); + expect(data.recentCommits.length).toBeGreaterThanOrEqual(2); + // Check that our commits are in the list + const commitsStr = data.recentCommits.join("\n"); + expect(commitsStr).toContain("First status test commit"); + expect(commitsStr).toContain("Second status test commit"); + }); + + test("should return empty status for non-existent worktree", async ({ + page, + }) => { + await setupProjectWithPath(page, testRepo.path); + await page.goto("/"); + await waitForNetworkIdle(page); + + const response = await page.request.post( + "http://localhost:3008/api/worktree/status", + { + data: { + projectPath: testRepo.path, + featureId: "non-existent-worktree", + }, + } + ); + + // According to the implementation, non-existent worktree returns success with empty data + expect(response.ok()).toBe(true); + const data = await response.json(); + + expect(data.success).toBe(true); + expect(data.modifiedFiles).toBe(0); + expect(data.files).toHaveLength(0); + expect(data.diffStat).toBe(""); + expect(data.recentCommits).toHaveLength(0); + }); + + test("should handle status with missing required fields", async ({ + page, + }) => { + await setupProjectWithPath(page, testRepo.path); + await page.goto("/"); + await waitForNetworkIdle(page); + + // Missing featureId + const response1 = await page.request.post( + "http://localhost:3008/api/worktree/status", + { + data: { + projectPath: testRepo.path, + }, + } + ); + + expect(response1.ok()).toBe(false); + const data1 = await response1.json(); + expect(data1.success).toBe(false); + expect(data1.error).toContain("featureId"); + + // Missing projectPath + const response2 = await page.request.post( + "http://localhost:3008/api/worktree/status", + { + data: { + featureId: "some-id", + }, + } + ); + + expect(response2.ok()).toBe(false); + const data2 = await response2.json(); + expect(data2.success).toBe(false); + expect(data2.error).toContain("projectPath"); + }); + + // ========================================================================== + // Info Endpoint Tests + // ========================================================================== + + test("should get info for existing worktree", async ({ page }) => { + await setupProjectWithPath(page, testRepo.path); + await page.goto("/"); + await waitForNetworkIdle(page); + await waitForBoardView(page); + + const branchName = "feature/info-test"; + + await apiCreateWorktree(page, testRepo.path, branchName); + + const featureId = branchName.replace(/[^a-zA-Z0-9_-]/g, "-"); + const response = await page.request.post( + "http://localhost:3008/api/worktree/info", + { + data: { + projectPath: testRepo.path, + featureId, + }, + } + ); + + expect(response.ok()).toBe(true); + const data = await response.json(); + + expect(data.success).toBe(true); + expect(data.worktreePath).toBeDefined(); + expect(data.branchName).toBe(branchName); + // Verify path uses forward slashes (normalized) + expect(data.worktreePath).toContain("/"); + expect(data.worktreePath).not.toContain("\\\\"); + }); + + test("should return null for non-existent worktree info", async ({ + page, + }) => { + await setupProjectWithPath(page, testRepo.path); + await page.goto("/"); + await waitForNetworkIdle(page); + + const response = await page.request.post( + "http://localhost:3008/api/worktree/info", + { + data: { + projectPath: testRepo.path, + featureId: "non-existent-info-worktree", + }, + } + ); + + expect(response.ok()).toBe(true); + const data = await response.json(); + + expect(data.success).toBe(true); + expect(data.worktreePath).toBeNull(); + expect(data.branchName).toBeNull(); + }); + + test("should handle info with missing required fields", async ({ page }) => { + await setupProjectWithPath(page, testRepo.path); + await page.goto("/"); + await waitForNetworkIdle(page); + + // Missing featureId + const response1 = await page.request.post( + "http://localhost:3008/api/worktree/info", + { + data: { + projectPath: testRepo.path, + }, + } + ); + + expect(response1.ok()).toBe(false); + const data1 = await response1.json(); + expect(data1.success).toBe(false); + expect(data1.error).toContain("featureId"); + }); + + // ========================================================================== + // Diffs Endpoint Tests + // ========================================================================== + + test("should get diffs for worktree with changes", async ({ page }) => { + await setupProjectWithPath(page, testRepo.path); + await page.goto("/"); + await waitForNetworkIdle(page); + await waitForBoardView(page); + + const branchName = "feature/diffs-with-changes"; + const worktreePath = getWorktreePath(testRepo.path, branchName); + + await apiCreateWorktree(page, testRepo.path, branchName); + + // Add a tracked file and modify it + fs.writeFileSync(path.join(worktreePath, "diff-test.txt"), "initial"); + await execAsync("git add diff-test.txt", { cwd: worktreePath }); + await execAsync('git commit -m "Add file for diff test"', { + cwd: worktreePath, + }); + + // Modify the file to create a diff + fs.writeFileSync(path.join(worktreePath, "diff-test.txt"), "modified"); + + const featureId = branchName.replace(/[^a-zA-Z0-9_-]/g, "-"); + const response = await page.request.post( + "http://localhost:3008/api/worktree/diffs", + { + data: { + projectPath: testRepo.path, + featureId, + }, + } + ); + + expect(response.ok()).toBe(true); + const data = await response.json(); + + expect(data.success).toBe(true); + expect(data.hasChanges).toBe(true); + expect(data.diff).toContain("diff-test.txt"); + // files can be either strings or objects with path property + const filePaths = data.files.map((f: string | { path: string }) => + typeof f === "string" ? f : f.path + ); + expect(filePaths).toContain("diff-test.txt"); + }); + + test("should get diffs for worktree without changes", async ({ page }) => { + await setupProjectWithPath(page, testRepo.path); + await page.goto("/"); + await waitForNetworkIdle(page); + await waitForBoardView(page); + + const branchName = "feature/diffs-no-changes"; + + await apiCreateWorktree(page, testRepo.path, branchName); + + const featureId = branchName.replace(/[^a-zA-Z0-9_-]/g, "-"); + const response = await page.request.post( + "http://localhost:3008/api/worktree/diffs", + { + data: { + projectPath: testRepo.path, + featureId, + }, + } + ); + + expect(response.ok()).toBe(true); + const data = await response.json(); + + expect(data.success).toBe(true); + expect(data.hasChanges).toBe(false); + expect(data.files).toHaveLength(0); + }); + + test("should fallback to main project when worktree does not exist for diffs", async ({ + page, + }) => { + await setupProjectWithPath(page, testRepo.path); + await page.goto("/"); + await waitForNetworkIdle(page); + + // Call diffs endpoint with non-existent worktree + // It should fallback to main project path + const response = await page.request.post( + "http://localhost:3008/api/worktree/diffs", + { + data: { + projectPath: testRepo.path, + featureId: "non-existent-diffs-worktree", + }, + } + ); + + expect(response.ok()).toBe(true); + const data = await response.json(); + + // Should still succeed but with main project diffs + expect(data.success).toBe(true); + expect(data.hasChanges).toBeDefined(); + expect(data.files).toBeDefined(); + }); + + test("should handle diffs with missing required fields", async ({ page }) => { + await setupProjectWithPath(page, testRepo.path); + await page.goto("/"); + await waitForNetworkIdle(page); + + // Missing featureId + const response = await page.request.post( + "http://localhost:3008/api/worktree/diffs", + { + data: { + projectPath: testRepo.path, + }, + } + ); + + expect(response.ok()).toBe(false); + const data = await response.json(); + expect(data.success).toBe(false); + expect(data.error).toContain("featureId"); + }); + + // ========================================================================== + // File Diff Endpoint Tests + // ========================================================================== + + test("should get diff for a modified tracked file", async ({ page }) => { + await setupProjectWithPath(page, testRepo.path); + await page.goto("/"); + await waitForNetworkIdle(page); + await waitForBoardView(page); + + const branchName = "feature/file-diff-tracked"; + const worktreePath = getWorktreePath(testRepo.path, branchName); + + await apiCreateWorktree(page, testRepo.path, branchName); + + // Create, add, and commit a file + fs.writeFileSync( + path.join(worktreePath, "tracked-file.txt"), + "line 1\nline 2\nline 3" + ); + await execAsync("git add tracked-file.txt", { cwd: worktreePath }); + await execAsync('git commit -m "Add tracked file"', { cwd: worktreePath }); + + // Modify the file + fs.writeFileSync( + path.join(worktreePath, "tracked-file.txt"), + "line 1\nmodified line 2\nline 3\nline 4" + ); + + const featureId = branchName.replace(/[^a-zA-Z0-9_-]/g, "-"); + const response = await page.request.post( + "http://localhost:3008/api/worktree/file-diff", + { + data: { + projectPath: testRepo.path, + featureId, + filePath: "tracked-file.txt", + }, + } + ); + + expect(response.ok()).toBe(true); + const data = await response.json(); + + expect(data.success).toBe(true); + expect(data.filePath).toBe("tracked-file.txt"); + expect(data.diff).toContain("tracked-file.txt"); + expect(data.diff).toContain("-line 2"); + expect(data.diff).toContain("+modified line 2"); + }); + + test("should get synthetic diff for untracked/new file", async ({ page }) => { + await setupProjectWithPath(page, testRepo.path); + await page.goto("/"); + await waitForNetworkIdle(page); + await waitForBoardView(page); + + const branchName = "feature/file-diff-untracked"; + const worktreePath = getWorktreePath(testRepo.path, branchName); + + await apiCreateWorktree(page, testRepo.path, branchName); + + // Create an untracked file (don't git add) + fs.writeFileSync( + path.join(worktreePath, "new-untracked.txt"), + "new file content\nline 2" + ); + + const featureId = branchName.replace(/[^a-zA-Z0-9_-]/g, "-"); + const response = await page.request.post( + "http://localhost:3008/api/worktree/file-diff", + { + data: { + projectPath: testRepo.path, + featureId, + filePath: "new-untracked.txt", + }, + } + ); + + expect(response.ok()).toBe(true); + const data = await response.json(); + + expect(data.success).toBe(true); + expect(data.filePath).toBe("new-untracked.txt"); + // Synthetic diff should show all lines as added + expect(data.diff).toContain("+new file content"); + expect(data.diff).toContain("+line 2"); + }); + + test("should return empty diff for non-existent file", async ({ page }) => { + await setupProjectWithPath(page, testRepo.path); + await page.goto("/"); + await waitForNetworkIdle(page); + await waitForBoardView(page); + + const branchName = "feature/file-diff-nonexistent"; + + await apiCreateWorktree(page, testRepo.path, branchName); + + const featureId = branchName.replace(/[^a-zA-Z0-9_-]/g, "-"); + const response = await page.request.post( + "http://localhost:3008/api/worktree/file-diff", + { + data: { + projectPath: testRepo.path, + featureId, + filePath: "does-not-exist.txt", + }, + } + ); + + expect(response.ok()).toBe(true); + const data = await response.json(); + + expect(data.success).toBe(true); + expect(data.diff).toBe(""); + }); + + test("should handle file-diff with missing required fields", async ({ + page, + }) => { + await setupProjectWithPath(page, testRepo.path); + await page.goto("/"); + await waitForNetworkIdle(page); + + // Missing filePath + const response = await page.request.post( + "http://localhost:3008/api/worktree/file-diff", + { + data: { + projectPath: testRepo.path, + featureId: "some-id", + }, + } + ); + + expect(response.ok()).toBe(false); + const data = await response.json(); + expect(data.success).toBe(false); + expect(data.error).toContain("filePath"); + }); + + // ========================================================================== + // Merge Endpoint Tests + // Note: These tests are skipped because the merge endpoint expects a specific + // worktree path structure (.worktrees/{featureId}) that doesn't match how + // apiCreateWorktree creates worktrees (uses sanitized branch names). + // The endpoints have different conventions for featureId vs branchName. + // ========================================================================== + + test.skip("should merge worktree branch into main", async ({ page }) => { + await setupProjectWithPath(page, testRepo.path); + await page.goto("/"); + await waitForNetworkIdle(page); + await waitForBoardView(page); + + // The merge endpoint expects featureId (not full branch name) + // and constructs branch name as `feature/${featureId}` + const featureId = "merge-test"; + const branchName = `feature/${featureId}`; + const worktreePath = getWorktreePath(testRepo.path, branchName); + + await apiCreateWorktree(page, testRepo.path, branchName); + + // Add a file and commit in the worktree + fs.writeFileSync(path.join(worktreePath, "merge-file.txt"), "merge content"); + await execAsync("git add merge-file.txt", { cwd: worktreePath }); + await execAsync('git commit -m "Add file for merge test"', { + cwd: worktreePath, + }); + + // Call merge endpoint + const response = await page.request.post( + "http://localhost:3008/api/worktree/merge", + { + data: { + projectPath: testRepo.path, + featureId, + }, + } + ); + + expect(response.ok()).toBe(true); + const data = await response.json(); + + expect(data.success).toBe(true); + expect(data.mergedBranch).toBe(branchName); + + // Verify the file from the feature branch is now in main + const mergedFilePath = path.join(testRepo.path, "merge-file.txt"); + expect(fs.existsSync(mergedFilePath)).toBe(true); + const content = fs.readFileSync(mergedFilePath, "utf-8"); + expect(content).toBe("merge content"); + + // Verify worktree was cleaned up + expect(fs.existsSync(worktreePath)).toBe(false); + + // Verify branch was deleted + const branches = await listBranches(testRepo.path); + expect(branches).not.toContain(branchName); + }); + + test.skip("should merge with squash option", async ({ page }) => { + await setupProjectWithPath(page, testRepo.path); + await page.goto("/"); + await waitForNetworkIdle(page); + await waitForBoardView(page); + + const featureId = "squash-merge-test"; + const branchName = `feature/${featureId}`; + const worktreePath = getWorktreePath(testRepo.path, branchName); + + await apiCreateWorktree(page, testRepo.path, branchName); + + // Make multiple commits + fs.writeFileSync(path.join(worktreePath, "squash1.txt"), "content 1"); + await execAsync("git add squash1.txt", { cwd: worktreePath }); + await execAsync('git commit -m "First squash commit"', { + cwd: worktreePath, + }); + + fs.writeFileSync(path.join(worktreePath, "squash2.txt"), "content 2"); + await execAsync("git add squash2.txt", { cwd: worktreePath }); + await execAsync('git commit -m "Second squash commit"', { + cwd: worktreePath, + }); + + // Merge with squash + const response = await page.request.post( + "http://localhost:3008/api/worktree/merge", + { + data: { + projectPath: testRepo.path, + featureId, + options: { + squash: true, + message: "Squashed feature commits", + }, + }, + } + ); + + expect(response.ok()).toBe(true); + const data = await response.json(); + + expect(data.success).toBe(true); + + // Verify files are present + expect(fs.existsSync(path.join(testRepo.path, "squash1.txt"))).toBe(true); + expect(fs.existsSync(path.join(testRepo.path, "squash2.txt"))).toBe(true); + + // Verify commit message + const { stdout: logOutput } = await execAsync("git log --oneline -1", { + cwd: testRepo.path, + }); + expect(logOutput).toContain("Squashed feature commits"); + }); + + test("should handle merge with missing required fields", async ({ page }) => { + await setupProjectWithPath(page, testRepo.path); + await page.goto("/"); + await waitForNetworkIdle(page); + + // Missing featureId + const response = await page.request.post( + "http://localhost:3008/api/worktree/merge", + { + data: { + projectPath: testRepo.path, + }, + } + ); + + expect(response.ok()).toBe(false); + const data = await response.json(); + expect(data.success).toBe(false); + expect(data.error).toContain("featureId"); + }); + + // ========================================================================== + // Create Worktree with baseBranch Parameter Tests + // ========================================================================== + + test("should create worktree from specific base branch", async ({ page }) => { + await setupProjectWithPath(page, testRepo.path); + await page.goto("/"); + await waitForNetworkIdle(page); + await waitForBoardView(page); + + // Create a base branch with a specific file + await execAsync("git checkout -b develop", { cwd: testRepo.path }); + fs.writeFileSync( + path.join(testRepo.path, "develop-only.txt"), + "This file only exists on develop" + ); + await execAsync("git add develop-only.txt", { cwd: testRepo.path }); + await execAsync('git commit -m "Add develop-only file"', { + cwd: testRepo.path, + }); + await execAsync("git checkout main", { cwd: testRepo.path }); + + // Verify file doesn't exist on main + expect(fs.existsSync(path.join(testRepo.path, "develop-only.txt"))).toBe( + false + ); + + // Create worktree from develop branch + const { response, data } = await apiCreateWorktree( + page, + testRepo.path, + "feature/from-develop", + "develop" // baseBranch + ); + + expect(response.ok()).toBe(true); + expect(data.success).toBe(true); + + // Verify the worktree has the file from develop + const worktreePath = getWorktreePath(testRepo.path, "feature/from-develop"); + expect( + fs.existsSync(path.join(worktreePath, "develop-only.txt")) + ).toBe(true); + const content = fs.readFileSync( + path.join(worktreePath, "develop-only.txt"), + "utf-8" + ); + expect(content).toBe("This file only exists on develop"); + }); + + test("should create worktree from HEAD when baseBranch not provided", async ({ + page, + }) => { + await setupProjectWithPath(page, testRepo.path); + await page.goto("/"); + await waitForNetworkIdle(page); + await waitForBoardView(page); + + // Get the current commit hash on main + const { stdout: mainHash } = await execAsync("git rev-parse HEAD", { + cwd: testRepo.path, + }); + + // Create worktree without specifying baseBranch + const { response, data } = await apiCreateWorktree( + page, + testRepo.path, + "feature/from-head" + ); + + expect(response.ok()).toBe(true); + expect(data.success).toBe(true); + + // Verify the worktree starts from the same commit as main + const worktreePath = getWorktreePath(testRepo.path, "feature/from-head"); + const { stdout: worktreeHash } = await execAsync( + "git rev-parse HEAD~0", + { cwd: worktreePath } + ); + + // The worktree's initial commit should be the same as main's HEAD + // (Since it was just created, we check the parent commit) + // Actually, a new worktree from HEAD should have the same commit + expect(worktreeHash.trim()).toBe(mainHash.trim()); + }); + + // ========================================================================== + // Branch Name Sanitization Tests + // ========================================================================== + + test("should create worktree with special characters in branch name", async ({ + page, + }) => { + await setupProjectWithPath(page, testRepo.path); + await page.goto("/"); + await waitForNetworkIdle(page); + await waitForBoardView(page); + + // Branch name with special characters + const branchName = "feature/test@special#chars"; + + const { response, data } = await apiCreateWorktree( + page, + testRepo.path, + branchName + ); + + expect(response.ok()).toBe(true); + expect(data.success).toBe(true); + + // Verify the sanitized path doesn't contain special characters + const expectedSanitizedName = branchName.replace(/[^a-zA-Z0-9_-]/g, "-"); + const expectedPath = path.join( + testRepo.path, + ".worktrees", + expectedSanitizedName + ); + + expect(fs.existsSync(expectedPath)).toBe(true); + }); + + test("should create worktree with nested slashes in branch name", async ({ + page, + }) => { + await setupProjectWithPath(page, testRepo.path); + await page.goto("/"); + await waitForNetworkIdle(page); + await waitForBoardView(page); + + // Branch name with nested slashes + const branchName = "feature/nested/deep/branch"; + + const { response, data } = await apiCreateWorktree( + page, + testRepo.path, + branchName + ); + + expect(response.ok()).toBe(true); + expect(data.success).toBe(true); + + // Verify the worktree was created + const expectedSanitizedName = branchName.replace(/[^a-zA-Z0-9_-]/g, "-"); + const expectedPath = path.join( + testRepo.path, + ".worktrees", + expectedSanitizedName + ); + + expect(fs.existsSync(expectedPath)).toBe(true); + + // Verify the branch exists + const branches = await listBranches(testRepo.path); + expect(branches).toContain(branchName); + }); + + // ========================================================================== + // Push Endpoint Tests + // ========================================================================== + + test("should handle push with missing required fields", async ({ page }) => { + await setupProjectWithPath(page, testRepo.path); + await page.goto("/"); + await waitForNetworkIdle(page); + + // Missing worktreePath + const response = await page.request.post( + "http://localhost:3008/api/worktree/push", + { + data: {}, + } + ); + + expect(response.ok()).toBe(false); + const data = await response.json(); + expect(data.success).toBe(false); + expect(data.error).toContain("worktreePath"); + }); + + test("should fail push when no remote is configured", async ({ page }) => { + await setupProjectWithPath(page, testRepo.path); + await page.goto("/"); + await waitForNetworkIdle(page); + await waitForBoardView(page); + + const branchName = "feature/push-no-remote"; + const worktreePath = getWorktreePath(testRepo.path, branchName); + + await apiCreateWorktree(page, testRepo.path, branchName); + + // Try to push (should fail because there's no remote) + const response = await page.request.post( + "http://localhost:3008/api/worktree/push", + { + data: { + worktreePath, + }, + } + ); + + expect(response.ok()).toBe(false); + const data = await response.json(); + expect(data.success).toBe(false); + // Error message should indicate no remote + expect(data.error).toBeDefined(); + }); + + // ========================================================================== + // Pull Endpoint Tests + // ========================================================================== + + test("should handle pull with missing required fields", async ({ page }) => { + await setupProjectWithPath(page, testRepo.path); + await page.goto("/"); + await waitForNetworkIdle(page); + + // Missing worktreePath + const response = await page.request.post( + "http://localhost:3008/api/worktree/pull", + { + data: {}, + } + ); + + expect(response.ok()).toBe(false); + const data = await response.json(); + expect(data.success).toBe(false); + expect(data.error).toContain("worktreePath"); + }); + + // Skip: This test requires a remote configured because pull does `git fetch origin` + // before checking for local changes. Without a remote, the fetch fails first. + test.skip("should fail pull when there are uncommitted local changes", async ({ + page, + }) => { + await setupProjectWithPath(page, testRepo.path); + await page.goto("/"); + await waitForNetworkIdle(page); + await waitForBoardView(page); + + const branchName = "feature/pull-uncommitted"; + const worktreePath = getWorktreePath(testRepo.path, branchName); + + await apiCreateWorktree(page, testRepo.path, branchName); + + // Create uncommitted changes + fs.writeFileSync( + path.join(worktreePath, "uncommitted.txt"), + "uncommitted changes" + ); + + // Try to pull (should fail because of uncommitted changes) + const response = await page.request.post( + "http://localhost:3008/api/worktree/pull", + { + data: { + worktreePath, + }, + } + ); + + expect(response.ok()).toBe(false); + const data = await response.json(); + expect(data.success).toBe(false); + expect(data.error).toContain("local changes"); + }); + + // ========================================================================== + // Create PR Endpoint Tests + // ========================================================================== + + test("should handle create-pr with missing required fields", async ({ + page, + }) => { + await setupProjectWithPath(page, testRepo.path); + await page.goto("/"); + await waitForNetworkIdle(page); + + // Missing worktreePath + const response = await page.request.post( + "http://localhost:3008/api/worktree/create-pr", + { + data: {}, + } + ); + + expect(response.ok()).toBe(false); + const data = await response.json(); + expect(data.success).toBe(false); + expect(data.error).toContain("worktreePath"); + }); + + test("should fail create-pr when push fails (no remote)", async ({ + page, + }) => { + await setupProjectWithPath(page, testRepo.path); + await page.goto("/"); + await waitForNetworkIdle(page); + await waitForBoardView(page); + + const branchName = "feature/pr-no-remote"; + const worktreePath = getWorktreePath(testRepo.path, branchName); + + await apiCreateWorktree(page, testRepo.path, branchName); + + // Add some changes to commit + fs.writeFileSync(path.join(worktreePath, "pr-file.txt"), "pr content"); + + // Try to create PR (should fail because there's no remote) + const response = await page.request.post( + "http://localhost:3008/api/worktree/create-pr", + { + data: { + worktreePath, + commitMessage: "Test commit", + prTitle: "Test PR", + prBody: "Test PR body", + }, + } + ); + + expect(response.ok()).toBe(false); + const data = await response.json(); + expect(data.success).toBe(false); + // Should fail during push + expect(data.error).toContain("push"); + }); + + // ========================================================================== + // Edit Feature with Branch Change + // ========================================================================== + + test("should create worktree when editing a feature and selecting a new branch", async ({ + page, + }) => { + await setupProjectWithPath(page, testRepo.path); + await page.goto("/"); + await waitForNetworkIdle(page); + await waitForBoardView(page); + + // First, create a feature on main branch (default) + await clickAddFeature(page); + await fillAddFeatureDialog(page, "Feature to edit branch", { + category: "Testing", + }); + await confirmAddFeature(page); + + // Wait for the feature to appear + await page.waitForTimeout(1000); + + // Verify feature was created on main branch + const featuresDir = path.join(testRepo.path, ".automaker", "features"); + const featureDirs = fs.readdirSync(featuresDir); + expect(featureDirs.length).toBeGreaterThan(0); + + // Find the feature we just created + const featureDir = featureDirs.find((dir) => { + const featureFilePath = path.join(featuresDir, dir, "feature.json"); + if (fs.existsSync(featureFilePath)) { + const data = JSON.parse(fs.readFileSync(featureFilePath, "utf-8")); + return data.description === "Feature to edit branch"; + } + return false; + }); + expect(featureDir).toBeDefined(); + + const featureFilePath = path.join(featuresDir, featureDir!, "feature.json"); + let featureData = JSON.parse(fs.readFileSync(featureFilePath, "utf-8")); + + // Initially, the feature should be on main or have no branch set + expect( + !featureData.branchName || featureData.branchName === "main" + ).toBe(true); + + // The new branch we want to assign + const newBranchName = "feature/edited-branch"; + const expectedWorktreePath = getWorktreePath(testRepo.path, newBranchName); + + // Verify worktree does NOT exist before editing + expect(fs.existsSync(expectedWorktreePath)).toBe(false); + + // Find and click the edit button on the feature card + const featureCard = page.getByText("Feature to edit branch"); + await expect(featureCard).toBeVisible({ timeout: 10000 }); + + // Double-click to open edit dialog + await featureCard.dblclick(); + await page.waitForTimeout(500); + + // Wait for edit dialog to open + const editDialog = page.locator('[data-testid="edit-feature-dialog"]'); + await expect(editDialog).toBeVisible({ timeout: 5000 }); + + // Find and click on the branch input to open the autocomplete + const branchInput = page.locator('[data-testid="edit-feature-branch"]'); + await branchInput.click(); + await page.waitForTimeout(300); + + // Type the new branch name + const commandInput = page.locator('[cmdk-input]'); + await commandInput.fill(newBranchName); + + // Press Enter to select/create the branch + await commandInput.press("Enter"); + await page.waitForTimeout(200); + + // Click Save Changes + const saveButton = page.locator('[data-testid="confirm-edit-feature"]'); + await saveButton.click(); + + // Wait for the dialog to close and worktree to be created + await page.waitForTimeout(2000); + + // Verify worktree was automatically created + expect(fs.existsSync(expectedWorktreePath)).toBe(true); + + // Verify the branch was created + const branches = await listBranches(testRepo.path); + expect(branches).toContain(newBranchName); + + // Verify feature was updated with correct branch and worktreePath + featureData = JSON.parse(fs.readFileSync(featureFilePath, "utf-8")); + expect(featureData.branchName).toBe(newBranchName); + expect(featureData.worktreePath).toBe(expectedWorktreePath); + }); + + test("should not create worktree when editing a feature and selecting main branch", async ({ + page, + }) => { + await setupProjectWithPath(page, testRepo.path); + await page.goto("/"); + await waitForNetworkIdle(page); + await waitForBoardView(page); + + // First, create a worktree with a feature assigned to it + const existingBranch = "feature/existing-branch"; + await apiCreateWorktree(page, testRepo.path, existingBranch); + + // Refresh to see the new worktree + const refreshButton = page.locator('button[title="Refresh worktrees"]'); + await refreshButton.click(); + await page.waitForTimeout(500); + + // Select the worktree + const worktreeButton = page.getByRole("button", { + name: new RegExp(existingBranch, "i"), + }); + await worktreeButton.click(); + await page.waitForTimeout(500); + + // Create a feature on this branch + await clickAddFeature(page); + await fillAddFeatureDialog(page, "Feature to change to main", { + branch: existingBranch, + category: "Testing", + }); + await confirmAddFeature(page); + await page.waitForTimeout(1000); + + // Verify feature was created with the branch + const featuresDir = path.join(testRepo.path, ".automaker", "features"); + const featureDirs = fs.readdirSync(featuresDir); + const featureDir = featureDirs.find((dir) => { + const featureFilePath = path.join(featuresDir, dir, "feature.json"); + if (fs.existsSync(featureFilePath)) { + const data = JSON.parse(fs.readFileSync(featureFilePath, "utf-8")); + return data.description === "Feature to change to main"; + } + return false; + }); + expect(featureDir).toBeDefined(); + + const featureFilePath = path.join(featuresDir, featureDir!, "feature.json"); + let featureData = JSON.parse(fs.readFileSync(featureFilePath, "utf-8")); + expect(featureData.branchName).toBe(existingBranch); + + // Now edit and change to main branch + const featureCard = page.getByText("Feature to change to main"); + await featureCard.dblclick(); + await page.waitForTimeout(500); + + // Wait for edit dialog to open + const editDialog = page.locator('[data-testid="edit-feature-dialog"]'); + await expect(editDialog).toBeVisible({ timeout: 5000 }); + + // Find and click on the branch input + const branchInput = page.locator('[data-testid="edit-feature-branch"]'); + await branchInput.click(); + await page.waitForTimeout(300); + + // Type "main" to change to main branch + const commandInput = page.locator('[cmdk-input]'); + await commandInput.fill("main"); + await commandInput.press("Enter"); + await page.waitForTimeout(200); + + // Save changes + const saveButton = page.locator('[data-testid="confirm-edit-feature"]'); + await saveButton.click(); + await page.waitForTimeout(1000); + + // Verify feature was updated to main branch + featureData = JSON.parse(fs.readFileSync(featureFilePath, "utf-8")); + expect(featureData.branchName).toBe("main"); + + // worktreePath should be cleared (null or undefined) for main branch + expect( + featureData.worktreePath === null || + featureData.worktreePath === undefined + ).toBe(true); + }); + + test("should reuse existing worktree when editing feature to an existing branch", async ({ + page, + }) => { + await setupProjectWithPath(page, testRepo.path); + await page.goto("/"); + await waitForNetworkIdle(page); + await waitForBoardView(page); + + // Create a worktree first + const existingBranch = "feature/already-exists"; + const existingWorktreePath = getWorktreePath(testRepo.path, existingBranch); + await apiCreateWorktree(page, testRepo.path, existingBranch); + + // Verify worktree exists + expect(fs.existsSync(existingWorktreePath)).toBe(true); + + // Create a feature on main + await clickAddFeature(page); + await fillAddFeatureDialog(page, "Feature to use existing worktree", { + category: "Testing", + }); + await confirmAddFeature(page); + await page.waitForTimeout(1000); + + // Edit the feature to use the existing branch + const featureCard = page.getByText("Feature to use existing worktree"); + await featureCard.dblclick(); + await page.waitForTimeout(500); + + const editDialog = page.locator('[data-testid="edit-feature-dialog"]'); + await expect(editDialog).toBeVisible({ timeout: 5000 }); + + // Change to the existing branch + const branchInput = page.locator('[data-testid="edit-feature-branch"]'); + await branchInput.click(); + await page.waitForTimeout(300); + + const commandInput = page.locator('[cmdk-input]'); + await commandInput.fill(existingBranch); + await commandInput.press("Enter"); + await page.waitForTimeout(200); + + // Save changes + const saveButton = page.locator('[data-testid="confirm-edit-feature"]'); + await saveButton.click(); + await page.waitForTimeout(1000); + + // Verify the existing worktree is still there (not duplicated) + const worktrees = await listWorktrees(testRepo.path); + const matchingWorktrees = worktrees.filter( + (wt) => wt === existingWorktreePath + ); + expect(matchingWorktrees.length).toBe(1); + + // Verify feature was updated with the correct worktreePath + const featuresDir = path.join(testRepo.path, ".automaker", "features"); + const featureDirs = fs.readdirSync(featuresDir); + const featureDir = featureDirs.find((dir) => { + const featureFilePath = path.join(featuresDir, dir, "feature.json"); + if (fs.existsSync(featureFilePath)) { + const data = JSON.parse(fs.readFileSync(featureFilePath, "utf-8")); + return data.description === "Feature to use existing worktree"; + } + return false; + }); + + const featureFilePath = path.join(featuresDir, featureDir!, "feature.json"); + const featureData = JSON.parse(fs.readFileSync(featureFilePath, "utf-8")); + expect(featureData.branchName).toBe(existingBranch); + expect(featureData.worktreePath).toBe(existingWorktreePath); + }); }); diff --git a/apps/server/src/routes/app-spec/index.ts b/apps/server/src/routes/app-spec/index.ts index 7dbde6aa..9964289c 100644 --- a/apps/server/src/routes/app-spec/index.ts +++ b/apps/server/src/routes/app-spec/index.ts @@ -22,3 +22,4 @@ export function createSpecRegenerationRoutes(events: EventEmitter): Router { return router; } + diff --git a/apps/server/src/routes/setup/routes/delete-api-key.ts b/apps/server/src/routes/setup/routes/delete-api-key.ts index 9012dab3..575d0758 100644 --- a/apps/server/src/routes/setup/routes/delete-api-key.ts +++ b/apps/server/src/routes/setup/routes/delete-api-key.ts @@ -102,3 +102,4 @@ export function createDeleteApiKeyHandler() { }; } + diff --git a/apps/server/src/routes/worktree/index.ts b/apps/server/src/routes/worktree/index.ts index 4c9761cb..b6c182c8 100644 --- a/apps/server/src/routes/worktree/index.ts +++ b/apps/server/src/routes/worktree/index.ts @@ -8,7 +8,6 @@ import { createStatusHandler } from "./routes/status.js"; import { createListHandler } from "./routes/list.js"; 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"; @@ -19,9 +18,11 @@ 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, createGetDefaultEditorHandler } from "./routes/open-in-editor.js"; +import { + createOpenInEditorHandler, + createGetDefaultEditorHandler, +} 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"; @@ -35,7 +36,6 @@ export function createWorktreeRoutes(): Router { router.post("/list", createListHandler()); router.post("/diffs", createDiffsHandler()); router.post("/file-diff", createFileDiffHandler()); - router.post("/revert", createRevertHandler()); router.post("/merge", createMergeHandler()); router.post("/create", createCreateHandler()); router.post("/delete", createDeleteHandler()); @@ -49,7 +49,6 @@ export function createWorktreeRoutes(): Router { router.post("/open-in-editor", createOpenInEditorHandler()); router.get("/default-editor", createGetDefaultEditorHandler()); router.post("/init-git", createInitGitHandler()); - router.post("/activate", createActivateHandler()); router.post("/migrate", createMigrateHandler()); router.post("/start-dev", createStartDevHandler()); router.post("/stop-dev", createStopDevHandler()); diff --git a/apps/server/src/routes/worktree/routes/activate.ts b/apps/server/src/routes/worktree/routes/activate.ts deleted file mode 100644 index 59e845b9..00000000 --- a/apps/server/src/routes/worktree/routes/activate.ts +++ /dev/null @@ -1,149 +0,0 @@ -/** - * 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 { - 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 { - const { stdout } = await execAsync("git branch --show-current", { cwd }); - return stdout.trim(); -} - -async function getWorktreeBranch(worktreePath: string): Promise { - const { stdout } = await execAsync("git branch --show-current", { - cwd: worktreePath, - }); - return stdout.trim(); -} - -async function getChangesSummary(cwd: string): Promise { - 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 => { - 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) }); - } - }; -} diff --git a/apps/server/src/routes/worktree/routes/revert.ts b/apps/server/src/routes/worktree/routes/revert.ts deleted file mode 100644 index 6f0d7871..00000000 --- a/apps/server/src/routes/worktree/routes/revert.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** - * POST /revert endpoint - Revert feature (remove worktree) - */ - -import type { Request, Response } from "express"; -import { exec } from "child_process"; -import { promisify } from "util"; -import path from "path"; -import { getErrorMessage, logError } from "../common.js"; - -const execAsync = promisify(exec); - -export function createRevertHandler() { - return async (req: Request, res: Response): Promise => { - try { - const { projectPath, featureId } = req.body as { - projectPath: string; - featureId: string; - }; - - if (!projectPath || !featureId) { - res - .status(400) - .json({ - success: false, - error: "projectPath and featureId required", - }); - return; - } - - // Git worktrees are stored in project directory - const worktreePath = path.join(projectPath, ".worktrees", featureId); - - try { - // Remove worktree - await execAsync(`git worktree remove "${worktreePath}" --force`, { - cwd: projectPath, - }); - // Delete branch - await execAsync(`git branch -D feature/${featureId}`, { - cwd: projectPath, - }); - - res.json({ success: true, removedPath: worktreePath }); - } catch (error) { - // Worktree might not exist - res.json({ success: true, removedPath: null }); - } - } catch (error) { - logError(error, "Revert worktree failed"); - res.status(500).json({ success: false, error: getErrorMessage(error) }); - } - }; -} diff --git a/docs/server/route-organization.md b/docs/server/route-organization.md index 45cc972f..bb8df194 100644 --- a/docs/server/route-organization.md +++ b/docs/server/route-organization.md @@ -581,3 +581,4 @@ The route organization pattern provides: Apply this pattern to all route modules for consistency and improved code quality. +