diff --git a/apps/server/src/routes/worktree/common.ts b/apps/server/src/routes/worktree/common.ts index b165b025..afe42e7a 100644 --- a/apps/server/src/routes/worktree/common.ts +++ b/apps/server/src/routes/worktree/common.ts @@ -17,6 +17,9 @@ const logger = createLogger("Worktree"); const execAsync = promisify(exec); const featureLoader = new FeatureLoader(); +export const AUTOMAKER_INITIAL_COMMIT_MESSAGE = + "chore: automaker initial commit"; + /** * Normalize path separators to forward slashes for cross-platform consistency. * This ensures paths from `path.join()` (backslashes on Windows) match paths @@ -77,3 +80,30 @@ export function logWorktreeError( // Re-export shared utilities export { getErrorMessageShared as getErrorMessage }; 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 { + 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}` + ); + } + } +} diff --git a/apps/server/src/routes/worktree/routes/create.ts b/apps/server/src/routes/worktree/routes/create.ts index ab44374b..690afe48 100644 --- a/apps/server/src/routes/worktree/routes/create.ts +++ b/apps/server/src/routes/worktree/routes/create.ts @@ -12,7 +12,13 @@ import { exec } from "child_process"; import { promisify } from "util"; import path from "path"; 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"; const execAsync = promisify(exec); @@ -93,6 +99,9 @@ export function createCreateHandler() { 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) const existingWorktree = await findExistingWorktreeForBranch(projectPath, branchName); if (existingWorktree) { diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index 8edfd6dd..14fdf724 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -520,29 +520,28 @@ export class AutoModeService { } // 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; - const branchName = feature.branchName || `feature/${featureId}`; + const branchName = feature.branchName; if (useWorktrees && branchName) { // Try to find existing worktree for this branch + // Worktree should already exist (created when feature was added/edited) worktreePath = await this.findExistingWorktreeForBranch( projectPath, branchName ); - if (!worktreePath) { - // Create worktree for this branch - worktreePath = await this.setupWorktree( - projectPath, - featureId, - branchName + if (worktreePath) { + console.log( + `[AutoMode] Using worktree for branch "${branchName}": ${worktreePath}` + ); + } else { + // 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 @@ -552,7 +551,7 @@ export class AutoModeService { // Update running feature with actual worktree info tempRunningFeature.worktreePath = worktreePath; - tempRunningFeature.branchName = branchName; + tempRunningFeature.branchName = branchName ?? null; // Update feature status to in_progress await this.updateFeatureStatus(projectPath, featureId, "in_progress"); @@ -1479,60 +1478,6 @@ Format your response as a structured markdown document.`; } } - private async setupWorktree( - projectPath: string, - featureId: string, - branchName: string - ): Promise { - // 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( projectPath: string, featureId: string diff --git a/apps/server/tests/integration/routes/worktree/create.integration.test.ts b/apps/server/tests/integration/routes/worktree/create.integration.test.ts new file mode 100644 index 00000000..03b85e7e --- /dev/null +++ b/apps/server/tests/integration/routes/worktree/create.integration.test.ts @@ -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); + }); +}); + diff --git a/apps/server/tests/integration/services/auto-mode-service.integration.test.ts b/apps/server/tests/integration/services/auto-mode-service.integration.test.ts index 45b4d6e4..ebf0857f 100644 --- a/apps/server/tests/integration/services/auto-mode-service.integration.test.ts +++ b/apps/server/tests/integration/services/auto-mode-service.integration.test.ts @@ -13,6 +13,10 @@ import { } from "../helpers/git-test-repo.js"; import * as fs from "fs/promises"; import * as path from "path"; +import { exec } from "child_process"; +import { promisify } from "util"; + +const execAsync = promisify(exec); vi.mock("@/providers/provider-factory.js"); @@ -43,13 +47,24 @@ describe("auto-mode-service.ts (integration)", () => { }); describe("worktree operations", () => { - it("should create git worktree for feature", async () => { - // Create a test feature + it("should use existing git worktree for feature", async () => { + const branchName = "feature/test-feature-1"; + + // Create a test feature with branchName set await createTestFeature(testRepo.path, "test-feature-1", { id: "test-feature-1", category: "test", description: "Test feature", 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 @@ -82,9 +97,20 @@ describe("auto-mode-service.ts (integration)", () => { false // isAutoMode ); - // Verify branch was created + // Verify branch exists (was created when worktree was created) 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 // This is expected behavior - manual cleanup is required diff --git a/apps/ui/src/components/session-manager.tsx b/apps/ui/src/components/session-manager.tsx index 7f2a4585..c255c27f 100644 --- a/apps/ui/src/components/session-manager.tsx +++ b/apps/ui/src/components/session-manager.tsx @@ -1,11 +1,6 @@ import { useState, useEffect } from "react"; -import { - Card, - CardContent, - CardHeader, - CardTitle, -} from "@/components/ui/card"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { HotkeyButton } from "@/components/ui/hotkey-button"; import { Input } from "@/components/ui/input"; @@ -115,8 +110,10 @@ export function SessionManager({ new Set() ); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); - const [sessionToDelete, setSessionToDelete] = useState(null); - const [isDeleteAllArchivedDialogOpen, setIsDeleteAllArchivedDialogOpen] = useState(false); + const [sessionToDelete, setSessionToDelete] = + useState(null); + const [isDeleteAllArchivedDialogOpen, setIsDeleteAllArchivedDialogOpen] = + useState(false); // Check running state for all sessions const checkRunningSessions = async (sessionList: SessionListItem[]) => { @@ -233,11 +230,7 @@ export function SessionManager({ const api = getElectronAPI(); if (!editingName.trim() || !api?.sessions) return; - const result = await api.sessions.update( - sessionId, - editingName, - undefined - ); + const result = await api.sessions.update(sessionId, editingName, undefined); if (result.success) { setEditingSessionId(null); diff --git a/apps/ui/src/components/ui/branch-autocomplete.tsx b/apps/ui/src/components/ui/branch-autocomplete.tsx index 32b00ce1..9af78bd8 100644 --- a/apps/ui/src/components/ui/branch-autocomplete.tsx +++ b/apps/ui/src/components/ui/branch-autocomplete.tsx @@ -7,6 +7,7 @@ interface BranchAutocompleteProps { value: string; onChange: (value: string) => void; branches: string[]; + branchCardCounts?: Record; // Map of branch name to unarchived card count placeholder?: string; className?: string; disabled?: boolean; @@ -18,6 +19,7 @@ export function BranchAutocomplete({ value, onChange, branches, + branchCardCounts, placeholder = "Select a branch...", className, disabled = false, @@ -27,12 +29,22 @@ export function BranchAutocomplete({ // Always include "main" at the top of suggestions const branchOptions: AutocompleteOption[] = React.useMemo(() => { const branchSet = new Set(["main", ...branches]); - return Array.from(branchSet).map((branch) => ({ - value: branch, - label: branch, - badge: branch === "main" ? "default" : undefined, - })); - }, [branches]); + return Array.from(branchSet).map((branch) => { + const cardCount = branchCardCounts?.[branch]; + // Show card count if available, otherwise show "default" for main branch only + const badge = branchCardCounts !== undefined + ? String(cardCount ?? 0) + : branch === "main" + ? "default" + : undefined; + + return { + value: branch, + label: branch, + badge, + }; + }); + }, [branches, branchCardCounts]); return ( { + return hookFeatures.reduce((counts, feature) => { + if (feature.status !== "completed") { + const branch = feature.branchName ?? "main"; + counts[branch] = (counts[branch] || 0) + 1; + } + return counts; + }, {} as Record); + }, [hookFeatures]); + // Custom collision detection that prioritizes columns over cards const collisionDetectionStrategy = useCallback((args: any) => { // First, check if pointer is within a column @@ -301,14 +312,14 @@ export function BoardView() { }); if (matchesRemovedWorktree) { - // Reset the feature's branch assignment - persistFeatureUpdate(feature.id, { - branchName: null as unknown as string | undefined, - }); + // Reset the feature's branch assignment - update both local state and persist + const updates = { 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) @@ -417,6 +428,18 @@ export function BoardView() { hookFeaturesRef.current = 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) const pendingFeaturesRef = useRef>(new Set()); @@ -484,8 +507,9 @@ export function BoardView() { } // Count currently running tasks + pending features + // Use ref to get the latest running tasks without causing effect re-runs const currentRunning = - runningAutoTasks.length + pendingFeaturesRef.current.size; + runningAutoTasksRef.current.length + pendingFeaturesRef.current.size; const availableSlots = maxConcurrency - currentRunning; // No available slots, skip check @@ -540,6 +564,10 @@ export function BoardView() { // Start features up to available slots const featuresToStart = eligibleFeatures.slice(0, availableSlots); + const startImplementation = handleStartImplementationRef.current; + if (!startImplementation) { + return; + } for (const feature of featuresToStart) { // Check again before starting each feature @@ -565,7 +593,7 @@ export function BoardView() { } // 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 (started) { @@ -579,7 +607,7 @@ export function BoardView() { // Check immediately, then every 3 seconds checkAndStartFeatures(); - const interval = setInterval(checkAndStartFeatures, 3000); + const interval = setInterval(checkAndStartFeatures, 1000); return () => { // Mark as inactive to prevent any pending async operations from continuing @@ -591,7 +619,8 @@ export function BoardView() { }, [ autoMode.isRunning, currentProject, - runningAutoTasks, + // runningAutoTasks is accessed via runningAutoTasksRef to prevent effect re-runs + // that would clear pendingFeaturesRef and cause concurrency issues maxConcurrency, // hookFeatures is accessed via hookFeaturesRef to prevent effect re-runs currentWorktreeBranch, @@ -600,7 +629,6 @@ export function BoardView() { isPrimaryWorktreeBranch, enableDependencyBlocking, persistFeatureUpdate, - handleStartImplementation, ]); // Use keyboard shortcuts hook (after actions hook) @@ -639,7 +667,9 @@ export function BoardView() { // Find feature for pending plan approval const pendingApprovalFeature = useMemo(() => { if (!pendingPlanApproval) return null; - return hookFeatures.find((f) => f.id === pendingPlanApproval.featureId) || null; + return ( + hookFeatures.find((f) => f.id === pendingPlanApproval.featureId) || null + ); }, [pendingPlanApproval, hookFeatures]); // Handle plan approval @@ -665,10 +695,10 @@ export function BoardView() { if (result.success) { // Immediately update local feature state to hide "Approve Plan" button // Get current feature to preserve version - const currentFeature = hookFeatures.find(f => f.id === featureId); + const currentFeature = hookFeatures.find((f) => f.id === featureId); updateFeature(featureId, { planSpec: { - status: 'approved', + status: "approved", content: editedPlan || pendingPlanApproval.planContent, version: currentFeature?.planSpec?.version || 1, approvedAt: new Date().toISOString(), @@ -687,7 +717,14 @@ export function BoardView() { setPendingPlanApproval(null); } }, - [pendingPlanApproval, currentProject, setPendingPlanApproval, updateFeature, loadFeatures, hookFeatures] + [ + pendingPlanApproval, + currentProject, + setPendingPlanApproval, + updateFeature, + loadFeatures, + hookFeatures, + ] ); // Handle plan rejection @@ -714,11 +751,11 @@ export function BoardView() { if (result.success) { // Immediately update local feature state // Get current feature to preserve version - const currentFeature = hookFeatures.find(f => f.id === featureId); + const currentFeature = hookFeatures.find((f) => f.id === featureId); updateFeature(featureId, { - status: 'backlog', + status: "backlog", planSpec: { - status: 'rejected', + status: "rejected", content: pendingPlanApproval.planContent, version: currentFeature?.planSpec?.version || 1, reviewedByUser: true, @@ -736,7 +773,14 @@ export function BoardView() { setPendingPlanApproval(null); } }, - [pendingPlanApproval, currentProject, setPendingPlanApproval, updateFeature, loadFeatures, hookFeatures] + [ + pendingPlanApproval, + currentProject, + setPendingPlanApproval, + updateFeature, + loadFeatures, + hookFeatures, + ] ); // Handle opening approval dialog from feature card button @@ -747,7 +791,7 @@ export function BoardView() { // Determine the planning mode for approval (skip should never have a plan requiring approval) const mode = feature.planningMode; 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 setPendingPlanApproval({ @@ -832,6 +876,7 @@ export function BoardView() { }} onRemovedWorktrees={handleRemovedWorktrees} runningFeatureIds={runningAutoTasks} + branchCardCounts={branchCardCounts} features={hookFeatures.map((f) => ({ id: f.id, branchName: f.branchName, @@ -928,6 +973,7 @@ export function BoardView() { onAdd={handleAddFeature} categorySuggestions={categorySuggestions} branchSuggestions={branchSuggestions} + branchCardCounts={branchCardCounts} defaultSkipTests={defaultSkipTests} defaultBranch={selectedWorktreeBranch} currentBranch={currentWorktreeBranch || undefined} @@ -943,6 +989,7 @@ export function BoardView() { onUpdate={handleUpdateFeature} categorySuggestions={categorySuggestions} branchSuggestions={branchSuggestions} + branchCardCounts={branchCardCounts} currentBranch={currentWorktreeBranch || undefined} isMaximized={isMaximized} showProfilesOnly={showProfilesOnly} @@ -1064,15 +1111,24 @@ export function BoardView() { onOpenChange={setShowDeleteWorktreeDialog} projectPath={currentProject.path} worktree={selectedWorktreeForAction} + affectedFeatureCount={ + selectedWorktreeForAction + ? hookFeatures.filter( + (f) => f.branchName === selectedWorktreeForAction.branch + ).length + : 0 + } onDeleted={(deletedWorktree, _deletedBranch) => { // Reset features that were assigned to the deleted worktree (by branch) hookFeatures.forEach((feature) => { // Match by branch name since worktreePath is no longer stored if (feature.branchName === deletedWorktree.branch) { - // Reset the feature's branch assignment - persistFeatureUpdate(feature.id, { + // Reset the feature's branch assignment - update both local state and persist + const updates = { branchName: null as unknown as string | undefined, - }); + }; + updateFeature(feature.id, updates); + persistFeatureUpdate(feature.id, updates); } }); diff --git a/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx index f84eaded..4bd0b632 100644 --- a/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx +++ b/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx @@ -73,6 +73,7 @@ interface AddFeatureDialogProps { }) => void; categorySuggestions: string[]; branchSuggestions: string[]; + branchCardCounts?: Record; // Map of branch name to unarchived card count defaultSkipTests: boolean; defaultBranch?: string; currentBranch?: string; @@ -87,6 +88,7 @@ export function AddFeatureDialog({ onAdd, categorySuggestions, branchSuggestions, + branchCardCounts, defaultSkipTests, defaultBranch = "main", currentBranch, @@ -116,11 +118,16 @@ export function AddFeatureDialog({ const [enhancementMode, setEnhancementMode] = useState< "improve" | "technical" | "simplify" | "acceptance" >("improve"); - const [planningMode, setPlanningMode] = useState('skip'); + const [planningMode, setPlanningMode] = useState("skip"); const [requirePlanApproval, setRequirePlanApproval] = useState(false); // 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 useEffect(() => { @@ -134,7 +141,13 @@ export function AddFeatureDialog({ setPlanningMode(defaultPlanningMode); setRequirePlanApproval(defaultRequirePlanApproval); } - }, [open, defaultSkipTests, defaultBranch, defaultPlanningMode, defaultRequirePlanApproval]); + }, [ + open, + defaultSkipTests, + defaultBranch, + defaultPlanningMode, + defaultRequirePlanApproval, + ]); const handleAdd = () => { if (!newFeature.description.trim()) { @@ -158,7 +171,7 @@ export function AddFeatureDialog({ // If currentBranch is provided (non-primary worktree), use it // Otherwise (primary worktree), use empty string which means "unassigned" (show only on primary) const finalBranchName = useCurrentBranch - ? (currentBranch || "") + ? currentBranch || "" : newFeature.branchName || ""; onAdd({ @@ -399,6 +412,7 @@ export function AddFeatureDialog({ setNewFeature({ ...newFeature, branchName: value }) } branchSuggestions={branchSuggestions} + branchCardCounts={branchCardCounts} currentBranch={currentBranch} testIdPrefix="feature" /> @@ -481,7 +495,10 @@ export function AddFeatureDialog({ {/* Options Tab */} - + {/* Planning Mode Section */} Add Feature diff --git a/apps/ui/src/components/views/board-view/dialogs/delete-worktree-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/delete-worktree-dialog.tsx index 60bf9178..6dee9277 100644 --- a/apps/ui/src/components/views/board-view/dialogs/delete-worktree-dialog.tsx +++ b/apps/ui/src/components/views/board-view/dialogs/delete-worktree-dialog.tsx @@ -11,7 +11,7 @@ import { import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; 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 { toast } from "sonner"; @@ -29,6 +29,8 @@ interface DeleteWorktreeDialogProps { projectPath: string; worktree: WorktreeInfo | null; onDeleted: (deletedWorktree: WorktreeInfo, deletedBranch: boolean) => void; + /** Number of features assigned to this worktree's branch */ + affectedFeatureCount?: number; } export function DeleteWorktreeDialog({ @@ -37,6 +39,7 @@ export function DeleteWorktreeDialog({ projectPath, worktree, onDeleted, + affectedFeatureCount = 0, }: DeleteWorktreeDialogProps) { const [deleteBranch, setDeleteBranch] = useState(false); const [isLoading, setIsLoading] = useState(false); @@ -99,6 +102,18 @@ export function DeleteWorktreeDialog({ ? + {affectedFeatureCount > 0 && ( +
+ + + {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. + +
+ )} + {worktree.hasChanges && (
diff --git a/apps/ui/src/components/views/board-view/dialogs/edit-feature-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/edit-feature-dialog.tsx index 5afe6b71..981a212f 100644 --- a/apps/ui/src/components/views/board-view/dialogs/edit-feature-dialog.tsx +++ b/apps/ui/src/components/views/board-view/dialogs/edit-feature-dialog.tsx @@ -76,6 +76,7 @@ interface EditFeatureDialogProps { ) => void; categorySuggestions: string[]; branchSuggestions: string[]; + branchCardCounts?: Record; // Map of branch name to unarchived card count currentBranch?: string; isMaximized: boolean; showProfilesOnly: boolean; @@ -89,6 +90,7 @@ export function EditFeatureDialog({ onUpdate, categorySuggestions, branchSuggestions, + branchCardCounts, currentBranch, isMaximized, showProfilesOnly, @@ -388,6 +390,7 @@ export function EditFeatureDialog({ }) } branchSuggestions={branchSuggestions} + branchCardCounts={branchCardCounts} currentBranch={currentBranch} disabled={editingFeature.status !== "backlog"} testIdPrefix="edit-feature" diff --git a/apps/ui/src/components/views/board-view/hooks/use-board-actions.ts b/apps/ui/src/components/views/board-view/hooks/use-board-actions.ts index 9deb8a40..8370d96f 100644 --- a/apps/ui/src/components/views/board-view/hooks/use-board-actions.ts +++ b/apps/ui/src/components/views/board-view/hooks/use-board-actions.ts @@ -82,8 +82,8 @@ export function useBoardActions({ } = useAppStore(); const autoMode = useAutoMode(); - // Note: getOrCreateWorktreeForFeature removed - worktrees are now created server-side - // at execution time based on feature.branchName + // Worktrees are created when adding/editing features with a branch name + // This ensures the worktree exists before the feature starts execution const handleAddFeature = useCallback( async (featureData: { @@ -100,24 +100,58 @@ export function useBoardActions({ planningMode: PlanningMode; 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 // Non-empty string is the actual branch name (for non-primary worktrees) 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 = { ...featureData, status: "backlog" as const, branchName: finalBranchName, - // No worktreePath - derived at runtime from branchName }; const createdFeature = addFeature(newFeatureData); // 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, currentProject, onWorktreeCreated] ); const handleUpdateFeature = useCallback( @@ -139,6 +173,43 @@ export function useBoardActions({ ) => { 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 = { ...updates, branchName: finalBranchName, @@ -151,7 +222,7 @@ export function useBoardActions({ } setEditingFeature(null); }, - [updateFeature, persistFeatureUpdate, saveCategory, setEditingFeature] + [updateFeature, persistFeatureUpdate, saveCategory, setEditingFeature, useWorktrees, currentProject, onWorktreeCreated] ); const handleDeleteFeature = useCallback( diff --git a/apps/ui/src/components/views/board-view/shared/branch-selector.tsx b/apps/ui/src/components/views/board-view/shared/branch-selector.tsx index a395edf5..0ba0848b 100644 --- a/apps/ui/src/components/views/board-view/shared/branch-selector.tsx +++ b/apps/ui/src/components/views/board-view/shared/branch-selector.tsx @@ -10,6 +10,7 @@ interface BranchSelectorProps { branchName: string; onBranchNameChange: (branchName: string) => void; branchSuggestions: string[]; + branchCardCounts?: Record; // Map of branch name to unarchived card count currentBranch?: string; disabled?: boolean; testIdPrefix?: string; @@ -21,6 +22,7 @@ export function BranchSelector({ branchName, onBranchNameChange, branchSuggestions, + branchCardCounts, currentBranch, disabled = false, testIdPrefix = "branch", @@ -69,6 +71,7 @@ export function BranchSelector({ value={branchName} onChange={onBranchNameChange} branches={branchSuggestions} + branchCardCounts={branchCardCounts} placeholder="Select or create branch..." data-testid={`${testIdPrefix}-input`} disabled={disabled} diff --git a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-tab.tsx b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-tab.tsx index 7776f983..fb11afcd 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-tab.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-tab.tsx @@ -8,6 +8,7 @@ import { WorktreeActionsDropdown } from "./worktree-actions-dropdown"; interface WorktreeTabProps { worktree: WorktreeInfo; + cardCount?: number; // Number of unarchived cards for this branch isSelected: boolean; isRunning: boolean; isActivating: boolean; @@ -43,6 +44,7 @@ interface WorktreeTabProps { export function WorktreeTab({ worktree, + cardCount, isSelected, isRunning, isActivating, @@ -96,9 +98,9 @@ export function WorktreeTab({ )} {worktree.branch} - {worktree.hasChanges && ( + {cardCount !== undefined && cardCount > 0 && ( - {worktree.changedFilesCount} + {cardCount} )} @@ -139,9 +141,9 @@ export function WorktreeTab({ )} {worktree.branch} - {worktree.hasChanges && ( + {cardCount !== undefined && cardCount > 0 && ( - {worktree.changedFilesCount} + {cardCount} )} diff --git a/apps/ui/src/components/views/board-view/worktree-panel/types.ts b/apps/ui/src/components/views/board-view/worktree-panel/types.ts index e143ae73..c1beaf5f 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/types.ts +++ b/apps/ui/src/components/views/board-view/worktree-panel/types.ts @@ -35,5 +35,6 @@ export interface WorktreePanelProps { onRemovedWorktrees?: (removedWorktrees: Array<{ path: string; branch: string }>) => void; runningFeatureIds?: string[]; features?: FeatureInfo[]; + branchCardCounts?: Record; // Map of branch name to unarchived card count refreshTrigger?: number; } diff --git a/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx b/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx index 5bf77958..b3d90593 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx @@ -23,6 +23,7 @@ export function WorktreePanel({ onRemovedWorktrees, runningFeatureIds = [], features = [], + branchCardCounts, refreshTrigger = 0, }: WorktreePanelProps) { const { @@ -109,43 +110,47 @@ export function WorktreePanel({ Branch:
- {worktrees.map((worktree) => ( - - ))} + {worktrees.map((worktree) => { + const cardCount = branchCardCounts?.[worktree.branch]; + return ( + + ); + })}