mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +00:00
Implement branch selection and worktree management features
- Added a new BranchAutocomplete component for selecting branches in feature dialogs. - Enhanced BoardView to fetch and display branch suggestions. - Updated CreateWorktreeDialog and EditFeatureDialog to include branch selection. - Modified worktree management to ensure proper handling of branch-specific worktrees. - Refactored related components and hooks to support the new branch management functionality. - Removed unused revert and merge handlers from Kanban components for cleaner code.
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -12,4 +12,5 @@ dist/
|
|||||||
/.automaker/*
|
/.automaker/*
|
||||||
/.automaker/
|
/.automaker/
|
||||||
|
|
||||||
/logs
|
/logs
|
||||||
|
.worktrees/
|
||||||
Submodule .worktrees/feature-model-select deleted from b95c54a539
151
apps/app/src/components/ui/branch-autocomplete.tsx
Normal file
151
apps/app/src/components/ui/branch-autocomplete.tsx
Normal file
@@ -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 (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={open}
|
||||||
|
disabled={disabled}
|
||||||
|
className={cn("w-full justify-between font-mono text-sm", className)}
|
||||||
|
data-testid={testId}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2 truncate">
|
||||||
|
<GitBranch className="w-4 h-4 shrink-0 text-muted-foreground" />
|
||||||
|
{value || placeholder}
|
||||||
|
</span>
|
||||||
|
<ChevronsUpDown className="opacity-50 shrink-0" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[300px] p-0" data-testid="branch-autocomplete-list">
|
||||||
|
<Command shouldFilter={false}>
|
||||||
|
<CommandInput
|
||||||
|
placeholder="Search or type new branch..."
|
||||||
|
className="h-9"
|
||||||
|
value={inputValue}
|
||||||
|
onValueChange={setInputValue}
|
||||||
|
/>
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>
|
||||||
|
{inputValue.trim() ? (
|
||||||
|
<div className="py-2 px-3 text-sm">
|
||||||
|
Press enter to create{" "}
|
||||||
|
<code className="bg-muted px-1 rounded">{inputValue}</code>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
"No branches found."
|
||||||
|
)}
|
||||||
|
</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{/* Show "Create new" option if typing a new branch name */}
|
||||||
|
{isNewBranch && (
|
||||||
|
<CommandItem
|
||||||
|
value={inputValue}
|
||||||
|
onSelect={() => {
|
||||||
|
onChange(inputValue);
|
||||||
|
setInputValue("");
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
className="text-[var(--status-success)]"
|
||||||
|
data-testid="branch-option-create-new"
|
||||||
|
>
|
||||||
|
<GitBranch className="w-4 h-4 mr-2" />
|
||||||
|
Create "{inputValue}"
|
||||||
|
<span className="ml-auto text-xs text-muted-foreground">
|
||||||
|
(new)
|
||||||
|
</span>
|
||||||
|
</CommandItem>
|
||||||
|
)}
|
||||||
|
{filteredBranches.map((branch) => (
|
||||||
|
<CommandItem
|
||||||
|
key={branch}
|
||||||
|
value={branch}
|
||||||
|
onSelect={(currentValue) => {
|
||||||
|
onChange(currentValue);
|
||||||
|
setInputValue("");
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
data-testid={`branch-option-${branch.replace(/[/\\]/g, "-")}`}
|
||||||
|
>
|
||||||
|
<GitBranch className="w-4 h-4 mr-2" />
|
||||||
|
{branch}
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"ml-auto",
|
||||||
|
value === branch ? "opacity-100" : "opacity-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{branch === "main" && (
|
||||||
|
<span className="ml-2 text-xs text-muted-foreground">
|
||||||
|
(default)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -63,6 +63,7 @@ export function BoardView() {
|
|||||||
specCreatingForProject,
|
specCreatingForProject,
|
||||||
setSpecCreatingForProject,
|
setSpecCreatingForProject,
|
||||||
getCurrentWorktree,
|
getCurrentWorktree,
|
||||||
|
setCurrentWorktree,
|
||||||
} = useAppStore();
|
} = useAppStore();
|
||||||
const shortcuts = useKeyboardShortcutsConfig();
|
const shortcuts = useKeyboardShortcutsConfig();
|
||||||
const {
|
const {
|
||||||
@@ -208,6 +209,40 @@ export function BoardView() {
|
|||||||
return [...new Set(allCategories)].sort();
|
return [...new Set(allCategories)].sort();
|
||||||
}, [hookFeatures, persistedCategories]);
|
}, [hookFeatures, persistedCategories]);
|
||||||
|
|
||||||
|
// Branch suggestions for the branch autocomplete
|
||||||
|
const [branchSuggestions, setBranchSuggestions] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// 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
|
// Custom collision detection that prioritizes columns over cards
|
||||||
const collisionDetectionStrategy = useCallback(
|
const collisionDetectionStrategy = useCallback(
|
||||||
(args: any) => {
|
(args: any) => {
|
||||||
@@ -287,6 +322,8 @@ export function BoardView() {
|
|||||||
setShowFollowUpDialog,
|
setShowFollowUpDialog,
|
||||||
inProgressFeaturesForShortcuts,
|
inProgressFeaturesForShortcuts,
|
||||||
outputFeature,
|
outputFeature,
|
||||||
|
projectPath: currentProject?.path || null,
|
||||||
|
onWorktreeCreated: () => setWorktreeRefreshKey((k) => k + 1),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Use keyboard shortcuts hook (after actions hook)
|
// Use keyboard shortcuts hook (after actions hook)
|
||||||
@@ -299,8 +336,12 @@ export function BoardView() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Use drag and drop hook
|
// 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 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({
|
const { activeFeature, handleDragStart, handleDragEnd } = useBoardDragDrop({
|
||||||
features: hookFeatures,
|
features: hookFeatures,
|
||||||
@@ -308,8 +349,8 @@ export function BoardView() {
|
|||||||
runningAutoTasks,
|
runningAutoTasks,
|
||||||
persistFeatureUpdate,
|
persistFeatureUpdate,
|
||||||
handleStartImplementation,
|
handleStartImplementation,
|
||||||
currentWorktreePath,
|
|
||||||
projectPath: currentProject?.path || null,
|
projectPath: currentProject?.path || null,
|
||||||
|
onWorktreeCreated: () => setWorktreeRefreshKey((k) => k + 1),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Use column features hook
|
// Use column features hook
|
||||||
@@ -318,6 +359,7 @@ export function BoardView() {
|
|||||||
runningAutoTasks,
|
runningAutoTasks,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
currentWorktreePath,
|
currentWorktreePath,
|
||||||
|
currentWorktreeBranch,
|
||||||
projectPath: currentProject?.path || null,
|
projectPath: currentProject?.path || null,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -372,7 +414,7 @@ export function BoardView() {
|
|||||||
|
|
||||||
{/* Worktree Selector */}
|
{/* Worktree Selector */}
|
||||||
<WorktreeSelector
|
<WorktreeSelector
|
||||||
key={worktreeRefreshKey}
|
refreshTrigger={worktreeRefreshKey}
|
||||||
projectPath={currentProject.path}
|
projectPath={currentProject.path}
|
||||||
onCreateWorktree={() => setShowCreateWorktreeDialog(true)}
|
onCreateWorktree={() => setShowCreateWorktreeDialog(true)}
|
||||||
onDeleteWorktree={(worktree) => {
|
onDeleteWorktree={(worktree) => {
|
||||||
@@ -391,6 +433,8 @@ export function BoardView() {
|
|||||||
setSelectedWorktreeForAction(worktree);
|
setSelectedWorktreeForAction(worktree);
|
||||||
setShowCreateBranchDialog(true);
|
setShowCreateBranchDialog(true);
|
||||||
}}
|
}}
|
||||||
|
runningFeatureIds={runningAutoTasks}
|
||||||
|
features={hookFeatures.map(f => ({ id: f.id, worktreePath: f.worktreePath }))}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Main Content Area */}
|
{/* Main Content Area */}
|
||||||
@@ -435,8 +479,6 @@ export function BoardView() {
|
|||||||
onMoveBackToInProgress={handleMoveBackToInProgress}
|
onMoveBackToInProgress={handleMoveBackToInProgress}
|
||||||
onFollowUp={handleOpenFollowUp}
|
onFollowUp={handleOpenFollowUp}
|
||||||
onCommit={handleCommitFeature}
|
onCommit={handleCommitFeature}
|
||||||
onRevert={handleRevertFeature}
|
|
||||||
onMerge={handleMergeFeature}
|
|
||||||
onComplete={handleCompleteFeature}
|
onComplete={handleCompleteFeature}
|
||||||
onImplement={handleStartImplementation}
|
onImplement={handleStartImplementation}
|
||||||
featuresWithContext={featuresWithContext}
|
featuresWithContext={featuresWithContext}
|
||||||
@@ -482,6 +524,7 @@ export function BoardView() {
|
|||||||
onOpenChange={setShowAddDialog}
|
onOpenChange={setShowAddDialog}
|
||||||
onAdd={handleAddFeature}
|
onAdd={handleAddFeature}
|
||||||
categorySuggestions={categorySuggestions}
|
categorySuggestions={categorySuggestions}
|
||||||
|
branchSuggestions={branchSuggestions}
|
||||||
defaultSkipTests={defaultSkipTests}
|
defaultSkipTests={defaultSkipTests}
|
||||||
isMaximized={isMaximized}
|
isMaximized={isMaximized}
|
||||||
showProfilesOnly={showProfilesOnly}
|
showProfilesOnly={showProfilesOnly}
|
||||||
@@ -494,6 +537,7 @@ export function BoardView() {
|
|||||||
onClose={() => setEditingFeature(null)}
|
onClose={() => setEditingFeature(null)}
|
||||||
onUpdate={handleUpdateFeature}
|
onUpdate={handleUpdateFeature}
|
||||||
categorySuggestions={categorySuggestions}
|
categorySuggestions={categorySuggestions}
|
||||||
|
branchSuggestions={branchSuggestions}
|
||||||
isMaximized={isMaximized}
|
isMaximized={isMaximized}
|
||||||
showProfilesOnly={showProfilesOnly}
|
showProfilesOnly={showProfilesOnly}
|
||||||
aiProfiles={aiProfiles}
|
aiProfiles={aiProfiles}
|
||||||
@@ -551,7 +595,11 @@ export function BoardView() {
|
|||||||
open={showCreateWorktreeDialog}
|
open={showCreateWorktreeDialog}
|
||||||
onOpenChange={setShowCreateWorktreeDialog}
|
onOpenChange={setShowCreateWorktreeDialog}
|
||||||
projectPath={currentProject.path}
|
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 */}
|
{/* Delete Worktree Dialog */}
|
||||||
|
|||||||
@@ -52,8 +52,6 @@ import {
|
|||||||
MoreVertical,
|
MoreVertical,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
Undo2,
|
|
||||||
GitMerge,
|
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
Brain,
|
Brain,
|
||||||
@@ -103,8 +101,6 @@ interface KanbanCardProps {
|
|||||||
onMoveBackToInProgress?: () => void;
|
onMoveBackToInProgress?: () => void;
|
||||||
onFollowUp?: () => void;
|
onFollowUp?: () => void;
|
||||||
onCommit?: () => void;
|
onCommit?: () => void;
|
||||||
onRevert?: () => void;
|
|
||||||
onMerge?: () => void;
|
|
||||||
onImplement?: () => void;
|
onImplement?: () => void;
|
||||||
onComplete?: () => void;
|
onComplete?: () => void;
|
||||||
hasContext?: boolean;
|
hasContext?: boolean;
|
||||||
@@ -130,8 +126,6 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
onMoveBackToInProgress,
|
onMoveBackToInProgress,
|
||||||
onFollowUp,
|
onFollowUp,
|
||||||
onCommit,
|
onCommit,
|
||||||
onRevert,
|
|
||||||
onMerge,
|
|
||||||
onImplement,
|
onImplement,
|
||||||
onComplete,
|
onComplete,
|
||||||
hasContext,
|
hasContext,
|
||||||
@@ -146,7 +140,6 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
}: KanbanCardProps) {
|
}: KanbanCardProps) {
|
||||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||||
const [isSummaryDialogOpen, setIsSummaryDialogOpen] = useState(false);
|
const [isSummaryDialogOpen, setIsSummaryDialogOpen] = useState(false);
|
||||||
const [isRevertDialogOpen, setIsRevertDialogOpen] = useState(false);
|
|
||||||
const [agentInfo, setAgentInfo] = useState<AgentTaskInfo | null>(null);
|
const [agentInfo, setAgentInfo] = useState<AgentTaskInfo | null>(null);
|
||||||
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false);
|
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false);
|
||||||
const [currentTime, setCurrentTime] = useState(() => Date.now());
|
const [currentTime, setCurrentTime] = useState(() => Date.now());
|
||||||
@@ -621,6 +614,16 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent className="p-3 pt-0">
|
<CardContent className="p-3 pt-0">
|
||||||
|
{/* Target Branch Display */}
|
||||||
|
{feature.branchName && (
|
||||||
|
<div className="mb-2 flex items-center gap-1.5 text-[11px] text-muted-foreground">
|
||||||
|
<GitBranch className="w-3 h-3 shrink-0" />
|
||||||
|
<span className="font-mono truncate" title={feature.branchName}>
|
||||||
|
{feature.branchName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Steps Preview */}
|
{/* Steps Preview */}
|
||||||
{showSteps && feature.steps && feature.steps.length > 0 && (
|
{showSteps && feature.steps && feature.steps.length > 0 && (
|
||||||
<div className="mb-3 space-y-1.5">
|
<div className="mb-3 space-y-1.5">
|
||||||
@@ -953,30 +956,6 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
)}
|
)}
|
||||||
{!isCurrentAutoTask && feature.status === "waiting_approval" && (
|
{!isCurrentAutoTask && feature.status === "waiting_approval" && (
|
||||||
<>
|
<>
|
||||||
{hasWorktree && onRevert && (
|
|
||||||
<TooltipProvider delayDuration={300}>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-7 w-7 p-0 text-[var(--status-error)] hover:text-[var(--status-error)] hover:bg-[var(--status-error-bg)] shrink-0"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setIsRevertDialogOpen(true);
|
|
||||||
}}
|
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
|
||||||
data-testid={`revert-${feature.id}`}
|
|
||||||
>
|
|
||||||
<Undo2 className="w-3.5 h-3.5" />
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="top" className="text-xs">
|
|
||||||
<p>Revert changes</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
)}
|
|
||||||
{/* Refine prompt button */}
|
{/* Refine prompt button */}
|
||||||
{onFollowUp && (
|
{onFollowUp && (
|
||||||
<Button
|
<Button
|
||||||
@@ -994,24 +973,7 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
<span className="truncate">Refine</span>
|
<span className="truncate">Refine</span>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{hasWorktree && onMerge && (
|
{onCommit && (
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
size="sm"
|
|
||||||
className="flex-1 h-7 text-[11px] bg-[var(--status-info)] hover:bg-[var(--status-info)]/90 min-w-0"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onMerge();
|
|
||||||
}}
|
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
|
||||||
data-testid={`merge-${feature.id}`}
|
|
||||||
title="Merge changes into main branch"
|
|
||||||
>
|
|
||||||
<GitMerge className="w-3 h-3 mr-1 shrink-0" />
|
|
||||||
<span className="truncate">Merge</span>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{!hasWorktree && onCommit && (
|
|
||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -1121,53 +1083,6 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{/* Revert Confirmation Dialog */}
|
|
||||||
<Dialog open={isRevertDialogOpen} onOpenChange={setIsRevertDialogOpen}>
|
|
||||||
<DialogContent data-testid="revert-confirmation-dialog">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="flex items-center gap-2 text-[var(--status-error)]">
|
|
||||||
<Undo2 className="w-5 h-5" />
|
|
||||||
Revert Changes
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
This will discard all changes made by the agent and move the
|
|
||||||
feature back to the backlog.
|
|
||||||
{feature.branchName && (
|
|
||||||
<span className="block mt-2 font-medium">
|
|
||||||
Branch{" "}
|
|
||||||
<code className="bg-muted px-1.5 py-0.5 rounded text-[11px]">
|
|
||||||
{feature.branchName}
|
|
||||||
</code>{" "}
|
|
||||||
will be deleted.
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span className="block mt-2 text-[var(--status-error)] font-medium">
|
|
||||||
This action cannot be undone.
|
|
||||||
</span>
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => setIsRevertDialogOpen(false)}
|
|
||||||
data-testid="cancel-revert-button"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
onClick={() => {
|
|
||||||
setIsRevertDialogOpen(false);
|
|
||||||
onRevert?.();
|
|
||||||
}}
|
|
||||||
data-testid="confirm-revert-button"
|
|
||||||
>
|
|
||||||
<Undo2 className="w-4 h-4 mr-2" />
|
|
||||||
Revert Changes
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ import {
|
|||||||
GitBranchPlus,
|
GitBranchPlus,
|
||||||
Check,
|
Check,
|
||||||
Search,
|
Search,
|
||||||
|
Play,
|
||||||
|
Square,
|
||||||
|
Globe,
|
||||||
|
Loader2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useAppStore } from "@/store/app-store";
|
import { useAppStore } from "@/store/app-store";
|
||||||
import { getElectronAPI } from "@/lib/electron";
|
import { getElectronAPI } from "@/lib/electron";
|
||||||
@@ -37,6 +41,8 @@ interface WorktreeInfo {
|
|||||||
path: string;
|
path: string;
|
||||||
branch: string;
|
branch: string;
|
||||||
isMain: boolean;
|
isMain: boolean;
|
||||||
|
isCurrent: boolean; // Is this the currently checked out branch?
|
||||||
|
hasWorktree: boolean; // Does this branch have an active worktree?
|
||||||
hasChanges?: boolean;
|
hasChanges?: boolean;
|
||||||
changedFilesCount?: number;
|
changedFilesCount?: number;
|
||||||
}
|
}
|
||||||
@@ -47,6 +53,17 @@ interface BranchInfo {
|
|||||||
isRemote: boolean;
|
isRemote: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DevServerInfo {
|
||||||
|
worktreePath: string;
|
||||||
|
port: number;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FeatureInfo {
|
||||||
|
id: string;
|
||||||
|
worktreePath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface WorktreeSelectorProps {
|
interface WorktreeSelectorProps {
|
||||||
projectPath: string;
|
projectPath: string;
|
||||||
onCreateWorktree: () => void;
|
onCreateWorktree: () => void;
|
||||||
@@ -54,6 +71,10 @@ interface WorktreeSelectorProps {
|
|||||||
onCommit: (worktree: WorktreeInfo) => void;
|
onCommit: (worktree: WorktreeInfo) => void;
|
||||||
onCreatePR: (worktree: WorktreeInfo) => void;
|
onCreatePR: (worktree: WorktreeInfo) => void;
|
||||||
onCreateBranch: (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({
|
export function WorktreeSelector({
|
||||||
@@ -63,17 +84,23 @@ export function WorktreeSelector({
|
|||||||
onCommit,
|
onCommit,
|
||||||
onCreatePR,
|
onCreatePR,
|
||||||
onCreateBranch,
|
onCreateBranch,
|
||||||
|
runningFeatureIds = [],
|
||||||
|
features = [],
|
||||||
|
refreshTrigger = 0,
|
||||||
}: WorktreeSelectorProps) {
|
}: WorktreeSelectorProps) {
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [isPulling, setIsPulling] = useState(false);
|
const [isPulling, setIsPulling] = useState(false);
|
||||||
const [isPushing, setIsPushing] = useState(false);
|
const [isPushing, setIsPushing] = useState(false);
|
||||||
const [isSwitching, setIsSwitching] = useState(false);
|
const [isSwitching, setIsSwitching] = useState(false);
|
||||||
|
const [isActivating, setIsActivating] = useState(false);
|
||||||
|
const [isStartingDevServer, setIsStartingDevServer] = useState(false);
|
||||||
const [worktrees, setWorktrees] = useState<WorktreeInfo[]>([]);
|
const [worktrees, setWorktrees] = useState<WorktreeInfo[]>([]);
|
||||||
const [branches, setBranches] = useState<BranchInfo[]>([]);
|
const [branches, setBranches] = useState<BranchInfo[]>([]);
|
||||||
const [aheadCount, setAheadCount] = useState(0);
|
const [aheadCount, setAheadCount] = useState(0);
|
||||||
const [behindCount, setBehindCount] = useState(0);
|
const [behindCount, setBehindCount] = useState(0);
|
||||||
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
|
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
|
||||||
const [branchFilter, setBranchFilter] = useState("");
|
const [branchFilter, setBranchFilter] = useState("");
|
||||||
|
const [runningDevServers, setRunningDevServers] = useState<Map<string, DevServerInfo>>(new Map());
|
||||||
const currentWorktree = useAppStore((s) => s.getCurrentWorktree(projectPath));
|
const currentWorktree = useAppStore((s) => s.getCurrentWorktree(projectPath));
|
||||||
const setCurrentWorktree = useAppStore((s) => s.setCurrentWorktree);
|
const setCurrentWorktree = useAppStore((s) => s.setCurrentWorktree);
|
||||||
const setWorktreesInStore = useAppStore((s) => s.setWorktrees);
|
const setWorktreesInStore = useAppStore((s) => s.setWorktrees);
|
||||||
@@ -99,6 +126,25 @@ export function WorktreeSelector({
|
|||||||
}
|
}
|
||||||
}, [projectPath, setWorktreesInStore]);
|
}, [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<string, DevServerInfo>();
|
||||||
|
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) => {
|
const fetchBranches = useCallback(async (worktreePath: string) => {
|
||||||
setIsLoadingBranches(true);
|
setIsLoadingBranches(true);
|
||||||
try {
|
try {
|
||||||
@@ -122,12 +168,198 @@ export function WorktreeSelector({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchWorktrees();
|
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);
|
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) => {
|
const handleSwitchBranch = async (worktree: WorktreeInfo, branchName: string) => {
|
||||||
if (isSwitching || branchName === worktree.branch) return;
|
if (isSwitching || branchName === worktree.branch) return;
|
||||||
setIsSwitching(true);
|
setIsSwitching(true);
|
||||||
@@ -204,10 +436,11 @@ export function WorktreeSelector({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectedWorktree =
|
// The "selected" worktree is based on UI state, not git's current branch
|
||||||
worktrees.find((w) =>
|
// currentWorktree is null for main, or the worktree path for others
|
||||||
currentWorktree ? w.path === currentWorktree : w.isMain
|
const selectedWorktree = currentWorktree
|
||||||
) || worktrees.find((w) => w.isMain);
|
? worktrees.find((w) => w.path === currentWorktree)
|
||||||
|
: worktrees.find((w) => w.isMain);
|
||||||
|
|
||||||
if (worktrees.length === 0 && !isLoading) {
|
if (worktrees.length === 0 && !isLoading) {
|
||||||
// No git repo or loading
|
// No git repo or loading
|
||||||
@@ -216,116 +449,146 @@ export function WorktreeSelector({
|
|||||||
|
|
||||||
// Render a worktree tab with branch selector (for main) and actions dropdown
|
// Render a worktree tab with branch selector (for main) and actions dropdown
|
||||||
const renderWorktreeTab = (worktree: WorktreeInfo) => {
|
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 (
|
return (
|
||||||
<div key={worktree.path} className="flex items-center">
|
<div key={worktree.path} className="flex items-center">
|
||||||
{/* Branch name - clickable dropdown for main repo to switch branches */}
|
{/* Main branch: clickable button + separate branch switch dropdown */}
|
||||||
{worktree.isMain ? (
|
{worktree.isMain ? (
|
||||||
<DropdownMenu onOpenChange={(open) => {
|
<>
|
||||||
if (open) {
|
{/* Clickable button to select/preview main */}
|
||||||
// Select this worktree when opening the dropdown
|
<Button
|
||||||
if (!isSelected) {
|
variant={isSelected ? "default" : "outline"}
|
||||||
handleSelectWorktree(worktree);
|
size="sm"
|
||||||
|
className={cn(
|
||||||
|
"h-7 px-3 text-xs font-mono gap-1.5 border-r-0 rounded-l-md rounded-r-none",
|
||||||
|
isSelected && "bg-primary text-primary-foreground",
|
||||||
|
!isSelected && "bg-secondary/50 hover:bg-secondary"
|
||||||
|
)}
|
||||||
|
onClick={() => handleSelectWorktree(worktree)}
|
||||||
|
disabled={isActivating}
|
||||||
|
title="Click to preview main"
|
||||||
|
>
|
||||||
|
{isRunning && <Loader2 className="w-3 h-3 animate-spin" />}
|
||||||
|
{isActivating && !isRunning && <RefreshCw className="w-3 h-3 animate-spin" />}
|
||||||
|
{worktree.branch}
|
||||||
|
{worktree.hasChanges && (
|
||||||
|
<span className="inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded bg-background/80 text-foreground border border-border">
|
||||||
|
{worktree.changedFilesCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
{/* Branch switch dropdown button */}
|
||||||
|
<DropdownMenu onOpenChange={(open) => {
|
||||||
|
if (open) {
|
||||||
|
fetchBranches(worktree.path);
|
||||||
|
setBranchFilter("");
|
||||||
}
|
}
|
||||||
fetchBranches(worktree.path);
|
}}>
|
||||||
setBranchFilter("");
|
<DropdownMenuTrigger asChild>
|
||||||
}
|
<Button
|
||||||
}}>
|
variant={isSelected ? "default" : "outline"}
|
||||||
<DropdownMenuTrigger asChild>
|
size="sm"
|
||||||
<Button
|
className={cn(
|
||||||
variant={isSelected ? "default" : "ghost"}
|
"h-7 w-7 p-0 rounded-none border-r-0",
|
||||||
size="sm"
|
isSelected && "bg-primary text-primary-foreground",
|
||||||
className={cn(
|
!isSelected && "bg-secondary/50 hover:bg-secondary"
|
||||||
"h-7 px-3 text-xs font-mono gap-1.5 rounded-r-none",
|
)}
|
||||||
isSelected && "bg-primary text-primary-foreground",
|
title="Switch branch"
|
||||||
!isSelected && "hover:bg-secondary"
|
>
|
||||||
)}
|
<GitBranch className="w-3 h-3" />
|
||||||
>
|
</Button>
|
||||||
<GitBranch className="w-3 h-3" />
|
</DropdownMenuTrigger>
|
||||||
{worktree.branch}
|
<DropdownMenuContent align="start" className="w-64">
|
||||||
{worktree.hasChanges && (
|
<DropdownMenuLabel className="text-xs">Switch Branch</DropdownMenuLabel>
|
||||||
<span className="inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded bg-background/80 text-foreground border border-border">
|
<DropdownMenuSeparator />
|
||||||
{worktree.changedFilesCount}
|
{/* Search input */}
|
||||||
</span>
|
<div className="px-2 py-1.5">
|
||||||
)}
|
<div className="relative">
|
||||||
<ChevronDown className="w-3 h-3 ml-0.5" />
|
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
|
||||||
</Button>
|
<Input
|
||||||
</DropdownMenuTrigger>
|
placeholder="Filter branches..."
|
||||||
<DropdownMenuContent align="start" className="w-64">
|
value={branchFilter}
|
||||||
{/* Search input */}
|
onChange={(e) => setBranchFilter(e.target.value)}
|
||||||
<div className="px-2 py-1.5">
|
onKeyDown={(e) => e.stopPropagation()}
|
||||||
<div className="relative">
|
onKeyUp={(e) => e.stopPropagation()}
|
||||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
|
onKeyPress={(e) => e.stopPropagation()}
|
||||||
<Input
|
className="h-7 pl-7 text-xs"
|
||||||
placeholder="Filter branches..."
|
autoFocus
|
||||||
value={branchFilter}
|
/>
|
||||||
onChange={(e) => setBranchFilter(e.target.value)}
|
</div>
|
||||||
onKeyDown={(e) => e.stopPropagation()}
|
|
||||||
onKeyUp={(e) => e.stopPropagation()}
|
|
||||||
onKeyPress={(e) => e.stopPropagation()}
|
|
||||||
className="h-7 pl-7 text-xs"
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuSeparator />
|
<div className="max-h-[250px] overflow-y-auto">
|
||||||
<div className="max-h-[250px] overflow-y-auto">
|
{isLoadingBranches ? (
|
||||||
{isLoadingBranches ? (
|
<DropdownMenuItem disabled className="text-xs">
|
||||||
<DropdownMenuItem disabled className="text-xs">
|
<RefreshCw className="w-3.5 h-3.5 mr-2 animate-spin" />
|
||||||
<RefreshCw className="w-3.5 h-3.5 mr-2 animate-spin" />
|
Loading branches...
|
||||||
Loading branches...
|
|
||||||
</DropdownMenuItem>
|
|
||||||
) : (() => {
|
|
||||||
const filteredBranches = branches.filter((b) =>
|
|
||||||
b.name.toLowerCase().includes(branchFilter.toLowerCase())
|
|
||||||
);
|
|
||||||
if (filteredBranches.length === 0) {
|
|
||||||
return (
|
|
||||||
<DropdownMenuItem disabled className="text-xs">
|
|
||||||
{branchFilter ? "No matching branches" : "No branches found"}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return filteredBranches.map((branch) => (
|
|
||||||
<DropdownMenuItem
|
|
||||||
key={branch.name}
|
|
||||||
onClick={() => handleSwitchBranch(worktree, branch.name)}
|
|
||||||
disabled={isSwitching || branch.name === worktree.branch}
|
|
||||||
className="text-xs font-mono"
|
|
||||||
>
|
|
||||||
{branch.name === worktree.branch ? (
|
|
||||||
<Check className="w-3.5 h-3.5 mr-2 flex-shrink-0" />
|
|
||||||
) : (
|
|
||||||
<span className="w-3.5 mr-2 flex-shrink-0" />
|
|
||||||
)}
|
|
||||||
<span className="truncate">{branch.name}</span>
|
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
));
|
) : (() => {
|
||||||
})()}
|
const filteredBranches = branches.filter((b) =>
|
||||||
</div>
|
b.name.toLowerCase().includes(branchFilter.toLowerCase())
|
||||||
<DropdownMenuSeparator />
|
);
|
||||||
<DropdownMenuItem
|
if (filteredBranches.length === 0) {
|
||||||
onClick={() => onCreateBranch(worktree)}
|
return (
|
||||||
className="text-xs"
|
<DropdownMenuItem disabled className="text-xs">
|
||||||
>
|
{branchFilter ? "No matching branches" : "No branches found"}
|
||||||
<GitBranchPlus className="w-3.5 h-3.5 mr-2" />
|
</DropdownMenuItem>
|
||||||
Create New Branch...
|
);
|
||||||
</DropdownMenuItem>
|
}
|
||||||
</DropdownMenuContent>
|
return filteredBranches.map((branch) => (
|
||||||
</DropdownMenu>
|
<DropdownMenuItem
|
||||||
|
key={branch.name}
|
||||||
|
onClick={() => handleSwitchBranch(worktree, branch.name)}
|
||||||
|
disabled={isSwitching || branch.name === worktree.branch}
|
||||||
|
className="text-xs font-mono"
|
||||||
|
>
|
||||||
|
{branch.name === worktree.branch ? (
|
||||||
|
<Check className="w-3.5 h-3.5 mr-2 flex-shrink-0" />
|
||||||
|
) : (
|
||||||
|
<span className="w-3.5 mr-2 flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
<span className="truncate">{branch.name}</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
));
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => onCreateBranch(worktree)}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<GitBranchPlus className="w-3.5 h-3.5 mr-2" />
|
||||||
|
Create New Branch...
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
// Non-main worktrees - just show branch name (worktrees are tied to branches)
|
// Non-main branches - click to switch to this branch
|
||||||
<Button
|
<Button
|
||||||
variant={isSelected ? "default" : "ghost"}
|
variant={isSelected ? "default" : "outline"}
|
||||||
size="sm"
|
size="sm"
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-7 px-3 text-xs font-mono gap-1.5 rounded-r-none",
|
"h-7 px-3 text-xs font-mono gap-1.5 rounded-l-md rounded-r-none border-r-0",
|
||||||
isSelected && "bg-primary text-primary-foreground",
|
isSelected && "bg-primary text-primary-foreground",
|
||||||
!isSelected && "hover:bg-secondary"
|
!isSelected && "bg-secondary/50 hover:bg-secondary",
|
||||||
|
!worktree.hasWorktree && !isSelected && "opacity-70" // Dim if no active worktree
|
||||||
)}
|
)}
|
||||||
onClick={() => handleSelectWorktree(worktree)}
|
onClick={() => handleSelectWorktree(worktree)}
|
||||||
|
disabled={isActivating}
|
||||||
|
title={worktree.hasWorktree
|
||||||
|
? "Click to switch to this worktree's branch"
|
||||||
|
: "Click to switch to this branch"}
|
||||||
>
|
>
|
||||||
|
{isRunning && <Loader2 className="w-3 h-3 animate-spin" />}
|
||||||
|
{isActivating && !isRunning && <RefreshCw className="w-3 h-3 animate-spin" />}
|
||||||
{worktree.branch}
|
{worktree.branch}
|
||||||
{worktree.hasChanges && (
|
{worktree.hasChanges && (
|
||||||
<span className="inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded bg-background/80 text-foreground border border-border">
|
<span className="inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded bg-background/80 text-foreground border border-border">
|
||||||
@@ -335,6 +598,24 @@ export function WorktreeSelector({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Dev server indicator */}
|
||||||
|
{runningDevServers.has(getWorktreeKey(worktree)) && (
|
||||||
|
<Button
|
||||||
|
variant={isSelected ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
className={cn(
|
||||||
|
"h-7 w-7 p-0 rounded-none border-r-0",
|
||||||
|
isSelected && "bg-primary text-primary-foreground",
|
||||||
|
!isSelected && "bg-secondary/50 hover:bg-secondary",
|
||||||
|
"text-green-500"
|
||||||
|
)}
|
||||||
|
onClick={() => handleOpenDevServerUrl(worktree)}
|
||||||
|
title={`Open dev server (port ${runningDevServers.get(getWorktreeKey(worktree))?.port})`}
|
||||||
|
>
|
||||||
|
<Globe className="w-3 h-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Actions dropdown */}
|
{/* Actions dropdown */}
|
||||||
<DropdownMenu onOpenChange={(open) => {
|
<DropdownMenu onOpenChange={(open) => {
|
||||||
if (open) {
|
if (open) {
|
||||||
@@ -343,18 +624,54 @@ export function WorktreeSelector({
|
|||||||
}}>
|
}}>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant={isSelected ? "default" : "ghost"}
|
variant={isSelected ? "default" : "outline"}
|
||||||
size="sm"
|
size="sm"
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-7 w-6 p-0 rounded-l-none",
|
"h-7 w-7 p-0 rounded-l-none",
|
||||||
isSelected && "bg-primary text-primary-foreground",
|
isSelected && "bg-primary text-primary-foreground",
|
||||||
!isSelected && "hover:bg-secondary"
|
!isSelected && "bg-secondary/50 hover:bg-secondary"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<MoreHorizontal className="w-3 h-3" />
|
<MoreHorizontal className="w-3 h-3" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="start" className="w-56">
|
<DropdownMenuContent align="start" className="w-56">
|
||||||
|
{/* Dev server controls */}
|
||||||
|
{runningDevServers.has(getWorktreeKey(worktree)) ? (
|
||||||
|
<>
|
||||||
|
<DropdownMenuLabel className="text-xs flex items-center gap-2">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
|
||||||
|
Dev Server Running (:{runningDevServers.get(getWorktreeKey(worktree))?.port})
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => handleOpenDevServerUrl(worktree)}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Globe className="w-3.5 h-3.5 mr-2" />
|
||||||
|
Open in Browser
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => handleStopDevServer(worktree)}
|
||||||
|
className="text-xs text-destructive focus:text-destructive"
|
||||||
|
>
|
||||||
|
<Square className="w-3.5 h-3.5 mr-2" />
|
||||||
|
Stop Dev Server
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => handleStartDevServer(worktree)}
|
||||||
|
disabled={isStartingDevServer}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Play className={cn("w-3.5 h-3.5 mr-2", isStartingDevServer && "animate-pulse")} />
|
||||||
|
{isStartingDevServer ? "Starting..." : "Start Dev Server"}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{/* Pull option */}
|
{/* Pull option */}
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => handlePull(worktree)}
|
onClick={() => handlePull(worktree)}
|
||||||
@@ -384,6 +701,15 @@ export function WorktreeSelector({
|
|||||||
)}
|
)}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
|
{/* Open in editor */}
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => handleOpenInEditor(worktree)}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-3.5 h-3.5 mr-2" />
|
||||||
|
Open in Editor
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
{/* Commit changes */}
|
{/* Commit changes */}
|
||||||
{worktree.hasChanges && (
|
{worktree.hasChanges && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { HotkeyButton } from "@/components/ui/hotkey-button";
|
import { HotkeyButton } from "@/components/ui/hotkey-button";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { CategoryAutocomplete } from "@/components/ui/category-autocomplete";
|
import { CategoryAutocomplete } from "@/components/ui/category-autocomplete";
|
||||||
|
import { BranchAutocomplete } from "@/components/ui/branch-autocomplete";
|
||||||
import {
|
import {
|
||||||
DescriptionImageDropZone,
|
DescriptionImageDropZone,
|
||||||
FeatureImagePath as DescriptionImagePath,
|
FeatureImagePath as DescriptionImagePath,
|
||||||
@@ -47,8 +48,10 @@ interface AddFeatureDialogProps {
|
|||||||
skipTests: boolean;
|
skipTests: boolean;
|
||||||
model: AgentModel;
|
model: AgentModel;
|
||||||
thinkingLevel: ThinkingLevel;
|
thinkingLevel: ThinkingLevel;
|
||||||
|
branchName: string;
|
||||||
}) => void;
|
}) => void;
|
||||||
categorySuggestions: string[];
|
categorySuggestions: string[];
|
||||||
|
branchSuggestions: string[];
|
||||||
defaultSkipTests: boolean;
|
defaultSkipTests: boolean;
|
||||||
isMaximized: boolean;
|
isMaximized: boolean;
|
||||||
showProfilesOnly: boolean;
|
showProfilesOnly: boolean;
|
||||||
@@ -60,6 +63,7 @@ export function AddFeatureDialog({
|
|||||||
onOpenChange,
|
onOpenChange,
|
||||||
onAdd,
|
onAdd,
|
||||||
categorySuggestions,
|
categorySuggestions,
|
||||||
|
branchSuggestions,
|
||||||
defaultSkipTests,
|
defaultSkipTests,
|
||||||
isMaximized,
|
isMaximized,
|
||||||
showProfilesOnly,
|
showProfilesOnly,
|
||||||
@@ -74,6 +78,7 @@ export function AddFeatureDialog({
|
|||||||
skipTests: false,
|
skipTests: false,
|
||||||
model: "opus" as AgentModel,
|
model: "opus" as AgentModel,
|
||||||
thinkingLevel: "none" as ThinkingLevel,
|
thinkingLevel: "none" as ThinkingLevel,
|
||||||
|
branchName: "main",
|
||||||
});
|
});
|
||||||
const [newFeaturePreviewMap, setNewFeaturePreviewMap] =
|
const [newFeaturePreviewMap, setNewFeaturePreviewMap] =
|
||||||
useState<ImagePreviewMap>(() => new Map());
|
useState<ImagePreviewMap>(() => new Map());
|
||||||
@@ -111,6 +116,7 @@ export function AddFeatureDialog({
|
|||||||
skipTests: newFeature.skipTests,
|
skipTests: newFeature.skipTests,
|
||||||
model: selectedModel,
|
model: selectedModel,
|
||||||
thinkingLevel: normalizedThinking,
|
thinkingLevel: normalizedThinking,
|
||||||
|
branchName: newFeature.branchName,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Reset form
|
// Reset form
|
||||||
@@ -123,6 +129,7 @@ export function AddFeatureDialog({
|
|||||||
skipTests: defaultSkipTests,
|
skipTests: defaultSkipTests,
|
||||||
model: "opus",
|
model: "opus",
|
||||||
thinkingLevel: "none",
|
thinkingLevel: "none",
|
||||||
|
branchName: "main",
|
||||||
});
|
});
|
||||||
setNewFeaturePreviewMap(new Map());
|
setNewFeaturePreviewMap(new Map());
|
||||||
setShowAdvancedOptions(false);
|
setShowAdvancedOptions(false);
|
||||||
@@ -237,6 +244,21 @@ export function AddFeatureDialog({
|
|||||||
data-testid="feature-category-input"
|
data-testid="feature-category-input"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="branch">Target Branch</Label>
|
||||||
|
<BranchAutocomplete
|
||||||
|
value={newFeature.branchName}
|
||||||
|
onChange={(value) =>
|
||||||
|
setNewFeature({ ...newFeature, branchName: value })
|
||||||
|
}
|
||||||
|
branches={branchSuggestions}
|
||||||
|
placeholder="Select or create branch..."
|
||||||
|
data-testid="feature-branch-input"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Work will be done in this branch. A worktree will be created if needed.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* Model Tab */}
|
{/* Model Tab */}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ interface CreateWorktreeDialogProps {
|
|||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
projectPath: string;
|
projectPath: string;
|
||||||
onCreated: () => void;
|
onCreated: (worktreePath: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CreateWorktreeDialog({
|
export function CreateWorktreeDialog({
|
||||||
@@ -68,7 +68,7 @@ export function CreateWorktreeDialog({
|
|||||||
: "Using existing branch",
|
: "Using existing branch",
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
onCreated();
|
onCreated(result.worktree.path);
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
setBranchName("");
|
setBranchName("");
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { HotkeyButton } from "@/components/ui/hotkey-button";
|
import { HotkeyButton } from "@/components/ui/hotkey-button";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { CategoryAutocomplete } from "@/components/ui/category-autocomplete";
|
import { CategoryAutocomplete } from "@/components/ui/category-autocomplete";
|
||||||
|
import { BranchAutocomplete } from "@/components/ui/branch-autocomplete";
|
||||||
import {
|
import {
|
||||||
DescriptionImageDropZone,
|
DescriptionImageDropZone,
|
||||||
FeatureImagePath as DescriptionImagePath,
|
FeatureImagePath as DescriptionImagePath,
|
||||||
@@ -47,9 +48,11 @@ interface EditFeatureDialogProps {
|
|||||||
model: AgentModel;
|
model: AgentModel;
|
||||||
thinkingLevel: ThinkingLevel;
|
thinkingLevel: ThinkingLevel;
|
||||||
imagePaths: DescriptionImagePath[];
|
imagePaths: DescriptionImagePath[];
|
||||||
|
branchName: string;
|
||||||
}
|
}
|
||||||
) => void;
|
) => void;
|
||||||
categorySuggestions: string[];
|
categorySuggestions: string[];
|
||||||
|
branchSuggestions: string[];
|
||||||
isMaximized: boolean;
|
isMaximized: boolean;
|
||||||
showProfilesOnly: boolean;
|
showProfilesOnly: boolean;
|
||||||
aiProfiles: AIProfile[];
|
aiProfiles: AIProfile[];
|
||||||
@@ -60,6 +63,7 @@ export function EditFeatureDialog({
|
|||||||
onClose,
|
onClose,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
categorySuggestions,
|
categorySuggestions,
|
||||||
|
branchSuggestions,
|
||||||
isMaximized,
|
isMaximized,
|
||||||
showProfilesOnly,
|
showProfilesOnly,
|
||||||
aiProfiles,
|
aiProfiles,
|
||||||
@@ -93,6 +97,7 @@ export function EditFeatureDialog({
|
|||||||
model: selectedModel,
|
model: selectedModel,
|
||||||
thinkingLevel: normalizedThinking,
|
thinkingLevel: normalizedThinking,
|
||||||
imagePaths: editingFeature.imagePaths ?? [],
|
imagePaths: editingFeature.imagePaths ?? [],
|
||||||
|
branchName: editingFeature.branchName ?? "main",
|
||||||
};
|
};
|
||||||
|
|
||||||
onUpdate(editingFeature.id, updates);
|
onUpdate(editingFeature.id, updates);
|
||||||
@@ -214,6 +219,32 @@ export function EditFeatureDialog({
|
|||||||
data-testid="edit-feature-category"
|
data-testid="edit-feature-category"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="edit-branch">Target Branch</Label>
|
||||||
|
<BranchAutocomplete
|
||||||
|
value={editingFeature.branchName ?? "main"}
|
||||||
|
onChange={(value) =>
|
||||||
|
setEditingFeature({
|
||||||
|
...editingFeature,
|
||||||
|
branchName: value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
branches={branchSuggestions}
|
||||||
|
placeholder="Select or create branch..."
|
||||||
|
data-testid="edit-feature-branch"
|
||||||
|
disabled={editingFeature.status !== "backlog"}
|
||||||
|
/>
|
||||||
|
{editingFeature.status !== "backlog" && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Branch cannot be changed after work has started.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{editingFeature.status === "backlog" && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Work will be done in this branch. A worktree will be created if needed.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* Model Tab */}
|
{/* Model Tab */}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useState } from "react";
|
import { useCallback } from "react";
|
||||||
import { Feature, FeatureImage, AgentModel, ThinkingLevel, useAppStore } from "@/store/app-store";
|
import { Feature, FeatureImage, AgentModel, ThinkingLevel, useAppStore } from "@/store/app-store";
|
||||||
import { FeatureImagePath as DescriptionImagePath } from "@/components/ui/description-image-dropzone";
|
import { FeatureImagePath as DescriptionImagePath } from "@/components/ui/description-image-dropzone";
|
||||||
import { getElectronAPI } from "@/lib/electron";
|
import { getElectronAPI } from "@/lib/electron";
|
||||||
@@ -28,6 +28,8 @@ interface UseBoardActionsProps {
|
|||||||
setShowFollowUpDialog: (show: boolean) => void;
|
setShowFollowUpDialog: (show: boolean) => void;
|
||||||
inProgressFeaturesForShortcuts: Feature[];
|
inProgressFeaturesForShortcuts: Feature[];
|
||||||
outputFeature: Feature | null;
|
outputFeature: Feature | null;
|
||||||
|
projectPath: string | null;
|
||||||
|
onWorktreeCreated?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useBoardActions({
|
export function useBoardActions({
|
||||||
@@ -52,10 +54,67 @@ export function useBoardActions({
|
|||||||
setShowFollowUpDialog,
|
setShowFollowUpDialog,
|
||||||
inProgressFeaturesForShortcuts,
|
inProgressFeaturesForShortcuts,
|
||||||
outputFeature,
|
outputFeature,
|
||||||
|
projectPath,
|
||||||
|
onWorktreeCreated,
|
||||||
}: UseBoardActionsProps) {
|
}: UseBoardActionsProps) {
|
||||||
const { addFeature, updateFeature, removeFeature, moveFeature, useWorktrees } = useAppStore();
|
const { addFeature, updateFeature, removeFeature, moveFeature, useWorktrees } = useAppStore();
|
||||||
const autoMode = useAutoMode();
|
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<string | null> => {
|
||||||
|
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(
|
const handleAddFeature = useCallback(
|
||||||
(featureData: {
|
(featureData: {
|
||||||
category: string;
|
category: string;
|
||||||
@@ -66,6 +125,7 @@ export function useBoardActions({
|
|||||||
skipTests: boolean;
|
skipTests: boolean;
|
||||||
model: AgentModel;
|
model: AgentModel;
|
||||||
thinkingLevel: ThinkingLevel;
|
thinkingLevel: ThinkingLevel;
|
||||||
|
branchName: string;
|
||||||
}) => {
|
}) => {
|
||||||
const newFeatureData = {
|
const newFeatureData = {
|
||||||
...featureData,
|
...featureData,
|
||||||
@@ -89,6 +149,7 @@ export function useBoardActions({
|
|||||||
model: AgentModel;
|
model: AgentModel;
|
||||||
thinkingLevel: ThinkingLevel;
|
thinkingLevel: ThinkingLevel;
|
||||||
imagePaths: DescriptionImagePath[];
|
imagePaths: DescriptionImagePath[];
|
||||||
|
branchName: string;
|
||||||
}
|
}
|
||||||
) => {
|
) => {
|
||||||
updateFeature(featureId, updates);
|
updateFeature(featureId, updates);
|
||||||
@@ -155,14 +216,19 @@ export function useBoardActions({
|
|||||||
return;
|
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(
|
const result = await api.autoMode.runFeature(
|
||||||
currentProject.path,
|
currentProject.path,
|
||||||
feature.id,
|
feature.id,
|
||||||
useWorktrees
|
useWorktrees,
|
||||||
|
featureWorktreePath || undefined
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
console.log("[Board] Feature run started successfully");
|
console.log("[Board] Feature run started successfully in worktree:", featureWorktreePath || "main");
|
||||||
} else {
|
} else {
|
||||||
console.error("[Board] Failed to run feature:", result.error);
|
console.error("[Board] Failed to run feature:", result.error);
|
||||||
await loadFeatures();
|
await loadFeatures();
|
||||||
@@ -327,8 +393,10 @@ export function useBoardActions({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const imagePaths = followUpImagePaths.map((img) => img.path);
|
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
|
api.autoMode
|
||||||
.followUpFeature(currentProject.path, followUpFeature.id, followUpPrompt, imagePaths)
|
.followUpFeature(currentProject.path, followUpFeature.id, followUpPrompt, imagePaths, featureWorktreePath)
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error("[Board] Error sending follow-up:", error);
|
console.error("[Board] Error sending follow-up:", error);
|
||||||
toast.error("Failed to send follow-up", {
|
toast.error("Failed to send follow-up", {
|
||||||
@@ -365,7 +433,8 @@ export function useBoardActions({
|
|||||||
return;
|
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) {
|
if (result.success) {
|
||||||
moveFeature(feature.id, "verified");
|
moveFeature(feature.id, "verified");
|
||||||
@@ -373,6 +442,8 @@ export function useBoardActions({
|
|||||||
toast.success("Feature committed", {
|
toast.success("Feature committed", {
|
||||||
description: `Committed and verified: ${truncateDescription(feature.description)}`,
|
description: `Committed and verified: ${truncateDescription(feature.description)}`,
|
||||||
});
|
});
|
||||||
|
// Refresh worktree selector to update commit counts
|
||||||
|
onWorktreeCreated?.();
|
||||||
} else {
|
} else {
|
||||||
console.error("[Board] Failed to commit feature:", result.error);
|
console.error("[Board] Failed to commit feature:", result.error);
|
||||||
toast.error("Failed to commit feature", {
|
toast.error("Failed to commit feature", {
|
||||||
@@ -388,7 +459,7 @@ export function useBoardActions({
|
|||||||
await loadFeatures();
|
await loadFeatures();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[currentProject, moveFeature, persistFeatureUpdate, loadFeatures]
|
[currentProject, moveFeature, persistFeatureUpdate, loadFeatures, onWorktreeCreated]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleRevertFeature = useCallback(
|
const handleRevertFeature = useCallback(
|
||||||
@@ -565,12 +636,29 @@ export function useBoardActions({
|
|||||||
return;
|
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) {
|
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 handleDeleteAllVerified = useCallback(async () => {
|
||||||
const verifiedFeatures = features.filter((f) => f.status === "verified");
|
const verifiedFeatures = features.filter((f) => f.status === "verified");
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ interface UseBoardColumnFeaturesProps {
|
|||||||
runningAutoTasks: string[];
|
runningAutoTasks: string[];
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
currentWorktreePath: string | null; // Currently selected worktree path
|
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)
|
projectPath: string | null; // Main project path (for main worktree)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -16,6 +17,7 @@ export function useBoardColumnFeatures({
|
|||||||
runningAutoTasks,
|
runningAutoTasks,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
currentWorktreePath,
|
currentWorktreePath,
|
||||||
|
currentWorktreeBranch,
|
||||||
projectPath,
|
projectPath,
|
||||||
}: UseBoardColumnFeaturesProps) {
|
}: UseBoardColumnFeaturesProps) {
|
||||||
// Memoize column features to prevent unnecessary re-renders
|
// Memoize column features to prevent unnecessary re-renders
|
||||||
@@ -38,18 +40,32 @@ export function useBoardColumnFeatures({
|
|||||||
)
|
)
|
||||||
: features;
|
: features;
|
||||||
|
|
||||||
// Determine the effective worktree path for filtering
|
// Determine the effective worktree path and branch for filtering
|
||||||
// If currentWorktreePath is null, we're on the main worktree (use projectPath)
|
// If currentWorktreePath is null, we're on the main worktree
|
||||||
const effectiveWorktreePath = currentWorktreePath || projectPath;
|
const effectiveWorktreePath = currentWorktreePath || projectPath;
|
||||||
|
const effectiveBranch = currentWorktreeBranch || "main";
|
||||||
|
|
||||||
filteredFeatures.forEach((f) => {
|
filteredFeatures.forEach((f) => {
|
||||||
// If feature has a running agent, always show it in "in_progress"
|
// If feature has a running agent, always show it in "in_progress"
|
||||||
const isRunning = runningAutoTasks.includes(f.id);
|
const isRunning = runningAutoTasks.includes(f.id);
|
||||||
|
|
||||||
// Check if feature matches the current worktree
|
// Check if feature matches the current worktree
|
||||||
// Features without a worktreePath are considered unassigned (backlog items)
|
// Match by worktreePath if set, OR by branchName if set
|
||||||
// Features with a worktreePath should only show if it matches the selected worktree
|
// Features with neither are considered unassigned (show on main only)
|
||||||
const matchesWorktree = !f.worktreePath || f.worktreePath === effectiveWorktreePath;
|
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) {
|
if (isRunning) {
|
||||||
// Only show running tasks if they match the current worktree
|
// Only show running tasks if they match the current worktree
|
||||||
@@ -84,7 +100,7 @@ export function useBoardColumnFeatures({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return map;
|
return map;
|
||||||
}, [features, runningAutoTasks, searchQuery, currentWorktreePath, projectPath]);
|
}, [features, runningAutoTasks, searchQuery, currentWorktreePath, currentWorktreeBranch, projectPath]);
|
||||||
|
|
||||||
const getColumnFeatures = useCallback(
|
const getColumnFeatures = useCallback(
|
||||||
(columnId: ColumnId) => {
|
(columnId: ColumnId) => {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Feature } from "@/store/app-store";
|
|||||||
import { useAppStore } from "@/store/app-store";
|
import { useAppStore } from "@/store/app-store";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { COLUMNS, ColumnId } from "../constants";
|
import { COLUMNS, ColumnId } from "../constants";
|
||||||
|
import { getElectronAPI } from "@/lib/electron";
|
||||||
|
|
||||||
interface UseBoardDragDropProps {
|
interface UseBoardDragDropProps {
|
||||||
features: Feature[];
|
features: Feature[];
|
||||||
@@ -14,8 +15,8 @@ interface UseBoardDragDropProps {
|
|||||||
updates: Partial<Feature>
|
updates: Partial<Feature>
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
handleStartImplementation: (feature: Feature) => Promise<boolean>;
|
handleStartImplementation: (feature: Feature) => Promise<boolean>;
|
||||||
currentWorktreePath: string | null; // Currently selected worktree path
|
|
||||||
projectPath: string | null; // Main project path
|
projectPath: string | null; // Main project path
|
||||||
|
onWorktreeCreated?: () => void; // Callback when a new worktree is created
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useBoardDragDrop({
|
export function useBoardDragDrop({
|
||||||
@@ -24,14 +25,66 @@ export function useBoardDragDrop({
|
|||||||
runningAutoTasks,
|
runningAutoTasks,
|
||||||
persistFeatureUpdate,
|
persistFeatureUpdate,
|
||||||
handleStartImplementation,
|
handleStartImplementation,
|
||||||
currentWorktreePath,
|
|
||||||
projectPath,
|
projectPath,
|
||||||
|
onWorktreeCreated,
|
||||||
}: UseBoardDragDropProps) {
|
}: UseBoardDragDropProps) {
|
||||||
const [activeFeature, setActiveFeature] = useState<Feature | null>(null);
|
const [activeFeature, setActiveFeature] = useState<Feature | null>(null);
|
||||||
const { moveFeature } = useAppStore();
|
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<string | null> => {
|
||||||
|
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(
|
const handleDragStart = useCallback(
|
||||||
(event: DragStartEvent) => {
|
(event: DragStartEvent) => {
|
||||||
@@ -104,12 +157,16 @@ export function useBoardDragDrop({
|
|||||||
if (draggedFeature.status === "backlog") {
|
if (draggedFeature.status === "backlog") {
|
||||||
// From backlog
|
// From backlog
|
||||||
if (targetStatus === "in_progress") {
|
if (targetStatus === "in_progress") {
|
||||||
// Assign the current worktree to this feature when moving to in_progress
|
// Get or create worktree based on the feature's assigned branch
|
||||||
if (effectiveWorktreePath) {
|
const worktreePath = await getOrCreateWorktreeForFeature(draggedFeature);
|
||||||
await persistFeatureUpdate(featureId, { worktreePath: effectiveWorktreePath });
|
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
|
// 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 {
|
} else {
|
||||||
moveFeature(featureId, targetStatus);
|
moveFeature(featureId, targetStatus);
|
||||||
persistFeatureUpdate(featureId, { status: targetStatus });
|
persistFeatureUpdate(featureId, { status: targetStatus });
|
||||||
@@ -219,7 +276,8 @@ export function useBoardDragDrop({
|
|||||||
moveFeature,
|
moveFeature,
|
||||||
persistFeatureUpdate,
|
persistFeatureUpdate,
|
||||||
handleStartImplementation,
|
handleStartImplementation,
|
||||||
effectiveWorktreePath,
|
getOrCreateWorktreeForFeature,
|
||||||
|
onWorktreeCreated,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -45,8 +45,6 @@ interface KanbanBoardProps {
|
|||||||
onMoveBackToInProgress: (feature: Feature) => void;
|
onMoveBackToInProgress: (feature: Feature) => void;
|
||||||
onFollowUp: (feature: Feature) => void;
|
onFollowUp: (feature: Feature) => void;
|
||||||
onCommit: (feature: Feature) => void;
|
onCommit: (feature: Feature) => void;
|
||||||
onRevert: (feature: Feature) => void;
|
|
||||||
onMerge: (feature: Feature) => void;
|
|
||||||
onComplete: (feature: Feature) => void;
|
onComplete: (feature: Feature) => void;
|
||||||
onImplement: (feature: Feature) => void;
|
onImplement: (feature: Feature) => void;
|
||||||
featuresWithContext: Set<string>;
|
featuresWithContext: Set<string>;
|
||||||
@@ -77,8 +75,6 @@ export function KanbanBoard({
|
|||||||
onMoveBackToInProgress,
|
onMoveBackToInProgress,
|
||||||
onFollowUp,
|
onFollowUp,
|
||||||
onCommit,
|
onCommit,
|
||||||
onRevert,
|
|
||||||
onMerge,
|
|
||||||
onComplete,
|
onComplete,
|
||||||
onImplement,
|
onImplement,
|
||||||
featuresWithContext,
|
featuresWithContext,
|
||||||
@@ -191,8 +187,6 @@ export function KanbanBoard({
|
|||||||
}
|
}
|
||||||
onFollowUp={() => onFollowUp(feature)}
|
onFollowUp={() => onFollowUp(feature)}
|
||||||
onCommit={() => onCommit(feature)}
|
onCommit={() => onCommit(feature)}
|
||||||
onRevert={() => onRevert(feature)}
|
|
||||||
onMerge={() => onMerge(feature)}
|
|
||||||
onComplete={() => onComplete(feature)}
|
onComplete={() => onComplete(feature)}
|
||||||
onImplement={() => onImplement(feature)}
|
onImplement={() => onImplement(feature)}
|
||||||
hasContext={featuresWithContext.has(feature.id)}
|
hasContext={featuresWithContext.has(feature.id)}
|
||||||
|
|||||||
@@ -224,7 +224,8 @@ export interface AutoModeAPI {
|
|||||||
runFeature: (
|
runFeature: (
|
||||||
projectPath: string,
|
projectPath: string,
|
||||||
featureId: string,
|
featureId: string,
|
||||||
useWorktrees?: boolean
|
useWorktrees?: boolean,
|
||||||
|
worktreePath?: string
|
||||||
) => Promise<{ success: boolean; passes?: boolean; error?: string }>;
|
) => Promise<{ success: boolean; passes?: boolean; error?: string }>;
|
||||||
verifyFeature: (
|
verifyFeature: (
|
||||||
projectPath: string,
|
projectPath: string,
|
||||||
@@ -245,11 +246,13 @@ export interface AutoModeAPI {
|
|||||||
projectPath: string,
|
projectPath: string,
|
||||||
featureId: string,
|
featureId: string,
|
||||||
prompt: string,
|
prompt: string,
|
||||||
imagePaths?: string[]
|
imagePaths?: string[],
|
||||||
|
worktreePath?: string
|
||||||
) => Promise<{ success: boolean; passes?: boolean; error?: string }>;
|
) => Promise<{ success: boolean; passes?: boolean; error?: string }>;
|
||||||
commitFeature: (
|
commitFeature: (
|
||||||
projectPath: string,
|
projectPath: string,
|
||||||
featureId: string
|
featureId: string,
|
||||||
|
worktreePath?: string
|
||||||
) => Promise<{ success: boolean; error?: string }>;
|
) => Promise<{ success: boolean; error?: string }>;
|
||||||
onEvent: (callback: (event: AutoModeEvent) => void) => () => void;
|
onEvent: (callback: (event: AutoModeEvent) => void) => () => void;
|
||||||
}
|
}
|
||||||
@@ -1056,7 +1059,7 @@ function createMockWorktreeAPI(): WorktreeAPI {
|
|||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
worktrees: [
|
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: {
|
result: {
|
||||||
branch: "feature-branch",
|
branch: "feature-branch",
|
||||||
pushed: true,
|
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 (
|
runFeature: async (
|
||||||
projectPath: string,
|
projectPath: string,
|
||||||
featureId: string,
|
featureId: string,
|
||||||
useWorktrees?: boolean
|
useWorktrees?: boolean,
|
||||||
|
worktreePath?: string
|
||||||
) => {
|
) => {
|
||||||
if (mockRunningFeatures.has(featureId)) {
|
if (mockRunningFeatures.has(featureId)) {
|
||||||
return {
|
return {
|
||||||
@@ -1321,7 +1393,7 @@ function createMockAutoModeAPI(): AutoModeAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`[Mock] Running feature ${featureId} with useWorktrees: ${useWorktrees}`
|
`[Mock] Running feature ${featureId} with useWorktrees: ${useWorktrees}, worktreePath: ${worktreePath}`
|
||||||
);
|
);
|
||||||
mockRunningFeatures.add(featureId);
|
mockRunningFeatures.add(featureId);
|
||||||
simulateAutoModeLoop(projectPath, featureId);
|
simulateAutoModeLoop(projectPath, featureId);
|
||||||
@@ -1481,7 +1553,8 @@ function createMockAutoModeAPI(): AutoModeAPI {
|
|||||||
projectPath: string,
|
projectPath: string,
|
||||||
featureId: string,
|
featureId: string,
|
||||||
prompt: string,
|
prompt: string,
|
||||||
imagePaths?: string[]
|
imagePaths?: string[],
|
||||||
|
worktreePath?: string
|
||||||
) => {
|
) => {
|
||||||
if (mockRunningFeatures.has(featureId)) {
|
if (mockRunningFeatures.has(featureId)) {
|
||||||
return {
|
return {
|
||||||
@@ -1506,8 +1579,8 @@ function createMockAutoModeAPI(): AutoModeAPI {
|
|||||||
return { success: true };
|
return { success: true };
|
||||||
},
|
},
|
||||||
|
|
||||||
commitFeature: async (projectPath: string, featureId: string) => {
|
commitFeature: async (projectPath: string, featureId: string, worktreePath?: string) => {
|
||||||
console.log("[Mock] Committing feature:", { projectPath, featureId });
|
console.log("[Mock] Committing feature:", { projectPath, featureId, worktreePath });
|
||||||
|
|
||||||
// Simulate commit operation
|
// Simulate commit operation
|
||||||
emitAutoModeEvent({
|
emitAutoModeEvent({
|
||||||
|
|||||||
@@ -509,12 +509,14 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
runFeature: (
|
runFeature: (
|
||||||
projectPath: string,
|
projectPath: string,
|
||||||
featureId: string,
|
featureId: string,
|
||||||
useWorktrees?: boolean
|
useWorktrees?: boolean,
|
||||||
|
worktreePath?: string
|
||||||
) =>
|
) =>
|
||||||
this.post("/api/auto-mode/run-feature", {
|
this.post("/api/auto-mode/run-feature", {
|
||||||
projectPath,
|
projectPath,
|
||||||
featureId,
|
featureId,
|
||||||
useWorktrees,
|
useWorktrees,
|
||||||
|
worktreePath,
|
||||||
}),
|
}),
|
||||||
verifyFeature: (projectPath: string, featureId: string) =>
|
verifyFeature: (projectPath: string, featureId: string) =>
|
||||||
this.post("/api/auto-mode/verify-feature", { projectPath, featureId }),
|
this.post("/api/auto-mode/verify-feature", { projectPath, featureId }),
|
||||||
@@ -528,16 +530,18 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
projectPath: string,
|
projectPath: string,
|
||||||
featureId: string,
|
featureId: string,
|
||||||
prompt: string,
|
prompt: string,
|
||||||
imagePaths?: string[]
|
imagePaths?: string[],
|
||||||
|
worktreePath?: string
|
||||||
) =>
|
) =>
|
||||||
this.post("/api/auto-mode/follow-up-feature", {
|
this.post("/api/auto-mode/follow-up-feature", {
|
||||||
projectPath,
|
projectPath,
|
||||||
featureId,
|
featureId,
|
||||||
prompt,
|
prompt,
|
||||||
imagePaths,
|
imagePaths,
|
||||||
|
worktreePath,
|
||||||
}),
|
}),
|
||||||
commitFeature: (projectPath: string, featureId: string) =>
|
commitFeature: (projectPath: string, featureId: string, worktreePath?: string) =>
|
||||||
this.post("/api/auto-mode/commit-feature", { projectPath, featureId }),
|
this.post("/api/auto-mode/commit-feature", { projectPath, featureId, worktreePath }),
|
||||||
onEvent: (callback: (event: AutoModeEvent) => void) => {
|
onEvent: (callback: (event: AutoModeEvent) => void) => {
|
||||||
return this.subscribeToEvent(
|
return this.subscribeToEvent(
|
||||||
"auto-mode:event",
|
"auto-mode:event",
|
||||||
@@ -586,6 +590,18 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
this.post("/api/worktree/list-branches", { worktreePath }),
|
this.post("/api/worktree/list-branches", { worktreePath }),
|
||||||
switchBranch: (worktreePath: string, branchName: string) =>
|
switchBranch: (worktreePath: string, branchName: string) =>
|
||||||
this.post("/api/worktree/switch-branch", { worktreePath, branchName }),
|
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
|
// Git API
|
||||||
|
|||||||
@@ -48,6 +48,31 @@ export async function initializeProject(
|
|||||||
const existingFiles: string[] = [];
|
const existingFiles: string[] = [];
|
||||||
|
|
||||||
try {
|
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
|
// Create all required directories
|
||||||
for (const dir of REQUIRED_STRUCTURE.directories) {
|
for (const dir of REQUIRED_STRUCTURE.directories) {
|
||||||
const fullPath = `${projectPath}/${dir}`;
|
const fullPath = `${projectPath}/${dir}`;
|
||||||
|
|||||||
82
apps/app/src/types/electron.d.ts
vendored
82
apps/app/src/types/electron.d.ts
vendored
@@ -340,7 +340,8 @@ export interface AutoModeAPI {
|
|||||||
runFeature: (
|
runFeature: (
|
||||||
projectPath: string,
|
projectPath: string,
|
||||||
featureId: string,
|
featureId: string,
|
||||||
useWorktrees?: boolean
|
useWorktrees?: boolean,
|
||||||
|
worktreePath?: string
|
||||||
) => Promise<{
|
) => Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
passes?: boolean;
|
passes?: boolean;
|
||||||
@@ -384,7 +385,8 @@ export interface AutoModeAPI {
|
|||||||
projectPath: string,
|
projectPath: string,
|
||||||
featureId: string,
|
featureId: string,
|
||||||
prompt: string,
|
prompt: string,
|
||||||
imagePaths?: string[]
|
imagePaths?: string[],
|
||||||
|
worktreePath?: string
|
||||||
) => Promise<{
|
) => Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
passes?: boolean;
|
passes?: boolean;
|
||||||
@@ -631,6 +633,8 @@ export interface WorktreeAPI {
|
|||||||
path: string;
|
path: string;
|
||||||
branch: string;
|
branch: string;
|
||||||
isMain: boolean;
|
isMain: boolean;
|
||||||
|
isCurrent: boolean; // Is this the currently checked out branch?
|
||||||
|
hasWorktree: boolean; // Does this branch have an active worktree?
|
||||||
hasChanges?: boolean;
|
hasChanges?: boolean;
|
||||||
changedFilesCount?: number;
|
changedFilesCount?: number;
|
||||||
}>;
|
}>;
|
||||||
@@ -690,6 +694,7 @@ export interface WorktreeAPI {
|
|||||||
result?: {
|
result?: {
|
||||||
branch: string;
|
branch: string;
|
||||||
pushed: boolean;
|
pushed: boolean;
|
||||||
|
message: string;
|
||||||
};
|
};
|
||||||
error?: string;
|
error?: string;
|
||||||
}>;
|
}>;
|
||||||
@@ -785,6 +790,79 @@ export interface WorktreeAPI {
|
|||||||
};
|
};
|
||||||
error?: string;
|
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 {
|
export interface GitAPI {
|
||||||
|
|||||||
322
apps/server/src/lib/automaker-paths.ts
Normal file
322
apps/server/src/lib/automaker-paths.ts
Normal file
@@ -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<string> {
|
||||||
|
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<string> {
|
||||||
|
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<string> {
|
||||||
|
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<string> {
|
||||||
|
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<string> {
|
||||||
|
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<string> {
|
||||||
|
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<string> {
|
||||||
|
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<string> {
|
||||||
|
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<string> {
|
||||||
|
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<string> {
|
||||||
|
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<string> {
|
||||||
|
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<string> {
|
||||||
|
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<boolean> {
|
||||||
|
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<string> {
|
||||||
|
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<boolean> {
|
||||||
|
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<string> {
|
||||||
|
// 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<string> {
|
||||||
|
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<string> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
67
apps/server/src/lib/fs-utils.ts
Normal file
67
apps/server/src/lib/fs-utils.ts
Normal file
@@ -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<void> {
|
||||||
|
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<boolean> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,13 +3,13 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { query } from "@anthropic-ai/claude-agent-sdk";
|
import { query } from "@anthropic-ai/claude-agent-sdk";
|
||||||
import path from "path";
|
|
||||||
import fs from "fs/promises";
|
import fs from "fs/promises";
|
||||||
import type { EventEmitter } from "../../lib/events.js";
|
import type { EventEmitter } from "../../lib/events.js";
|
||||||
import { createLogger } from "../../lib/logger.js";
|
import { createLogger } from "../../lib/logger.js";
|
||||||
import { createFeatureGenerationOptions } from "../../lib/sdk-options.js";
|
import { createFeatureGenerationOptions } from "../../lib/sdk-options.js";
|
||||||
import { logAuthStatus } from "./common.js";
|
import { logAuthStatus } from "./common.js";
|
||||||
import { parseAndCreateFeatures } from "./parse-and-create-features.js";
|
import { parseAndCreateFeatures } from "./parse-and-create-features.js";
|
||||||
|
import { getAppSpecPath } from "../../lib/automaker-paths.js";
|
||||||
|
|
||||||
const logger = createLogger("SpecRegeneration");
|
const logger = createLogger("SpecRegeneration");
|
||||||
|
|
||||||
@@ -26,8 +26,8 @@ export async function generateFeaturesFromSpec(
|
|||||||
logger.debug("projectPath:", projectPath);
|
logger.debug("projectPath:", projectPath);
|
||||||
logger.debug("maxFeatures:", featureCount);
|
logger.debug("maxFeatures:", featureCount);
|
||||||
|
|
||||||
// Read existing spec
|
// Read existing spec from external automaker directory
|
||||||
const specPath = path.join(projectPath, ".automaker", "app_spec.txt");
|
const specPath = await getAppSpecPath(projectPath);
|
||||||
let spec: string;
|
let spec: string;
|
||||||
|
|
||||||
logger.debug("Reading spec from:", specPath);
|
logger.debug("Reading spec from:", specPath);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { createLogger } from "../../lib/logger.js";
|
|||||||
import { createSpecGenerationOptions } from "../../lib/sdk-options.js";
|
import { createSpecGenerationOptions } from "../../lib/sdk-options.js";
|
||||||
import { logAuthStatus } from "./common.js";
|
import { logAuthStatus } from "./common.js";
|
||||||
import { generateFeaturesFromSpec } from "./generate-features-from-spec.js";
|
import { generateFeaturesFromSpec } from "./generate-features-from-spec.js";
|
||||||
|
import { ensureAutomakerDir, getAppSpecPath } from "../../lib/automaker-paths.js";
|
||||||
|
|
||||||
const logger = createLogger("SpecRegeneration");
|
const logger = createLogger("SpecRegeneration");
|
||||||
|
|
||||||
@@ -209,14 +210,13 @@ ${getAppSpecFormatInstruction()}`;
|
|||||||
logger.error("❌ WARNING: responseText is empty! Nothing to save.");
|
logger.error("❌ WARNING: responseText is empty! Nothing to save.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save spec
|
// Save spec to external automaker directory
|
||||||
const specDir = path.join(projectPath, ".automaker");
|
const specDir = await ensureAutomakerDir(projectPath);
|
||||||
const specPath = path.join(specDir, "app_spec.txt");
|
const specPath = await getAppSpecPath(projectPath);
|
||||||
|
|
||||||
logger.info("Saving spec to:", specPath);
|
logger.info("Saving spec to:", specPath);
|
||||||
logger.info(`Content to save (${responseText.length} chars)`);
|
logger.info(`Content to save (${responseText.length} chars)`);
|
||||||
|
|
||||||
await fs.mkdir(specDir, { recursive: true });
|
|
||||||
await fs.writeFile(specPath, responseText);
|
await fs.writeFile(specPath, responseText);
|
||||||
|
|
||||||
// Verify the file was written
|
// Verify the file was written
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import path from "path";
|
|||||||
import fs from "fs/promises";
|
import fs from "fs/promises";
|
||||||
import type { EventEmitter } from "../../lib/events.js";
|
import type { EventEmitter } from "../../lib/events.js";
|
||||||
import { createLogger } from "../../lib/logger.js";
|
import { createLogger } from "../../lib/logger.js";
|
||||||
|
import { getFeaturesDir } from "../../lib/automaker-paths.js";
|
||||||
|
|
||||||
const logger = createLogger("SpecRegeneration");
|
const logger = createLogger("SpecRegeneration");
|
||||||
|
|
||||||
@@ -41,7 +42,7 @@ export async function parseAndCreateFeatures(
|
|||||||
logger.info(`Parsed ${parsed.features?.length || 0} features`);
|
logger.info(`Parsed ${parsed.features?.length || 0} features`);
|
||||||
logger.info("Parsed features:", JSON.stringify(parsed.features, null, 2));
|
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 });
|
await fs.mkdir(featuresDir, { recursive: true });
|
||||||
|
|
||||||
const createdFeatures: Array<{ id: string; title: string }> = [];
|
const createdFeatures: Array<{ id: string; title: string }> = [];
|
||||||
|
|||||||
@@ -9,9 +9,10 @@ import { getErrorMessage, logError } from "../common.js";
|
|||||||
export function createCommitFeatureHandler(autoModeService: AutoModeService) {
|
export function createCommitFeatureHandler(autoModeService: AutoModeService) {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const { projectPath, featureId } = req.body as {
|
const { projectPath, featureId, worktreePath } = req.body as {
|
||||||
projectPath: string;
|
projectPath: string;
|
||||||
featureId: string;
|
featureId: string;
|
||||||
|
worktreePath?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!projectPath || !featureId) {
|
if (!projectPath || !featureId) {
|
||||||
@@ -26,7 +27,8 @@ export function createCommitFeatureHandler(autoModeService: AutoModeService) {
|
|||||||
|
|
||||||
const commitHash = await autoModeService.commitFeature(
|
const commitHash = await autoModeService.commitFeature(
|
||||||
projectPath,
|
projectPath,
|
||||||
featureId
|
featureId,
|
||||||
|
worktreePath
|
||||||
);
|
);
|
||||||
res.json({ success: true, commitHash });
|
res.json({ success: true, commitHash });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -12,11 +12,12 @@ const logger = createLogger("AutoMode");
|
|||||||
export function createFollowUpFeatureHandler(autoModeService: AutoModeService) {
|
export function createFollowUpFeatureHandler(autoModeService: AutoModeService) {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const { projectPath, featureId, prompt, imagePaths } = req.body as {
|
const { projectPath, featureId, prompt, imagePaths, worktreePath } = req.body as {
|
||||||
projectPath: string;
|
projectPath: string;
|
||||||
featureId: string;
|
featureId: string;
|
||||||
prompt: string;
|
prompt: string;
|
||||||
imagePaths?: string[];
|
imagePaths?: string[];
|
||||||
|
worktreePath?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!projectPath || !featureId || !prompt) {
|
if (!projectPath || !featureId || !prompt) {
|
||||||
@@ -27,9 +28,9 @@ export function createFollowUpFeatureHandler(autoModeService: AutoModeService) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start follow-up in background
|
// Start follow-up in background, using the feature's worktreePath for correct branch
|
||||||
autoModeService
|
autoModeService
|
||||||
.followUpFeature(projectPath, featureId, prompt, imagePaths)
|
.followUpFeature(projectPath, featureId, prompt, imagePaths, worktreePath)
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
logger.error(
|
logger.error(
|
||||||
`[AutoMode] Follow up feature ${featureId} error:`,
|
`[AutoMode] Follow up feature ${featureId} error:`,
|
||||||
|
|||||||
@@ -12,10 +12,11 @@ const logger = createLogger("AutoMode");
|
|||||||
export function createRunFeatureHandler(autoModeService: AutoModeService) {
|
export function createRunFeatureHandler(autoModeService: AutoModeService) {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const { projectPath, featureId, useWorktrees } = req.body as {
|
const { projectPath, featureId, useWorktrees, worktreePath } = req.body as {
|
||||||
projectPath: string;
|
projectPath: string;
|
||||||
featureId: string;
|
featureId: string;
|
||||||
useWorktrees?: boolean;
|
useWorktrees?: boolean;
|
||||||
|
worktreePath?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!projectPath || !featureId) {
|
if (!projectPath || !featureId) {
|
||||||
@@ -29,8 +30,9 @@ export function createRunFeatureHandler(autoModeService: AutoModeService) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Start execution in background
|
// Start execution in background
|
||||||
|
// If worktreePath is provided, use it directly; otherwise let the service decide
|
||||||
autoModeService
|
autoModeService
|
||||||
.executeFeature(projectPath, featureId, useWorktrees ?? true, false)
|
.executeFeature(projectPath, featureId, useWorktrees ?? true, false, worktreePath)
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
logger.error(`[AutoMode] Feature ${featureId} error:`, error);
|
logger.error(`[AutoMode] Feature ${featureId} error:`, error);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type { Request, Response } from "express";
|
|||||||
import fs from "fs/promises";
|
import fs from "fs/promises";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { getErrorMessage, logError } from "../common.js";
|
import { getErrorMessage, logError } from "../common.js";
|
||||||
|
import { getBoardDir } from "../../../lib/automaker-paths.js";
|
||||||
|
|
||||||
export function createDeleteBoardBackgroundHandler() {
|
export function createDeleteBoardBackgroundHandler() {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
@@ -20,10 +21,11 @@ export function createDeleteBoardBackgroundHandler() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const boardDir = path.join(projectPath, ".automaker", "board");
|
// Get external board directory
|
||||||
|
const boardDir = await getBoardDir(projectPath);
|
||||||
|
|
||||||
try {
|
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);
|
const files = await fs.readdir(boardDir);
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
if (file.startsWith("background")) {
|
if (file.startsWith("background")) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* POST /mkdir endpoint - Create directory
|
* POST /mkdir endpoint - Create directory
|
||||||
|
* Handles symlinks safely to avoid ELOOP errors
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Request, Response } from "express";
|
import type { Request, Response } from "express";
|
||||||
@@ -20,13 +21,46 @@ export function createMkdirHandler() {
|
|||||||
|
|
||||||
const resolvedPath = path.resolve(dirPath);
|
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 });
|
await fs.mkdir(resolvedPath, { recursive: true });
|
||||||
|
|
||||||
// Add the new directory to allowed paths for tracking
|
// Add the new directory to allowed paths for tracking
|
||||||
addAllowedPath(resolvedPath);
|
addAllowedPath(resolvedPath);
|
||||||
|
|
||||||
res.json({ success: true });
|
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");
|
logError(error, "Create directory failed");
|
||||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import fs from "fs/promises";
|
|||||||
import path from "path";
|
import path from "path";
|
||||||
import { addAllowedPath } from "../../../lib/security.js";
|
import { addAllowedPath } from "../../../lib/security.js";
|
||||||
import { getErrorMessage, logError } from "../common.js";
|
import { getErrorMessage, logError } from "../common.js";
|
||||||
|
import { getBoardDir } from "../../../lib/automaker-paths.js";
|
||||||
|
|
||||||
export function createSaveBoardBackgroundHandler() {
|
export function createSaveBoardBackgroundHandler() {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
@@ -26,8 +27,8 @@ export function createSaveBoardBackgroundHandler() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create .automaker/board directory if it doesn't exist
|
// Get external board directory
|
||||||
const boardDir = path.join(projectPath, ".automaker", "board");
|
const boardDir = await getBoardDir(projectPath);
|
||||||
await fs.mkdir(boardDir, { recursive: true });
|
await fs.mkdir(boardDir, { recursive: true });
|
||||||
|
|
||||||
// Decode base64 data (remove data URL prefix if present)
|
// Decode base64 data (remove data URL prefix if present)
|
||||||
@@ -42,12 +43,11 @@ export function createSaveBoardBackgroundHandler() {
|
|||||||
// Write file
|
// Write file
|
||||||
await fs.writeFile(filePath, buffer);
|
await fs.writeFile(filePath, buffer);
|
||||||
|
|
||||||
// Add project path to allowed paths if not already
|
// Add board directory to allowed paths
|
||||||
addAllowedPath(projectPath);
|
addAllowedPath(boardDir);
|
||||||
|
|
||||||
// Return the relative path for storage
|
// Return the absolute path
|
||||||
const relativePath = `.automaker/board/${uniqueFilename}`;
|
res.json({ success: true, path: filePath });
|
||||||
res.json({ success: true, path: relativePath });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError(error, "Save board background failed");
|
logError(error, "Save board background failed");
|
||||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||||
|
|||||||
@@ -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";
|
import type { Request, Response } from "express";
|
||||||
@@ -7,6 +7,7 @@ import fs from "fs/promises";
|
|||||||
import path from "path";
|
import path from "path";
|
||||||
import { addAllowedPath } from "../../../lib/security.js";
|
import { addAllowedPath } from "../../../lib/security.js";
|
||||||
import { getErrorMessage, logError } from "../common.js";
|
import { getErrorMessage, logError } from "../common.js";
|
||||||
|
import { getImagesDir } from "../../../lib/automaker-paths.js";
|
||||||
|
|
||||||
export function createSaveImageHandler() {
|
export function createSaveImageHandler() {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
@@ -26,8 +27,8 @@ export function createSaveImageHandler() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create .automaker/images directory if it doesn't exist
|
// Get external images directory
|
||||||
const imagesDir = path.join(projectPath, ".automaker", "images");
|
const imagesDir = await getImagesDir(projectPath);
|
||||||
await fs.mkdir(imagesDir, { recursive: true });
|
await fs.mkdir(imagesDir, { recursive: true });
|
||||||
|
|
||||||
// Decode base64 data (remove data URL prefix if present)
|
// Decode base64 data (remove data URL prefix if present)
|
||||||
@@ -44,9 +45,10 @@ export function createSaveImageHandler() {
|
|||||||
// Write file
|
// Write file
|
||||||
await fs.writeFile(filePath, buffer);
|
await fs.writeFile(filePath, buffer);
|
||||||
|
|
||||||
// Add project path to allowed paths if not already
|
// Add automaker directory to allowed paths
|
||||||
addAllowedPath(projectPath);
|
addAllowedPath(imagesDir);
|
||||||
|
|
||||||
|
// Return the absolute path
|
||||||
res.json({ success: true, path: filePath });
|
res.json({ success: true, path: filePath });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError(error, "Save image failed");
|
logError(error, "Save image failed");
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import fs from "fs/promises";
|
|||||||
import path from "path";
|
import path from "path";
|
||||||
import { validatePath } from "../../../lib/security.js";
|
import { validatePath } from "../../../lib/security.js";
|
||||||
import { getErrorMessage, logError } from "../common.js";
|
import { getErrorMessage, logError } from "../common.js";
|
||||||
|
import { mkdirSafe } from "../../../lib/fs-utils.js";
|
||||||
|
|
||||||
export function createWriteHandler() {
|
export function createWriteHandler() {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
@@ -23,8 +24,8 @@ export function createWriteHandler() {
|
|||||||
|
|
||||||
const resolvedPath = validatePath(filePath);
|
const resolvedPath = validatePath(filePath);
|
||||||
|
|
||||||
// Ensure parent directory exists
|
// Ensure parent directory exists (symlink-safe)
|
||||||
await fs.mkdir(path.dirname(resolvedPath), { recursive: true });
|
await mkdirSafe(path.dirname(resolvedPath));
|
||||||
await fs.writeFile(resolvedPath, content, "utf-8");
|
await fs.writeFile(resolvedPath, content, "utf-8");
|
||||||
|
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
|
|||||||
@@ -19,6 +19,13 @@ import { createPullHandler } from "./routes/pull.js";
|
|||||||
import { createCheckoutBranchHandler } from "./routes/checkout-branch.js";
|
import { createCheckoutBranchHandler } from "./routes/checkout-branch.js";
|
||||||
import { createListBranchesHandler } from "./routes/list-branches.js";
|
import { createListBranchesHandler } from "./routes/list-branches.js";
|
||||||
import { createSwitchBranchHandler } from "./routes/switch-branch.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 {
|
export function createWorktreeRoutes(): Router {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
@@ -39,6 +46,13 @@ export function createWorktreeRoutes(): Router {
|
|||||||
router.post("/checkout-branch", createCheckoutBranchHandler());
|
router.post("/checkout-branch", createCheckoutBranchHandler());
|
||||||
router.post("/list-branches", createListBranchesHandler());
|
router.post("/list-branches", createListBranchesHandler());
|
||||||
router.post("/switch-branch", createSwitchBranchHandler());
|
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;
|
return router;
|
||||||
}
|
}
|
||||||
|
|||||||
149
apps/server/src/routes/worktree/routes/activate.ts
Normal file
149
apps/server/src/routes/worktree/routes/activate.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
/**
|
||||||
|
* POST /activate endpoint - Switch main project to a worktree's branch
|
||||||
|
*
|
||||||
|
* This allows users to "activate" a worktree so their running dev server
|
||||||
|
* (like Vite) shows the worktree's files. It does this by:
|
||||||
|
* 1. Checking for uncommitted changes (fails if found)
|
||||||
|
* 2. Removing the worktree (unlocks the branch)
|
||||||
|
* 3. Checking out that branch in the main directory
|
||||||
|
*
|
||||||
|
* Users should commit their changes before activating a worktree.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Request, Response } from "express";
|
||||||
|
import { exec } from "child_process";
|
||||||
|
import { promisify } from "util";
|
||||||
|
import { getErrorMessage, logError } from "../common.js";
|
||||||
|
|
||||||
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
|
async function hasUncommittedChanges(cwd: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const { stdout } = await execAsync("git status --porcelain", { cwd });
|
||||||
|
// Filter out our own .worktrees directory from the check
|
||||||
|
const lines = stdout.trim().split("\n").filter((line) => {
|
||||||
|
if (!line.trim()) return false;
|
||||||
|
// Exclude .worktrees/ directory (created by automaker)
|
||||||
|
if (line.includes(".worktrees/") || line.endsWith(".worktrees")) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
return lines.length > 0;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getCurrentBranch(cwd: string): Promise<string> {
|
||||||
|
const { stdout } = await execAsync("git branch --show-current", { cwd });
|
||||||
|
return stdout.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getWorktreeBranch(worktreePath: string): Promise<string> {
|
||||||
|
const { stdout } = await execAsync("git branch --show-current", {
|
||||||
|
cwd: worktreePath,
|
||||||
|
});
|
||||||
|
return stdout.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getChangesSummary(cwd: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
const { stdout } = await execAsync("git status --short", { cwd });
|
||||||
|
const lines = stdout.trim().split("\n").filter((line) => {
|
||||||
|
if (!line.trim()) return false;
|
||||||
|
// Exclude .worktrees/ directory
|
||||||
|
if (line.includes(".worktrees/") || line.endsWith(".worktrees")) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
if (lines.length === 0) return "";
|
||||||
|
if (lines.length <= 5) return lines.join(", ");
|
||||||
|
return `${lines.slice(0, 5).join(", ")} and ${lines.length - 5} more files`;
|
||||||
|
} catch {
|
||||||
|
return "unknown changes";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createActivateHandler() {
|
||||||
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { projectPath, worktreePath } = req.body as {
|
||||||
|
projectPath: string;
|
||||||
|
worktreePath: string | null; // null means switch back to main branch
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!projectPath) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: "projectPath is required",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentBranch = await getCurrentBranch(projectPath);
|
||||||
|
let targetBranch: string;
|
||||||
|
|
||||||
|
// Check for uncommitted changes in main directory
|
||||||
|
if (await hasUncommittedChanges(projectPath)) {
|
||||||
|
const summary = await getChangesSummary(projectPath);
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: `Cannot switch: you have uncommitted changes in the main directory (${summary}). Please commit your changes first.`,
|
||||||
|
code: "UNCOMMITTED_CHANGES",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (worktreePath) {
|
||||||
|
// Switching to a worktree's branch
|
||||||
|
targetBranch = await getWorktreeBranch(worktreePath);
|
||||||
|
|
||||||
|
// Check for uncommitted changes in the worktree
|
||||||
|
if (await hasUncommittedChanges(worktreePath)) {
|
||||||
|
const summary = await getChangesSummary(worktreePath);
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: `Cannot switch: you have uncommitted changes in the worktree (${summary}). Please commit your changes first.`,
|
||||||
|
code: "UNCOMMITTED_CHANGES",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the worktree (unlocks the branch)
|
||||||
|
console.log(`[activate] Removing worktree at ${worktreePath}...`);
|
||||||
|
await execAsync(`git worktree remove "${worktreePath}" --force`, {
|
||||||
|
cwd: projectPath,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Checkout the branch in main directory
|
||||||
|
console.log(`[activate] Checking out branch ${targetBranch}...`);
|
||||||
|
await execAsync(`git checkout "${targetBranch}"`, { cwd: projectPath });
|
||||||
|
} else {
|
||||||
|
// Switching back to main branch
|
||||||
|
try {
|
||||||
|
const { stdout: mainBranch } = await execAsync(
|
||||||
|
"git symbolic-ref refs/remotes/origin/HEAD --short 2>/dev/null | sed 's@origin/@@' || echo 'main'",
|
||||||
|
{ cwd: projectPath }
|
||||||
|
);
|
||||||
|
targetBranch = mainBranch.trim() || "main";
|
||||||
|
} catch {
|
||||||
|
targetBranch = "main";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Checkout main branch
|
||||||
|
console.log(`[activate] Checking out main branch ${targetBranch}...`);
|
||||||
|
await execAsync(`git checkout "${targetBranch}"`, { cwd: projectPath });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
result: {
|
||||||
|
previousBranch: currentBranch,
|
||||||
|
currentBranch: targetBranch,
|
||||||
|
message: `Switched from ${currentBranch} to ${targetBranch}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logError(error, "Activate worktree failed");
|
||||||
|
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
125
apps/server/src/routes/worktree/routes/branch-tracking.ts
Normal file
125
apps/server/src/routes/worktree/routes/branch-tracking.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
/**
|
||||||
|
* Branch tracking utilities
|
||||||
|
*
|
||||||
|
* Tracks active branches in external automaker storage so users
|
||||||
|
* can switch between branches even after worktrees are removed.
|
||||||
|
*
|
||||||
|
* Data is stored outside the git repo to avoid worktree/symlink conflicts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFile, writeFile } from "fs/promises";
|
||||||
|
import path from "path";
|
||||||
|
import {
|
||||||
|
getBranchTrackingPath,
|
||||||
|
ensureAutomakerDir,
|
||||||
|
} from "../../../lib/automaker-paths.js";
|
||||||
|
|
||||||
|
export interface TrackedBranch {
|
||||||
|
name: string;
|
||||||
|
createdAt: string;
|
||||||
|
lastActivatedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BranchTrackingData {
|
||||||
|
branches: TrackedBranch[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read tracked branches from file
|
||||||
|
*/
|
||||||
|
export async function getTrackedBranches(
|
||||||
|
projectPath: string
|
||||||
|
): Promise<TrackedBranch[]> {
|
||||||
|
try {
|
||||||
|
const filePath = await getBranchTrackingPath(projectPath);
|
||||||
|
const content = await readFile(filePath, "utf-8");
|
||||||
|
const data: BranchTrackingData = JSON.parse(content);
|
||||||
|
return data.branches || [];
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.code === "ENOENT") {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
console.warn("[branch-tracking] Failed to read tracked branches:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save tracked branches to file
|
||||||
|
*/
|
||||||
|
async function saveTrackedBranches(
|
||||||
|
projectPath: string,
|
||||||
|
branches: TrackedBranch[]
|
||||||
|
): Promise<void> {
|
||||||
|
const automakerDir = await ensureAutomakerDir(projectPath);
|
||||||
|
const filePath = path.join(automakerDir, "active-branches.json");
|
||||||
|
const data: BranchTrackingData = { branches };
|
||||||
|
await writeFile(filePath, JSON.stringify(data, null, 2), "utf-8");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a branch to tracking
|
||||||
|
*/
|
||||||
|
export async function trackBranch(
|
||||||
|
projectPath: string,
|
||||||
|
branchName: string
|
||||||
|
): Promise<void> {
|
||||||
|
const branches = await getTrackedBranches(projectPath);
|
||||||
|
|
||||||
|
// Check if already tracked
|
||||||
|
const existing = branches.find((b) => b.name === branchName);
|
||||||
|
if (existing) {
|
||||||
|
return; // Already tracked
|
||||||
|
}
|
||||||
|
|
||||||
|
branches.push({
|
||||||
|
name: branchName,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await saveTrackedBranches(projectPath, branches);
|
||||||
|
console.log(`[branch-tracking] Now tracking branch: ${branchName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a branch from tracking
|
||||||
|
*/
|
||||||
|
export async function untrackBranch(
|
||||||
|
projectPath: string,
|
||||||
|
branchName: string
|
||||||
|
): Promise<void> {
|
||||||
|
const branches = await getTrackedBranches(projectPath);
|
||||||
|
const filtered = branches.filter((b) => b.name !== branchName);
|
||||||
|
|
||||||
|
if (filtered.length !== branches.length) {
|
||||||
|
await saveTrackedBranches(projectPath, filtered);
|
||||||
|
console.log(`[branch-tracking] Stopped tracking branch: ${branchName}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update last activated timestamp for a branch
|
||||||
|
*/
|
||||||
|
export async function updateBranchActivation(
|
||||||
|
projectPath: string,
|
||||||
|
branchName: string
|
||||||
|
): Promise<void> {
|
||||||
|
const branches = await getTrackedBranches(projectPath);
|
||||||
|
const branch = branches.find((b) => b.name === branchName);
|
||||||
|
|
||||||
|
if (branch) {
|
||||||
|
branch.lastActivatedAt = new Date().toISOString();
|
||||||
|
await saveTrackedBranches(projectPath, branches);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a branch is tracked
|
||||||
|
*/
|
||||||
|
export async function isBranchTracked(
|
||||||
|
projectPath: string,
|
||||||
|
branchName: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
const branches = await getTrackedBranches(projectPath);
|
||||||
|
return branches.some((b) => b.name === branchName);
|
||||||
|
}
|
||||||
@@ -6,8 +6,9 @@ import type { Request, Response } from "express";
|
|||||||
import { exec } from "child_process";
|
import { exec } from "child_process";
|
||||||
import { promisify } from "util";
|
import { promisify } from "util";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import fs from "fs/promises";
|
import { mkdir, access } from "fs/promises";
|
||||||
import { isGitRepo, getErrorMessage, logError } from "../common.js";
|
import { isGitRepo, getErrorMessage, logError } from "../common.js";
|
||||||
|
import { trackBranch } from "./branch-tracking.js";
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
@@ -42,14 +43,19 @@ export function createCreateHandler() {
|
|||||||
const worktreePath = path.join(worktreesDir, sanitizedName);
|
const worktreePath = path.join(worktreesDir, sanitizedName);
|
||||||
|
|
||||||
// Create worktrees directory if it doesn't exist
|
// Create worktrees directory if it doesn't exist
|
||||||
await fs.mkdir(worktreesDir, { recursive: true });
|
await mkdir(worktreesDir, { recursive: true });
|
||||||
|
|
||||||
// Check if worktree already exists
|
// Check if worktree already exists
|
||||||
try {
|
try {
|
||||||
await fs.access(worktreePath);
|
await access(worktreePath);
|
||||||
res.status(400).json({
|
// Worktree already exists, return it instead of error
|
||||||
success: false,
|
res.json({
|
||||||
error: `Worktree for branch '${branchName}' already exists`,
|
success: true,
|
||||||
|
worktree: {
|
||||||
|
path: worktreePath,
|
||||||
|
branch: branchName,
|
||||||
|
isNew: false,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -80,22 +86,12 @@ export function createCreateHandler() {
|
|||||||
|
|
||||||
await execAsync(createCmd, { cwd: projectPath });
|
await execAsync(createCmd, { cwd: projectPath });
|
||||||
|
|
||||||
// Symlink .automaker directory to worktree so features are shared
|
// Note: We intentionally do NOT symlink .automaker to worktrees
|
||||||
const mainAutomaker = path.join(projectPath, ".automaker");
|
// Features and config are always accessed from the main project path
|
||||||
const worktreeAutomaker = path.join(worktreePath, ".automaker");
|
// This avoids symlink loop issues when activating worktrees
|
||||||
|
|
||||||
try {
|
// Track the branch so it persists in the UI even after worktree is removed
|
||||||
// Check if .automaker exists in main project
|
await trackBranch(projectPath, branchName);
|
||||||
await fs.access(mainAutomaker);
|
|
||||||
// Create symlink in worktree pointing to main .automaker
|
|
||||||
// Use 'junction' on Windows, 'dir' on other platforms
|
|
||||||
const symlinkType = process.platform === "win32" ? "junction" : "dir";
|
|
||||||
await fs.symlink(mainAutomaker, worktreeAutomaker, symlinkType);
|
|
||||||
} catch (symlinkError) {
|
|
||||||
// .automaker doesn't exist or symlink failed
|
|
||||||
// Log but don't fail - worktree is still usable without shared .automaker
|
|
||||||
console.warn("[Worktree] Could not create .automaker symlink:", symlinkError);
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -29,12 +29,8 @@ export function createDiffsHandler() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const worktreePath = path.join(
|
// Git worktrees are stored in project directory
|
||||||
projectPath,
|
const worktreePath = path.join(projectPath, ".worktrees", featureId);
|
||||||
".automaker",
|
|
||||||
"worktrees",
|
|
||||||
featureId
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fs.access(worktreePath);
|
await fs.access(worktreePath);
|
||||||
|
|||||||
@@ -28,12 +28,8 @@ export function createFileDiffHandler() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const worktreePath = path.join(
|
// Git worktrees are stored in project directory
|
||||||
projectPath,
|
const worktreePath = path.join(projectPath, ".worktrees", featureId);
|
||||||
".automaker",
|
|
||||||
"worktrees",
|
|
||||||
featureId
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fs.access(worktreePath);
|
await fs.access(worktreePath);
|
||||||
|
|||||||
@@ -29,13 +29,8 @@ export function createInfoHandler() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if worktree exists
|
// Check if worktree exists (git worktrees are stored in project directory)
|
||||||
const worktreePath = path.join(
|
const worktreePath = path.join(projectPath, ".worktrees", featureId);
|
||||||
projectPath,
|
|
||||||
".automaker",
|
|
||||||
"worktrees",
|
|
||||||
featureId
|
|
||||||
);
|
|
||||||
try {
|
try {
|
||||||
await fs.access(worktreePath);
|
await fs.access(worktreePath);
|
||||||
const { stdout } = await execAsync("git rev-parse --abbrev-ref HEAD", {
|
const { stdout } = await execAsync("git rev-parse --abbrev-ref HEAD", {
|
||||||
|
|||||||
60
apps/server/src/routes/worktree/routes/init-git.ts
Normal file
60
apps/server/src/routes/worktree/routes/init-git.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
/**
|
||||||
|
* POST /init-git endpoint - Initialize a git repository in a directory
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Request, Response } from "express";
|
||||||
|
import { exec } from "child_process";
|
||||||
|
import { promisify } from "util";
|
||||||
|
import { existsSync } from "fs";
|
||||||
|
import { join } from "path";
|
||||||
|
import { getErrorMessage, logError } from "../common.js";
|
||||||
|
|
||||||
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
|
export function createInitGitHandler() {
|
||||||
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { projectPath } = req.body as {
|
||||||
|
projectPath: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!projectPath) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: "projectPath required",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if .git already exists
|
||||||
|
const gitDirPath = join(projectPath, ".git");
|
||||||
|
if (existsSync(gitDirPath)) {
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
result: {
|
||||||
|
initialized: false,
|
||||||
|
message: "Git repository already exists",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize git and create an initial empty commit
|
||||||
|
await execAsync(
|
||||||
|
`git init && git commit --allow-empty -m "Initial commit"`,
|
||||||
|
{ cwd: projectPath }
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
result: {
|
||||||
|
initialized: true,
|
||||||
|
message: "Git repository initialized with initial commit",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logError(error, "Init git failed");
|
||||||
|
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
29
apps/server/src/routes/worktree/routes/list-dev-servers.ts
Normal file
29
apps/server/src/routes/worktree/routes/list-dev-servers.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* POST /list-dev-servers endpoint - List all running dev servers
|
||||||
|
*
|
||||||
|
* Returns information about all worktree dev servers currently running,
|
||||||
|
* including their ports and URLs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Request, Response } from "express";
|
||||||
|
import { getDevServerService } from "../../../services/dev-server-service.js";
|
||||||
|
import { getErrorMessage, logError } from "../common.js";
|
||||||
|
|
||||||
|
export function createListDevServersHandler() {
|
||||||
|
return async (_req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const devServerService = getDevServerService();
|
||||||
|
const result = devServerService.listDevServers();
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
result: {
|
||||||
|
servers: result.result.servers,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logError(error, "List dev servers failed");
|
||||||
|
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* POST /list endpoint - List all worktrees
|
* POST /list endpoint - List all git worktrees
|
||||||
|
*
|
||||||
|
* Returns actual git worktrees from `git worktree list`.
|
||||||
|
* Does NOT include tracked branches - only real worktrees with separate directories.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Request, Response } from "express";
|
import type { Request, Response } from "express";
|
||||||
@@ -13,10 +16,21 @@ interface WorktreeInfo {
|
|||||||
path: string;
|
path: string;
|
||||||
branch: string;
|
branch: string;
|
||||||
isMain: boolean;
|
isMain: boolean;
|
||||||
|
isCurrent: boolean; // Is this the currently checked out branch in main?
|
||||||
|
hasWorktree: boolean; // Always true for items in this list
|
||||||
hasChanges?: boolean;
|
hasChanges?: boolean;
|
||||||
changedFilesCount?: number;
|
changedFilesCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getCurrentBranch(cwd: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
const { stdout } = await execAsync("git branch --show-current", { cwd });
|
||||||
|
return stdout.trim();
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function createListHandler() {
|
export function createListHandler() {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
@@ -35,6 +49,10 @@ export function createListHandler() {
|
|||||||
return;
|
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", {
|
const { stdout } = await execAsync("git worktree list --porcelain", {
|
||||||
cwd: projectPath,
|
cwd: projectPath,
|
||||||
});
|
});
|
||||||
@@ -51,11 +69,12 @@ export function createListHandler() {
|
|||||||
current.branch = line.slice(7).replace("refs/heads/", "");
|
current.branch = line.slice(7).replace("refs/heads/", "");
|
||||||
} else if (line === "") {
|
} else if (line === "") {
|
||||||
if (current.path && current.branch) {
|
if (current.path && current.branch) {
|
||||||
// The first worktree in the list is always the main worktree
|
|
||||||
worktrees.push({
|
worktrees.push({
|
||||||
path: current.path,
|
path: current.path,
|
||||||
branch: current.branch,
|
branch: current.branch,
|
||||||
isMain: isFirst
|
isMain: isFirst,
|
||||||
|
isCurrent: current.branch === currentBranch,
|
||||||
|
hasWorktree: true,
|
||||||
});
|
});
|
||||||
isFirst = false;
|
isFirst = false;
|
||||||
}
|
}
|
||||||
@@ -71,11 +90,13 @@ export function createListHandler() {
|
|||||||
"git status --porcelain",
|
"git status --porcelain",
|
||||||
{ cwd: worktree.path }
|
{ 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.hasChanges = changedFiles.length > 0;
|
||||||
worktree.changedFilesCount = changedFiles.length;
|
worktree.changedFilesCount = changedFiles.length;
|
||||||
} catch {
|
} catch {
|
||||||
// If we can't get status, assume no changes
|
|
||||||
worktree.hasChanges = false;
|
worktree.hasChanges = false;
|
||||||
worktree.changedFilesCount = 0;
|
worktree.changedFilesCount = 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,12 +30,8 @@ export function createMergeHandler() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const branchName = `feature/${featureId}`;
|
const branchName = `feature/${featureId}`;
|
||||||
const worktreePath = path.join(
|
// Git worktrees are stored in project directory
|
||||||
projectPath,
|
const worktreePath = path.join(projectPath, ".worktrees", featureId);
|
||||||
".automaker",
|
|
||||||
"worktrees",
|
|
||||||
featureId
|
|
||||||
);
|
|
||||||
|
|
||||||
// Get current branch
|
// Get current branch
|
||||||
const { stdout: currentBranch } = await execAsync(
|
const { stdout: currentBranch } = await execAsync(
|
||||||
|
|||||||
63
apps/server/src/routes/worktree/routes/migrate.ts
Normal file
63
apps/server/src/routes/worktree/routes/migrate.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
/**
|
||||||
|
* POST /migrate endpoint - Migrate legacy .automaker data to external storage
|
||||||
|
*
|
||||||
|
* This endpoint checks if there's legacy .automaker data in the project directory
|
||||||
|
* and migrates it to the external ~/.automaker/projects/{project-id}/ location.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Request, Response } from "express";
|
||||||
|
import { getErrorMessage, logError } from "../common.js";
|
||||||
|
import {
|
||||||
|
hasLegacyAutomakerDir,
|
||||||
|
migrateLegacyData,
|
||||||
|
getAutomakerDir,
|
||||||
|
getLegacyAutomakerDir,
|
||||||
|
} from "../../../lib/automaker-paths.js";
|
||||||
|
|
||||||
|
export function createMigrateHandler() {
|
||||||
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { projectPath } = req.body as { projectPath: string };
|
||||||
|
|
||||||
|
if (!projectPath) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: "projectPath is required",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if migration is needed
|
||||||
|
const hasLegacy = await hasLegacyAutomakerDir(projectPath);
|
||||||
|
|
||||||
|
if (!hasLegacy) {
|
||||||
|
const automakerDir = await getAutomakerDir(projectPath);
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
migrated: false,
|
||||||
|
message: "No legacy .automaker directory found - nothing to migrate",
|
||||||
|
externalPath: automakerDir,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform migration
|
||||||
|
console.log(`[migrate] Starting migration for project: ${projectPath}`);
|
||||||
|
const legacyPath = await getLegacyAutomakerDir(projectPath);
|
||||||
|
const externalPath = await getAutomakerDir(projectPath);
|
||||||
|
|
||||||
|
await migrateLegacyData(projectPath);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
migrated: true,
|
||||||
|
message: "Successfully migrated .automaker data to external storage",
|
||||||
|
legacyPath,
|
||||||
|
externalPath,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logError(error, "Migration failed");
|
||||||
|
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
73
apps/server/src/routes/worktree/routes/open-in-editor.ts
Normal file
73
apps/server/src/routes/worktree/routes/open-in-editor.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
/**
|
||||||
|
* POST /open-in-editor endpoint - Open a worktree directory in VS Code
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Request, Response } from "express";
|
||||||
|
import { exec } from "child_process";
|
||||||
|
import { promisify } from "util";
|
||||||
|
import { getErrorMessage, logError } from "../common.js";
|
||||||
|
|
||||||
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
|
export function createOpenInEditorHandler() {
|
||||||
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { worktreePath } = req.body as {
|
||||||
|
worktreePath: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!worktreePath) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: "worktreePath required",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to open in VS Code
|
||||||
|
try {
|
||||||
|
await execAsync(`code "${worktreePath}"`);
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
result: {
|
||||||
|
message: `Opened ${worktreePath} in VS Code`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// If 'code' command fails, try 'cursor' (for Cursor editor)
|
||||||
|
try {
|
||||||
|
await execAsync(`cursor "${worktreePath}"`);
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
result: {
|
||||||
|
message: `Opened ${worktreePath} in Cursor`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// If both fail, try opening in default file manager
|
||||||
|
const platform = process.platform;
|
||||||
|
let openCommand: string;
|
||||||
|
|
||||||
|
if (platform === "darwin") {
|
||||||
|
openCommand = `open "${worktreePath}"`;
|
||||||
|
} else if (platform === "win32") {
|
||||||
|
openCommand = `explorer "${worktreePath}"`;
|
||||||
|
} else {
|
||||||
|
openCommand = `xdg-open "${worktreePath}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
await execAsync(openCommand);
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
result: {
|
||||||
|
message: `Opened ${worktreePath} in file manager`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logError(error, "Open in editor failed");
|
||||||
|
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -43,7 +43,7 @@ export function createPullHandler() {
|
|||||||
if (hasLocalChanges) {
|
if (hasLocalChanges) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
success: false,
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,12 +28,8 @@ export function createRevertHandler() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const worktreePath = path.join(
|
// Git worktrees are stored in project directory
|
||||||
projectPath,
|
const worktreePath = path.join(projectPath, ".worktrees", featureId);
|
||||||
".automaker",
|
|
||||||
"worktrees",
|
|
||||||
featureId
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Remove worktree
|
// Remove worktree
|
||||||
|
|||||||
61
apps/server/src/routes/worktree/routes/start-dev.ts
Normal file
61
apps/server/src/routes/worktree/routes/start-dev.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
/**
|
||||||
|
* POST /start-dev endpoint - Start a dev server for a worktree
|
||||||
|
*
|
||||||
|
* Spins up a development server (npm run dev) in the worktree directory
|
||||||
|
* on a unique port, allowing preview of the worktree's changes without
|
||||||
|
* affecting the main dev server.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Request, Response } from "express";
|
||||||
|
import { getDevServerService } from "../../../services/dev-server-service.js";
|
||||||
|
import { getErrorMessage, logError } from "../common.js";
|
||||||
|
|
||||||
|
export function createStartDevHandler() {
|
||||||
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { projectPath, worktreePath } = req.body as {
|
||||||
|
projectPath: string;
|
||||||
|
worktreePath: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!projectPath) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: "projectPath is required",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!worktreePath) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: "worktreePath is required",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const devServerService = getDevServerService();
|
||||||
|
const result = await devServerService.startDevServer(projectPath, worktreePath);
|
||||||
|
|
||||||
|
if (result.success && result.result) {
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
result: {
|
||||||
|
worktreePath: result.result.worktreePath,
|
||||||
|
port: result.result.port,
|
||||||
|
url: result.result.url,
|
||||||
|
message: result.result.message,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: result.error || "Failed to start dev server",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logError(error, "Start dev server failed");
|
||||||
|
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -29,12 +29,8 @@ export function createStatusHandler() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const worktreePath = path.join(
|
// Git worktrees are stored in project directory
|
||||||
projectPath,
|
const worktreePath = path.join(projectPath, ".worktrees", featureId);
|
||||||
".automaker",
|
|
||||||
"worktrees",
|
|
||||||
featureId
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fs.access(worktreePath);
|
await fs.access(worktreePath);
|
||||||
|
|||||||
49
apps/server/src/routes/worktree/routes/stop-dev.ts
Normal file
49
apps/server/src/routes/worktree/routes/stop-dev.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
/**
|
||||||
|
* POST /stop-dev endpoint - Stop a dev server for a worktree
|
||||||
|
*
|
||||||
|
* Stops the development server running for a specific worktree,
|
||||||
|
* freeing up the ports for reuse.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Request, Response } from "express";
|
||||||
|
import { getDevServerService } from "../../../services/dev-server-service.js";
|
||||||
|
import { getErrorMessage, logError } from "../common.js";
|
||||||
|
|
||||||
|
export function createStopDevHandler() {
|
||||||
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { worktreePath } = req.body as {
|
||||||
|
worktreePath: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!worktreePath) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: "worktreePath is required",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const devServerService = getDevServerService();
|
||||||
|
const result = await devServerService.stopDevServer(worktreePath);
|
||||||
|
|
||||||
|
if (result.success && result.result) {
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
result: {
|
||||||
|
worktreePath: result.result.worktreePath,
|
||||||
|
message: result.result.message,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: result.error || "Failed to stop dev server",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logError(error, "Stop dev server failed");
|
||||||
|
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* POST /switch-branch endpoint - Switch to an existing branch
|
* 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";
|
import type { Request, Response } from "express";
|
||||||
@@ -10,6 +13,46 @@ import { getErrorMessage, logError } from "../common.js";
|
|||||||
|
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if there are uncommitted changes in the working directory
|
||||||
|
* Excludes .worktrees/ directory which is created by automaker
|
||||||
|
*/
|
||||||
|
async function hasUncommittedChanges(cwd: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const { stdout } = await execAsync("git status --porcelain", { cwd });
|
||||||
|
const lines = stdout.trim().split("\n").filter((line) => {
|
||||||
|
if (!line.trim()) return false;
|
||||||
|
// Exclude .worktrees/ directory (created by automaker)
|
||||||
|
if (line.includes(".worktrees/") || line.endsWith(".worktrees")) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
return lines.length > 0;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a summary of uncommitted changes for user feedback
|
||||||
|
* Excludes .worktrees/ directory
|
||||||
|
*/
|
||||||
|
async function getChangesSummary(cwd: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
const { stdout } = await execAsync("git status --short", { cwd });
|
||||||
|
const lines = stdout.trim().split("\n").filter((line) => {
|
||||||
|
if (!line.trim()) return false;
|
||||||
|
// Exclude .worktrees/ directory
|
||||||
|
if (line.includes(".worktrees/") || line.endsWith(".worktrees")) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
if (lines.length === 0) return "";
|
||||||
|
if (lines.length <= 5) return lines.join(", ");
|
||||||
|
return `${lines.slice(0, 5).join(", ")} and ${lines.length - 5} more files`;
|
||||||
|
} catch {
|
||||||
|
return "unknown changes";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function createSwitchBranchHandler() {
|
export function createSwitchBranchHandler() {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
@@ -34,7 +77,7 @@ export function createSwitchBranchHandler() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get current branch for reference
|
// Get current branch
|
||||||
const { stdout: currentBranchOutput } = await execAsync(
|
const { stdout: currentBranchOutput } = await execAsync(
|
||||||
"git rev-parse --abbrev-ref HEAD",
|
"git rev-parse --abbrev-ref HEAD",
|
||||||
{ cwd: worktreePath }
|
{ cwd: worktreePath }
|
||||||
@@ -48,7 +91,6 @@ export function createSwitchBranchHandler() {
|
|||||||
previousBranch,
|
previousBranch,
|
||||||
currentBranch: branchName,
|
currentBranch: branchName,
|
||||||
message: `Already on branch '${branchName}'`,
|
message: `Already on branch '${branchName}'`,
|
||||||
stashed: false,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -68,81 +110,27 @@ export function createSwitchBranchHandler() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check for uncommitted changes
|
// Check for uncommitted changes
|
||||||
const { stdout: statusOutput } = await execAsync(
|
if (await hasUncommittedChanges(worktreePath)) {
|
||||||
"git status --porcelain",
|
const summary = await getChangesSummary(worktreePath);
|
||||||
{ cwd: worktreePath }
|
res.status(400).json({
|
||||||
);
|
success: false,
|
||||||
|
error: `Cannot switch branches: you have uncommitted changes (${summary}). Please commit your changes first.`,
|
||||||
const hasChanges = statusOutput.trim().length > 0;
|
code: "UNCOMMITTED_CHANGES",
|
||||||
let stashed = false;
|
|
||||||
|
|
||||||
// Stash changes if there are any
|
|
||||||
if (hasChanges) {
|
|
||||||
await execAsync("git stash push -m \"auto-stash before branch switch\"", {
|
|
||||||
cwd: worktreePath,
|
|
||||||
});
|
});
|
||||||
stashed = true;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
// Switch to the target branch
|
||||||
// Switch to the branch
|
await execAsync(`git checkout "${branchName}"`, { cwd: worktreePath });
|
||||||
await execAsync(`git checkout ${branchName}`, {
|
|
||||||
cwd: worktreePath,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Pop the stash if we stashed changes
|
res.json({
|
||||||
if (stashed) {
|
success: true,
|
||||||
try {
|
result: {
|
||||||
await execAsync("git stash pop", {
|
previousBranch,
|
||||||
cwd: worktreePath,
|
currentBranch: branchName,
|
||||||
});
|
message: `Switched to branch '${branchName}'`,
|
||||||
} catch (stashPopError) {
|
},
|
||||||
// Stash pop might fail due to conflicts
|
});
|
||||||
const err = stashPopError as { stderr?: string; message?: string };
|
|
||||||
const errorMsg = err.stderr || err.message || "";
|
|
||||||
|
|
||||||
if (errorMsg.includes("CONFLICT") || errorMsg.includes("conflict")) {
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
result: {
|
|
||||||
previousBranch,
|
|
||||||
currentBranch: branchName,
|
|
||||||
message: `Switched to '${branchName}' but stash had conflicts. Please resolve manually.`,
|
|
||||||
stashed: true,
|
|
||||||
stashConflict: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Re-throw if it's not a conflict error
|
|
||||||
throw stashPopError;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const message = stashed
|
|
||||||
? `Switched to branch '${branchName}' (changes stashed and restored)`
|
|
||||||
: `Switched to branch '${branchName}'`;
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
result: {
|
|
||||||
previousBranch,
|
|
||||||
currentBranch: branchName,
|
|
||||||
message,
|
|
||||||
stashed,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (checkoutError) {
|
|
||||||
// If checkout fails and we stashed, try to restore the stash
|
|
||||||
if (stashed) {
|
|
||||||
try {
|
|
||||||
await execAsync("git stash pop", { cwd: worktreePath });
|
|
||||||
} catch {
|
|
||||||
// Ignore stash pop errors during recovery
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw checkoutError;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError(error, "Switch branch failed");
|
logError(error, "Switch branch failed");
|
||||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||||
|
|||||||
@@ -20,6 +20,12 @@ import { buildPromptWithImages } from "../lib/prompt-builder.js";
|
|||||||
import { resolveModelString, DEFAULT_MODELS } from "../lib/model-resolver.js";
|
import { resolveModelString, DEFAULT_MODELS } from "../lib/model-resolver.js";
|
||||||
import { createAutoModeOptions } from "../lib/sdk-options.js";
|
import { createAutoModeOptions } from "../lib/sdk-options.js";
|
||||||
import { isAbortError, classifyError } from "../lib/error-handler.js";
|
import { isAbortError, classifyError } from "../lib/error-handler.js";
|
||||||
|
import {
|
||||||
|
getFeatureDir,
|
||||||
|
getFeaturesDir,
|
||||||
|
getAutomakerDir,
|
||||||
|
getWorktreesDir,
|
||||||
|
} from "../lib/automaker-paths.js";
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
@@ -178,12 +184,18 @@ export class AutoModeService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute a single feature
|
* 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(
|
async executeFeature(
|
||||||
projectPath: string,
|
projectPath: string,
|
||||||
featureId: string,
|
featureId: string,
|
||||||
useWorktrees = true,
|
useWorktrees = true,
|
||||||
isAutoMode = false
|
isAutoMode = false,
|
||||||
|
providedWorktreePath?: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (this.runningFeatures.has(featureId)) {
|
if (this.runningFeatures.has(featureId)) {
|
||||||
throw new Error(`Feature ${featureId} is already running`);
|
throw new Error(`Feature ${featureId} is already running`);
|
||||||
@@ -193,8 +205,13 @@ export class AutoModeService {
|
|||||||
const branchName = `feature/${featureId}`;
|
const branchName = `feature/${featureId}`;
|
||||||
let worktreePath: string | null = null;
|
let worktreePath: string | null = null;
|
||||||
|
|
||||||
// Setup worktree if enabled
|
// Use provided worktree path if given, otherwise setup new worktree if enabled
|
||||||
if (useWorktrees) {
|
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(
|
worktreePath = await this.setupWorktree(
|
||||||
projectPath,
|
projectPath,
|
||||||
featureId,
|
featureId,
|
||||||
@@ -320,14 +337,9 @@ export class AutoModeService {
|
|||||||
featureId: string,
|
featureId: string,
|
||||||
useWorktrees = true
|
useWorktrees = true
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Check if context exists
|
// Check if context exists in external automaker directory
|
||||||
const contextPath = path.join(
|
const featureDir = await getFeatureDir(projectPath, featureId);
|
||||||
projectPath,
|
const contextPath = path.join(featureDir, "agent-output.md");
|
||||||
".automaker",
|
|
||||||
"features",
|
|
||||||
featureId,
|
|
||||||
"agent-output.md"
|
|
||||||
);
|
|
||||||
|
|
||||||
let hasContext = false;
|
let hasContext = false;
|
||||||
try {
|
try {
|
||||||
@@ -359,7 +371,8 @@ export class AutoModeService {
|
|||||||
projectPath: string,
|
projectPath: string,
|
||||||
featureId: string,
|
featureId: string,
|
||||||
prompt: string,
|
prompt: string,
|
||||||
imagePaths?: string[]
|
imagePaths?: string[],
|
||||||
|
providedWorktreePath?: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (this.runningFeatures.has(featureId)) {
|
if (this.runningFeatures.has(featureId)) {
|
||||||
throw new Error(`Feature ${featureId} is already running`);
|
throw new Error(`Feature ${featureId} is already running`);
|
||||||
@@ -367,33 +380,28 @@ export class AutoModeService {
|
|||||||
|
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
|
|
||||||
// Check if worktree exists
|
// Use the provided worktreePath (from the feature's assigned branch)
|
||||||
const worktreePath = path.join(
|
// Fall back to project path if not provided
|
||||||
projectPath,
|
|
||||||
".automaker",
|
|
||||||
"worktrees",
|
|
||||||
featureId
|
|
||||||
);
|
|
||||||
let workDir = projectPath;
|
let workDir = projectPath;
|
||||||
|
let worktreePath: string | null = null;
|
||||||
|
|
||||||
try {
|
if (providedWorktreePath) {
|
||||||
await fs.access(worktreePath);
|
try {
|
||||||
workDir = worktreePath;
|
await fs.access(providedWorktreePath);
|
||||||
} catch {
|
workDir = providedWorktreePath;
|
||||||
// No worktree, use project path
|
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
|
// Load feature info for context
|
||||||
const feature = await this.loadFeature(projectPath, featureId);
|
const feature = await this.loadFeature(projectPath, featureId);
|
||||||
|
|
||||||
// Load previous agent output if it exists
|
// Load previous agent output if it exists (from external automaker)
|
||||||
const contextPath = path.join(
|
const featureDir = await getFeatureDir(projectPath, featureId);
|
||||||
projectPath,
|
const contextPath = path.join(featureDir, "agent-output.md");
|
||||||
".automaker",
|
|
||||||
"features",
|
|
||||||
featureId,
|
|
||||||
"agent-output.md"
|
|
||||||
);
|
|
||||||
let previousContext = "";
|
let previousContext = "";
|
||||||
try {
|
try {
|
||||||
previousContext = await fs.readFile(contextPath, "utf-8");
|
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, {
|
this.runningFeatures.set(featureId, {
|
||||||
featureId,
|
featureId,
|
||||||
projectPath,
|
projectPath,
|
||||||
worktreePath: workDir !== projectPath ? worktreePath : null,
|
worktreePath,
|
||||||
branchName: `feature/${featureId}`,
|
branchName: worktreePath ? path.basename(worktreePath) : null,
|
||||||
abortController,
|
abortController,
|
||||||
isAutoMode: false,
|
isAutoMode: false,
|
||||||
startTime: Date.now(),
|
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
|
// Update feature status to in_progress
|
||||||
await this.updateFeatureStatus(projectPath, featureId, "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[] = [];
|
const copiedImagePaths: string[] = [];
|
||||||
if (imagePaths && imagePaths.length > 0) {
|
if (imagePaths && imagePaths.length > 0) {
|
||||||
const featureImagesDir = path.join(
|
const featureDirForImages = await getFeatureDir(projectPath, featureId);
|
||||||
projectPath,
|
const featureImagesDir = path.join(featureDirForImages, "images");
|
||||||
".automaker",
|
|
||||||
"features",
|
|
||||||
featureId,
|
|
||||||
"images"
|
|
||||||
);
|
|
||||||
|
|
||||||
await fs.mkdir(featureImagesDir, { recursive: true });
|
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
|
// Copy the image
|
||||||
await fs.copyFile(imagePath, destPath);
|
await fs.copyFile(imagePath, destPath);
|
||||||
|
|
||||||
// Store the relative path (like FeatureLoader does)
|
// Store the absolute path (external storage uses absolute paths)
|
||||||
const relativePath = path.join(
|
copiedImagePaths.push(destPath);
|
||||||
".automaker",
|
|
||||||
"features",
|
|
||||||
featureId,
|
|
||||||
"images",
|
|
||||||
filename
|
|
||||||
);
|
|
||||||
copiedImagePaths.push(relativePath);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error(
|
||||||
`[AutoMode] Failed to copy follow-up image ${imagePath}:`,
|
`[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);
|
allImagePaths.push(...allPaths);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save updated feature.json with new images
|
// Save updated feature.json with new images (external automaker)
|
||||||
if (copiedImagePaths.length > 0 && feature) {
|
if (copiedImagePaths.length > 0 && feature) {
|
||||||
const featurePath = path.join(
|
const featureDirForSave = await getFeatureDir(projectPath, featureId);
|
||||||
projectPath,
|
const featurePath = path.join(featureDirForSave, "feature.json");
|
||||||
".automaker",
|
|
||||||
"features",
|
|
||||||
featureId,
|
|
||||||
"feature.json"
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fs.writeFile(featurePath, JSON.stringify(feature, null, 2));
|
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,
|
projectPath: string,
|
||||||
featureId: string
|
featureId: string
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const worktreePath = path.join(
|
// Worktrees are in project dir
|
||||||
projectPath,
|
const worktreePath = path.join(projectPath, ".worktrees", featureId);
|
||||||
".automaker",
|
|
||||||
"worktrees",
|
|
||||||
featureId
|
|
||||||
);
|
|
||||||
let workDir = projectPath;
|
let workDir = projectPath;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -640,24 +627,36 @@ Address the follow-up instructions above. Review the previous work and make the
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Commit feature changes
|
* 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(
|
async commitFeature(
|
||||||
projectPath: string,
|
projectPath: string,
|
||||||
featureId: string
|
featureId: string,
|
||||||
|
providedWorktreePath?: string
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
const worktreePath = path.join(
|
|
||||||
projectPath,
|
|
||||||
".automaker",
|
|
||||||
"worktrees",
|
|
||||||
featureId
|
|
||||||
);
|
|
||||||
let workDir = projectPath;
|
let workDir = projectPath;
|
||||||
|
|
||||||
try {
|
// Use the provided worktree path if given
|
||||||
await fs.access(worktreePath);
|
if (providedWorktreePath) {
|
||||||
workDir = worktreePath;
|
try {
|
||||||
} catch {
|
await fs.access(providedWorktreePath);
|
||||||
// No worktree
|
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 {
|
try {
|
||||||
@@ -708,13 +707,9 @@ Address the follow-up instructions above. Review the previous work and make the
|
|||||||
projectPath: string,
|
projectPath: string,
|
||||||
featureId: string
|
featureId: string
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const contextPath = path.join(
|
// Context is stored in external automaker directory
|
||||||
projectPath,
|
const featureDir = await getFeatureDir(projectPath, featureId);
|
||||||
".automaker",
|
const contextPath = path.join(featureDir, "agent-output.md");
|
||||||
"features",
|
|
||||||
featureId,
|
|
||||||
"agent-output.md"
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fs.access(contextPath);
|
await fs.access(contextPath);
|
||||||
@@ -787,13 +782,10 @@ Format your response as a structured markdown document.`;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save analysis
|
// Save analysis to external automaker directory
|
||||||
const analysisPath = path.join(
|
const automakerDir = await getAutomakerDir(projectPath);
|
||||||
projectPath,
|
const analysisPath = path.join(automakerDir, "project-analysis.md");
|
||||||
".automaker",
|
await fs.mkdir(automakerDir, { recursive: true });
|
||||||
"project-analysis.md"
|
|
||||||
);
|
|
||||||
await fs.mkdir(path.dirname(analysisPath), { recursive: true });
|
|
||||||
await fs.writeFile(analysisPath, analysisResult);
|
await fs.writeFile(analysisPath, analysisResult);
|
||||||
|
|
||||||
this.emitAutoModeEvent("auto_mode_feature_complete", {
|
this.emitAutoModeEvent("auto_mode_feature_complete", {
|
||||||
@@ -852,7 +844,8 @@ Format your response as a structured markdown document.`;
|
|||||||
featureId: string,
|
featureId: string,
|
||||||
branchName: string
|
branchName: string
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
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);
|
const worktreePath = path.join(worktreesDir, featureId);
|
||||||
|
|
||||||
await fs.mkdir(worktreesDir, { recursive: true });
|
await fs.mkdir(worktreesDir, { recursive: true });
|
||||||
@@ -890,13 +883,9 @@ Format your response as a structured markdown document.`;
|
|||||||
projectPath: string,
|
projectPath: string,
|
||||||
featureId: string
|
featureId: string
|
||||||
): Promise<Feature | null> {
|
): Promise<Feature | null> {
|
||||||
const featurePath = path.join(
|
// Features are stored in external automaker directory
|
||||||
projectPath,
|
const featureDir = await getFeatureDir(projectPath, featureId);
|
||||||
".automaker",
|
const featurePath = path.join(featureDir, "feature.json");
|
||||||
"features",
|
|
||||||
featureId,
|
|
||||||
"feature.json"
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await fs.readFile(featurePath, "utf-8");
|
const data = await fs.readFile(featurePath, "utf-8");
|
||||||
@@ -911,13 +900,9 @@ Format your response as a structured markdown document.`;
|
|||||||
featureId: string,
|
featureId: string,
|
||||||
status: string
|
status: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const featurePath = path.join(
|
// Features are stored in external automaker directory
|
||||||
projectPath,
|
const featureDir = await getFeatureDir(projectPath, featureId);
|
||||||
".automaker",
|
const featurePath = path.join(featureDir, "feature.json");
|
||||||
"features",
|
|
||||||
featureId,
|
|
||||||
"feature.json"
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await fs.readFile(featurePath, "utf-8");
|
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<Feature[]> {
|
private async loadPendingFeatures(projectPath: string): Promise<Feature[]> {
|
||||||
const featuresDir = path.join(projectPath, ".automaker", "features");
|
// Features are stored in external automaker directory
|
||||||
|
const featuresDir = await getFeaturesDir(projectPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const entries = await fs.readdir(featuresDir, { withFileTypes: true });
|
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
|
// Execute via provider
|
||||||
const stream = provider.executeQuery(options);
|
const stream = provider.executeQuery(options);
|
||||||
let responseText = "";
|
let responseText = "";
|
||||||
const outputPath = path.join(
|
// Agent output goes to external automaker directory
|
||||||
workDir,
|
// Note: We use the original projectPath here (from config), not workDir
|
||||||
".automaker",
|
// because workDir might be a worktree path
|
||||||
"features",
|
const configProjectPath = this.config?.projectPath || workDir;
|
||||||
featureId,
|
const featureDirForOutput = await getFeatureDir(configProjectPath, featureId);
|
||||||
"agent-output.md"
|
const outputPath = path.join(featureDirForOutput, "agent-output.md");
|
||||||
);
|
|
||||||
|
|
||||||
for await (const msg of stream) {
|
for await (const msg of stream) {
|
||||||
if (msg.type === "assistant" && msg.message?.content) {
|
if (msg.type === "assistant" && msg.message?.content) {
|
||||||
|
|||||||
460
apps/server/src/services/dev-server-service.ts
Normal file
460
apps/server/src/services/dev-server-service.ts
Normal file
@@ -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<string, DevServerInfo> = new Map();
|
||||||
|
private allocatedPorts: Set<number> = new Set();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a port is available (not in use by system or by us)
|
||||||
|
*/
|
||||||
|
private async isPortAvailable(port: number): Promise<boolean> {
|
||||||
|
// 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<string>();
|
||||||
|
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<number> {
|
||||||
|
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<void> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,10 +1,18 @@
|
|||||||
/**
|
/**
|
||||||
* Feature Loader - Handles loading and managing features from individual feature folders
|
* 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 path from "path";
|
||||||
import fs from "fs/promises";
|
import fs from "fs/promises";
|
||||||
|
import {
|
||||||
|
getFeaturesDir,
|
||||||
|
getFeatureDir,
|
||||||
|
getFeatureImagesDir,
|
||||||
|
ensureAutomakerDir,
|
||||||
|
} from "../lib/automaker-paths.js";
|
||||||
|
|
||||||
export interface Feature {
|
export interface Feature {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -21,15 +29,18 @@ export class FeatureLoader {
|
|||||||
/**
|
/**
|
||||||
* Get the features directory path
|
* Get the features directory path
|
||||||
*/
|
*/
|
||||||
getFeaturesDir(projectPath: string): string {
|
async getFeaturesDir(projectPath: string): Promise<string> {
|
||||||
return path.join(projectPath, ".automaker", "features");
|
return getFeaturesDir(projectPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the images directory path for a feature
|
* Get the images directory path for a feature
|
||||||
*/
|
*/
|
||||||
getFeatureImagesDir(projectPath: string, featureId: string): string {
|
async getFeatureImagesDir(
|
||||||
return path.join(this.getFeatureDir(projectPath, featureId), "images");
|
projectPath: string,
|
||||||
|
featureId: string
|
||||||
|
): Promise<string> {
|
||||||
|
return getFeatureImagesDir(projectPath, featureId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -56,15 +67,15 @@ export class FeatureLoader {
|
|||||||
for (const oldPath of oldPathSet) {
|
for (const oldPath of oldPathSet) {
|
||||||
if (!newPathSet.has(oldPath)) {
|
if (!newPathSet.has(oldPath)) {
|
||||||
try {
|
try {
|
||||||
const fullPath = path.isAbsolute(oldPath)
|
// Paths are now absolute
|
||||||
? oldPath
|
await fs.unlink(oldPath);
|
||||||
: path.join(projectPath, oldPath);
|
|
||||||
|
|
||||||
await fs.unlink(fullPath);
|
|
||||||
console.log(`[FeatureLoader] Deleted orphaned image: ${oldPath}`);
|
console.log(`[FeatureLoader] Deleted orphaned image: ${oldPath}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Ignore errors when deleting (file may already be gone)
|
// 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,
|
projectPath: string,
|
||||||
featureId: string,
|
featureId: string,
|
||||||
imagePaths?: Array<string | { path: string; [key: string]: unknown }>
|
imagePaths?: Array<string | { path: string; [key: string]: unknown }>
|
||||||
): Promise<Array<string | { path: string; [key: string]: unknown }> | undefined> {
|
): Promise<
|
||||||
|
Array<string | { path: string; [key: string]: unknown }> | undefined
|
||||||
|
> {
|
||||||
if (!imagePaths || imagePaths.length === 0) {
|
if (!imagePaths || imagePaths.length === 0) {
|
||||||
return imagePaths;
|
return imagePaths;
|
||||||
}
|
}
|
||||||
|
|
||||||
const featureImagesDir = this.getFeatureImagesDir(projectPath, featureId);
|
const featureImagesDir = await this.getFeatureImagesDir(
|
||||||
|
projectPath,
|
||||||
|
featureId
|
||||||
|
);
|
||||||
await fs.mkdir(featureImagesDir, { recursive: true });
|
await fs.mkdir(featureImagesDir, { recursive: true });
|
||||||
|
|
||||||
const updatedPaths: Array<string | { path: string; [key: string]: unknown }> = [];
|
const updatedPaths: Array<string | { path: string; [key: string]: unknown }> =
|
||||||
|
[];
|
||||||
|
|
||||||
for (const imagePath of imagePaths) {
|
for (const imagePath of imagePaths) {
|
||||||
try {
|
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/`)) {
|
if (originalPath.includes(`/features/${featureId}/images/`)) {
|
||||||
updatedPaths.push(imagePath);
|
updatedPaths.push(imagePath);
|
||||||
continue;
|
continue;
|
||||||
@@ -106,18 +124,21 @@ export class FeatureLoader {
|
|||||||
try {
|
try {
|
||||||
await fs.access(fullOriginalPath);
|
await fs.access(fullOriginalPath);
|
||||||
} catch {
|
} catch {
|
||||||
console.warn(`[FeatureLoader] Image not found, skipping: ${fullOriginalPath}`);
|
console.warn(
|
||||||
|
`[FeatureLoader] Image not found, skipping: ${fullOriginalPath}`
|
||||||
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get filename and create new path
|
// Get filename and create new path in external storage
|
||||||
const filename = path.basename(originalPath);
|
const filename = path.basename(originalPath);
|
||||||
const newPath = path.join(featureImagesDir, filename);
|
const newPath = path.join(featureImagesDir, filename);
|
||||||
const relativePath = `.automaker/features/${featureId}/images/${filename}`;
|
|
||||||
|
|
||||||
// Copy the file
|
// Copy the file
|
||||||
await fs.copyFile(fullOriginalPath, newPath);
|
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 to delete the original temp file
|
||||||
try {
|
try {
|
||||||
@@ -126,11 +147,11 @@ export class FeatureLoader {
|
|||||||
// Ignore errors when deleting temp file
|
// 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") {
|
if (typeof imagePath === "string") {
|
||||||
updatedPaths.push(relativePath);
|
updatedPaths.push(newPath);
|
||||||
} else {
|
} else {
|
||||||
updatedPaths.push({ ...imagePath, path: relativePath });
|
updatedPaths.push({ ...imagePath, path: newPath });
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[FeatureLoader] Failed to migrate image:`, error);
|
console.error(`[FeatureLoader] Failed to migrate image:`, error);
|
||||||
@@ -145,22 +166,30 @@ export class FeatureLoader {
|
|||||||
/**
|
/**
|
||||||
* Get the path to a specific feature folder
|
* Get the path to a specific feature folder
|
||||||
*/
|
*/
|
||||||
getFeatureDir(projectPath: string, featureId: string): string {
|
async getFeatureDir(projectPath: string, featureId: string): Promise<string> {
|
||||||
return path.join(this.getFeaturesDir(projectPath), featureId);
|
return getFeatureDir(projectPath, featureId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the path to a feature's feature.json file
|
* Get the path to a feature's feature.json file
|
||||||
*/
|
*/
|
||||||
getFeatureJsonPath(projectPath: string, featureId: string): string {
|
async getFeatureJsonPath(
|
||||||
return path.join(this.getFeatureDir(projectPath, featureId), "feature.json");
|
projectPath: string,
|
||||||
|
featureId: string
|
||||||
|
): Promise<string> {
|
||||||
|
const featureDir = await this.getFeatureDir(projectPath, featureId);
|
||||||
|
return path.join(featureDir, "feature.json");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the path to a feature's agent-output.md file
|
* Get the path to a feature's agent-output.md file
|
||||||
*/
|
*/
|
||||||
getAgentOutputPath(projectPath: string, featureId: string): string {
|
async getAgentOutputPath(
|
||||||
return path.join(this.getFeatureDir(projectPath, featureId), "agent-output.md");
|
projectPath: string,
|
||||||
|
featureId: string
|
||||||
|
): Promise<string> {
|
||||||
|
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<Feature[]> {
|
async getAll(projectPath: string): Promise<Feature[]> {
|
||||||
try {
|
try {
|
||||||
const featuresDir = this.getFeaturesDir(projectPath);
|
const featuresDir = await this.getFeaturesDir(projectPath);
|
||||||
|
|
||||||
// Check if features directory exists
|
// Check if features directory exists
|
||||||
try {
|
try {
|
||||||
@@ -192,7 +221,10 @@ export class FeatureLoader {
|
|||||||
const features: Feature[] = [];
|
const features: Feature[] = [];
|
||||||
for (const dir of featureDirs) {
|
for (const dir of featureDirs) {
|
||||||
const featureId = dir.name;
|
const featureId = dir.name;
|
||||||
const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId);
|
const featureJsonPath = await this.getFeatureJsonPath(
|
||||||
|
projectPath,
|
||||||
|
featureId
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const content = await fs.readFile(featureJsonPath, "utf-8");
|
const content = await fs.readFile(featureJsonPath, "utf-8");
|
||||||
@@ -241,14 +273,20 @@ export class FeatureLoader {
|
|||||||
*/
|
*/
|
||||||
async get(projectPath: string, featureId: string): Promise<Feature | null> {
|
async get(projectPath: string, featureId: string): Promise<Feature | null> {
|
||||||
try {
|
try {
|
||||||
const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId);
|
const featureJsonPath = await this.getFeatureJsonPath(
|
||||||
|
projectPath,
|
||||||
|
featureId
|
||||||
|
);
|
||||||
const content = await fs.readFile(featureJsonPath, "utf-8");
|
const content = await fs.readFile(featureJsonPath, "utf-8");
|
||||||
return JSON.parse(content);
|
return JSON.parse(content);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
console.error(`[FeatureLoader] Failed to get feature ${featureId}:`, error);
|
console.error(
|
||||||
|
`[FeatureLoader] Failed to get feature ${featureId}:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -256,14 +294,16 @@ export class FeatureLoader {
|
|||||||
/**
|
/**
|
||||||
* Create a new feature
|
* Create a new feature
|
||||||
*/
|
*/
|
||||||
async create(projectPath: string, featureData: Partial<Feature>): Promise<Feature> {
|
async create(
|
||||||
|
projectPath: string,
|
||||||
|
featureData: Partial<Feature>
|
||||||
|
): Promise<Feature> {
|
||||||
const featureId = featureData.id || this.generateFeatureId();
|
const featureId = featureData.id || this.generateFeatureId();
|
||||||
const featureDir = this.getFeatureDir(projectPath, featureId);
|
const featureDir = await this.getFeatureDir(projectPath, featureId);
|
||||||
const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId);
|
const featureJsonPath = await this.getFeatureJsonPath(projectPath, featureId);
|
||||||
|
|
||||||
// Ensure features directory exists
|
// Ensure automaker directory exists
|
||||||
const featuresDir = this.getFeaturesDir(projectPath);
|
await ensureAutomakerDir(projectPath);
|
||||||
await fs.mkdir(featuresDir, { recursive: true });
|
|
||||||
|
|
||||||
// Create feature directory
|
// Create feature directory
|
||||||
await fs.mkdir(featureDir, { recursive: true });
|
await fs.mkdir(featureDir, { recursive: true });
|
||||||
@@ -285,7 +325,11 @@ export class FeatureLoader {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Write feature.json
|
// 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}`);
|
console.log(`[FeatureLoader] Created feature ${featureId}`);
|
||||||
return feature;
|
return feature;
|
||||||
@@ -326,11 +370,13 @@ export class FeatureLoader {
|
|||||||
const updatedFeature: Feature = {
|
const updatedFeature: Feature = {
|
||||||
...feature,
|
...feature,
|
||||||
...updates,
|
...updates,
|
||||||
...(updatedImagePaths !== undefined ? { imagePaths: updatedImagePaths } : {}),
|
...(updatedImagePaths !== undefined
|
||||||
|
? { imagePaths: updatedImagePaths }
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Write back to file
|
// Write back to file
|
||||||
const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId);
|
const featureJsonPath = await this.getFeatureJsonPath(projectPath, featureId);
|
||||||
await fs.writeFile(
|
await fs.writeFile(
|
||||||
featureJsonPath,
|
featureJsonPath,
|
||||||
JSON.stringify(updatedFeature, null, 2),
|
JSON.stringify(updatedFeature, null, 2),
|
||||||
@@ -346,12 +392,15 @@ export class FeatureLoader {
|
|||||||
*/
|
*/
|
||||||
async delete(projectPath: string, featureId: string): Promise<boolean> {
|
async delete(projectPath: string, featureId: string): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const featureDir = this.getFeatureDir(projectPath, featureId);
|
const featureDir = await this.getFeatureDir(projectPath, featureId);
|
||||||
await fs.rm(featureDir, { recursive: true, force: true });
|
await fs.rm(featureDir, { recursive: true, force: true });
|
||||||
console.log(`[FeatureLoader] Deleted feature ${featureId}`);
|
console.log(`[FeatureLoader] Deleted feature ${featureId}`);
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[FeatureLoader] Failed to delete feature ${featureId}:`, error);
|
console.error(
|
||||||
|
`[FeatureLoader] Failed to delete feature ${featureId}:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -364,7 +413,10 @@ export class FeatureLoader {
|
|||||||
featureId: string
|
featureId: string
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const agentOutputPath = this.getAgentOutputPath(projectPath, featureId);
|
const agentOutputPath = await this.getAgentOutputPath(
|
||||||
|
projectPath,
|
||||||
|
featureId
|
||||||
|
);
|
||||||
const content = await fs.readFile(agentOutputPath, "utf-8");
|
const content = await fs.readFile(agentOutputPath, "utf-8");
|
||||||
return content;
|
return content;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -387,19 +439,25 @@ export class FeatureLoader {
|
|||||||
featureId: string,
|
featureId: string,
|
||||||
content: string
|
content: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const featureDir = this.getFeatureDir(projectPath, featureId);
|
const featureDir = await this.getFeatureDir(projectPath, featureId);
|
||||||
await fs.mkdir(featureDir, { recursive: true });
|
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");
|
await fs.writeFile(agentOutputPath, content, "utf-8");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete agent output for a feature
|
* Delete agent output for a feature
|
||||||
*/
|
*/
|
||||||
async deleteAgentOutput(projectPath: string, featureId: string): Promise<void> {
|
async deleteAgentOutput(
|
||||||
|
projectPath: string,
|
||||||
|
featureId: string
|
||||||
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const agentOutputPath = this.getAgentOutputPath(projectPath, featureId);
|
const agentOutputPath = await this.getAgentOutputPath(
|
||||||
|
projectPath,
|
||||||
|
featureId
|
||||||
|
);
|
||||||
await fs.unlink(agentOutputPath);
|
await fs.unlink(agentOutputPath);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||||
|
|||||||
Reference in New Issue
Block a user