diff --git a/apps/server/src/routes/app-spec/generate-features-from-spec.ts b/apps/server/src/routes/app-spec/generate-features-from-spec.ts index a621c9082..49ce3a595 100644 --- a/apps/server/src/routes/app-spec/generate-features-from-spec.ts +++ b/apps/server/src/routes/app-spec/generate-features-from-spec.ts @@ -28,12 +28,14 @@ export async function generateFeaturesFromSpec( events: EventEmitter, abortController: AbortController, maxFeatures?: number, - settingsService?: SettingsService + settingsService?: SettingsService, + targetBranch?: string ): Promise { const featureCount = maxFeatures ?? DEFAULT_MAX_FEATURES; logger.debug('========== generateFeaturesFromSpec() started =========='); logger.debug('projectPath:', projectPath); logger.debug('maxFeatures:', featureCount); + logger.debug('targetBranch:', targetBranch); // Read existing spec from .automaker directory const specPath = getAppSpecPath(projectPath); @@ -233,7 +235,7 @@ CRITICAL INSTRUCTIONS: logger.info(responseText); logger.info('========== END RESPONSE TEXT =========='); - await parseAndCreateFeatures(projectPath, responseText, events); + await parseAndCreateFeatures(projectPath, responseText, events, targetBranch); logger.debug('========== generateFeaturesFromSpec() completed =========='); } diff --git a/apps/server/src/routes/app-spec/generate-spec.ts b/apps/server/src/routes/app-spec/generate-spec.ts index a0a115142..268d17ef2 100644 --- a/apps/server/src/routes/app-spec/generate-spec.ts +++ b/apps/server/src/routes/app-spec/generate-spec.ts @@ -26,6 +26,7 @@ import { generateFeaturesFromSpec } from './generate-features-from-spec.js'; import { ensureAutomakerDir, getAppSpecPath } from '@automaker/platform'; import type { SettingsService } from '../../services/settings-service.js'; import { getAutoLoadClaudeMdSetting } from '../../lib/settings-helpers.js'; +import { validateBranchName } from '@automaker/git-utils'; const logger = createLogger('SpecRegeneration'); @@ -37,7 +38,8 @@ export async function generateSpec( generateFeatures?: boolean, analyzeProject?: boolean, maxFeatures?: number, - settingsService?: SettingsService + settingsService?: SettingsService, + targetBranch?: string ): Promise { logger.info('========== generateSpec() started =========='); logger.info('projectPath:', projectPath); @@ -46,6 +48,7 @@ export async function generateSpec( logger.info('generateFeatures:', generateFeatures); logger.info('analyzeProject:', analyzeProject); logger.info('maxFeatures:', maxFeatures); + logger.info('targetBranch:', targetBranch); // Build the prompt based on whether we should analyze the project let analysisInstructions = ''; @@ -360,6 +363,81 @@ Your entire response should be valid JSON starting with { and ending with }. No }); } + // Create worktree for target branch if specified and doesn't exist + if (targetBranch && targetBranch !== 'main' && targetBranch !== 'master') { + try { + // Validate branch name to prevent command injection + validateBranchName(targetBranch); + + logger.info(`Checking if worktree for branch '${targetBranch}' exists...`); + const { execFile } = await import('child_process'); + const { promisify } = await import('util'); + const fs = await import('fs'); + const execFileAsync = promisify(execFile); + + // Check if worktree already exists + const worktreePath = path.join(projectPath, '.worktrees', targetBranch); + const worktreeExists = fs.existsSync(worktreePath); + + if (worktreeExists) { + logger.info(`Worktree for branch '${targetBranch}' already exists at ${worktreePath}`); + } else { + // Check if branch exists + let branchExists = false; + try { + await execFileAsync('git', ['rev-parse', '--verify', targetBranch], { cwd: projectPath }); + branchExists = true; + logger.info(`Branch '${targetBranch}' already exists`); + } catch { + logger.info(`Branch '${targetBranch}' does not exist, will create with worktree`); + } + + // Create .worktrees directory if it doesn't exist + const worktreesDir = path.join(projectPath, '.worktrees'); + if (!fs.existsSync(worktreesDir)) { + fs.mkdirSync(worktreesDir, { recursive: true }); + } + + // Create worktree + logger.info(`Creating worktree for branch '${targetBranch}' at ${worktreePath}`); + if (branchExists) { + // Use existing branch + await execFileAsync('git', ['worktree', 'add', worktreePath, targetBranch], { + cwd: projectPath, + }); + } else { + // Create new branch from HEAD + await execFileAsync( + 'git', + ['worktree', 'add', '-b', targetBranch, worktreePath, 'HEAD'], + { + cwd: projectPath, + } + ); + } + logger.info(`✓ Created worktree for branch '${targetBranch}'`); + + // Track the branch so it persists in the UI + try { + await execFileAsync('git', ['config', `branch.${targetBranch}.remote`, 'origin'], { + cwd: projectPath, + }); + await execFileAsync( + 'git', + ['config', `branch.${targetBranch}.merge`, `refs/heads/${targetBranch}`], + { cwd: projectPath } + ); + logger.info(`✓ Configured tracking for branch '${targetBranch}'`); + } catch (trackError) { + logger.warn(`Failed to configure tracking for branch '${targetBranch}':`, trackError); + } + } + } catch (worktreeError) { + logger.warn(`Failed to create worktree for branch '${targetBranch}':`, worktreeError); + // Don't throw - this is not critical, continue with feature generation + } + } + // If generate features was requested, generate them from the spec if (generateFeatures) { logger.info('Starting feature generation from spec...'); @@ -371,7 +449,8 @@ Your entire response should be valid JSON starting with { and ending with }. No events, featureAbortController, maxFeatures, - settingsService + settingsService, + targetBranch ); // Final completion will be emitted by generateFeaturesFromSpec -> parseAndCreateFeatures } catch (featureError) { diff --git a/apps/server/src/routes/app-spec/parse-and-create-features.ts b/apps/server/src/routes/app-spec/parse-and-create-features.ts index 78137a731..bae9bafa5 100644 --- a/apps/server/src/routes/app-spec/parse-and-create-features.ts +++ b/apps/server/src/routes/app-spec/parse-and-create-features.ts @@ -14,10 +14,12 @@ const logger = createLogger('SpecRegeneration'); export async function parseAndCreateFeatures( projectPath: string, content: string, - events: EventEmitter + events: EventEmitter, + targetBranch?: string ): Promise { logger.info('========== parseAndCreateFeatures() started =========='); logger.info(`Content length: ${content.length} chars`); + logger.info('targetBranch:', targetBranch); logger.info('========== CONTENT RECEIVED FOR PARSING =========='); logger.info(content); logger.info('========== END CONTENT =========='); @@ -53,6 +55,38 @@ export async function parseAndCreateFeatures( const featuresDir = getFeaturesDir(projectPath); await secureFs.mkdir(featuresDir, { recursive: true }); + // Delete existing features for this branch before creating new ones + if (targetBranch) { + logger.info(`Deleting existing features for branch '${targetBranch}'...`); + try { + const existingFeatures = await secureFs.readdir(featuresDir); + let deletedCount = 0; + + for (const featureId of existingFeatures) { + const featureJsonPath = path.join(featuresDir, featureId, 'feature.json'); + try { + const featureContent = (await secureFs.readFile(featureJsonPath, 'utf-8')) as string; + const featureData = JSON.parse(featureContent); + + // Delete if it matches the target branch + if (featureData.branchName === targetBranch) { + logger.info(`Deleting feature ${featureId} from branch ${targetBranch}`); + await secureFs.rm(path.join(featuresDir, featureId), { recursive: true }); + deletedCount++; + } + } catch (err) { + // Skip if feature.json doesn't exist or can't be read + logger.debug(`Skipping ${featureId}: ${err}`); + } + } + + logger.info(`✓ Deleted ${deletedCount} existing features for branch '${targetBranch}'`); + } catch (err) { + logger.warn('Failed to delete existing features:', err); + // Continue anyway - not critical + } + } + const createdFeatures: Array<{ id: string; title: string }> = []; for (const feature of parsed.features) { @@ -69,6 +103,7 @@ export async function parseAndCreateFeatures( priority: feature.priority || 2, complexity: feature.complexity || 'moderate', dependencies: feature.dependencies || [], + branchName: targetBranch || 'main', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; @@ -83,10 +118,18 @@ export async function parseAndCreateFeatures( logger.info(`✓ Created ${createdFeatures.length} features successfully`); + // Calculate worktree path for the target branch + let worktreePath: string | undefined; + if (targetBranch && targetBranch !== 'main' && targetBranch !== 'master') { + worktreePath = path.join(projectPath, '.worktrees', targetBranch); + } + events.emit('spec-regeneration:event', { type: 'spec_regeneration_complete', message: `Spec regeneration complete! Created ${createdFeatures.length} features.`, projectPath: projectPath, + targetBranch: targetBranch, + worktreePath: worktreePath, }); } catch (error) { logger.error('❌ parseAndCreateFeatures() failed:'); diff --git a/apps/server/src/routes/app-spec/routes/create.ts b/apps/server/src/routes/app-spec/routes/create.ts index ed6f68f11..95a7dae61 100644 --- a/apps/server/src/routes/app-spec/routes/create.ts +++ b/apps/server/src/routes/app-spec/routes/create.ts @@ -22,14 +22,21 @@ export function createCreateHandler(events: EventEmitter) { logger.debug('Request body:', JSON.stringify(req.body, null, 2)); try { - const { projectPath, projectOverview, generateFeatures, analyzeProject, maxFeatures } = - req.body as { - projectPath: string; - projectOverview: string; - generateFeatures?: boolean; - analyzeProject?: boolean; - maxFeatures?: number; - }; + const { + projectPath, + projectOverview, + generateFeatures, + analyzeProject, + maxFeatures, + targetBranch, + } = req.body as { + projectPath: string; + projectOverview: string; + generateFeatures?: boolean; + analyzeProject?: boolean; + maxFeatures?: number; + targetBranch?: string; + }; logger.debug('Parsed params:'); logger.debug(' projectPath:', projectPath); @@ -37,6 +44,7 @@ export function createCreateHandler(events: EventEmitter) { logger.debug(' generateFeatures:', generateFeatures); logger.debug(' analyzeProject:', analyzeProject); logger.debug(' maxFeatures:', maxFeatures); + logger.debug(' targetBranch:', targetBranch); if (!projectPath || !projectOverview) { logger.error('Missing required parameters'); @@ -68,7 +76,9 @@ export function createCreateHandler(events: EventEmitter) { abortController, generateFeatures, analyzeProject, - maxFeatures + maxFeatures, + undefined, + targetBranch ) .catch((error) => { logError(error, 'Generation failed with error'); diff --git a/apps/server/src/routes/app-spec/routes/generate.ts b/apps/server/src/routes/app-spec/routes/generate.ts index a03dacb7e..c618f47c5 100644 --- a/apps/server/src/routes/app-spec/routes/generate.ts +++ b/apps/server/src/routes/app-spec/routes/generate.ts @@ -23,14 +23,21 @@ export function createGenerateHandler(events: EventEmitter, settingsService?: Se logger.debug('Request body:', JSON.stringify(req.body, null, 2)); try { - const { projectPath, projectDefinition, generateFeatures, analyzeProject, maxFeatures } = - req.body as { - projectPath: string; - projectDefinition: string; - generateFeatures?: boolean; - analyzeProject?: boolean; - maxFeatures?: number; - }; + const { + projectPath, + projectDefinition, + generateFeatures, + analyzeProject, + maxFeatures, + targetBranch, + } = req.body as { + projectPath: string; + projectDefinition: string; + generateFeatures?: boolean; + analyzeProject?: boolean; + maxFeatures?: number; + targetBranch?: string; + }; logger.debug('Parsed params:'); logger.debug(' projectPath:', projectPath); @@ -38,6 +45,7 @@ export function createGenerateHandler(events: EventEmitter, settingsService?: Se logger.debug(' generateFeatures:', generateFeatures); logger.debug(' analyzeProject:', analyzeProject); logger.debug(' maxFeatures:', maxFeatures); + logger.debug(' targetBranch:', targetBranch); if (!projectPath || !projectDefinition) { logger.error('Missing required parameters'); @@ -69,7 +77,8 @@ export function createGenerateHandler(events: EventEmitter, settingsService?: Se generateFeatures, analyzeProject, maxFeatures, - settingsService + settingsService, + targetBranch ) .catch((error) => { logError(error, 'Generation failed with error'); diff --git a/apps/server/src/routes/worktree/index.ts b/apps/server/src/routes/worktree/index.ts index 7fef5c6ed..7d1f8b4fa 100644 --- a/apps/server/src/routes/worktree/index.ts +++ b/apps/server/src/routes/worktree/index.ts @@ -30,6 +30,7 @@ import { createMigrateHandler } from './routes/migrate.js'; import { createStartDevHandler } from './routes/start-dev.js'; import { createStopDevHandler } from './routes/stop-dev.js'; import { createListDevServersHandler } from './routes/list-dev-servers.js'; +import { createGetCurrentBranchHandler } from './routes/get-current-branch.js'; export function createWorktreeRoutes(): Router { const router = Router(); @@ -37,6 +38,11 @@ export function createWorktreeRoutes(): Router { router.post('/info', validatePathParams('projectPath'), createInfoHandler()); router.post('/status', validatePathParams('projectPath'), createStatusHandler()); router.post('/list', createListHandler()); + router.post( + '/current-branch', + validatePathParams('projectPath'), + createGetCurrentBranchHandler() + ); router.post('/diffs', validatePathParams('projectPath'), createDiffsHandler()); router.post('/file-diff', validatePathParams('projectPath', 'filePath'), createFileDiffHandler()); router.post( diff --git a/apps/server/src/routes/worktree/routes/get-current-branch.ts b/apps/server/src/routes/worktree/routes/get-current-branch.ts new file mode 100644 index 000000000..2d0328442 --- /dev/null +++ b/apps/server/src/routes/worktree/routes/get-current-branch.ts @@ -0,0 +1,64 @@ +/** + * GET /current-branch endpoint - Get the current branch of a project + */ + +import type { Request, Response } from 'express'; +import { promisify } from 'util'; +import { exec } from 'child_process'; +import { createLogger } from '@automaker/utils'; + +const execAsync = promisify(exec); +const logger = createLogger('GetCurrentBranch'); + +export function createGetCurrentBranchHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath } = req.body as { + projectPath: string; + }; + + if (!projectPath) { + res.status(400).json({ + success: false, + error: 'projectPath is required', + }); + return; + } + + try { + const { stdout } = await execAsync('git branch --show-current', { cwd: projectPath }); + const branch = stdout.toString().trim(); + + if (!branch) { + // Might be in detached HEAD state, try to get branch from symbolic-ref + try { + const { stdout: refStdout } = await execAsync('git symbolic-ref --short HEAD', { + cwd: projectPath, + }); + const refBranch = refStdout.toString().trim(); + if (refBranch) { + res.json({ success: true, branch: refBranch }); + return; + } + } catch { + // Fall through to default + } + + // Default to main if we can't determine the branch + res.json({ success: true, branch: 'main' }); + return; + } + + res.json({ success: true, branch }); + } catch (error) { + logger.error('Failed to get current branch:', error); + // Return main as fallback + res.json({ success: true, branch: 'main' }); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.error('Get current branch failed:', message); + res.status(500).json({ success: false, error: message }); + } + }; +} diff --git a/apps/ui/src/components/views/board-view.tsx b/apps/ui/src/components/views/board-view.tsx index 2c82261b0..27b485f94 100644 --- a/apps/ui/src/components/views/board-view.tsx +++ b/apps/ui/src/components/views/board-view.tsx @@ -83,6 +83,7 @@ export function BoardView() { pendingPlanApproval, setPendingPlanApproval, updateFeature, + removeFeature, getCurrentWorktree, setCurrentWorktree, getWorktrees, @@ -1433,20 +1434,33 @@ export function BoardView() { ? 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 - update both local state and persist - const updates = { - branchName: null as unknown as string | undefined, - }; - updateFeature(feature.id, updates); - persistFeatureUpdate(feature.id, updates); - } - }); + onDeleted={async (deletedWorktree, _deletedBranch, deleteFeatures) => { + // Handle features assigned to the deleted worktree (by branch) + const featuresToHandle = hookFeatures.filter( + (f) => f.branchName === deletedWorktree.branch + ); + + // Compute mainBranch once before processing + const mainBranch = worktrees.find((w) => w.isMain)?.branch || 'main'; + + if (deleteFeatures) { + // Delete all features from disk in parallel + // handleDeleteFeature already calls removeFeature, so no need to call it separately + await Promise.all(featuresToHandle.map((feature) => handleDeleteFeature(feature.id))); + } else { + // Reassign all features to main branch + const updates = { branchName: mainBranch }; + + // Use Promise.all to await all updates in parallel + await Promise.all( + featuresToHandle.map((feature) => { + updateFeature(feature.id, updates); + return persistFeatureUpdate(feature.id, updates); + }) + ); + } + // Refresh worktree list after all operations complete setWorktreeRefreshKey((k) => k + 1); setSelectedWorktreeForAction(null); }} 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 3c45c0144..a93d274af 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 @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { Dialog, DialogContent, @@ -27,7 +27,11 @@ interface DeleteWorktreeDialogProps { onOpenChange: (open: boolean) => void; projectPath: string; worktree: WorktreeInfo | null; - onDeleted: (deletedWorktree: WorktreeInfo, deletedBranch: boolean) => void; + onDeleted: ( + deletedWorktree: WorktreeInfo, + deletedBranch: boolean, + deleteFeatures: boolean + ) => void; /** Number of features assigned to this worktree's branch */ affectedFeatureCount?: number; } @@ -41,8 +45,17 @@ export function DeleteWorktreeDialog({ affectedFeatureCount = 0, }: DeleteWorktreeDialogProps) { const [deleteBranch, setDeleteBranch] = useState(false); + const [deleteFeatures, setDeleteFeatures] = useState(false); const [isLoading, setIsLoading] = useState(false); + // Reset checkbox state when dialog closes to prevent accidental data loss + useEffect(() => { + if (!open) { + setDeleteBranch(false); + setDeleteFeatures(false); + } + }, [open]); + const handleDelete = async () => { if (!worktree) return; @@ -61,9 +74,8 @@ export function DeleteWorktreeDialog({ ? `Branch "${worktree.branch}" was also deleted` : `Branch "${worktree.branch}" was kept`, }); - onDeleted(worktree, deleteBranch); - onOpenChange(false); - setDeleteBranch(false); + onDeleted(worktree, deleteBranch, deleteFeatures); + onOpenChange(false); // useEffect will reset checkbox state } else { toast.error('Failed to delete worktree', { description: result.error, @@ -94,7 +106,7 @@ export function DeleteWorktreeDialog({ {worktree.branch}? - {affectedFeatureCount > 0 && ( + {affectedFeatureCount > 0 && !deleteFeatures && (
@@ -118,16 +130,34 @@ export function DeleteWorktreeDialog({ -
- setDeleteBranch(checked === true)} - /> - +
+
+ setDeleteBranch(checked === true)} + disabled={isLoading} + /> + +
+ + {affectedFeatureCount > 0 && ( +
+ setDeleteFeatures(checked === true)} + disabled={isLoading} + /> + +
+ )}
diff --git a/apps/ui/src/components/views/board-view/hooks/use-board-features.ts b/apps/ui/src/components/views/board-view/hooks/use-board-features.ts index 3122a5ee4..90959ec14 100644 --- a/apps/ui/src/components/views/board-view/hooks/use-board-features.ts +++ b/apps/ui/src/components/views/board-view/hooks/use-board-features.ts @@ -222,6 +222,19 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) { loadFeatures(); + // Check if this is a user cancellation (not a real error) + const isCancellation = + event.error && + (event.error.includes('cancelled') || + event.error.includes('canceled') || + event.error.includes('aborted')); + + // Don't show error toast for user-initiated cancellations + if (isCancellation) { + logger.info('Operation was cancelled by user'); + return; + } + // Check for authentication errors and show a more helpful message const isAuthError = event.errorType === 'authentication' || diff --git a/apps/ui/src/components/views/board-view/worktree-panel/components/branch-switch-dropdown.tsx b/apps/ui/src/components/views/board-view/worktree-panel/components/branch-switch-dropdown.tsx index f83959769..76467d94c 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/components/branch-switch-dropdown.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/components/branch-switch-dropdown.tsx @@ -50,6 +50,7 @@ export function BranchSwitchDropdown({ !isSelected && 'bg-secondary/50 hover:bg-secondary' )} title="Switch branch" + data-testid={`worktree-dropdown-trigger-${worktree.branch}`} > diff --git a/apps/ui/src/components/views/spec-view.tsx b/apps/ui/src/components/views/spec-view.tsx index 189e0f9ab..511c59e43 100644 --- a/apps/ui/src/components/views/spec-view.tsx +++ b/apps/ui/src/components/views/spec-view.tsx @@ -37,6 +37,10 @@ export function SpecView() { setAnalyzeProjectOnCreate, featureCountOnCreate, setFeatureCountOnCreate, + useWorktreeBranchOnCreate, + setUseWorktreeBranchOnCreate, + worktreeBranchOnCreate, + setWorktreeBranchOnCreate, // Regenerate state projectDefinition, @@ -48,6 +52,10 @@ export function SpecView() { setAnalyzeProjectOnRegenerate, featureCountOnRegenerate, setFeatureCountOnRegenerate, + useWorktreeBranchOnRegenerate, + setUseWorktreeBranchOnRegenerate, + worktreeBranchOnRegenerate, + setWorktreeBranchOnRegenerate, // Feature generation isGeneratingFeatures, @@ -111,6 +119,10 @@ export function SpecView() { onAnalyzeProjectChange={setAnalyzeProjectOnCreate} featureCount={featureCountOnCreate} onFeatureCountChange={setFeatureCountOnCreate} + useWorktreeBranch={useWorktreeBranchOnCreate} + onUseWorktreeBranchChange={setUseWorktreeBranchOnCreate} + worktreeBranch={worktreeBranchOnCreate} + onWorktreeBranchChange={setWorktreeBranchOnCreate} onCreateSpec={handleCreateSpec} isCreatingSpec={isCreating} /> @@ -147,6 +159,10 @@ export function SpecView() { onAnalyzeProjectChange={setAnalyzeProjectOnRegenerate} featureCount={featureCountOnRegenerate} onFeatureCountChange={setFeatureCountOnRegenerate} + useWorktreeBranch={useWorktreeBranchOnRegenerate} + onUseWorktreeBranchChange={setUseWorktreeBranchOnRegenerate} + worktreeBranch={worktreeBranchOnRegenerate} + onWorktreeBranchChange={setWorktreeBranchOnRegenerate} onRegenerate={handleRegenerate} isRegenerating={isRegenerating} isGeneratingFeatures={isGeneratingFeatures} diff --git a/apps/ui/src/components/views/spec-view/constants.ts b/apps/ui/src/components/views/spec-view/constants.ts index 5b4a5a4a1..940d2cf5c 100644 --- a/apps/ui/src/components/views/spec-view/constants.ts +++ b/apps/ui/src/components/views/spec-view/constants.ts @@ -8,13 +8,14 @@ export const STATUS_CHECK_INTERVAL_MS = 2000; // Feature count options with labels and warnings export const FEATURE_COUNT_OPTIONS: { - value: FeatureCount; + value: number | 'custom'; label: string; warning?: string; }[] = [ { value: 20, label: '20' }, { value: 50, label: '50', warning: 'May take up to 5 minutes' }, { value: 100, label: '100', warning: 'May take up to 5 minutes' }, + { value: 'custom', label: 'Custom' }, ]; // Phase display labels for UI diff --git a/apps/ui/src/components/views/spec-view/dialogs/create-spec-dialog.tsx b/apps/ui/src/components/views/spec-view/dialogs/create-spec-dialog.tsx index d25e7f62f..7585d9a09 100644 --- a/apps/ui/src/components/views/spec-view/dialogs/create-spec-dialog.tsx +++ b/apps/ui/src/components/views/spec-view/dialogs/create-spec-dialog.tsx @@ -1,4 +1,5 @@ import { Sparkles, Clock, Loader2 } from 'lucide-react'; +import { useState, useEffect } from 'react'; import { Dialog, DialogContent, @@ -10,6 +11,7 @@ import { import { Button } from '@/components/ui/button'; import { HotkeyButton } from '@/components/ui/hotkey-button'; import { Checkbox } from '@/components/ui/checkbox'; +import { Input } from '@/components/ui/input'; import { cn } from '@/lib/utils'; import { FEATURE_COUNT_OPTIONS } from '../constants'; import type { CreateSpecDialogProps, FeatureCount } from '../types'; @@ -25,6 +27,10 @@ export function CreateSpecDialog({ onAnalyzeProjectChange, featureCount, onFeatureCountChange, + useWorktreeBranch, + onUseWorktreeBranchChange, + worktreeBranch, + onWorktreeBranchChange, onCreateSpec, onSkip, isCreatingSpec, @@ -32,7 +38,34 @@ export function CreateSpecDialog({ title = 'Create App Specification', description = "We didn't find an app_spec.txt file. Let us help you generate your app_spec.txt to help describe your project for our system. We'll analyze your project's tech stack and create a comprehensive specification.", }: CreateSpecDialogProps) { - const selectedOption = FEATURE_COUNT_OPTIONS.find((o) => o.value === featureCount); + const [customFeatureCount, setCustomFeatureCount] = useState(''); + const [isCustom, setIsCustom] = useState(false); + + // Sync local state with prop when dialog opens or featureCount changes + useEffect(() => { + if (!open) return; + + const numericOptions = FEATURE_COUNT_OPTIONS.map((o) => o.value).filter( + (v): v is number => typeof v === 'number' + ); + + if (typeof featureCount === 'number') { + const isPreset = numericOptions.includes(featureCount); + setIsCustom(!isPreset); + setCustomFeatureCount(isPreset ? '' : String(featureCount)); + } + }, [open, featureCount]); + + const selectedOption = FEATURE_COUNT_OPTIONS.find( + (o) => o.value === featureCount || (isCustom && o.value === 'custom') + ); + + // Validate custom feature count + const customCountNum = Number(customFeatureCount); + const isCustomCountValid = + !generateFeatures || + !isCustom || + (Number.isInteger(customCountNum) && customCountNum >= 1 && customCountNum <= 200); return (
+
+ onUseWorktreeBranchChange(checked === true)} + disabled={isCreatingSpec} + /> +
+ +

+ If checked, create features in a separate worktree branch. If unchecked, detects and + uses the current branch of the selected project. +

+ + {useWorktreeBranch && ( +
+ onWorktreeBranchChange(e.target.value)} + onBlur={(e) => onWorktreeBranchChange(e.target.value.trim())} + placeholder="e.g., test-worktree, feature-new" + disabled={isCreatingSpec} + className="font-mono text-sm" + data-testid="create-worktree-branch-input" + /> +
+ )} +
+
+
- {FEATURE_COUNT_OPTIONS.map((option) => ( - - ))} + {FEATURE_COUNT_OPTIONS.map((option) => { + const isSelected = + option.value === 'custom' ? isCustom : featureCount === option.value; + return ( + + ); + })}
- {selectedOption?.warning && ( + {isCustom && ( + <> + { + setCustomFeatureCount(e.target.value); + const num = e.currentTarget.valueAsNumber; + if (Number.isInteger(num) && num >= 1 && num <= 200) { + onFeatureCountChange(num); + } + }} + placeholder="Enter number of features (1-200)" + disabled={isCreatingSpec} + className="text-sm" + data-testid="feature-count-custom-input" + /> + {!isCustomCountValid && ( +

+ Enter a whole number from 1–200. +

+ )} + + )} + {selectedOption?.warning && !isCustom && (

{selectedOption.warning} @@ -157,7 +267,12 @@ export function CreateSpecDialog({ )} diff --git a/apps/ui/src/components/views/spec-view/dialogs/regenerate-spec-dialog.tsx b/apps/ui/src/components/views/spec-view/dialogs/regenerate-spec-dialog.tsx index 4887d496a..97a560a46 100644 --- a/apps/ui/src/components/views/spec-view/dialogs/regenerate-spec-dialog.tsx +++ b/apps/ui/src/components/views/spec-view/dialogs/regenerate-spec-dialog.tsx @@ -1,4 +1,5 @@ import { Sparkles, Clock, Loader2 } from 'lucide-react'; +import { useState, useEffect } from 'react'; import { Dialog, DialogContent, @@ -10,6 +11,7 @@ import { import { Button } from '@/components/ui/button'; import { HotkeyButton } from '@/components/ui/hotkey-button'; import { Checkbox } from '@/components/ui/checkbox'; +import { Input } from '@/components/ui/input'; import { cn } from '@/lib/utils'; import { FEATURE_COUNT_OPTIONS } from '../constants'; import type { RegenerateSpecDialogProps, FeatureCount } from '../types'; @@ -25,13 +27,44 @@ export function RegenerateSpecDialog({ onAnalyzeProjectChange, featureCount, onFeatureCountChange, + useWorktreeBranch, + onUseWorktreeBranchChange, + worktreeBranch, + onWorktreeBranchChange, onRegenerate, isRegenerating, isGeneratingFeatures = false, }: RegenerateSpecDialogProps) { - const selectedOption = FEATURE_COUNT_OPTIONS.find((o) => o.value === featureCount); + const [customFeatureCount, setCustomFeatureCount] = useState(''); + const [isCustom, setIsCustom] = useState(false); + + // Sync local state with prop when dialog opens or featureCount changes + useEffect(() => { + if (!open) return; + + const numericOptions = FEATURE_COUNT_OPTIONS.map((o) => o.value).filter( + (v): v is number => typeof v === 'number' + ); + + if (typeof featureCount === 'number') { + const isPreset = numericOptions.includes(featureCount); + setIsCustom(!isPreset); + setCustomFeatureCount(isPreset ? '' : String(featureCount)); + } + }, [open, featureCount]); + + const selectedOption = FEATURE_COUNT_OPTIONS.find( + (o) => o.value === featureCount || (isCustom && o.value === 'custom') + ); const isDisabled = isRegenerating || isGeneratingFeatures; + // Validate custom feature count + const customCountNum = Number(customFeatureCount); + const isCustomCountValid = + !generateFeatures || + !isCustom || + (Number.isInteger(customCountNum) && customCountNum >= 1 && customCountNum <= 200); + return (

+
+ onUseWorktreeBranchChange(checked === true)} + disabled={isDisabled} + /> +
+ +

+ If checked, create features in a separate worktree branch. If unchecked, detects and + uses the current branch of the selected project. +

+ + {useWorktreeBranch && ( +
+ onWorktreeBranchChange(e.target.value)} + onBlur={(e) => onWorktreeBranchChange(e.target.value.trim())} + placeholder="e.g., test-worktree, feature-new" + disabled={isDisabled} + className="font-mono text-sm" + data-testid="regenerate-worktree-branch-input" + /> +
+ )} +
+
+
- {FEATURE_COUNT_OPTIONS.map((option) => ( - - ))} + {FEATURE_COUNT_OPTIONS.map((option) => { + const isSelected = + option.value === 'custom' ? isCustom : featureCount === option.value; + return ( + + ); + })}
- {selectedOption?.warning && ( + {isCustom && ( + <> + { + setCustomFeatureCount(e.target.value); + const num = e.currentTarget.valueAsNumber; + if (Number.isInteger(num) && num >= 1 && num <= 200) { + onFeatureCountChange(num); + } + }} + placeholder="Enter number of features (1-200)" + disabled={isDisabled} + className="text-sm" + data-testid="regenerate-feature-count-custom-input" + /> + {!isCustomCountValid && ( +

+ Enter a whole number from 1–200. +

+ )} + + )} + {selectedOption?.warning && !isCustom && (

{selectedOption.warning} @@ -152,7 +262,12 @@ export function RegenerateSpecDialog({ diff --git a/apps/ui/src/components/views/spec-view/hooks/use-spec-generation.ts b/apps/ui/src/components/views/spec-view/hooks/use-spec-generation.ts index 7507ce2c5..e3b997084 100644 --- a/apps/ui/src/components/views/spec-view/hooks/use-spec-generation.ts +++ b/apps/ui/src/components/views/spec-view/hooks/use-spec-generation.ts @@ -28,6 +28,8 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) { const [generateFeatures, setGenerateFeatures] = useState(true); const [analyzeProjectOnCreate, setAnalyzeProjectOnCreate] = useState(true); const [featureCountOnCreate, setFeatureCountOnCreate] = useState(50); + const [useWorktreeBranchOnCreate, setUseWorktreeBranchOnCreate] = useState(false); + const [worktreeBranchOnCreate, setWorktreeBranchOnCreate] = useState(''); // Regenerate spec state const [projectDefinition, setProjectDefinition] = useState(''); @@ -35,6 +37,8 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) { const [generateFeaturesOnRegenerate, setGenerateFeaturesOnRegenerate] = useState(true); const [analyzeProjectOnRegenerate, setAnalyzeProjectOnRegenerate] = useState(true); const [featureCountOnRegenerate, setFeatureCountOnRegenerate] = useState(50); + const [useWorktreeBranchOnRegenerate, setUseWorktreeBranchOnRegenerate] = useState(false); + const [worktreeBranchOnRegenerate, setWorktreeBranchOnRegenerate] = useState(''); // Generate features only state const [isGeneratingFeatures, setIsGeneratingFeatures] = useState(false); @@ -347,6 +351,18 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) { setErrorMessage(''); stateRestoredRef.current = false; + // Switch to target worktree if specified + if (event.targetBranch && event.worktreePath && currentProject) { + logger.info( + 'Switching to target worktree:', + event.worktreePath, + 'branch:', + event.targetBranch + ); + const { setCurrentWorktree } = useAppStore.getState(); + setCurrentWorktree(currentProject.path, event.worktreePath, event.targetBranch); + } + setTimeout(() => { loadSpec(); }, SPEC_FILE_WRITE_DELAY); @@ -413,12 +429,26 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) { setIsCreating(false); return; } + + // Determine target branch: use current branch if checkbox is unchecked + let effectiveBranch = worktreeBranchOnCreate; + if (!useWorktreeBranchOnCreate) { + // Detect current branch + try { + const branchResult = await api.worktree?.getCurrentBranch?.(currentProject.path); + effectiveBranch = branchResult?.success ? branchResult.branch : 'main'; + } catch { + effectiveBranch = 'main'; + } + } + const result = await api.specRegeneration.create( currentProject.path, projectOverview.trim(), generateFeatures, analyzeProjectOnCreate, - generateFeatures ? featureCountOnCreate : undefined + generateFeatures ? featureCountOnCreate : undefined, + effectiveBranch ); if (!result.success) { @@ -447,6 +477,8 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) { generateFeatures, analyzeProjectOnCreate, featureCountOnCreate, + useWorktreeBranchOnCreate, + worktreeBranchOnCreate, ]); const handleRegenerate = useCallback(async () => { @@ -469,12 +501,26 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) { setIsRegenerating(false); return; } + + // Determine target branch: use current branch if checkbox is unchecked + let effectiveBranch = worktreeBranchOnRegenerate; + if (!useWorktreeBranchOnRegenerate) { + // Detect current branch + try { + const branchResult = await api.worktree?.getCurrentBranch?.(currentProject.path); + effectiveBranch = branchResult?.success ? branchResult.branch : 'main'; + } catch { + effectiveBranch = 'main'; + } + } + const result = await api.specRegeneration.generate( currentProject.path, projectDefinition.trim(), generateFeaturesOnRegenerate, analyzeProjectOnRegenerate, - generateFeaturesOnRegenerate ? featureCountOnRegenerate : undefined + generateFeaturesOnRegenerate ? featureCountOnRegenerate : undefined, + effectiveBranch ); if (!result.success) { @@ -503,6 +549,8 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) { generateFeaturesOnRegenerate, analyzeProjectOnRegenerate, featureCountOnRegenerate, + useWorktreeBranchOnRegenerate, + worktreeBranchOnRegenerate, ]); const handleGenerateFeatures = useCallback(async () => { @@ -563,6 +611,10 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) { setAnalyzeProjectOnCreate, featureCountOnCreate, setFeatureCountOnCreate, + useWorktreeBranchOnCreate, + setUseWorktreeBranchOnCreate, + worktreeBranchOnCreate, + setWorktreeBranchOnCreate, // Regenerate state projectDefinition, @@ -574,6 +626,10 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) { setAnalyzeProjectOnRegenerate, featureCountOnRegenerate, setFeatureCountOnRegenerate, + useWorktreeBranchOnRegenerate, + setUseWorktreeBranchOnRegenerate, + worktreeBranchOnRegenerate, + setWorktreeBranchOnRegenerate, // Feature generation state isGeneratingFeatures, diff --git a/apps/ui/src/components/views/spec-view/types.ts b/apps/ui/src/components/views/spec-view/types.ts index 084909e9d..4c68906ef 100644 --- a/apps/ui/src/components/views/spec-view/types.ts +++ b/apps/ui/src/components/views/spec-view/types.ts @@ -1,5 +1,5 @@ // Feature count options for spec generation -export type FeatureCount = 20 | 50 | 100; +export type FeatureCount = number; // Generation phases for UI display export type GenerationPhase = @@ -23,6 +23,10 @@ export interface CreateSpecDialogProps { onAnalyzeProjectChange: (value: boolean) => void; featureCount: FeatureCount; onFeatureCountChange: (value: FeatureCount) => void; + useWorktreeBranch: boolean; + onUseWorktreeBranchChange: (value: boolean) => void; + worktreeBranch: string; + onWorktreeBranchChange: (value: string) => void; onCreateSpec: () => void; onSkip?: () => void; isCreatingSpec: boolean; @@ -43,6 +47,10 @@ export interface RegenerateSpecDialogProps { onAnalyzeProjectChange: (value: boolean) => void; featureCount: FeatureCount; onFeatureCountChange: (value: FeatureCount) => void; + useWorktreeBranch: boolean; + onUseWorktreeBranchChange: (value: boolean) => void; + worktreeBranch: string; + onWorktreeBranchChange: (value: string) => void; onRegenerate: () => void; onGenerateFeaturesOnly?: () => void; isRegenerating: boolean; diff --git a/apps/ui/src/lib/electron.ts b/apps/ui/src/lib/electron.ts index d81b46b6e..1c9f239f0 100644 --- a/apps/ui/src/lib/electron.ts +++ b/apps/ui/src/lib/electron.ts @@ -408,7 +408,13 @@ export type SpecRegenerationEvent = input: unknown; projectPath: string; } - | { type: 'spec_regeneration_complete'; message: string; projectPath: string } + | { + type: 'spec_regeneration_complete'; + message: string; + projectPath: string; + targetBranch?: string; + worktreePath?: string; + } | { type: 'spec_regeneration_error'; error: string; projectPath: string }; export interface SpecRegenerationAPI { @@ -417,14 +423,16 @@ export interface SpecRegenerationAPI { projectOverview: string, generateFeatures?: boolean, analyzeProject?: boolean, - maxFeatures?: number + maxFeatures?: number, + targetBranch?: string ) => Promise<{ success: boolean; error?: string }>; generate: ( projectPath: string, projectDefinition: string, generateFeatures?: boolean, analyzeProject?: boolean, - maxFeatures?: number + maxFeatures?: number, + targetBranch?: string ) => Promise<{ success: boolean; error?: string }>; generateFeatures: ( projectPath: string, @@ -1364,6 +1372,11 @@ function createMockSetupAPI(): SetupAPI { // Mock Worktree API implementation function createMockWorktreeAPI(): WorktreeAPI { return { + getCurrentBranch: async (projectPath: string) => { + console.log('[Mock] Getting current branch:', { projectPath }); + return { success: true, branch: 'main' }; + }, + mergeFeature: async (projectPath: string, featureId: string, options?: object) => { console.log('[Mock] Merging feature:', { projectPath, diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index 9bf58d8e1..3ae5e11d0 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -1309,6 +1309,8 @@ export class HttpApiClient implements ElectronAPI { // Worktree API worktree: WorktreeAPI = { + getCurrentBranch: (projectPath: string) => + this.post('/api/worktree/current-branch', { projectPath }), mergeFeature: (projectPath: string, featureId: string, options?: object) => this.post('/api/worktree/merge', { projectPath, featureId, options }), getInfo: (projectPath: string, featureId: string) => @@ -1393,7 +1395,8 @@ export class HttpApiClient implements ElectronAPI { projectOverview: string, generateFeatures?: boolean, analyzeProject?: boolean, - maxFeatures?: number + maxFeatures?: number, + targetBranch?: string ) => this.post('/api/spec-regeneration/create', { projectPath, @@ -1401,13 +1404,15 @@ export class HttpApiClient implements ElectronAPI { generateFeatures, analyzeProject, maxFeatures, + targetBranch, }), generate: ( projectPath: string, projectDefinition: string, generateFeatures?: boolean, analyzeProject?: boolean, - maxFeatures?: number + maxFeatures?: number, + targetBranch?: string ) => this.post('/api/spec-regeneration/generate', { projectPath, @@ -1415,6 +1420,7 @@ export class HttpApiClient implements ElectronAPI { generateFeatures, analyzeProject, maxFeatures, + targetBranch, }), generateFeatures: (projectPath: string, maxFeatures?: number) => this.post('/api/spec-regeneration/generate-features', { diff --git a/apps/ui/src/types/electron.d.ts b/apps/ui/src/types/electron.d.ts index 068feb615..dc420e06f 100644 --- a/apps/ui/src/types/electron.d.ts +++ b/apps/ui/src/types/electron.d.ts @@ -316,6 +316,8 @@ export type SpecRegenerationEvent = type: 'spec_regeneration_complete'; message: string; projectPath: string; + targetBranch?: string; + worktreePath?: string; } | { type: 'spec_regeneration_error'; @@ -329,7 +331,8 @@ export interface SpecRegenerationAPI { projectOverview: string, generateFeatures?: boolean, analyzeProject?: boolean, - maxFeatures?: number + maxFeatures?: number, + targetBranch?: string ) => Promise<{ success: boolean; error?: string; @@ -340,7 +343,8 @@ export interface SpecRegenerationAPI { projectDefinition: string, generateFeatures?: boolean, analyzeProject?: boolean, - maxFeatures?: number + maxFeatures?: number, + targetBranch?: string ) => Promise<{ success: boolean; error?: string; @@ -632,6 +636,13 @@ export interface FileDiffResult { } export interface WorktreeAPI { + // Get the current branch of a project + getCurrentBranch: (projectPath: string) => Promise<{ + success: boolean; + branch?: string; + error?: string; + }>; + // Merge feature worktree changes back to main branch mergeFeature: ( projectPath: string, diff --git a/apps/ui/tests/git/delete-worktree-with-features.spec.ts b/apps/ui/tests/git/delete-worktree-with-features.spec.ts new file mode 100644 index 000000000..e1c1e7a12 --- /dev/null +++ b/apps/ui/tests/git/delete-worktree-with-features.spec.ts @@ -0,0 +1,499 @@ +/** + * Delete Worktree with Features E2E Test + * + * Tests deleting a worktree with features: + * 1. Delete worktree without deleting features (features move to main) + * 2. Delete worktree with deleting features (features are removed) + */ + +import { test, expect, type Page } from '@playwright/test'; +import * as fs from 'fs'; +import * as path from 'path'; +import { exec } from 'child_process'; +import { promisify } from 'util'; +import { + waitForNetworkIdle, + createTestGitRepo, + cleanupTempDir, + createTempDirPath, + setupProjectWithPath, + waitForBoardView, + authenticateForTests, + handleLoginScreenIfPresent, + addFeature, + getKanbanColumn, +} from '../utils'; + +const execAsync = promisify(exec); +const TEST_TEMP_DIR = createTempDirPath('delete-worktree-tests'); + +// Helper to enable worktrees in settings +async function enableWorktrees(page: Page) { + await page.click('[data-testid="settings-button"]'); + await page.waitForSelector('[data-testid="settings-view"]', { timeout: 5000 }); + + // Click on Feature Defaults section + const featureDefaultsSection = page.getByRole('button', { name: /Feature Defaults/i }); + await featureDefaultsSection.click(); + await page.waitForTimeout(500); + + // Scroll to and enable worktrees checkbox + const worktreeCheckbox = page.locator('[data-testid="use-worktrees-checkbox"]'); + await worktreeCheckbox.scrollIntoViewIfNeeded(); + await worktreeCheckbox.waitFor({ state: 'visible', timeout: 5000 }); + const isChecked = await worktreeCheckbox.isChecked(); + if (!isChecked) { + await worktreeCheckbox.click(); + await page.waitForTimeout(500); + } + + // Navigate back to board using keyboard shortcut + await page.keyboard.press('k'); + await waitForBoardView(page); + + // Expand worktree panel if collapsed (click the expand button) + const expandButton = page.getByRole('button', { name: /expand worktree panel/i }); + const isExpandButtonVisible = await expandButton.isVisible().catch(() => false); + if (isExpandButtonVisible) { + await expandButton.click(); + await page.waitForTimeout(500); + } +} + +// Helper to create a worktree via the Create Worktree dialog +async function createWorktree(page: Page, branchName: string) { + const createWorktreeButton = page.getByRole('button', { name: 'Create new worktree' }); + await createWorktreeButton.click(); + await page.waitForSelector('[role="dialog"]', { timeout: 5000 }); + + // Fill in the branch name and submit + const branchInput = page.locator('[role="dialog"] input'); + await branchInput.waitFor({ state: 'visible', timeout: 5000 }); + await branchInput.click(); + await page.keyboard.press('ControlOrMeta+a'); + await branchInput.fill(branchName); + await page.keyboard.press('Enter'); + + // Wait for worktree to be created - check for the success toast + const successToast = page.locator(`text=/Worktree created for branch.*${branchName}/i`); + await successToast.waitFor({ state: 'visible', timeout: 10000 }); + + // Wait for UI to update + await page.waitForTimeout(2000); +} + +// Helper to get all feature IDs for a branch +function getFeaturesForBranch(projectPath: string, branchName: string): string[] { + const featuresDir = path.join(projectPath, '.automaker', 'features'); + if (!fs.existsSync(featuresDir)) return []; + + const featureIds: string[] = []; + const entries = fs.readdirSync(featuresDir); + + for (const featureId of entries) { + const featureJsonPath = path.join(featuresDir, featureId, 'feature.json'); + if (fs.existsSync(featureJsonPath)) { + const featureData = JSON.parse(fs.readFileSync(featureJsonPath, 'utf-8')); + if (featureData.branchName === branchName) { + featureIds.push(featureId); + } + } + } + + return featureIds; +} + +interface TestRepo { + path: string; + cleanup: () => Promise; +} + +test.describe('Delete Worktree with Features', () => { + let testRepo: TestRepo; + + test.beforeAll(async () => { + if (!fs.existsSync(TEST_TEMP_DIR)) { + fs.mkdirSync(TEST_TEMP_DIR, { recursive: true }); + } + }); + + test.beforeEach(async () => { + testRepo = await createTestGitRepo(TEST_TEMP_DIR); + }); + + test.afterEach(async () => { + if (testRepo) { + await testRepo.cleanup(); + } + }); + + test.afterAll(async () => { + cleanupTempDir(TEST_TEMP_DIR); + }); + + test('should move features to main when deleting worktree without checkbox', async ({ page }) => { + test.setTimeout(90000); + + // Setup project and enable worktrees + await setupProjectWithPath(page, testRepo.path); + await authenticateForTests(page); + await page.goto('/'); + await page.waitForLoadState('load'); + await handleLoginScreenIfPresent(page); + await waitForNetworkIdle(page); + await waitForBoardView(page); + + // Enable worktrees + await enableWorktrees(page); + + // Create a new worktree branch "feature-branch" + await createWorktree(page, 'feature-branch'); + + // Add 2 features to the feature-branch + await addFeature(page, 'Feature 1 for feature-branch', { branch: 'feature-branch' }); + await page.waitForTimeout(500); + await addFeature(page, 'Feature 2 for feature-branch', { branch: 'feature-branch' }); + await page.waitForTimeout(500); + + // Verify features are visible in backlog on feature-branch + const backlogColumn = await getKanbanColumn(page, 'backlog'); + const feature1Card = backlogColumn.locator('text=Feature 1 for feature-branch'); + const feature2Card = backlogColumn.locator('text=Feature 2 for feature-branch'); + await expect(feature1Card).toBeVisible(); + await expect(feature2Card).toBeVisible(); + + // Open worktree actions dropdown + const featureBranchTab = page.getByRole('button', { name: /^feature-branch\s+\d+$/ }); + await featureBranchTab.waitFor({ state: 'visible', timeout: 10000 }); + + // Get the parent container and find the actions dropdown (three dots button) + const worktreeContainer = featureBranchTab.locator('..'); + const actionsDropdown = worktreeContainer.locator('button').last(); // Last button in the group is the actions menu + await actionsDropdown.click(); + await page.waitForTimeout(300); + + // Click delete option + const deleteOption = page.getByRole('menuitem', { name: /delete/i }); + await deleteOption.click(); + + // Wait for delete dialog + await page.waitForSelector('[role="dialog"]', { timeout: 5000 }); + + // Verify the affected feature count is shown + const affectedFeaturesWarning = page.locator('text=/2 features.*assigned to this branch/i'); + await expect(affectedFeaturesWarning).toBeVisible(); + + // DO NOT check the "Delete backlog" checkbox - leave it unchecked + const deleteBacklogCheckbox = page.locator('[id="delete-features"]'); + const isDeleteBacklogChecked = await deleteBacklogCheckbox.isChecked(); + expect(isDeleteBacklogChecked).toBe(false); + + // Confirm deletion + const deleteButton = page.getByRole('button', { name: /^Delete$/i }); + await deleteButton.click(); + + // Wait for deletion to complete + await page.waitForTimeout(2000); + + // Switch to main branch (count is optional - may be 0) + const mainBranchTab = page.getByRole('button', { name: /^main(\s+\d+)?$/ }); + await mainBranchTab.waitFor({ state: 'visible', timeout: 5000 }); + await mainBranchTab.click(); + await page.waitForTimeout(1000); + + // Verify features are now visible on main branch (moved from feature-branch) + const mainBacklogColumn = await getKanbanColumn(page, 'backlog'); + const movedFeature1 = mainBacklogColumn.locator('text=Feature 1 for feature-branch').first(); + const movedFeature2 = mainBacklogColumn.locator('text=Feature 2 for feature-branch').first(); + await expect(movedFeature1).toBeVisible({ timeout: 5000 }); + await expect(movedFeature2).toBeVisible({ timeout: 5000 }); + + // Verify features on disk have been reassigned to main + const mainFeatures = getFeaturesForBranch(testRepo.path, 'main'); + expect(mainFeatures.length).toBeGreaterThanOrEqual(2); + + // Verify no features remain for feature-branch + const featureBranchFeatures = getFeaturesForBranch(testRepo.path, 'feature-branch'); + expect(featureBranchFeatures.length).toBe(0); + + // Verify feature-branch tab is gone + await expect(featureBranchTab).not.toBeVisible(); + }); + + test('should delete features when deleting worktree with checkbox checked', async ({ page }) => { + test.setTimeout(90000); + + // Setup project and enable worktrees + await setupProjectWithPath(page, testRepo.path); + await authenticateForTests(page); + await page.goto('/'); + await page.waitForLoadState('load'); + await handleLoginScreenIfPresent(page); + await waitForNetworkIdle(page); + await waitForBoardView(page); + + // Enable worktrees + await enableWorktrees(page); + + // Create a new worktree branch "delete-branch" + await createWorktree(page, 'delete-branch'); + + // Add 3 features to the delete-branch + await addFeature(page, 'Feature to be deleted 1', { branch: 'delete-branch' }); + await page.waitForTimeout(500); + await addFeature(page, 'Feature to be deleted 2', { branch: 'delete-branch' }); + await page.waitForTimeout(500); + await addFeature(page, 'Feature to be deleted 3', { branch: 'delete-branch' }); + await page.waitForTimeout(500); + + // Verify features are visible in backlog on delete-branch + const backlogColumn = await getKanbanColumn(page, 'backlog'); + const feature1Card = backlogColumn.locator('text=Feature to be deleted 1'); + await expect(feature1Card).toBeVisible(); + + // Open worktree actions dropdown + const deleteBranchTab = page.getByRole('button', { name: /^delete-branch\s+\d+$/ }); + await deleteBranchTab.waitFor({ state: 'visible', timeout: 10000 }); + + // Get the parent container and find the actions dropdown (three dots button) + const worktreeContainer = deleteBranchTab.locator('..'); + const actionsDropdown = worktreeContainer.locator('button').last(); + await actionsDropdown.click(); + await page.waitForTimeout(300); + + // Click delete option + const deleteOption = page.getByRole('menuitem', { name: /delete/i }); + await deleteOption.click(); + + // Wait for delete dialog + await page.waitForSelector('[role="dialog"]', { timeout: 5000 }); + + // Check the "Delete backlog" checkbox + const deleteBacklogCheckbox = page.locator('[id="delete-features"]'); + await expect(deleteBacklogCheckbox).toBeVisible(); + await deleteBacklogCheckbox.click(); + await page.waitForTimeout(300); + + // Verify the warning message is hidden when checkbox is checked + const affectedFeaturesWarning = page.locator( + 'text=/features.*will be unassigned and moved to the main worktree/i' + ); + await expect(affectedFeaturesWarning).not.toBeVisible(); + + // Confirm deletion + const deleteButton = page.getByRole('button', { name: /^Delete$/i }); + await deleteButton.click(); + + // Wait for deletion to complete + await page.waitForTimeout(2000); + + // Switch to main branch (count is optional - may be 0) + const mainBranchTab = page.getByRole('button', { name: /^main(\s+\d+)?$/ }); + await mainBranchTab.waitFor({ state: 'visible', timeout: 5000 }); + await mainBranchTab.click(); + await page.waitForTimeout(1000); + + // Verify features are NOT on main branch (they were deleted) + const mainBacklogColumn = await getKanbanColumn(page, 'backlog'); + const deletedFeature1 = mainBacklogColumn.locator('text=Feature to be deleted 1'); + const deletedFeature2 = mainBacklogColumn.locator('text=Feature to be deleted 2'); + const deletedFeature3 = mainBacklogColumn.locator('text=Feature to be deleted 3'); + await expect(deletedFeature1).not.toBeVisible(); + await expect(deletedFeature2).not.toBeVisible(); + await expect(deletedFeature3).not.toBeVisible(); + + // Verify features were actually deleted from disk (not just moved) + const deleteBranchFeatures = getFeaturesForBranch(testRepo.path, 'delete-branch'); + expect(deleteBranchFeatures.length).toBe(0); + + // Also verify they're not on main branch + const featuresDir = path.join(testRepo.path, '.automaker', 'features'); + const allFeatures = fs.existsSync(featuresDir) ? fs.readdirSync(featuresDir) : []; + + // Check that no feature contains the "deleted" text in description + for (const featureId of allFeatures) { + const featureJsonPath = path.join(featuresDir, featureId, 'feature.json'); + if (fs.existsSync(featureJsonPath)) { + const featureData = JSON.parse(fs.readFileSync(featureJsonPath, 'utf-8')); + expect(featureData.description).not.toContain('Feature to be deleted'); + } + } + + // Verify delete-branch tab is gone + await expect(deleteBranchTab).not.toBeVisible(); + }); + + test('should show feature count in delete dialog', async ({ page }) => { + test.setTimeout(90000); + + // Setup project and enable worktrees + await setupProjectWithPath(page, testRepo.path); + await authenticateForTests(page); + await page.goto('/'); + await page.waitForLoadState('load'); + await handleLoginScreenIfPresent(page); + await waitForNetworkIdle(page); + await waitForBoardView(page); + + // Enable worktrees + await enableWorktrees(page); + + // Create worktree + await createWorktree(page, 'count-test'); + + // Add exactly 5 features + for (let i = 1; i <= 5; i++) { + await addFeature(page, `Count test feature ${i}`, { branch: 'count-test' }); + await page.waitForTimeout(400); + } + + // Open delete dialog + const countTestTab = page.getByRole('button', { name: /^count-test\s+\d+$/ }); + await countTestTab.waitFor({ state: 'visible', timeout: 10000 }); + + // Get the parent container and find the actions dropdown (three dots button) + const worktreeContainer = countTestTab.locator('..'); + const actionsDropdown = worktreeContainer.locator('button').last(); + await actionsDropdown.click(); + await page.waitForTimeout(300); + + const deleteOption = page.getByRole('menuitem', { name: /delete/i }); + await deleteOption.click(); + await page.waitForSelector('[role="dialog"]', { timeout: 5000 }); + + // Verify checkbox shows correct count + const deleteBacklogLabel = page.locator('text=/Delete backlog.*5 features/i'); + await expect(deleteBacklogLabel).toBeVisible(); + + // Verify warning shows correct count + const warningMessage = page.locator('text=/5 features are assigned to this branch/i'); + await expect(warningMessage).toBeVisible(); + + // Cancel + const cancelButton = page.getByRole('button', { name: /cancel/i }); + await cancelButton.click(); + }); + + test('should delete git branch when "Also delete the branch" is checked', async ({ page }) => { + test.setTimeout(90000); + + // Setup project and enable worktrees + await setupProjectWithPath(page, testRepo.path); + await authenticateForTests(page); + await page.goto('/'); + await page.waitForLoadState('load'); + await handleLoginScreenIfPresent(page); + await waitForNetworkIdle(page); + await waitForBoardView(page); + + // Enable worktrees + await enableWorktrees(page); + + // Create a new worktree branch "branch-to-delete" + await createWorktree(page, 'branch-to-delete'); + + // Verify branch exists before deletion + const { stdout: beforeBranches } = await execAsync('git branch', { cwd: testRepo.path }); + expect(beforeBranches).toContain('branch-to-delete'); + + // Open worktree actions dropdown + const branchTab = page.getByRole('button', { name: /^branch-to-delete(\s+\d+)?$/ }); + await branchTab.waitFor({ state: 'visible', timeout: 10000 }); + + const worktreeContainer = branchTab.locator('..'); + const actionsDropdown = worktreeContainer.locator('button').last(); + await actionsDropdown.click(); + await page.waitForTimeout(300); + + // Click delete option + const deleteOption = page.getByRole('menuitem', { name: /delete/i }); + await deleteOption.click(); + + // Wait for delete dialog + await page.waitForSelector('[role="dialog"]', { timeout: 5000 }); + + // Check the "Also delete the branch" checkbox + const deleteBranchCheckbox = page.locator('[id="delete-branch"]'); + await expect(deleteBranchCheckbox).toBeVisible(); + await deleteBranchCheckbox.click(); + await page.waitForTimeout(300); + + // Confirm deletion + const deleteButton = page.getByRole('button', { name: /^Delete$/i }); + await deleteButton.click(); + + // Wait for deletion to complete + await page.waitForTimeout(2000); + + // Verify git branch was deleted + const { stdout: afterBranches } = await execAsync('git branch', { cwd: testRepo.path }); + expect(afterBranches).not.toContain('branch-to-delete'); + + // Verify worktree tab is gone + await expect(branchTab).not.toBeVisible(); + }); + + test('should keep git branch when "Also delete the branch" is unchecked', async ({ page }) => { + test.setTimeout(90000); + + // Setup project and enable worktrees + await setupProjectWithPath(page, testRepo.path); + await authenticateForTests(page); + await page.goto('/'); + await page.waitForLoadState('load'); + await handleLoginScreenIfPresent(page); + await waitForNetworkIdle(page); + await waitForBoardView(page); + + // Enable worktrees + await enableWorktrees(page); + + // Create a new worktree branch "branch-to-keep" + await createWorktree(page, 'branch-to-keep'); + + // Verify branch exists before deletion + const { stdout: beforeBranches } = await execAsync('git branch', { cwd: testRepo.path }); + expect(beforeBranches).toContain('branch-to-keep'); + + // Open worktree actions dropdown + const branchTab = page.getByRole('button', { name: /^branch-to-keep(\s+\d+)?$/ }); + await branchTab.waitFor({ state: 'visible', timeout: 10000 }); + + const worktreeContainer = branchTab.locator('..'); + const actionsDropdown = worktreeContainer.locator('button').last(); + await actionsDropdown.click(); + await page.waitForTimeout(300); + + // Click delete option + const deleteOption = page.getByRole('menuitem', { name: /delete/i }); + await deleteOption.click(); + + // Wait for delete dialog + await page.waitForSelector('[role="dialog"]', { timeout: 5000 }); + + // Verify the "Also delete the branch" checkbox exists and is checked by default + const deleteBranchCheckbox = page.locator('[id="delete-branch"]'); + await expect(deleteBranchCheckbox).toBeVisible(); + const isChecked = await deleteBranchCheckbox.isChecked(); + + // If checked, uncheck it to keep the branch + if (isChecked) { + await deleteBranchCheckbox.click(); + await page.waitForTimeout(300); + } + + // Confirm deletion + const deleteButton = page.getByRole('button', { name: /^Delete$/i }); + await deleteButton.click(); + + // Wait for deletion to complete + await page.waitForTimeout(2000); + + // Verify git branch still exists + const { stdout: afterBranches } = await execAsync('git branch', { cwd: testRepo.path }); + expect(afterBranches).toContain('branch-to-keep'); + + // Verify worktree tab is gone (worktree deleted but branch kept) + await expect(branchTab).not.toBeVisible(); + }); +}); diff --git a/apps/ui/tests/projects/regenerate-spec-with-worktree.spec.ts b/apps/ui/tests/projects/regenerate-spec-with-worktree.spec.ts new file mode 100644 index 000000000..1d198c6f2 --- /dev/null +++ b/apps/ui/tests/projects/regenerate-spec-with-worktree.spec.ts @@ -0,0 +1,348 @@ +/** + * Spec Regeneration with Worktree E2E Test + * + * Tests the spec regeneration flow with worktree creation: + * 1. Creates a new project + * 2. Opens regenerate spec dialog + * 3. Checks "Use worktree branch" checkbox and enters branch name + * 4. Generates features with worktree branch + * 5. Verifies worktree was created + * 6. Verifies features have correct branchName + * 7. Verifies UI switches to target worktree + */ + +import { test, expect } from '@playwright/test'; +import * as fs from 'fs'; +import * as path from 'path'; +import { exec } from 'child_process'; +import { promisify } from 'util'; +import { + createTempDirPath, + cleanupTempDir, + setupWelcomeView, + authenticateForTests, + handleLoginScreenIfPresent, +} from '../utils'; + +const execAsync = promisify(exec); +const TEST_TEMP_DIR = createTempDirPath('spec-regeneration-worktree-test'); + +test.describe('Spec Regeneration with Worktree', () => { + // Use larger viewport to accommodate dialog + test.use({ viewport: { width: 1920, height: 1080 } }); + + test.beforeAll(async () => { + if (!fs.existsSync(TEST_TEMP_DIR)) { + fs.mkdirSync(TEST_TEMP_DIR, { recursive: true }); + } + }); + + test.afterAll(async () => { + cleanupTempDir(TEST_TEMP_DIR); + }); + + test('should create worktree when regenerating spec with target branch', async ({ page }) => { + test.setTimeout(180000); // 3 minutes for AI generation + + const projectName = `test-worktree-${Date.now()}`; + const projectPath = path.join(TEST_TEMP_DIR, projectName); + const targetBranch = 'feature-test'; + + // Setup and authenticate + await setupWelcomeView(page, { workspaceDir: TEST_TEMP_DIR }); + await authenticateForTests(page); + await page.goto('/'); + await page.waitForLoadState('load'); + await handleLoginScreenIfPresent(page); + + // Create a new project + await expect(page.locator('[data-testid="welcome-view"]')).toBeVisible({ timeout: 10000 }); + await page.locator('[data-testid="create-new-project"]').click(); + await page.locator('[data-testid="quick-setup-option"]').click(); + await expect(page.locator('[data-testid="new-project-modal"]')).toBeVisible({ timeout: 5000 }); + await page.locator('[data-testid="project-name-input"]').fill(projectName); + await page.locator('[data-testid="confirm-create-project"]').click(); + + // Wait for board view to load + await expect(page.locator('[data-testid="board-view"]')).toBeVisible({ timeout: 15000 }); + await page.waitForTimeout(2000); + + // Navigate to spec view + const specNavButton = page.locator('[data-testid="nav-spec"]'); + await expect(specNavButton).toBeVisible({ timeout: 5000 }); + await specNavButton.click(); + await page.waitForTimeout(1000); + + // Wait for spec view to appear + await expect( + page.locator('[data-testid="spec-view"], [data-testid="spec-view-empty"]') + ).toBeVisible({ timeout: 10000 }); + + // Click the regenerate/create button + const regenerateButton = page + .locator('button:has-text("Regenerate"), button:has-text("Create")') + .first(); + await expect(regenerateButton).toBeVisible({ timeout: 5000 }); + await regenerateButton.click(); + + // Wait for dialog to open (using ARIA role dialog) + const dialog = page.locator('[role="dialog"]').first(); + await expect(dialog).toBeVisible({ timeout: 5000 }); + + // Fill in project definition + const projectDefinitionTextarea = dialog.locator('textarea').first(); + await projectDefinitionTextarea.fill( + 'A task management application with React and TypeScript that supports user authentication and real-time collaboration' + ); + + // Check the "Use worktree branch" checkbox + const useWorktreeBranchCheckbox = dialog.locator( + '[id="regenerate-use-worktree-branch"], [id="create-use-worktree-branch"]' + ); + + if (await useWorktreeBranchCheckbox.isVisible()) { + await useWorktreeBranchCheckbox.click(); + await page.waitForTimeout(300); + + // Fill in the worktree branch name + const branchInput = dialog.locator( + '[data-testid="regenerate-worktree-branch-input"], [data-testid="create-worktree-branch-input"]' + ); + await expect(branchInput).toBeVisible({ timeout: 5000 }); + await branchInput.fill(targetBranch); + } + + // Enable "Generate feature list" if checkbox exists + const generateFeaturesCheckbox = dialog + .locator( + 'input[type="checkbox"][id*="generate-features" i], input[type="checkbox"][id*="feature" i]' + ) + .first(); + + if (await generateFeaturesCheckbox.isVisible()) { + // Check if already checked + const isChecked = await generateFeaturesCheckbox.isChecked(); + if (!isChecked) { + await generateFeaturesCheckbox.check(); + } + + // Set custom feature count to 2 for faster test + const customButton = dialog + .locator('button:has-text("Custom"), [data-testid*="custom"]') + .first(); + + if (await customButton.isVisible()) { + await customButton.click({ force: true }); + + const customInput = dialog.locator('input[type="number"], input[type="text"]').last(); + if (await customInput.isVisible()) { + await customInput.fill('2'); + } + } + } + + // Submit the form + const submitButton = dialog + .locator( + 'button:has-text("Regenerate Spec"), button:has-text("Generate Spec"), button:has-text("Create Spec")' + ) + .first(); + await submitButton.click({ force: true }); + + // Wait for dialog to close + await expect(dialog).not.toBeVisible({ timeout: 5000 }); + + // Wait for regeneration to start (button shows "Regenerating..." or "Creating...") + const loadingButton = page.locator( + 'button:has-text("Regenerating..."), button:has-text("Creating..."), button:has-text("Generating...")' + ); + await expect(loadingButton).toBeVisible({ timeout: 10000 }); + + // Wait for completion (button returns to normal state) + await expect(loadingButton).not.toBeVisible({ timeout: 120000 }); // 2 minutes timeout + + // Wait a bit for files to be written + await page.waitForTimeout(3000); + + // Verify the worktree was created + const worktreesDir = path.join(projectPath, '.worktrees'); + const worktreePath = path.join(worktreesDir, targetBranch); + + if (fs.existsSync(worktreePath)) { + // Worktree created successfully + expect(fs.existsSync(worktreePath)).toBe(true); + + // Verify it's a valid git worktree + const worktreeGitDir = path.join(worktreePath, '.git'); + expect(fs.existsSync(worktreeGitDir)).toBe(true); + } + + // Verify features were created with correct branchName + const featuresDir = path.join(projectPath, '.automaker', 'features'); + if (fs.existsSync(featuresDir)) { + const featureDirs = fs.readdirSync(featuresDir).filter((name) => { + const featureJsonPath = path.join(featuresDir, name, 'feature.json'); + return fs.existsSync(featureJsonPath); + }); + + if (featureDirs.length > 0) { + // Check that features have correct branchName + for (const featureDir of featureDirs) { + const featureJsonPath = path.join(featuresDir, featureDir, 'feature.json'); + const featureJson = JSON.parse(fs.readFileSync(featureJsonPath, 'utf-8')); + + // Feature should have branchName field + expect(featureJson).toHaveProperty('branchName'); + + // If we successfully set the target branch, it should be in the feature + if (fs.existsSync(worktreePath)) { + expect(featureJson.branchName).toBe(targetBranch); + } + } + } + } + + // Verify git branch was created + try { + const { stdout: branchesOutput } = await execAsync('git branch', { cwd: projectPath }); + const branches = branchesOutput.split('\n').map((b) => b.trim().replace('* ', '')); + + if (fs.existsSync(worktreePath)) { + expect(branches).toContain(targetBranch); + } + } catch (error) { + // Git commands might fail if project isn't properly initialized + console.log('Git branch verification skipped:', error); + } + }); + + test('should not create worktree for main/master branch', async ({ page }) => { + test.setTimeout(180000); + + const projectName = `test-main-${Date.now()}`; + const projectPath = path.join(TEST_TEMP_DIR, projectName); + + // Setup and create project + await setupWelcomeView(page, { workspaceDir: TEST_TEMP_DIR }); + await authenticateForTests(page); + await page.goto('/'); + await page.waitForLoadState('load'); + await handleLoginScreenIfPresent(page); + + await expect(page.locator('[data-testid="welcome-view"]')).toBeVisible({ timeout: 10000 }); + await page.locator('[data-testid="create-new-project"]').click(); + await page.locator('[data-testid="quick-setup-option"]').click(); + await expect(page.locator('[data-testid="new-project-modal"]')).toBeVisible({ timeout: 5000 }); + await page.locator('[data-testid="project-name-input"]').fill(projectName); + await page.locator('[data-testid="confirm-create-project"]').click(); + + await expect(page.locator('[data-testid="board-view"]')).toBeVisible({ timeout: 15000 }); + await page.waitForTimeout(2000); + + // Get initial branches before regeneration + let initialBranches: string[] = []; + try { + const { stdout } = await execAsync('git branch', { cwd: projectPath }); + initialBranches = stdout + .split('\n') + .map((b) => b.trim().replace('* ', '')) + .filter((b) => b); + } catch { + // Ignore if git commands fail + } + + // Navigate to spec and regenerate with "main" branch + const specNavButton = page.locator('[data-testid="nav-spec"]'); + await specNavButton.click(); + await expect( + page.locator('[data-testid="spec-view"], [data-testid="spec-view-empty"]') + ).toBeVisible({ timeout: 10000 }); + + const regenerateButton = page + .locator('button:has-text("Regenerate"), button:has-text("Create")') + .first(); + await regenerateButton.click(); + + const dialog = page.locator('[role="dialog"]').first(); + await expect(dialog).toBeVisible({ timeout: 5000 }); + + const projectDefinitionTextarea = dialog.locator('textarea').first(); + await projectDefinitionTextarea.fill('A simple calculator application'); + + // Try to select "main" as target branch (default behavior) + const branchSelector = dialog + .locator('[data-testid="regenerate-branch-selector"], [data-testid="create-branch-selector"]') + .first(); + + if (await branchSelector.isVisible()) { + await branchSelector.click(); + await page.keyboard.type('main'); + await page.keyboard.press('Enter'); + await page.waitForTimeout(500); + } + + // Enable features with small count + const generateFeaturesCheckbox = dialog.locator('input[type="checkbox"]').first(); + if (await generateFeaturesCheckbox.isVisible()) { + const isChecked = await generateFeaturesCheckbox.isChecked(); + if (!isChecked) { + await generateFeaturesCheckbox.check(); + } + } + + const submitButton = dialog + .locator( + 'button:has-text("Regenerate Spec"), button:has-text("Generate Spec"), button:has-text("Create Spec")' + ) + .first(); + await submitButton.click({ force: true }); + + await expect(dialog).not.toBeVisible({ timeout: 5000 }); + + const loadingButton = page.locator( + 'button:has-text("Regenerating..."), button:has-text("Creating...")' + ); + await expect(loadingButton).toBeVisible({ timeout: 10000 }); + await expect(loadingButton).not.toBeVisible({ timeout: 120000 }); + + await page.waitForTimeout(2000); + + // Verify no new branches were created (main/master should not trigger branch creation) + try { + const { stdout: finalBranchesOutput } = await execAsync('git branch', { cwd: projectPath }); + const finalBranches = finalBranchesOutput + .split('\n') + .map((b) => b.trim().replace('* ', '')) + .filter((b) => b); + + // Branch count should remain the same + expect(finalBranches.length).toBe(initialBranches.length); + } catch { + // Ignore if git commands fail + } + + // Verify no worktrees directory was created for main + const worktreesDir = path.join(projectPath, '.worktrees'); + if (fs.existsSync(worktreesDir)) { + const mainWorktree = path.join(worktreesDir, 'main'); + const masterWorktree = path.join(worktreesDir, 'master'); + + expect(fs.existsSync(mainWorktree)).toBe(false); + expect(fs.existsSync(masterWorktree)).toBe(false); + } + + // Features should still have branchName: "main" + const featuresDir = path.join(projectPath, '.automaker', 'features'); + if (fs.existsSync(featuresDir)) { + const featureDirs = fs + .readdirSync(featuresDir) + .filter((name) => fs.existsSync(path.join(featuresDir, name, 'feature.json'))); + + for (const featureDir of featureDirs) { + const featureJsonPath = path.join(featuresDir, featureDir, 'feature.json'); + const featureJson = JSON.parse(fs.readFileSync(featureJsonPath, 'utf-8')); + expect(featureJson.branchName).toBe('main'); + } + } + }); +}); diff --git a/apps/ui/tests/utils/views/board.ts b/apps/ui/tests/utils/views/board.ts index d691b9146..50def0e4e 100644 --- a/apps/ui/tests/utils/views/board.ts +++ b/apps/ui/tests/utils/views/board.ts @@ -114,30 +114,51 @@ export async function fillAddFeatureDialog( const descriptionInput = page.locator('[data-testid="add-feature-dialog"] textarea').first(); await descriptionInput.fill(description); - // Fill branch if provided (it's a combobox autocomplete) + // Fill branch if provided if (options?.branch) { - // First, select "Other branch" radio option if not already selected - const otherBranchRadio = page - .locator('[data-testid="feature-radio-group"]') - .locator('[id="feature-other"]'); - await otherBranchRadio.waitFor({ state: 'visible', timeout: 5000 }); - await otherBranchRadio.click(); - // Wait for the branch input to appear - await page.waitForTimeout(300); - - // Now click on the branch input (autocomplete) - const branchInput = page.locator('[data-testid="feature-input"]'); - await branchInput.waitFor({ state: 'visible', timeout: 5000 }); - await branchInput.click(); - // Wait for the popover to open - await page.waitForTimeout(300); - // Type in the command input - const commandInput = page.locator('[cmdk-input]'); - await commandInput.fill(options.branch); - // Press Enter to select/create the branch - await commandInput.press('Enter'); - // Wait for popover to close - await page.waitForTimeout(200); + // Check if "Use current selected branch" option exists and matches our desired branch + const currentBranchRadio = page.locator('[id="feature-current"]'); + const currentBranchLabel = page.locator('label[for="feature-current"]'); + + const currentBranchExists = await currentBranchRadio.isVisible().catch(() => false); + let useCurrentBranch = false; + + if (currentBranchExists) { + const labelText = await currentBranchLabel.textContent(); + // Check if the label contains our desired branch name + if (labelText?.includes(options.branch)) { + useCurrentBranch = true; + } + } + + if (useCurrentBranch) { + // Select "Use current selected branch" radio option + await currentBranchRadio.click(); + await page.waitForTimeout(200); + } else { + // Select "Other branch" radio option + const otherBranchRadio = page + .locator('[data-testid="feature-radio-group"]') + .locator('[id="feature-other"]'); + await otherBranchRadio.waitFor({ state: 'visible', timeout: 5000 }); + await otherBranchRadio.click(); + // Wait for the branch input to appear + await page.waitForTimeout(300); + + // Now click on the branch input (autocomplete) + const branchInput = page.locator('[data-testid="feature-input"]'); + await branchInput.waitFor({ state: 'visible', timeout: 5000 }); + await branchInput.click(); + // Wait for the popover to open + await page.waitForTimeout(300); + // Type in the command input + const commandInput = page.locator('[cmdk-input]'); + await commandInput.fill(options.branch); + // Press Enter to select/create the branch + await commandInput.press('Enter'); + // Wait for popover to close + await page.waitForTimeout(200); + } } // Fill category if provided (it's also a combobox autocomplete) diff --git a/apps/ui/tests/utils/views/spec-editor.ts b/apps/ui/tests/utils/views/spec-editor.ts index 9916815b5..fe47d5dd2 100644 --- a/apps/ui/tests/utils/views/spec-editor.ts +++ b/apps/ui/tests/utils/views/spec-editor.ts @@ -25,13 +25,6 @@ export async function setSpecEditorContent(page: Page, content: string): Promise await editor.fill(content); } -/** - * Click the save spec button - */ -export async function clickSaveSpec(page: Page): Promise { - await clickElement(page, 'save-spec'); -} - /** * Click the reload spec button */ @@ -81,9 +74,8 @@ export async function setEditorContent(page: Page, content: string): Promise