diff --git a/.gitignore b/.gitignore index 590e1b67..de92f2ec 100644 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,5 @@ dist/ /.automaker/* /.automaker/ -/logs \ No newline at end of file +/logs +.worktrees/ \ No newline at end of file diff --git a/.worktrees/feature-model-select b/.worktrees/feature-model-select deleted file mode 160000 index b95c54a5..00000000 --- a/.worktrees/feature-model-select +++ /dev/null @@ -1 +0,0 @@ -Subproject commit b95c54a5399c125a793b3086abe4129c4e9769e8 diff --git a/apps/app/src/components/ui/branch-autocomplete.tsx b/apps/app/src/components/ui/branch-autocomplete.tsx new file mode 100644 index 00000000..1d5758d8 --- /dev/null +++ b/apps/app/src/components/ui/branch-autocomplete.tsx @@ -0,0 +1,151 @@ +"use client"; + +import * as React from "react"; +import { Check, ChevronsUpDown, GitBranch } from "lucide-react"; + +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; + +interface BranchAutocompleteProps { + value: string; + onChange: (value: string) => void; + branches: string[]; + placeholder?: string; + className?: string; + disabled?: boolean; + "data-testid"?: string; +} + +export function BranchAutocomplete({ + value, + onChange, + branches, + placeholder = "Select a branch...", + className, + disabled = false, + "data-testid": testId, +}: BranchAutocompleteProps) { + const [open, setOpen] = React.useState(false); + const [inputValue, setInputValue] = React.useState(""); + + // Always include "main" at the top of suggestions + const allBranches = React.useMemo(() => { + const branchSet = new Set(["main", ...branches]); + return Array.from(branchSet); + }, [branches]); + + // Filter branches based on input + const filteredBranches = React.useMemo(() => { + if (!inputValue) return allBranches; + const lower = inputValue.toLowerCase(); + return allBranches.filter((b) => b.toLowerCase().includes(lower)); + }, [allBranches, inputValue]); + + // Check if user typed a new branch name that doesn't exist + const isNewBranch = + inputValue.trim() && + !allBranches.some((b) => b.toLowerCase() === inputValue.toLowerCase()); + + return ( + + + + + + + + + + {inputValue.trim() ? ( +
+ Press enter to create{" "} + {inputValue} +
+ ) : ( + "No branches found." + )} +
+ + {/* Show "Create new" option if typing a new branch name */} + {isNewBranch && ( + { + onChange(inputValue); + setInputValue(""); + setOpen(false); + }} + className="text-[var(--status-success)]" + data-testid="branch-option-create-new" + > + + Create "{inputValue}" + + (new) + + + )} + {filteredBranches.map((branch) => ( + { + onChange(currentValue); + setInputValue(""); + setOpen(false); + }} + data-testid={`branch-option-${branch.replace(/[/\\]/g, "-")}`} + > + + {branch} + + {branch === "main" && ( + + (default) + + )} + + ))} + +
+
+
+
+ ); +} diff --git a/apps/app/src/components/views/board-view.tsx b/apps/app/src/components/views/board-view.tsx index 280ae5d1..a2dfabf1 100644 --- a/apps/app/src/components/views/board-view.tsx +++ b/apps/app/src/components/views/board-view.tsx @@ -63,6 +63,7 @@ export function BoardView() { specCreatingForProject, setSpecCreatingForProject, getCurrentWorktree, + setCurrentWorktree, } = useAppStore(); const shortcuts = useKeyboardShortcutsConfig(); const { @@ -208,6 +209,40 @@ export function BoardView() { return [...new Set(allCategories)].sort(); }, [hookFeatures, persistedCategories]); + // Branch suggestions for the branch autocomplete + const [branchSuggestions, setBranchSuggestions] = useState([]); + + // Fetch branches when project changes + useEffect(() => { + const fetchBranches = async () => { + if (!currentProject) { + setBranchSuggestions([]); + return; + } + + try { + const api = getElectronAPI(); + if (!api?.worktree?.listBranches) { + setBranchSuggestions([]); + return; + } + + const result = await api.worktree.listBranches(currentProject.path); + if (result.success && result.result?.branches) { + const localBranches = result.result.branches + .filter((b) => !b.isRemote) + .map((b) => b.name); + setBranchSuggestions(localBranches); + } + } catch (error) { + console.error("[BoardView] Error fetching branches:", error); + setBranchSuggestions([]); + } + }; + + fetchBranches(); + }, [currentProject]); + // Custom collision detection that prioritizes columns over cards const collisionDetectionStrategy = useCallback( (args: any) => { @@ -287,6 +322,8 @@ export function BoardView() { setShowFollowUpDialog, inProgressFeaturesForShortcuts, outputFeature, + projectPath: currentProject?.path || null, + onWorktreeCreated: () => setWorktreeRefreshKey((k) => k + 1), }); // Use keyboard shortcuts hook (after actions hook) @@ -299,8 +336,12 @@ export function BoardView() { }); // Use drag and drop hook - // Get current worktree path for filtering features and assigning to cards + // Get current worktree path and branch for filtering features const currentWorktreePath = currentProject ? getCurrentWorktree(currentProject.path) : null; + const worktrees = useAppStore((s) => currentProject ? s.getWorktrees(currentProject.path) : []); + const currentWorktreeBranch = currentWorktreePath + ? worktrees.find(w => w.path === currentWorktreePath)?.branch || null + : null; const { activeFeature, handleDragStart, handleDragEnd } = useBoardDragDrop({ features: hookFeatures, @@ -308,8 +349,8 @@ export function BoardView() { runningAutoTasks, persistFeatureUpdate, handleStartImplementation, - currentWorktreePath, projectPath: currentProject?.path || null, + onWorktreeCreated: () => setWorktreeRefreshKey((k) => k + 1), }); // Use column features hook @@ -318,6 +359,7 @@ export function BoardView() { runningAutoTasks, searchQuery, currentWorktreePath, + currentWorktreeBranch, projectPath: currentProject?.path || null, }); @@ -372,7 +414,7 @@ export function BoardView() { {/* Worktree Selector */} setShowCreateWorktreeDialog(true)} onDeleteWorktree={(worktree) => { @@ -391,6 +433,8 @@ export function BoardView() { setSelectedWorktreeForAction(worktree); setShowCreateBranchDialog(true); }} + runningFeatureIds={runningAutoTasks} + features={hookFeatures.map(f => ({ id: f.id, worktreePath: f.worktreePath }))} /> {/* Main Content Area */} @@ -435,8 +479,6 @@ export function BoardView() { onMoveBackToInProgress={handleMoveBackToInProgress} onFollowUp={handleOpenFollowUp} onCommit={handleCommitFeature} - onRevert={handleRevertFeature} - onMerge={handleMergeFeature} onComplete={handleCompleteFeature} onImplement={handleStartImplementation} featuresWithContext={featuresWithContext} @@ -482,6 +524,7 @@ export function BoardView() { onOpenChange={setShowAddDialog} onAdd={handleAddFeature} categorySuggestions={categorySuggestions} + branchSuggestions={branchSuggestions} defaultSkipTests={defaultSkipTests} isMaximized={isMaximized} showProfilesOnly={showProfilesOnly} @@ -494,6 +537,7 @@ export function BoardView() { onClose={() => setEditingFeature(null)} onUpdate={handleUpdateFeature} categorySuggestions={categorySuggestions} + branchSuggestions={branchSuggestions} isMaximized={isMaximized} showProfilesOnly={showProfilesOnly} aiProfiles={aiProfiles} @@ -551,7 +595,11 @@ export function BoardView() { open={showCreateWorktreeDialog} onOpenChange={setShowCreateWorktreeDialog} projectPath={currentProject.path} - onCreated={() => setWorktreeRefreshKey((k) => k + 1)} + onCreated={(worktreePath) => { + setWorktreeRefreshKey((k) => k + 1); + // Auto-select the newly created worktree + setCurrentWorktree(currentProject.path, worktreePath); + }} /> {/* Delete Worktree Dialog */} 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 11e67ac6..37b20ce9 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 @@ -52,8 +52,6 @@ import { MoreVertical, AlertCircle, GitBranch, - Undo2, - GitMerge, ChevronDown, ChevronUp, Brain, @@ -103,8 +101,6 @@ interface KanbanCardProps { onMoveBackToInProgress?: () => void; onFollowUp?: () => void; onCommit?: () => void; - onRevert?: () => void; - onMerge?: () => void; onImplement?: () => void; onComplete?: () => void; hasContext?: boolean; @@ -130,8 +126,6 @@ export const KanbanCard = memo(function KanbanCard({ onMoveBackToInProgress, onFollowUp, onCommit, - onRevert, - onMerge, onImplement, onComplete, hasContext, @@ -146,7 +140,6 @@ export const KanbanCard = memo(function KanbanCard({ }: KanbanCardProps) { const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const [isSummaryDialogOpen, setIsSummaryDialogOpen] = useState(false); - const [isRevertDialogOpen, setIsRevertDialogOpen] = useState(false); const [agentInfo, setAgentInfo] = useState(null); const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false); const [currentTime, setCurrentTime] = useState(() => Date.now()); @@ -621,6 +614,16 @@ export const KanbanCard = memo(function KanbanCard({ + {/* Target Branch Display */} + {feature.branchName && ( +
+ + + {feature.branchName} + +
+ )} + {/* Steps Preview */} {showSteps && feature.steps && feature.steps.length > 0 && (
@@ -953,30 +956,6 @@ export const KanbanCard = memo(function KanbanCard({ )} {!isCurrentAutoTask && feature.status === "waiting_approval" && ( <> - {hasWorktree && onRevert && ( - - - - - - -

Revert changes

-
-
-
- )} {/* Refine prompt button */} {onFollowUp && ( )} - {hasWorktree && onMerge && ( - - )} - {!hasWorktree && onCommit && ( + {onCommit && ( - - - - ); 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 24c5e930..fed25640 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 @@ -27,6 +27,10 @@ import { GitBranchPlus, Check, Search, + Play, + Square, + Globe, + Loader2, } from "lucide-react"; import { useAppStore } from "@/store/app-store"; import { getElectronAPI } from "@/lib/electron"; @@ -37,6 +41,8 @@ interface WorktreeInfo { path: string; branch: string; isMain: boolean; + isCurrent: boolean; // Is this the currently checked out branch? + hasWorktree: boolean; // Does this branch have an active worktree? hasChanges?: boolean; changedFilesCount?: number; } @@ -47,6 +53,17 @@ interface BranchInfo { isRemote: boolean; } +interface DevServerInfo { + worktreePath: string; + port: number; + url: string; +} + +interface FeatureInfo { + id: string; + worktreePath?: string; +} + interface WorktreeSelectorProps { projectPath: string; onCreateWorktree: () => void; @@ -54,6 +71,10 @@ interface WorktreeSelectorProps { onCommit: (worktree: WorktreeInfo) => void; onCreatePR: (worktree: WorktreeInfo) => void; onCreateBranch: (worktree: WorktreeInfo) => void; + runningFeatureIds?: string[]; + features?: FeatureInfo[]; + /** Increment this to trigger a refresh without unmounting the component */ + refreshTrigger?: number; } export function WorktreeSelector({ @@ -63,17 +84,23 @@ export function WorktreeSelector({ onCommit, onCreatePR, onCreateBranch, + runningFeatureIds = [], + features = [], + refreshTrigger = 0, }: WorktreeSelectorProps) { const [isLoading, setIsLoading] = useState(false); const [isPulling, setIsPulling] = useState(false); const [isPushing, setIsPushing] = useState(false); const [isSwitching, setIsSwitching] = useState(false); + const [isActivating, setIsActivating] = useState(false); + const [isStartingDevServer, setIsStartingDevServer] = useState(false); const [worktrees, setWorktrees] = useState([]); const [branches, setBranches] = useState([]); const [aheadCount, setAheadCount] = useState(0); const [behindCount, setBehindCount] = useState(0); const [isLoadingBranches, setIsLoadingBranches] = useState(false); const [branchFilter, setBranchFilter] = useState(""); + const [runningDevServers, setRunningDevServers] = useState>(new Map()); const currentWorktree = useAppStore((s) => s.getCurrentWorktree(projectPath)); const setCurrentWorktree = useAppStore((s) => s.setCurrentWorktree); const setWorktreesInStore = useAppStore((s) => s.setWorktrees); @@ -99,6 +126,25 @@ export function WorktreeSelector({ } }, [projectPath, setWorktreesInStore]); + const fetchDevServers = useCallback(async () => { + try { + const api = getElectronAPI(); + if (!api?.worktree?.listDevServers) { + return; + } + const result = await api.worktree.listDevServers(); + if (result.success && result.result?.servers) { + const serversMap = new Map(); + for (const server of result.result.servers) { + serversMap.set(server.worktreePath, server); + } + setRunningDevServers(serversMap); + } + } catch (error) { + console.error("Failed to fetch dev servers:", error); + } + }, []); + const fetchBranches = useCallback(async (worktreePath: string) => { setIsLoadingBranches(true); try { @@ -122,12 +168,198 @@ export function WorktreeSelector({ useEffect(() => { fetchWorktrees(); - }, [fetchWorktrees]); + fetchDevServers(); + }, [fetchWorktrees, fetchDevServers]); - const handleSelectWorktree = (worktree: WorktreeInfo) => { + // Refresh when refreshTrigger changes (but skip the initial render) + useEffect(() => { + if (refreshTrigger > 0) { + fetchWorktrees(); + } + }, [refreshTrigger, fetchWorktrees]); + + // Initialize selection to main if not set + useEffect(() => { + if (worktrees.length > 0 && currentWorktree === undefined) { + setCurrentWorktree(projectPath, null); // null = main worktree + } + }, [worktrees, currentWorktree, projectPath, setCurrentWorktree]); + + const handleSelectWorktree = async (worktree: WorktreeInfo) => { + // Simply select the worktree in the UI setCurrentWorktree(projectPath, worktree.isMain ? null : worktree.path); }; + const handleStartDevServer = async (worktree: WorktreeInfo) => { + if (isStartingDevServer) return; + setIsStartingDevServer(true); + + try { + const api = getElectronAPI(); + if (!api?.worktree?.startDevServer) { + toast.error("Start dev server API not available"); + return; + } + + // Use projectPath for main, worktree.path for others + const targetPath = worktree.isMain ? projectPath : worktree.path; + const result = await api.worktree.startDevServer(projectPath, targetPath); + + if (result.success && result.result) { + // Update running servers map + setRunningDevServers((prev) => { + const next = new Map(prev); + next.set(targetPath, { + worktreePath: result.result!.worktreePath, + port: result.result!.port, + url: result.result!.url, + }); + return next; + }); + toast.success(`Dev server started on port ${result.result.port}`); + } else { + toast.error(result.error || "Failed to start dev server"); + } + } catch (error) { + console.error("Start dev server failed:", error); + toast.error("Failed to start dev server"); + } finally { + setIsStartingDevServer(false); + } + }; + + const handleStopDevServer = async (worktree: WorktreeInfo) => { + try { + const api = getElectronAPI(); + if (!api?.worktree?.stopDevServer) { + toast.error("Stop dev server API not available"); + return; + } + + // Use projectPath for main, worktree.path for others + const targetPath = worktree.isMain ? projectPath : worktree.path; + const result = await api.worktree.stopDevServer(targetPath); + + if (result.success) { + // Update running servers map + setRunningDevServers((prev) => { + const next = new Map(prev); + next.delete(targetPath); + return next; + }); + toast.success(result.result?.message || "Dev server stopped"); + } else { + toast.error(result.error || "Failed to stop dev server"); + } + } catch (error) { + console.error("Stop dev server failed:", error); + toast.error("Failed to stop dev server"); + } + }; + + const handleOpenDevServerUrl = (worktree: WorktreeInfo) => { + const targetPath = worktree.isMain ? projectPath : worktree.path; + const serverInfo = runningDevServers.get(targetPath); + if (serverInfo) { + window.open(serverInfo.url, "_blank"); + } + }; + + // Helper to get the path key for a worktree (for looking up in runningDevServers) + const getWorktreeKey = (worktree: WorktreeInfo) => { + return worktree.isMain ? projectPath : worktree.path; + }; + + // Helper to check if a worktree has running features + const hasRunningFeatures = (worktree: WorktreeInfo) => { + if (runningFeatureIds.length === 0) return false; + + const worktreeKey = getWorktreeKey(worktree); + + // Check if any running feature belongs to this worktree + return runningFeatureIds.some((featureId) => { + const feature = features.find((f) => f.id === featureId); + if (!feature) return false; + + // For main worktree, check features with no worktreePath or matching projectPath + if (worktree.isMain) { + return !feature.worktreePath || feature.worktreePath === projectPath; + } + + // For other worktrees, check if worktreePath matches + return feature.worktreePath === worktreeKey; + }); + }; + + 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(); + if (!api?.worktree?.openInEditor) { + console.warn("Open in editor API not available"); + return; + } + const result = await api.worktree.openInEditor(worktree.path); + if (result.success && result.result) { + toast.success(result.result.message); + } else if (result.error) { + toast.error(result.error); + } + } catch (error) { + console.error("Open in editor failed:", error); + } + }; + const handleSwitchBranch = async (worktree: WorktreeInfo, branchName: string) => { if (isSwitching || branchName === worktree.branch) return; setIsSwitching(true); @@ -204,10 +436,11 @@ export function WorktreeSelector({ } }; - const selectedWorktree = - worktrees.find((w) => - currentWorktree ? w.path === currentWorktree : w.isMain - ) || worktrees.find((w) => w.isMain); + // The "selected" worktree is based on UI state, not git's current branch + // currentWorktree is null for main, or the worktree path for others + const selectedWorktree = currentWorktree + ? worktrees.find((w) => w.path === currentWorktree) + : worktrees.find((w) => w.isMain); if (worktrees.length === 0 && !isLoading) { // No git repo or loading @@ -216,116 +449,146 @@ export function WorktreeSelector({ // Render a worktree tab with branch selector (for main) and actions dropdown const renderWorktreeTab = (worktree: WorktreeInfo) => { - const isSelected = selectedWorktree?.path === worktree.path; + // Selection is based on UI state, not git's current branch + // Default to main selected if currentWorktree is null or undefined + const isSelected = worktree.isMain + ? currentWorktree === null || currentWorktree === undefined + : worktree.path === currentWorktree; + + const isRunning = hasRunningFeatures(worktree); return (
- {/* Branch name - clickable dropdown for main repo to switch branches */} + {/* Main branch: clickable button + separate branch switch dropdown */} {worktree.isMain ? ( - { - if (open) { - // Select this worktree when opening the dropdown - if (!isSelected) { - handleSelectWorktree(worktree); + <> + {/* Clickable button to select/preview main */} + + {/* Branch switch dropdown button */} + { + if (open) { + fetchBranches(worktree.path); + setBranchFilter(""); } - fetchBranches(worktree.path); - setBranchFilter(""); - } - }}> - - - - - {/* Search input */} -
-
- - setBranchFilter(e.target.value)} - onKeyDown={(e) => e.stopPropagation()} - onKeyUp={(e) => e.stopPropagation()} - onKeyPress={(e) => e.stopPropagation()} - className="h-7 pl-7 text-xs" - autoFocus - /> + }}> + + + + + Switch Branch + + {/* Search input */} +
+
+ + setBranchFilter(e.target.value)} + onKeyDown={(e) => e.stopPropagation()} + onKeyUp={(e) => e.stopPropagation()} + onKeyPress={(e) => e.stopPropagation()} + className="h-7 pl-7 text-xs" + autoFocus + /> +
-
- -
- {isLoadingBranches ? ( - - - Loading branches... - - ) : (() => { - const filteredBranches = branches.filter((b) => - b.name.toLowerCase().includes(branchFilter.toLowerCase()) - ); - if (filteredBranches.length === 0) { - return ( - - {branchFilter ? "No matching branches" : "No branches found"} - - ); - } - return filteredBranches.map((branch) => ( - handleSwitchBranch(worktree, branch.name)} - disabled={isSwitching || branch.name === worktree.branch} - className="text-xs font-mono" - > - {branch.name === worktree.branch ? ( - - ) : ( - - )} - {branch.name} + +
+ {isLoadingBranches ? ( + + + Loading branches... - )); - })()} -
- - onCreateBranch(worktree)} - className="text-xs" - > - - Create New Branch... - - - + ) : (() => { + const filteredBranches = branches.filter((b) => + b.name.toLowerCase().includes(branchFilter.toLowerCase()) + ); + if (filteredBranches.length === 0) { + return ( + + {branchFilter ? "No matching branches" : "No branches found"} + + ); + } + return filteredBranches.map((branch) => ( + handleSwitchBranch(worktree, branch.name)} + disabled={isSwitching || branch.name === worktree.branch} + className="text-xs font-mono" + > + {branch.name === worktree.branch ? ( + + ) : ( + + )} + {branch.name} + + )); + })()} +
+ + onCreateBranch(worktree)} + className="text-xs" + > + + Create New Branch... + + + + ) : ( - // Non-main worktrees - just show branch name (worktrees are tied to branches) + // Non-main branches - click to switch to this branch )} + {/* Dev server indicator */} + {runningDevServers.has(getWorktreeKey(worktree)) && ( + + )} + {/* Actions dropdown */} { if (open) { @@ -343,18 +624,54 @@ export function WorktreeSelector({ }}> + {/* Dev server controls */} + {runningDevServers.has(getWorktreeKey(worktree)) ? ( + <> + + + Dev Server Running (:{runningDevServers.get(getWorktreeKey(worktree))?.port}) + + handleOpenDevServerUrl(worktree)} + className="text-xs" + > + + Open in Browser + + handleStopDevServer(worktree)} + className="text-xs text-destructive focus:text-destructive" + > + + Stop Dev Server + + + + ) : ( + <> + handleStartDevServer(worktree)} + disabled={isStartingDevServer} + className="text-xs" + > + + {isStartingDevServer ? "Starting..." : "Start Dev Server"} + + + + )} {/* Pull option */} handlePull(worktree)} @@ -384,6 +701,15 @@ export function WorktreeSelector({ )} + {/* Open in editor */} + handleOpenInEditor(worktree)} + className="text-xs" + > + + Open in Editor + + {/* Commit changes */} {worktree.hasChanges && ( void; categorySuggestions: string[]; + branchSuggestions: string[]; defaultSkipTests: boolean; isMaximized: boolean; showProfilesOnly: boolean; @@ -60,6 +63,7 @@ export function AddFeatureDialog({ onOpenChange, onAdd, categorySuggestions, + branchSuggestions, defaultSkipTests, isMaximized, showProfilesOnly, @@ -74,6 +78,7 @@ export function AddFeatureDialog({ skipTests: false, model: "opus" as AgentModel, thinkingLevel: "none" as ThinkingLevel, + branchName: "main", }); const [newFeaturePreviewMap, setNewFeaturePreviewMap] = useState(() => new Map()); @@ -111,6 +116,7 @@ export function AddFeatureDialog({ skipTests: newFeature.skipTests, model: selectedModel, thinkingLevel: normalizedThinking, + branchName: newFeature.branchName, }); // Reset form @@ -123,6 +129,7 @@ export function AddFeatureDialog({ skipTests: defaultSkipTests, model: "opus", thinkingLevel: "none", + branchName: "main", }); setNewFeaturePreviewMap(new Map()); setShowAdvancedOptions(false); @@ -237,6 +244,21 @@ export function AddFeatureDialog({ data-testid="feature-category-input" />
+
+ + + setNewFeature({ ...newFeature, branchName: value }) + } + branches={branchSuggestions} + placeholder="Select or create branch..." + data-testid="feature-branch-input" + /> +

+ Work will be done in this branch. A worktree will be created if needed. +

+
{/* Model Tab */} diff --git a/apps/app/src/components/views/board-view/dialogs/create-worktree-dialog.tsx b/apps/app/src/components/views/board-view/dialogs/create-worktree-dialog.tsx index 268bc3a6..f264b9b9 100644 --- a/apps/app/src/components/views/board-view/dialogs/create-worktree-dialog.tsx +++ b/apps/app/src/components/views/board-view/dialogs/create-worktree-dialog.tsx @@ -20,7 +20,7 @@ interface CreateWorktreeDialogProps { open: boolean; onOpenChange: (open: boolean) => void; projectPath: string; - onCreated: () => void; + onCreated: (worktreePath: string) => void; } export function CreateWorktreeDialog({ @@ -68,7 +68,7 @@ export function CreateWorktreeDialog({ : "Using existing branch", } ); - onCreated(); + onCreated(result.worktree.path); onOpenChange(false); setBranchName(""); } else { diff --git a/apps/app/src/components/views/board-view/dialogs/edit-feature-dialog.tsx b/apps/app/src/components/views/board-view/dialogs/edit-feature-dialog.tsx index fdc13d60..cecc3971 100644 --- a/apps/app/src/components/views/board-view/dialogs/edit-feature-dialog.tsx +++ b/apps/app/src/components/views/board-view/dialogs/edit-feature-dialog.tsx @@ -14,6 +14,7 @@ import { Button } from "@/components/ui/button"; import { HotkeyButton } from "@/components/ui/hotkey-button"; import { Label } from "@/components/ui/label"; import { CategoryAutocomplete } from "@/components/ui/category-autocomplete"; +import { BranchAutocomplete } from "@/components/ui/branch-autocomplete"; import { DescriptionImageDropZone, FeatureImagePath as DescriptionImagePath, @@ -47,9 +48,11 @@ interface EditFeatureDialogProps { model: AgentModel; thinkingLevel: ThinkingLevel; imagePaths: DescriptionImagePath[]; + branchName: string; } ) => void; categorySuggestions: string[]; + branchSuggestions: string[]; isMaximized: boolean; showProfilesOnly: boolean; aiProfiles: AIProfile[]; @@ -60,6 +63,7 @@ export function EditFeatureDialog({ onClose, onUpdate, categorySuggestions, + branchSuggestions, isMaximized, showProfilesOnly, aiProfiles, @@ -93,6 +97,7 @@ export function EditFeatureDialog({ model: selectedModel, thinkingLevel: normalizedThinking, imagePaths: editingFeature.imagePaths ?? [], + branchName: editingFeature.branchName ?? "main", }; onUpdate(editingFeature.id, updates); @@ -214,6 +219,32 @@ export function EditFeatureDialog({ data-testid="edit-feature-category" />
+
+ + + setEditingFeature({ + ...editingFeature, + branchName: value, + }) + } + branches={branchSuggestions} + placeholder="Select or create branch..." + data-testid="edit-feature-branch" + disabled={editingFeature.status !== "backlog"} + /> + {editingFeature.status !== "backlog" && ( +

+ Branch cannot be changed after work has started. +

+ )} + {editingFeature.status === "backlog" && ( +

+ Work will be done in this branch. A worktree will be created if needed. +

+ )} +
{/* Model Tab */} 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 af121fa6..5e72ef63 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 @@ -1,4 +1,4 @@ -import { useCallback, useState } from "react"; +import { useCallback } from "react"; import { Feature, FeatureImage, AgentModel, ThinkingLevel, useAppStore } from "@/store/app-store"; import { FeatureImagePath as DescriptionImagePath } from "@/components/ui/description-image-dropzone"; import { getElectronAPI } from "@/lib/electron"; @@ -28,6 +28,8 @@ interface UseBoardActionsProps { setShowFollowUpDialog: (show: boolean) => void; inProgressFeaturesForShortcuts: Feature[]; outputFeature: Feature | null; + projectPath: string | null; + onWorktreeCreated?: () => void; } export function useBoardActions({ @@ -52,10 +54,67 @@ export function useBoardActions({ setShowFollowUpDialog, inProgressFeaturesForShortcuts, outputFeature, + projectPath, + onWorktreeCreated, }: UseBoardActionsProps) { const { addFeature, updateFeature, removeFeature, moveFeature, useWorktrees } = useAppStore(); const autoMode = useAutoMode(); + /** + * Get or create the worktree path for a feature based on its branchName. + * - If branchName is "main" or empty, returns the project path + * - Otherwise, creates a worktree for that branch if needed + */ + const getOrCreateWorktreeForFeature = useCallback( + async (feature: Feature): Promise => { + if (!projectPath) return null; + + const branchName = feature.branchName || "main"; + + // If targeting main branch, use the project path directly + if (branchName === "main" || branchName === "master") { + return projectPath; + } + + // For other branches, create a worktree if it doesn't exist + try { + const api = getElectronAPI(); + if (!api?.worktree?.create) { + console.error("[BoardActions] Worktree API not available"); + return projectPath; + } + + // Try to create the worktree (will return existing if already exists) + const result = await api.worktree.create(projectPath, branchName); + + if (result.success && result.worktree) { + console.log( + `[BoardActions] Worktree ready for branch "${branchName}": ${result.worktree.path}` + ); + if (result.worktree.isNew) { + toast.success(`Worktree created for branch "${branchName}"`, { + description: "A new worktree was created for this feature.", + }); + } + return result.worktree.path; + } else { + console.error("[BoardActions] Failed to create worktree:", result.error); + toast.error("Failed to create worktree", { + description: result.error || "Could not create worktree for this branch.", + }); + return projectPath; // Fall back to project path + } + } catch (error) { + console.error("[BoardActions] Error creating worktree:", error); + toast.error("Error creating worktree", { + description: error instanceof Error ? error.message : "Unknown error", + }); + return projectPath; // Fall back to project path + } + }, + [projectPath] + ); + const handleAddFeature = useCallback( (featureData: { category: string; @@ -66,6 +125,7 @@ export function useBoardActions({ skipTests: boolean; model: AgentModel; thinkingLevel: ThinkingLevel; + branchName: string; }) => { const newFeatureData = { ...featureData, @@ -89,6 +149,7 @@ export function useBoardActions({ model: AgentModel; thinkingLevel: ThinkingLevel; imagePaths: DescriptionImagePath[]; + branchName: string; } ) => { updateFeature(featureId, updates); @@ -155,14 +216,19 @@ export function useBoardActions({ return; } + // Use the feature's assigned worktreePath (set when moving to in_progress) + // This ensures work happens in the correct worktree based on the feature's branchName + const featureWorktreePath = feature.worktreePath; + const result = await api.autoMode.runFeature( currentProject.path, feature.id, - useWorktrees + useWorktrees, + featureWorktreePath || undefined ); if (result.success) { - console.log("[Board] Feature run started successfully"); + console.log("[Board] Feature run started successfully in worktree:", featureWorktreePath || "main"); } else { console.error("[Board] Failed to run feature:", result.error); await loadFeatures(); @@ -327,8 +393,10 @@ export function useBoardActions({ }); const imagePaths = followUpImagePaths.map((img) => img.path); + // Use the feature's worktreePath to ensure work happens in the correct branch + const featureWorktreePath = followUpFeature.worktreePath; api.autoMode - .followUpFeature(currentProject.path, followUpFeature.id, followUpPrompt, imagePaths) + .followUpFeature(currentProject.path, followUpFeature.id, followUpPrompt, imagePaths, featureWorktreePath) .catch((error) => { console.error("[Board] Error sending follow-up:", error); toast.error("Failed to send follow-up", { @@ -365,7 +433,8 @@ export function useBoardActions({ return; } - const result = await api.autoMode.commitFeature(currentProject.path, feature.id); + // Pass the feature's worktreePath to ensure commits happen in the correct worktree + const result = await api.autoMode.commitFeature(currentProject.path, feature.id, feature.worktreePath); if (result.success) { moveFeature(feature.id, "verified"); @@ -373,6 +442,8 @@ export function useBoardActions({ toast.success("Feature committed", { description: `Committed and verified: ${truncateDescription(feature.description)}`, }); + // Refresh worktree selector to update commit counts + onWorktreeCreated?.(); } else { console.error("[Board] Failed to commit feature:", result.error); toast.error("Failed to commit feature", { @@ -388,7 +459,7 @@ export function useBoardActions({ await loadFeatures(); } }, - [currentProject, moveFeature, persistFeatureUpdate, loadFeatures] + [currentProject, moveFeature, persistFeatureUpdate, loadFeatures, onWorktreeCreated] ); const handleRevertFeature = useCallback( @@ -565,12 +636,29 @@ export function useBoardActions({ return; } - const featuresToStart = backlogFeatures.slice(0, availableSlots); + if (backlogFeatures.length === 0) { + toast.info("Backlog empty", { + description: "No features in backlog to start.", + }); + return; + } + + // Start only one feature per keypress (user must press again for next) + const featuresToStart = backlogFeatures.slice(0, 1); for (const feature of featuresToStart) { - await handleStartImplementation(feature); + // Get or create worktree based on the feature's assigned branch (same as drag-to-in-progress) + const worktreePath = await getOrCreateWorktreeForFeature(feature); + if (worktreePath) { + await persistFeatureUpdate(feature.id, { worktreePath }); + } + // Refresh worktree selector after creating worktree + onWorktreeCreated?.(); + // Start the implementation + // Pass feature with worktreePath so handleRunFeature uses the correct path + await handleStartImplementation({ ...feature, worktreePath: worktreePath || undefined }); } - }, [features, runningAutoTasks, handleStartImplementation]); + }, [features, runningAutoTasks, handleStartImplementation, getOrCreateWorktreeForFeature, persistFeatureUpdate, onWorktreeCreated]); const handleDeleteAllVerified = useCallback(async () => { const verifiedFeatures = features.filter((f) => f.status === "verified"); 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 8f0c8e84..000c9b1e 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 @@ -8,6 +8,7 @@ interface UseBoardColumnFeaturesProps { runningAutoTasks: string[]; searchQuery: string; currentWorktreePath: string | null; // Currently selected worktree path + currentWorktreeBranch: string | null; // Branch name of the selected worktree (null = main) projectPath: string | null; // Main project path (for main worktree) } @@ -16,6 +17,7 @@ export function useBoardColumnFeatures({ runningAutoTasks, searchQuery, currentWorktreePath, + currentWorktreeBranch, projectPath, }: UseBoardColumnFeaturesProps) { // Memoize column features to prevent unnecessary re-renders @@ -38,18 +40,32 @@ export function useBoardColumnFeatures({ ) : features; - // Determine the effective worktree path for filtering - // If currentWorktreePath is null, we're on the main worktree (use projectPath) + // Determine the effective worktree path and branch for filtering + // If currentWorktreePath is null, we're on the main worktree const effectiveWorktreePath = currentWorktreePath || projectPath; + const effectiveBranch = currentWorktreeBranch || "main"; filteredFeatures.forEach((f) => { // If feature has a running agent, always show it in "in_progress" const isRunning = runningAutoTasks.includes(f.id); // Check if feature matches the current worktree - // Features without a worktreePath are considered unassigned (backlog items) - // Features with a worktreePath should only show if it matches the selected worktree - const matchesWorktree = !f.worktreePath || f.worktreePath === effectiveWorktreePath; + // Match by worktreePath if set, OR by branchName if set + // Features with neither are considered unassigned (show on main only) + 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; + } else if (f.worktreePath) { + // Has worktreePath - match by path + matchesWorktree = f.worktreePath === effectiveWorktreePath; + } else { + // Has branchName but no worktreePath - match by branch name + matchesWorktree = featureBranch === effectiveBranch; + } if (isRunning) { // Only show running tasks if they match the current worktree @@ -84,7 +100,7 @@ export function useBoardColumnFeatures({ }); return map; - }, [features, runningAutoTasks, searchQuery, currentWorktreePath, projectPath]); + }, [features, runningAutoTasks, searchQuery, currentWorktreePath, currentWorktreeBranch, projectPath]); const getColumnFeatures = useCallback( (columnId: ColumnId) => { diff --git a/apps/app/src/components/views/board-view/hooks/use-board-drag-drop.ts b/apps/app/src/components/views/board-view/hooks/use-board-drag-drop.ts index aeb6db1e..7eddbf90 100644 --- a/apps/app/src/components/views/board-view/hooks/use-board-drag-drop.ts +++ b/apps/app/src/components/views/board-view/hooks/use-board-drag-drop.ts @@ -4,6 +4,7 @@ import { Feature } from "@/store/app-store"; import { useAppStore } from "@/store/app-store"; import { toast } from "sonner"; import { COLUMNS, ColumnId } from "../constants"; +import { getElectronAPI } from "@/lib/electron"; interface UseBoardDragDropProps { features: Feature[]; @@ -14,8 +15,8 @@ interface UseBoardDragDropProps { updates: Partial ) => Promise; handleStartImplementation: (feature: Feature) => Promise; - currentWorktreePath: string | null; // Currently selected worktree path projectPath: string | null; // Main project path + onWorktreeCreated?: () => void; // Callback when a new worktree is created } export function useBoardDragDrop({ @@ -24,14 +25,66 @@ export function useBoardDragDrop({ runningAutoTasks, persistFeatureUpdate, handleStartImplementation, - currentWorktreePath, projectPath, + onWorktreeCreated, }: UseBoardDragDropProps) { const [activeFeature, setActiveFeature] = useState(null); const { moveFeature } = useAppStore(); - // Determine the effective worktree path for assigning to features - const effectiveWorktreePath = currentWorktreePath || projectPath; + /** + * Get or create the worktree path for a feature based on its branchName. + * - If branchName is "main" or empty, returns the project path + * - Otherwise, creates a worktree for that branch if needed + */ + const getOrCreateWorktreeForFeature = useCallback( + async (feature: Feature): Promise => { + if (!projectPath) return null; + + const branchName = feature.branchName || "main"; + + // If targeting main branch, use the project path directly + if (branchName === "main" || branchName === "master") { + return projectPath; + } + + // For other branches, create a worktree if it doesn't exist + try { + const api = getElectronAPI(); + if (!api?.worktree?.create) { + console.error("[DragDrop] Worktree API not available"); + return projectPath; + } + + // Try to create the worktree (will return existing if already exists) + const result = await api.worktree.create(projectPath, branchName); + + if (result.success && result.worktree) { + console.log( + `[DragDrop] Worktree ready for branch "${branchName}": ${result.worktree.path}` + ); + if (result.worktree.isNew) { + toast.success(`Worktree created for branch "${branchName}"`, { + description: "A new worktree was created for this feature.", + }); + } + return result.worktree.path; + } else { + console.error("[DragDrop] Failed to create worktree:", result.error); + toast.error("Failed to create worktree", { + description: result.error || "Could not create worktree for this branch.", + }); + return projectPath; // Fall back to project path + } + } catch (error) { + console.error("[DragDrop] Error creating worktree:", error); + toast.error("Error creating worktree", { + description: error instanceof Error ? error.message : "Unknown error", + }); + return projectPath; // Fall back to project path + } + }, + [projectPath] + ); const handleDragStart = useCallback( (event: DragStartEvent) => { @@ -104,12 +157,16 @@ export function useBoardDragDrop({ if (draggedFeature.status === "backlog") { // From backlog if (targetStatus === "in_progress") { - // Assign the current worktree to this feature when moving to in_progress - if (effectiveWorktreePath) { - await persistFeatureUpdate(featureId, { worktreePath: effectiveWorktreePath }); + // Get or create worktree based on the feature's assigned branch + const worktreePath = await getOrCreateWorktreeForFeature(draggedFeature); + if (worktreePath) { + await persistFeatureUpdate(featureId, { worktreePath }); } + // Always refresh worktree selector after moving to in_progress + onWorktreeCreated?.(); // Use helper function to handle concurrency check and start implementation - await handleStartImplementation(draggedFeature); + // Pass feature with worktreePath so handleRunFeature uses the correct path + await handleStartImplementation({ ...draggedFeature, worktreePath: worktreePath || undefined }); } else { moveFeature(featureId, targetStatus); persistFeatureUpdate(featureId, { status: targetStatus }); @@ -219,7 +276,8 @@ export function useBoardDragDrop({ moveFeature, persistFeatureUpdate, handleStartImplementation, - effectiveWorktreePath, + getOrCreateWorktreeForFeature, + onWorktreeCreated, ] ); diff --git a/apps/app/src/components/views/board-view/kanban-board.tsx b/apps/app/src/components/views/board-view/kanban-board.tsx index eb8a318e..77bd6cc1 100644 --- a/apps/app/src/components/views/board-view/kanban-board.tsx +++ b/apps/app/src/components/views/board-view/kanban-board.tsx @@ -45,8 +45,6 @@ interface KanbanBoardProps { onMoveBackToInProgress: (feature: Feature) => void; onFollowUp: (feature: Feature) => void; onCommit: (feature: Feature) => void; - onRevert: (feature: Feature) => void; - onMerge: (feature: Feature) => void; onComplete: (feature: Feature) => void; onImplement: (feature: Feature) => void; featuresWithContext: Set; @@ -77,8 +75,6 @@ export function KanbanBoard({ onMoveBackToInProgress, onFollowUp, onCommit, - onRevert, - onMerge, onComplete, onImplement, featuresWithContext, @@ -191,8 +187,6 @@ export function KanbanBoard({ } onFollowUp={() => onFollowUp(feature)} onCommit={() => onCommit(feature)} - onRevert={() => onRevert(feature)} - onMerge={() => onMerge(feature)} onComplete={() => onComplete(feature)} onImplement={() => onImplement(feature)} hasContext={featuresWithContext.has(feature.id)} diff --git a/apps/app/src/lib/electron.ts b/apps/app/src/lib/electron.ts index bf795748..7cdcff2b 100644 --- a/apps/app/src/lib/electron.ts +++ b/apps/app/src/lib/electron.ts @@ -224,7 +224,8 @@ export interface AutoModeAPI { runFeature: ( projectPath: string, featureId: string, - useWorktrees?: boolean + useWorktrees?: boolean, + worktreePath?: string ) => Promise<{ success: boolean; passes?: boolean; error?: string }>; verifyFeature: ( projectPath: string, @@ -245,11 +246,13 @@ export interface AutoModeAPI { projectPath: string, featureId: string, prompt: string, - imagePaths?: string[] + imagePaths?: string[], + worktreePath?: string ) => Promise<{ success: boolean; passes?: boolean; error?: string }>; commitFeature: ( projectPath: string, - featureId: string + featureId: string, + worktreePath?: string ) => Promise<{ success: boolean; error?: string }>; onEvent: (callback: (event: AutoModeEvent) => void) => () => void; } @@ -1056,7 +1059,7 @@ function createMockWorktreeAPI(): WorktreeAPI { return { success: true, worktrees: [ - { path: projectPath, branch: "main", isMain: true, hasChanges: false, changedFilesCount: 0 }, + { path: projectPath, branch: "main", isMain: true, isCurrent: true, hasWorktree: true, hasChanges: false, changedFilesCount: 0 }, ], }; }, @@ -1104,6 +1107,7 @@ function createMockWorktreeAPI(): WorktreeAPI { result: { branch: "feature-branch", pushed: true, + message: "Successfully pushed to origin/feature-branch", }, }; }, @@ -1205,6 +1209,73 @@ function createMockWorktreeAPI(): WorktreeAPI { }, }; }, + + openInEditor: async (worktreePath: string) => { + console.log("[Mock] Opening in editor:", worktreePath); + return { + success: true, + result: { + message: `Opened ${worktreePath} in editor`, + }, + }; + }, + + initGit: async (projectPath: string) => { + console.log("[Mock] Initializing git:", projectPath); + return { + success: true, + result: { + initialized: true, + message: `Initialized git repository in ${projectPath}`, + }, + }; + }, + + 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 { + success: true, + result: { + worktreePath, + port: 3001, + url: "http://localhost:3001", + message: "Dev server started on port 3001", + }, + }; + }, + + stopDevServer: async (worktreePath: string) => { + console.log("[Mock] Stopping dev server:", worktreePath); + return { + success: true, + result: { + worktreePath, + message: "Dev server stopped", + }, + }; + }, + + listDevServers: async () => { + console.log("[Mock] Listing dev servers"); + return { + success: true, + result: { + servers: [], + }, + }; + }, }; } @@ -1311,7 +1382,8 @@ function createMockAutoModeAPI(): AutoModeAPI { runFeature: async ( projectPath: string, featureId: string, - useWorktrees?: boolean + useWorktrees?: boolean, + worktreePath?: string ) => { if (mockRunningFeatures.has(featureId)) { return { @@ -1321,7 +1393,7 @@ function createMockAutoModeAPI(): AutoModeAPI { } console.log( - `[Mock] Running feature ${featureId} with useWorktrees: ${useWorktrees}` + `[Mock] Running feature ${featureId} with useWorktrees: ${useWorktrees}, worktreePath: ${worktreePath}` ); mockRunningFeatures.add(featureId); simulateAutoModeLoop(projectPath, featureId); @@ -1481,7 +1553,8 @@ function createMockAutoModeAPI(): AutoModeAPI { projectPath: string, featureId: string, prompt: string, - imagePaths?: string[] + imagePaths?: string[], + worktreePath?: string ) => { if (mockRunningFeatures.has(featureId)) { return { @@ -1506,8 +1579,8 @@ function createMockAutoModeAPI(): AutoModeAPI { return { success: true }; }, - commitFeature: async (projectPath: string, featureId: string) => { - console.log("[Mock] Committing feature:", { projectPath, featureId }); + 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 d1216bd5..9661ca06 100644 --- a/apps/app/src/lib/http-api-client.ts +++ b/apps/app/src/lib/http-api-client.ts @@ -509,12 +509,14 @@ export class HttpApiClient implements ElectronAPI { runFeature: ( projectPath: string, featureId: string, - useWorktrees?: boolean + useWorktrees?: boolean, + worktreePath?: string ) => this.post("/api/auto-mode/run-feature", { projectPath, featureId, useWorktrees, + worktreePath, }), verifyFeature: (projectPath: string, featureId: string) => this.post("/api/auto-mode/verify-feature", { projectPath, featureId }), @@ -528,16 +530,18 @@ export class HttpApiClient implements ElectronAPI { projectPath: string, featureId: string, prompt: string, - imagePaths?: string[] + imagePaths?: string[], + worktreePath?: string ) => this.post("/api/auto-mode/follow-up-feature", { projectPath, featureId, prompt, imagePaths, + worktreePath, }), - commitFeature: (projectPath: string, featureId: string) => - this.post("/api/auto-mode/commit-feature", { projectPath, featureId }), + 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", @@ -586,6 +590,18 @@ export class HttpApiClient implements ElectronAPI { this.post("/api/worktree/list-branches", { worktreePath }), switchBranch: (worktreePath: string, branchName: string) => this.post("/api/worktree/switch-branch", { worktreePath, branchName }), + openInEditor: (worktreePath: string) => + this.post("/api/worktree/open-in-editor", { worktreePath }), + 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", {}), }; // Git API diff --git a/apps/app/src/lib/project-init.ts b/apps/app/src/lib/project-init.ts index 2ba184fc..65574924 100644 --- a/apps/app/src/lib/project-init.ts +++ b/apps/app/src/lib/project-init.ts @@ -48,6 +48,31 @@ export async function initializeProject( const existingFiles: string[] = []; try { + // Initialize git repository if it doesn't exist + const gitDirExists = await api.exists(`${projectPath}/.git`); + if (!gitDirExists) { + console.log("[project-init] Initializing git repository..."); + try { + // Initialize git and create an initial empty commit via server route + const result = await api.worktree?.initGit(projectPath); + if (result?.success && result.result?.initialized) { + createdFiles.push(".git"); + console.log("[project-init] Git repository initialized with initial commit"); + } else if (result?.success && !result.result?.initialized) { + // Git already existed (shouldn't happen since we checked, but handle it) + existingFiles.push(".git"); + console.log("[project-init] Git repository already exists"); + } else { + console.warn("[project-init] Failed to initialize git repository:", result?.error); + } + } catch (gitError) { + console.warn("[project-init] Failed to initialize git repository:", gitError); + // Don't fail the whole initialization if git init fails + } + } else { + existingFiles.push(".git"); + } + // Create all required directories for (const dir of REQUIRED_STRUCTURE.directories) { const fullPath = `${projectPath}/${dir}`; diff --git a/apps/app/src/types/electron.d.ts b/apps/app/src/types/electron.d.ts index 809d7bf9..63a1b623 100644 --- a/apps/app/src/types/electron.d.ts +++ b/apps/app/src/types/electron.d.ts @@ -340,7 +340,8 @@ export interface AutoModeAPI { runFeature: ( projectPath: string, featureId: string, - useWorktrees?: boolean + useWorktrees?: boolean, + worktreePath?: string ) => Promise<{ success: boolean; passes?: boolean; @@ -384,7 +385,8 @@ export interface AutoModeAPI { projectPath: string, featureId: string, prompt: string, - imagePaths?: string[] + imagePaths?: string[], + worktreePath?: string ) => Promise<{ success: boolean; passes?: boolean; @@ -631,6 +633,8 @@ export interface WorktreeAPI { path: string; branch: string; isMain: boolean; + isCurrent: boolean; // Is this the currently checked out branch? + hasWorktree: boolean; // Does this branch have an active worktree? hasChanges?: boolean; changedFilesCount?: number; }>; @@ -690,6 +694,7 @@ export interface WorktreeAPI { result?: { branch: string; pushed: boolean; + message: string; }; error?: string; }>; @@ -785,6 +790,79 @@ export interface WorktreeAPI { }; error?: string; }>; + + // Open a worktree directory in the editor + openInEditor: (worktreePath: string) => Promise<{ + success: boolean; + result?: { + message: string; + }; + error?: string; + }>; + + // Initialize git repository in a project + initGit: (projectPath: string) => Promise<{ + success: boolean; + result?: { + initialized: boolean; + message: string; + }; + 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, + worktreePath: string + ) => Promise<{ + success: boolean; + result?: { + worktreePath: string; + port: number; + url: string; + message: string; + }; + error?: string; + }>; + + // Stop a dev server for a worktree + stopDevServer: ( + worktreePath: string + ) => Promise<{ + success: boolean; + result?: { + worktreePath: string; + message: string; + }; + error?: string; + }>; + + // List all running dev servers + listDevServers: () => Promise<{ + success: boolean; + result?: { + servers: Array<{ + worktreePath: string; + port: number; + url: string; + }>; + }; + error?: string; + }>; } export interface GitAPI { diff --git a/apps/server/src/lib/automaker-paths.ts b/apps/server/src/lib/automaker-paths.ts new file mode 100644 index 00000000..bd4b328d --- /dev/null +++ b/apps/server/src/lib/automaker-paths.ts @@ -0,0 +1,322 @@ +/** + * Automaker Paths - Utilities for managing automaker data storage + * + * Stores project data in an external location (~/.automaker/projects/{project-id}/) + * to avoid conflicts with git worktrees and symlink issues. + * + * The project-id is derived from the git remote URL (if available) or project path, + * ensuring each project has a unique storage location that persists across worktrees. + */ + +import fs from "fs/promises"; +import path from "path"; +import { createHash } from "crypto"; +import { exec } from "child_process"; +import { promisify } from "util"; +import os from "os"; + +const execAsync = promisify(exec); + +/** + * Get the base automaker directory in user's home + */ +export function getAutomakerBaseDir(): string { + return path.join(os.homedir(), ".automaker"); +} + +/** + * Get the projects directory + */ +export function getProjectsDir(): string { + return path.join(getAutomakerBaseDir(), "projects"); +} + +/** + * Generate a project ID from a unique identifier (git remote or path) + */ +function generateProjectId(identifier: string): string { + const hash = createHash("sha256").update(identifier).digest("hex"); + return hash.substring(0, 16); +} + +/** + * Get the main git repository root path (resolves worktree paths to main repo) + */ +async function getMainRepoPath(projectPath: string): Promise { + try { + // Get the main worktree path (handles worktrees) + const { stdout } = await execAsync( + "git worktree list --porcelain | head -1 | sed 's/worktree //'", + { cwd: projectPath } + ); + const mainPath = stdout.trim(); + return mainPath || projectPath; + } catch { + return projectPath; + } +} + +/** + * Get a unique identifier for a git project + * Prefers git remote URL, falls back to main repo path + */ +async function getProjectIdentifier(projectPath: string): Promise { + const mainPath = await getMainRepoPath(projectPath); + + try { + // Try to get the git remote URL first (most stable identifier) + const { stdout } = await execAsync("git remote get-url origin", { + cwd: mainPath, + }); + const remoteUrl = stdout.trim(); + if (remoteUrl) { + return remoteUrl; + } + } catch { + // No remote configured, fall through + } + + // Fall back to the absolute main repo path + return path.resolve(mainPath); +} + +/** + * Get the automaker data directory for a project + * This is the external location where all .automaker data is stored + */ +export async function getAutomakerDir(projectPath: string): Promise { + const identifier = await getProjectIdentifier(projectPath); + const projectId = generateProjectId(identifier); + return path.join(getProjectsDir(), projectId); +} + +/** + * Get the features directory for a project + */ +export async function getFeaturesDir(projectPath: string): Promise { + const automakerDir = await getAutomakerDir(projectPath); + return path.join(automakerDir, "features"); +} + +/** + * Get the directory for a specific feature + */ +export async function getFeatureDir( + projectPath: string, + featureId: string +): Promise { + const featuresDir = await getFeaturesDir(projectPath); + return path.join(featuresDir, featureId); +} + +/** + * Get the images directory for a feature + */ +export async function getFeatureImagesDir( + projectPath: string, + featureId: string +): Promise { + const featureDir = await getFeatureDir(projectPath, featureId); + return path.join(featureDir, "images"); +} + +/** + * Get the board directory for a project (board backgrounds, etc.) + */ +export async function getBoardDir(projectPath: string): Promise { + const automakerDir = await getAutomakerDir(projectPath); + return path.join(automakerDir, "board"); +} + +/** + * Get the images directory for a project (general images) + */ +export async function getImagesDir(projectPath: string): Promise { + const automakerDir = await getAutomakerDir(projectPath); + return path.join(automakerDir, "images"); +} + +/** + * Get the worktrees metadata directory for a project + */ +export async function getWorktreesDir(projectPath: string): Promise { + const automakerDir = await getAutomakerDir(projectPath); + return path.join(automakerDir, "worktrees"); +} + +/** + * Get the app spec file path for a project + */ +export async function getAppSpecPath(projectPath: string): Promise { + const automakerDir = await getAutomakerDir(projectPath); + return path.join(automakerDir, "app_spec.txt"); +} + +/** + * Get the branch tracking file path for a project + */ +export async function getBranchTrackingPath( + projectPath: string +): Promise { + const automakerDir = await getAutomakerDir(projectPath); + return path.join(automakerDir, "active-branches.json"); +} + +/** + * Ensure the automaker directory structure exists for a project + */ +export async function ensureAutomakerDir(projectPath: string): Promise { + const automakerDir = await getAutomakerDir(projectPath); + await fs.mkdir(automakerDir, { recursive: true }); + return automakerDir; +} + +/** + * Check if there's existing .automaker data in the project directory that needs migration + */ +export async function hasLegacyAutomakerDir( + projectPath: string +): Promise { + const mainPath = await getMainRepoPath(projectPath); + const legacyPath = path.join(mainPath, ".automaker"); + + try { + const stats = await fs.lstat(legacyPath); + // Only count it as legacy if it's a directory (not a symlink) + return stats.isDirectory() && !stats.isSymbolicLink(); + } catch { + return false; + } +} + +/** + * Get the legacy .automaker path in the project directory + */ +export async function getLegacyAutomakerDir( + projectPath: string +): Promise { + const mainPath = await getMainRepoPath(projectPath); + return path.join(mainPath, ".automaker"); +} + +/** + * Migrate data from legacy in-repo .automaker to external location + * Returns true if migration was performed, false if not needed + */ +export async function migrateLegacyData(projectPath: string): Promise { + if (!(await hasLegacyAutomakerDir(projectPath))) { + return false; + } + + const legacyDir = await getLegacyAutomakerDir(projectPath); + const newDir = await ensureAutomakerDir(projectPath); + + console.log(`[automaker-paths] Migrating data from ${legacyDir} to ${newDir}`); + + try { + // Copy all contents from legacy to new location + const entries = await fs.readdir(legacyDir, { withFileTypes: true }); + + for (const entry of entries) { + const srcPath = path.join(legacyDir, entry.name); + const destPath = path.join(newDir, entry.name); + + // Skip if destination already exists + try { + await fs.access(destPath); + console.log( + `[automaker-paths] Skipping ${entry.name} (already exists in destination)` + ); + continue; + } catch { + // Destination doesn't exist, proceed with copy + } + + if (entry.isDirectory()) { + await fs.cp(srcPath, destPath, { recursive: true }); + } else if (entry.isFile()) { + await fs.copyFile(srcPath, destPath); + } + // Skip symlinks + } + + console.log(`[automaker-paths] Migration complete`); + + // Optionally rename the old directory to mark it as migrated + const backupPath = path.join( + path.dirname(legacyDir), + ".automaker-migrated" + ); + try { + await fs.rename(legacyDir, backupPath); + console.log( + `[automaker-paths] Renamed legacy directory to .automaker-migrated` + ); + } catch (error) { + console.warn( + `[automaker-paths] Could not rename legacy directory:`, + error + ); + } + + return true; + } catch (error) { + console.error(`[automaker-paths] Migration failed:`, error); + throw error; + } +} + +/** + * Convert a legacy relative path (e.g., ".automaker/features/...") + * to the new external absolute path + */ +export async function convertLegacyPath( + projectPath: string, + legacyRelativePath: string +): Promise { + // If it doesn't start with .automaker, return as-is + if (!legacyRelativePath.startsWith(".automaker")) { + return legacyRelativePath; + } + + const automakerDir = await getAutomakerDir(projectPath); + // Remove ".automaker/" prefix and join with new base + const relativePart = legacyRelativePath.replace(/^\.automaker\/?/, ""); + return path.join(automakerDir, relativePart); +} + +/** + * Get a relative path for display/storage (relative to external automaker dir) + * The path is prefixed with "automaker:" to indicate it's an external path + */ +export async function getDisplayPath( + projectPath: string, + absolutePath: string +): Promise { + const automakerDir = await getAutomakerDir(projectPath); + if (absolutePath.startsWith(automakerDir)) { + const relativePart = absolutePath.substring(automakerDir.length + 1); + return `automaker:${relativePart}`; + } + return absolutePath; +} + +/** + * Resolve a display path back to absolute path + */ +export async function resolveDisplayPath( + projectPath: string, + displayPath: string +): Promise { + if (displayPath.startsWith("automaker:")) { + const automakerDir = await getAutomakerDir(projectPath); + const relativePart = displayPath.substring("automaker:".length); + return path.join(automakerDir, relativePart); + } + // Legacy ".automaker" paths + if (displayPath.startsWith(".automaker")) { + return convertLegacyPath(projectPath, displayPath); + } + // Already absolute or project-relative path + return displayPath; +} diff --git a/apps/server/src/lib/fs-utils.ts b/apps/server/src/lib/fs-utils.ts new file mode 100644 index 00000000..5b67124a --- /dev/null +++ b/apps/server/src/lib/fs-utils.ts @@ -0,0 +1,67 @@ +/** + * File system utilities that handle symlinks safely + */ + +import fs from "fs/promises"; +import path from "path"; + +/** + * Create a directory, handling symlinks safely to avoid ELOOP errors. + * If the path already exists as a directory or symlink, returns success. + */ +export async function mkdirSafe(dirPath: string): Promise { + const resolvedPath = path.resolve(dirPath); + + // Check if path already exists using lstat (doesn't follow symlinks) + try { + const stats = await fs.lstat(resolvedPath); + // Path exists - if it's a directory or symlink, consider it success + if (stats.isDirectory() || stats.isSymbolicLink()) { + return; + } + // It's a file - can't create directory + throw new Error(`Path exists and is not a directory: ${resolvedPath}`); + } catch (error: any) { + // ENOENT means path doesn't exist - we should create it + if (error.code !== "ENOENT") { + // Some other error (could be ELOOP in parent path) + // If it's ELOOP, the path involves symlinks - don't try to create + if (error.code === "ELOOP") { + console.warn(`[fs-utils] Symlink loop detected at ${resolvedPath}, skipping mkdir`); + return; + } + throw error; + } + } + + // Path doesn't exist, create it + try { + await fs.mkdir(resolvedPath, { recursive: true }); + } catch (error: any) { + // Handle race conditions and symlink issues + if (error.code === "EEXIST" || error.code === "ELOOP") { + return; + } + throw error; + } +} + +/** + * Check if a path exists, handling symlinks safely. + * Returns true if the path exists as a file, directory, or symlink. + */ +export async function existsSafe(filePath: string): Promise { + try { + await fs.lstat(filePath); + return true; + } catch (error: any) { + if (error.code === "ENOENT") { + return false; + } + // ELOOP or other errors - path exists but is problematic + if (error.code === "ELOOP") { + return true; // Symlink exists, even if looping + } + throw error; + } +} diff --git a/apps/server/src/routes/app-spec/generate-features-from-spec.ts b/apps/server/src/routes/app-spec/generate-features-from-spec.ts index 84964a9c..c514d1c4 100644 --- a/apps/server/src/routes/app-spec/generate-features-from-spec.ts +++ b/apps/server/src/routes/app-spec/generate-features-from-spec.ts @@ -3,13 +3,13 @@ */ import { query } from "@anthropic-ai/claude-agent-sdk"; -import path from "path"; import fs from "fs/promises"; import type { EventEmitter } from "../../lib/events.js"; import { createLogger } from "../../lib/logger.js"; import { createFeatureGenerationOptions } from "../../lib/sdk-options.js"; import { logAuthStatus } from "./common.js"; import { parseAndCreateFeatures } from "./parse-and-create-features.js"; +import { getAppSpecPath } from "../../lib/automaker-paths.js"; const logger = createLogger("SpecRegeneration"); @@ -26,8 +26,8 @@ export async function generateFeaturesFromSpec( logger.debug("projectPath:", projectPath); logger.debug("maxFeatures:", featureCount); - // Read existing spec - const specPath = path.join(projectPath, ".automaker", "app_spec.txt"); + // Read existing spec from external automaker directory + const specPath = await getAppSpecPath(projectPath); let spec: string; logger.debug("Reading spec from:", specPath); diff --git a/apps/server/src/routes/app-spec/generate-spec.ts b/apps/server/src/routes/app-spec/generate-spec.ts index 4b638935..0013e28b 100644 --- a/apps/server/src/routes/app-spec/generate-spec.ts +++ b/apps/server/src/routes/app-spec/generate-spec.ts @@ -11,6 +11,7 @@ import { createLogger } from "../../lib/logger.js"; import { createSpecGenerationOptions } from "../../lib/sdk-options.js"; import { logAuthStatus } from "./common.js"; import { generateFeaturesFromSpec } from "./generate-features-from-spec.js"; +import { ensureAutomakerDir, getAppSpecPath } from "../../lib/automaker-paths.js"; const logger = createLogger("SpecRegeneration"); @@ -209,14 +210,13 @@ ${getAppSpecFormatInstruction()}`; logger.error("❌ WARNING: responseText is empty! Nothing to save."); } - // Save spec - const specDir = path.join(projectPath, ".automaker"); - const specPath = path.join(specDir, "app_spec.txt"); + // Save spec to external automaker directory + const specDir = await ensureAutomakerDir(projectPath); + const specPath = await getAppSpecPath(projectPath); logger.info("Saving spec to:", specPath); logger.info(`Content to save (${responseText.length} chars)`); - await fs.mkdir(specDir, { recursive: true }); await fs.writeFile(specPath, responseText); // Verify the file was written diff --git a/apps/server/src/routes/app-spec/parse-and-create-features.ts b/apps/server/src/routes/app-spec/parse-and-create-features.ts index 8a119972..14f475b5 100644 --- a/apps/server/src/routes/app-spec/parse-and-create-features.ts +++ b/apps/server/src/routes/app-spec/parse-and-create-features.ts @@ -6,6 +6,7 @@ import path from "path"; import fs from "fs/promises"; import type { EventEmitter } from "../../lib/events.js"; import { createLogger } from "../../lib/logger.js"; +import { getFeaturesDir } from "../../lib/automaker-paths.js"; const logger = createLogger("SpecRegeneration"); @@ -41,7 +42,7 @@ export async function parseAndCreateFeatures( logger.info(`Parsed ${parsed.features?.length || 0} features`); logger.info("Parsed features:", JSON.stringify(parsed.features, null, 2)); - const featuresDir = path.join(projectPath, ".automaker", "features"); + const featuresDir = await getFeaturesDir(projectPath); await fs.mkdir(featuresDir, { recursive: true }); const createdFeatures: Array<{ id: string; title: string }> = []; diff --git a/apps/server/src/routes/auto-mode/routes/commit-feature.ts b/apps/server/src/routes/auto-mode/routes/commit-feature.ts index f0297102..aaf2e6f5 100644 --- a/apps/server/src/routes/auto-mode/routes/commit-feature.ts +++ b/apps/server/src/routes/auto-mode/routes/commit-feature.ts @@ -9,9 +9,10 @@ import { getErrorMessage, logError } from "../common.js"; export function createCommitFeatureHandler(autoModeService: AutoModeService) { return async (req: Request, res: Response): Promise => { try { - const { projectPath, featureId } = req.body as { + const { projectPath, featureId, worktreePath } = req.body as { projectPath: string; featureId: string; + worktreePath?: string; }; if (!projectPath || !featureId) { @@ -26,7 +27,8 @@ export function createCommitFeatureHandler(autoModeService: AutoModeService) { const commitHash = await autoModeService.commitFeature( projectPath, - featureId + featureId, + worktreePath ); res.json({ success: true, commitHash }); } catch (error) { diff --git a/apps/server/src/routes/auto-mode/routes/follow-up-feature.ts b/apps/server/src/routes/auto-mode/routes/follow-up-feature.ts index 06741d17..aa8887ad 100644 --- a/apps/server/src/routes/auto-mode/routes/follow-up-feature.ts +++ b/apps/server/src/routes/auto-mode/routes/follow-up-feature.ts @@ -12,11 +12,12 @@ const logger = createLogger("AutoMode"); export function createFollowUpFeatureHandler(autoModeService: AutoModeService) { return async (req: Request, res: Response): Promise => { try { - const { projectPath, featureId, prompt, imagePaths } = req.body as { + const { projectPath, featureId, prompt, imagePaths, worktreePath } = req.body as { projectPath: string; featureId: string; prompt: string; imagePaths?: string[]; + worktreePath?: string; }; if (!projectPath || !featureId || !prompt) { @@ -27,9 +28,9 @@ export function createFollowUpFeatureHandler(autoModeService: AutoModeService) { return; } - // Start follow-up in background + // Start follow-up in background, using the feature's worktreePath for correct branch autoModeService - .followUpFeature(projectPath, featureId, prompt, imagePaths) + .followUpFeature(projectPath, featureId, prompt, imagePaths, worktreePath) .catch((error) => { logger.error( `[AutoMode] Follow up feature ${featureId} error:`, diff --git a/apps/server/src/routes/auto-mode/routes/run-feature.ts b/apps/server/src/routes/auto-mode/routes/run-feature.ts index f3d258c2..056acbfd 100644 --- a/apps/server/src/routes/auto-mode/routes/run-feature.ts +++ b/apps/server/src/routes/auto-mode/routes/run-feature.ts @@ -12,10 +12,11 @@ const logger = createLogger("AutoMode"); export function createRunFeatureHandler(autoModeService: AutoModeService) { return async (req: Request, res: Response): Promise => { try { - const { projectPath, featureId, useWorktrees } = req.body as { + const { projectPath, featureId, useWorktrees, worktreePath } = req.body as { projectPath: string; featureId: string; useWorktrees?: boolean; + worktreePath?: string; }; if (!projectPath || !featureId) { @@ -29,8 +30,9 @@ export function createRunFeatureHandler(autoModeService: AutoModeService) { } // Start execution in background + // If worktreePath is provided, use it directly; otherwise let the service decide autoModeService - .executeFeature(projectPath, featureId, useWorktrees ?? true, false) + .executeFeature(projectPath, featureId, useWorktrees ?? true, false, worktreePath) .catch((error) => { logger.error(`[AutoMode] Feature ${featureId} error:`, error); }); diff --git a/apps/server/src/routes/fs/routes/delete-board-background.ts b/apps/server/src/routes/fs/routes/delete-board-background.ts index a9dacc9d..684225e7 100644 --- a/apps/server/src/routes/fs/routes/delete-board-background.ts +++ b/apps/server/src/routes/fs/routes/delete-board-background.ts @@ -6,6 +6,7 @@ import type { Request, Response } from "express"; import fs from "fs/promises"; import path from "path"; import { getErrorMessage, logError } from "../common.js"; +import { getBoardDir } from "../../../lib/automaker-paths.js"; export function createDeleteBoardBackgroundHandler() { return async (req: Request, res: Response): Promise => { @@ -20,10 +21,11 @@ export function createDeleteBoardBackgroundHandler() { return; } - const boardDir = path.join(projectPath, ".automaker", "board"); + // Get external board directory + const boardDir = await getBoardDir(projectPath); try { - // Try to remove all files in the board directory + // Try to remove all background files in the board directory const files = await fs.readdir(boardDir); for (const file of files) { if (file.startsWith("background")) { diff --git a/apps/server/src/routes/fs/routes/mkdir.ts b/apps/server/src/routes/fs/routes/mkdir.ts index 51634544..8cf41033 100644 --- a/apps/server/src/routes/fs/routes/mkdir.ts +++ b/apps/server/src/routes/fs/routes/mkdir.ts @@ -1,5 +1,6 @@ /** * POST /mkdir endpoint - Create directory + * Handles symlinks safely to avoid ELOOP errors */ import type { Request, Response } from "express"; @@ -20,13 +21,46 @@ export function createMkdirHandler() { const resolvedPath = path.resolve(dirPath); + // Check if path already exists using lstat (doesn't follow symlinks) + try { + const stats = await fs.lstat(resolvedPath); + // Path exists - if it's a directory or symlink, consider it success + if (stats.isDirectory() || stats.isSymbolicLink()) { + addAllowedPath(resolvedPath); + res.json({ success: true }); + return; + } + // It's a file - can't create directory + res.status(400).json({ + success: false, + error: "Path exists and is not a directory", + }); + return; + } catch (statError: any) { + // ENOENT means path doesn't exist - we should create it + if (statError.code !== "ENOENT") { + // Some other error (could be ELOOP in parent path) + throw statError; + } + } + + // Path doesn't exist, create it await fs.mkdir(resolvedPath, { recursive: true }); // Add the new directory to allowed paths for tracking addAllowedPath(resolvedPath); res.json({ success: true }); - } catch (error) { + } catch (error: any) { + // Handle ELOOP specifically + if (error.code === "ELOOP") { + logError(error, "Create directory failed - symlink loop detected"); + res.status(400).json({ + success: false, + error: "Cannot create directory: symlink loop detected in path", + }); + return; + } logError(error, "Create directory failed"); res.status(500).json({ success: false, error: getErrorMessage(error) }); } diff --git a/apps/server/src/routes/fs/routes/save-board-background.ts b/apps/server/src/routes/fs/routes/save-board-background.ts index 3697e4a8..4b4eb90d 100644 --- a/apps/server/src/routes/fs/routes/save-board-background.ts +++ b/apps/server/src/routes/fs/routes/save-board-background.ts @@ -7,6 +7,7 @@ import fs from "fs/promises"; import path from "path"; import { addAllowedPath } from "../../../lib/security.js"; import { getErrorMessage, logError } from "../common.js"; +import { getBoardDir } from "../../../lib/automaker-paths.js"; export function createSaveBoardBackgroundHandler() { return async (req: Request, res: Response): Promise => { @@ -26,8 +27,8 @@ export function createSaveBoardBackgroundHandler() { return; } - // Create .automaker/board directory if it doesn't exist - const boardDir = path.join(projectPath, ".automaker", "board"); + // Get external board directory + const boardDir = await getBoardDir(projectPath); await fs.mkdir(boardDir, { recursive: true }); // Decode base64 data (remove data URL prefix if present) @@ -42,12 +43,11 @@ export function createSaveBoardBackgroundHandler() { // Write file await fs.writeFile(filePath, buffer); - // Add project path to allowed paths if not already - addAllowedPath(projectPath); + // Add board directory to allowed paths + addAllowedPath(boardDir); - // Return the relative path for storage - const relativePath = `.automaker/board/${uniqueFilename}`; - res.json({ success: true, path: relativePath }); + // Return the absolute path + res.json({ success: true, path: filePath }); } catch (error) { logError(error, "Save board background failed"); res.status(500).json({ success: false, error: getErrorMessage(error) }); diff --git a/apps/server/src/routes/fs/routes/save-image.ts b/apps/server/src/routes/fs/routes/save-image.ts index c6987d05..eac863ed 100644 --- a/apps/server/src/routes/fs/routes/save-image.ts +++ b/apps/server/src/routes/fs/routes/save-image.ts @@ -1,5 +1,5 @@ /** - * POST /save-image endpoint - Save image to .automaker/images directory + * POST /save-image endpoint - Save image to external automaker images directory */ import type { Request, Response } from "express"; @@ -7,6 +7,7 @@ import fs from "fs/promises"; import path from "path"; import { addAllowedPath } from "../../../lib/security.js"; import { getErrorMessage, logError } from "../common.js"; +import { getImagesDir } from "../../../lib/automaker-paths.js"; export function createSaveImageHandler() { return async (req: Request, res: Response): Promise => { @@ -26,8 +27,8 @@ export function createSaveImageHandler() { return; } - // Create .automaker/images directory if it doesn't exist - const imagesDir = path.join(projectPath, ".automaker", "images"); + // Get external images directory + const imagesDir = await getImagesDir(projectPath); await fs.mkdir(imagesDir, { recursive: true }); // Decode base64 data (remove data URL prefix if present) @@ -44,9 +45,10 @@ export function createSaveImageHandler() { // Write file await fs.writeFile(filePath, buffer); - // Add project path to allowed paths if not already - addAllowedPath(projectPath); + // Add automaker directory to allowed paths + addAllowedPath(imagesDir); + // Return the absolute path res.json({ success: true, path: filePath }); } catch (error) { logError(error, "Save image failed"); diff --git a/apps/server/src/routes/fs/routes/write.ts b/apps/server/src/routes/fs/routes/write.ts index 81336104..b984b25d 100644 --- a/apps/server/src/routes/fs/routes/write.ts +++ b/apps/server/src/routes/fs/routes/write.ts @@ -7,6 +7,7 @@ import fs from "fs/promises"; import path from "path"; import { validatePath } from "../../../lib/security.js"; import { getErrorMessage, logError } from "../common.js"; +import { mkdirSafe } from "../../../lib/fs-utils.js"; export function createWriteHandler() { return async (req: Request, res: Response): Promise => { @@ -23,8 +24,8 @@ export function createWriteHandler() { const resolvedPath = validatePath(filePath); - // Ensure parent directory exists - await fs.mkdir(path.dirname(resolvedPath), { recursive: true }); + // Ensure parent directory exists (symlink-safe) + await mkdirSafe(path.dirname(resolvedPath)); await fs.writeFile(resolvedPath, content, "utf-8"); res.json({ success: true }); diff --git a/apps/server/src/routes/worktree/index.ts b/apps/server/src/routes/worktree/index.ts index 54b93419..6c4fb870 100644 --- a/apps/server/src/routes/worktree/index.ts +++ b/apps/server/src/routes/worktree/index.ts @@ -19,6 +19,13 @@ import { createPullHandler } from "./routes/pull.js"; import { createCheckoutBranchHandler } from "./routes/checkout-branch.js"; import { createListBranchesHandler } from "./routes/list-branches.js"; import { createSwitchBranchHandler } from "./routes/switch-branch.js"; +import { createOpenInEditorHandler } from "./routes/open-in-editor.js"; +import { createInitGitHandler } from "./routes/init-git.js"; +import { createActivateHandler } from "./routes/activate.js"; +import { createMigrateHandler } from "./routes/migrate.js"; +import { createStartDevHandler } from "./routes/start-dev.js"; +import { createStopDevHandler } from "./routes/stop-dev.js"; +import { createListDevServersHandler } from "./routes/list-dev-servers.js"; export function createWorktreeRoutes(): Router { const router = Router(); @@ -39,6 +46,13 @@ export function createWorktreeRoutes(): Router { router.post("/checkout-branch", createCheckoutBranchHandler()); router.post("/list-branches", createListBranchesHandler()); router.post("/switch-branch", createSwitchBranchHandler()); + router.post("/open-in-editor", createOpenInEditorHandler()); + router.post("/init-git", createInitGitHandler()); + router.post("/activate", createActivateHandler()); + router.post("/migrate", createMigrateHandler()); + router.post("/start-dev", createStartDevHandler()); + router.post("/stop-dev", createStopDevHandler()); + router.post("/list-dev-servers", createListDevServersHandler()); return router; } diff --git a/apps/server/src/routes/worktree/routes/activate.ts b/apps/server/src/routes/worktree/routes/activate.ts new file mode 100644 index 00000000..59e845b9 --- /dev/null +++ b/apps/server/src/routes/worktree/routes/activate.ts @@ -0,0 +1,149 @@ +/** + * POST /activate endpoint - Switch main project to a worktree's branch + * + * This allows users to "activate" a worktree so their running dev server + * (like Vite) shows the worktree's files. It does this by: + * 1. Checking for uncommitted changes (fails if found) + * 2. Removing the worktree (unlocks the branch) + * 3. Checking out that branch in the main directory + * + * Users should commit their changes before activating a worktree. + */ + +import type { Request, Response } from "express"; +import { exec } from "child_process"; +import { promisify } from "util"; +import { getErrorMessage, logError } from "../common.js"; + +const execAsync = promisify(exec); + +async function hasUncommittedChanges(cwd: string): Promise { + 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/branch-tracking.ts b/apps/server/src/routes/worktree/routes/branch-tracking.ts new file mode 100644 index 00000000..0e6e68b3 --- /dev/null +++ b/apps/server/src/routes/worktree/routes/branch-tracking.ts @@ -0,0 +1,125 @@ +/** + * Branch tracking utilities + * + * Tracks active branches in external automaker storage so users + * can switch between branches even after worktrees are removed. + * + * Data is stored outside the git repo to avoid worktree/symlink conflicts. + */ + +import { readFile, writeFile } from "fs/promises"; +import path from "path"; +import { + getBranchTrackingPath, + ensureAutomakerDir, +} from "../../../lib/automaker-paths.js"; + +export interface TrackedBranch { + name: string; + createdAt: string; + lastActivatedAt?: string; +} + +interface BranchTrackingData { + branches: TrackedBranch[]; +} + +/** + * Read tracked branches from file + */ +export async function getTrackedBranches( + projectPath: string +): Promise { + try { + const filePath = await getBranchTrackingPath(projectPath); + const content = await readFile(filePath, "utf-8"); + const data: BranchTrackingData = JSON.parse(content); + return data.branches || []; + } catch (error: any) { + if (error.code === "ENOENT") { + return []; + } + console.warn("[branch-tracking] Failed to read tracked branches:", error); + return []; + } +} + +/** + * Save tracked branches to file + */ +async function saveTrackedBranches( + projectPath: string, + branches: TrackedBranch[] +): Promise { + const automakerDir = await ensureAutomakerDir(projectPath); + const filePath = path.join(automakerDir, "active-branches.json"); + const data: BranchTrackingData = { branches }; + await writeFile(filePath, JSON.stringify(data, null, 2), "utf-8"); +} + +/** + * Add a branch to tracking + */ +export async function trackBranch( + projectPath: string, + branchName: string +): Promise { + const branches = await getTrackedBranches(projectPath); + + // Check if already tracked + const existing = branches.find((b) => b.name === branchName); + if (existing) { + return; // Already tracked + } + + branches.push({ + name: branchName, + createdAt: new Date().toISOString(), + }); + + await saveTrackedBranches(projectPath, branches); + console.log(`[branch-tracking] Now tracking branch: ${branchName}`); +} + +/** + * Remove a branch from tracking + */ +export async function untrackBranch( + projectPath: string, + branchName: string +): Promise { + const branches = await getTrackedBranches(projectPath); + const filtered = branches.filter((b) => b.name !== branchName); + + if (filtered.length !== branches.length) { + await saveTrackedBranches(projectPath, filtered); + console.log(`[branch-tracking] Stopped tracking branch: ${branchName}`); + } +} + +/** + * Update last activated timestamp for a branch + */ +export async function updateBranchActivation( + projectPath: string, + branchName: string +): Promise { + const branches = await getTrackedBranches(projectPath); + const branch = branches.find((b) => b.name === branchName); + + if (branch) { + branch.lastActivatedAt = new Date().toISOString(); + await saveTrackedBranches(projectPath, branches); + } +} + +/** + * Check if a branch is tracked + */ +export async function isBranchTracked( + projectPath: string, + branchName: string +): Promise { + const branches = await getTrackedBranches(projectPath); + return branches.some((b) => b.name === branchName); +} diff --git a/apps/server/src/routes/worktree/routes/create.ts b/apps/server/src/routes/worktree/routes/create.ts index f37a0e92..aef254b1 100644 --- a/apps/server/src/routes/worktree/routes/create.ts +++ b/apps/server/src/routes/worktree/routes/create.ts @@ -6,8 +6,9 @@ import type { Request, Response } from "express"; import { exec } from "child_process"; import { promisify } from "util"; import path from "path"; -import fs from "fs/promises"; +import { mkdir, access } from "fs/promises"; import { isGitRepo, getErrorMessage, logError } from "../common.js"; +import { trackBranch } from "./branch-tracking.js"; const execAsync = promisify(exec); @@ -42,14 +43,19 @@ export function createCreateHandler() { const worktreePath = path.join(worktreesDir, sanitizedName); // Create worktrees directory if it doesn't exist - await fs.mkdir(worktreesDir, { recursive: true }); + await mkdir(worktreesDir, { recursive: true }); // Check if worktree already exists try { - await fs.access(worktreePath); - res.status(400).json({ - success: false, - error: `Worktree for branch '${branchName}' already exists`, + await access(worktreePath); + // Worktree already exists, return it instead of error + res.json({ + success: true, + worktree: { + path: worktreePath, + branch: branchName, + isNew: false, + }, }); return; } catch { @@ -80,22 +86,12 @@ export function createCreateHandler() { await execAsync(createCmd, { cwd: projectPath }); - // Symlink .automaker directory to worktree so features are shared - const mainAutomaker = path.join(projectPath, ".automaker"); - const worktreeAutomaker = path.join(worktreePath, ".automaker"); + // Note: We intentionally do NOT symlink .automaker to worktrees + // Features and config are always accessed from the main project path + // This avoids symlink loop issues when activating worktrees - try { - // Check if .automaker exists in main project - await fs.access(mainAutomaker); - // Create symlink in worktree pointing to main .automaker - // Use 'junction' on Windows, 'dir' on other platforms - const symlinkType = process.platform === "win32" ? "junction" : "dir"; - await fs.symlink(mainAutomaker, worktreeAutomaker, symlinkType); - } catch (symlinkError) { - // .automaker doesn't exist or symlink failed - // Log but don't fail - worktree is still usable without shared .automaker - console.warn("[Worktree] Could not create .automaker symlink:", symlinkError); - } + // Track the branch so it persists in the UI even after worktree is removed + await trackBranch(projectPath, branchName); res.json({ success: true, diff --git a/apps/server/src/routes/worktree/routes/diffs.ts b/apps/server/src/routes/worktree/routes/diffs.ts index cc2c6cf4..ba0cbdde 100644 --- a/apps/server/src/routes/worktree/routes/diffs.ts +++ b/apps/server/src/routes/worktree/routes/diffs.ts @@ -29,12 +29,8 @@ export function createDiffsHandler() { return; } - const worktreePath = path.join( - projectPath, - ".automaker", - "worktrees", - featureId - ); + // Git worktrees are stored in project directory + const worktreePath = path.join(projectPath, ".worktrees", featureId); try { await fs.access(worktreePath); diff --git a/apps/server/src/routes/worktree/routes/file-diff.ts b/apps/server/src/routes/worktree/routes/file-diff.ts index 27fafba5..c8dea633 100644 --- a/apps/server/src/routes/worktree/routes/file-diff.ts +++ b/apps/server/src/routes/worktree/routes/file-diff.ts @@ -28,12 +28,8 @@ export function createFileDiffHandler() { return; } - const worktreePath = path.join( - projectPath, - ".automaker", - "worktrees", - featureId - ); + // Git worktrees are stored in project directory + const worktreePath = path.join(projectPath, ".worktrees", featureId); try { await fs.access(worktreePath); diff --git a/apps/server/src/routes/worktree/routes/info.ts b/apps/server/src/routes/worktree/routes/info.ts index e1175262..fa705153 100644 --- a/apps/server/src/routes/worktree/routes/info.ts +++ b/apps/server/src/routes/worktree/routes/info.ts @@ -29,13 +29,8 @@ export function createInfoHandler() { return; } - // Check if worktree exists - const worktreePath = path.join( - projectPath, - ".automaker", - "worktrees", - featureId - ); + // Check if worktree exists (git worktrees are stored in project directory) + const worktreePath = path.join(projectPath, ".worktrees", featureId); try { await fs.access(worktreePath); const { stdout } = await execAsync("git rev-parse --abbrev-ref HEAD", { diff --git a/apps/server/src/routes/worktree/routes/init-git.ts b/apps/server/src/routes/worktree/routes/init-git.ts new file mode 100644 index 00000000..0aecc8af --- /dev/null +++ b/apps/server/src/routes/worktree/routes/init-git.ts @@ -0,0 +1,60 @@ +/** + * POST /init-git endpoint - Initialize a git repository in a directory + */ + +import type { Request, Response } from "express"; +import { exec } from "child_process"; +import { promisify } from "util"; +import { existsSync } from "fs"; +import { join } from "path"; +import { getErrorMessage, logError } from "../common.js"; + +const execAsync = promisify(exec); + +export function createInitGitHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath } = req.body as { + projectPath: string; + }; + + if (!projectPath) { + res.status(400).json({ + success: false, + error: "projectPath required", + }); + return; + } + + // Check if .git already exists + const gitDirPath = join(projectPath, ".git"); + if (existsSync(gitDirPath)) { + res.json({ + success: true, + result: { + initialized: false, + message: "Git repository already exists", + }, + }); + return; + } + + // Initialize git and create an initial empty commit + await execAsync( + `git init && git commit --allow-empty -m "Initial commit"`, + { cwd: projectPath } + ); + + res.json({ + success: true, + result: { + initialized: true, + message: "Git repository initialized with initial commit", + }, + }); + } catch (error) { + logError(error, "Init git failed"); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/worktree/routes/list-dev-servers.ts b/apps/server/src/routes/worktree/routes/list-dev-servers.ts new file mode 100644 index 00000000..ff5c527a --- /dev/null +++ b/apps/server/src/routes/worktree/routes/list-dev-servers.ts @@ -0,0 +1,29 @@ +/** + * POST /list-dev-servers endpoint - List all running dev servers + * + * Returns information about all worktree dev servers currently running, + * including their ports and URLs. + */ + +import type { Request, Response } from "express"; +import { getDevServerService } from "../../../services/dev-server-service.js"; +import { getErrorMessage, logError } from "../common.js"; + +export function createListDevServersHandler() { + return async (_req: Request, res: Response): Promise => { + try { + const devServerService = getDevServerService(); + const result = devServerService.listDevServers(); + + res.json({ + success: true, + result: { + servers: result.result.servers, + }, + }); + } catch (error) { + logError(error, "List dev servers failed"); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/worktree/routes/list.ts b/apps/server/src/routes/worktree/routes/list.ts index ba21a92b..0619f2a0 100644 --- a/apps/server/src/routes/worktree/routes/list.ts +++ b/apps/server/src/routes/worktree/routes/list.ts @@ -1,5 +1,8 @@ /** - * POST /list endpoint - List all worktrees + * POST /list endpoint - List all git worktrees + * + * Returns actual git worktrees from `git worktree list`. + * Does NOT include tracked branches - only real worktrees with separate directories. */ import type { Request, Response } from "express"; @@ -13,10 +16,21 @@ interface WorktreeInfo { path: string; branch: string; isMain: boolean; + isCurrent: boolean; // Is this the currently checked out branch in main? + hasWorktree: boolean; // Always true for items in this list hasChanges?: boolean; changedFilesCount?: number; } +async function getCurrentBranch(cwd: string): Promise { + try { + const { stdout } = await execAsync("git branch --show-current", { cwd }); + return stdout.trim(); + } catch { + return ""; + } +} + export function createListHandler() { return async (req: Request, res: Response): Promise => { try { @@ -35,6 +49,10 @@ export function createListHandler() { return; } + // Get current branch in main directory + const currentBranch = await getCurrentBranch(projectPath); + + // Get actual worktrees from git const { stdout } = await execAsync("git worktree list --porcelain", { cwd: projectPath, }); @@ -51,11 +69,12 @@ export function createListHandler() { current.branch = line.slice(7).replace("refs/heads/", ""); } else if (line === "") { if (current.path && current.branch) { - // The first worktree in the list is always the main worktree worktrees.push({ path: current.path, branch: current.branch, - isMain: isFirst + isMain: isFirst, + isCurrent: current.branch === currentBranch, + hasWorktree: true, }); isFirst = false; } @@ -71,11 +90,13 @@ export function createListHandler() { "git status --porcelain", { cwd: worktree.path } ); - const changedFiles = statusOutput.trim().split("\n").filter(line => line.trim()); + const changedFiles = statusOutput + .trim() + .split("\n") + .filter((line) => line.trim()); worktree.hasChanges = changedFiles.length > 0; worktree.changedFilesCount = changedFiles.length; } catch { - // If we can't get status, assume no changes worktree.hasChanges = false; worktree.changedFilesCount = 0; } diff --git a/apps/server/src/routes/worktree/routes/merge.ts b/apps/server/src/routes/worktree/routes/merge.ts index 57ae2c96..f9499d85 100644 --- a/apps/server/src/routes/worktree/routes/merge.ts +++ b/apps/server/src/routes/worktree/routes/merge.ts @@ -30,12 +30,8 @@ export function createMergeHandler() { } const branchName = `feature/${featureId}`; - const worktreePath = path.join( - projectPath, - ".automaker", - "worktrees", - featureId - ); + // Git worktrees are stored in project directory + const worktreePath = path.join(projectPath, ".worktrees", featureId); // Get current branch const { stdout: currentBranch } = await execAsync( diff --git a/apps/server/src/routes/worktree/routes/migrate.ts b/apps/server/src/routes/worktree/routes/migrate.ts new file mode 100644 index 00000000..e69e1cc9 --- /dev/null +++ b/apps/server/src/routes/worktree/routes/migrate.ts @@ -0,0 +1,63 @@ +/** + * POST /migrate endpoint - Migrate legacy .automaker data to external storage + * + * This endpoint checks if there's legacy .automaker data in the project directory + * and migrates it to the external ~/.automaker/projects/{project-id}/ location. + */ + +import type { Request, Response } from "express"; +import { getErrorMessage, logError } from "../common.js"; +import { + hasLegacyAutomakerDir, + migrateLegacyData, + getAutomakerDir, + getLegacyAutomakerDir, +} from "../../../lib/automaker-paths.js"; + +export function createMigrateHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath } = req.body as { projectPath: string }; + + if (!projectPath) { + res.status(400).json({ + success: false, + error: "projectPath is required", + }); + return; + } + + // Check if migration is needed + const hasLegacy = await hasLegacyAutomakerDir(projectPath); + + if (!hasLegacy) { + const automakerDir = await getAutomakerDir(projectPath); + res.json({ + success: true, + migrated: false, + message: "No legacy .automaker directory found - nothing to migrate", + externalPath: automakerDir, + }); + return; + } + + // Perform migration + console.log(`[migrate] Starting migration for project: ${projectPath}`); + const legacyPath = await getLegacyAutomakerDir(projectPath); + const externalPath = await getAutomakerDir(projectPath); + + await migrateLegacyData(projectPath); + + res.json({ + success: true, + migrated: true, + message: "Successfully migrated .automaker data to external storage", + legacyPath, + externalPath, + }); + } catch (error) { + logError(error, "Migration failed"); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/worktree/routes/open-in-editor.ts b/apps/server/src/routes/worktree/routes/open-in-editor.ts new file mode 100644 index 00000000..868227ee --- /dev/null +++ b/apps/server/src/routes/worktree/routes/open-in-editor.ts @@ -0,0 +1,73 @@ +/** + * POST /open-in-editor endpoint - Open a worktree directory in VS Code + */ + +import type { Request, Response } from "express"; +import { exec } from "child_process"; +import { promisify } from "util"; +import { getErrorMessage, logError } from "../common.js"; + +const execAsync = promisify(exec); + +export function createOpenInEditorHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { worktreePath } = req.body as { + worktreePath: string; + }; + + if (!worktreePath) { + res.status(400).json({ + success: false, + error: "worktreePath required", + }); + return; + } + + // Try to open in VS Code + try { + await execAsync(`code "${worktreePath}"`); + res.json({ + success: true, + result: { + message: `Opened ${worktreePath} in VS Code`, + }, + }); + } catch { + // If 'code' command fails, try 'cursor' (for Cursor editor) + try { + await execAsync(`cursor "${worktreePath}"`); + res.json({ + success: true, + result: { + message: `Opened ${worktreePath} in Cursor`, + }, + }); + } catch { + // If both fail, try opening in default file manager + const platform = process.platform; + let openCommand: string; + + if (platform === "darwin") { + openCommand = `open "${worktreePath}"`; + } else if (platform === "win32") { + openCommand = `explorer "${worktreePath}"`; + } else { + openCommand = `xdg-open "${worktreePath}"`; + } + + await execAsync(openCommand); + res.json({ + success: true, + result: { + message: `Opened ${worktreePath} in file manager`, + }, + }); + } + } + } catch (error) { + logError(error, "Open in editor failed"); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/worktree/routes/pull.ts b/apps/server/src/routes/worktree/routes/pull.ts index 8f376c45..119192d0 100644 --- a/apps/server/src/routes/worktree/routes/pull.ts +++ b/apps/server/src/routes/worktree/routes/pull.ts @@ -43,7 +43,7 @@ export function createPullHandler() { if (hasLocalChanges) { res.status(400).json({ success: false, - error: "You have local changes. Please commit or stash them before pulling.", + error: "You have local changes. Please commit them before pulling.", }); return; } diff --git a/apps/server/src/routes/worktree/routes/revert.ts b/apps/server/src/routes/worktree/routes/revert.ts index 95f5a33a..6f0d7871 100644 --- a/apps/server/src/routes/worktree/routes/revert.ts +++ b/apps/server/src/routes/worktree/routes/revert.ts @@ -28,12 +28,8 @@ export function createRevertHandler() { return; } - const worktreePath = path.join( - projectPath, - ".automaker", - "worktrees", - featureId - ); + // Git worktrees are stored in project directory + const worktreePath = path.join(projectPath, ".worktrees", featureId); try { // Remove worktree diff --git a/apps/server/src/routes/worktree/routes/start-dev.ts b/apps/server/src/routes/worktree/routes/start-dev.ts new file mode 100644 index 00000000..fcd0cec7 --- /dev/null +++ b/apps/server/src/routes/worktree/routes/start-dev.ts @@ -0,0 +1,61 @@ +/** + * POST /start-dev endpoint - Start a dev server for a worktree + * + * Spins up a development server (npm run dev) in the worktree directory + * on a unique port, allowing preview of the worktree's changes without + * affecting the main dev server. + */ + +import type { Request, Response } from "express"; +import { getDevServerService } from "../../../services/dev-server-service.js"; +import { getErrorMessage, logError } from "../common.js"; + +export function createStartDevHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, worktreePath } = req.body as { + projectPath: string; + worktreePath: string; + }; + + if (!projectPath) { + res.status(400).json({ + success: false, + error: "projectPath is required", + }); + return; + } + + if (!worktreePath) { + res.status(400).json({ + success: false, + error: "worktreePath is required", + }); + return; + } + + const devServerService = getDevServerService(); + const result = await devServerService.startDevServer(projectPath, worktreePath); + + if (result.success && result.result) { + res.json({ + success: true, + result: { + worktreePath: result.result.worktreePath, + port: result.result.port, + url: result.result.url, + message: result.result.message, + }, + }); + } else { + res.status(400).json({ + success: false, + error: result.error || "Failed to start dev server", + }); + } + } catch (error) { + logError(error, "Start dev server failed"); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/worktree/routes/status.ts b/apps/server/src/routes/worktree/routes/status.ts index 5d3a330b..3f56ef17 100644 --- a/apps/server/src/routes/worktree/routes/status.ts +++ b/apps/server/src/routes/worktree/routes/status.ts @@ -29,12 +29,8 @@ export function createStatusHandler() { return; } - const worktreePath = path.join( - projectPath, - ".automaker", - "worktrees", - featureId - ); + // Git worktrees are stored in project directory + const worktreePath = path.join(projectPath, ".worktrees", featureId); try { await fs.access(worktreePath); diff --git a/apps/server/src/routes/worktree/routes/stop-dev.ts b/apps/server/src/routes/worktree/routes/stop-dev.ts new file mode 100644 index 00000000..2c22b006 --- /dev/null +++ b/apps/server/src/routes/worktree/routes/stop-dev.ts @@ -0,0 +1,49 @@ +/** + * POST /stop-dev endpoint - Stop a dev server for a worktree + * + * Stops the development server running for a specific worktree, + * freeing up the ports for reuse. + */ + +import type { Request, Response } from "express"; +import { getDevServerService } from "../../../services/dev-server-service.js"; +import { getErrorMessage, logError } from "../common.js"; + +export function createStopDevHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { worktreePath } = req.body as { + worktreePath: string; + }; + + if (!worktreePath) { + res.status(400).json({ + success: false, + error: "worktreePath is required", + }); + return; + } + + const devServerService = getDevServerService(); + const result = await devServerService.stopDevServer(worktreePath); + + if (result.success && result.result) { + res.json({ + success: true, + result: { + worktreePath: result.result.worktreePath, + message: result.result.message, + }, + }); + } else { + res.status(400).json({ + success: false, + error: result.error || "Failed to stop dev server", + }); + } + } catch (error) { + logError(error, "Stop dev server failed"); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/worktree/routes/switch-branch.ts b/apps/server/src/routes/worktree/routes/switch-branch.ts index 696fa719..c3c4cdb4 100644 --- a/apps/server/src/routes/worktree/routes/switch-branch.ts +++ b/apps/server/src/routes/worktree/routes/switch-branch.ts @@ -1,6 +1,9 @@ /** * POST /switch-branch endpoint - Switch to an existing branch - * Automatically stashes uncommitted changes and pops them after switching + * + * Simple branch switching. + * If there are uncommitted changes, the switch will fail and + * the user should commit first. */ import type { Request, Response } from "express"; @@ -10,6 +13,46 @@ import { getErrorMessage, logError } from "../common.js"; const execAsync = promisify(exec); +/** + * Check if there are uncommitted changes in the working directory + * Excludes .worktrees/ directory which is created by automaker + */ +async function hasUncommittedChanges(cwd: string): Promise { + try { + const { stdout } = await execAsync("git status --porcelain", { cwd }); + const lines = stdout.trim().split("\n").filter((line) => { + if (!line.trim()) return false; + // Exclude .worktrees/ directory (created by automaker) + if (line.includes(".worktrees/") || line.endsWith(".worktrees")) return false; + return true; + }); + return lines.length > 0; + } catch { + return false; + } +} + +/** + * Get a summary of uncommitted changes for user feedback + * Excludes .worktrees/ directory + */ +async function getChangesSummary(cwd: string): Promise { + try { + const { stdout } = await execAsync("git status --short", { cwd }); + const lines = stdout.trim().split("\n").filter((line) => { + if (!line.trim()) return false; + // Exclude .worktrees/ directory + if (line.includes(".worktrees/") || line.endsWith(".worktrees")) return false; + return true; + }); + if (lines.length === 0) return ""; + if (lines.length <= 5) return lines.join(", "); + return `${lines.slice(0, 5).join(", ")} and ${lines.length - 5} more files`; + } catch { + return "unknown changes"; + } +} + export function createSwitchBranchHandler() { return async (req: Request, res: Response): Promise => { try { @@ -34,7 +77,7 @@ export function createSwitchBranchHandler() { return; } - // Get current branch for reference + // Get current branch const { stdout: currentBranchOutput } = await execAsync( "git rev-parse --abbrev-ref HEAD", { cwd: worktreePath } @@ -48,7 +91,6 @@ export function createSwitchBranchHandler() { previousBranch, currentBranch: branchName, message: `Already on branch '${branchName}'`, - stashed: false, }, }); return; @@ -68,81 +110,27 @@ export function createSwitchBranchHandler() { } // Check for uncommitted changes - const { stdout: statusOutput } = await execAsync( - "git status --porcelain", - { cwd: worktreePath } - ); - - const hasChanges = statusOutput.trim().length > 0; - let stashed = false; - - // Stash changes if there are any - if (hasChanges) { - await execAsync("git stash push -m \"auto-stash before branch switch\"", { - cwd: worktreePath, + if (await hasUncommittedChanges(worktreePath)) { + const summary = await getChangesSummary(worktreePath); + res.status(400).json({ + success: false, + error: `Cannot switch branches: you have uncommitted changes (${summary}). Please commit your changes first.`, + code: "UNCOMMITTED_CHANGES", }); - stashed = true; + return; } - try { - // Switch to the branch - await execAsync(`git checkout ${branchName}`, { - cwd: worktreePath, - }); + // Switch to the target branch + await execAsync(`git checkout "${branchName}"`, { cwd: worktreePath }); - // Pop the stash if we stashed changes - if (stashed) { - try { - await execAsync("git stash pop", { - cwd: worktreePath, - }); - } catch (stashPopError) { - // Stash pop might fail due to conflicts - const err = stashPopError as { stderr?: string; message?: string }; - const errorMsg = err.stderr || err.message || ""; - - if (errorMsg.includes("CONFLICT") || errorMsg.includes("conflict")) { - res.json({ - success: true, - result: { - previousBranch, - currentBranch: branchName, - message: `Switched to '${branchName}' but stash had conflicts. Please resolve manually.`, - stashed: true, - stashConflict: true, - }, - }); - return; - } - // Re-throw if it's not a conflict error - throw stashPopError; - } - } - - const message = stashed - ? `Switched to branch '${branchName}' (changes stashed and restored)` - : `Switched to branch '${branchName}'`; - - res.json({ - success: true, - result: { - previousBranch, - currentBranch: branchName, - message, - stashed, - }, - }); - } catch (checkoutError) { - // If checkout fails and we stashed, try to restore the stash - if (stashed) { - try { - await execAsync("git stash pop", { cwd: worktreePath }); - } catch { - // Ignore stash pop errors during recovery - } - } - throw checkoutError; - } + res.json({ + success: true, + result: { + previousBranch, + currentBranch: branchName, + message: `Switched to branch '${branchName}'`, + }, + }); } catch (error) { logError(error, "Switch branch failed"); res.status(500).json({ success: false, error: getErrorMessage(error) }); diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index 00bfd0f7..621f192f 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -20,6 +20,12 @@ import { buildPromptWithImages } from "../lib/prompt-builder.js"; import { resolveModelString, DEFAULT_MODELS } from "../lib/model-resolver.js"; import { createAutoModeOptions } from "../lib/sdk-options.js"; import { isAbortError, classifyError } from "../lib/error-handler.js"; +import { + getFeatureDir, + getFeaturesDir, + getAutomakerDir, + getWorktreesDir, +} from "../lib/automaker-paths.js"; const execAsync = promisify(exec); @@ -178,12 +184,18 @@ export class AutoModeService { /** * Execute a single feature + * @param projectPath - The main project path + * @param featureId - The feature ID to execute + * @param useWorktrees - Whether to use worktrees for isolation + * @param isAutoMode - Whether this is running in auto mode + * @param providedWorktreePath - Optional: use this worktree path instead of creating a new one */ async executeFeature( projectPath: string, featureId: string, useWorktrees = true, - isAutoMode = false + isAutoMode = false, + providedWorktreePath?: string ): Promise { if (this.runningFeatures.has(featureId)) { throw new Error(`Feature ${featureId} is already running`); @@ -193,8 +205,13 @@ export class AutoModeService { const branchName = `feature/${featureId}`; let worktreePath: string | null = null; - // Setup worktree if enabled - if (useWorktrees) { + // Use provided worktree path if given, otherwise setup new worktree if enabled + if (providedWorktreePath) { + // User selected a specific worktree - use it directly + worktreePath = providedWorktreePath; + console.log(`[AutoMode] Using provided worktree path: ${worktreePath}`); + } else if (useWorktrees) { + // No specific worktree provided, create a new one for this feature worktreePath = await this.setupWorktree( projectPath, featureId, @@ -320,14 +337,9 @@ export class AutoModeService { featureId: string, useWorktrees = true ): Promise { - // Check if context exists - const contextPath = path.join( - projectPath, - ".automaker", - "features", - featureId, - "agent-output.md" - ); + // Check if context exists in external automaker directory + const featureDir = await getFeatureDir(projectPath, featureId); + const contextPath = path.join(featureDir, "agent-output.md"); let hasContext = false; try { @@ -359,7 +371,8 @@ export class AutoModeService { projectPath: string, featureId: string, prompt: string, - imagePaths?: string[] + imagePaths?: string[], + providedWorktreePath?: string ): Promise { if (this.runningFeatures.has(featureId)) { throw new Error(`Feature ${featureId} is already running`); @@ -367,33 +380,28 @@ export class AutoModeService { const abortController = new AbortController(); - // Check if worktree exists - const worktreePath = path.join( - projectPath, - ".automaker", - "worktrees", - featureId - ); + // Use the provided worktreePath (from the feature's assigned branch) + // Fall back to project path if not provided let workDir = projectPath; + let worktreePath: string | null = null; - try { - await fs.access(worktreePath); - workDir = worktreePath; - } catch { - // No worktree, use project path + if (providedWorktreePath) { + try { + await fs.access(providedWorktreePath); + workDir = providedWorktreePath; + worktreePath = providedWorktreePath; + } catch { + // Worktree path provided but doesn't exist, use project path + console.log(`[AutoMode] Provided worktreePath doesn't exist: ${providedWorktreePath}, using project path`); + } } // Load feature info for context const feature = await this.loadFeature(projectPath, featureId); - // Load previous agent output if it exists - const contextPath = path.join( - projectPath, - ".automaker", - "features", - featureId, - "agent-output.md" - ); + // Load previous agent output if it exists (from external automaker) + const featureDir = await getFeatureDir(projectPath, featureId); + const contextPath = path.join(featureDir, "agent-output.md"); let previousContext = ""; try { previousContext = await fs.readFile(contextPath, "utf-8"); @@ -426,8 +434,8 @@ Address the follow-up instructions above. Review the previous work and make the this.runningFeatures.set(featureId, { featureId, projectPath, - worktreePath: workDir !== projectPath ? worktreePath : null, - branchName: `feature/${featureId}`, + worktreePath, + branchName: worktreePath ? path.basename(worktreePath) : null, abortController, isAutoMode: false, startTime: Date.now(), @@ -453,16 +461,11 @@ Address the follow-up instructions above. Review the previous work and make the // Update feature status to in_progress await this.updateFeatureStatus(projectPath, featureId, "in_progress"); - // Copy follow-up images to feature folder + // Copy follow-up images to feature folder (external automaker) const copiedImagePaths: string[] = []; if (imagePaths && imagePaths.length > 0) { - const featureImagesDir = path.join( - projectPath, - ".automaker", - "features", - featureId, - "images" - ); + const featureDirForImages = await getFeatureDir(projectPath, featureId); + const featureImagesDir = path.join(featureDirForImages, "images"); await fs.mkdir(featureImagesDir, { recursive: true }); @@ -475,15 +478,8 @@ Address the follow-up instructions above. Review the previous work and make the // Copy the image await fs.copyFile(imagePath, destPath); - // Store the relative path (like FeatureLoader does) - const relativePath = path.join( - ".automaker", - "features", - featureId, - "images", - filename - ); - copiedImagePaths.push(relativePath); + // Store the absolute path (external storage uses absolute paths) + copiedImagePaths.push(destPath); } catch (error) { console.error( `[AutoMode] Failed to copy follow-up image ${imagePath}:`, @@ -516,15 +512,10 @@ Address the follow-up instructions above. Review the previous work and make the allImagePaths.push(...allPaths); } - // Save updated feature.json with new images + // Save updated feature.json with new images (external automaker) if (copiedImagePaths.length > 0 && feature) { - const featurePath = path.join( - projectPath, - ".automaker", - "features", - featureId, - "feature.json" - ); + const featureDirForSave = await getFeatureDir(projectPath, featureId); + const featurePath = path.join(featureDirForSave, "feature.json"); try { await fs.writeFile(featurePath, JSON.stringify(feature, null, 2)); @@ -576,12 +567,8 @@ Address the follow-up instructions above. Review the previous work and make the projectPath: string, featureId: string ): Promise { - const worktreePath = path.join( - projectPath, - ".automaker", - "worktrees", - featureId - ); + // Worktrees are in project dir + const worktreePath = path.join(projectPath, ".worktrees", featureId); let workDir = projectPath; try { @@ -640,24 +627,36 @@ Address the follow-up instructions above. Review the previous work and make the /** * Commit feature changes + * @param projectPath - The main project path + * @param featureId - The feature ID to commit + * @param providedWorktreePath - Optional: the worktree path where the feature's changes are located */ async commitFeature( projectPath: string, - featureId: string + featureId: string, + providedWorktreePath?: string ): Promise { - const worktreePath = path.join( - projectPath, - ".automaker", - "worktrees", - featureId - ); let workDir = projectPath; - try { - await fs.access(worktreePath); - workDir = worktreePath; - } catch { - // No worktree + // Use the provided worktree path if given + if (providedWorktreePath) { + try { + await fs.access(providedWorktreePath); + workDir = providedWorktreePath; + console.log(`[AutoMode] Committing in provided worktree: ${workDir}`); + } catch { + console.log(`[AutoMode] Provided worktree path doesn't exist: ${providedWorktreePath}, using project path`); + } + } else { + // Fallback: try to find worktree at legacy location + const legacyWorktreePath = path.join(projectPath, ".worktrees", featureId); + try { + await fs.access(legacyWorktreePath); + workDir = legacyWorktreePath; + console.log(`[AutoMode] Committing in legacy worktree: ${workDir}`); + } catch { + console.log(`[AutoMode] No worktree found, committing in project path: ${workDir}`); + } } try { @@ -708,13 +707,9 @@ Address the follow-up instructions above. Review the previous work and make the projectPath: string, featureId: string ): Promise { - const contextPath = path.join( - projectPath, - ".automaker", - "features", - featureId, - "agent-output.md" - ); + // Context is stored in external automaker directory + const featureDir = await getFeatureDir(projectPath, featureId); + const contextPath = path.join(featureDir, "agent-output.md"); try { await fs.access(contextPath); @@ -787,13 +782,10 @@ Format your response as a structured markdown document.`; } } - // Save analysis - const analysisPath = path.join( - projectPath, - ".automaker", - "project-analysis.md" - ); - await fs.mkdir(path.dirname(analysisPath), { recursive: true }); + // Save analysis to external automaker directory + const automakerDir = await getAutomakerDir(projectPath); + const analysisPath = path.join(automakerDir, "project-analysis.md"); + await fs.mkdir(automakerDir, { recursive: true }); await fs.writeFile(analysisPath, analysisResult); this.emitAutoModeEvent("auto_mode_feature_complete", { @@ -852,7 +844,8 @@ Format your response as a structured markdown document.`; featureId: string, branchName: string ): Promise { - const worktreesDir = path.join(projectPath, ".automaker", "worktrees"); + // Git worktrees stay in project directory (not external automaker) + const worktreesDir = path.join(projectPath, ".worktrees"); const worktreePath = path.join(worktreesDir, featureId); await fs.mkdir(worktreesDir, { recursive: true }); @@ -890,13 +883,9 @@ Format your response as a structured markdown document.`; projectPath: string, featureId: string ): Promise { - const featurePath = path.join( - projectPath, - ".automaker", - "features", - featureId, - "feature.json" - ); + // Features are stored in external automaker directory + const featureDir = await getFeatureDir(projectPath, featureId); + const featurePath = path.join(featureDir, "feature.json"); try { const data = await fs.readFile(featurePath, "utf-8"); @@ -911,13 +900,9 @@ Format your response as a structured markdown document.`; featureId: string, status: string ): Promise { - const featurePath = path.join( - projectPath, - ".automaker", - "features", - featureId, - "feature.json" - ); + // Features are stored in external automaker directory + const featureDir = await getFeatureDir(projectPath, featureId); + const featurePath = path.join(featureDir, "feature.json"); try { const data = await fs.readFile(featurePath, "utf-8"); @@ -939,7 +924,8 @@ Format your response as a structured markdown document.`; } private async loadPendingFeatures(projectPath: string): Promise { - const featuresDir = path.join(projectPath, ".automaker", "features"); + // Features are stored in external automaker directory + const featuresDir = await getFeaturesDir(projectPath); try { const entries = await fs.readdir(featuresDir, { withFileTypes: true }); @@ -1128,13 +1114,12 @@ When done, summarize what you implemented and any notes for the developer.`; // Execute via provider const stream = provider.executeQuery(options); let responseText = ""; - const outputPath = path.join( - workDir, - ".automaker", - "features", - featureId, - "agent-output.md" - ); + // Agent output goes to external automaker directory + // Note: We use the original projectPath here (from config), not workDir + // because workDir might be a worktree path + const configProjectPath = this.config?.projectPath || workDir; + const featureDirForOutput = await getFeatureDir(configProjectPath, featureId); + const outputPath = path.join(featureDirForOutput, "agent-output.md"); for await (const msg of stream) { if (msg.type === "assistant" && msg.message?.content) { diff --git a/apps/server/src/services/dev-server-service.ts b/apps/server/src/services/dev-server-service.ts new file mode 100644 index 00000000..40134530 --- /dev/null +++ b/apps/server/src/services/dev-server-service.ts @@ -0,0 +1,460 @@ +/** + * Dev Server Service + * + * Manages multiple development server processes for git worktrees. + * Each worktree can have its own dev server running on a unique port. + * + * Developers should configure their projects to use the PORT environment variable. + */ + +import { spawn, execSync, type ChildProcess } from "child_process"; +import { existsSync } from "fs"; +import path from "path"; +import net from "net"; + +export interface DevServerInfo { + worktreePath: string; + port: number; + url: string; + process: ChildProcess | null; + startedAt: Date; +} + +// Port allocation starts at 3001 to avoid conflicts with common dev ports +const BASE_PORT = 3001; +const MAX_PORT = 3099; // Safety limit + +class DevServerService { + private runningServers: Map = new Map(); + private allocatedPorts: Set = new Set(); + + /** + * Check if a port is available (not in use by system or by us) + */ + private async isPortAvailable(port: number): Promise { + // First check if we've already allocated it + if (this.allocatedPorts.has(port)) { + return false; + } + + // Then check if the system has it in use + return new Promise((resolve) => { + const server = net.createServer(); + server.once("error", () => resolve(false)); + server.once("listening", () => { + server.close(); + resolve(true); + }); + server.listen(port, "127.0.0.1"); + }); + } + + /** + * Kill any process running on the given port + */ + private killProcessOnPort(port: number): void { + try { + if (process.platform === "win32") { + // Windows: find and kill process on port + const result = execSync(`netstat -ano | findstr :${port}`, { encoding: "utf-8" }); + const lines = result.trim().split("\n"); + const pids = new Set(); + for (const line of lines) { + const parts = line.trim().split(/\s+/); + const pid = parts[parts.length - 1]; + if (pid && pid !== "0") { + pids.add(pid); + } + } + for (const pid of pids) { + try { + execSync(`taskkill /F /PID ${pid}`, { stdio: "ignore" }); + console.log(`[DevServerService] Killed process ${pid} on port ${port}`); + } catch { + // Process may have already exited + } + } + } else { + // macOS/Linux: use lsof to find and kill process + try { + const result = execSync(`lsof -ti:${port}`, { encoding: "utf-8" }); + const pids = result.trim().split("\n").filter(Boolean); + for (const pid of pids) { + try { + execSync(`kill -9 ${pid}`, { stdio: "ignore" }); + console.log(`[DevServerService] Killed process ${pid} on port ${port}`); + } catch { + // Process may have already exited + } + } + } catch { + // No process found on port, which is fine + } + } + } catch (error) { + // Ignore errors - port might not have any process + console.log(`[DevServerService] No process to kill on port ${port}`); + } + } + + /** + * Find the next available port, killing any process on it first + */ + private async findAvailablePort(): Promise { + let port = BASE_PORT; + + while (port <= MAX_PORT) { + // Skip ports we've already allocated internally + if (this.allocatedPorts.has(port)) { + port++; + continue; + } + + // Force kill any process on this port before checking availability + // This ensures we can claim the port even if something stale is holding it + this.killProcessOnPort(port); + + // Small delay to let the port be released + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Now check if it's available + if (await this.isPortAvailable(port)) { + return port; + } + port++; + } + + throw new Error(`No available ports found between ${BASE_PORT} and ${MAX_PORT}`); + } + + /** + * Detect the package manager used in a directory + */ + private detectPackageManager( + dir: string + ): "npm" | "yarn" | "pnpm" | "bun" | null { + if (existsSync(path.join(dir, "bun.lockb"))) return "bun"; + if (existsSync(path.join(dir, "pnpm-lock.yaml"))) return "pnpm"; + if (existsSync(path.join(dir, "yarn.lock"))) return "yarn"; + if (existsSync(path.join(dir, "package-lock.json"))) return "npm"; + if (existsSync(path.join(dir, "package.json"))) return "npm"; // Default + return null; + } + + /** + * Get the dev script command for a directory + */ + private getDevCommand(dir: string): { cmd: string; args: string[] } | null { + const pm = this.detectPackageManager(dir); + if (!pm) return null; + + switch (pm) { + case "bun": + return { cmd: "bun", args: ["run", "dev"] }; + case "pnpm": + return { cmd: "pnpm", args: ["run", "dev"] }; + case "yarn": + return { cmd: "yarn", args: ["dev"] }; + case "npm": + default: + return { cmd: "npm", args: ["run", "dev"] }; + } + } + + /** + * Start a dev server for a worktree + */ + async startDevServer( + projectPath: string, + worktreePath: string + ): Promise<{ + success: boolean; + result?: { + worktreePath: string; + port: number; + url: string; + message: string; + }; + error?: string; + }> { + // Check if already running + if (this.runningServers.has(worktreePath)) { + const existing = this.runningServers.get(worktreePath)!; + return { + success: true, + result: { + worktreePath: existing.worktreePath, + port: existing.port, + url: existing.url, + message: `Dev server already running on port ${existing.port}`, + }, + }; + } + + // Verify the worktree exists + if (!existsSync(worktreePath)) { + return { + success: false, + error: `Worktree path does not exist: ${worktreePath}`, + }; + } + + // Check for package.json + const packageJsonPath = path.join(worktreePath, "package.json"); + if (!existsSync(packageJsonPath)) { + return { + success: false, + error: `No package.json found in: ${worktreePath}`, + }; + } + + // Get dev command + const devCommand = this.getDevCommand(worktreePath); + if (!devCommand) { + return { + success: false, + error: `Could not determine dev command for: ${worktreePath}`, + }; + } + + // Find available port + let port: number; + try { + port = await this.findAvailablePort(); + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : "Port allocation failed", + }; + } + + // Reserve the port (port was already force-killed in findAvailablePort) + this.allocatedPorts.add(port); + + // Also kill common related ports (livereload uses 35729 by default) + // Some dev servers use fixed ports for HMR/livereload regardless of main port + const commonRelatedPorts = [35729, 35730, 35731]; + for (const relatedPort of commonRelatedPorts) { + this.killProcessOnPort(relatedPort); + } + + // Small delay to ensure related ports are freed + await new Promise((resolve) => setTimeout(resolve, 100)); + + console.log( + `[DevServerService] Starting dev server on port ${port}` + ); + console.log( + `[DevServerService] Working directory (cwd): ${worktreePath}` + ); + console.log( + `[DevServerService] Command: ${devCommand.cmd} ${devCommand.args.join(" ")} with PORT=${port}` + ); + + // Spawn the dev process with PORT environment variable + const env = { + ...process.env, + PORT: String(port), + }; + + const devProcess = spawn(devCommand.cmd, devCommand.args, { + cwd: worktreePath, + env, + stdio: ["ignore", "pipe", "pipe"], + detached: false, + }); + + // Track if process failed early using object to work around TypeScript narrowing + const status = { error: null as string | null, exited: false }; + + // Log output for debugging + if (devProcess.stdout) { + devProcess.stdout.on("data", (data: Buffer) => { + console.log(`[DevServer:${port}] ${data.toString().trim()}`); + }); + } + + if (devProcess.stderr) { + devProcess.stderr.on("data", (data: Buffer) => { + const msg = data.toString().trim(); + console.error(`[DevServer:${port}] ${msg}`); + }); + } + + devProcess.on("error", (error) => { + console.error(`[DevServerService] Process error:`, error); + status.error = error.message; + this.allocatedPorts.delete(port); + this.runningServers.delete(worktreePath); + }); + + devProcess.on("exit", (code) => { + console.log( + `[DevServerService] Process for ${worktreePath} exited with code ${code}` + ); + status.exited = true; + this.allocatedPorts.delete(port); + this.runningServers.delete(worktreePath); + }); + + // Wait a moment to see if the process fails immediately + await new Promise((resolve) => setTimeout(resolve, 500)); + + if (status.error) { + return { + success: false, + error: `Failed to start dev server: ${status.error}`, + }; + } + + if (status.exited) { + return { + success: false, + error: `Dev server process exited immediately. Check server logs for details.`, + }; + } + + const serverInfo: DevServerInfo = { + worktreePath, + port, + url: `http://localhost:${port}`, + process: devProcess, + startedAt: new Date(), + }; + + this.runningServers.set(worktreePath, serverInfo); + + return { + success: true, + result: { + worktreePath, + port, + url: `http://localhost:${port}`, + message: `Dev server started on port ${port}`, + }, + }; + } + + /** + * Stop a dev server for a worktree + */ + async stopDevServer(worktreePath: string): Promise<{ + success: boolean; + result?: { worktreePath: string; message: string }; + error?: string; + }> { + const server = this.runningServers.get(worktreePath); + + // If we don't have a record of this server, it may have crashed/exited on its own + // Return success so the frontend can clear its state + if (!server) { + console.log(`[DevServerService] No server record for ${worktreePath}, may have already stopped`); + return { + success: true, + result: { + worktreePath, + message: `Dev server already stopped`, + }, + }; + } + + console.log(`[DevServerService] Stopping dev server for ${worktreePath}`); + + // Kill the process + if (server.process && !server.process.killed) { + server.process.kill("SIGTERM"); + } + + // Free the port + this.allocatedPorts.delete(server.port); + this.runningServers.delete(worktreePath); + + return { + success: true, + result: { + worktreePath, + message: `Stopped dev server on port ${server.port}`, + }, + }; + } + + /** + * List all running dev servers + */ + listDevServers(): { + success: boolean; + result: { + servers: Array<{ + worktreePath: string; + port: number; + url: string; + }>; + }; + } { + const servers = Array.from(this.runningServers.values()).map((s) => ({ + worktreePath: s.worktreePath, + port: s.port, + url: s.url, + })); + + return { + success: true, + result: { servers }, + }; + } + + /** + * Check if a worktree has a running dev server + */ + isRunning(worktreePath: string): boolean { + return this.runningServers.has(worktreePath); + } + + /** + * Get info for a specific worktree's dev server + */ + getServerInfo(worktreePath: string): DevServerInfo | undefined { + return this.runningServers.get(worktreePath); + } + + /** + * Get all allocated ports + */ + getAllocatedPorts(): number[] { + return Array.from(this.allocatedPorts); + } + + /** + * Stop all running dev servers (for cleanup) + */ + async stopAll(): Promise { + console.log(`[DevServerService] Stopping all ${this.runningServers.size} dev servers`); + + for (const [worktreePath] of this.runningServers) { + await this.stopDevServer(worktreePath); + } + } +} + +// Singleton instance +let devServerServiceInstance: DevServerService | null = null; + +export function getDevServerService(): DevServerService { + if (!devServerServiceInstance) { + devServerServiceInstance = new DevServerService(); + } + return devServerServiceInstance; +} + +// Cleanup on process exit +process.on("SIGTERM", async () => { + if (devServerServiceInstance) { + await devServerServiceInstance.stopAll(); + } +}); + +process.on("SIGINT", async () => { + if (devServerServiceInstance) { + await devServerServiceInstance.stopAll(); + } +}); diff --git a/apps/server/src/services/feature-loader.ts b/apps/server/src/services/feature-loader.ts index 9a90c6eb..578253b4 100644 --- a/apps/server/src/services/feature-loader.ts +++ b/apps/server/src/services/feature-loader.ts @@ -1,10 +1,18 @@ /** * Feature Loader - Handles loading and managing features from individual feature folders - * Each feature is stored in .automaker/features/{featureId}/feature.json + * Each feature is stored in external automaker storage: ~/.automaker/projects/{project-id}/features/{featureId}/feature.json + * + * Features are stored outside the git repo to avoid worktree conflicts. */ import path from "path"; import fs from "fs/promises"; +import { + getFeaturesDir, + getFeatureDir, + getFeatureImagesDir, + ensureAutomakerDir, +} from "../lib/automaker-paths.js"; export interface Feature { id: string; @@ -21,15 +29,18 @@ export class FeatureLoader { /** * Get the features directory path */ - getFeaturesDir(projectPath: string): string { - return path.join(projectPath, ".automaker", "features"); + async getFeaturesDir(projectPath: string): Promise { + return getFeaturesDir(projectPath); } /** * Get the images directory path for a feature */ - getFeatureImagesDir(projectPath: string, featureId: string): string { - return path.join(this.getFeatureDir(projectPath, featureId), "images"); + async getFeatureImagesDir( + projectPath: string, + featureId: string + ): Promise { + return getFeatureImagesDir(projectPath, featureId); } /** @@ -56,15 +67,15 @@ export class FeatureLoader { for (const oldPath of oldPathSet) { if (!newPathSet.has(oldPath)) { try { - const fullPath = path.isAbsolute(oldPath) - ? oldPath - : path.join(projectPath, oldPath); - - await fs.unlink(fullPath); + // Paths are now absolute + await fs.unlink(oldPath); console.log(`[FeatureLoader] Deleted orphaned image: ${oldPath}`); } catch (error) { // Ignore errors when deleting (file may already be gone) - console.warn(`[FeatureLoader] Failed to delete image: ${oldPath}`, error); + console.warn( + `[FeatureLoader] Failed to delete image: ${oldPath}`, + error + ); } } } @@ -77,21 +88,28 @@ export class FeatureLoader { projectPath: string, featureId: string, imagePaths?: Array - ): Promise | undefined> { + ): Promise< + Array | undefined + > { if (!imagePaths || imagePaths.length === 0) { return imagePaths; } - const featureImagesDir = this.getFeatureImagesDir(projectPath, featureId); + const featureImagesDir = await this.getFeatureImagesDir( + projectPath, + featureId + ); await fs.mkdir(featureImagesDir, { recursive: true }); - const updatedPaths: Array = []; + const updatedPaths: Array = + []; for (const imagePath of imagePaths) { try { - const originalPath = typeof imagePath === "string" ? imagePath : imagePath.path; + const originalPath = + typeof imagePath === "string" ? imagePath : imagePath.path; - // Skip if already in feature directory + // Skip if already in feature directory (already absolute path in external storage) if (originalPath.includes(`/features/${featureId}/images/`)) { updatedPaths.push(imagePath); continue; @@ -106,18 +124,21 @@ export class FeatureLoader { try { await fs.access(fullOriginalPath); } catch { - console.warn(`[FeatureLoader] Image not found, skipping: ${fullOriginalPath}`); + console.warn( + `[FeatureLoader] Image not found, skipping: ${fullOriginalPath}` + ); continue; } - // Get filename and create new path + // Get filename and create new path in external storage const filename = path.basename(originalPath); const newPath = path.join(featureImagesDir, filename); - const relativePath = `.automaker/features/${featureId}/images/${filename}`; // Copy the file await fs.copyFile(fullOriginalPath, newPath); - console.log(`[FeatureLoader] Copied image: ${originalPath} -> ${relativePath}`); + console.log( + `[FeatureLoader] Copied image: ${originalPath} -> ${newPath}` + ); // Try to delete the original temp file try { @@ -126,11 +147,11 @@ export class FeatureLoader { // Ignore errors when deleting temp file } - // Update the path in the result + // Update the path in the result (use absolute path) if (typeof imagePath === "string") { - updatedPaths.push(relativePath); + updatedPaths.push(newPath); } else { - updatedPaths.push({ ...imagePath, path: relativePath }); + updatedPaths.push({ ...imagePath, path: newPath }); } } catch (error) { console.error(`[FeatureLoader] Failed to migrate image:`, error); @@ -145,22 +166,30 @@ export class FeatureLoader { /** * Get the path to a specific feature folder */ - getFeatureDir(projectPath: string, featureId: string): string { - return path.join(this.getFeaturesDir(projectPath), featureId); + async getFeatureDir(projectPath: string, featureId: string): Promise { + return getFeatureDir(projectPath, featureId); } /** * Get the path to a feature's feature.json file */ - getFeatureJsonPath(projectPath: string, featureId: string): string { - return path.join(this.getFeatureDir(projectPath, featureId), "feature.json"); + async getFeatureJsonPath( + projectPath: string, + featureId: string + ): Promise { + const featureDir = await this.getFeatureDir(projectPath, featureId); + return path.join(featureDir, "feature.json"); } /** * Get the path to a feature's agent-output.md file */ - getAgentOutputPath(projectPath: string, featureId: string): string { - return path.join(this.getFeatureDir(projectPath, featureId), "agent-output.md"); + async getAgentOutputPath( + projectPath: string, + featureId: string + ): Promise { + const featureDir = await this.getFeatureDir(projectPath, featureId); + return path.join(featureDir, "agent-output.md"); } /** @@ -175,7 +204,7 @@ export class FeatureLoader { */ async getAll(projectPath: string): Promise { try { - const featuresDir = this.getFeaturesDir(projectPath); + const featuresDir = await this.getFeaturesDir(projectPath); // Check if features directory exists try { @@ -192,7 +221,10 @@ export class FeatureLoader { const features: Feature[] = []; for (const dir of featureDirs) { const featureId = dir.name; - const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId); + const featureJsonPath = await this.getFeatureJsonPath( + projectPath, + featureId + ); try { const content = await fs.readFile(featureJsonPath, "utf-8"); @@ -241,14 +273,20 @@ export class FeatureLoader { */ async get(projectPath: string, featureId: string): Promise { try { - const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId); + const featureJsonPath = await this.getFeatureJsonPath( + projectPath, + featureId + ); const content = await fs.readFile(featureJsonPath, "utf-8"); return JSON.parse(content); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") { return null; } - console.error(`[FeatureLoader] Failed to get feature ${featureId}:`, error); + console.error( + `[FeatureLoader] Failed to get feature ${featureId}:`, + error + ); throw error; } } @@ -256,14 +294,16 @@ export class FeatureLoader { /** * Create a new feature */ - async create(projectPath: string, featureData: Partial): Promise { + async create( + projectPath: string, + featureData: Partial + ): Promise { const featureId = featureData.id || this.generateFeatureId(); - const featureDir = this.getFeatureDir(projectPath, featureId); - const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId); + const featureDir = await this.getFeatureDir(projectPath, featureId); + const featureJsonPath = await this.getFeatureJsonPath(projectPath, featureId); - // Ensure features directory exists - const featuresDir = this.getFeaturesDir(projectPath); - await fs.mkdir(featuresDir, { recursive: true }); + // Ensure automaker directory exists + await ensureAutomakerDir(projectPath); // Create feature directory await fs.mkdir(featureDir, { recursive: true }); @@ -285,7 +325,11 @@ export class FeatureLoader { }; // Write feature.json - await fs.writeFile(featureJsonPath, JSON.stringify(feature, null, 2), "utf-8"); + await fs.writeFile( + featureJsonPath, + JSON.stringify(feature, null, 2), + "utf-8" + ); console.log(`[FeatureLoader] Created feature ${featureId}`); return feature; @@ -326,11 +370,13 @@ export class FeatureLoader { const updatedFeature: Feature = { ...feature, ...updates, - ...(updatedImagePaths !== undefined ? { imagePaths: updatedImagePaths } : {}), + ...(updatedImagePaths !== undefined + ? { imagePaths: updatedImagePaths } + : {}), }; // Write back to file - const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId); + const featureJsonPath = await this.getFeatureJsonPath(projectPath, featureId); await fs.writeFile( featureJsonPath, JSON.stringify(updatedFeature, null, 2), @@ -346,12 +392,15 @@ export class FeatureLoader { */ async delete(projectPath: string, featureId: string): Promise { try { - const featureDir = this.getFeatureDir(projectPath, featureId); + const featureDir = await this.getFeatureDir(projectPath, featureId); await fs.rm(featureDir, { recursive: true, force: true }); console.log(`[FeatureLoader] Deleted feature ${featureId}`); return true; } catch (error) { - console.error(`[FeatureLoader] Failed to delete feature ${featureId}:`, error); + console.error( + `[FeatureLoader] Failed to delete feature ${featureId}:`, + error + ); return false; } } @@ -364,7 +413,10 @@ export class FeatureLoader { featureId: string ): Promise { try { - const agentOutputPath = this.getAgentOutputPath(projectPath, featureId); + const agentOutputPath = await this.getAgentOutputPath( + projectPath, + featureId + ); const content = await fs.readFile(agentOutputPath, "utf-8"); return content; } catch (error) { @@ -387,19 +439,25 @@ export class FeatureLoader { featureId: string, content: string ): Promise { - const featureDir = this.getFeatureDir(projectPath, featureId); + const featureDir = await this.getFeatureDir(projectPath, featureId); await fs.mkdir(featureDir, { recursive: true }); - const agentOutputPath = this.getAgentOutputPath(projectPath, featureId); + const agentOutputPath = await this.getAgentOutputPath(projectPath, featureId); await fs.writeFile(agentOutputPath, content, "utf-8"); } /** * Delete agent output for a feature */ - async deleteAgentOutput(projectPath: string, featureId: string): Promise { + async deleteAgentOutput( + projectPath: string, + featureId: string + ): Promise { try { - const agentOutputPath = this.getAgentOutputPath(projectPath, featureId); + const agentOutputPath = await this.getAgentOutputPath( + projectPath, + featureId + ); await fs.unlink(agentOutputPath); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") {