diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 59cc6f57..f763c08d 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -217,7 +217,7 @@ app.use('/api/sessions', createSessionsRoutes(agentService)); app.use('/api/features', createFeaturesRoutes(featureLoader)); app.use('/api/auto-mode', createAutoModeRoutes(autoModeService)); app.use('/api/enhance-prompt', createEnhancePromptRoutes(settingsService)); -app.use('/api/worktree', createWorktreeRoutes()); +app.use('/api/worktree', createWorktreeRoutes(events)); app.use('/api/git', createGitRoutes()); app.use('/api/suggestions', createSuggestionsRoutes(events, settingsService)); app.use('/api/models', createModelsRoutes()); diff --git a/apps/server/src/lib/worktree-metadata.ts b/apps/server/src/lib/worktree-metadata.ts index edeadc5b..3f7ea60d 100644 --- a/apps/server/src/lib/worktree-metadata.ts +++ b/apps/server/src/lib/worktree-metadata.ts @@ -21,6 +21,12 @@ export interface WorktreeMetadata { branch: string; createdAt: string; pr?: WorktreePRInfo; + /** Whether the init script has been executed for this worktree */ + initScriptRan?: boolean; + /** Status of the init script execution */ + initScriptStatus?: 'running' | 'success' | 'failed'; + /** Error message if init script failed */ + initScriptError?: string; } /** diff --git a/apps/server/src/routes/backlog-plan/routes/apply.ts b/apps/server/src/routes/backlog-plan/routes/apply.ts index 71dc3bd9..b6c257a0 100644 --- a/apps/server/src/routes/backlog-plan/routes/apply.ts +++ b/apps/server/src/routes/backlog-plan/routes/apply.ts @@ -12,11 +12,22 @@ const featureLoader = new FeatureLoader(); export function createApplyHandler() { return async (req: Request, res: Response): Promise => { try { - const { projectPath, plan } = req.body as { + const { + projectPath, + plan, + branchName: rawBranchName, + } = req.body as { projectPath: string; plan: BacklogPlanResult; + branchName?: string; }; + // Validate branchName: must be undefined or a non-empty trimmed string + const branchName = + typeof rawBranchName === 'string' && rawBranchName.trim().length > 0 + ? rawBranchName.trim() + : undefined; + if (!projectPath) { res.status(400).json({ success: false, error: 'projectPath required' }); return; @@ -82,6 +93,7 @@ export function createApplyHandler() { dependencies: change.feature.dependencies, priority: change.feature.priority, status: 'backlog', + branchName, }); appliedChanges.push(`added:${newFeature.id}`); diff --git a/apps/server/src/routes/worktree/common.ts b/apps/server/src/routes/worktree/common.ts index 4f63a382..75c3a437 100644 --- a/apps/server/src/routes/worktree/common.ts +++ b/apps/server/src/routes/worktree/common.ts @@ -3,15 +3,51 @@ */ import { createLogger } from '@automaker/utils'; +import { spawnProcess } from '@automaker/platform'; import { exec } from 'child_process'; import { promisify } from 'util'; -import path from 'path'; import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js'; -import { FeatureLoader } from '../../services/feature-loader.js'; const logger = createLogger('Worktree'); export const execAsync = promisify(exec); -const featureLoader = new FeatureLoader(); + +// ============================================================================ +// Secure Command Execution +// ============================================================================ + +/** + * Execute git command with array arguments to prevent command injection. + * Uses spawnProcess from @automaker/platform for secure, cross-platform execution. + * + * @param args - Array of git command arguments (e.g., ['worktree', 'add', path]) + * @param cwd - Working directory to execute the command in + * @returns Promise resolving to stdout output + * @throws Error with stderr message if command fails + * + * @example + * ```typescript + * // Safe: no injection possible + * await execGitCommand(['branch', '-D', branchName], projectPath); + * + * // Instead of unsafe: + * // await execAsync(`git branch -D ${branchName}`, { cwd }); + * ``` + */ +export async function execGitCommand(args: string[], cwd: string): Promise { + const result = await spawnProcess({ + command: 'git', + args, + cwd, + }); + + // spawnProcess returns { stdout, stderr, exitCode } + if (result.exitCode === 0) { + return result.stdout; + } else { + const errorMessage = result.stderr || `Git command failed with code ${result.exitCode}`; + throw new Error(errorMessage); + } +} // ============================================================================ // Constants @@ -99,18 +135,6 @@ export function normalizePath(p: string): string { return p.replace(/\\/g, '/'); } -/** - * Check if a path is a git repo - */ -export async function isGitRepo(repoPath: string): Promise { - try { - await execAsync('git rev-parse --is-inside-work-tree', { cwd: repoPath }); - return true; - } catch { - return false; - } -} - /** * Check if a git repository has at least one commit (i.e., HEAD exists) * Returns false for freshly initialized repos with no commits diff --git a/apps/server/src/routes/worktree/index.ts b/apps/server/src/routes/worktree/index.ts index 7972dcd6..a00e0bfe 100644 --- a/apps/server/src/routes/worktree/index.ts +++ b/apps/server/src/routes/worktree/index.ts @@ -3,6 +3,7 @@ */ import { Router } from 'express'; +import type { EventEmitter } from '../../lib/events.js'; import { validatePathParams } from '../../middleware/validate-paths.js'; import { requireValidWorktree, requireValidProject, requireGitRepoOnly } from './middleware.js'; import { createInfoHandler } from './routes/info.js'; @@ -32,8 +33,14 @@ 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 { + createGetInitScriptHandler, + createPutInitScriptHandler, + createDeleteInitScriptHandler, + createRunInitScriptHandler, +} from './routes/init-script.js'; -export function createWorktreeRoutes(): Router { +export function createWorktreeRoutes(events: EventEmitter): Router { const router = Router(); router.post('/info', validatePathParams('projectPath'), createInfoHandler()); @@ -47,7 +54,7 @@ export function createWorktreeRoutes(): Router { requireValidProject, createMergeHandler() ); - router.post('/create', validatePathParams('projectPath'), createCreateHandler()); + router.post('/create', validatePathParams('projectPath'), createCreateHandler(events)); router.post('/delete', validatePathParams('projectPath', 'worktreePath'), createDeleteHandler()); router.post('/create-pr', createCreatePRHandler()); router.post('/pr-info', createPRInfoHandler()); @@ -91,5 +98,15 @@ export function createWorktreeRoutes(): Router { router.post('/stop-dev', createStopDevHandler()); router.post('/list-dev-servers', createListDevServersHandler()); + // Init script routes + router.get('/init-script', createGetInitScriptHandler()); + router.put('/init-script', validatePathParams('projectPath'), createPutInitScriptHandler()); + router.delete('/init-script', validatePathParams('projectPath'), createDeleteInitScriptHandler()); + router.post( + '/run-init-script', + validatePathParams('projectPath', 'worktreePath'), + createRunInitScriptHandler(events) + ); + return router; } diff --git a/apps/server/src/routes/worktree/middleware.ts b/apps/server/src/routes/worktree/middleware.ts index d933fff4..eb83377f 100644 --- a/apps/server/src/routes/worktree/middleware.ts +++ b/apps/server/src/routes/worktree/middleware.ts @@ -3,7 +3,8 @@ */ import type { Request, Response, NextFunction } from 'express'; -import { isGitRepo, hasCommits } from './common.js'; +import { isGitRepo } from '@automaker/git-utils'; +import { hasCommits } from './common.js'; interface ValidationOptions { /** Check if the path is a git repository (default: true) */ diff --git a/apps/server/src/routes/worktree/routes/create.ts b/apps/server/src/routes/worktree/routes/create.ts index b8e07570..061fa801 100644 --- a/apps/server/src/routes/worktree/routes/create.ts +++ b/apps/server/src/routes/worktree/routes/create.ts @@ -12,15 +12,19 @@ import { exec } from 'child_process'; import { promisify } from 'util'; import path from 'path'; import * as secureFs from '../../../lib/secure-fs.js'; +import type { EventEmitter } from '../../../lib/events.js'; +import { isGitRepo } from '@automaker/git-utils'; import { - isGitRepo, getErrorMessage, logError, normalizePath, ensureInitialCommit, + isValidBranchName, + execGitCommand, } from '../common.js'; import { trackBranch } from './branch-tracking.js'; import { createLogger } from '@automaker/utils'; +import { runInitScript } from '../../../services/init-script-service.js'; const logger = createLogger('Worktree'); @@ -77,7 +81,7 @@ async function findExistingWorktreeForBranch( } } -export function createCreateHandler() { +export function createCreateHandler(events: EventEmitter) { return async (req: Request, res: Response): Promise => { try { const { projectPath, branchName, baseBranch } = req.body as { @@ -94,6 +98,26 @@ export function createCreateHandler() { return; } + // Validate branch name to prevent command injection + if (!isValidBranchName(branchName)) { + res.status(400).json({ + success: false, + error: + 'Invalid branch name. Branch names must contain only letters, numbers, dots, hyphens, underscores, and forward slashes.', + }); + return; + } + + // Validate base branch if provided + if (baseBranch && !isValidBranchName(baseBranch) && baseBranch !== 'HEAD') { + res.status(400).json({ + success: false, + error: + 'Invalid base branch name. Branch names must contain only letters, numbers, dots, hyphens, underscores, and forward slashes.', + }); + return; + } + if (!(await isGitRepo(projectPath))) { res.status(400).json({ success: false, @@ -143,30 +167,28 @@ export function createCreateHandler() { // Create worktrees directory if it doesn't exist await secureFs.mkdir(worktreesDir, { recursive: true }); - // Check if branch exists + // Check if branch exists (using array arguments to prevent injection) let branchExists = false; try { - await execAsync(`git rev-parse --verify ${branchName}`, { - cwd: projectPath, - }); + await execGitCommand(['rev-parse', '--verify', branchName], projectPath); branchExists = true; } catch { // Branch doesn't exist } - // Create worktree - let createCmd: string; + // Create worktree (using array arguments to prevent injection) if (branchExists) { // Use existing branch - createCmd = `git worktree add "${worktreePath}" ${branchName}`; + await execGitCommand(['worktree', 'add', worktreePath, branchName], projectPath); } else { // Create new branch from base or HEAD const base = baseBranch || 'HEAD'; - createCmd = `git worktree add -b ${branchName} "${worktreePath}" ${base}`; + await execGitCommand( + ['worktree', 'add', '-b', branchName, worktreePath, base], + projectPath + ); } - await execAsync(createCmd, { cwd: projectPath }); - // Note: We intentionally do NOT symlink .automaker to worktrees // Features and config are always accessed from the main project path // This avoids symlink loop issues when activating worktrees @@ -177,6 +199,8 @@ export function createCreateHandler() { // Resolve to absolute path for cross-platform compatibility // normalizePath converts to forward slashes for API consistency const absoluteWorktreePath = path.resolve(worktreePath); + + // Respond immediately (non-blocking) res.json({ success: true, worktree: { @@ -185,6 +209,17 @@ export function createCreateHandler() { isNew: !branchExists, }, }); + + // Trigger init script asynchronously after response + // runInitScript internally checks if script exists and hasn't already run + runInitScript({ + projectPath, + worktreePath: absoluteWorktreePath, + branch: branchName, + emitter: events, + }).catch((err) => { + logger.error(`Init script failed for ${branchName}:`, err); + }); } catch (error) { logError(error, 'Create worktree failed'); res.status(500).json({ success: false, error: getErrorMessage(error) }); diff --git a/apps/server/src/routes/worktree/routes/delete.ts b/apps/server/src/routes/worktree/routes/delete.ts index 93857f78..9d8f9d27 100644 --- a/apps/server/src/routes/worktree/routes/delete.ts +++ b/apps/server/src/routes/worktree/routes/delete.ts @@ -6,9 +6,11 @@ import type { Request, Response } from 'express'; import { exec } from 'child_process'; import { promisify } from 'util'; import { isGitRepo } from '@automaker/git-utils'; -import { getErrorMessage, logError } from '../common.js'; +import { getErrorMessage, logError, isValidBranchName, execGitCommand } from '../common.js'; +import { createLogger } from '@automaker/utils'; const execAsync = promisify(exec); +const logger = createLogger('Worktree'); export function createDeleteHandler() { return async (req: Request, res: Response): Promise => { @@ -46,22 +48,25 @@ export function createDeleteHandler() { // Could not get branch name } - // Remove the worktree + // Remove the worktree (using array arguments to prevent injection) try { - await execAsync(`git worktree remove "${worktreePath}" --force`, { - cwd: projectPath, - }); + await execGitCommand(['worktree', 'remove', worktreePath, '--force'], projectPath); } catch (error) { // Try with prune if remove fails - await execAsync('git worktree prune', { cwd: projectPath }); + await execGitCommand(['worktree', 'prune'], projectPath); } // Optionally delete the branch if (deleteBranch && branchName && branchName !== 'main' && branchName !== 'master') { - try { - await execAsync(`git branch -D ${branchName}`, { cwd: projectPath }); - } catch { - // Branch deletion failed, not critical + // Validate branch name to prevent command injection + if (!isValidBranchName(branchName)) { + logger.warn(`Invalid branch name detected, skipping deletion: ${branchName}`); + } else { + try { + await execGitCommand(['branch', '-D', branchName], projectPath); + } catch { + // Branch deletion failed, not critical + } } } diff --git a/apps/server/src/routes/worktree/routes/init-script.ts b/apps/server/src/routes/worktree/routes/init-script.ts new file mode 100644 index 00000000..bb04d706 --- /dev/null +++ b/apps/server/src/routes/worktree/routes/init-script.ts @@ -0,0 +1,270 @@ +/** + * Init Script routes - Read/write/run the worktree-init.sh file + * + * POST /init-script - Read the init script content + * PUT /init-script - Write content to the init script file + * DELETE /init-script - Delete the init script file + * POST /run-init-script - Run the init script for a worktree + */ + +import type { Request, Response } from 'express'; +import path from 'path'; +import * as secureFs from '../../../lib/secure-fs.js'; +import { getErrorMessage, logError, isValidBranchName } from '../common.js'; +import { createLogger } from '@automaker/utils'; +import type { EventEmitter } from '../../../lib/events.js'; +import { forceRunInitScript } from '../../../services/init-script-service.js'; + +const logger = createLogger('InitScript'); + +/** Fixed path for init script within .automaker directory */ +const INIT_SCRIPT_FILENAME = 'worktree-init.sh'; + +/** Maximum allowed size for init scripts (1MB) */ +const MAX_SCRIPT_SIZE_BYTES = 1024 * 1024; + +/** + * Get the full path to the init script for a project + */ +function getInitScriptPath(projectPath: string): string { + return path.join(projectPath, '.automaker', INIT_SCRIPT_FILENAME); +} + +/** + * GET /init-script - Read the init script content + */ +export function createGetInitScriptHandler() { + return async (req: Request, res: Response): Promise => { + try { + const projectPath = req.query.projectPath as string; + + if (!projectPath) { + res.status(400).json({ + success: false, + error: 'projectPath query parameter is required', + }); + return; + } + + const scriptPath = getInitScriptPath(projectPath); + + try { + const content = await secureFs.readFile(scriptPath, 'utf-8'); + res.json({ + success: true, + exists: true, + content: content as string, + path: scriptPath, + }); + } catch { + // File doesn't exist + res.json({ + success: true, + exists: false, + content: '', + path: scriptPath, + }); + } + } catch (error) { + logError(error, 'Read init script failed'); + res.status(500).json({ + success: false, + error: getErrorMessage(error), + }); + } + }; +} + +/** + * PUT /init-script - Write content to the init script file + */ +export function createPutInitScriptHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, content } = req.body as { + projectPath: string; + content: string; + }; + + if (!projectPath) { + res.status(400).json({ + success: false, + error: 'projectPath is required', + }); + return; + } + + if (typeof content !== 'string') { + res.status(400).json({ + success: false, + error: 'content must be a string', + }); + return; + } + + // Validate script size to prevent disk exhaustion + const sizeBytes = Buffer.byteLength(content, 'utf-8'); + if (sizeBytes > MAX_SCRIPT_SIZE_BYTES) { + res.status(400).json({ + success: false, + error: `Script size (${Math.round(sizeBytes / 1024)}KB) exceeds maximum allowed size (${Math.round(MAX_SCRIPT_SIZE_BYTES / 1024)}KB)`, + }); + return; + } + + // Log warning if potentially dangerous patterns are detected (non-blocking) + const dangerousPatterns = [ + /rm\s+-rf\s+\/(?!\s*\$)/i, // rm -rf / (not followed by variable) + /curl\s+.*\|\s*(?:bash|sh)/i, // curl | bash + /wget\s+.*\|\s*(?:bash|sh)/i, // wget | sh + ]; + + for (const pattern of dangerousPatterns) { + if (pattern.test(content)) { + logger.warn( + `Init script contains potentially dangerous pattern: ${pattern.source}. User responsibility to verify script safety.` + ); + } + } + + const scriptPath = getInitScriptPath(projectPath); + const automakerDir = path.dirname(scriptPath); + + // Ensure .automaker directory exists + await secureFs.mkdir(automakerDir, { recursive: true }); + + // Write the script content + await secureFs.writeFile(scriptPath, content, 'utf-8'); + + logger.info(`Wrote init script to ${scriptPath}`); + + res.json({ + success: true, + path: scriptPath, + }); + } catch (error) { + logError(error, 'Write init script failed'); + res.status(500).json({ + success: false, + error: getErrorMessage(error), + }); + } + }; +} + +/** + * DELETE /init-script - Delete the init script file + */ +export function createDeleteInitScriptHandler() { + 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; + } + + const scriptPath = getInitScriptPath(projectPath); + + await secureFs.rm(scriptPath, { force: true }); + logger.info(`Deleted init script at ${scriptPath}`); + res.json({ + success: true, + }); + } catch (error) { + logError(error, 'Delete init script failed'); + res.status(500).json({ + success: false, + error: getErrorMessage(error), + }); + } + }; +} + +/** + * POST /run-init-script - Run (or re-run) the init script for a worktree + */ +export function createRunInitScriptHandler(events: EventEmitter) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, worktreePath, branch } = req.body as { + projectPath: string; + worktreePath: string; + branch: string; + }; + + if (!projectPath) { + res.status(400).json({ + success: false, + error: 'projectPath is required', + }); + return; + } + + if (!worktreePath) { + res.status(400).json({ + success: false, + error: 'worktreePath is required', + }); + return; + } + + if (!branch) { + res.status(400).json({ + success: false, + error: 'branch is required', + }); + return; + } + + // Validate branch name to prevent injection via environment variables + if (!isValidBranchName(branch)) { + res.status(400).json({ + success: false, + error: + 'Invalid branch name. Branch names must contain only letters, numbers, dots, hyphens, underscores, and forward slashes.', + }); + return; + } + + const scriptPath = getInitScriptPath(projectPath); + + // Check if script exists + try { + await secureFs.access(scriptPath); + } catch { + res.status(404).json({ + success: false, + error: 'No init script found. Create one in Settings > Worktrees.', + }); + return; + } + + logger.info(`Running init script for branch "${branch}" (forced)`); + + // Run the script asynchronously (non-blocking) + forceRunInitScript({ + projectPath, + worktreePath, + branch, + emitter: events, + }); + + // Return immediately - progress will be streamed via WebSocket events + res.json({ + success: true, + message: 'Init script started', + }); + } catch (error) { + logError(error, 'Run init script failed'); + res.status(500).json({ + success: false, + error: getErrorMessage(error), + }); + } + }; +} diff --git a/apps/server/src/services/init-script-service.ts b/apps/server/src/services/init-script-service.ts new file mode 100644 index 00000000..7731c5ee --- /dev/null +++ b/apps/server/src/services/init-script-service.ts @@ -0,0 +1,360 @@ +/** + * Init Script Service - Executes worktree initialization scripts + * + * Runs the .automaker/worktree-init.sh script after worktree creation. + * Uses Git Bash on Windows for cross-platform shell script compatibility. + */ + +import { spawn } from 'child_process'; +import path from 'path'; +import { createLogger } from '@automaker/utils'; +import { systemPathExists, getShellPaths, findGitBashPath } from '@automaker/platform'; +import { findCommand } from '../lib/cli-detection.js'; +import type { EventEmitter } from '../lib/events.js'; +import { readWorktreeMetadata, writeWorktreeMetadata } from '../lib/worktree-metadata.js'; +import * as secureFs from '../lib/secure-fs.js'; + +const logger = createLogger('InitScript'); + +export interface InitScriptOptions { + /** Absolute path to the project root */ + projectPath: string; + /** Absolute path to the worktree directory */ + worktreePath: string; + /** Branch name for this worktree */ + branch: string; + /** Event emitter for streaming output */ + emitter: EventEmitter; +} + +interface ShellCommand { + shell: string; + args: string[]; +} + +/** + * Init Script Service + * + * Handles execution of worktree initialization scripts with cross-platform + * shell detection and proper streaming of output via WebSocket events. + */ +export class InitScriptService { + private cachedShellCommand: ShellCommand | null | undefined = undefined; + + /** + * Get the path to the init script for a project + */ + getInitScriptPath(projectPath: string): string { + return path.join(projectPath, '.automaker', 'worktree-init.sh'); + } + + /** + * Check if the init script has already been run for a worktree + */ + async hasInitScriptRun(projectPath: string, branch: string): Promise { + const metadata = await readWorktreeMetadata(projectPath, branch); + return metadata?.initScriptRan === true; + } + + /** + * Find the appropriate shell for running scripts + * Uses findGitBashPath() on Windows to avoid WSL bash, then falls back to PATH + */ + async findShellCommand(): Promise { + // Return cached result if available + if (this.cachedShellCommand !== undefined) { + return this.cachedShellCommand; + } + + if (process.platform === 'win32') { + // On Windows, prioritize Git Bash over WSL bash (C:\Windows\System32\bash.exe) + // WSL bash may not be properly configured and causes ENOENT errors + + // First try known Git Bash installation paths + const gitBashPath = await findGitBashPath(); + if (gitBashPath) { + logger.debug(`Found Git Bash at: ${gitBashPath}`); + this.cachedShellCommand = { shell: gitBashPath, args: [] }; + return this.cachedShellCommand; + } + + // Fall back to finding bash in PATH, but skip WSL bash + const bashInPath = await findCommand(['bash']); + if (bashInPath && !bashInPath.toLowerCase().includes('system32')) { + logger.debug(`Found bash in PATH at: ${bashInPath}`); + this.cachedShellCommand = { shell: bashInPath, args: [] }; + return this.cachedShellCommand; + } + + logger.warn('Git Bash not found. WSL bash was skipped to avoid compatibility issues.'); + this.cachedShellCommand = null; + return null; + } + + // Unix-like systems: use getShellPaths() and check existence + const shellPaths = getShellPaths(); + const posixShells = shellPaths.filter( + (p) => p.includes('bash') || p === '/bin/sh' || p === '/usr/bin/sh' + ); + + for (const shellPath of posixShells) { + try { + if (systemPathExists(shellPath)) { + this.cachedShellCommand = { shell: shellPath, args: [] }; + return this.cachedShellCommand; + } + } catch { + // Path not allowed or doesn't exist, continue + } + } + + // Ultimate fallback + if (systemPathExists('/bin/sh')) { + this.cachedShellCommand = { shell: '/bin/sh', args: [] }; + return this.cachedShellCommand; + } + + this.cachedShellCommand = null; + return null; + } + + /** + * Run the worktree initialization script + * Non-blocking - returns immediately after spawning + */ + async runInitScript(options: InitScriptOptions): Promise { + const { projectPath, worktreePath, branch, emitter } = options; + + const scriptPath = this.getInitScriptPath(projectPath); + + // Check if script exists using secureFs (respects ALLOWED_ROOT_DIRECTORY) + try { + await secureFs.access(scriptPath); + } catch { + logger.debug(`No init script found at ${scriptPath}`); + return; + } + + // Check if already run + if (await this.hasInitScriptRun(projectPath, branch)) { + logger.info(`Init script already ran for branch "${branch}", skipping`); + return; + } + + // Get shell command + const shellCmd = await this.findShellCommand(); + if (!shellCmd) { + const error = + process.platform === 'win32' + ? 'Git Bash not found. Please install Git for Windows to run init scripts.' + : 'No shell found (/bin/bash or /bin/sh)'; + logger.error(error); + + // Update metadata with error, preserving existing metadata + const existingMetadata = await readWorktreeMetadata(projectPath, branch); + await writeWorktreeMetadata(projectPath, branch, { + branch, + createdAt: existingMetadata?.createdAt || new Date().toISOString(), + pr: existingMetadata?.pr, + initScriptRan: true, + initScriptStatus: 'failed', + initScriptError: error, + }); + + emitter.emit('worktree:init-completed', { + projectPath, + worktreePath, + branch, + success: false, + error, + }); + return; + } + + logger.info(`Running init script for branch "${branch}" in ${worktreePath}`); + logger.debug(`Using shell: ${shellCmd.shell}`); + + // Update metadata to mark as running + const existingMetadata = await readWorktreeMetadata(projectPath, branch); + await writeWorktreeMetadata(projectPath, branch, { + branch, + createdAt: existingMetadata?.createdAt || new Date().toISOString(), + pr: existingMetadata?.pr, + initScriptRan: false, + initScriptStatus: 'running', + }); + + // Emit started event + emitter.emit('worktree:init-started', { + projectPath, + worktreePath, + branch, + }); + + // Build safe environment - only pass necessary variables, not all of process.env + // This prevents exposure of sensitive credentials like ANTHROPIC_API_KEY + const safeEnv: Record = { + // Automaker-specific variables + AUTOMAKER_PROJECT_PATH: projectPath, + AUTOMAKER_WORKTREE_PATH: worktreePath, + AUTOMAKER_BRANCH: branch, + + // Essential system variables + PATH: process.env.PATH || '', + HOME: process.env.HOME || '', + USER: process.env.USER || '', + TMPDIR: process.env.TMPDIR || process.env.TEMP || process.env.TMP || '/tmp', + + // Shell and locale + SHELL: process.env.SHELL || '', + LANG: process.env.LANG || 'en_US.UTF-8', + LC_ALL: process.env.LC_ALL || '', + + // Force color output even though we're not a TTY + FORCE_COLOR: '1', + npm_config_color: 'always', + CLICOLOR_FORCE: '1', + + // Git configuration + GIT_TERMINAL_PROMPT: '0', + }; + + // Platform-specific additions + if (process.platform === 'win32') { + safeEnv.USERPROFILE = process.env.USERPROFILE || ''; + safeEnv.APPDATA = process.env.APPDATA || ''; + safeEnv.LOCALAPPDATA = process.env.LOCALAPPDATA || ''; + safeEnv.SystemRoot = process.env.SystemRoot || 'C:\\Windows'; + safeEnv.TEMP = process.env.TEMP || ''; + } + + // Spawn the script with safe environment + const child = spawn(shellCmd.shell, [...shellCmd.args, scriptPath], { + cwd: worktreePath, + env: safeEnv, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + // Stream stdout + child.stdout?.on('data', (data: Buffer) => { + const content = data.toString(); + emitter.emit('worktree:init-output', { + projectPath, + branch, + type: 'stdout', + content, + }); + }); + + // Stream stderr + child.stderr?.on('data', (data: Buffer) => { + const content = data.toString(); + emitter.emit('worktree:init-output', { + projectPath, + branch, + type: 'stderr', + content, + }); + }); + + // Handle completion + child.on('exit', async (code) => { + const success = code === 0; + const status = success ? 'success' : 'failed'; + + logger.info(`Init script for branch "${branch}" ${status} with exit code ${code}`); + + // Update metadata + const metadata = await readWorktreeMetadata(projectPath, branch); + await writeWorktreeMetadata(projectPath, branch, { + branch, + createdAt: metadata?.createdAt || new Date().toISOString(), + pr: metadata?.pr, + initScriptRan: true, + initScriptStatus: status, + initScriptError: success ? undefined : `Exit code: ${code}`, + }); + + // Emit completion event + emitter.emit('worktree:init-completed', { + projectPath, + worktreePath, + branch, + success, + exitCode: code, + }); + }); + + child.on('error', async (error) => { + logger.error(`Init script error for branch "${branch}":`, error); + + // Update metadata + const metadata = await readWorktreeMetadata(projectPath, branch); + await writeWorktreeMetadata(projectPath, branch, { + branch, + createdAt: metadata?.createdAt || new Date().toISOString(), + pr: metadata?.pr, + initScriptRan: true, + initScriptStatus: 'failed', + initScriptError: error.message, + }); + + // Emit completion with error + emitter.emit('worktree:init-completed', { + projectPath, + worktreePath, + branch, + success: false, + error: error.message, + }); + }); + } + + /** + * Force re-run the worktree initialization script + * Ignores the initScriptRan flag - useful for testing or re-setup + */ + async forceRunInitScript(options: InitScriptOptions): Promise { + const { projectPath, branch } = options; + + // Reset the initScriptRan flag so the script will run + const metadata = await readWorktreeMetadata(projectPath, branch); + if (metadata) { + await writeWorktreeMetadata(projectPath, branch, { + ...metadata, + initScriptRan: false, + initScriptStatus: undefined, + initScriptError: undefined, + }); + } + + // Now run the script + await this.runInitScript(options); + } +} + +// Singleton instance for convenience +let initScriptService: InitScriptService | null = null; + +/** + * Get the singleton InitScriptService instance + */ +export function getInitScriptService(): InitScriptService { + if (!initScriptService) { + initScriptService = new InitScriptService(); + } + return initScriptService; +} + +// Export convenience functions that use the singleton +export const getInitScriptPath = (projectPath: string) => + getInitScriptService().getInitScriptPath(projectPath); + +export const hasInitScriptRun = (projectPath: string, branch: string) => + getInitScriptService().hasInitScriptRun(projectPath, branch); + +export const runInitScript = (options: InitScriptOptions) => + getInitScriptService().runInitScript(options); + +export const forceRunInitScript = (options: InitScriptOptions) => + getInitScriptService().forceRunInitScript(options); diff --git a/apps/ui/package.json b/apps/ui/package.json index f5b5aa6e..167734d3 100644 --- a/apps/ui/package.json +++ b/apps/ui/package.json @@ -42,6 +42,8 @@ "@automaker/dependency-resolver": "1.0.0", "@automaker/types": "1.0.0", "@codemirror/lang-xml": "6.1.0", + "@codemirror/language": "^6.12.1", + "@codemirror/legacy-modes": "^6.5.2", "@codemirror/theme-one-dark": "6.1.3", "@dnd-kit/core": "6.3.1", "@dnd-kit/sortable": "10.0.0", diff --git a/apps/ui/src/components/ui/ansi-output.tsx b/apps/ui/src/components/ui/ansi-output.tsx new file mode 100644 index 00000000..83b3c4ab --- /dev/null +++ b/apps/ui/src/components/ui/ansi-output.tsx @@ -0,0 +1,276 @@ +import { useMemo } from 'react'; +import { cn } from '@/lib/utils'; + +interface AnsiOutputProps { + text: string; + className?: string; +} + +// ANSI color codes to CSS color mappings +const ANSI_COLORS: Record = { + // Standard colors + 30: '#6b7280', // Black (use gray for visibility on dark bg) + 31: '#ef4444', // Red + 32: '#22c55e', // Green + 33: '#eab308', // Yellow + 34: '#3b82f6', // Blue + 35: '#a855f7', // Magenta + 36: '#06b6d4', // Cyan + 37: '#d1d5db', // White + // Bright colors + 90: '#9ca3af', // Bright Black (Gray) + 91: '#f87171', // Bright Red + 92: '#4ade80', // Bright Green + 93: '#facc15', // Bright Yellow + 94: '#60a5fa', // Bright Blue + 95: '#c084fc', // Bright Magenta + 96: '#22d3ee', // Bright Cyan + 97: '#ffffff', // Bright White +}; + +const ANSI_BG_COLORS: Record = { + 40: 'transparent', + 41: '#ef4444', + 42: '#22c55e', + 43: '#eab308', + 44: '#3b82f6', + 45: '#a855f7', + 46: '#06b6d4', + 47: '#f3f4f6', + // Bright backgrounds + 100: '#374151', + 101: '#f87171', + 102: '#4ade80', + 103: '#facc15', + 104: '#60a5fa', + 105: '#c084fc', + 106: '#22d3ee', + 107: '#ffffff', +}; + +interface TextSegment { + text: string; + style: { + color?: string; + backgroundColor?: string; + fontWeight?: string; + fontStyle?: string; + textDecoration?: string; + }; +} + +/** + * Strip hyperlink escape sequences (OSC 8) + * Format: ESC]8;;url ESC\ text ESC]8;; ESC\ + */ +function stripHyperlinks(text: string): string { + // Remove OSC 8 hyperlink sequences + // eslint-disable-next-line no-control-regex + return text.replace(/\x1b\]8;;[^\x07\x1b]*(?:\x07|\x1b\\)/g, ''); +} + +/** + * Strip other OSC sequences (title, etc.) + */ +function stripOtherOSC(text: string): string { + // Remove OSC sequences (ESC ] ... BEL or ESC ] ... ST) + // eslint-disable-next-line no-control-regex + return text.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, ''); +} + +function parseAnsi(text: string): TextSegment[] { + // Pre-process: strip hyperlinks and other OSC sequences + let processedText = stripHyperlinks(text); + processedText = stripOtherOSC(processedText); + + const segments: TextSegment[] = []; + + // Match ANSI escape sequences: ESC[...m (SGR - Select Graphic Rendition) + // Also handle ESC[K (erase line) and other CSI sequences by stripping them + // The ESC character can be \x1b, \033, \u001b + // eslint-disable-next-line no-control-regex + const ansiRegex = /\x1b\[([0-9;]*)([a-zA-Z])/g; + + let currentStyle: TextSegment['style'] = {}; + let lastIndex = 0; + let match; + + while ((match = ansiRegex.exec(processedText)) !== null) { + // Add text before this escape sequence + if (match.index > lastIndex) { + const content = processedText.slice(lastIndex, match.index); + if (content) { + segments.push({ text: content, style: { ...currentStyle } }); + } + } + + const params = match[1]; + const command = match[2]; + + // Only process 'm' command (SGR - graphics/color) + // Ignore other commands like K (erase), H (cursor), J (clear), etc. + if (command === 'm') { + // Parse the escape sequence codes + const codes = params ? params.split(';').map((c) => parseInt(c, 10) || 0) : [0]; + + for (let i = 0; i < codes.length; i++) { + const code = codes[i]; + + if (code === 0) { + // Reset all attributes + currentStyle = {}; + } else if (code === 1) { + // Bold + currentStyle.fontWeight = 'bold'; + } else if (code === 2) { + // Dim/faint + currentStyle.color = 'var(--muted-foreground)'; + } else if (code === 3) { + // Italic + currentStyle.fontStyle = 'italic'; + } else if (code === 4) { + // Underline + currentStyle.textDecoration = 'underline'; + } else if (code === 22) { + // Normal intensity (not bold, not dim) + currentStyle.fontWeight = undefined; + } else if (code === 23) { + // Not italic + currentStyle.fontStyle = undefined; + } else if (code === 24) { + // Not underlined + currentStyle.textDecoration = undefined; + } else if (code === 38) { + // Extended foreground color + if (codes[i + 1] === 5 && codes[i + 2] !== undefined) { + // 256 color mode: 38;5;n + const colorIndex = codes[i + 2]; + currentStyle.color = get256Color(colorIndex); + i += 2; + } else if (codes[i + 1] === 2 && codes[i + 4] !== undefined) { + // RGB mode: 38;2;r;g;b + const r = codes[i + 2]; + const g = codes[i + 3]; + const b = codes[i + 4]; + currentStyle.color = `rgb(${r}, ${g}, ${b})`; + i += 4; + } + } else if (code === 48) { + // Extended background color + if (codes[i + 1] === 5 && codes[i + 2] !== undefined) { + // 256 color mode: 48;5;n + const colorIndex = codes[i + 2]; + currentStyle.backgroundColor = get256Color(colorIndex); + i += 2; + } else if (codes[i + 1] === 2 && codes[i + 4] !== undefined) { + // RGB mode: 48;2;r;g;b + const r = codes[i + 2]; + const g = codes[i + 3]; + const b = codes[i + 4]; + currentStyle.backgroundColor = `rgb(${r}, ${g}, ${b})`; + i += 4; + } + } else if (ANSI_COLORS[code]) { + // Standard foreground color (30-37, 90-97) + currentStyle.color = ANSI_COLORS[code]; + } else if (ANSI_BG_COLORS[code]) { + // Standard background color (40-47, 100-107) + currentStyle.backgroundColor = ANSI_BG_COLORS[code]; + } else if (code === 39) { + // Default foreground + currentStyle.color = undefined; + } else if (code === 49) { + // Default background + currentStyle.backgroundColor = undefined; + } + } + } + + lastIndex = match.index + match[0].length; + } + + // Add remaining text after last escape sequence + if (lastIndex < processedText.length) { + const content = processedText.slice(lastIndex); + if (content) { + segments.push({ text: content, style: { ...currentStyle } }); + } + } + + // If no segments were created (no ANSI codes), return the whole text + if (segments.length === 0 && processedText) { + segments.push({ text: processedText, style: {} }); + } + + return segments; +} + +/** + * Convert 256-color palette index to CSS color + */ +function get256Color(index: number): string { + // 0-15: Standard colors + if (index < 16) { + const standardColors = [ + '#000000', + '#cd0000', + '#00cd00', + '#cdcd00', + '#0000ee', + '#cd00cd', + '#00cdcd', + '#e5e5e5', + '#7f7f7f', + '#ff0000', + '#00ff00', + '#ffff00', + '#5c5cff', + '#ff00ff', + '#00ffff', + '#ffffff', + ]; + return standardColors[index]; + } + + // 16-231: 6x6x6 color cube + if (index < 232) { + const n = index - 16; + const b = n % 6; + const g = Math.floor(n / 6) % 6; + const r = Math.floor(n / 36); + const toHex = (v: number) => (v === 0 ? 0 : 55 + v * 40); + return `rgb(${toHex(r)}, ${toHex(g)}, ${toHex(b)})`; + } + + // 232-255: Grayscale + const gray = 8 + (index - 232) * 10; + return `rgb(${gray}, ${gray}, ${gray})`; +} + +export function AnsiOutput({ text, className }: AnsiOutputProps) { + const segments = useMemo(() => parseAnsi(text), [text]); + + return ( +
+      {segments.map((segment, index) => (
+        
+          {segment.text}
+        
+      ))}
+    
+ ); +} diff --git a/apps/ui/src/components/ui/shell-syntax-editor.tsx b/apps/ui/src/components/ui/shell-syntax-editor.tsx new file mode 100644 index 00000000..159123c4 --- /dev/null +++ b/apps/ui/src/components/ui/shell-syntax-editor.tsx @@ -0,0 +1,142 @@ +import CodeMirror from '@uiw/react-codemirror'; +import { StreamLanguage } from '@codemirror/language'; +import { shell } from '@codemirror/legacy-modes/mode/shell'; +import { EditorView } from '@codemirror/view'; +import { Extension } from '@codemirror/state'; +import { HighlightStyle, syntaxHighlighting } from '@codemirror/language'; +import { tags as t } from '@lezer/highlight'; +import { cn } from '@/lib/utils'; + +interface ShellSyntaxEditorProps { + value: string; + onChange: (value: string) => void; + placeholder?: string; + className?: string; + minHeight?: string; + maxHeight?: string; + 'data-testid'?: string; +} + +// Syntax highlighting using CSS variables for theme compatibility +const syntaxColors = HighlightStyle.define([ + // Keywords (if, then, else, fi, for, while, do, done, case, esac, etc.) + { tag: t.keyword, color: 'var(--chart-4, oklch(0.7 0.15 280))' }, + + // Strings (single and double quoted) + { tag: t.string, color: 'var(--chart-1, oklch(0.646 0.222 41.116))' }, + + // Comments + { tag: t.comment, color: 'var(--muted-foreground)', fontStyle: 'italic' }, + + // Variables ($VAR, ${VAR}) + { tag: t.variableName, color: 'var(--chart-2, oklch(0.6 0.118 184.704))' }, + + // Operators + { tag: t.operator, color: 'var(--muted-foreground)' }, + + // Numbers + { tag: t.number, color: 'var(--chart-3, oklch(0.7 0.15 150))' }, + + // Function names / commands + { tag: t.function(t.variableName), color: 'var(--primary)' }, + { tag: t.attributeName, color: 'var(--chart-5, oklch(0.65 0.2 30))' }, + + // Default text + { tag: t.content, color: 'var(--foreground)' }, +]); + +// Editor theme using CSS variables +const editorTheme = EditorView.theme({ + '&': { + height: '100%', + fontSize: '0.875rem', + fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace', + backgroundColor: 'transparent', + color: 'var(--foreground)', + }, + '.cm-scroller': { + overflow: 'auto', + fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace', + }, + '.cm-content': { + padding: '0.75rem', + minHeight: '100%', + caretColor: 'var(--primary)', + }, + '.cm-cursor, .cm-dropCursor': { + borderLeftColor: 'var(--primary)', + }, + '&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection': { + backgroundColor: 'oklch(0.55 0.25 265 / 0.3)', + }, + '.cm-activeLine': { + backgroundColor: 'var(--accent)', + opacity: '0.3', + }, + '.cm-line': { + padding: '0 0.25rem', + }, + '&.cm-focused': { + outline: 'none', + }, + '.cm-gutters': { + backgroundColor: 'transparent', + color: 'var(--muted-foreground)', + border: 'none', + paddingRight: '0.5rem', + }, + '.cm-lineNumbers .cm-gutterElement': { + minWidth: '2rem', + textAlign: 'right', + paddingRight: '0.5rem', + }, + '.cm-placeholder': { + color: 'var(--muted-foreground)', + fontStyle: 'italic', + }, +}); + +// Combine all extensions +const extensions: Extension[] = [ + StreamLanguage.define(shell), + syntaxHighlighting(syntaxColors), + editorTheme, +]; + +export function ShellSyntaxEditor({ + value, + onChange, + placeholder, + className, + minHeight = '200px', + maxHeight, + 'data-testid': testId, +}: ShellSyntaxEditorProps) { + return ( +
+ +
+ ); +} diff --git a/apps/ui/src/components/views/board-view.tsx b/apps/ui/src/components/views/board-view.tsx index 2ad6560c..30cd4db3 100644 --- a/apps/ui/src/components/views/board-view.tsx +++ b/apps/ui/src/components/views/board-view.tsx @@ -75,6 +75,8 @@ import { } from './board-view/hooks'; import { SelectionActionBar } from './board-view/components'; import { MassEditDialog } from './board-view/dialogs'; +import { InitScriptIndicator } from './board-view/init-script-indicator'; +import { useInitScriptEvents } from '@/hooks/use-init-script-events'; // Stable empty array to avoid infinite loop in selector const EMPTY_WORKTREES: ReturnType['getWorktrees']> = []; @@ -99,6 +101,8 @@ export function BoardView() { useWorktrees, enableDependencyBlocking, skipVerificationInAutoMode, + planUseSelectedWorktreeBranch, + addFeatureUseSelectedWorktreeBranch, isPrimaryWorktreeBranch, getPrimaryWorktreeBranch, setPipelineConfig, @@ -107,6 +111,12 @@ export function BoardView() { const pipelineConfigByProject = useAppStore((state) => state.pipelineConfigByProject); // Subscribe to worktreePanelVisibleByProject to trigger re-renders when it changes const worktreePanelVisibleByProject = useAppStore((state) => state.worktreePanelVisibleByProject); + // Subscribe to showInitScriptIndicatorByProject to trigger re-renders when it changes + const showInitScriptIndicatorByProject = useAppStore( + (state) => state.showInitScriptIndicatorByProject + ); + const getShowInitScriptIndicator = useAppStore((state) => state.getShowInitScriptIndicator); + const getDefaultDeleteBranch = useAppStore((state) => state.getDefaultDeleteBranch); const shortcuts = useKeyboardShortcutsConfig(); const { features: hookFeatures, @@ -252,6 +262,9 @@ export function BoardView() { // Window state hook for compact dialog mode const { isMaximized } = useWindowState(); + // Init script events hook - subscribe to worktree init script events + useInitScriptEvents(currentProject?.path ?? null); + // Keyboard shortcuts hook will be initialized after actions hook // Prevent hydration issues @@ -1361,6 +1374,14 @@ export function BoardView() { isMaximized={isMaximized} parentFeature={spawnParentFeature} allFeatures={hookFeatures} + // When setting is enabled and a non-main worktree is selected, pass its branch to default to 'custom' work mode + selectedNonMainWorktreeBranch={ + addFeatureUseSelectedWorktreeBranch && currentWorktreePath !== null + ? currentWorktreeBranch || undefined + : undefined + } + // When the worktree setting is disabled, force 'current' branch mode + forceCurrentBranchMode={!addFeatureUseSelectedWorktreeBranch} /> {/* Edit Feature Dialog */} @@ -1440,6 +1461,7 @@ export function BoardView() { setPendingPlanResult={setPendingBacklogPlan} isGeneratingPlan={isGeneratingPlan} setIsGeneratingPlan={setIsGeneratingPlan} + currentBranch={planUseSelectedWorktreeBranch ? selectedWorktreeBranch : undefined} /> {/* Plan Approval Dialog */} @@ -1507,6 +1529,7 @@ export function BoardView() { ? hookFeatures.filter((f) => f.branchName === selectedWorktreeForAction.branch).length : 0 } + defaultDeleteBranch={getDefaultDeleteBranch(currentProject.path)} onDeleted={(deletedWorktree, _deletedBranch) => { // Reset features that were assigned to the deleted worktree (by branch) hookFeatures.forEach((feature) => { @@ -1574,6 +1597,11 @@ export function BoardView() { setSelectedWorktreeForAction(null); }} /> + + {/* Init Script Indicator - floating overlay for worktree init script status */} + {getShowInitScriptIndicator(currentProject.path) && ( + + )} ); } diff --git a/apps/ui/src/components/views/board-view/board-header.tsx b/apps/ui/src/components/views/board-view/board-header.tsx index 5a9b7302..cfaa8a27 100644 --- a/apps/ui/src/components/views/board-view/board-header.tsx +++ b/apps/ui/src/components/views/board-view/board-header.tsx @@ -9,6 +9,8 @@ import { UsagePopover } from '@/components/usage-popover'; import { useAppStore } from '@/store/app-store'; import { useSetupStore } from '@/store/setup-store'; import { AutoModeSettingsDialog } from './dialogs/auto-mode-settings-dialog'; +import { WorktreeSettingsDialog } from './dialogs/worktree-settings-dialog'; +import { PlanSettingsDialog } from './dialogs/plan-settings-dialog'; import { getHttpApiClient } from '@/lib/http-api-client'; import { BoardSearchBar } from './board-search-bar'; import { BoardControls } from './board-controls'; @@ -55,10 +57,22 @@ export function BoardHeader({ completedCount, }: BoardHeaderProps) { const [showAutoModeSettings, setShowAutoModeSettings] = useState(false); + const [showWorktreeSettings, setShowWorktreeSettings] = useState(false); + const [showPlanSettings, setShowPlanSettings] = useState(false); const apiKeys = useAppStore((state) => state.apiKeys); const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus); const skipVerificationInAutoMode = useAppStore((state) => state.skipVerificationInAutoMode); const setSkipVerificationInAutoMode = useAppStore((state) => state.setSkipVerificationInAutoMode); + const planUseSelectedWorktreeBranch = useAppStore((state) => state.planUseSelectedWorktreeBranch); + const setPlanUseSelectedWorktreeBranch = useAppStore( + (state) => state.setPlanUseSelectedWorktreeBranch + ); + const addFeatureUseSelectedWorktreeBranch = useAppStore( + (state) => state.addFeatureUseSelectedWorktreeBranch + ); + const setAddFeatureUseSelectedWorktreeBranch = useAppStore( + (state) => state.setAddFeatureUseSelectedWorktreeBranch + ); const codexAuthStatus = useSetupStore((state) => state.codexAuthStatus); // Worktree panel visibility (per-project) @@ -132,9 +146,25 @@ export function BoardHeader({ onCheckedChange={handleWorktreePanelToggle} data-testid="worktrees-toggle" /> + )} + {/* Worktree Settings Dialog */} + + {/* Concurrency Control - only show after mount to prevent hydration issues */} {isMounted && ( @@ -209,15 +239,33 @@ export function BoardHeader({ onSkipVerificationChange={setSkipVerificationInAutoMode} /> - + {/* Plan Button with Settings */} +
+ + +
+ + {/* Plan Settings Dialog */} + ); 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 797b64b9..736f3c40 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 @@ -56,6 +56,32 @@ import { const logger = createLogger('AddFeatureDialog'); +/** + * Determines the default work mode based on global settings and current worktree selection. + * + * Priority: + * 1. If forceCurrentBranchMode is true, always defaults to 'current' (work on current branch) + * 2. If a non-main worktree is selected in the board header, defaults to 'custom' (use that branch) + * 3. If useWorktrees global setting is enabled, defaults to 'auto' (automatic worktree creation) + * 4. Otherwise, defaults to 'current' (work on current branch without isolation) + */ +const getDefaultWorkMode = ( + useWorktrees: boolean, + selectedNonMainWorktreeBranch?: string, + forceCurrentBranchMode?: boolean +): WorkMode => { + // If force current branch mode is enabled (worktree setting is off), always use 'current' + if (forceCurrentBranchMode) { + return 'current'; + } + // If a non-main worktree is selected, default to 'custom' mode with that branch + if (selectedNonMainWorktreeBranch) { + return 'custom'; + } + // Otherwise, respect the global worktree setting + return useWorktrees ? 'auto' : 'current'; +}; + type FeatureData = { title: string; category: string; @@ -89,6 +115,16 @@ interface AddFeatureDialogProps { isMaximized: boolean; parentFeature?: Feature | null; allFeatures?: Feature[]; + /** + * When a non-main worktree is selected in the board header, this will be set to that worktree's branch. + * When set, the dialog will default to 'custom' work mode with this branch pre-filled. + */ + selectedNonMainWorktreeBranch?: string; + /** + * When true, forces the dialog to default to 'current' work mode (work on current branch). + * This is used when the "Use selected worktree branch" setting is disabled. + */ + forceCurrentBranchMode?: boolean; } /** @@ -112,6 +148,8 @@ export function AddFeatureDialog({ isMaximized, parentFeature = null, allFeatures = [], + selectedNonMainWorktreeBranch, + forceCurrentBranchMode, }: AddFeatureDialogProps) { const isSpawnMode = !!parentFeature; const [workMode, setWorkMode] = useState('current'); @@ -149,7 +187,7 @@ export function AddFeatureDialog({ const [selectedAncestorIds, setSelectedAncestorIds] = useState>(new Set()); // Get defaults from store - const { defaultPlanningMode, defaultRequirePlanApproval } = useAppStore(); + const { defaultPlanningMode, defaultRequirePlanApproval, useWorktrees } = useAppStore(); // Track previous open state to detect when dialog opens const wasOpenRef = useRef(false); @@ -161,8 +199,12 @@ export function AddFeatureDialog({ if (justOpened) { setSkipTests(defaultSkipTests); - setBranchName(defaultBranch || ''); - setWorkMode('current'); + // When a non-main worktree is selected, use its branch name for custom mode + // Otherwise, use the default branch + setBranchName(selectedNonMainWorktreeBranch || defaultBranch || ''); + setWorkMode( + getDefaultWorkMode(useWorktrees, selectedNonMainWorktreeBranch, forceCurrentBranchMode) + ); setPlanningMode(defaultPlanningMode); setRequirePlanApproval(defaultRequirePlanApproval); setModelEntry({ model: 'opus' }); @@ -186,6 +228,9 @@ export function AddFeatureDialog({ defaultBranch, defaultPlanningMode, defaultRequirePlanApproval, + useWorktrees, + selectedNonMainWorktreeBranch, + forceCurrentBranchMode, parentFeature, allFeatures, ]); @@ -270,10 +315,13 @@ export function AddFeatureDialog({ setImagePaths([]); setTextFilePaths([]); setSkipTests(defaultSkipTests); - setBranchName(''); + // When a non-main worktree is selected, use its branch name for custom mode + setBranchName(selectedNonMainWorktreeBranch || ''); setPriority(2); setModelEntry({ model: 'opus' }); - setWorkMode('current'); + setWorkMode( + getDefaultWorkMode(useWorktrees, selectedNonMainWorktreeBranch, forceCurrentBranchMode) + ); setPlanningMode(defaultPlanningMode); setRequirePlanApproval(defaultRequirePlanApproval); setPreviewMap(new Map()); diff --git a/apps/ui/src/components/views/board-view/dialogs/backlog-plan-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/backlog-plan-dialog.tsx index 3579a48b..ee78f153 100644 --- a/apps/ui/src/components/views/board-view/dialogs/backlog-plan-dialog.tsx +++ b/apps/ui/src/components/views/board-view/dialogs/backlog-plan-dialog.tsx @@ -63,6 +63,8 @@ interface BacklogPlanDialogProps { setPendingPlanResult: (result: BacklogPlanResult | null) => void; isGeneratingPlan: boolean; setIsGeneratingPlan: (generating: boolean) => void; + // Branch to use for created features (defaults to main if not provided) + currentBranch?: string; } type DialogMode = 'input' | 'review' | 'applying'; @@ -76,6 +78,7 @@ export function BacklogPlanDialog({ setPendingPlanResult, isGeneratingPlan, setIsGeneratingPlan, + currentBranch, }: BacklogPlanDialogProps) { const [mode, setMode] = useState('input'); const [prompt, setPrompt] = useState(''); @@ -167,7 +170,7 @@ export function BacklogPlanDialog({ }) || [], }; - const result = await api.backlogPlan.apply(projectPath, filteredPlanResult); + const result = await api.backlogPlan.apply(projectPath, filteredPlanResult, currentBranch); if (result.success) { toast.success(`Applied ${result.appliedChanges?.length || 0} changes`); setPendingPlanResult(null); @@ -184,6 +187,7 @@ export function BacklogPlanDialog({ setPendingPlanResult, onPlanApplied, onClose, + currentBranch, ]); const handleDiscard = useCallback(() => { diff --git a/apps/ui/src/components/views/board-view/dialogs/create-worktree-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/create-worktree-dialog.tsx index 584ed622..8a675069 100644 --- a/apps/ui/src/components/views/board-view/dialogs/create-worktree-dialog.tsx +++ b/apps/ui/src/components/views/board-view/dialogs/create-worktree-dialog.tsx @@ -10,10 +10,73 @@ import { import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; -import { GitBranch, Loader2 } from 'lucide-react'; +import { GitBranch, Loader2, AlertCircle } from 'lucide-react'; import { getElectronAPI } from '@/lib/electron'; import { toast } from 'sonner'; +/** + * Parse git/worktree error messages and return user-friendly versions + */ +function parseWorktreeError(error: string): { title: string; description?: string } { + const errorLower = error.toLowerCase(); + + // Worktree already exists + if (errorLower.includes('already exists') && errorLower.includes('worktree')) { + return { + title: 'A worktree with this name already exists', + description: 'Try a different branch name or delete the existing worktree first.', + }; + } + + // Branch already checked out in another worktree + if ( + errorLower.includes('already checked out') || + errorLower.includes('is already used by worktree') + ) { + return { + title: 'This branch is already in use', + description: 'The branch is checked out in another worktree. Use a different branch name.', + }; + } + + // Branch name conflicts with existing branch + if (errorLower.includes('already exists') && errorLower.includes('branch')) { + return { + title: 'A branch with this name already exists', + description: 'The worktree will use the existing branch, or try a different name.', + }; + } + + // Not a git repository + if (errorLower.includes('not a git repository')) { + return { + title: 'Not a git repository', + description: 'Initialize git in this project first with "git init".', + }; + } + + // Lock file exists (another git operation in progress) + if (errorLower.includes('.lock') || errorLower.includes('lock file')) { + return { + title: 'Another git operation is in progress', + description: 'Wait for it to complete or remove stale lock files.', + }; + } + + // Permission denied + if (errorLower.includes('permission denied') || errorLower.includes('access denied')) { + return { + title: 'Permission denied', + description: 'Check file permissions for the project directory.', + }; + } + + // Default: return original error but cleaned up + return { + title: error.replace(/^(fatal|error):\s*/i, '').split('\n')[0], + }; +} + interface CreatedWorktreeInfo { path: string; branch: string; @@ -34,20 +97,21 @@ export function CreateWorktreeDialog({ }: CreateWorktreeDialogProps) { const [branchName, setBranchName] = useState(''); const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); + const [error, setError] = useState<{ title: string; description?: string } | null>(null); const handleCreate = async () => { if (!branchName.trim()) { - setError('Branch name is required'); + setError({ title: 'Branch name is required' }); return; } // Validate branch name (git-compatible) const validBranchRegex = /^[a-zA-Z0-9._/-]+$/; if (!validBranchRegex.test(branchName)) { - setError( - 'Invalid branch name. Use only letters, numbers, dots, underscores, hyphens, and slashes.' - ); + setError({ + title: 'Invalid branch name', + description: 'Use only letters, numbers, dots, underscores, hyphens, and slashes.', + }); return; } @@ -57,7 +121,7 @@ export function CreateWorktreeDialog({ try { const api = getElectronAPI(); if (!api?.worktree?.create) { - setError('Worktree API not available'); + setError({ title: 'Worktree API not available' }); return; } const result = await api.worktree.create(projectPath, branchName); @@ -70,10 +134,12 @@ export function CreateWorktreeDialog({ onOpenChange(false); setBranchName(''); } else { - setError(result.error || 'Failed to create worktree'); + setError(parseWorktreeError(result.error || 'Failed to create worktree')); } } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to create worktree'); + setError( + parseWorktreeError(err instanceof Error ? err.message : 'Failed to create worktree') + ); } finally { setIsLoading(false); } @@ -114,7 +180,17 @@ export function CreateWorktreeDialog({ className="font-mono text-sm" autoFocus /> - {error &&

{error}

} + {error && ( +
+ +
+

{error.title}

+ {error.description && ( +

{error.description}

+ )} +
+
+ )}
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 3c45c014..718bef0c 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, @@ -30,6 +30,8 @@ interface DeleteWorktreeDialogProps { onDeleted: (deletedWorktree: WorktreeInfo, deletedBranch: boolean) => void; /** Number of features assigned to this worktree's branch */ affectedFeatureCount?: number; + /** Default value for the "delete branch" checkbox */ + defaultDeleteBranch?: boolean; } export function DeleteWorktreeDialog({ @@ -39,10 +41,18 @@ export function DeleteWorktreeDialog({ worktree, onDeleted, affectedFeatureCount = 0, + defaultDeleteBranch = false, }: DeleteWorktreeDialogProps) { - const [deleteBranch, setDeleteBranch] = useState(false); + const [deleteBranch, setDeleteBranch] = useState(defaultDeleteBranch); const [isLoading, setIsLoading] = useState(false); + // Reset deleteBranch to default when dialog opens + useEffect(() => { + if (open) { + setDeleteBranch(defaultDeleteBranch); + } + }, [open, defaultDeleteBranch]); + const handleDelete = async () => { if (!worktree) return; diff --git a/apps/ui/src/components/views/board-view/dialogs/plan-settings-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/plan-settings-dialog.tsx new file mode 100644 index 00000000..bd42cb1a --- /dev/null +++ b/apps/ui/src/components/views/board-view/dialogs/plan-settings-dialog.tsx @@ -0,0 +1,67 @@ +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; +import { GitBranch, Settings2 } from 'lucide-react'; + +interface PlanSettingsDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + planUseSelectedWorktreeBranch: boolean; + onPlanUseSelectedWorktreeBranchChange: (value: boolean) => void; +} + +export function PlanSettingsDialog({ + open, + onOpenChange, + planUseSelectedWorktreeBranch, + onPlanUseSelectedWorktreeBranchChange, +}: PlanSettingsDialogProps) { + return ( + + + + + + Plan Settings + + + Configure how the Plan feature creates and organizes new features. + + + +
+ {/* Use Selected Worktree Branch Setting */} +
+
+
+ + +
+

+ When enabled, features created via the Plan dialog will be assigned to the currently + selected worktree branch. When disabled, features will be added to the main branch. +

+
+
+
+
+
+ ); +} diff --git a/apps/ui/src/components/views/board-view/dialogs/worktree-settings-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/worktree-settings-dialog.tsx new file mode 100644 index 00000000..da7cb134 --- /dev/null +++ b/apps/ui/src/components/views/board-view/dialogs/worktree-settings-dialog.tsx @@ -0,0 +1,67 @@ +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; +import { GitBranch, Settings2 } from 'lucide-react'; + +interface WorktreeSettingsDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + addFeatureUseSelectedWorktreeBranch: boolean; + onAddFeatureUseSelectedWorktreeBranchChange: (value: boolean) => void; +} + +export function WorktreeSettingsDialog({ + open, + onOpenChange, + addFeatureUseSelectedWorktreeBranch, + onAddFeatureUseSelectedWorktreeBranchChange, +}: WorktreeSettingsDialogProps) { + return ( + + + + + + Worktree Settings + + + Configure how worktrees affect feature creation and organization. + + + +
+ {/* Use Selected Worktree Branch Setting */} +
+
+
+ + +
+

+ When enabled, the Add Feature dialog will default to custom branch mode with the + currently selected worktree branch pre-filled. +

+
+
+
+
+
+ ); +} diff --git a/apps/ui/src/components/views/board-view/init-script-indicator.tsx b/apps/ui/src/components/views/board-view/init-script-indicator.tsx new file mode 100644 index 00000000..33298394 --- /dev/null +++ b/apps/ui/src/components/views/board-view/init-script-indicator.tsx @@ -0,0 +1,209 @@ +import { useState, useRef, useEffect, useCallback } from 'react'; +import { Terminal, Check, X, Loader2, ChevronDown, ChevronUp } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { useAppStore, type InitScriptState } from '@/store/app-store'; +import { AnsiOutput } from '@/components/ui/ansi-output'; + +interface InitScriptIndicatorProps { + projectPath: string; +} + +interface SingleIndicatorProps { + stateKey: string; + state: InitScriptState; + onDismiss: (key: string) => void; + isOnlyOne: boolean; // Whether this is the only indicator shown + autoDismiss: boolean; // Whether to auto-dismiss after completion +} + +function SingleIndicator({ + stateKey, + state, + onDismiss, + isOnlyOne, + autoDismiss, +}: SingleIndicatorProps) { + const [showLogs, setShowLogs] = useState(false); + const logsEndRef = useRef(null); + + const { status, output, branch, error } = state; + + // Auto-scroll to bottom when new output arrives + useEffect(() => { + if (showLogs && logsEndRef.current) { + logsEndRef.current.scrollIntoView({ behavior: 'smooth' }); + } + }, [output, showLogs]); + + // Auto-expand logs when script starts (only if it's the only one or running) + useEffect(() => { + if (status === 'running' && isOnlyOne) { + setShowLogs(true); + } + }, [status, isOnlyOne]); + + // Auto-dismiss after completion (5 seconds) + useEffect(() => { + if (autoDismiss && (status === 'success' || status === 'failed')) { + const timer = setTimeout(() => { + onDismiss(stateKey); + }, 5000); + return () => clearTimeout(timer); + } + }, [status, autoDismiss, stateKey, onDismiss]); + + if (status === 'idle') return null; + + return ( +
+ {/* Header */} +
+
+ {status === 'running' && } + {status === 'success' && } + {status === 'failed' && } + + Init Script{' '} + {status === 'running' ? 'Running' : status === 'success' ? 'Completed' : 'Failed'} + +
+
+ + {status !== 'running' && ( + + )} +
+
+ + {/* Branch info */} +
+ + Branch: {branch} +
+ + {/* Logs (collapsible) */} + {showLogs && ( +
+
+ {output.length > 0 ? ( + + ) : ( +
+ {status === 'running' ? 'Waiting for output...' : 'No output'} +
+ )} + {error &&
Error: {error}
} +
+
+
+ )} + + {/* Status bar for completed states */} + {status !== 'running' && ( +
+ {status === 'success' + ? 'Initialization completed successfully' + : 'Initialization failed - worktree is still usable'} +
+ )} +
+ ); +} + +export function InitScriptIndicator({ projectPath }: InitScriptIndicatorProps) { + const getInitScriptStatesForProject = useAppStore((s) => s.getInitScriptStatesForProject); + const clearInitScriptState = useAppStore((s) => s.clearInitScriptState); + const getAutoDismissInitScriptIndicator = useAppStore((s) => s.getAutoDismissInitScriptIndicator); + const [dismissedKeys, setDismissedKeys] = useState>(new Set()); + + // Get auto-dismiss setting + const autoDismiss = getAutoDismissInitScriptIndicator(projectPath); + + // Get all init script states for this project + const allStates = getInitScriptStatesForProject(projectPath); + + // Filter out dismissed and idle states + const activeStates = allStates.filter( + ({ key, state }) => !dismissedKeys.has(key) && state.status !== 'idle' + ); + + // Reset dismissed keys when a new script starts for a branch + useEffect(() => { + const runningKeys = allStates + .filter(({ state }) => state.status === 'running') + .map(({ key }) => key); + + if (runningKeys.length > 0) { + setDismissedKeys((prev) => { + const newSet = new Set(prev); + runningKeys.forEach((key) => newSet.delete(key)); + return newSet; + }); + } + }, [allStates]); + + const handleDismiss = useCallback( + (key: string) => { + setDismissedKeys((prev) => new Set(prev).add(key)); + // Extract branch from key (format: "projectPath::branch") + const branch = key.split('::')[1]; + if (branch) { + // Clear state after a delay to allow for future scripts + setTimeout(() => { + clearInitScriptState(projectPath, branch); + }, 100); + } + }, + [projectPath, clearInitScriptState] + ); + + if (activeStates.length === 0) return null; + + return ( +
+ {activeStates.map(({ key, state }) => ( + + ))} +
+ ); +} diff --git a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx index b94faed7..c7d8f26b 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx @@ -23,6 +23,7 @@ import { MessageSquare, GitMerge, AlertCircle, + RefreshCw, Copy, } from 'lucide-react'; import { toast } from 'sonner'; @@ -55,6 +56,8 @@ interface WorktreeActionsDropdownProps { onStartDevServer: (worktree: WorktreeInfo) => void; onStopDevServer: (worktree: WorktreeInfo) => void; onOpenDevServerUrl: (worktree: WorktreeInfo) => void; + onRunInitScript: (worktree: WorktreeInfo) => void; + hasInitScript: boolean; } export function WorktreeActionsDropdown({ @@ -80,6 +83,8 @@ export function WorktreeActionsDropdown({ onStartDevServer, onStopDevServer, onOpenDevServerUrl, + onRunInitScript, + hasInitScript, }: WorktreeActionsDropdownProps) { // Get available editors for the "Open In" submenu const { editors } = useAvailableEditors(); @@ -266,6 +271,12 @@ export function WorktreeActionsDropdown({ )} + {!worktree.isMain && hasInitScript && ( + onRunInitScript(worktree)} className="text-xs"> + + Re-run Init Script + + )} {worktree.hasChanges && ( void; onStopDevServer: (worktree: WorktreeInfo) => void; onOpenDevServerUrl: (worktree: WorktreeInfo) => void; + onRunInitScript: (worktree: WorktreeInfo) => void; + hasInitScript: boolean; } export function WorktreeTab({ @@ -85,6 +87,8 @@ export function WorktreeTab({ onStartDevServer, onStopDevServer, onOpenDevServerUrl, + onRunInitScript, + hasInitScript, }: WorktreeTabProps) { let prBadge: JSX.Element | null = null; if (worktree.pr) { @@ -333,6 +337,8 @@ export function WorktreeTab({ onStartDevServer={onStartDevServer} onStopDevServer={onStopDevServer} onOpenDevServerUrl={onOpenDevServerUrl} + onRunInitScript={onRunInitScript} + hasInitScript={hasInitScript} />
); 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 034f9d2a..a9f2431e 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 @@ -1,7 +1,9 @@ -import { useEffect, useRef } from 'react'; +import { useEffect, useRef, useCallback, useState } from 'react'; import { Button } from '@/components/ui/button'; import { GitBranch, Plus, RefreshCw } from 'lucide-react'; import { cn, pathsEqual } from '@/lib/utils'; +import { toast } from 'sonner'; +import { getHttpApiClient } from '@/lib/http-api-client'; import type { WorktreePanelProps, WorktreeInfo } from './types'; import { useWorktrees, @@ -79,6 +81,28 @@ export function WorktreePanel({ features, }); + // Track whether init script exists for the project + const [hasInitScript, setHasInitScript] = useState(false); + + useEffect(() => { + if (!projectPath) { + setHasInitScript(false); + return; + } + + const checkInitScript = async () => { + try { + const api = getHttpApiClient(); + const result = await api.worktree.getInitScript(projectPath); + setHasInitScript(result.success && result.exists); + } catch { + setHasInitScript(false); + } + }; + + checkInitScript(); + }, [projectPath]); + // Periodic interval check (5 seconds) to detect branch changes on disk // Reduced from 1s to 5s to minimize GPU/CPU usage from frequent re-renders const intervalRef = useRef(null); @@ -113,6 +137,33 @@ export function WorktreePanel({ } }; + const handleRunInitScript = useCallback( + async (worktree: WorktreeInfo) => { + if (!projectPath) return; + + try { + const api = getHttpApiClient(); + const result = await api.worktree.runInitScript( + projectPath, + worktree.path, + worktree.branch + ); + + if (!result.success) { + toast.error('Failed to run init script', { + description: result.error, + }); + } + // Success feedback will come via WebSocket events (init-started, init-output, init-completed) + } catch (error) { + toast.error('Failed to run init script', { + description: error instanceof Error ? error.message : 'Unknown error', + }); + } + }, + [projectPath] + ); + const mainWorktree = worktrees.find((w) => w.isMain); const nonMainWorktrees = worktrees.filter((w) => !w.isMain); @@ -162,6 +213,8 @@ export function WorktreePanel({ onStartDevServer={handleStartDevServer} onStopDevServer={handleStopDevServer} onOpenDevServerUrl={handleOpenDevServerUrl} + onRunInitScript={handleRunInitScript} + hasInitScript={hasInitScript} /> )}
@@ -216,6 +269,8 @@ export function WorktreePanel({ onStartDevServer={handleStartDevServer} onStopDevServer={handleStopDevServer} onOpenDevServerUrl={handleOpenDevServerUrl} + onRunInitScript={handleRunInitScript} + hasInitScript={hasInitScript} /> ); })} diff --git a/apps/ui/src/components/views/settings-view.tsx b/apps/ui/src/components/views/settings-view.tsx index 27cc7703..aa6a8a84 100644 --- a/apps/ui/src/components/views/settings-view.tsx +++ b/apps/ui/src/components/views/settings-view.tsx @@ -15,6 +15,7 @@ import { TerminalSection } from './settings-view/terminal/terminal-section'; import { AudioSection } from './settings-view/audio/audio-section'; import { KeyboardShortcutsSection } from './settings-view/keyboard-shortcuts/keyboard-shortcuts-section'; import { FeatureDefaultsSection } from './settings-view/feature-defaults/feature-defaults-section'; +import { WorktreesSection } from './settings-view/worktrees'; import { DangerZoneSection } from './settings-view/danger-zone/danger-zone-section'; import { AccountSection } from './settings-view/account'; import { SecuritySection } from './settings-view/security'; @@ -149,17 +150,19 @@ export function SettingsView() { defaultSkipTests={defaultSkipTests} enableDependencyBlocking={enableDependencyBlocking} skipVerificationInAutoMode={skipVerificationInAutoMode} - useWorktrees={useWorktrees} defaultPlanningMode={defaultPlanningMode} defaultRequirePlanApproval={defaultRequirePlanApproval} onDefaultSkipTestsChange={setDefaultSkipTests} onEnableDependencyBlockingChange={setEnableDependencyBlocking} onSkipVerificationInAutoModeChange={setSkipVerificationInAutoMode} - onUseWorktreesChange={setUseWorktrees} onDefaultPlanningModeChange={setDefaultPlanningMode} onDefaultRequirePlanApprovalChange={setDefaultRequirePlanApproval} /> ); + case 'worktrees': + return ( + + ); case 'account': return ; case 'security': diff --git a/apps/ui/src/components/views/settings-view/config/navigation.ts b/apps/ui/src/components/views/settings-view/config/navigation.ts index f7f2b9f6..f63d0494 100644 --- a/apps/ui/src/components/views/settings-view/config/navigation.ts +++ b/apps/ui/src/components/views/settings-view/config/navigation.ts @@ -14,6 +14,8 @@ import { MessageSquareText, User, Shield, + Cpu, + GitBranch, } from 'lucide-react'; import { AnthropicIcon, CursorIcon, OpenAIIcon, OpenCodeIcon } from '@/components/ui/provider-icon'; import type { SettingsViewId } from '../hooks/use-settings-view'; @@ -37,6 +39,7 @@ export const GLOBAL_NAV_GROUPS: NavigationGroup[] = [ items: [ { id: 'model-defaults', label: 'Model Defaults', icon: Workflow }, { id: 'defaults', label: 'Feature Defaults', icon: FlaskConical }, + { id: 'worktrees', label: 'Worktrees', icon: GitBranch }, { id: 'prompts', label: 'Prompt Customization', icon: MessageSquareText }, { id: 'api-keys', label: 'API Keys', icon: Key }, { diff --git a/apps/ui/src/components/views/settings-view/feature-defaults/feature-defaults-section.tsx b/apps/ui/src/components/views/settings-view/feature-defaults/feature-defaults-section.tsx index a0f9ab36..c3b4e9ae 100644 --- a/apps/ui/src/components/views/settings-view/feature-defaults/feature-defaults-section.tsx +++ b/apps/ui/src/components/views/settings-view/feature-defaults/feature-defaults-section.tsx @@ -3,7 +3,6 @@ import { Checkbox } from '@/components/ui/checkbox'; import { FlaskConical, TestTube, - GitBranch, AlertCircle, Zap, ClipboardList, @@ -27,13 +26,11 @@ interface FeatureDefaultsSectionProps { defaultSkipTests: boolean; enableDependencyBlocking: boolean; skipVerificationInAutoMode: boolean; - useWorktrees: boolean; defaultPlanningMode: PlanningMode; defaultRequirePlanApproval: boolean; onDefaultSkipTestsChange: (value: boolean) => void; onEnableDependencyBlockingChange: (value: boolean) => void; onSkipVerificationInAutoModeChange: (value: boolean) => void; - onUseWorktreesChange: (value: boolean) => void; onDefaultPlanningModeChange: (value: PlanningMode) => void; onDefaultRequirePlanApprovalChange: (value: boolean) => void; } @@ -42,13 +39,11 @@ export function FeatureDefaultsSection({ defaultSkipTests, enableDependencyBlocking, skipVerificationInAutoMode, - useWorktrees, defaultPlanningMode, defaultRequirePlanApproval, onDefaultSkipTestsChange, onEnableDependencyBlockingChange, onSkipVerificationInAutoModeChange, - onUseWorktreesChange, onDefaultPlanningModeChange, onDefaultRequirePlanApprovalChange, }: FeatureDefaultsSectionProps) { @@ -256,33 +251,6 @@ export function FeatureDefaultsSection({

- - {/* Separator */} -
- - {/* Worktree Isolation Setting */} -
- onUseWorktreesChange(checked === true)} - className="mt-1" - data-testid="use-worktrees-checkbox" - /> -
- -

- Creates isolated git branches for each feature. When disabled, agents work directly in - the main project directory. -

-
-
); diff --git a/apps/ui/src/components/views/settings-view/hooks/use-settings-view.ts b/apps/ui/src/components/views/settings-view/hooks/use-settings-view.ts index 1dc0208f..29d29ea2 100644 --- a/apps/ui/src/components/views/settings-view/hooks/use-settings-view.ts +++ b/apps/ui/src/components/views/settings-view/hooks/use-settings-view.ts @@ -16,6 +16,7 @@ export type SettingsViewId = | 'keyboard' | 'audio' | 'defaults' + | 'worktrees' | 'account' | 'security' | 'danger'; diff --git a/apps/ui/src/components/views/settings-view/worktrees/index.ts b/apps/ui/src/components/views/settings-view/worktrees/index.ts new file mode 100644 index 00000000..a240bd72 --- /dev/null +++ b/apps/ui/src/components/views/settings-view/worktrees/index.ts @@ -0,0 +1 @@ +export { WorktreesSection } from './worktrees-section'; diff --git a/apps/ui/src/components/views/settings-view/worktrees/worktrees-section.tsx b/apps/ui/src/components/views/settings-view/worktrees/worktrees-section.tsx new file mode 100644 index 00000000..2d232a65 --- /dev/null +++ b/apps/ui/src/components/views/settings-view/worktrees/worktrees-section.tsx @@ -0,0 +1,430 @@ +import { useState, useEffect, useCallback } from 'react'; +import { Label } from '@/components/ui/label'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Button } from '@/components/ui/button'; +import { ShellSyntaxEditor } from '@/components/ui/shell-syntax-editor'; +import { + GitBranch, + Terminal, + FileCode, + Save, + RotateCcw, + Trash2, + Loader2, + PanelBottomClose, +} from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { apiGet, apiPut, apiDelete } from '@/lib/api-fetch'; +import { toast } from 'sonner'; +import { useAppStore } from '@/store/app-store'; +import { getHttpApiClient } from '@/lib/http-api-client'; + +interface WorktreesSectionProps { + useWorktrees: boolean; + onUseWorktreesChange: (value: boolean) => void; +} + +interface InitScriptResponse { + success: boolean; + exists: boolean; + content: string; + path: string; + error?: string; +} + +export function WorktreesSection({ useWorktrees, onUseWorktreesChange }: WorktreesSectionProps) { + const currentProject = useAppStore((s) => s.currentProject); + const getShowInitScriptIndicator = useAppStore((s) => s.getShowInitScriptIndicator); + const setShowInitScriptIndicator = useAppStore((s) => s.setShowInitScriptIndicator); + const getDefaultDeleteBranch = useAppStore((s) => s.getDefaultDeleteBranch); + const setDefaultDeleteBranch = useAppStore((s) => s.setDefaultDeleteBranch); + const getAutoDismissInitScriptIndicator = useAppStore((s) => s.getAutoDismissInitScriptIndicator); + const setAutoDismissInitScriptIndicator = useAppStore((s) => s.setAutoDismissInitScriptIndicator); + const [scriptContent, setScriptContent] = useState(''); + const [originalContent, setOriginalContent] = useState(''); + const [scriptExists, setScriptExists] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + + // Get the current show indicator setting + const showIndicator = currentProject?.path + ? getShowInitScriptIndicator(currentProject.path) + : true; + + // Get the default delete branch setting + const defaultDeleteBranch = currentProject?.path + ? getDefaultDeleteBranch(currentProject.path) + : false; + + // Get the auto-dismiss setting + const autoDismiss = currentProject?.path + ? getAutoDismissInitScriptIndicator(currentProject.path) + : true; + + // Check if there are unsaved changes + const hasChanges = scriptContent !== originalContent; + + // Load init script content when project changes + useEffect(() => { + if (!currentProject?.path) { + setScriptContent(''); + setOriginalContent(''); + setScriptExists(false); + setIsLoading(false); + return; + } + + const loadInitScript = async () => { + setIsLoading(true); + try { + const response = await apiGet( + `/api/worktree/init-script?projectPath=${encodeURIComponent(currentProject.path)}` + ); + if (response.success) { + const content = response.content || ''; + setScriptContent(content); + setOriginalContent(content); + setScriptExists(response.exists); + } + } catch (error) { + console.error('Failed to load init script:', error); + } finally { + setIsLoading(false); + } + }; + + loadInitScript(); + }, [currentProject?.path]); + + // Save script + const handleSave = useCallback(async () => { + if (!currentProject?.path) return; + + setIsSaving(true); + try { + const response = await apiPut<{ success: boolean; error?: string }>( + '/api/worktree/init-script', + { + projectPath: currentProject.path, + content: scriptContent, + } + ); + if (response.success) { + setOriginalContent(scriptContent); + setScriptExists(true); + toast.success('Init script saved'); + } else { + toast.error('Failed to save init script', { + description: response.error, + }); + } + } catch (error) { + console.error('Failed to save init script:', error); + toast.error('Failed to save init script'); + } finally { + setIsSaving(false); + } + }, [currentProject?.path, scriptContent]); + + // Reset to original content + const handleReset = useCallback(() => { + setScriptContent(originalContent); + }, [originalContent]); + + // Delete script + const handleDelete = useCallback(async () => { + if (!currentProject?.path) return; + + setIsDeleting(true); + try { + const response = await apiDelete<{ success: boolean; error?: string }>( + '/api/worktree/init-script', + { + body: { projectPath: currentProject.path }, + } + ); + if (response.success) { + setScriptContent(''); + setOriginalContent(''); + setScriptExists(false); + toast.success('Init script deleted'); + } else { + toast.error('Failed to delete init script', { + description: response.error, + }); + } + } catch (error) { + console.error('Failed to delete init script:', error); + toast.error('Failed to delete init script'); + } finally { + setIsDeleting(false); + } + }, [currentProject?.path]); + + // Handle content change (no auto-save) + const handleContentChange = useCallback((value: string) => { + setScriptContent(value); + }, []); + + return ( +
+
+
+
+ +
+

Worktrees

+
+

+ Configure git worktree isolation and initialization scripts. +

+
+
+ {/* Enable Worktrees Toggle */} +
+ onUseWorktreesChange(checked === true)} + className="mt-1" + data-testid="use-worktrees-checkbox" + /> +
+ +

+ Creates isolated git branches for each feature. When disabled, agents work directly in + the main project directory. +

+
+
+ + {/* Show Init Script Indicator Toggle */} + {currentProject && ( +
+ { + if (currentProject?.path) { + const value = checked === true; + setShowInitScriptIndicator(currentProject.path, value); + // Persist to server + try { + const httpClient = getHttpApiClient(); + await httpClient.settings.updateProject(currentProject.path, { + showInitScriptIndicator: value, + }); + } catch (error) { + console.error('Failed to persist showInitScriptIndicator:', error); + } + } + }} + className="mt-1" + /> +
+ +

+ Display a floating panel in the bottom-right corner showing init script execution + status and output when a worktree is created. +

+
+
+ )} + + {/* Auto-dismiss Init Script Indicator Toggle */} + {currentProject && showIndicator && ( +
+ { + if (currentProject?.path) { + const value = checked === true; + setAutoDismissInitScriptIndicator(currentProject.path, value); + // Persist to server + try { + const httpClient = getHttpApiClient(); + await httpClient.settings.updateProject(currentProject.path, { + autoDismissInitScriptIndicator: value, + }); + } catch (error) { + console.error('Failed to persist autoDismissInitScriptIndicator:', error); + } + } + }} + className="mt-1" + /> +
+ +

+ Automatically hide the indicator 5 seconds after the script completes. +

+
+
+ )} + + {/* Default Delete Branch Toggle */} + {currentProject && ( +
+ { + if (currentProject?.path) { + const value = checked === true; + setDefaultDeleteBranch(currentProject.path, value); + // Persist to server + try { + const httpClient = getHttpApiClient(); + await httpClient.settings.updateProject(currentProject.path, { + defaultDeleteBranch: value, + }); + } catch (error) { + console.error('Failed to persist defaultDeleteBranch:', error); + } + } + }} + className="mt-1" + /> +
+ +

+ When deleting a worktree, automatically check the "Also delete the branch" option. +

+
+
+ )} + + {/* Separator */} +
+ + {/* Init Script Section */} +
+
+
+ + +
+
+

+ Shell commands to run after a worktree is created. Runs once per worktree. Uses Git Bash + on Windows for cross-platform compatibility. +

+ + {currentProject ? ( + <> + {/* File path indicator */} +
+ + .automaker/worktree-init.sh + {hasChanges && ( + (unsaved changes) + )} +
+ + {isLoading ? ( +
+ +
+ ) : ( + <> + + + {/* Action buttons */} +
+ + + +
+ + )} + + ) : ( +
+ Select a project to configure the init script. +
+ )} +
+
+
+ ); +} diff --git a/apps/ui/src/hooks/use-init-script-events.ts b/apps/ui/src/hooks/use-init-script-events.ts new file mode 100644 index 00000000..aa51409f --- /dev/null +++ b/apps/ui/src/hooks/use-init-script-events.ts @@ -0,0 +1,79 @@ +import { useEffect } from 'react'; +import { useAppStore } from '@/store/app-store'; +import { getHttpApiClient } from '@/lib/http-api-client'; +import { pathsEqual } from '@/lib/utils'; + +interface InitScriptStartedPayload { + projectPath: string; + worktreePath: string; + branch: string; +} + +interface InitScriptOutputPayload { + projectPath: string; + branch: string; + type: 'stdout' | 'stderr'; + content: string; +} + +interface InitScriptCompletedPayload { + projectPath: string; + worktreePath: string; + branch: string; + success: boolean; + exitCode?: number; + error?: string; +} + +/** + * Hook to subscribe to init script WebSocket events and update the store. + * Should be used in a component that's always mounted (e.g., board-view). + */ +export function useInitScriptEvents(projectPath: string | null) { + const setInitScriptState = useAppStore((s) => s.setInitScriptState); + const appendInitScriptOutput = useAppStore((s) => s.appendInitScriptOutput); + + useEffect(() => { + if (!projectPath) return; + + const api = getHttpApiClient(); + + const unsubscribe = api.worktree.onInitScriptEvent((event) => { + const payload = event.payload as + | InitScriptStartedPayload + | InitScriptOutputPayload + | InitScriptCompletedPayload; + + // Only handle events for the current project (use pathsEqual for cross-platform path comparison) + if (!pathsEqual(payload.projectPath, projectPath)) return; + + switch (event.type) { + case 'worktree:init-started': { + const startPayload = payload as InitScriptStartedPayload; + setInitScriptState(projectPath, startPayload.branch, { + status: 'running', + branch: startPayload.branch, + output: [], + error: undefined, + }); + break; + } + case 'worktree:init-output': { + const outputPayload = payload as InitScriptOutputPayload; + appendInitScriptOutput(projectPath, outputPayload.branch, outputPayload.content); + break; + } + case 'worktree:init-completed': { + const completePayload = payload as InitScriptCompletedPayload; + setInitScriptState(projectPath, completePayload.branch, { + status: completePayload.success ? 'success' : 'failed', + error: completePayload.error, + }); + break; + } + } + }); + + return unsubscribe; + }, [projectPath, setInitScriptState, appendInitScriptOutput]); +} diff --git a/apps/ui/src/hooks/use-project-settings-loader.ts b/apps/ui/src/hooks/use-project-settings-loader.ts index 62784f5f..4da50473 100644 --- a/apps/ui/src/hooks/use-project-settings-loader.ts +++ b/apps/ui/src/hooks/use-project-settings-loader.ts @@ -18,6 +18,11 @@ export function useProjectSettingsLoader() { const setCardBorderOpacity = useAppStore((state) => state.setCardBorderOpacity); const setHideScrollbar = useAppStore((state) => state.setHideScrollbar); const setWorktreePanelVisible = useAppStore((state) => state.setWorktreePanelVisible); + const setShowInitScriptIndicator = useAppStore((state) => state.setShowInitScriptIndicator); + const setDefaultDeleteBranch = useAppStore((state) => state.setDefaultDeleteBranch); + const setAutoDismissInitScriptIndicator = useAppStore( + (state) => state.setAutoDismissInitScriptIndicator + ); const loadingRef = useRef(null); const currentProjectRef = useRef(null); @@ -78,6 +83,27 @@ export function useProjectSettingsLoader() { if (result.settings.worktreePanelVisible !== undefined) { setWorktreePanelVisible(requestedProjectPath, result.settings.worktreePanelVisible); } + + // Apply showInitScriptIndicator if present + if (result.settings.showInitScriptIndicator !== undefined) { + setShowInitScriptIndicator( + requestedProjectPath, + result.settings.showInitScriptIndicator + ); + } + + // Apply defaultDeleteBranch if present + if (result.settings.defaultDeleteBranch !== undefined) { + setDefaultDeleteBranch(requestedProjectPath, result.settings.defaultDeleteBranch); + } + + // Apply autoDismissInitScriptIndicator if present + if (result.settings.autoDismissInitScriptIndicator !== undefined) { + setAutoDismissInitScriptIndicator( + requestedProjectPath, + result.settings.autoDismissInitScriptIndicator + ); + } } } catch (error) { console.error('Failed to load project settings:', error); diff --git a/apps/ui/src/lib/electron.ts b/apps/ui/src/lib/electron.ts index c93eba79..29c8aa2e 100644 --- a/apps/ui/src/lib/electron.ts +++ b/apps/ui/src/lib/electron.ts @@ -651,7 +651,8 @@ export interface ElectronAPI { removedDependencies: string[]; addedDependencies: string[]; }>; - } + }, + branchName?: string ) => Promise<{ success: boolean; appliedChanges?: string[]; error?: string }>; onEvent: (callback: (data: unknown) => void) => () => void; }; @@ -1769,6 +1770,47 @@ function createMockWorktreeAPI(): WorktreeAPI { }, }; }, + + getInitScript: async (projectPath: string) => { + console.log('[Mock] Getting init script:', { projectPath }); + return { + success: true, + exists: false, + content: '', + path: `${projectPath}/.automaker/worktree-init.sh`, + }; + }, + + setInitScript: async (projectPath: string, content: string) => { + console.log('[Mock] Setting init script:', { projectPath, content }); + return { + success: true, + path: `${projectPath}/.automaker/worktree-init.sh`, + }; + }, + + deleteInitScript: async (projectPath: string) => { + console.log('[Mock] Deleting init script:', { projectPath }); + return { + success: true, + }; + }, + + runInitScript: async (projectPath: string, worktreePath: string, branch: string) => { + console.log('[Mock] Running init script:', { projectPath, worktreePath, branch }); + return { + success: true, + message: 'Init script started (mock)', + }; + }, + + onInitScriptEvent: (callback) => { + console.log('[Mock] Subscribing to init script events'); + // Return unsubscribe function + return () => { + console.log('[Mock] Unsubscribing from init script events'); + }; + }, }; } diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index 62b0c734..d3b71a74 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -507,7 +507,10 @@ type EventType = | 'issue-validation:event' | 'backlog-plan:event' | 'ideation:stream' - | 'ideation:analysis'; + | 'ideation:analysis' + | 'worktree:init-started' + | 'worktree:init-output' + | 'worktree:init-completed'; type EventCallback = (payload: unknown) => void; @@ -846,13 +849,14 @@ export class HttpApiClient implements ElectronAPI { return response.json(); } - private async httpDelete(endpoint: string): Promise { + private async httpDelete(endpoint: string, body?: unknown): Promise { // Ensure API key is initialized before making request await waitForApiKeyInit(); const response = await fetch(`${this.serverUrl}${endpoint}`, { method: 'DELETE', headers: this.getHeaders(), credentials: 'include', // Include cookies for session auth + body: body ? JSON.stringify(body) : undefined, }); if (response.status === 401 || response.status === 403) { @@ -1647,6 +1651,37 @@ export class HttpApiClient implements ElectronAPI { listDevServers: () => this.post('/api/worktree/list-dev-servers', {}), getPRInfo: (worktreePath: string, branchName: string) => this.post('/api/worktree/pr-info', { worktreePath, branchName }), + // Init script methods + getInitScript: (projectPath: string) => + this.get(`/api/worktree/init-script?projectPath=${encodeURIComponent(projectPath)}`), + setInitScript: (projectPath: string, content: string) => + this.put('/api/worktree/init-script', { projectPath, content }), + deleteInitScript: (projectPath: string) => + this.httpDelete('/api/worktree/init-script', { projectPath }), + runInitScript: (projectPath: string, worktreePath: string, branch: string) => + this.post('/api/worktree/run-init-script', { projectPath, worktreePath, branch }), + onInitScriptEvent: ( + callback: (event: { + type: 'worktree:init-started' | 'worktree:init-output' | 'worktree:init-completed'; + payload: unknown; + }) => void + ) => { + // Note: subscribeToEvent callback receives (payload) not (_, payload) + const unsub1 = this.subscribeToEvent('worktree:init-started', (payload) => + callback({ type: 'worktree:init-started', payload }) + ); + const unsub2 = this.subscribeToEvent('worktree:init-output', (payload) => + callback({ type: 'worktree:init-output', payload }) + ); + const unsub3 = this.subscribeToEvent('worktree:init-completed', (payload) => + callback({ type: 'worktree:init-completed', payload }) + ); + return () => { + unsub1(); + unsub2(); + unsub3(); + }; + }, }; // Git API @@ -2167,9 +2202,10 @@ export class HttpApiClient implements ElectronAPI { removedDependencies: string[]; addedDependencies: string[]; }>; - } + }, + branchName?: string ): Promise<{ success: boolean; appliedChanges?: string[]; error?: string }> => - this.post('/api/backlog-plan/apply', { projectPath, plan }), + this.post('/api/backlog-plan/apply', { projectPath, plan, branchName }), onEvent: (callback: (data: unknown) => void): (() => void) => { return this.subscribeToEvent('backlog-plan:event', callback as EventCallback); diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index 4b9e319c..e9593f3c 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -80,6 +80,9 @@ export type ThemeMode = // LocalStorage key for theme persistence (fallback when server settings aren't available) export const THEME_STORAGE_KEY = 'automaker:theme'; +// Maximum number of output lines to keep in init script state (prevents unbounded memory growth) +export const MAX_INIT_OUTPUT_LINES = 500; + /** * Get the theme from localStorage as a fallback * Used before server settings are loaded (e.g., on login/setup pages) @@ -469,6 +472,14 @@ export interface PersistedTerminalSettings { maxSessions: number; } +/** State for worktree init script execution */ +export interface InitScriptState { + status: 'idle' | 'running' | 'success' | 'failed'; + branch: string; + output: string[]; + error?: string; +} + export interface AppState { // Project state projects: Project[]; @@ -522,6 +533,8 @@ export interface AppState { defaultSkipTests: boolean; // Default value for skip tests when creating new features enableDependencyBlocking: boolean; // When true, show blocked badges and warnings for features with incomplete dependencies (default: true) skipVerificationInAutoMode: boolean; // When true, auto-mode grabs features even if dependencies are not verified (only checks they're not running) + planUseSelectedWorktreeBranch: boolean; // When true, Plan dialog creates features on the currently selected worktree branch + addFeatureUseSelectedWorktreeBranch: boolean; // When true, Add Feature dialog defaults to custom mode with selected worktree branch // Worktree Settings useWorktrees: boolean; // Whether to use git worktree isolation for features (default: true) @@ -670,6 +683,18 @@ export interface AppState { // Whether the worktree panel row is visible (default: true) worktreePanelVisibleByProject: Record; + // Init Script Indicator Visibility (per-project, keyed by project path) + // Whether to show the floating init script indicator panel (default: true) + showInitScriptIndicatorByProject: Record; + + // Default Delete Branch With Worktree (per-project, keyed by project path) + // Whether to default the "delete branch" checkbox when deleting a worktree (default: false) + defaultDeleteBranchByProject: Record; + + // Auto-dismiss Init Script Indicator (per-project, keyed by project path) + // Whether to auto-dismiss the indicator after completion (default: true) + autoDismissInitScriptIndicatorByProject: Record; + // UI State (previously in localStorage, now synced via API) /** Whether worktree panel is collapsed in board view */ worktreePanelCollapsed: boolean; @@ -677,6 +702,9 @@ export interface AppState { lastProjectDir: string; /** Recently accessed folders for quick access */ recentFolders: string[]; + + // Init Script State (keyed by "projectPath::branch" to support concurrent scripts) + initScriptState: Record; } // Claude Usage interface matching the server response @@ -890,6 +918,8 @@ export interface AppActions { setDefaultSkipTests: (skip: boolean) => void; setEnableDependencyBlocking: (enabled: boolean) => void; setSkipVerificationInAutoMode: (enabled: boolean) => Promise; + setPlanUseSelectedWorktreeBranch: (enabled: boolean) => Promise; + setAddFeatureUseSelectedWorktreeBranch: (enabled: boolean) => Promise; // Worktree Settings actions setUseWorktrees: (enabled: boolean) => void; @@ -1083,6 +1113,18 @@ export interface AppActions { setWorktreePanelVisible: (projectPath: string, visible: boolean) => void; getWorktreePanelVisible: (projectPath: string) => boolean; + // Init Script Indicator Visibility actions (per-project) + setShowInitScriptIndicator: (projectPath: string, visible: boolean) => void; + getShowInitScriptIndicator: (projectPath: string) => boolean; + + // Default Delete Branch actions (per-project) + setDefaultDeleteBranch: (projectPath: string, deleteBranch: boolean) => void; + getDefaultDeleteBranch: (projectPath: string) => boolean; + + // Auto-dismiss Init Script Indicator actions (per-project) + setAutoDismissInitScriptIndicator: (projectPath: string, autoDismiss: boolean) => void; + getAutoDismissInitScriptIndicator: (projectPath: string) => boolean; + // UI State actions (previously in localStorage, now synced via API) setWorktreePanelCollapsed: (collapsed: boolean) => void; setLastProjectDir: (dir: string) => void; @@ -1111,6 +1153,19 @@ export interface AppActions { }> ) => void; + // Init Script State actions (keyed by projectPath::branch to support concurrent scripts) + setInitScriptState: ( + projectPath: string, + branch: string, + state: Partial + ) => void; + appendInitScriptOutput: (projectPath: string, branch: string, content: string) => void; + clearInitScriptState: (projectPath: string, branch: string) => void; + getInitScriptState: (projectPath: string, branch: string) => InitScriptState | null; + getInitScriptStatesForProject: ( + projectPath: string + ) => Array<{ key: string; state: InitScriptState }>; + // Reset reset: () => void; } @@ -1143,6 +1198,8 @@ const initialState: AppState = { defaultSkipTests: true, // Default to manual verification (tests disabled) enableDependencyBlocking: true, // Default to enabled (show dependency blocking UI) skipVerificationInAutoMode: false, // Default to disabled (require dependencies to be verified) + planUseSelectedWorktreeBranch: true, // Default to enabled (Plan creates features on selected worktree branch) + addFeatureUseSelectedWorktreeBranch: false, // Default to disabled (Add Feature uses normal defaults) useWorktrees: true, // Default to enabled (git worktree isolation) currentWorktreeByProject: {}, worktreesByProject: {}, @@ -1208,10 +1265,14 @@ const initialState: AppState = { codexModelsLastFetched: null, pipelineConfigByProject: {}, worktreePanelVisibleByProject: {}, + showInitScriptIndicatorByProject: {}, + defaultDeleteBranchByProject: {}, + autoDismissInitScriptIndicatorByProject: {}, // UI State (previously in localStorage, now synced via API) worktreePanelCollapsed: false, lastProjectDir: '', recentFolders: [], + initScriptState: {}, }; export const useAppStore = create()((set, get) => ({ @@ -1764,6 +1825,30 @@ export const useAppStore = create()((set, get) => ({ const { syncSettingsToServer } = await import('@/hooks/use-settings-migration'); await syncSettingsToServer(); }, + setPlanUseSelectedWorktreeBranch: async (enabled) => { + const previous = get().planUseSelectedWorktreeBranch; + set({ planUseSelectedWorktreeBranch: enabled }); + // Sync to server settings file + const { syncSettingsToServer } = await import('@/hooks/use-settings-migration'); + const ok = await syncSettingsToServer(); + if (!ok) { + logger.error('Failed to sync planUseSelectedWorktreeBranch setting to server - reverting'); + set({ planUseSelectedWorktreeBranch: previous }); + } + }, + setAddFeatureUseSelectedWorktreeBranch: async (enabled) => { + const previous = get().addFeatureUseSelectedWorktreeBranch; + set({ addFeatureUseSelectedWorktreeBranch: enabled }); + // Sync to server settings file + const { syncSettingsToServer } = await import('@/hooks/use-settings-migration'); + const ok = await syncSettingsToServer(); + if (!ok) { + logger.error( + 'Failed to sync addFeatureUseSelectedWorktreeBranch setting to server - reverting' + ); + set({ addFeatureUseSelectedWorktreeBranch: previous }); + } + }, // Worktree Settings actions setUseWorktrees: (enabled) => set({ useWorktrees: enabled }), @@ -3136,6 +3221,51 @@ export const useAppStore = create()((set, get) => ({ return get().worktreePanelVisibleByProject[projectPath] ?? true; }, + // Init Script Indicator Visibility actions (per-project) + setShowInitScriptIndicator: (projectPath, visible) => { + set({ + showInitScriptIndicatorByProject: { + ...get().showInitScriptIndicatorByProject, + [projectPath]: visible, + }, + }); + }, + + getShowInitScriptIndicator: (projectPath) => { + // Default to true (visible) if not set + return get().showInitScriptIndicatorByProject[projectPath] ?? true; + }, + + // Default Delete Branch actions (per-project) + setDefaultDeleteBranch: (projectPath, deleteBranch) => { + set({ + defaultDeleteBranchByProject: { + ...get().defaultDeleteBranchByProject, + [projectPath]: deleteBranch, + }, + }); + }, + + getDefaultDeleteBranch: (projectPath) => { + // Default to false (don't delete branch) if not set + return get().defaultDeleteBranchByProject[projectPath] ?? false; + }, + + // Auto-dismiss Init Script Indicator actions (per-project) + setAutoDismissInitScriptIndicator: (projectPath, autoDismiss) => { + set({ + autoDismissInitScriptIndicatorByProject: { + ...get().autoDismissInitScriptIndicatorByProject, + [projectPath]: autoDismiss, + }, + }); + }, + + getAutoDismissInitScriptIndicator: (projectPath) => { + // Default to true (auto-dismiss enabled) if not set + return get().autoDismissInitScriptIndicatorByProject[projectPath] ?? true; + }, + // UI State actions (previously in localStorage, now synced via API) setWorktreePanelCollapsed: (collapsed) => set({ worktreePanelCollapsed: collapsed }), setLastProjectDir: (dir) => set({ lastProjectDir: dir }), @@ -3149,6 +3279,62 @@ export const useAppStore = create()((set, get) => ({ set({ recentFolders: updated }); }, + // Init Script State actions (keyed by "projectPath::branch") + setInitScriptState: (projectPath, branch, state) => { + const key = `${projectPath}::${branch}`; + const current = get().initScriptState[key] || { + status: 'idle', + branch, + output: [], + }; + set({ + initScriptState: { + ...get().initScriptState, + [key]: { ...current, ...state }, + }, + }); + }, + + appendInitScriptOutput: (projectPath, branch, content) => { + const key = `${projectPath}::${branch}`; + // Initialize state if absent to avoid dropping output due to event-order races + const current = get().initScriptState[key] || { + status: 'idle' as const, + branch, + output: [], + }; + // Append new content and enforce fixed-size buffer to prevent memory bloat + const newOutput = [...current.output, content].slice(-MAX_INIT_OUTPUT_LINES); + set({ + initScriptState: { + ...get().initScriptState, + [key]: { + ...current, + output: newOutput, + }, + }, + }); + }, + + clearInitScriptState: (projectPath, branch) => { + const key = `${projectPath}::${branch}`; + const { [key]: _, ...rest } = get().initScriptState; + set({ initScriptState: rest }); + }, + + getInitScriptState: (projectPath, branch) => { + const key = `${projectPath}::${branch}`; + return get().initScriptState[key] || null; + }, + + getInitScriptStatesForProject: (projectPath) => { + const prefix = `${projectPath}::`; + const states = get().initScriptState; + return Object.entries(states) + .filter(([key]) => key.startsWith(prefix)) + .map(([key, state]) => ({ key, state })); + }, + // Reset reset: () => set(initialState), })); diff --git a/apps/ui/src/types/electron.d.ts b/apps/ui/src/types/electron.d.ts index 745f6956..4c9cce55 100644 --- a/apps/ui/src/types/electron.d.ts +++ b/apps/ui/src/types/electron.d.ts @@ -1015,6 +1015,50 @@ export interface WorktreeAPI { }; error?: string; }>; + + // Get init script content for a project + getInitScript: (projectPath: string) => Promise<{ + success: boolean; + exists: boolean; + content: string; + path: string; + error?: string; + }>; + + // Set init script content for a project + setInitScript: ( + projectPath: string, + content: string + ) => Promise<{ + success: boolean; + path?: string; + error?: string; + }>; + + // Delete init script for a project + deleteInitScript: (projectPath: string) => Promise<{ + success: boolean; + error?: string; + }>; + + // Run (or re-run) init script for a worktree + runInitScript: ( + projectPath: string, + worktreePath: string, + branch: string + ) => Promise<{ + success: boolean; + message?: string; + error?: string; + }>; + + // Subscribe to init script events + onInitScriptEvent: ( + callback: (event: { + type: 'worktree:init-started' | 'worktree:init-output' | 'worktree:init-completed'; + payload: unknown; + }) => void + ) => () => void; } export interface GitAPI { diff --git a/docs/worktree-init-script-example.sh b/docs/worktree-init-script-example.sh new file mode 100644 index 00000000..2f942544 --- /dev/null +++ b/docs/worktree-init-script-example.sh @@ -0,0 +1,30 @@ +#!/bin/bash +# Example worktree init script for Automaker +# Copy this content to Settings > Worktrees > Init Script +# Or save directly as .automaker/worktree-init.sh in your project + +echo "==========================================" +echo " Worktree Init Script Starting..." +echo "==========================================" +echo "" +echo "Current directory: $(pwd)" +echo "Branch: $(git branch --show-current 2>/dev/null || echo 'unknown')" +echo "" + +# Install dependencies +echo "[1/1] Installing npm dependencies..." +if [ -f "package.json" ]; then + if npm install; then + echo "Dependencies installed successfully!" + else + echo "ERROR: npm install failed with exit code $?" + exit 1 + fi +else + echo "No package.json found, skipping npm install" +fi +echo "" + +echo "==========================================" +echo " Worktree initialization complete!" +echo "==========================================" diff --git a/libs/platform/src/index.ts b/libs/platform/src/index.ts index 5fd985c4..cd37da49 100644 --- a/libs/platform/src/index.ts +++ b/libs/platform/src/index.ts @@ -97,6 +97,7 @@ export { getCodexCliPaths, getCodexConfigDir, getCodexAuthPath, + getGitBashPaths, getOpenCodeCliPaths, getOpenCodeConfigDir, getOpenCodeAuthPath, @@ -130,6 +131,7 @@ export { findCodexCliPath, getCodexAuthIndicators, type CodexAuthIndicators, + findGitBashPath, findOpenCodeCliPath, getOpenCodeAuthIndicators, type OpenCodeAuthIndicators, diff --git a/libs/platform/src/system-paths.ts b/libs/platform/src/system-paths.ts index c1faee26..31382a33 100644 --- a/libs/platform/src/system-paths.ts +++ b/libs/platform/src/system-paths.ts @@ -232,6 +232,87 @@ export function getClaudeProjectsDir(): string { return path.join(getClaudeConfigDir(), 'projects'); } +/** + * Enumerate directories matching a prefix pattern and return full paths + * Used to resolve dynamic directory names like version numbers + */ +function enumerateMatchingPaths( + parentDir: string, + prefix: string, + ...subPathParts: string[] +): string[] { + try { + if (!fsSync.existsSync(parentDir)) { + return []; + } + const entries = fsSync.readdirSync(parentDir); + const matching = entries.filter((entry) => entry.startsWith(prefix)); + return matching.map((entry) => path.join(parentDir, entry, ...subPathParts)); + } catch { + return []; + } +} + +/** + * Get common Git Bash installation paths on Windows + * Git Bash is needed for running shell scripts cross-platform + */ +export function getGitBashPaths(): string[] { + if (process.platform !== 'win32') { + return []; + } + + const homeDir = os.homedir(); + const localAppData = process.env.LOCALAPPDATA || ''; + + // Dynamic paths that require directory enumeration + // winget installs to: LocalAppData\Microsoft\WinGet\Packages\Git.Git_\bin\bash.exe + const wingetGitPaths = localAppData + ? enumerateMatchingPaths( + path.join(localAppData, 'Microsoft', 'WinGet', 'Packages'), + 'Git.Git_', + 'bin', + 'bash.exe' + ) + : []; + + // GitHub Desktop bundles Git at: LocalAppData\GitHubDesktop\app-\resources\app\git\cmd\bash.exe + const githubDesktopPaths = localAppData + ? enumerateMatchingPaths( + path.join(localAppData, 'GitHubDesktop'), + 'app-', + 'resources', + 'app', + 'git', + 'cmd', + 'bash.exe' + ) + : []; + + return [ + // Standard Git for Windows installations + 'C:\\Program Files\\Git\\bin\\bash.exe', + 'C:\\Program Files (x86)\\Git\\bin\\bash.exe', + // User-local installations + path.join(localAppData, 'Programs', 'Git', 'bin', 'bash.exe'), + // Scoop package manager + path.join(homeDir, 'scoop', 'apps', 'git', 'current', 'bin', 'bash.exe'), + // Chocolatey + path.join( + process.env.ChocolateyInstall || 'C:\\ProgramData\\chocolatey', + 'lib', + 'git', + 'tools', + 'bin', + 'bash.exe' + ), + // winget installations (dynamically resolved) + ...wingetGitPaths, + // GitHub Desktop bundled Git (dynamically resolved) + ...githubDesktopPaths, + ].filter(Boolean); +} + /** * Get common shell paths for shell detection * Includes both full paths and short names to match $SHELL or PATH entries @@ -550,6 +631,8 @@ function getAllAllowedSystemPaths(): string[] { getOpenCodeAuthPath(), // Shell paths ...getShellPaths(), + // Git Bash paths (for Windows cross-platform shell script execution) + ...getGitBashPaths(), // Node.js system paths ...getNodeSystemPaths(), getScoopNodePath(), @@ -883,6 +966,13 @@ export async function findCodexCliPath(): Promise { return findFirstExistingPath(getCodexCliPaths()); } +/** + * Find Git Bash on Windows and return its path + */ +export async function findGitBashPath(): Promise { + return findFirstExistingPath(getGitBashPaths()); +} + /** * Get Claude authentication status by checking various indicators */ diff --git a/libs/types/src/event.ts b/libs/types/src/event.ts index 6692f0f0..092c80bd 100644 --- a/libs/types/src/event.ts +++ b/libs/types/src/event.ts @@ -39,6 +39,9 @@ export type EventType = | 'ideation:idea-created' | 'ideation:idea-updated' | 'ideation:idea-deleted' - | 'ideation:idea-converted'; + | 'ideation:idea-converted' + | 'worktree:init-started' + | 'worktree:init-output' + | 'worktree:init-completed'; export type EventCallback = (type: EventType, payload: unknown) => void; diff --git a/libs/types/src/settings.ts b/libs/types/src/settings.ts index 5b51c793..8ec4ef6c 100644 --- a/libs/types/src/settings.ts +++ b/libs/types/src/settings.ts @@ -599,6 +599,14 @@ export interface ProjectSettings { // UI Visibility /** Whether the worktree panel row is visible (default: true) */ worktreePanelVisible?: boolean; + /** Whether to show the init script indicator panel (default: true) */ + showInitScriptIndicator?: boolean; + + // Worktree Behavior + /** Default value for "delete branch" checkbox when deleting a worktree (default: false) */ + defaultDeleteBranchWithWorktree?: boolean; + /** Auto-dismiss init script indicator after completion (default: true) */ + autoDismissInitScriptIndicator?: boolean; // Session Tracking /** Last chat session selected in this project */ diff --git a/package-lock.json b/package-lock.json index f358ed5d..9ec0585e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -87,6 +87,8 @@ "@automaker/dependency-resolver": "1.0.0", "@automaker/types": "1.0.0", "@codemirror/lang-xml": "6.1.0", + "@codemirror/language": "^6.12.1", + "@codemirror/legacy-modes": "^6.5.2", "@codemirror/theme-one-dark": "6.1.3", "@dnd-kit/core": "6.3.1", "@dnd-kit/sortable": "10.0.0", @@ -1199,19 +1201,28 @@ } }, "node_modules/@codemirror/language": { - "version": "6.11.3", - "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.11.3.tgz", - "integrity": "sha512-9HBM2XnwDj7fnu0551HkGdrUrrqmYq/WC5iv6nbY2WdicXdGbhR/gfbZOH73Aqj4351alY1+aoG9rCNfiwS1RA==", + "version": "6.12.1", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.1.tgz", + "integrity": "sha512-Fa6xkSiuGKc8XC8Cn96T+TQHYj4ZZ7RdFmXA3i9xe/3hLHfwPZdM+dqfX0Cp0zQklBKhVD8Yzc8LS45rkqcwpQ==", "license": "MIT", "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", - "@lezer/common": "^1.1.0", + "@lezer/common": "^1.5.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0", "style-mod": "^4.0.0" } }, + "node_modules/@codemirror/legacy-modes": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/@codemirror/legacy-modes/-/legacy-modes-6.5.2.tgz", + "integrity": "sha512-/jJbwSTazlQEDOQw2FJ8LEEKVS72pU0lx6oM54kGpL8t/NJ2Jda3CZ4pcltiKTdqYSRk3ug1B3pil1gsjA6+8Q==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0" + } + }, "node_modules/@codemirror/lint": { "version": "6.9.2", "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.2.tgz", @@ -3604,9 +3615,9 @@ } }, "node_modules/@lezer/common": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.4.0.tgz", - "integrity": "sha512-DVeMRoGrgn/k45oQNu189BoW4SZwgZFzJ1+1TV5j2NJ/KFC83oa/enRqZSGshyeMk5cPWMhsKs9nx+8o0unwGg==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.0.tgz", + "integrity": "sha512-PNGcolp9hr4PJdXR4ix7XtixDrClScvtSCYW3rQG106oVMOOI+jFb+0+J3mbeL/53g1Zd6s0kJzaw6Ri68GmAA==", "license": "MIT" }, "node_modules/@lezer/highlight": {