mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +00:00
feat: enhance worktree management and UI integration
- Refactored BoardView and WorktreeSelector components for improved readability and maintainability, including consistent formatting and structure. - Updated feature handling to ensure correct worktree assignment and reset logic when worktrees are deleted, enhancing user experience. - Enhanced KanbanCard to display priority badges with improved styling and layout. - Removed deprecated revert feature logic from the server and client, streamlining the codebase. - Introduced new tests for feature lifecycle and worktree integration, ensuring robust functionality and error handling.
This commit is contained in:
@@ -10,6 +10,7 @@ import {
|
||||
} from "@dnd-kit/core";
|
||||
import { useAppStore, Feature } from "@/store/app-store";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
import { pathsEqual } from "@/lib/utils";
|
||||
import { BoardBackgroundModal } from "@/components/dialogs/board-background-modal";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { useAutoMode } from "@/hooks/use-auto-mode";
|
||||
@@ -51,7 +52,9 @@ import {
|
||||
} from "./board-view/hooks";
|
||||
|
||||
// Stable empty array to avoid infinite loop in selector
|
||||
const EMPTY_WORKTREES: ReturnType<ReturnType<typeof useAppStore.getState>['getWorktrees']> = [];
|
||||
const EMPTY_WORKTREES: ReturnType<
|
||||
ReturnType<typeof useAppStore.getState>["getWorktrees"]
|
||||
> = [];
|
||||
|
||||
export function BoardView() {
|
||||
const {
|
||||
@@ -95,9 +98,12 @@ export function BoardView() {
|
||||
useState<Feature | null>(null);
|
||||
|
||||
// Worktree dialog states
|
||||
const [showCreateWorktreeDialog, setShowCreateWorktreeDialog] = useState(false);
|
||||
const [showDeleteWorktreeDialog, setShowDeleteWorktreeDialog] = useState(false);
|
||||
const [showCommitWorktreeDialog, setShowCommitWorktreeDialog] = useState(false);
|
||||
const [showCreateWorktreeDialog, setShowCreateWorktreeDialog] =
|
||||
useState(false);
|
||||
const [showDeleteWorktreeDialog, setShowDeleteWorktreeDialog] =
|
||||
useState(false);
|
||||
const [showCommitWorktreeDialog, setShowCommitWorktreeDialog] =
|
||||
useState(false);
|
||||
const [showCreatePRDialog, setShowCreatePRDialog] = useState(false);
|
||||
const [showCreateBranchDialog, setShowCreateBranchDialog] = useState(false);
|
||||
const [selectedWorktreeForAction, setSelectedWorktreeForAction] = useState<{
|
||||
@@ -251,31 +257,25 @@ export function BoardView() {
|
||||
}, [currentProject, worktreeRefreshKey]);
|
||||
|
||||
// Custom collision detection that prioritizes columns over cards
|
||||
const collisionDetectionStrategy = useCallback(
|
||||
(args: any) => {
|
||||
// First, check if pointer is within a column
|
||||
const pointerCollisions = pointerWithin(args);
|
||||
const columnCollisions = pointerCollisions.filter((collision: any) =>
|
||||
COLUMNS.some((col) => col.id === collision.id)
|
||||
);
|
||||
const collisionDetectionStrategy = useCallback((args: any) => {
|
||||
// First, check if pointer is within a column
|
||||
const pointerCollisions = pointerWithin(args);
|
||||
const columnCollisions = pointerCollisions.filter((collision: any) =>
|
||||
COLUMNS.some((col) => col.id === collision.id)
|
||||
);
|
||||
|
||||
// If we found a column collision, use that
|
||||
if (columnCollisions.length > 0) {
|
||||
return columnCollisions;
|
||||
}
|
||||
// If we found a column collision, use that
|
||||
if (columnCollisions.length > 0) {
|
||||
return columnCollisions;
|
||||
}
|
||||
|
||||
// Otherwise, use rectangle intersection for cards
|
||||
return rectIntersection(args);
|
||||
},
|
||||
[]
|
||||
);
|
||||
// Otherwise, use rectangle intersection for cards
|
||||
return rectIntersection(args);
|
||||
}, []);
|
||||
|
||||
// Use persistence hook
|
||||
const {
|
||||
persistFeatureCreate,
|
||||
persistFeatureUpdate,
|
||||
persistFeatureDelete,
|
||||
} = useBoardPersistence({ currentProject });
|
||||
const { persistFeatureCreate, persistFeatureUpdate, persistFeatureDelete } =
|
||||
useBoardPersistence({ currentProject });
|
||||
|
||||
// Get in-progress features for keyboard shortcuts (needed before actions hook)
|
||||
const inProgressFeaturesForShortcuts = useMemo(() => {
|
||||
@@ -287,20 +287,24 @@ export function BoardView() {
|
||||
|
||||
// Get current worktree info (path and branch) for filtering features
|
||||
// This needs to be before useBoardActions so we can pass currentWorktreeBranch
|
||||
const currentWorktreeInfo = currentProject ? getCurrentWorktree(currentProject.path) : null;
|
||||
const currentWorktreeInfo = currentProject
|
||||
? getCurrentWorktree(currentProject.path)
|
||||
: null;
|
||||
const currentWorktreePath = currentWorktreeInfo?.path ?? null;
|
||||
const currentWorktreeBranch = currentWorktreeInfo?.branch ?? null;
|
||||
const worktreesByProject = useAppStore((s) => s.worktreesByProject);
|
||||
const worktrees = useMemo(
|
||||
() => (currentProject ? (worktreesByProject[currentProject.path] ?? EMPTY_WORKTREES) : EMPTY_WORKTREES),
|
||||
() =>
|
||||
currentProject
|
||||
? worktreesByProject[currentProject.path] ?? EMPTY_WORKTREES
|
||||
: EMPTY_WORKTREES,
|
||||
[currentProject, worktreesByProject]
|
||||
);
|
||||
|
||||
// Get the branch for the currently selected worktree (for defaulting new features)
|
||||
// Use the branch from currentWorktreeInfo, or fall back to main worktree's branch
|
||||
const selectedWorktreeBranch = currentWorktreeBranch
|
||||
|| worktrees.find(w => w.isMain)?.branch
|
||||
|| "main";
|
||||
const selectedWorktreeBranch =
|
||||
currentWorktreeBranch || worktrees.find((w) => w.isMain)?.branch || "main";
|
||||
|
||||
// Extract all action handlers into a hook
|
||||
const {
|
||||
@@ -315,7 +319,6 @@ export function BoardView() {
|
||||
handleOpenFollowUp,
|
||||
handleSendFollowUp,
|
||||
handleCommitFeature,
|
||||
handleRevertFeature,
|
||||
handleMergeFeature,
|
||||
handleCompleteFeature,
|
||||
handleUnarchiveFeature,
|
||||
@@ -452,7 +455,11 @@ export function BoardView() {
|
||||
setShowCreateBranchDialog(true);
|
||||
}}
|
||||
runningFeatureIds={runningAutoTasks}
|
||||
features={hookFeatures.map(f => ({ id: f.id, worktreePath: f.worktreePath, branchName: f.branchName }))}
|
||||
features={hookFeatures.map((f) => ({
|
||||
id: f.id,
|
||||
worktreePath: f.worktreePath,
|
||||
branchName: f.branchName,
|
||||
}))}
|
||||
/>
|
||||
|
||||
{/* Main Content Area */}
|
||||
@@ -626,10 +633,17 @@ export function BoardView() {
|
||||
isCurrent: false,
|
||||
hasWorktree: true,
|
||||
};
|
||||
setWorktrees(currentProject.path, [...currentWorktrees, newWorktreeInfo]);
|
||||
setWorktrees(currentProject.path, [
|
||||
...currentWorktrees,
|
||||
newWorktreeInfo,
|
||||
]);
|
||||
|
||||
// Now set the current worktree with both path and branch
|
||||
setCurrentWorktree(currentProject.path, newWorktree.path, newWorktree.branch);
|
||||
setCurrentWorktree(
|
||||
currentProject.path,
|
||||
newWorktree.path,
|
||||
newWorktree.branch
|
||||
);
|
||||
|
||||
// Trigger refresh to get full worktree details (hasChanges, etc.)
|
||||
setWorktreeRefreshKey((k) => k + 1);
|
||||
@@ -642,7 +656,24 @@ export function BoardView() {
|
||||
onOpenChange={setShowDeleteWorktreeDialog}
|
||||
projectPath={currentProject.path}
|
||||
worktree={selectedWorktreeForAction}
|
||||
onDeleted={() => {
|
||||
onDeleted={(deletedWorktree, _deletedBranch) => {
|
||||
// Reset features that were assigned to the deleted worktree
|
||||
hookFeatures.forEach((feature) => {
|
||||
const matchesByPath =
|
||||
feature.worktreePath &&
|
||||
pathsEqual(feature.worktreePath, deletedWorktree.path);
|
||||
const matchesByBranch =
|
||||
feature.branchName === deletedWorktree.branch;
|
||||
|
||||
if (matchesByPath || matchesByBranch) {
|
||||
// Reset the feature's worktree assignment
|
||||
persistFeatureUpdate(feature.id, {
|
||||
branchName: null as unknown as string | undefined,
|
||||
worktreePath: null as unknown as string | undefined,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
setWorktreeRefreshKey((k) => k + 1);
|
||||
setSelectedWorktreeForAction(null);
|
||||
}}
|
||||
|
||||
@@ -328,8 +328,8 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute px-2 py-1 text-sm font-bold rounded-md flex items-center justify-center z-10",
|
||||
"top-2 left-2 min-w-[36px]",
|
||||
"absolute px-2 rounded-md z-10",
|
||||
"top-2 left-2",
|
||||
feature.priority === 1 &&
|
||||
"bg-red-500/20 text-red-500 border-2 border-red-500/50",
|
||||
feature.priority === 2 &&
|
||||
@@ -337,9 +337,22 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
feature.priority === 3 &&
|
||||
"bg-blue-500/20 text-blue-500 border-2 border-blue-500/50"
|
||||
)}
|
||||
style={{ height: "28px" }}
|
||||
data-testid={`priority-badge-${feature.id}`}
|
||||
>
|
||||
P{feature.priority}
|
||||
{Array.from({ length: 4 - feature.priority }).map((_, i) => (
|
||||
<ChevronUp
|
||||
key={i}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
top: `${2 + i * 3}px`,
|
||||
width: "12px",
|
||||
height: "12px",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="text-xs">
|
||||
@@ -347,8 +360,8 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
{feature.priority === 1
|
||||
? "High Priority"
|
||||
: feature.priority === 2
|
||||
? "Medium Priority"
|
||||
: "Low Priority"}
|
||||
? "Medium Priority"
|
||||
: "Low Priority"}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
@@ -1095,7 +1108,6 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
</Card>
|
||||
);
|
||||
|
||||
|
||||
@@ -101,7 +101,9 @@ export function WorktreeSelector({
|
||||
const [behindCount, setBehindCount] = useState(0);
|
||||
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
|
||||
const [branchFilter, setBranchFilter] = useState("");
|
||||
const [runningDevServers, setRunningDevServers] = useState<Map<string, DevServerInfo>>(new Map());
|
||||
const [runningDevServers, setRunningDevServers] = useState<
|
||||
Map<string, DevServerInfo>
|
||||
>(new Map());
|
||||
const [defaultEditorName, setDefaultEditorName] = useState<string>("Editor");
|
||||
const currentWorktree = useAppStore((s) => s.getCurrentWorktree(projectPath));
|
||||
const setCurrentWorktree = useAppStore((s) => s.setCurrentWorktree);
|
||||
@@ -197,18 +199,37 @@ export function WorktreeSelector({
|
||||
}
|
||||
}, [refreshTrigger, fetchWorktrees]);
|
||||
|
||||
// Initialize selection to main if not set
|
||||
// Initialize selection to main if not set OR if the stored worktree no longer exists
|
||||
// This handles stale data (e.g., a worktree that was deleted)
|
||||
useEffect(() => {
|
||||
if (worktrees.length > 0 && currentWorktree === undefined) {
|
||||
const mainWorktree = worktrees.find(w => w.isMain);
|
||||
const mainBranch = mainWorktree?.branch || "main";
|
||||
setCurrentWorktree(projectPath, null, mainBranch); // null = main worktree
|
||||
if (worktrees.length > 0) {
|
||||
const currentPath = currentWorktree?.path;
|
||||
|
||||
// Check if the currently selected worktree still exists
|
||||
// null path means main (which always exists if worktrees has items)
|
||||
// Non-null path means we need to verify it exists in the worktrees list
|
||||
const currentWorktreeExists = currentPath === null
|
||||
? true
|
||||
: worktrees.some((w) => !w.isMain && pathsEqual(w.path, currentPath));
|
||||
|
||||
// Reset to main if:
|
||||
// 1. No worktree is set (currentWorktree is null/undefined)
|
||||
// 2. Current worktree has a path that doesn't exist in the list (stale data)
|
||||
if (currentWorktree == null || (currentPath !== null && !currentWorktreeExists)) {
|
||||
const mainWorktree = worktrees.find((w) => w.isMain);
|
||||
const mainBranch = mainWorktree?.branch || "main";
|
||||
setCurrentWorktree(projectPath, null, mainBranch); // null = main worktree
|
||||
}
|
||||
}
|
||||
}, [worktrees, currentWorktree, projectPath, setCurrentWorktree]);
|
||||
|
||||
const handleSelectWorktree = async (worktree: WorktreeInfo) => {
|
||||
// Simply select the worktree in the UI with both path and branch
|
||||
setCurrentWorktree(projectPath, worktree.isMain ? null : worktree.path, worktree.branch);
|
||||
setCurrentWorktree(
|
||||
projectPath,
|
||||
worktree.isMain ? null : worktree.path,
|
||||
worktree.branch
|
||||
);
|
||||
};
|
||||
|
||||
const handleStartDevServer = async (worktree: WorktreeInfo) => {
|
||||
@@ -326,57 +347,6 @@ export function WorktreeSelector({
|
||||
});
|
||||
};
|
||||
|
||||
const handleActivateWorktree = async (worktree: WorktreeInfo) => {
|
||||
if (isActivating) return;
|
||||
setIsActivating(true);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.activate) {
|
||||
toast.error("Activate worktree API not available");
|
||||
return;
|
||||
}
|
||||
const result = await api.worktree.activate(projectPath, worktree.path);
|
||||
if (result.success && result.result) {
|
||||
toast.success(result.result.message);
|
||||
// After activation, refresh to show updated state
|
||||
fetchWorktrees();
|
||||
} else {
|
||||
toast.error(result.error || "Failed to activate worktree");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Activate worktree failed:", error);
|
||||
toast.error("Failed to activate worktree");
|
||||
} finally {
|
||||
setIsActivating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSwitchToBranch = async (branchName: string) => {
|
||||
if (isActivating) return;
|
||||
setIsActivating(true);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.activate) {
|
||||
toast.error("Activate API not available");
|
||||
return;
|
||||
}
|
||||
// Pass null as worktreePath to switch to a branch without a worktree
|
||||
// We'll need to update the activate endpoint to handle this case
|
||||
const result = await api.worktree.switchBranch(projectPath, branchName);
|
||||
if (result.success && result.result) {
|
||||
toast.success(result.result.message);
|
||||
fetchWorktrees();
|
||||
} else {
|
||||
toast.error(result.error || "Failed to switch branch");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Switch branch failed:", error);
|
||||
toast.error("Failed to switch branch");
|
||||
} finally {
|
||||
setIsActivating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenInEditor = async (worktree: WorktreeInfo) => {
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
@@ -395,7 +365,10 @@ export function WorktreeSelector({
|
||||
}
|
||||
};
|
||||
|
||||
const handleSwitchBranch = async (worktree: WorktreeInfo, branchName: string) => {
|
||||
const handleSwitchBranch = async (
|
||||
worktree: WorktreeInfo,
|
||||
branchName: string
|
||||
) => {
|
||||
if (isSwitching || branchName === worktree.branch) return;
|
||||
setIsSwitching(true);
|
||||
try {
|
||||
@@ -478,13 +451,14 @@ export function WorktreeSelector({
|
||||
? worktrees.find((w) => pathsEqual(w.path, currentWorktreePath))
|
||||
: worktrees.find((w) => w.isMain);
|
||||
|
||||
|
||||
// Render a worktree tab with branch selector (for main) and actions dropdown
|
||||
const renderWorktreeTab = (worktree: WorktreeInfo) => {
|
||||
// Selection is based on UI state, not git's current branch
|
||||
// Default to main selected if currentWorktree is null/undefined or path is null
|
||||
const isSelected = worktree.isMain
|
||||
? currentWorktree === null || currentWorktree === undefined || currentWorktree.path === null
|
||||
? currentWorktree === null ||
|
||||
currentWorktree === undefined ||
|
||||
currentWorktree.path === null
|
||||
: pathsEqual(worktree.path, currentWorktreePath);
|
||||
|
||||
const isRunning = hasRunningFeatures(worktree);
|
||||
@@ -508,7 +482,9 @@ export function WorktreeSelector({
|
||||
title="Click to preview main"
|
||||
>
|
||||
{isRunning && <Loader2 className="w-3 h-3 animate-spin" />}
|
||||
{isActivating && !isRunning && <RefreshCw 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">
|
||||
@@ -517,12 +493,14 @@ export function WorktreeSelector({
|
||||
)}
|
||||
</Button>
|
||||
{/* Branch switch dropdown button */}
|
||||
<DropdownMenu onOpenChange={(open) => {
|
||||
if (open) {
|
||||
fetchBranches(worktree.path);
|
||||
setBranchFilter("");
|
||||
}
|
||||
}}>
|
||||
<DropdownMenu
|
||||
onOpenChange={(open) => {
|
||||
if (open) {
|
||||
fetchBranches(worktree.path);
|
||||
setBranchFilter("");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant={isSelected ? "default" : "outline"}
|
||||
@@ -538,7 +516,9 @@ export function WorktreeSelector({
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-64">
|
||||
<DropdownMenuLabel className="text-xs">Switch Branch</DropdownMenuLabel>
|
||||
<DropdownMenuLabel className="text-xs">
|
||||
Switch Branch
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{/* Search input */}
|
||||
<div className="px-2 py-1.5">
|
||||
@@ -563,33 +543,43 @@ export function WorktreeSelector({
|
||||
<RefreshCw className="w-3.5 h-3.5 mr-2 animate-spin" />
|
||||
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>
|
||||
) : (
|
||||
(() => {
|
||||
const filteredBranches = branches.filter((b) =>
|
||||
b.name
|
||||
.toLowerCase()
|
||||
.includes(branchFilter.toLowerCase())
|
||||
);
|
||||
}
|
||||
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>
|
||||
));
|
||||
})()}
|
||||
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>
|
||||
));
|
||||
})()
|
||||
)}
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
@@ -615,12 +605,16 @@ export function WorktreeSelector({
|
||||
)}
|
||||
onClick={() => handleSelectWorktree(worktree)}
|
||||
disabled={isActivating}
|
||||
title={worktree.hasWorktree
|
||||
? "Click to switch to this worktree's branch"
|
||||
: "Click to switch to this branch"}
|
||||
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" />}
|
||||
{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">
|
||||
@@ -642,18 +636,22 @@ export function WorktreeSelector({
|
||||
"text-green-500"
|
||||
)}
|
||||
onClick={() => handleOpenDevServerUrl(worktree)}
|
||||
title={`Open dev server (port ${runningDevServers.get(getWorktreeKey(worktree))?.port})`}
|
||||
title={`Open dev server (port ${
|
||||
runningDevServers.get(getWorktreeKey(worktree))?.port
|
||||
})`}
|
||||
>
|
||||
<Globe className="w-3 h-3" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Actions dropdown */}
|
||||
<DropdownMenu onOpenChange={(open) => {
|
||||
if (open) {
|
||||
fetchBranches(worktree.path);
|
||||
}
|
||||
}}>
|
||||
<DropdownMenu
|
||||
onOpenChange={(open) => {
|
||||
if (open) {
|
||||
fetchBranches(worktree.path);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant={isSelected ? "default" : "outline"}
|
||||
@@ -673,7 +671,8 @@ export function WorktreeSelector({
|
||||
<>
|
||||
<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})
|
||||
Dev Server Running (:
|
||||
{runningDevServers.get(getWorktreeKey(worktree))?.port})
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleOpenDevServerUrl(worktree)}
|
||||
@@ -698,7 +697,12 @@ export function WorktreeSelector({
|
||||
disabled={isStartingDevServer}
|
||||
className="text-xs"
|
||||
>
|
||||
<Play className={cn("w-3.5 h-3.5 mr-2", isStartingDevServer && "animate-pulse")} />
|
||||
<Play
|
||||
className={cn(
|
||||
"w-3.5 h-3.5 mr-2",
|
||||
isStartingDevServer && "animate-pulse"
|
||||
)}
|
||||
/>
|
||||
{isStartingDevServer ? "Starting..." : "Start Dev Server"}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
@@ -710,7 +714,9 @@ export function WorktreeSelector({
|
||||
disabled={isPulling}
|
||||
className="text-xs"
|
||||
>
|
||||
<Download className={cn("w-3.5 h-3.5 mr-2", isPulling && "animate-pulse")} />
|
||||
<Download
|
||||
className={cn("w-3.5 h-3.5 mr-2", isPulling && "animate-pulse")}
|
||||
/>
|
||||
{isPulling ? "Pulling..." : "Pull"}
|
||||
{behindCount > 0 && (
|
||||
<span className="ml-auto text-[10px] bg-muted px-1.5 py-0.5 rounded">
|
||||
@@ -724,7 +730,9 @@ export function WorktreeSelector({
|
||||
disabled={isPushing || aheadCount === 0}
|
||||
className="text-xs"
|
||||
>
|
||||
<Upload className={cn("w-3.5 h-3.5 mr-2", isPushing && "animate-pulse")} />
|
||||
<Upload
|
||||
className={cn("w-3.5 h-3.5 mr-2", isPushing && "animate-pulse")}
|
||||
/>
|
||||
{isPushing ? "Pushing..." : "Push"}
|
||||
{aheadCount > 0 && (
|
||||
<span className="ml-auto text-[10px] bg-primary/20 text-primary px-1.5 py-0.5 rounded">
|
||||
@@ -815,7 +823,9 @@ export function WorktreeSelector({
|
||||
disabled={isLoading}
|
||||
title="Refresh worktrees"
|
||||
>
|
||||
<RefreshCw className={cn("w-3.5 h-3.5", isLoading && "animate-spin")} />
|
||||
<RefreshCw
|
||||
className={cn("w-3.5 h-3.5", isLoading && "animate-spin")}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -29,7 +29,7 @@ interface DeleteWorktreeDialogProps {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
projectPath: string;
|
||||
worktree: WorktreeInfo | null;
|
||||
onDeleted: () => void;
|
||||
onDeleted: (deletedWorktree: WorktreeInfo, deletedBranch: boolean) => void;
|
||||
}
|
||||
|
||||
export function DeleteWorktreeDialog({
|
||||
@@ -64,7 +64,7 @@ export function DeleteWorktreeDialog({
|
||||
? `Branch "${worktree.branch}" was also deleted`
|
||||
: `Branch "${worktree.branch}" was kept`,
|
||||
});
|
||||
onDeleted();
|
||||
onDeleted(worktree, deleteBranch);
|
||||
onOpenChange(false);
|
||||
setDeleteBranch(false);
|
||||
} else {
|
||||
|
||||
@@ -137,7 +137,7 @@ export function useBoardActions({
|
||||
);
|
||||
|
||||
const handleAddFeature = useCallback(
|
||||
(featureData: {
|
||||
async (featureData: {
|
||||
category: string;
|
||||
description: string;
|
||||
steps: string[];
|
||||
@@ -149,19 +149,38 @@ export function useBoardActions({
|
||||
branchName: string;
|
||||
priority: number;
|
||||
}) => {
|
||||
let worktreePath: string | undefined;
|
||||
|
||||
// If worktrees are enabled and a non-main branch is selected, create the worktree
|
||||
if (useWorktrees && featureData.branchName) {
|
||||
const branchName = featureData.branchName;
|
||||
if (branchName !== "main" && branchName !== "master") {
|
||||
// Create a temporary feature-like object for getOrCreateWorktreeForFeature
|
||||
const tempFeature = { branchName } as Feature;
|
||||
const path = await getOrCreateWorktreeForFeature(tempFeature);
|
||||
if (path && path !== projectPath) {
|
||||
worktreePath = path;
|
||||
// Refresh worktree selector after creating worktree
|
||||
onWorktreeCreated?.();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const newFeatureData = {
|
||||
...featureData,
|
||||
status: "backlog" as const,
|
||||
worktreePath,
|
||||
};
|
||||
const createdFeature = addFeature(newFeatureData);
|
||||
persistFeatureCreate(createdFeature);
|
||||
// Must await to ensure feature exists on server before user can drag it
|
||||
await persistFeatureCreate(createdFeature);
|
||||
saveCategory(featureData.category);
|
||||
},
|
||||
[addFeature, persistFeatureCreate, saveCategory]
|
||||
[addFeature, persistFeatureCreate, saveCategory, useWorktrees, getOrCreateWorktreeForFeature, projectPath, onWorktreeCreated]
|
||||
);
|
||||
|
||||
const handleUpdateFeature = useCallback(
|
||||
(
|
||||
async (
|
||||
featureId: string,
|
||||
updates: {
|
||||
category: string;
|
||||
@@ -175,14 +194,53 @@ export function useBoardActions({
|
||||
priority: number;
|
||||
}
|
||||
) => {
|
||||
updateFeature(featureId, updates);
|
||||
persistFeatureUpdate(featureId, updates);
|
||||
// Get the current feature to check if branch is changing
|
||||
const currentFeature = features.find((f) => f.id === featureId);
|
||||
const currentBranch = currentFeature?.branchName || "main";
|
||||
const newBranch = updates.branchName || "main";
|
||||
const branchIsChanging = currentBranch !== newBranch;
|
||||
|
||||
let worktreePath: string | undefined;
|
||||
let shouldClearWorktreePath = false;
|
||||
|
||||
// If worktrees are enabled and branch is changing to a non-main branch, create worktree
|
||||
if (useWorktrees && branchIsChanging) {
|
||||
if (newBranch === "main" || newBranch === "master") {
|
||||
// Changing to main - clear the worktreePath
|
||||
shouldClearWorktreePath = true;
|
||||
} else {
|
||||
// Changing to a feature branch - create worktree if needed
|
||||
const tempFeature = { branchName: newBranch } as Feature;
|
||||
const path = await getOrCreateWorktreeForFeature(tempFeature);
|
||||
if (path && path !== projectPath) {
|
||||
worktreePath = path;
|
||||
// Refresh worktree selector after creating worktree
|
||||
onWorktreeCreated?.();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build final updates with worktreePath if it was changed
|
||||
let finalUpdates: typeof updates & { worktreePath?: string };
|
||||
if (branchIsChanging && useWorktrees) {
|
||||
if (shouldClearWorktreePath) {
|
||||
// Use null to clear the value in persistence (cast to work around type system)
|
||||
finalUpdates = { ...updates, worktreePath: null as unknown as string | undefined };
|
||||
} else {
|
||||
finalUpdates = { ...updates, worktreePath };
|
||||
}
|
||||
} else {
|
||||
finalUpdates = updates;
|
||||
}
|
||||
|
||||
updateFeature(featureId, finalUpdates);
|
||||
persistFeatureUpdate(featureId, finalUpdates);
|
||||
if (updates.category) {
|
||||
saveCategory(updates.category);
|
||||
}
|
||||
setEditingFeature(null);
|
||||
},
|
||||
[updateFeature, persistFeatureUpdate, saveCategory, setEditingFeature]
|
||||
[updateFeature, persistFeatureUpdate, saveCategory, setEditingFeature, features, useWorktrees, getOrCreateWorktreeForFeature, projectPath, onWorktreeCreated]
|
||||
);
|
||||
|
||||
const handleDeleteFeature = useCallback(
|
||||
@@ -291,7 +349,8 @@ export function useBoardActions({
|
||||
startedAt: new Date().toISOString(),
|
||||
};
|
||||
updateFeature(feature.id, updates);
|
||||
persistFeatureUpdate(feature.id, updates);
|
||||
// Must await to ensure feature status is persisted before starting agent
|
||||
await persistFeatureUpdate(feature.id, updates);
|
||||
console.log("[Board] Feature moved to in_progress, starting agent...");
|
||||
await handleRunFeature(feature);
|
||||
return true;
|
||||
@@ -535,50 +594,6 @@ export function useBoardActions({
|
||||
]
|
||||
);
|
||||
|
||||
const handleRevertFeature = useCallback(
|
||||
async (feature: Feature) => {
|
||||
if (!currentProject) return;
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.revertFeature) {
|
||||
console.error("Worktree API not available");
|
||||
toast.error("Revert not available", {
|
||||
description:
|
||||
"This feature is not available in the current version.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await api.worktree.revertFeature(
|
||||
currentProject.path,
|
||||
feature.id
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
await loadFeatures();
|
||||
toast.success("Feature reverted", {
|
||||
description: `All changes discarded. Moved back to backlog: ${truncateDescription(
|
||||
feature.description
|
||||
)}`,
|
||||
});
|
||||
} else {
|
||||
console.error("[Board] Failed to revert feature:", result.error);
|
||||
toast.error("Failed to revert feature", {
|
||||
description: result.error || "An error occurred",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Board] Error reverting feature:", error);
|
||||
toast.error("Failed to revert feature", {
|
||||
description:
|
||||
error instanceof Error ? error.message : "An error occurred",
|
||||
});
|
||||
}
|
||||
},
|
||||
[currentProject, loadFeatures]
|
||||
);
|
||||
|
||||
const handleMergeFeature = useCallback(
|
||||
async (feature: Feature) => {
|
||||
if (!currentProject) return;
|
||||
@@ -698,7 +713,8 @@ export function useBoardActions({
|
||||
|
||||
if (targetStatus !== feature.status) {
|
||||
moveFeature(feature.id, targetStatus);
|
||||
persistFeatureUpdate(feature.id, { status: targetStatus });
|
||||
// Must await to ensure file is written before user can restart
|
||||
await persistFeatureUpdate(feature.id, { status: targetStatus });
|
||||
}
|
||||
|
||||
toast.success("Agent stopped", {
|
||||
@@ -733,8 +749,16 @@ export function useBoardActions({
|
||||
|
||||
// If no worktree is selected (currentWorktreeBranch is null or main-like),
|
||||
// show features with no branch or "main"/"master" branch
|
||||
if (!currentWorktreeBranch || currentWorktreeBranch === "main" || currentWorktreeBranch === "master") {
|
||||
return !f.branchName || featureBranch === "main" || featureBranch === "master";
|
||||
if (
|
||||
!currentWorktreeBranch ||
|
||||
currentWorktreeBranch === "main" ||
|
||||
currentWorktreeBranch === "master"
|
||||
) {
|
||||
return (
|
||||
!f.branchName ||
|
||||
featureBranch === "main" ||
|
||||
featureBranch === "master"
|
||||
);
|
||||
}
|
||||
|
||||
// Otherwise, only show features matching the selected worktree branch
|
||||
@@ -754,9 +778,12 @@ export function useBoardActions({
|
||||
|
||||
if (backlogFeatures.length === 0) {
|
||||
toast.info("Backlog empty", {
|
||||
description: currentWorktreeBranch && currentWorktreeBranch !== "main" && currentWorktreeBranch !== "master"
|
||||
? `No features in backlog for branch "${currentWorktreeBranch}".`
|
||||
: "No features in backlog to start.",
|
||||
description:
|
||||
currentWorktreeBranch &&
|
||||
currentWorktreeBranch !== "main" &&
|
||||
currentWorktreeBranch !== "master"
|
||||
? `No features in backlog for branch "${currentWorktreeBranch}".`
|
||||
: "No features in backlog to start.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -833,7 +860,6 @@ export function useBoardActions({
|
||||
handleOpenFollowUp,
|
||||
handleSendFollowUp,
|
||||
handleCommitFeature,
|
||||
handleRevertFeature,
|
||||
handleMergeFeature,
|
||||
handleCompleteFeature,
|
||||
handleUnarchiveFeature,
|
||||
|
||||
@@ -57,22 +57,22 @@ export function useBoardColumnFeatures({
|
||||
|
||||
// Check if feature matches the current worktree
|
||||
// Match by worktreePath if set, OR by branchName if set
|
||||
// Features with neither are considered unassigned (show on main only)
|
||||
// Features with neither are considered unassigned (show on ALL worktrees)
|
||||
const featureBranch = f.branchName || "main";
|
||||
const hasWorktreeAssigned = f.worktreePath || f.branchName;
|
||||
|
||||
let matchesWorktree: boolean;
|
||||
if (!hasWorktreeAssigned) {
|
||||
// No worktree or branch assigned - show only on main
|
||||
matchesWorktree = !currentWorktreePath;
|
||||
// No worktree or branch assigned - show on ALL worktrees (unassigned)
|
||||
matchesWorktree = true;
|
||||
} else if (f.worktreePath) {
|
||||
// Has worktreePath - match by path (use pathsEqual for cross-platform compatibility)
|
||||
matchesWorktree = pathsEqual(f.worktreePath, effectiveWorktreePath);
|
||||
} else if (effectiveBranch === null) {
|
||||
// We're selecting a non-main worktree but can't determine its branch yet
|
||||
// (worktrees haven't loaded). Don't show branch-only features until we know.
|
||||
// This prevents showing wrong features during loading.
|
||||
matchesWorktree = false;
|
||||
// We're viewing main but branch hasn't been initialized yet
|
||||
// (worktrees disabled or haven't loaded yet).
|
||||
// Show features assigned to main/master branch since we're on the main worktree.
|
||||
matchesWorktree = featureBranch === "main" || featureBranch === "master";
|
||||
} else {
|
||||
// Has branchName but no worktreePath - match by branch name
|
||||
matchesWorktree = featureBranch === effectiveBranch;
|
||||
|
||||
@@ -68,6 +68,13 @@ function isInputFocused(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for any open dropdown menus (Radix UI uses role="menu")
|
||||
// This prevents shortcuts from firing when user is typing in dropdown filters
|
||||
const dropdownMenu = document.querySelector('[role="menu"]');
|
||||
if (dropdownMenu) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -158,7 +158,10 @@ export interface SpecRegenerationAPI {
|
||||
analyzeProject?: boolean,
|
||||
maxFeatures?: number
|
||||
) => Promise<{ success: boolean; error?: string }>;
|
||||
generateFeatures: (projectPath: string, maxFeatures?: number) => Promise<{
|
||||
generateFeatures: (
|
||||
projectPath: string,
|
||||
maxFeatures?: number
|
||||
) => Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}>;
|
||||
@@ -321,7 +324,11 @@ export interface ElectronAPI {
|
||||
features?: FeaturesAPI;
|
||||
runningAgents?: RunningAgentsAPI;
|
||||
enhancePrompt?: {
|
||||
enhance: (originalText: string, enhancementMode: string, model?: string) => Promise<{
|
||||
enhance: (
|
||||
originalText: string,
|
||||
enhancementMode: string,
|
||||
model?: string
|
||||
) => Promise<{
|
||||
success: boolean;
|
||||
enhancedText?: string;
|
||||
error?: string;
|
||||
@@ -1042,11 +1049,6 @@ function createMockSetupAPI(): SetupAPI {
|
||||
// Mock Worktree API implementation
|
||||
function createMockWorktreeAPI(): WorktreeAPI {
|
||||
return {
|
||||
revertFeature: async (projectPath: string, featureId: string) => {
|
||||
console.log("[Mock] Reverting feature:", { projectPath, featureId });
|
||||
return { success: true, removedPath: `/mock/worktree/${featureId}` };
|
||||
},
|
||||
|
||||
mergeFeature: async (
|
||||
projectPath: string,
|
||||
featureId: string,
|
||||
@@ -1093,17 +1095,36 @@ function createMockWorktreeAPI(): WorktreeAPI {
|
||||
},
|
||||
|
||||
listAll: async (projectPath: string, includeDetails?: boolean) => {
|
||||
console.log("[Mock] Listing all worktrees:", { projectPath, includeDetails });
|
||||
console.log("[Mock] Listing all worktrees:", {
|
||||
projectPath,
|
||||
includeDetails,
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
worktrees: [
|
||||
{ path: projectPath, branch: "main", isMain: true, isCurrent: true, hasWorktree: true, hasChanges: false, changedFilesCount: 0 },
|
||||
{
|
||||
path: projectPath,
|
||||
branch: "main",
|
||||
isMain: true,
|
||||
isCurrent: true,
|
||||
hasWorktree: true,
|
||||
hasChanges: false,
|
||||
changedFilesCount: 0,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
|
||||
create: async (projectPath: string, branchName: string, baseBranch?: string) => {
|
||||
console.log("[Mock] Creating worktree:", { projectPath, branchName, baseBranch });
|
||||
create: async (
|
||||
projectPath: string,
|
||||
branchName: string,
|
||||
baseBranch?: string
|
||||
) => {
|
||||
console.log("[Mock] Creating worktree:", {
|
||||
projectPath,
|
||||
branchName,
|
||||
baseBranch,
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
worktree: {
|
||||
@@ -1114,8 +1135,16 @@ function createMockWorktreeAPI(): WorktreeAPI {
|
||||
};
|
||||
},
|
||||
|
||||
delete: async (projectPath: string, worktreePath: string, deleteBranch?: boolean) => {
|
||||
console.log("[Mock] Deleting worktree:", { projectPath, worktreePath, deleteBranch });
|
||||
delete: async (
|
||||
projectPath: string,
|
||||
worktreePath: string,
|
||||
deleteBranch?: boolean
|
||||
) => {
|
||||
console.log("[Mock] Deleting worktree:", {
|
||||
projectPath,
|
||||
worktreePath,
|
||||
deleteBranch,
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
deleted: {
|
||||
@@ -1208,7 +1237,10 @@ function createMockWorktreeAPI(): WorktreeAPI {
|
||||
},
|
||||
|
||||
checkoutBranch: async (worktreePath: string, branchName: string) => {
|
||||
console.log("[Mock] Creating and checking out branch:", { worktreePath, branchName });
|
||||
console.log("[Mock] Creating and checking out branch:", {
|
||||
worktreePath,
|
||||
branchName,
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
@@ -1281,18 +1313,6 @@ function createMockWorktreeAPI(): WorktreeAPI {
|
||||
};
|
||||
},
|
||||
|
||||
activate: async (projectPath: string, worktreePath: string | null) => {
|
||||
console.log("[Mock] Activating worktree:", { projectPath, worktreePath });
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
previousBranch: "main",
|
||||
currentBranch: worktreePath ? "feature-branch" : "main",
|
||||
message: worktreePath ? "Switched to worktree branch" : "Switched to main",
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
startDevServer: async (projectPath: string, worktreePath: string) => {
|
||||
console.log("[Mock] Starting dev server:", { projectPath, worktreePath });
|
||||
return {
|
||||
@@ -1465,7 +1485,11 @@ function createMockAutoModeAPI(): AutoModeAPI {
|
||||
return { success: true, passes: true };
|
||||
},
|
||||
|
||||
resumeFeature: async (projectPath: string, featureId: string, useWorktrees?: boolean) => {
|
||||
resumeFeature: async (
|
||||
projectPath: string,
|
||||
featureId: string,
|
||||
useWorktrees?: boolean
|
||||
) => {
|
||||
if (mockRunningFeatures.has(featureId)) {
|
||||
return {
|
||||
success: false,
|
||||
@@ -1629,8 +1653,16 @@ function createMockAutoModeAPI(): AutoModeAPI {
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
commitFeature: async (projectPath: string, featureId: string, worktreePath?: string) => {
|
||||
console.log("[Mock] Committing feature:", { projectPath, featureId, worktreePath });
|
||||
commitFeature: async (
|
||||
projectPath: string,
|
||||
featureId: string,
|
||||
worktreePath?: string
|
||||
) => {
|
||||
console.log("[Mock] Committing feature:", {
|
||||
projectPath,
|
||||
featureId,
|
||||
worktreePath,
|
||||
});
|
||||
|
||||
// Simulate commit operation
|
||||
emitAutoModeEvent({
|
||||
|
||||
@@ -468,7 +468,9 @@ export class HttpApiClient implements ElectronAPI {
|
||||
isLinux: boolean;
|
||||
}> => this.get("/api/setup/platform"),
|
||||
|
||||
verifyClaudeAuth: (authMethod?: "cli" | "api_key"): Promise<{
|
||||
verifyClaudeAuth: (
|
||||
authMethod?: "cli" | "api_key"
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
authenticated: boolean;
|
||||
error?: string;
|
||||
@@ -536,8 +538,16 @@ export class HttpApiClient implements ElectronAPI {
|
||||
}),
|
||||
verifyFeature: (projectPath: string, featureId: string) =>
|
||||
this.post("/api/auto-mode/verify-feature", { projectPath, featureId }),
|
||||
resumeFeature: (projectPath: string, featureId: string, useWorktrees?: boolean) =>
|
||||
this.post("/api/auto-mode/resume-feature", { projectPath, featureId, useWorktrees }),
|
||||
resumeFeature: (
|
||||
projectPath: string,
|
||||
featureId: string,
|
||||
useWorktrees?: boolean
|
||||
) =>
|
||||
this.post("/api/auto-mode/resume-feature", {
|
||||
projectPath,
|
||||
featureId,
|
||||
useWorktrees,
|
||||
}),
|
||||
contextExists: (projectPath: string, featureId: string) =>
|
||||
this.post("/api/auto-mode/context-exists", { projectPath, featureId }),
|
||||
analyzeProject: (projectPath: string) =>
|
||||
@@ -556,8 +566,16 @@ export class HttpApiClient implements ElectronAPI {
|
||||
imagePaths,
|
||||
worktreePath,
|
||||
}),
|
||||
commitFeature: (projectPath: string, featureId: string, worktreePath?: string) =>
|
||||
this.post("/api/auto-mode/commit-feature", { projectPath, featureId, worktreePath }),
|
||||
commitFeature: (
|
||||
projectPath: string,
|
||||
featureId: string,
|
||||
worktreePath?: string
|
||||
) =>
|
||||
this.post("/api/auto-mode/commit-feature", {
|
||||
projectPath,
|
||||
featureId,
|
||||
worktreePath,
|
||||
}),
|
||||
onEvent: (callback: (event: AutoModeEvent) => void) => {
|
||||
return this.subscribeToEvent(
|
||||
"auto-mode:event",
|
||||
@@ -582,8 +600,6 @@ export class HttpApiClient implements ElectronAPI {
|
||||
|
||||
// Worktree API
|
||||
worktree: WorktreeAPI = {
|
||||
revertFeature: (projectPath: string, featureId: string) =>
|
||||
this.post("/api/worktree/revert", { projectPath, featureId }),
|
||||
mergeFeature: (projectPath: string, featureId: string, options?: object) =>
|
||||
this.post("/api/worktree/merge", { projectPath, featureId, options }),
|
||||
getInfo: (projectPath: string, featureId: string) =>
|
||||
@@ -595,9 +611,21 @@ export class HttpApiClient implements ElectronAPI {
|
||||
listAll: (projectPath: string, includeDetails?: boolean) =>
|
||||
this.post("/api/worktree/list", { projectPath, includeDetails }),
|
||||
create: (projectPath: string, branchName: string, baseBranch?: string) =>
|
||||
this.post("/api/worktree/create", { projectPath, branchName, baseBranch }),
|
||||
delete: (projectPath: string, worktreePath: string, deleteBranch?: boolean) =>
|
||||
this.post("/api/worktree/delete", { projectPath, worktreePath, deleteBranch }),
|
||||
this.post("/api/worktree/create", {
|
||||
projectPath,
|
||||
branchName,
|
||||
baseBranch,
|
||||
}),
|
||||
delete: (
|
||||
projectPath: string,
|
||||
worktreePath: string,
|
||||
deleteBranch?: boolean
|
||||
) =>
|
||||
this.post("/api/worktree/delete", {
|
||||
projectPath,
|
||||
worktreePath,
|
||||
deleteBranch,
|
||||
}),
|
||||
commit: (worktreePath: string, message: string) =>
|
||||
this.post("/api/worktree/commit", { worktreePath, message }),
|
||||
push: (worktreePath: string, force?: boolean) =>
|
||||
@@ -622,18 +650,14 @@ export class HttpApiClient implements ElectronAPI {
|
||||
this.post("/api/worktree/switch-branch", { worktreePath, branchName }),
|
||||
openInEditor: (worktreePath: string) =>
|
||||
this.post("/api/worktree/open-in-editor", { worktreePath }),
|
||||
getDefaultEditor: () =>
|
||||
this.get("/api/worktree/default-editor"),
|
||||
getDefaultEditor: () => this.get("/api/worktree/default-editor"),
|
||||
initGit: (projectPath: string) =>
|
||||
this.post("/api/worktree/init-git", { projectPath }),
|
||||
activate: (projectPath: string, worktreePath: string | null) =>
|
||||
this.post("/api/worktree/activate", { projectPath, worktreePath }),
|
||||
startDevServer: (projectPath: string, worktreePath: string) =>
|
||||
this.post("/api/worktree/start-dev", { projectPath, worktreePath }),
|
||||
stopDevServer: (worktreePath: string) =>
|
||||
this.post("/api/worktree/stop-dev", { worktreePath }),
|
||||
listDevServers: () =>
|
||||
this.post("/api/worktree/list-dev-servers", {}),
|
||||
listDevServers: () => this.post("/api/worktree/list-dev-servers", {}),
|
||||
};
|
||||
|
||||
// Git API
|
||||
@@ -689,7 +713,10 @@ export class HttpApiClient implements ElectronAPI {
|
||||
maxFeatures,
|
||||
}),
|
||||
generateFeatures: (projectPath: string, maxFeatures?: number) =>
|
||||
this.post("/api/spec-regeneration/generate-features", { projectPath, maxFeatures }),
|
||||
this.post("/api/spec-regeneration/generate-features", {
|
||||
projectPath,
|
||||
maxFeatures,
|
||||
}),
|
||||
stop: () => this.post("/api/spec-regeneration/stop"),
|
||||
status: () => this.get("/api/spec-regeneration/status"),
|
||||
onEvent: (callback: (event: SpecRegenerationEvent) => void) => {
|
||||
|
||||
33
apps/app/src/types/electron.d.ts
vendored
33
apps/app/src/types/electron.d.ts
vendored
@@ -286,7 +286,10 @@ export interface SpecRegenerationAPI {
|
||||
error?: string;
|
||||
}>;
|
||||
|
||||
generateFeatures: (projectPath: string, maxFeatures?: number) => Promise<{
|
||||
generateFeatures: (
|
||||
projectPath: string,
|
||||
maxFeatures?: number
|
||||
) => Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}>;
|
||||
@@ -574,16 +577,6 @@ export interface FileDiffResult {
|
||||
}
|
||||
|
||||
export interface WorktreeAPI {
|
||||
// Revert feature changes by removing the worktree
|
||||
revertFeature: (
|
||||
projectPath: string,
|
||||
featureId: string
|
||||
) => Promise<{
|
||||
success: boolean;
|
||||
removedPath?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
|
||||
// Merge feature worktree changes back to main branch
|
||||
mergeFeature: (
|
||||
projectPath: string,
|
||||
@@ -824,20 +817,6 @@ export interface WorktreeAPI {
|
||||
error?: string;
|
||||
}>;
|
||||
|
||||
// Activate a worktree (switch main project to that branch)
|
||||
activate: (
|
||||
projectPath: string,
|
||||
worktreePath: string | null
|
||||
) => Promise<{
|
||||
success: boolean;
|
||||
result?: {
|
||||
previousBranch: string;
|
||||
currentBranch: string;
|
||||
message: string;
|
||||
};
|
||||
error?: string;
|
||||
}>;
|
||||
|
||||
// Start a dev server for a worktree
|
||||
startDevServer: (
|
||||
projectPath: string,
|
||||
@@ -854,9 +833,7 @@ export interface WorktreeAPI {
|
||||
}>;
|
||||
|
||||
// Stop a dev server for a worktree
|
||||
stopDevServer: (
|
||||
worktreePath: string
|
||||
) => Promise<{
|
||||
stopDevServer: (worktreePath: string) => Promise<{
|
||||
success: boolean;
|
||||
result?: {
|
||||
worktreePath: string;
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
createTestGitRepo,
|
||||
cleanupTempDir,
|
||||
createTempDirPath,
|
||||
setupProjectWithPath,
|
||||
setupProjectWithPathNoWorktrees,
|
||||
waitForBoardView,
|
||||
clickAddFeature,
|
||||
fillAddFeatureDialog,
|
||||
@@ -84,7 +84,8 @@ test.describe("Feature Lifecycle Tests", () => {
|
||||
// ==========================================================================
|
||||
// Step 1: Setup and create a feature in backlog
|
||||
// ==========================================================================
|
||||
await setupProjectWithPath(page, testRepo.path);
|
||||
// Use no-worktrees setup to avoid worktree-related filtering/initialization issues
|
||||
await setupProjectWithPathNoWorktrees(page, testRepo.path);
|
||||
await page.goto("/");
|
||||
await waitForNetworkIdle(page);
|
||||
await waitForBoardView(page);
|
||||
@@ -291,4 +292,153 @@ test.describe("Feature Lifecycle Tests", () => {
|
||||
const featureDirExists = fs.existsSync(path.join(featuresDir, featureId));
|
||||
expect(featureDirExists).toBe(false);
|
||||
});
|
||||
|
||||
test("stop and restart feature: create -> in_progress -> stop -> restart should work without 'Feature not found' error", async ({
|
||||
page,
|
||||
}) => {
|
||||
// This test verifies that stopping a feature and restarting it works correctly
|
||||
// Bug: Previously, stopping a feature and immediately restarting could cause
|
||||
// "Feature not found" error due to race conditions
|
||||
test.setTimeout(120000);
|
||||
|
||||
// ==========================================================================
|
||||
// Step 1: Setup and create a feature in backlog
|
||||
// ==========================================================================
|
||||
await setupProjectWithPathNoWorktrees(page, testRepo.path);
|
||||
await page.goto("/");
|
||||
await waitForNetworkIdle(page);
|
||||
await waitForBoardView(page);
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Click add feature button
|
||||
await clickAddFeature(page);
|
||||
|
||||
// Fill in the feature details
|
||||
const featureDescription = "Create a file named test-restart.txt";
|
||||
const descriptionInput = page.locator('[data-testid="add-feature-dialog"] textarea').first();
|
||||
await descriptionInput.fill(featureDescription);
|
||||
|
||||
// Confirm the feature creation
|
||||
await confirmAddFeature(page);
|
||||
|
||||
// Wait for the feature to be created in the filesystem
|
||||
const featuresDir = path.join(testRepo.path, ".automaker", "features");
|
||||
await expect(async () => {
|
||||
const dirs = fs.readdirSync(featuresDir);
|
||||
expect(dirs.length).toBeGreaterThan(0);
|
||||
}).toPass({ timeout: 10000 });
|
||||
|
||||
// Get the feature ID
|
||||
const featureDirs = fs.readdirSync(featuresDir);
|
||||
const testFeatureId = featureDirs[0];
|
||||
|
||||
// Reload to ensure features are loaded
|
||||
await page.reload();
|
||||
await waitForNetworkIdle(page);
|
||||
await waitForBoardView(page);
|
||||
|
||||
// Wait for the feature card to appear
|
||||
const featureCard = page.locator(`[data-testid="kanban-card-${testFeatureId}"]`);
|
||||
await expect(featureCard).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// ==========================================================================
|
||||
// Step 2: Drag feature to in_progress (first start)
|
||||
// ==========================================================================
|
||||
const dragHandle = page.locator(`[data-testid="drag-handle-${testFeatureId}"]`);
|
||||
const inProgressColumn = page.locator('[data-testid="kanban-column-in_progress"]');
|
||||
|
||||
await dragAndDropWithDndKit(page, dragHandle, inProgressColumn);
|
||||
|
||||
// Wait for the feature to be in in_progress
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Verify feature file still exists and is readable
|
||||
const featureFilePath = path.join(featuresDir, testFeatureId, "feature.json");
|
||||
expect(fs.existsSync(featureFilePath)).toBe(true);
|
||||
|
||||
// Wait a bit for the agent to start
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// ==========================================================================
|
||||
// Step 3: Wait for the mock agent to complete (it's fast in mock mode)
|
||||
// ==========================================================================
|
||||
// The mock agent completes quickly, so we wait for it to finish
|
||||
await expect(async () => {
|
||||
const featureData = JSON.parse(fs.readFileSync(featureFilePath, "utf-8"));
|
||||
expect(featureData.status).toBe("waiting_approval");
|
||||
}).toPass({ timeout: 30000 });
|
||||
|
||||
// Verify feature file still exists after completion
|
||||
expect(fs.existsSync(featureFilePath)).toBe(true);
|
||||
const featureDataAfterComplete = JSON.parse(fs.readFileSync(featureFilePath, "utf-8"));
|
||||
console.log("Feature status after first run:", featureDataAfterComplete.status);
|
||||
|
||||
// Reload to ensure clean state
|
||||
await page.reload();
|
||||
await waitForNetworkIdle(page);
|
||||
await waitForBoardView(page);
|
||||
|
||||
// ==========================================================================
|
||||
// Step 4: Move feature back to backlog to simulate stop scenario
|
||||
// ==========================================================================
|
||||
// Feature is in waiting_approval, drag it back to backlog
|
||||
const backlogColumn = page.locator('[data-testid="kanban-column-backlog"]');
|
||||
const currentCard = page.locator(`[data-testid="kanban-card-${testFeatureId}"]`);
|
||||
const currentDragHandle = page.locator(`[data-testid="drag-handle-${testFeatureId}"]`);
|
||||
|
||||
await expect(currentCard).toBeVisible({ timeout: 10000 });
|
||||
await dragAndDropWithDndKit(page, currentDragHandle, backlogColumn);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Verify feature is in backlog
|
||||
await expect(async () => {
|
||||
const data = JSON.parse(fs.readFileSync(featureFilePath, "utf-8"));
|
||||
expect(data.status).toBe("backlog");
|
||||
}).toPass({ timeout: 10000 });
|
||||
|
||||
// Reload to ensure clean state
|
||||
await page.reload();
|
||||
await waitForNetworkIdle(page);
|
||||
await waitForBoardView(page);
|
||||
|
||||
// ==========================================================================
|
||||
// Step 5: Restart the feature (drag to in_progress again)
|
||||
// ==========================================================================
|
||||
const restartCard = page.locator(`[data-testid="kanban-card-${testFeatureId}"]`);
|
||||
await expect(restartCard).toBeVisible({ timeout: 10000 });
|
||||
|
||||
const restartDragHandle = page.locator(`[data-testid="drag-handle-${testFeatureId}"]`);
|
||||
const inProgressColumnRestart = page.locator('[data-testid="kanban-column-in_progress"]');
|
||||
|
||||
// Listen for console errors to catch "Feature not found"
|
||||
const consoleErrors: string[] = [];
|
||||
page.on("console", (msg) => {
|
||||
if (msg.type() === "error") {
|
||||
consoleErrors.push(msg.text());
|
||||
}
|
||||
});
|
||||
|
||||
// Drag to in_progress to restart
|
||||
await dragAndDropWithDndKit(page, restartDragHandle, inProgressColumnRestart);
|
||||
|
||||
// Wait for the feature to be processed
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Verify no "Feature not found" errors in console
|
||||
const featureNotFoundErrors = consoleErrors.filter(
|
||||
(err) => err.includes("not found") || err.includes("Feature")
|
||||
);
|
||||
expect(featureNotFoundErrors).toEqual([]);
|
||||
|
||||
// Verify the feature file still exists
|
||||
expect(fs.existsSync(featureFilePath)).toBe(true);
|
||||
|
||||
// Wait for the mock agent to complete and move to waiting_approval
|
||||
await expect(async () => {
|
||||
const data = JSON.parse(fs.readFileSync(featureFilePath, "utf-8"));
|
||||
expect(data.status).toBe("waiting_approval");
|
||||
}).toPass({ timeout: 30000 });
|
||||
|
||||
console.log("Feature successfully restarted after stop!");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,7 +24,6 @@ export const API_ENDPOINTS = {
|
||||
switchBranch: `${API_BASE_URL}/api/worktree/switch-branch`,
|
||||
listBranches: `${API_BASE_URL}/api/worktree/list-branches`,
|
||||
status: `${API_BASE_URL}/api/worktree/status`,
|
||||
revert: `${API_BASE_URL}/api/worktree/revert`,
|
||||
info: `${API_BASE_URL}/api/worktree/info`,
|
||||
},
|
||||
fs: {
|
||||
|
||||
@@ -352,6 +352,106 @@ export async function setupProjectWithPath(page: Page, projectPath: string): Pro
|
||||
}, projectPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up localStorage with a project pointing to a test repo with worktrees DISABLED
|
||||
* Use this to test scenarios where the worktree feature flag is off
|
||||
*/
|
||||
export async function setupProjectWithPathNoWorktrees(page: Page, projectPath: string): Promise<void> {
|
||||
await page.addInitScript((pathArg: string) => {
|
||||
const mockProject = {
|
||||
id: "test-project-no-worktree",
|
||||
name: "Test Project (No Worktrees)",
|
||||
path: pathArg,
|
||||
lastOpened: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const mockState = {
|
||||
state: {
|
||||
projects: [mockProject],
|
||||
currentProject: mockProject,
|
||||
currentView: "board",
|
||||
theme: "dark",
|
||||
sidebarOpen: true,
|
||||
apiKeys: { anthropic: "", google: "" },
|
||||
chatSessions: [],
|
||||
chatHistoryOpen: false,
|
||||
maxConcurrency: 3,
|
||||
aiProfiles: [],
|
||||
useWorktrees: false, // Worktree feature DISABLED
|
||||
currentWorktreeByProject: {},
|
||||
worktreesByProject: {},
|
||||
},
|
||||
version: 0,
|
||||
};
|
||||
|
||||
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
|
||||
|
||||
// Mark setup as complete to skip the setup wizard
|
||||
const setupState = {
|
||||
state: {
|
||||
isFirstRun: false,
|
||||
setupComplete: true,
|
||||
currentStep: "complete",
|
||||
skipClaudeSetup: false,
|
||||
},
|
||||
version: 0,
|
||||
};
|
||||
localStorage.setItem("automaker-setup", JSON.stringify(setupState));
|
||||
}, projectPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up localStorage with a project that has STALE worktree data
|
||||
* The currentWorktreeByProject points to a worktree path that no longer exists
|
||||
* This simulates the scenario where a user previously selected a worktree that was later deleted
|
||||
*/
|
||||
export async function setupProjectWithStaleWorktree(page: Page, projectPath: string): Promise<void> {
|
||||
await page.addInitScript((pathArg: string) => {
|
||||
const mockProject = {
|
||||
id: "test-project-stale-worktree",
|
||||
name: "Stale Worktree Test Project",
|
||||
path: pathArg,
|
||||
lastOpened: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const mockState = {
|
||||
state: {
|
||||
projects: [mockProject],
|
||||
currentProject: mockProject,
|
||||
currentView: "board",
|
||||
theme: "dark",
|
||||
sidebarOpen: true,
|
||||
apiKeys: { anthropic: "", google: "" },
|
||||
chatSessions: [],
|
||||
chatHistoryOpen: false,
|
||||
maxConcurrency: 3,
|
||||
aiProfiles: [],
|
||||
useWorktrees: true, // Enable worktree feature for tests
|
||||
currentWorktreeByProject: {
|
||||
// This is STALE data - pointing to a worktree path that doesn't exist
|
||||
[pathArg]: { path: "/non/existent/worktree/path", branch: "feature/deleted-branch" },
|
||||
},
|
||||
worktreesByProject: {},
|
||||
},
|
||||
version: 0,
|
||||
};
|
||||
|
||||
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
|
||||
|
||||
// Mark setup as complete to skip the setup wizard
|
||||
const setupState = {
|
||||
state: {
|
||||
isFirstRun: false,
|
||||
setupComplete: true,
|
||||
currentStep: "complete",
|
||||
skipClaudeSetup: false,
|
||||
},
|
||||
version: 0,
|
||||
};
|
||||
localStorage.setItem("automaker-setup", JSON.stringify(setupState));
|
||||
}, projectPath);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Wait Utilities
|
||||
// ============================================================================
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -22,3 +22,4 @@ export function createSpecRegenerationRoutes(events: EventEmitter): Router {
|
||||
return router;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -102,3 +102,4 @@ export function createDeleteApiKeyHandler() {
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ import { createStatusHandler } from "./routes/status.js";
|
||||
import { createListHandler } from "./routes/list.js";
|
||||
import { createDiffsHandler } from "./routes/diffs.js";
|
||||
import { createFileDiffHandler } from "./routes/file-diff.js";
|
||||
import { createRevertHandler } from "./routes/revert.js";
|
||||
import { createMergeHandler } from "./routes/merge.js";
|
||||
import { createCreateHandler } from "./routes/create.js";
|
||||
import { createDeleteHandler } from "./routes/delete.js";
|
||||
@@ -19,9 +18,11 @@ import { createPullHandler } from "./routes/pull.js";
|
||||
import { createCheckoutBranchHandler } from "./routes/checkout-branch.js";
|
||||
import { createListBranchesHandler } from "./routes/list-branches.js";
|
||||
import { createSwitchBranchHandler } from "./routes/switch-branch.js";
|
||||
import { createOpenInEditorHandler, createGetDefaultEditorHandler } from "./routes/open-in-editor.js";
|
||||
import {
|
||||
createOpenInEditorHandler,
|
||||
createGetDefaultEditorHandler,
|
||||
} from "./routes/open-in-editor.js";
|
||||
import { createInitGitHandler } from "./routes/init-git.js";
|
||||
import { createActivateHandler } from "./routes/activate.js";
|
||||
import { createMigrateHandler } from "./routes/migrate.js";
|
||||
import { createStartDevHandler } from "./routes/start-dev.js";
|
||||
import { createStopDevHandler } from "./routes/stop-dev.js";
|
||||
@@ -35,7 +36,6 @@ export function createWorktreeRoutes(): Router {
|
||||
router.post("/list", createListHandler());
|
||||
router.post("/diffs", createDiffsHandler());
|
||||
router.post("/file-diff", createFileDiffHandler());
|
||||
router.post("/revert", createRevertHandler());
|
||||
router.post("/merge", createMergeHandler());
|
||||
router.post("/create", createCreateHandler());
|
||||
router.post("/delete", createDeleteHandler());
|
||||
@@ -49,7 +49,6 @@ export function createWorktreeRoutes(): Router {
|
||||
router.post("/open-in-editor", createOpenInEditorHandler());
|
||||
router.get("/default-editor", createGetDefaultEditorHandler());
|
||||
router.post("/init-git", createInitGitHandler());
|
||||
router.post("/activate", createActivateHandler());
|
||||
router.post("/migrate", createMigrateHandler());
|
||||
router.post("/start-dev", createStartDevHandler());
|
||||
router.post("/stop-dev", createStopDevHandler());
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
/**
|
||||
* POST /activate endpoint - Switch main project to a worktree's branch
|
||||
*
|
||||
* This allows users to "activate" a worktree so their running dev server
|
||||
* (like Vite) shows the worktree's files. It does this by:
|
||||
* 1. Checking for uncommitted changes (fails if found)
|
||||
* 2. Removing the worktree (unlocks the branch)
|
||||
* 3. Checking out that branch in the main directory
|
||||
*
|
||||
* Users should commit their changes before activating a worktree.
|
||||
*/
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import { getErrorMessage, logError } from "../common.js";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
async function hasUncommittedChanges(cwd: string): Promise<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) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
/**
|
||||
* POST /revert endpoint - Revert feature (remove worktree)
|
||||
*/
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import path from "path";
|
||||
import { getErrorMessage, logError } from "../common.js";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
export function createRevertHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, featureId } = req.body as {
|
||||
projectPath: string;
|
||||
featureId: string;
|
||||
};
|
||||
|
||||
if (!projectPath || !featureId) {
|
||||
res
|
||||
.status(400)
|
||||
.json({
|
||||
success: false,
|
||||
error: "projectPath and featureId required",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Git worktrees are stored in project directory
|
||||
const worktreePath = path.join(projectPath, ".worktrees", featureId);
|
||||
|
||||
try {
|
||||
// Remove worktree
|
||||
await execAsync(`git worktree remove "${worktreePath}" --force`, {
|
||||
cwd: projectPath,
|
||||
});
|
||||
// Delete branch
|
||||
await execAsync(`git branch -D feature/${featureId}`, {
|
||||
cwd: projectPath,
|
||||
});
|
||||
|
||||
res.json({ success: true, removedPath: worktreePath });
|
||||
} catch (error) {
|
||||
// Worktree might not exist
|
||||
res.json({ success: true, removedPath: null });
|
||||
}
|
||||
} catch (error) {
|
||||
logError(error, "Revert worktree failed");
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -581,3 +581,4 @@ The route organization pattern provides:
|
||||
|
||||
Apply this pattern to all route modules for consistency and improved code quality.
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user