Merge pull request #168 from AutoMaker-Org/feature/cards-in-worktrees

feat: add branch card counts to UI components
This commit is contained in:
Web Dev Cody
2025-12-18 21:43:45 -05:00
committed by GitHub
17 changed files with 442 additions and 189 deletions

View File

@@ -1,12 +1,7 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { HotkeyButton } from "@/components/ui/hotkey-button"; import { HotkeyButton } from "@/components/ui/hotkey-button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@@ -116,8 +111,10 @@ export function SessionManager({
new Set() new Set()
); );
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [sessionToDelete, setSessionToDelete] = useState<SessionListItem | null>(null); const [sessionToDelete, setSessionToDelete] =
const [isDeleteAllArchivedDialogOpen, setIsDeleteAllArchivedDialogOpen] = useState(false); useState<SessionListItem | null>(null);
const [isDeleteAllArchivedDialogOpen, setIsDeleteAllArchivedDialogOpen] =
useState(false);
// Check running state for all sessions // Check running state for all sessions
const checkRunningSessions = async (sessionList: SessionListItem[]) => { const checkRunningSessions = async (sessionList: SessionListItem[]) => {
@@ -234,11 +231,7 @@ export function SessionManager({
const api = getElectronAPI(); const api = getElectronAPI();
if (!editingName.trim() || !api?.sessions) return; if (!editingName.trim() || !api?.sessions) return;
const result = await api.sessions.update( const result = await api.sessions.update(sessionId, editingName, undefined);
sessionId,
editingName,
undefined
);
if (result.success) { if (result.success) {
setEditingSessionId(null); setEditingSessionId(null);

View File

@@ -8,6 +8,7 @@ interface BranchAutocompleteProps {
value: string; value: string;
onChange: (value: string) => void; onChange: (value: string) => void;
branches: string[]; branches: string[];
branchCardCounts?: Record<string, number>; // Map of branch name to unarchived card count
placeholder?: string; placeholder?: string;
className?: string; className?: string;
disabled?: boolean; disabled?: boolean;
@@ -19,6 +20,7 @@ export function BranchAutocomplete({
value, value,
onChange, onChange,
branches, branches,
branchCardCounts,
placeholder = "Select a branch...", placeholder = "Select a branch...",
className, className,
disabled = false, disabled = false,
@@ -28,12 +30,22 @@ export function BranchAutocomplete({
// Always include "main" at the top of suggestions // Always include "main" at the top of suggestions
const branchOptions: AutocompleteOption[] = React.useMemo(() => { const branchOptions: AutocompleteOption[] = React.useMemo(() => {
const branchSet = new Set(["main", ...branches]); const branchSet = new Set(["main", ...branches]);
return Array.from(branchSet).map((branch) => ({ return Array.from(branchSet).map((branch) => {
value: branch, const cardCount = branchCardCounts?.[branch];
label: branch, // Show card count if available, otherwise show "default" for main branch only
badge: branch === "main" ? "default" : undefined, const badge = branchCardCounts !== undefined
})); ? String(cardCount ?? 0)
}, [branches]); : branch === "main"
? "default"
: undefined;
return {
value: branch,
label: branch,
badge,
};
});
}, [branches, branchCardCounts]);
return ( return (
<Autocomplete <Autocomplete

View File

@@ -270,6 +270,17 @@ export function BoardView() {
fetchBranches(); fetchBranches();
}, [currentProject, worktreeRefreshKey]); }, [currentProject, worktreeRefreshKey]);
// Calculate unarchived card counts per branch
const branchCardCounts = useMemo(() => {
return hookFeatures.reduce((counts, feature) => {
if (feature.status !== "completed") {
const branch = feature.branchName ?? "main";
counts[branch] = (counts[branch] || 0) + 1;
}
return counts;
}, {} as Record<string, number>);
}, [hookFeatures]);
// Custom collision detection that prioritizes columns over cards // Custom collision detection that prioritizes columns over cards
const collisionDetectionStrategy = useCallback((args: any) => { const collisionDetectionStrategy = useCallback((args: any) => {
// First, check if pointer is within a column // First, check if pointer is within a column
@@ -302,14 +313,14 @@ export function BoardView() {
}); });
if (matchesRemovedWorktree) { if (matchesRemovedWorktree) {
// Reset the feature's branch assignment // Reset the feature's branch assignment - update both local state and persist
persistFeatureUpdate(feature.id, { const updates = { branchName: null as unknown as string | undefined };
branchName: null as unknown as string | undefined, updateFeature(feature.id, updates);
}); persistFeatureUpdate(feature.id, updates);
} }
}); });
}, },
[hookFeatures, persistFeatureUpdate] [hookFeatures, updateFeature, persistFeatureUpdate]
); );
// Get in-progress features for keyboard shortcuts (needed before actions hook) // Get in-progress features for keyboard shortcuts (needed before actions hook)
@@ -418,6 +429,18 @@ export function BoardView() {
hookFeaturesRef.current = hookFeatures; hookFeaturesRef.current = hookFeatures;
}, [hookFeatures]); }, [hookFeatures]);
// Use a ref to track running tasks to avoid effect re-runs that clear pendingFeaturesRef
const runningAutoTasksRef = useRef(runningAutoTasks);
useEffect(() => {
runningAutoTasksRef.current = runningAutoTasks;
}, [runningAutoTasks]);
// Keep latest start handler without retriggering the auto mode effect
const handleStartImplementationRef = useRef(handleStartImplementation);
useEffect(() => {
handleStartImplementationRef.current = handleStartImplementation;
}, [handleStartImplementation]);
// Track features that are pending (started but not yet confirmed running) // Track features that are pending (started but not yet confirmed running)
const pendingFeaturesRef = useRef<Set<string>>(new Set()); const pendingFeaturesRef = useRef<Set<string>>(new Set());
@@ -485,8 +508,9 @@ export function BoardView() {
} }
// Count currently running tasks + pending features // Count currently running tasks + pending features
// Use ref to get the latest running tasks without causing effect re-runs
const currentRunning = const currentRunning =
runningAutoTasks.length + pendingFeaturesRef.current.size; runningAutoTasksRef.current.length + pendingFeaturesRef.current.size;
const availableSlots = maxConcurrency - currentRunning; const availableSlots = maxConcurrency - currentRunning;
// No available slots, skip check // No available slots, skip check
@@ -541,6 +565,10 @@ export function BoardView() {
// Start features up to available slots // Start features up to available slots
const featuresToStart = eligibleFeatures.slice(0, availableSlots); const featuresToStart = eligibleFeatures.slice(0, availableSlots);
const startImplementation = handleStartImplementationRef.current;
if (!startImplementation) {
return;
}
for (const feature of featuresToStart) { for (const feature of featuresToStart) {
// Check again before starting each feature // Check again before starting each feature
@@ -566,7 +594,7 @@ export function BoardView() {
} }
// Start the implementation - server will derive workDir from feature.branchName // Start the implementation - server will derive workDir from feature.branchName
const started = await handleStartImplementation(feature); const started = await startImplementation(feature);
// If successfully started, track it as pending until we receive the start event // If successfully started, track it as pending until we receive the start event
if (started) { if (started) {
@@ -580,7 +608,7 @@ export function BoardView() {
// Check immediately, then every 3 seconds // Check immediately, then every 3 seconds
checkAndStartFeatures(); checkAndStartFeatures();
const interval = setInterval(checkAndStartFeatures, 3000); const interval = setInterval(checkAndStartFeatures, 1000);
return () => { return () => {
// Mark as inactive to prevent any pending async operations from continuing // Mark as inactive to prevent any pending async operations from continuing
@@ -592,7 +620,8 @@ export function BoardView() {
}, [ }, [
autoMode.isRunning, autoMode.isRunning,
currentProject, currentProject,
runningAutoTasks, // runningAutoTasks is accessed via runningAutoTasksRef to prevent effect re-runs
// that would clear pendingFeaturesRef and cause concurrency issues
maxConcurrency, maxConcurrency,
// hookFeatures is accessed via hookFeaturesRef to prevent effect re-runs // hookFeatures is accessed via hookFeaturesRef to prevent effect re-runs
currentWorktreeBranch, currentWorktreeBranch,
@@ -601,7 +630,6 @@ export function BoardView() {
isPrimaryWorktreeBranch, isPrimaryWorktreeBranch,
enableDependencyBlocking, enableDependencyBlocking,
persistFeatureUpdate, persistFeatureUpdate,
handleStartImplementation,
]); ]);
// Use keyboard shortcuts hook (after actions hook) // Use keyboard shortcuts hook (after actions hook)
@@ -640,7 +668,9 @@ export function BoardView() {
// Find feature for pending plan approval // Find feature for pending plan approval
const pendingApprovalFeature = useMemo(() => { const pendingApprovalFeature = useMemo(() => {
if (!pendingPlanApproval) return null; if (!pendingPlanApproval) return null;
return hookFeatures.find((f) => f.id === pendingPlanApproval.featureId) || null; return (
hookFeatures.find((f) => f.id === pendingPlanApproval.featureId) || null
);
}, [pendingPlanApproval, hookFeatures]); }, [pendingPlanApproval, hookFeatures]);
// Handle plan approval // Handle plan approval
@@ -666,10 +696,10 @@ export function BoardView() {
if (result.success) { if (result.success) {
// Immediately update local feature state to hide "Approve Plan" button // Immediately update local feature state to hide "Approve Plan" button
// Get current feature to preserve version // Get current feature to preserve version
const currentFeature = hookFeatures.find(f => f.id === featureId); const currentFeature = hookFeatures.find((f) => f.id === featureId);
updateFeature(featureId, { updateFeature(featureId, {
planSpec: { planSpec: {
status: 'approved', status: "approved",
content: editedPlan || pendingPlanApproval.planContent, content: editedPlan || pendingPlanApproval.planContent,
version: currentFeature?.planSpec?.version || 1, version: currentFeature?.planSpec?.version || 1,
approvedAt: new Date().toISOString(), approvedAt: new Date().toISOString(),
@@ -688,7 +718,14 @@ export function BoardView() {
setPendingPlanApproval(null); setPendingPlanApproval(null);
} }
}, },
[pendingPlanApproval, currentProject, setPendingPlanApproval, updateFeature, loadFeatures, hookFeatures] [
pendingPlanApproval,
currentProject,
setPendingPlanApproval,
updateFeature,
loadFeatures,
hookFeatures,
]
); );
// Handle plan rejection // Handle plan rejection
@@ -715,11 +752,11 @@ export function BoardView() {
if (result.success) { if (result.success) {
// Immediately update local feature state // Immediately update local feature state
// Get current feature to preserve version // Get current feature to preserve version
const currentFeature = hookFeatures.find(f => f.id === featureId); const currentFeature = hookFeatures.find((f) => f.id === featureId);
updateFeature(featureId, { updateFeature(featureId, {
status: 'backlog', status: "backlog",
planSpec: { planSpec: {
status: 'rejected', status: "rejected",
content: pendingPlanApproval.planContent, content: pendingPlanApproval.planContent,
version: currentFeature?.planSpec?.version || 1, version: currentFeature?.planSpec?.version || 1,
reviewedByUser: true, reviewedByUser: true,
@@ -737,7 +774,14 @@ export function BoardView() {
setPendingPlanApproval(null); setPendingPlanApproval(null);
} }
}, },
[pendingPlanApproval, currentProject, setPendingPlanApproval, updateFeature, loadFeatures, hookFeatures] [
pendingPlanApproval,
currentProject,
setPendingPlanApproval,
updateFeature,
loadFeatures,
hookFeatures,
]
); );
// Handle opening approval dialog from feature card button // Handle opening approval dialog from feature card button
@@ -748,7 +792,7 @@ export function BoardView() {
// Determine the planning mode for approval (skip should never have a plan requiring approval) // Determine the planning mode for approval (skip should never have a plan requiring approval)
const mode = feature.planningMode; const mode = feature.planningMode;
const approvalMode: "lite" | "spec" | "full" = const approvalMode: "lite" | "spec" | "full" =
mode === 'lite' || mode === 'spec' || mode === 'full' ? mode : 'spec'; mode === "lite" || mode === "spec" || mode === "full" ? mode : "spec";
// Re-open the approval dialog with the feature's plan data // Re-open the approval dialog with the feature's plan data
setPendingPlanApproval({ setPendingPlanApproval({
@@ -833,6 +877,7 @@ export function BoardView() {
}} }}
onRemovedWorktrees={handleRemovedWorktrees} onRemovedWorktrees={handleRemovedWorktrees}
runningFeatureIds={runningAutoTasks} runningFeatureIds={runningAutoTasks}
branchCardCounts={branchCardCounts}
features={hookFeatures.map((f) => ({ features={hookFeatures.map((f) => ({
id: f.id, id: f.id,
branchName: f.branchName, branchName: f.branchName,
@@ -929,6 +974,7 @@ export function BoardView() {
onAdd={handleAddFeature} onAdd={handleAddFeature}
categorySuggestions={categorySuggestions} categorySuggestions={categorySuggestions}
branchSuggestions={branchSuggestions} branchSuggestions={branchSuggestions}
branchCardCounts={branchCardCounts}
defaultSkipTests={defaultSkipTests} defaultSkipTests={defaultSkipTests}
defaultBranch={selectedWorktreeBranch} defaultBranch={selectedWorktreeBranch}
currentBranch={currentWorktreeBranch || undefined} currentBranch={currentWorktreeBranch || undefined}
@@ -944,6 +990,7 @@ export function BoardView() {
onUpdate={handleUpdateFeature} onUpdate={handleUpdateFeature}
categorySuggestions={categorySuggestions} categorySuggestions={categorySuggestions}
branchSuggestions={branchSuggestions} branchSuggestions={branchSuggestions}
branchCardCounts={branchCardCounts}
currentBranch={currentWorktreeBranch || undefined} currentBranch={currentWorktreeBranch || undefined}
isMaximized={isMaximized} isMaximized={isMaximized}
showProfilesOnly={showProfilesOnly} showProfilesOnly={showProfilesOnly}
@@ -1065,15 +1112,24 @@ export function BoardView() {
onOpenChange={setShowDeleteWorktreeDialog} onOpenChange={setShowDeleteWorktreeDialog}
projectPath={currentProject.path} projectPath={currentProject.path}
worktree={selectedWorktreeForAction} worktree={selectedWorktreeForAction}
affectedFeatureCount={
selectedWorktreeForAction
? hookFeatures.filter(
(f) => f.branchName === selectedWorktreeForAction.branch
).length
: 0
}
onDeleted={(deletedWorktree, _deletedBranch) => { onDeleted={(deletedWorktree, _deletedBranch) => {
// Reset features that were assigned to the deleted worktree (by branch) // Reset features that were assigned to the deleted worktree (by branch)
hookFeatures.forEach((feature) => { hookFeatures.forEach((feature) => {
// Match by branch name since worktreePath is no longer stored // Match by branch name since worktreePath is no longer stored
if (feature.branchName === deletedWorktree.branch) { if (feature.branchName === deletedWorktree.branch) {
// Reset the feature's branch assignment // Reset the feature's branch assignment - update both local state and persist
persistFeatureUpdate(feature.id, { const updates = {
branchName: null as unknown as string | undefined, branchName: null as unknown as string | undefined,
}); };
updateFeature(feature.id, updates);
persistFeatureUpdate(feature.id, updates);
} }
}); });

View File

@@ -73,6 +73,7 @@ interface AddFeatureDialogProps {
}) => void; }) => void;
categorySuggestions: string[]; categorySuggestions: string[];
branchSuggestions: string[]; branchSuggestions: string[];
branchCardCounts?: Record<string, number>; // Map of branch name to unarchived card count
defaultSkipTests: boolean; defaultSkipTests: boolean;
defaultBranch?: string; defaultBranch?: string;
currentBranch?: string; currentBranch?: string;
@@ -87,6 +88,7 @@ export function AddFeatureDialog({
onAdd, onAdd,
categorySuggestions, categorySuggestions,
branchSuggestions, branchSuggestions,
branchCardCounts,
defaultSkipTests, defaultSkipTests,
defaultBranch = "main", defaultBranch = "main",
currentBranch, currentBranch,
@@ -115,11 +117,16 @@ export function AddFeatureDialog({
const [enhancementMode, setEnhancementMode] = useState< const [enhancementMode, setEnhancementMode] = useState<
"improve" | "technical" | "simplify" | "acceptance" "improve" | "technical" | "simplify" | "acceptance"
>("improve"); >("improve");
const [planningMode, setPlanningMode] = useState<PlanningMode>('skip'); const [planningMode, setPlanningMode] = useState<PlanningMode>("skip");
const [requirePlanApproval, setRequirePlanApproval] = useState(false); const [requirePlanApproval, setRequirePlanApproval] = useState(false);
// Get enhancement model, planning mode defaults, and worktrees setting from store // Get enhancement model, planning mode defaults, and worktrees setting from store
const { enhancementModel, defaultPlanningMode, defaultRequirePlanApproval, useWorktrees } = useAppStore(); const {
enhancementModel,
defaultPlanningMode,
defaultRequirePlanApproval,
useWorktrees,
} = useAppStore();
// Sync defaults when dialog opens // Sync defaults when dialog opens
useEffect(() => { useEffect(() => {
@@ -133,7 +140,13 @@ export function AddFeatureDialog({
setPlanningMode(defaultPlanningMode); setPlanningMode(defaultPlanningMode);
setRequirePlanApproval(defaultRequirePlanApproval); setRequirePlanApproval(defaultRequirePlanApproval);
} }
}, [open, defaultSkipTests, defaultBranch, defaultPlanningMode, defaultRequirePlanApproval]); }, [
open,
defaultSkipTests,
defaultBranch,
defaultPlanningMode,
defaultRequirePlanApproval,
]);
const handleAdd = () => { const handleAdd = () => {
if (!newFeature.description.trim()) { if (!newFeature.description.trim()) {
@@ -157,7 +170,7 @@ export function AddFeatureDialog({
// If currentBranch is provided (non-primary worktree), use it // If currentBranch is provided (non-primary worktree), use it
// Otherwise (primary worktree), use empty string which means "unassigned" (show only on primary) // Otherwise (primary worktree), use empty string which means "unassigned" (show only on primary)
const finalBranchName = useCurrentBranch const finalBranchName = useCurrentBranch
? (currentBranch || "") ? currentBranch || ""
: newFeature.branchName || ""; : newFeature.branchName || "";
onAdd({ onAdd({
@@ -398,6 +411,7 @@ export function AddFeatureDialog({
setNewFeature({ ...newFeature, branchName: value }) setNewFeature({ ...newFeature, branchName: value })
} }
branchSuggestions={branchSuggestions} branchSuggestions={branchSuggestions}
branchCardCounts={branchCardCounts}
currentBranch={currentBranch} currentBranch={currentBranch}
testIdPrefix="feature" testIdPrefix="feature"
/> />
@@ -480,7 +494,10 @@ export function AddFeatureDialog({
</TabsContent> </TabsContent>
{/* Options Tab */} {/* Options Tab */}
<TabsContent value="options" className="space-y-4 overflow-y-auto cursor-default"> <TabsContent
value="options"
className="space-y-4 overflow-y-auto cursor-default"
>
{/* Planning Mode Section */} {/* Planning Mode Section */}
<PlanningModeSelector <PlanningModeSelector
mode={planningMode} mode={planningMode}
@@ -515,9 +532,7 @@ export function AddFeatureDialog({
hotkeyActive={open} hotkeyActive={open}
data-testid="confirm-add-feature" data-testid="confirm-add-feature"
disabled={ disabled={
useWorktrees && useWorktrees && !useCurrentBranch && !newFeature.branchName.trim()
!useCurrentBranch &&
!newFeature.branchName.trim()
} }
> >
Add Feature Add Feature

View File

@@ -12,7 +12,7 @@ import {
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Loader2, Trash2, AlertTriangle } from "lucide-react"; import { Loader2, Trash2, AlertTriangle, FileWarning } from "lucide-react";
import { getElectronAPI } from "@/lib/electron"; import { getElectronAPI } from "@/lib/electron";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -30,6 +30,8 @@ interface DeleteWorktreeDialogProps {
projectPath: string; projectPath: string;
worktree: WorktreeInfo | null; worktree: WorktreeInfo | null;
onDeleted: (deletedWorktree: WorktreeInfo, deletedBranch: boolean) => void; onDeleted: (deletedWorktree: WorktreeInfo, deletedBranch: boolean) => void;
/** Number of features assigned to this worktree's branch */
affectedFeatureCount?: number;
} }
export function DeleteWorktreeDialog({ export function DeleteWorktreeDialog({
@@ -38,6 +40,7 @@ export function DeleteWorktreeDialog({
projectPath, projectPath,
worktree, worktree,
onDeleted, onDeleted,
affectedFeatureCount = 0,
}: DeleteWorktreeDialogProps) { }: DeleteWorktreeDialogProps) {
const [deleteBranch, setDeleteBranch] = useState(false); const [deleteBranch, setDeleteBranch] = useState(false);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@@ -100,6 +103,18 @@ export function DeleteWorktreeDialog({
? ?
</span> </span>
{affectedFeatureCount > 0 && (
<div className="flex items-start gap-2 p-3 rounded-md bg-orange-500/10 border border-orange-500/20 mt-2">
<FileWarning className="w-4 h-4 text-orange-500 mt-0.5 flex-shrink-0" />
<span className="text-orange-500 text-sm">
{affectedFeatureCount} feature{affectedFeatureCount !== 1 ? "s" : ""}{" "}
{affectedFeatureCount !== 1 ? "are" : "is"} assigned to this
branch. {affectedFeatureCount !== 1 ? "They" : "It"} will be
unassigned and moved to the main worktree.
</span>
</div>
)}
{worktree.hasChanges && ( {worktree.hasChanges && (
<div className="flex items-start gap-2 p-3 rounded-md bg-yellow-500/10 border border-yellow-500/20 mt-2"> <div className="flex items-start gap-2 p-3 rounded-md bg-yellow-500/10 border border-yellow-500/20 mt-2">
<AlertTriangle className="w-4 h-4 text-yellow-500 mt-0.5 flex-shrink-0" /> <AlertTriangle className="w-4 h-4 text-yellow-500 mt-0.5 flex-shrink-0" />

View File

@@ -77,6 +77,7 @@ interface EditFeatureDialogProps {
) => void; ) => void;
categorySuggestions: string[]; categorySuggestions: string[];
branchSuggestions: string[]; branchSuggestions: string[];
branchCardCounts?: Record<string, number>; // Map of branch name to unarchived card count
currentBranch?: string; currentBranch?: string;
isMaximized: boolean; isMaximized: boolean;
showProfilesOnly: boolean; showProfilesOnly: boolean;
@@ -90,6 +91,7 @@ export function EditFeatureDialog({
onUpdate, onUpdate,
categorySuggestions, categorySuggestions,
branchSuggestions, branchSuggestions,
branchCardCounts,
currentBranch, currentBranch,
isMaximized, isMaximized,
showProfilesOnly, showProfilesOnly,
@@ -389,6 +391,7 @@ export function EditFeatureDialog({
}) })
} }
branchSuggestions={branchSuggestions} branchSuggestions={branchSuggestions}
branchCardCounts={branchCardCounts}
currentBranch={currentBranch} currentBranch={currentBranch}
disabled={editingFeature.status !== "backlog"} disabled={editingFeature.status !== "backlog"}
testIdPrefix="edit-feature" testIdPrefix="edit-feature"

View File

@@ -82,8 +82,8 @@ export function useBoardActions({
} = useAppStore(); } = useAppStore();
const autoMode = useAutoMode(); const autoMode = useAutoMode();
// Note: getOrCreateWorktreeForFeature removed - worktrees are now created server-side // Worktrees are created when adding/editing features with a branch name
// at execution time based on feature.branchName // This ensures the worktree exists before the feature starts execution
const handleAddFeature = useCallback( const handleAddFeature = useCallback(
async (featureData: { async (featureData: {
@@ -100,24 +100,58 @@ export function useBoardActions({
planningMode: PlanningMode; planningMode: PlanningMode;
requirePlanApproval: boolean; requirePlanApproval: boolean;
}) => { }) => {
// Simplified: Only store branchName, no worktree creation on add
// Worktrees are created at execution time (when feature starts)
// Empty string means "unassigned" (show only on primary worktree) - convert to undefined // Empty string means "unassigned" (show only on primary worktree) - convert to undefined
// Non-empty string is the actual branch name (for non-primary worktrees) // Non-empty string is the actual branch name (for non-primary worktrees)
const finalBranchName = featureData.branchName || undefined; const finalBranchName = featureData.branchName || undefined;
// If worktrees enabled and a branch is specified, create the worktree now
// This ensures the worktree exists before the feature starts
if (useWorktrees && finalBranchName && currentProject) {
try {
const api = getElectronAPI();
if (api?.worktree?.create) {
const result = await api.worktree.create(
currentProject.path,
finalBranchName
);
if (result.success) {
console.log(
`[Board] Worktree for branch "${finalBranchName}" ${
result.worktree?.isNew ? "created" : "already exists"
}`
);
// Refresh worktree list in UI
onWorktreeCreated?.();
} else {
console.error(
`[Board] Failed to create worktree for branch "${finalBranchName}":`,
result.error
);
toast.error("Failed to create worktree", {
description: result.error || "An error occurred",
});
}
}
} catch (error) {
console.error("[Board] Error creating worktree:", error);
toast.error("Failed to create worktree", {
description:
error instanceof Error ? error.message : "An error occurred",
});
}
}
const newFeatureData = { const newFeatureData = {
...featureData, ...featureData,
status: "backlog" as const, status: "backlog" as const,
branchName: finalBranchName, branchName: finalBranchName,
// No worktreePath - derived at runtime from branchName
}; };
const createdFeature = addFeature(newFeatureData); const createdFeature = addFeature(newFeatureData);
// Must await to ensure feature exists on server before user can drag it // Must await to ensure feature exists on server before user can drag it
await persistFeatureCreate(createdFeature); await persistFeatureCreate(createdFeature);
saveCategory(featureData.category); saveCategory(featureData.category);
}, },
[addFeature, persistFeatureCreate, saveCategory] [addFeature, persistFeatureCreate, saveCategory, useWorktrees, currentProject, onWorktreeCreated]
); );
const handleUpdateFeature = useCallback( const handleUpdateFeature = useCallback(
@@ -139,6 +173,43 @@ export function useBoardActions({
) => { ) => {
const finalBranchName = updates.branchName || undefined; const finalBranchName = updates.branchName || undefined;
// If worktrees enabled and a branch is specified, create the worktree now
// This ensures the worktree exists before the feature starts
if (useWorktrees && finalBranchName && currentProject) {
try {
const api = getElectronAPI();
if (api?.worktree?.create) {
const result = await api.worktree.create(
currentProject.path,
finalBranchName
);
if (result.success) {
console.log(
`[Board] Worktree for branch "${finalBranchName}" ${
result.worktree?.isNew ? "created" : "already exists"
}`
);
// Refresh worktree list in UI
onWorktreeCreated?.();
} else {
console.error(
`[Board] Failed to create worktree for branch "${finalBranchName}":`,
result.error
);
toast.error("Failed to create worktree", {
description: result.error || "An error occurred",
});
}
}
} catch (error) {
console.error("[Board] Error creating worktree:", error);
toast.error("Failed to create worktree", {
description:
error instanceof Error ? error.message : "An error occurred",
});
}
}
const finalUpdates = { const finalUpdates = {
...updates, ...updates,
branchName: finalBranchName, branchName: finalBranchName,
@@ -151,7 +222,7 @@ export function useBoardActions({
} }
setEditingFeature(null); setEditingFeature(null);
}, },
[updateFeature, persistFeatureUpdate, saveCategory, setEditingFeature] [updateFeature, persistFeatureUpdate, saveCategory, setEditingFeature, useWorktrees, currentProject, onWorktreeCreated]
); );
const handleDeleteFeature = useCallback( const handleDeleteFeature = useCallback(

View File

@@ -10,6 +10,7 @@ interface BranchSelectorProps {
branchName: string; branchName: string;
onBranchNameChange: (branchName: string) => void; onBranchNameChange: (branchName: string) => void;
branchSuggestions: string[]; branchSuggestions: string[];
branchCardCounts?: Record<string, number>; // Map of branch name to unarchived card count
currentBranch?: string; currentBranch?: string;
disabled?: boolean; disabled?: boolean;
testIdPrefix?: string; testIdPrefix?: string;
@@ -21,6 +22,7 @@ export function BranchSelector({
branchName, branchName,
onBranchNameChange, onBranchNameChange,
branchSuggestions, branchSuggestions,
branchCardCounts,
currentBranch, currentBranch,
disabled = false, disabled = false,
testIdPrefix = "branch", testIdPrefix = "branch",
@@ -69,6 +71,7 @@ export function BranchSelector({
value={branchName} value={branchName}
onChange={onBranchNameChange} onChange={onBranchNameChange}
branches={branchSuggestions} branches={branchSuggestions}
branchCardCounts={branchCardCounts}
placeholder="Select or create branch..." placeholder="Select or create branch..."
data-testid={`${testIdPrefix}-input`} data-testid={`${testIdPrefix}-input`}
disabled={disabled} disabled={disabled}

View File

@@ -9,6 +9,7 @@ import { WorktreeActionsDropdown } from "./worktree-actions-dropdown";
interface WorktreeTabProps { interface WorktreeTabProps {
worktree: WorktreeInfo; worktree: WorktreeInfo;
cardCount?: number; // Number of unarchived cards for this branch
isSelected: boolean; isSelected: boolean;
isRunning: boolean; isRunning: boolean;
isActivating: boolean; isActivating: boolean;
@@ -44,6 +45,7 @@ interface WorktreeTabProps {
export function WorktreeTab({ export function WorktreeTab({
worktree, worktree,
cardCount,
isSelected, isSelected,
isRunning, isRunning,
isActivating, isActivating,
@@ -97,9 +99,9 @@ export function WorktreeTab({
<RefreshCw className="w-3 h-3 animate-spin" /> <RefreshCw className="w-3 h-3 animate-spin" />
)} )}
{worktree.branch} {worktree.branch}
{worktree.hasChanges && ( {cardCount !== undefined && cardCount > 0 && (
<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">
{worktree.changedFilesCount} {cardCount}
</span> </span>
)} )}
</Button> </Button>
@@ -140,9 +142,9 @@ export function WorktreeTab({
<RefreshCw className="w-3 h-3 animate-spin" /> <RefreshCw className="w-3 h-3 animate-spin" />
)} )}
{worktree.branch} {worktree.branch}
{worktree.hasChanges && ( {cardCount !== undefined && cardCount > 0 && (
<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">
{worktree.changedFilesCount} {cardCount}
</span> </span>
)} )}
</Button> </Button>

View File

@@ -35,5 +35,6 @@ export interface WorktreePanelProps {
onRemovedWorktrees?: (removedWorktrees: Array<{ path: string; branch: string }>) => void; onRemovedWorktrees?: (removedWorktrees: Array<{ path: string; branch: string }>) => void;
runningFeatureIds?: string[]; runningFeatureIds?: string[];
features?: FeatureInfo[]; features?: FeatureInfo[];
branchCardCounts?: Record<string, number>; // Map of branch name to unarchived card count
refreshTrigger?: number; refreshTrigger?: number;
} }

View File

@@ -24,6 +24,7 @@ export function WorktreePanel({
onRemovedWorktrees, onRemovedWorktrees,
runningFeatureIds = [], runningFeatureIds = [],
features = [], features = [],
branchCardCounts,
refreshTrigger = 0, refreshTrigger = 0,
}: WorktreePanelProps) { }: WorktreePanelProps) {
const { const {
@@ -110,43 +111,47 @@ export function WorktreePanel({
<span className="text-sm text-muted-foreground mr-2">Branch:</span> <span className="text-sm text-muted-foreground mr-2">Branch:</span>
<div className="flex items-center gap-1 flex-wrap"> <div className="flex items-center gap-1 flex-wrap">
{worktrees.map((worktree) => ( {worktrees.map((worktree) => {
<WorktreeTab const cardCount = branchCardCounts?.[worktree.branch];
key={worktree.path} return (
worktree={worktree} <WorktreeTab
isSelected={isWorktreeSelected(worktree)} key={worktree.path}
isRunning={hasRunningFeatures(worktree)} worktree={worktree}
isActivating={isActivating} cardCount={cardCount}
isDevServerRunning={isDevServerRunning(worktree)} isSelected={isWorktreeSelected(worktree)}
devServerInfo={getDevServerInfo(worktree)} isRunning={hasRunningFeatures(worktree)}
defaultEditorName={defaultEditorName} isActivating={isActivating}
branches={branches} isDevServerRunning={isDevServerRunning(worktree)}
filteredBranches={filteredBranches} devServerInfo={getDevServerInfo(worktree)}
branchFilter={branchFilter} defaultEditorName={defaultEditorName}
isLoadingBranches={isLoadingBranches} branches={branches}
isSwitching={isSwitching} filteredBranches={filteredBranches}
isPulling={isPulling} branchFilter={branchFilter}
isPushing={isPushing} isLoadingBranches={isLoadingBranches}
isStartingDevServer={isStartingDevServer} isSwitching={isSwitching}
aheadCount={aheadCount} isPulling={isPulling}
behindCount={behindCount} isPushing={isPushing}
onSelectWorktree={handleSelectWorktree} isStartingDevServer={isStartingDevServer}
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(worktree)} aheadCount={aheadCount}
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(worktree)} behindCount={behindCount}
onBranchFilterChange={setBranchFilter} onSelectWorktree={handleSelectWorktree}
onSwitchBranch={handleSwitchBranch} onBranchDropdownOpenChange={handleBranchDropdownOpenChange(worktree)}
onCreateBranch={onCreateBranch} onActionsDropdownOpenChange={handleActionsDropdownOpenChange(worktree)}
onPull={handlePull} onBranchFilterChange={setBranchFilter}
onPush={handlePush} onSwitchBranch={handleSwitchBranch}
onOpenInEditor={handleOpenInEditor} onCreateBranch={onCreateBranch}
onCommit={onCommit} onPull={handlePull}
onCreatePR={onCreatePR} onPush={handlePush}
onDeleteWorktree={onDeleteWorktree} onOpenInEditor={handleOpenInEditor}
onStartDevServer={handleStartDevServer} onCommit={onCommit}
onStopDevServer={handleStopDevServer} onCreatePR={onCreatePR}
onOpenDevServerUrl={handleOpenDevServerUrl} onDeleteWorktree={onDeleteWorktree}
/> onStartDevServer={handleStartDevServer}
))} onStopDevServer={handleStopDevServer}
onOpenDevServerUrl={handleOpenDevServerUrl}
/>
);
})}
<Button <Button
variant="ghost" variant="ghost"

View File

@@ -779,7 +779,7 @@ test.describe("Worktree Integration Tests", () => {
expect(featureData.worktreePath).toBeUndefined(); expect(featureData.worktreePath).toBeUndefined();
}); });
test("should store branch name when adding feature with new branch (worktree created at execution)", async ({ test("should store branch name when adding feature with new branch (worktree created when adding feature)", async ({
page, page,
}) => { }) => {
await setupProjectWithPath(page, testRepo.path); await setupProjectWithPath(page, testRepo.path);
@@ -788,7 +788,7 @@ test.describe("Worktree Integration Tests", () => {
await waitForBoardView(page); await waitForBoardView(page);
// Use a branch name that doesn't exist yet // Use a branch name that doesn't exist yet
// Note: Worktrees are now created at execution time, not when adding to backlog // Note: Worktrees are now created when features are added/edited, not at execution time
const branchName = "feature/auto-create-worktree"; const branchName = "feature/auto-create-worktree";
// Verify branch does NOT exist before we create the feature // Verify branch does NOT exist before we create the feature
@@ -807,12 +807,16 @@ test.describe("Worktree Integration Tests", () => {
// Confirm // Confirm
await confirmAddFeature(page); await confirmAddFeature(page);
// Wait for feature to be saved // Wait for feature to be saved and worktree to be created
await page.waitForTimeout(1000); await page.waitForTimeout(2000);
// Verify branch was NOT created when adding feature (created at execution time) // Verify branch WAS created when adding feature (worktrees are created when features are added/edited)
const branchesAfter = await listBranches(testRepo.path); const branchesAfter = await listBranches(testRepo.path);
expect(branchesAfter).not.toContain(branchName); expect(branchesAfter).toContain(branchName);
// Verify worktree was created
const worktreePath = getWorktreePath(testRepo.path, branchName);
expect(fs.existsSync(worktreePath)).toBe(true);
// Verify feature was created with correct branch name stored // Verify feature was created with correct branch name stored
const featuresDir = path.join(testRepo.path, ".automaker", "features"); const featuresDir = path.join(testRepo.path, ".automaker", "features");
@@ -835,9 +839,6 @@ test.describe("Worktree Integration Tests", () => {
// Verify branch name is stored // Verify branch name is stored
expect(featureData.branchName).toBe(branchName); expect(featureData.branchName).toBe(branchName);
// Verify worktreePath is NOT set (worktrees are created at execution time)
expect(featureData.worktreePath).toBeUndefined();
// Verify feature is in backlog status // Verify feature is in backlog status
expect(featureData.status).toBe("backlog"); expect(featureData.status).toBe("backlog");
}); });
@@ -2399,7 +2400,7 @@ test.describe("Worktree Integration Tests", () => {
const newBranchName = "feature/edited-branch"; const newBranchName = "feature/edited-branch";
const expectedWorktreePath = getWorktreePath(testRepo.path, newBranchName); const expectedWorktreePath = getWorktreePath(testRepo.path, newBranchName);
// Verify worktree does NOT exist before editing (worktrees are created at execution time) // Verify worktree does NOT exist before editing
expect(fs.existsSync(expectedWorktreePath)).toBe(false); expect(fs.existsSync(expectedWorktreePath)).toBe(false);
// Find and click the edit button on the feature card // Find and click the edit button on the feature card
@@ -2435,22 +2436,19 @@ test.describe("Worktree Integration Tests", () => {
const saveButton = page.locator('[data-testid="confirm-edit-feature"]'); const saveButton = page.locator('[data-testid="confirm-edit-feature"]');
await saveButton.click(); await saveButton.click();
// Wait for the dialog to close // Wait for the dialog to close and worktree to be created
await page.waitForTimeout(2000); await page.waitForTimeout(2000);
// Verify worktree was NOT created during editing (worktrees are created at execution time) // Verify worktree WAS created during editing (worktrees are now created when features are added/edited)
expect(fs.existsSync(expectedWorktreePath)).toBe(false); expect(fs.existsSync(expectedWorktreePath)).toBe(true);
// Verify branch was NOT created (created at execution time) // Verify branch WAS created (worktrees are created when features are added/edited)
const branches = await listBranches(testRepo.path); const branches = await listBranches(testRepo.path);
expect(branches).not.toContain(newBranchName); expect(branches).toContain(newBranchName);
// Verify feature was updated with correct branchName only // Verify feature was updated with correct branchName
// Note: worktreePath is no longer stored - worktrees are created server-side at execution time
featureData = JSON.parse(fs.readFileSync(featureFilePath, "utf-8")); featureData = JSON.parse(fs.readFileSync(featureFilePath, "utf-8"));
expect(featureData.branchName).toBe(newBranchName); expect(featureData.branchName).toBe(newBranchName);
// worktreePath should not exist in the feature data
expect(featureData.worktreePath).toBeUndefined();
}); });
test("should not create worktree when editing a feature and selecting main branch", async ({ test("should not create worktree when editing a feature and selecting main branch", async ({

View File

@@ -13,6 +13,9 @@ import {
const logger = createLogger("Worktree"); const logger = createLogger("Worktree");
const execAsync = promisify(exec); const execAsync = promisify(exec);
export const AUTOMAKER_INITIAL_COMMIT_MESSAGE =
"chore: automaker initial commit";
/** /**
* Normalize path separators to forward slashes for cross-platform consistency. * Normalize path separators to forward slashes for cross-platform consistency.
* This ensures paths from `path.join()` (backslashes on Windows) match paths * This ensures paths from `path.join()` (backslashes on Windows) match paths
@@ -73,3 +76,30 @@ export function logWorktreeError(
// Re-export shared utilities // Re-export shared utilities
export { getErrorMessageShared as getErrorMessage }; export { getErrorMessageShared as getErrorMessage };
export const logError = createLogError(logger); export const logError = createLogError(logger);
/**
* Ensure the repository has at least one commit so git commands that rely on HEAD work.
* Returns true if an empty commit was created, false if the repo already had commits.
*/
export async function ensureInitialCommit(repoPath: string): Promise<boolean> {
try {
await execAsync("git rev-parse --verify HEAD", { cwd: repoPath });
return false;
} catch {
try {
await execAsync(
`git commit --allow-empty -m "${AUTOMAKER_INITIAL_COMMIT_MESSAGE}"`,
{ cwd: repoPath }
);
logger.info(
`[Worktree] Created initial empty commit to enable worktrees in ${repoPath}`
);
return true;
} catch (error) {
const reason = getErrorMessageShared(error);
throw new Error(
`Failed to create initial git commit. Please commit manually and retry. ${reason}`
);
}
}
}

View File

@@ -12,7 +12,13 @@ import { exec } from "child_process";
import { promisify } from "util"; import { promisify } from "util";
import path from "path"; import path from "path";
import { mkdir } from "fs/promises"; import { mkdir } from "fs/promises";
import { isGitRepo, getErrorMessage, logError, normalizePath } from "../common.js"; import {
isGitRepo,
getErrorMessage,
logError,
normalizePath,
ensureInitialCommit,
} from "../common.js";
import { trackBranch } from "./branch-tracking.js"; import { trackBranch } from "./branch-tracking.js";
const execAsync = promisify(exec); const execAsync = promisify(exec);
@@ -93,6 +99,9 @@ export function createCreateHandler() {
return; return;
} }
// Ensure the repository has at least one commit so worktree commands referencing HEAD succeed
await ensureInitialCommit(projectPath);
// First, check if git already has a worktree for this branch (anywhere) // First, check if git already has a worktree for this branch (anywhere)
const existingWorktree = await findExistingWorktreeForBranch(projectPath, branchName); const existingWorktree = await findExistingWorktreeForBranch(projectPath, branchName);
if (existingWorktree) { if (existingWorktree) {

View File

@@ -520,29 +520,28 @@ export class AutoModeService {
} }
// Derive workDir from feature.branchName // Derive workDir from feature.branchName
// If no branchName, derive from feature ID: feature/{featureId} // Worktrees should already be created when the feature is added/edited
let worktreePath: string | null = null; let worktreePath: string | null = null;
const branchName = feature.branchName || `feature/${featureId}`; const branchName = feature.branchName;
if (useWorktrees && branchName) { if (useWorktrees && branchName) {
// Try to find existing worktree for this branch // Try to find existing worktree for this branch
// Worktree should already exist (created when feature was added/edited)
worktreePath = await this.findExistingWorktreeForBranch( worktreePath = await this.findExistingWorktreeForBranch(
projectPath, projectPath,
branchName branchName
); );
if (!worktreePath) { if (worktreePath) {
// Create worktree for this branch console.log(
worktreePath = await this.setupWorktree( `[AutoMode] Using worktree for branch "${branchName}": ${worktreePath}`
projectPath, );
featureId, } else {
branchName // Worktree doesn't exist - log warning and continue with project path
console.warn(
`[AutoMode] Worktree for branch "${branchName}" not found, using project path`
); );
} }
console.log(
`[AutoMode] Using worktree for branch "${branchName}": ${worktreePath}`
);
} }
// Ensure workDir is always an absolute path for cross-platform compatibility // Ensure workDir is always an absolute path for cross-platform compatibility
@@ -552,7 +551,7 @@ export class AutoModeService {
// Update running feature with actual worktree info // Update running feature with actual worktree info
tempRunningFeature.worktreePath = worktreePath; tempRunningFeature.worktreePath = worktreePath;
tempRunningFeature.branchName = branchName; tempRunningFeature.branchName = branchName ?? null;
// 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");
@@ -1410,60 +1409,6 @@ Format your response as a structured markdown document.`;
} }
} }
private async setupWorktree(
projectPath: string,
featureId: string,
branchName: string
): Promise<string> {
// First, check if git already has a worktree for this branch (anywhere)
const existingWorktree = await this.findExistingWorktreeForBranch(
projectPath,
branchName
);
if (existingWorktree) {
// Path is already resolved to absolute in findExistingWorktreeForBranch
console.log(
`[AutoMode] Found existing worktree for branch "${branchName}" at: ${existingWorktree}`
);
return existingWorktree;
}
// Git worktrees stay in project directory
const worktreesDir = path.join(projectPath, ".worktrees");
const worktreePath = path.join(worktreesDir, featureId);
await fs.mkdir(worktreesDir, { recursive: true });
// Check if worktree directory already exists (might not be linked to branch)
try {
await fs.access(worktreePath);
// Return absolute path for cross-platform compatibility
return path.resolve(worktreePath);
} catch {
// Create new worktree
}
// Create branch if it doesn't exist
try {
await execAsync(`git branch ${branchName}`, { cwd: projectPath });
} catch {
// Branch may already exist
}
// Create worktree
try {
await execAsync(`git worktree add "${worktreePath}" ${branchName}`, {
cwd: projectPath,
});
// Return absolute path for cross-platform compatibility
return path.resolve(worktreePath);
} catch (error) {
// Worktree creation failed, fall back to direct execution
console.error(`[AutoMode] Worktree creation failed:`, error);
return path.resolve(projectPath);
}
}
private async loadFeature( private async loadFeature(
projectPath: string, projectPath: string,
featureId: string featureId: string

View File

@@ -0,0 +1,69 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { createCreateHandler } from "@/routes/worktree/routes/create.js";
import { AUTOMAKER_INITIAL_COMMIT_MESSAGE } from "@/routes/worktree/common.js";
import { exec } from "child_process";
import { promisify } from "util";
import * as fs from "fs/promises";
import * as os from "os";
import * as path from "path";
const execAsync = promisify(exec);
describe("worktree create route - repositories without commits", () => {
let repoPath: string | null = null;
async function initRepoWithoutCommit() {
repoPath = await fs.mkdtemp(
path.join(os.tmpdir(), "automaker-no-commit-")
);
await execAsync("git init", { cwd: repoPath });
await execAsync('git config user.email "test@example.com"', {
cwd: repoPath,
});
await execAsync('git config user.name "Test User"', { cwd: repoPath });
// Intentionally skip creating an initial commit
}
afterEach(async () => {
if (!repoPath) {
return;
}
await fs.rm(repoPath, { recursive: true, force: true });
repoPath = null;
});
it("creates an initial commit before adding a worktree when HEAD is missing", async () => {
await initRepoWithoutCommit();
const handler = createCreateHandler();
const json = vi.fn();
const status = vi.fn().mockReturnThis();
const req = {
body: { projectPath: repoPath, branchName: "feature/no-head" },
} as any;
const res = {
json,
status,
} as any;
await handler(req, res);
expect(status).not.toHaveBeenCalled();
expect(json).toHaveBeenCalled();
const payload = json.mock.calls[0][0];
expect(payload.success).toBe(true);
const { stdout: commitCount } = await execAsync(
"git rev-list --count HEAD",
{ cwd: repoPath! }
);
expect(Number(commitCount.trim())).toBeGreaterThan(0);
const { stdout: latestMessage } = await execAsync(
"git log -1 --pretty=%B",
{ cwd: repoPath! }
);
expect(latestMessage.trim()).toBe(AUTOMAKER_INITIAL_COMMIT_MESSAGE);
});
});

View File

@@ -13,6 +13,10 @@ import {
} from "../helpers/git-test-repo.js"; } from "../helpers/git-test-repo.js";
import * as fs from "fs/promises"; import * as fs from "fs/promises";
import * as path from "path"; import * as path from "path";
import { exec } from "child_process";
import { promisify } from "util";
const execAsync = promisify(exec);
vi.mock("@/providers/provider-factory.js"); vi.mock("@/providers/provider-factory.js");
@@ -43,13 +47,24 @@ describe("auto-mode-service.ts (integration)", () => {
}); });
describe("worktree operations", () => { describe("worktree operations", () => {
it("should create git worktree for feature", async () => { it("should use existing git worktree for feature", async () => {
// Create a test feature const branchName = "feature/test-feature-1";
// Create a test feature with branchName set
await createTestFeature(testRepo.path, "test-feature-1", { await createTestFeature(testRepo.path, "test-feature-1", {
id: "test-feature-1", id: "test-feature-1",
category: "test", category: "test",
description: "Test feature", description: "Test feature",
status: "pending", status: "pending",
branchName: branchName,
});
// Create worktree before executing (worktrees are now created when features are added/edited)
const worktreesDir = path.join(testRepo.path, ".worktrees");
const worktreePath = path.join(worktreesDir, "test-feature-1");
await fs.mkdir(worktreesDir, { recursive: true });
await execAsync(`git worktree add -b ${branchName} "${worktreePath}" HEAD`, {
cwd: testRepo.path,
}); });
// Mock provider to complete quickly // Mock provider to complete quickly
@@ -82,9 +97,20 @@ describe("auto-mode-service.ts (integration)", () => {
false // isAutoMode false // isAutoMode
); );
// Verify branch was created // Verify branch exists (was created when worktree was created)
const branches = await listBranches(testRepo.path); const branches = await listBranches(testRepo.path);
expect(branches).toContain("feature/test-feature-1"); expect(branches).toContain(branchName);
// Verify worktree exists and is being used
// The service should have found and used the worktree (check via logs)
// We can verify the worktree exists by checking git worktree list
const worktrees = await listWorktrees(testRepo.path);
expect(worktrees.length).toBeGreaterThan(0);
// Verify that at least one worktree path contains our feature ID
const worktreePathsMatch = worktrees.some(wt =>
wt.includes("test-feature-1") || wt.includes(".worktrees")
);
expect(worktreePathsMatch).toBe(true);
// Note: Worktrees are not automatically cleaned up by the service // Note: Worktrees are not automatically cleaned up by the service
// This is expected behavior - manual cleanup is required // This is expected behavior - manual cleanup is required