From 05d96a7d6eaa0acb3022373aebfec4e85b49b989 Mon Sep 17 00:00:00 2001 From: Kacper Date: Sat, 10 Jan 2026 22:19:34 +0100 Subject: [PATCH 01/14] feat: Implement worktree initialization script functionality This commit introduces a new feature for managing worktree initialization scripts, allowing users to configure and execute scripts upon worktree creation. Key changes include: 1. **New API Endpoints**: Added endpoints for getting, setting, and deleting init scripts. 2. **Worktree Routes**: Updated worktree routes to include init script handling. 3. **Init Script Service**: Created a service to execute the init scripts asynchronously, with support for cross-platform compatibility. 4. **UI Components**: Added UI components for displaying and editing init scripts, including a dedicated section in the settings view. 5. **Event Handling**: Implemented event handling for init script execution status, providing real-time feedback in the UI. This enhancement improves the user experience by allowing automated setup processes for new worktrees, streamlining project workflows. --- apps/server/src/index.ts | 2 +- apps/server/src/lib/worktree-metadata.ts | 6 + apps/server/src/routes/worktree/index.ts | 15 +- .../src/routes/worktree/routes/create.ts | 25 +- .../src/routes/worktree/routes/init-script.ts | 162 +++++++++++ .../src/services/init-script-service.ts | 258 +++++++++++++++++ apps/ui/package.json | 2 + apps/ui/src/components/ui/ansi-output.tsx | 262 ++++++++++++++++++ .../src/components/ui/shell-syntax-editor.tsx | 141 ++++++++++ apps/ui/src/components/views/board-view.tsx | 8 + .../board-view/init-script-indicator.tsx | 140 ++++++++++ .../ui/src/components/views/settings-view.tsx | 10 +- .../views/settings-view/config/navigation.ts | 3 + .../feature-defaults-section.tsx | 31 --- .../settings-view/hooks/use-settings-view.ts | 1 + .../views/settings-view/worktrees/index.ts | 1 + .../worktrees/worktrees-section.tsx | 238 ++++++++++++++++ apps/ui/src/hooks/use-init-script-events.ts | 79 ++++++ apps/ui/src/lib/http-api-client.ts | 29 ++ apps/ui/src/store/app-store.ts | 56 ++++ docs/worktree-init-script-example.sh | 26 ++ libs/types/src/event.ts | 5 +- package-lock.json | 27 +- 23 files changed, 1481 insertions(+), 46 deletions(-) create mode 100644 apps/server/src/routes/worktree/routes/init-script.ts create mode 100644 apps/server/src/services/init-script-service.ts create mode 100644 apps/ui/src/components/ui/ansi-output.tsx create mode 100644 apps/ui/src/components/ui/shell-syntax-editor.tsx create mode 100644 apps/ui/src/components/views/board-view/init-script-indicator.tsx create mode 100644 apps/ui/src/components/views/settings-view/worktrees/index.ts create mode 100644 apps/ui/src/components/views/settings-view/worktrees/worktrees-section.tsx create mode 100644 apps/ui/src/hooks/use-init-script-events.ts create mode 100644 docs/worktree-init-script-example.sh diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index caa4dd6a..dabbc712 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/worktree/index.ts b/apps/server/src/routes/worktree/index.ts index 7fef5c6e..abb56833 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'; @@ -30,8 +31,13 @@ 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, +} from './routes/init-script.js'; -export function createWorktreeRoutes(): Router { +export function createWorktreeRoutes(events: EventEmitter): Router { const router = Router(); router.post('/info', validatePathParams('projectPath'), createInfoHandler()); @@ -45,7 +51,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()); @@ -87,5 +93,10 @@ export function createWorktreeRoutes(): Router { router.post('/stop-dev', createStopDevHandler()); router.post('/list-dev-servers', createListDevServersHandler()); + // Init script routes + router.post('/init-script', validatePathParams('projectPath'), createGetInitScriptHandler()); + router.put('/init-script', validatePathParams('projectPath'), createPutInitScriptHandler()); + router.delete('/init-script', validatePathParams('projectPath'), createDeleteInitScriptHandler()); + return router; } diff --git a/apps/server/src/routes/worktree/routes/create.ts b/apps/server/src/routes/worktree/routes/create.ts index b8e07570..ecc3b9b0 100644 --- a/apps/server/src/routes/worktree/routes/create.ts +++ b/apps/server/src/routes/worktree/routes/create.ts @@ -12,6 +12,7 @@ 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, getErrorMessage, @@ -21,6 +22,8 @@ import { } from '../common.js'; import { trackBranch } from './branch-tracking.js'; import { createLogger } from '@automaker/utils'; +import { runInitScript, getInitScriptPath, hasInitScriptRun } from '../../../services/init-script-service.js'; +import fs from 'fs'; const logger = createLogger('Worktree'); @@ -77,7 +80,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 { @@ -177,6 +180,13 @@ export function createCreateHandler() { // Resolve to absolute path for cross-platform compatibility // normalizePath converts to forward slashes for API consistency const absoluteWorktreePath = path.resolve(worktreePath); + + // Check if init script exists and should be run (only for new worktrees) + const initScriptPath = getInitScriptPath(projectPath); + const hasInitScript = fs.existsSync(initScriptPath); + const alreadyRan = await hasInitScriptRun(projectPath, branchName); + + // Respond immediately (non-blocking) res.json({ success: true, worktree: { @@ -185,6 +195,19 @@ export function createCreateHandler() { isNew: !branchExists, }, }); + + // Trigger init script asynchronously after response + if (hasInitScript && !alreadyRan) { + logger.info(`Triggering init script for worktree: ${branchName}`); + 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/init-script.ts b/apps/server/src/routes/worktree/routes/init-script.ts new file mode 100644 index 00000000..89c32456 --- /dev/null +++ b/apps/server/src/routes/worktree/routes/init-script.ts @@ -0,0 +1,162 @@ +/** + * Init Script routes - Read/write the worktree-init.sh file + * + * GET /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 + */ + +import type { Request, Response } from 'express'; +import path from 'path'; +import * as secureFs from '../../../lib/secure-fs.js'; +import { getErrorMessage, logError } from '../common.js'; +import { createLogger } from '@automaker/utils'; + +const logger = createLogger('InitScript'); + +/** Fixed path for init script within .automaker directory */ +const INIT_SCRIPT_FILENAME = 'worktree-init.sh'; + +/** + * 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.body as { projectPath: string }; + + if (!projectPath) { + res.status(400).json({ + success: false, + error: 'projectPath 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; + } + + 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); + + try { + await secureFs.rm(scriptPath, { force: true }); + logger.info(`Deleted init script at ${scriptPath}`); + res.json({ + success: true, + }); + } catch { + // File doesn't exist - still success + res.json({ + success: true, + }); + } + } catch (error) { + logError(error, 'Delete 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..25a36fcb --- /dev/null +++ b/apps/server/src/services/init-script-service.ts @@ -0,0 +1,258 @@ +/** + * 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 fs from 'fs'; +import { createLogger } from '@automaker/utils'; +import type { EventEmitter } from '../lib/events.js'; +import { + readWorktreeMetadata, + writeWorktreeMetadata, +} from '../lib/worktree-metadata.js'; + +const logger = createLogger('InitScript'); + +/** Common Git Bash installation paths on Windows */ +const GIT_BASH_PATHS = [ + 'C:\\Program Files\\Git\\bin\\bash.exe', + 'C:\\Program Files (x86)\\Git\\bin\\bash.exe', + path.join(process.env.LOCALAPPDATA || '', 'Programs', 'Git', 'bin', 'bash.exe'), + path.join(process.env.USERPROFILE || '', 'scoop', 'apps', 'git', 'current', 'bin', 'bash.exe'), +]; + +/** + * Find Git Bash executable on Windows + */ +function findGitBash(): string | null { + if (process.platform !== 'win32') { + return null; + } + + for (const bashPath of GIT_BASH_PATHS) { + if (fs.existsSync(bashPath)) { + return bashPath; + } + } + + return null; +} + +/** + * Get the shell command for running scripts + * Returns [shellPath, shellArgs] for cross-platform compatibility + */ +function getShellCommand(): { shell: string; args: string[] } | null { + if (process.platform === 'win32') { + const gitBash = findGitBash(); + if (!gitBash) { + return null; + } + return { shell: gitBash, args: [] }; + } + + // Unix-like systems: prefer bash, fall back to sh + if (fs.existsSync('/bin/bash')) { + return { shell: '/bin/bash', args: [] }; + } + if (fs.existsSync('/bin/sh')) { + return { shell: '/bin/sh', args: [] }; + } + + return null; +} + +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; +} + +/** + * Check if init script exists for a project + */ +export function getInitScriptPath(projectPath: string): string { + return path.join(projectPath, '.automaker', 'worktree-init.sh'); +} + +/** + * Check if the init script has already been run for a worktree + */ +export async function hasInitScriptRun( + projectPath: string, + branch: string +): Promise { + const metadata = await readWorktreeMetadata(projectPath, branch); + return metadata?.initScriptRan === true; +} + +/** + * Run the worktree initialization script + * Non-blocking - returns immediately after spawning + */ +export async function runInitScript(options: InitScriptOptions): Promise { + const { projectPath, worktreePath, branch, emitter } = options; + + const scriptPath = getInitScriptPath(projectPath); + + // Check if script exists + if (!fs.existsSync(scriptPath)) { + logger.debug(`No init script found at ${scriptPath}`); + return; + } + + // Check if already run + if (await hasInitScriptRun(projectPath, branch)) { + logger.info(`Init script already ran for branch "${branch}", skipping`); + return; + } + + // Get shell command + const shellCmd = getShellCommand(); + 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 + await writeWorktreeMetadata(projectPath, branch, { + branch, + createdAt: new Date().toISOString(), + 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}`); + + // 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, + }); + + // Spawn the script + const child = spawn(shellCmd.shell, [...shellCmd.args, scriptPath], { + cwd: worktreePath, + env: { + ...process.env, + // Provide useful env vars to the script + AUTOMAKER_PROJECT_PATH: projectPath, + AUTOMAKER_WORKTREE_PATH: worktreePath, + AUTOMAKER_BRANCH: branch, + // Force color output even though we're not a TTY + FORCE_COLOR: '1', + npm_config_color: 'always', + CLICOLOR_FORCE: '1', + // Git colors + GIT_TERMINAL_PROMPT: '0', + }, + 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, + }); + }); +} 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..af9f8667 --- /dev/null +++ b/apps/ui/src/components/ui/ansi-output.tsx @@ -0,0 +1,262 @@ +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..03675539 --- /dev/null +++ b/apps/ui/src/components/ui/shell-syntax-editor.tsx @@ -0,0 +1,141 @@ +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; + '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', + '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 810d1a91..b4c050f6 100644 --- a/apps/ui/src/components/views/board-view.tsx +++ b/apps/ui/src/components/views/board-view.tsx @@ -78,6 +78,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']> = []; @@ -255,6 +257,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 @@ -1570,6 +1575,9 @@ export function BoardView() { setSelectedWorktreeForAction(null); }} /> + + {/* Init Script Indicator - floating overlay for worktree init script status */} + ); } 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..ca108dbd --- /dev/null +++ b/apps/ui/src/components/views/board-view/init-script-indicator.tsx @@ -0,0 +1,140 @@ +import { useState, useRef, useEffect } 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; +} + +export function InitScriptIndicator({ projectPath }: InitScriptIndicatorProps) { + const initScriptState = useAppStore((s) => s.initScriptState[projectPath]); + const clearInitScriptState = useAppStore((s) => s.clearInitScriptState); + const [showLogs, setShowLogs] = useState(false); + const [dismissed, setDismissed] = useState(false); + const logsEndRef = useRef(null); + + // Auto-scroll to bottom when new output arrives + useEffect(() => { + if (showLogs && logsEndRef.current) { + logsEndRef.current.scrollIntoView({ behavior: 'smooth' }); + } + }, [initScriptState?.output, showLogs]); + + // Reset dismissed state when a new script starts + useEffect(() => { + if (initScriptState?.status === 'running') { + setDismissed(false); + setShowLogs(true); + } + }, [initScriptState?.status]); + + if (!initScriptState || dismissed) return null; + if (initScriptState.status === 'idle') return null; + + const { status, output, branch, error } = initScriptState; + + const handleDismiss = () => { + setDismissed(true); + // Clear state after a delay to allow for future scripts + setTimeout(() => { + clearInitScriptState(projectPath); + }, 100); + }; + + 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'} +
+ )} +
+ ); +} diff --git a/apps/ui/src/components/views/settings-view.tsx b/apps/ui/src/components/views/settings-view.tsx index 27cc7703..52e09b1a 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,22 @@ 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..5a75f903 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) { @@ -257,32 +252,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..1af4c755 --- /dev/null +++ b/apps/ui/src/components/views/settings-view/worktrees/worktrees-section.tsx @@ -0,0 +1,238 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; +import { Label } from '@/components/ui/label'; +import { Checkbox } from '@/components/ui/checkbox'; +import { ShellSyntaxEditor } from '@/components/ui/shell-syntax-editor'; +import { GitBranch, Terminal, FileCode, Check, Loader2 } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { apiPost, apiPut } from '@/lib/api-fetch'; +import { toast } from 'sonner'; +import { useAppStore } from '@/store/app-store'; + +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 [scriptContent, setScriptContent] = useState(''); + const [scriptPath, setScriptPath] = useState(''); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + const [showSaved, setShowSaved] = useState(false); + const saveTimeoutRef = useRef | null>(null); + const savedTimeoutRef = useRef | null>(null); + + // Load init script content when project changes + useEffect(() => { + if (!currentProject?.path) { + setScriptContent(''); + setScriptPath(''); + setIsLoading(false); + return; + } + + const loadInitScript = async () => { + setIsLoading(true); + try { + const response = await apiPost('/api/worktree/init-script', { + projectPath: currentProject.path, + }); + if (response.success) { + setScriptContent(response.content || ''); + setScriptPath(response.path || ''); + } + } catch (error) { + console.error('Failed to load init script:', error); + } finally { + setIsLoading(false); + } + }; + + loadInitScript(); + }, [currentProject?.path]); + + // Debounced save function + const saveScript = useCallback( + async (content: string) => { + if (!currentProject?.path) return; + + setIsSaving(true); + try { + const response = await apiPut<{ success: boolean; error?: string }>( + '/api/worktree/init-script', + { + projectPath: currentProject.path, + content, + } + ); + if (response.success) { + setShowSaved(true); + savedTimeoutRef.current = setTimeout(() => setShowSaved(false), 2000); + } 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] + ); + + // Handle content change with debounce + const handleContentChange = useCallback( + (value: string) => { + setScriptContent(value); + setShowSaved(false); + + // Clear existing timeouts + if (saveTimeoutRef.current) { + clearTimeout(saveTimeoutRef.current); + } + if (savedTimeoutRef.current) { + clearTimeout(savedTimeoutRef.current); + } + + // Debounce save + saveTimeoutRef.current = setTimeout(() => { + saveScript(value); + }, 1000); + }, + [saveScript] + ); + + // Cleanup timeouts + useEffect(() => { + return () => { + if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current); + if (savedTimeoutRef.current) clearTimeout(savedTimeoutRef.current); + }; + }, []); + + 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. +

+
+
+ + {/* Separator */} +
+ + {/* Init Script Section */} +
+
+
+ + +
+
+ {isSaving && ( + + + Saving... + + )} + {showSaved && !isSaving && ( + + + Saved + + )} +
+
+

+ 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 +
+ + {isLoading ? ( +
+ +
+ ) : ( + + )} + + ) : ( +
+ 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..b95b485d --- /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, { + status: 'running', + branch: startPayload.branch, + output: [], + error: undefined, + }); + break; + } + case 'worktree:init-output': { + const outputPayload = payload as InitScriptOutputPayload; + appendInitScriptOutput(projectPath, outputPayload.content); + break; + } + case 'worktree:init-completed': { + const completePayload = payload as InitScriptCompletedPayload; + setInitScriptState(projectPath, { + status: completePayload.success ? 'success' : 'failed', + error: completePayload.error, + }); + break; + } + } + }); + + return unsubscribe; + }, [projectPath, setInitScriptState, appendInitScriptOutput]); +} diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index 85392000..7e81703c 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -1608,6 +1608,35 @@ 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.post('/api/worktree/init-script', { projectPath }), + setInitScript: (projectPath: string, content: string) => + this.put('/api/worktree/init-script', { projectPath, content }), + deleteInitScript: (projectPath: string) => + this.delete('/api/worktree/init-script', { projectPath }), + 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 diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index fa1daf4c..5479c015 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -459,6 +459,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[]; @@ -664,6 +672,9 @@ export interface AppState { lastProjectDir: string; /** Recently accessed folders for quick access */ recentFolders: string[]; + + // Init Script State (per-project, keyed by project path) + initScriptState: Record; } // Claude Usage interface matching the server response @@ -1095,6 +1106,12 @@ export interface AppActions { }> ) => void; + // Init Script State actions + setInitScriptState: (projectPath: string, state: Partial) => void; + appendInitScriptOutput: (projectPath: string, content: string) => void; + clearInitScriptState: (projectPath: string) => void; + getInitScriptState: (projectPath: string) => InitScriptState | null; + // Reset reset: () => void; } @@ -1195,6 +1212,7 @@ const initialState: AppState = { worktreePanelCollapsed: false, lastProjectDir: '', recentFolders: [], + initScriptState: {}, }; export const useAppStore = create()((set, get) => ({ @@ -3119,6 +3137,44 @@ export const useAppStore = create()((set, get) => ({ set({ recentFolders: updated }); }, + // Init Script State actions + setInitScriptState: (projectPath, state) => { + const current = get().initScriptState[projectPath] || { + status: 'idle', + branch: '', + output: [], + }; + set({ + initScriptState: { + ...get().initScriptState, + [projectPath]: { ...current, ...state }, + }, + }); + }, + + appendInitScriptOutput: (projectPath, content) => { + const current = get().initScriptState[projectPath]; + if (!current) return; + set({ + initScriptState: { + ...get().initScriptState, + [projectPath]: { + ...current, + output: [...current.output, content], + }, + }, + }); + }, + + clearInitScriptState: (projectPath) => { + const { [projectPath]: _, ...rest } = get().initScriptState; + set({ initScriptState: rest }); + }, + + getInitScriptState: (projectPath) => { + return get().initScriptState[projectPath] || null; + }, + // Reset reset: () => set(initialState), })); diff --git a/docs/worktree-init-script-example.sh b/docs/worktree-init-script-example.sh new file mode 100644 index 00000000..6e57389f --- /dev/null +++ b/docs/worktree-init-script-example.sh @@ -0,0 +1,26 @@ +#!/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 + npm install + echo "Dependencies installed successfully!" +else + echo "No package.json found, skipping npm install" +fi +echo "" + +echo "==========================================" +echo " Worktree initialization complete!" +echo "==========================================" 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/package-lock.json b/package-lock.json index f358ed5d..8c7f0355 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", @@ -1468,7 +1479,7 @@ }, "node_modules/@electron/node-gyp": { "version": "10.2.0-electron.1", - "resolved": "git+https://github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2", + "resolved": "git+ssh://git@github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2", "integrity": "sha512-4MSBTT8y07YUDqf69/vSh80Hh791epYqGtWHO3zSKhYFwQg+gx9wi1PqbqP6YqC4WMsNxZ5l9oDmnWdK5pfCKQ==", "dev": true, "license": "MIT", @@ -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": { From 6c412cd367712f0a0927e719d43388514e4d06ea Mon Sep 17 00:00:00 2001 From: Kacper Date: Sat, 10 Jan 2026 22:36:50 +0100 Subject: [PATCH 02/14] feat: Add run init script functionality for worktrees This commit introduces the ability to run initialization scripts for worktrees, enhancing the setup process. Key changes include: 1. **New API Endpoint**: Added a POST endpoint to run the init script for a specified worktree. 2. **Worktree Routes**: Updated worktree routes to include the new run init script handler. 3. **Init Script Service**: Enhanced the Init Script Service to support running scripts asynchronously and handling errors. 4. **UI Updates**: Added UI components to check for the existence of init scripts and trigger their execution, providing user feedback through toast notifications. 5. **Event Handling**: Implemented event handling for init script execution status, allowing real-time updates in the UI. This feature streamlines the workflow for users by automating the execution of setup scripts, improving overall project management. --- apps/server/src/routes/worktree/index.ts | 6 + .../src/routes/worktree/routes/init-script.ts | 81 ++- .../src/services/init-script-service.ts | 499 ++++++++++-------- .../components/worktree-actions-dropdown.tsx | 11 + .../components/worktree-tab.tsx | 6 + .../worktree-panel/worktree-panel.tsx | 57 +- apps/ui/src/lib/electron.ts | 41 ++ apps/ui/src/lib/http-api-client.ts | 15 +- apps/ui/src/types/electron.d.ts | 44 ++ libs/platform/src/index.ts | 2 + libs/platform/src/system-paths.ts | 60 +++ 11 files changed, 602 insertions(+), 220 deletions(-) diff --git a/apps/server/src/routes/worktree/index.ts b/apps/server/src/routes/worktree/index.ts index abb56833..a98377fb 100644 --- a/apps/server/src/routes/worktree/index.ts +++ b/apps/server/src/routes/worktree/index.ts @@ -35,6 +35,7 @@ import { createGetInitScriptHandler, createPutInitScriptHandler, createDeleteInitScriptHandler, + createRunInitScriptHandler, } from './routes/init-script.js'; export function createWorktreeRoutes(events: EventEmitter): Router { @@ -97,6 +98,11 @@ export function createWorktreeRoutes(events: EventEmitter): Router { router.post('/init-script', validatePathParams('projectPath'), 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/routes/init-script.ts b/apps/server/src/routes/worktree/routes/init-script.ts index 89c32456..0389bce3 100644 --- a/apps/server/src/routes/worktree/routes/init-script.ts +++ b/apps/server/src/routes/worktree/routes/init-script.ts @@ -1,9 +1,10 @@ /** - * Init Script routes - Read/write the worktree-init.sh file + * Init Script routes - Read/write/run the worktree-init.sh file * - * GET /init-script - Read the init script content + * 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'; @@ -11,6 +12,8 @@ import path from 'path'; import * as secureFs from '../../../lib/secure-fs.js'; import { getErrorMessage, logError } 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'); @@ -160,3 +163,77 @@ export function createDeleteInitScriptHandler() { } }; } + +/** + * 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; + } + + 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 index 25a36fcb..ac354bff 100644 --- a/apps/server/src/services/init-script-service.ts +++ b/apps/server/src/services/init-script-service.ts @@ -7,65 +7,15 @@ import { spawn } from 'child_process'; import path from 'path'; -import fs from 'fs'; 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 { readWorktreeMetadata, writeWorktreeMetadata } from '../lib/worktree-metadata.js'; +import * as secureFs from '../lib/secure-fs.js'; const logger = createLogger('InitScript'); -/** Common Git Bash installation paths on Windows */ -const GIT_BASH_PATHS = [ - 'C:\\Program Files\\Git\\bin\\bash.exe', - 'C:\\Program Files (x86)\\Git\\bin\\bash.exe', - path.join(process.env.LOCALAPPDATA || '', 'Programs', 'Git', 'bin', 'bash.exe'), - path.join(process.env.USERPROFILE || '', 'scoop', 'apps', 'git', 'current', 'bin', 'bash.exe'), -]; - -/** - * Find Git Bash executable on Windows - */ -function findGitBash(): string | null { - if (process.platform !== 'win32') { - return null; - } - - for (const bashPath of GIT_BASH_PATHS) { - if (fs.existsSync(bashPath)) { - return bashPath; - } - } - - return null; -} - -/** - * Get the shell command for running scripts - * Returns [shellPath, shellArgs] for cross-platform compatibility - */ -function getShellCommand(): { shell: string; args: string[] } | null { - if (process.platform === 'win32') { - const gitBash = findGitBash(); - if (!gitBash) { - return null; - } - return { shell: gitBash, args: [] }; - } - - // Unix-like systems: prefer bash, fall back to sh - if (fs.existsSync('/bin/bash')) { - return { shell: '/bin/bash', args: [] }; - } - if (fs.existsSync('/bin/sh')) { - return { shell: '/bin/sh', args: [] }; - } - - return null; -} - export interface InitScriptOptions { /** Absolute path to the project root */ projectPath: string; @@ -77,182 +27,307 @@ export interface InitScriptOptions { emitter: EventEmitter; } -/** - * Check if init script exists for a project - */ -export function getInitScriptPath(projectPath: string): string { - return path.join(projectPath, '.automaker', 'worktree-init.sh'); +interface ShellCommand { + shell: string; + args: string[]; } /** - * Check if the init script has already been run for a worktree + * Init Script Service + * + * Handles execution of worktree initialization scripts with cross-platform + * shell detection and proper streaming of output via WebSocket events. */ -export async function hasInitScriptRun( - projectPath: string, - branch: string -): Promise { - const metadata = await readWorktreeMetadata(projectPath, branch); - return metadata?.initScriptRan === true; -} +export class InitScriptService { + private cachedShellCommand: ShellCommand | null | undefined = undefined; -/** - * Run the worktree initialization script - * Non-blocking - returns immediately after spawning - */ -export async function runInitScript(options: InitScriptOptions): Promise { - const { projectPath, worktreePath, branch, emitter } = options; - - const scriptPath = getInitScriptPath(projectPath); - - // Check if script exists - if (!fs.existsSync(scriptPath)) { - logger.debug(`No init script found at ${scriptPath}`); - return; + /** + * Get the path to the init script for a project + */ + getInitScriptPath(projectPath: string): string { + return path.join(projectPath, '.automaker', 'worktree-init.sh'); } - // Check if already run - if (await hasInitScriptRun(projectPath, branch)) { - logger.info(`Init script already ran for branch "${branch}", skipping`); - return; - } - - // Get shell command - const shellCmd = getShellCommand(); - 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 - await writeWorktreeMetadata(projectPath, branch, { - branch, - createdAt: new Date().toISOString(), - 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}`); - - // 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, - }); - - // Spawn the script - const child = spawn(shellCmd.shell, [...shellCmd.args, scriptPath], { - cwd: worktreePath, - env: { - ...process.env, - // Provide useful env vars to the script - AUTOMAKER_PROJECT_PATH: projectPath, - AUTOMAKER_WORKTREE_PATH: worktreePath, - AUTOMAKER_BRANCH: branch, - // Force color output even though we're not a TTY - FORCE_COLOR: '1', - npm_config_color: 'always', - CLICOLOR_FORCE: '1', - // Git colors - GIT_TERMINAL_PROMPT: '0', - }, - 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 + /** + * 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 + await writeWorktreeMetadata(projectPath, branch, { + branch, + createdAt: new Date().toISOString(), + 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: metadata?.createdAt || new Date().toISOString(), - pr: metadata?.pr, - initScriptRan: true, - initScriptStatus: status, - initScriptError: success ? undefined : `Exit code: ${code}`, + createdAt: existingMetadata?.createdAt || new Date().toISOString(), + pr: existingMetadata?.pr, + initScriptRan: false, + initScriptStatus: 'running', }); - // Emit completion event - emitter.emit('worktree:init-completed', { + // Emit started event + emitter.emit('worktree:init-started', { projectPath, worktreePath, branch, - success, - exitCode: code, }); - }); - child.on('error', async (error) => { - logger.error(`Init script error for branch "${branch}":`, error); + // Spawn the script + const child = spawn(shellCmd.shell, [...shellCmd.args, scriptPath], { + cwd: worktreePath, + env: { + ...process.env, + // Provide useful env vars to the script + AUTOMAKER_PROJECT_PATH: projectPath, + AUTOMAKER_WORKTREE_PATH: worktreePath, + AUTOMAKER_BRANCH: branch, + // Force color output even though we're not a TTY + FORCE_COLOR: '1', + npm_config_color: 'always', + CLICOLOR_FORCE: '1', + // Git colors + GIT_TERMINAL_PROMPT: '0', + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); - // Update metadata + // 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); - await writeWorktreeMetadata(projectPath, branch, { - branch, - createdAt: metadata?.createdAt || new Date().toISOString(), - pr: metadata?.pr, - initScriptRan: true, - initScriptStatus: 'failed', - initScriptError: error.message, - }); + if (metadata) { + await writeWorktreeMetadata(projectPath, branch, { + ...metadata, + initScriptRan: false, + initScriptStatus: undefined, + initScriptError: undefined, + }); + } - // Emit completion with error - emitter.emit('worktree:init-completed', { - projectPath, - worktreePath, - branch, - success: false, - error: error.message, - }); - }); + // 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/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 c6542256..12f39188 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 @@ -21,6 +21,7 @@ import { MessageSquare, GitMerge, AlertCircle, + RefreshCw, } from 'lucide-react'; import { cn } from '@/lib/utils'; import type { WorktreeInfo, DevServerInfo, PRInfo, GitRepoStatus } from '../types'; @@ -50,6 +51,8 @@ interface WorktreeActionsDropdownProps { onStartDevServer: (worktree: WorktreeInfo) => void; onStopDevServer: (worktree: WorktreeInfo) => void; onOpenDevServerUrl: (worktree: WorktreeInfo) => void; + onRunInitScript: (worktree: WorktreeInfo) => void; + hasInitScript: boolean; } export function WorktreeActionsDropdown({ @@ -76,6 +79,8 @@ export function WorktreeActionsDropdown({ onStartDevServer, onStopDevServer, onOpenDevServerUrl, + onRunInitScript, + hasInitScript, }: WorktreeActionsDropdownProps) { // Check if there's a PR associated with this worktree from stored metadata const hasPR = !!worktree.pr; @@ -204,6 +209,12 @@ export function WorktreeActionsDropdown({ Open in {defaultEditorName} + {!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({ @@ -87,6 +89,8 @@ export function WorktreeTab({ onStartDevServer, onStopDevServer, onOpenDevServerUrl, + onRunInitScript, + hasInitScript, }: WorktreeTabProps) { let prBadge: JSX.Element | null = null; if (worktree.pr) { @@ -336,6 +340,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 b56f65e1..5b903116 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, @@ -82,6 +84,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); @@ -116,6 +140,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); @@ -166,6 +217,8 @@ export function WorktreePanel({ onStartDevServer={handleStartDevServer} onStopDevServer={handleStopDevServer} onOpenDevServerUrl={handleOpenDevServerUrl} + onRunInitScript={handleRunInitScript} + hasInitScript={hasInitScript} /> )} @@ -221,6 +274,8 @@ export function WorktreePanel({ onStartDevServer={handleStartDevServer} onStopDevServer={handleStopDevServer} onOpenDevServerUrl={handleOpenDevServerUrl} + onRunInitScript={handleRunInitScript} + hasInitScript={hasInitScript} /> ); })} diff --git a/apps/ui/src/lib/electron.ts b/apps/ui/src/lib/electron.ts index 2b52a2ac..58db6776 100644 --- a/apps/ui/src/lib/electron.ts +++ b/apps/ui/src/lib/electron.ts @@ -1716,6 +1716,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 7e81703c..f6f6e266 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -499,7 +499,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; @@ -825,13 +828,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) { @@ -1609,12 +1613,13 @@ export class HttpApiClient implements ElectronAPI { getPRInfo: (worktreePath: string, branchName: string) => this.post('/api/worktree/pr-info', { worktreePath, branchName }), // Init script methods - getInitScript: (projectPath: string) => - this.post('/api/worktree/init-script', { projectPath }), + getInitScript: (projectPath: string) => this.post('/api/worktree/init-script', { projectPath }), setInitScript: (projectPath: string, content: string) => this.put('/api/worktree/init-script', { projectPath, content }), deleteInitScript: (projectPath: string) => - this.delete('/api/worktree/init-script', { projectPath }), + 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'; diff --git a/apps/ui/src/types/electron.d.ts b/apps/ui/src/types/electron.d.ts index fc64f375..ca31a8ad 100644 --- a/apps/ui/src/types/electron.d.ts +++ b/apps/ui/src/types/electron.d.ts @@ -976,6 +976,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/libs/platform/src/index.ts b/libs/platform/src/index.ts index 7e4f2474..570311e0 100644 --- a/libs/platform/src/index.ts +++ b/libs/platform/src/index.ts @@ -96,6 +96,7 @@ export { getCodexCliPaths, getCodexConfigDir, getCodexAuthPath, + getGitBashPaths, getOpenCodeCliPaths, getOpenCodeConfigDir, getOpenCodeAuthPath, @@ -129,6 +130,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..3f1a1f4b 100644 --- a/libs/platform/src/system-paths.ts +++ b/libs/platform/src/system-paths.ts @@ -232,6 +232,57 @@ export function getClaudeProjectsDir(): string { return path.join(getClaudeConfigDir(), 'projects'); } +/** + * 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(); + 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(process.env.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 typical location + path.join( + process.env.LOCALAPPDATA || '', + 'Microsoft', + 'WinGet', + 'Packages', + 'Git.Git_*', + 'bin', + 'bash.exe' + ), + // GitHub Desktop bundled Git + path.join( + process.env.LOCALAPPDATA || '', + 'GitHubDesktop', + 'app-*', + 'resources', + 'app', + 'git', + 'cmd', + 'bash.exe' + ), + ].filter(Boolean); +} + /** * Get common shell paths for shell detection * Includes both full paths and short names to match $SHELL or PATH entries @@ -550,6 +601,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 +936,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 */ From c24e6207d07bb35e726ec07aa5a8d4133414ea4f Mon Sep 17 00:00:00 2001 From: Kacper Date: Sat, 10 Jan 2026 22:46:06 +0100 Subject: [PATCH 03/14] feat: Enhance ShellSyntaxEditor and WorktreesSection with new features This commit introduces several improvements to the ShellSyntaxEditor and WorktreesSection components: 1. **ShellSyntaxEditor**: Added a `maxHeight` prop to allow for customizable maximum height, enhancing layout flexibility. 2. **WorktreesSection**: - Introduced state management for original script content and existence checks for scripts. - Implemented save, reset, and delete functionalities for initialization scripts, providing users with better control over their scripts. - Added action buttons for saving, resetting, and deleting scripts, along with loading indicators for improved user feedback. - Enhanced UI to indicate unsaved changes, improving user awareness of script modifications. These changes improve the user experience by providing more robust script management capabilities and a more responsive UI. --- .../src/components/ui/shell-syntax-editor.tsx | 11 +- .../worktrees/worktrees-section.tsx | 219 +++++++++++------- 2 files changed, 139 insertions(+), 91 deletions(-) diff --git a/apps/ui/src/components/ui/shell-syntax-editor.tsx b/apps/ui/src/components/ui/shell-syntax-editor.tsx index 03675539..159123c4 100644 --- a/apps/ui/src/components/ui/shell-syntax-editor.tsx +++ b/apps/ui/src/components/ui/shell-syntax-editor.tsx @@ -13,6 +13,7 @@ interface ShellSyntaxEditorProps { placeholder?: string; className?: string; minHeight?: string; + maxHeight?: string; 'data-testid'?: string; } @@ -108,14 +109,12 @@ export function ShellSyntaxEditor({ placeholder, className, minHeight = '200px', + maxHeight, 'data-testid': testId, }: ShellSyntaxEditorProps) { return (
@@ -125,7 +124,9 @@ export function ShellSyntaxEditor({ extensions={extensions} theme="none" placeholder={placeholder} - className="h-full [&_.cm-editor]:h-full [&_.cm-editor]:min-h-[inherit]" + height={maxHeight} + minHeight={minHeight} + className="[&_.cm-editor]:min-h-[inherit]" basicSetup={{ lineNumbers: true, foldGutter: false, 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 index 1af4c755..8a615c58 100644 --- a/apps/ui/src/components/views/settings-view/worktrees/worktrees-section.tsx +++ b/apps/ui/src/components/views/settings-view/worktrees/worktrees-section.tsx @@ -1,10 +1,11 @@ -import { useState, useEffect, useCallback, useRef } from 'react'; +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, Check, Loader2 } from 'lucide-react'; +import { GitBranch, Terminal, FileCode, Save, RotateCcw, Trash2, Loader2 } from 'lucide-react'; import { cn } from '@/lib/utils'; -import { apiPost, apiPut } from '@/lib/api-fetch'; +import { apiPost, apiPut, apiDelete } from '@/lib/api-fetch'; import { toast } from 'sonner'; import { useAppStore } from '@/store/app-store'; @@ -24,18 +25,21 @@ interface InitScriptResponse { export function WorktreesSection({ useWorktrees, onUseWorktreesChange }: WorktreesSectionProps) { const currentProject = useAppStore((s) => s.currentProject); const [scriptContent, setScriptContent] = useState(''); - const [scriptPath, setScriptPath] = useState(''); + const [originalContent, setOriginalContent] = useState(''); + const [scriptExists, setScriptExists] = useState(false); const [isLoading, setIsLoading] = useState(true); const [isSaving, setIsSaving] = useState(false); - const [showSaved, setShowSaved] = useState(false); - const saveTimeoutRef = useRef | null>(null); - const savedTimeoutRef = useRef | null>(null); + const [isDeleting, setIsDeleting] = useState(false); + + // Check if there are unsaved changes + const hasChanges = scriptContent !== originalContent; // Load init script content when project changes useEffect(() => { if (!currentProject?.path) { setScriptContent(''); - setScriptPath(''); + setOriginalContent(''); + setScriptExists(false); setIsLoading(false); return; } @@ -47,8 +51,10 @@ export function WorktreesSection({ useWorktrees, onUseWorktreesChange }: Worktre projectPath: currentProject.path, }); if (response.success) { - setScriptContent(response.content || ''); - setScriptPath(response.path || ''); + const content = response.content || ''; + setScriptContent(content); + setOriginalContent(content); + setScriptExists(response.exists); } } catch (error) { console.error('Failed to load init script:', error); @@ -60,66 +66,74 @@ export function WorktreesSection({ useWorktrees, onUseWorktreesChange }: Worktre loadInitScript(); }, [currentProject?.path]); - // Debounced save function - const saveScript = useCallback( - async (content: string) => { - if (!currentProject?.path) return; + // 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, - } - ); - if (response.success) { - setShowSaved(true); - savedTimeoutRef.current = setTimeout(() => setShowSaved(false), 2000); - } else { - toast.error('Failed to save init script', { - description: response.error, - }); + setIsSaving(true); + try { + const response = await apiPut<{ success: boolean; error?: string }>( + '/api/worktree/init-script', + { + projectPath: currentProject.path, + content: scriptContent, } - } catch (error) { - console.error('Failed to save init script:', error); - toast.error('Failed to save init script'); - } finally { - setIsSaving(false); + ); + if (response.success) { + setOriginalContent(scriptContent); + setScriptExists(true); + toast.success('Init script saved'); + } else { + toast.error('Failed to save init script', { + description: response.error, + }); } - }, - [currentProject?.path] - ); + } catch (error) { + console.error('Failed to save init script:', error); + toast.error('Failed to save init script'); + } finally { + setIsSaving(false); + } + }, [currentProject?.path, scriptContent]); - // Handle content change with debounce - const handleContentChange = useCallback( - (value: string) => { - setScriptContent(value); - setShowSaved(false); + // Reset to original content + const handleReset = useCallback(() => { + setScriptContent(originalContent); + }, [originalContent]); - // Clear existing timeouts - if (saveTimeoutRef.current) { - clearTimeout(saveTimeoutRef.current); - } - if (savedTimeoutRef.current) { - clearTimeout(savedTimeoutRef.current); + // 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]); - // Debounce save - saveTimeoutRef.current = setTimeout(() => { - saveScript(value); - }, 1000); - }, - [saveScript] - ); - - // Cleanup timeouts - useEffect(() => { - return () => { - if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current); - if (savedTimeoutRef.current) clearTimeout(savedTimeoutRef.current); - }; + // Handle content change (no auto-save) + const handleContentChange = useCallback((value: string) => { + setScriptContent(value); }, []); return ( @@ -177,24 +191,10 @@ export function WorktreesSection({ useWorktrees, onUseWorktreesChange }: Worktre
-
- {isSaving && ( - - - Saving... - - )} - {showSaved && !isSaving && ( - - - Saved - - )} -

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

{currentProject ? ( @@ -203,6 +203,9 @@ export function WorktreesSection({ useWorktrees, onUseWorktreesChange }: Worktre
.automaker/worktree-init.sh + {hasChanges && ( + (unsaved changes) + )}
{isLoading ? ( @@ -210,10 +213,11 @@ export function WorktreesSection({ useWorktrees, onUseWorktreesChange }: Worktre ) : ( - + + minHeight="200px" + maxHeight="500px" + data-testid="init-script-editor" + /> + + {/* Action buttons */} +
+ + + +
+ )} ) : ( From aeb5bd829f072f413dd8683ac2656e18b3164a6c Mon Sep 17 00:00:00 2001 From: Kacper Date: Sat, 10 Jan 2026 23:03:29 +0100 Subject: [PATCH 04/14] feat: Add Init Script Indicator visibility feature for worktrees This commit introduces a new feature allowing users to toggle the visibility of the Init Script Indicator for each project. Key changes include: 1. **State Management**: Added `showInitScriptIndicatorByProject` to manage the visibility state per project. 2. **UI Components**: Implemented a checkbox in the WorktreesSection to enable or disable the Init Script Indicator, enhancing user control over the UI. 3. **BoardView Updates**: Modified the BoardView to conditionally render the Init Script Indicator based on the new visibility state. These enhancements improve the user experience by providing customizable visibility options for the Init Script Indicator, streamlining project management workflows. --- apps/ui/src/components/views/board-view.tsx | 9 +++- .../worktrees/worktrees-section.tsx | 47 ++++++++++++++++++- apps/ui/src/store/app-store.ts | 24 ++++++++++ libs/types/src/settings.ts | 2 + 4 files changed, 80 insertions(+), 2 deletions(-) diff --git a/apps/ui/src/components/views/board-view.tsx b/apps/ui/src/components/views/board-view.tsx index b4c050f6..028f7bd1 100644 --- a/apps/ui/src/components/views/board-view.tsx +++ b/apps/ui/src/components/views/board-view.tsx @@ -114,6 +114,11 @@ 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 shortcuts = useKeyboardShortcutsConfig(); const { features: hookFeatures, @@ -1577,7 +1582,9 @@ export function BoardView() { /> {/* Init Script Indicator - floating overlay for worktree init script status */} - + {getShowInitScriptIndicator(currentProject.path) && ( + + )} ); } 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 index 8a615c58..01ff77c6 100644 --- a/apps/ui/src/components/views/settings-view/worktrees/worktrees-section.tsx +++ b/apps/ui/src/components/views/settings-view/worktrees/worktrees-section.tsx @@ -3,7 +3,16 @@ 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 } from 'lucide-react'; +import { + GitBranch, + Terminal, + FileCode, + Save, + RotateCcw, + Trash2, + Loader2, + PanelBottomClose, +} from 'lucide-react'; import { cn } from '@/lib/utils'; import { apiPost, apiPut, apiDelete } from '@/lib/api-fetch'; import { toast } from 'sonner'; @@ -24,6 +33,8 @@ interface InitScriptResponse { 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 [scriptContent, setScriptContent] = useState(''); const [originalContent, setOriginalContent] = useState(''); const [scriptExists, setScriptExists] = useState(false); @@ -31,6 +42,11 @@ export function WorktreesSection({ useWorktrees, onUseWorktreesChange }: Worktre const [isSaving, setIsSaving] = useState(false); const [isDeleting, setIsDeleting] = useState(false); + // Get the current show indicator setting + const showIndicator = currentProject?.path + ? getShowInitScriptIndicator(currentProject.path) + : true; + // Check if there are unsaved changes const hasChanges = scriptContent !== originalContent; @@ -181,6 +197,35 @@ export function WorktreesSection({ useWorktrees, onUseWorktreesChange }: Worktre + {/* Show Init Script Indicator Toggle */} + {currentProject && ( +
+ { + if (currentProject?.path) { + setShowInitScriptIndicator(currentProject.path, checked === true); + } + }} + className="mt-1" + /> +
+ +

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

+
+
+ )} + {/* Separator */}
diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index 5479c015..974ffe87 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -665,6 +665,10 @@ 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; + // UI State (previously in localStorage, now synced via API) /** Whether worktree panel is collapsed in board view */ worktreePanelCollapsed: boolean; @@ -1078,6 +1082,10 @@ 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; + // UI State actions (previously in localStorage, now synced via API) setWorktreePanelCollapsed: (collapsed: boolean) => void; setLastProjectDir: (dir: string) => void; @@ -1208,6 +1216,7 @@ const initialState: AppState = { codexModelsLastFetched: null, pipelineConfigByProject: {}, worktreePanelVisibleByProject: {}, + showInitScriptIndicatorByProject: {}, // UI State (previously in localStorage, now synced via API) worktreePanelCollapsed: false, lastProjectDir: '', @@ -3124,6 +3133,21 @@ 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; + }, + // UI State actions (previously in localStorage, now synced via API) setWorktreePanelCollapsed: (collapsed) => set({ worktreePanelCollapsed: collapsed }), setLastProjectDir: (dir) => set({ lastProjectDir: dir }), diff --git a/libs/types/src/settings.ts b/libs/types/src/settings.ts index 07b4290d..7a46d4aa 100644 --- a/libs/types/src/settings.ts +++ b/libs/types/src/settings.ts @@ -595,6 +595,8 @@ 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; // Session Tracking /** Last chat session selected in this project */ From e902e8ea4cf74e8721ab0ffe3e89721df7340395 Mon Sep 17 00:00:00 2001 From: Kacper Date: Sat, 10 Jan 2026 23:18:39 +0100 Subject: [PATCH 05/14] feat: Introduce default delete branch option for worktrees This commit adds a new feature allowing users to set a default value for the "delete branch" checkbox when deleting a worktree. Key changes include: 1. **State Management**: Introduced `defaultDeleteBranchByProject` to manage the default delete branch setting per project. 2. **UI Components**: Updated the WorktreesSection to include a toggle for the default delete branch option, enhancing user control during worktree deletion. 3. **Dialog Updates**: Modified the DeleteWorktreeDialog to respect the default delete branch setting, improving the user experience by streamlining the deletion process. These enhancements provide users with more flexibility and control over worktree management, improving overall project workflows. --- apps/ui/src/components/views/board-view.tsx | 2 + .../dialogs/delete-worktree-dialog.tsx | 14 +- .../board-view/init-script-indicator.tsx | 127 ++++++++++++------ .../worktrees/worktrees-section.tsx | 35 +++++ apps/ui/src/hooks/use-init-script-events.ts | 6 +- apps/ui/src/store/app-store.ts | 79 ++++++++--- libs/types/src/settings.ts | 4 + 7 files changed, 204 insertions(+), 63 deletions(-) diff --git a/apps/ui/src/components/views/board-view.tsx b/apps/ui/src/components/views/board-view.tsx index 028f7bd1..683c46c5 100644 --- a/apps/ui/src/components/views/board-view.tsx +++ b/apps/ui/src/components/views/board-view.tsx @@ -119,6 +119,7 @@ export function BoardView() { (state) => state.showInitScriptIndicatorByProject ); const getShowInitScriptIndicator = useAppStore((state) => state.getShowInitScriptIndicator); + const getDefaultDeleteBranch = useAppStore((state) => state.getDefaultDeleteBranch); const shortcuts = useKeyboardShortcutsConfig(); const { features: hookFeatures, @@ -1513,6 +1514,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) => { 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/init-script-indicator.tsx b/apps/ui/src/components/views/board-view/init-script-indicator.tsx index ca108dbd..a039f58b 100644 --- a/apps/ui/src/components/views/board-view/init-script-indicator.tsx +++ b/apps/ui/src/components/views/board-view/init-script-indicator.tsx @@ -1,4 +1,4 @@ -import { useState, useRef, useEffect } from 'react'; +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'; @@ -8,45 +8,38 @@ interface InitScriptIndicatorProps { projectPath: string; } -export function InitScriptIndicator({ projectPath }: InitScriptIndicatorProps) { - const initScriptState = useAppStore((s) => s.initScriptState[projectPath]); - const clearInitScriptState = useAppStore((s) => s.clearInitScriptState); +interface SingleIndicatorProps { + stateKey: string; + state: InitScriptState; + onDismiss: (key: string) => void; + isOnlyOne: boolean; // Whether this is the only indicator shown +} + +function SingleIndicator({ stateKey, state, onDismiss, isOnlyOne }: SingleIndicatorProps) { const [showLogs, setShowLogs] = useState(false); - const [dismissed, setDismissed] = 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' }); } - }, [initScriptState?.output, showLogs]); + }, [output, showLogs]); - // Reset dismissed state when a new script starts + // Auto-expand logs when script starts (only if it's the only one or running) useEffect(() => { - if (initScriptState?.status === 'running') { - setDismissed(false); + if (status === 'running' && isOnlyOne) { setShowLogs(true); } - }, [initScriptState?.status]); + }, [status, isOnlyOne]); - if (!initScriptState || dismissed) return null; - if (initScriptState.status === 'idle') return null; - - const { status, output, branch, error } = initScriptState; - - const handleDismiss = () => { - setDismissed(true); - // Clear state after a delay to allow for future scripts - setTimeout(() => { - clearInitScriptState(projectPath); - }, 100); - }; + if (status === 'idle') return null; return (
- {status === 'running' && ( - - )} + {status === 'running' && } {status === 'success' && } {status === 'failed' && } Init Script{' '} - {status === 'running' - ? 'Running' - : status === 'success' - ? 'Completed' - : 'Failed'} + {status === 'running' ? 'Running' : status === 'success' ? 'Completed' : 'Failed'}
@@ -83,7 +70,7 @@ export function InitScriptIndicator({ projectPath }: InitScriptIndicatorProps) { {status !== 'running' && (
)} - {error && ( -
- Error: {error} -
- )} + {error &&
Error: {error}
}
@@ -125,9 +108,7 @@ export function InitScriptIndicator({ projectPath }: InitScriptIndicatorProps) {
{status === 'success' @@ -138,3 +119,69 @@ export function InitScriptIndicator({ projectPath }: InitScriptIndicatorProps) {
); } + +export function InitScriptIndicator({ projectPath }: InitScriptIndicatorProps) { + const getInitScriptStatesForProject = useAppStore((s) => s.getInitScriptStatesForProject); + const clearInitScriptState = useAppStore((s) => s.clearInitScriptState); + const [dismissedKeys, setDismissedKeys] = useState>(new Set()); + + // 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/settings-view/worktrees/worktrees-section.tsx b/apps/ui/src/components/views/settings-view/worktrees/worktrees-section.tsx index 01ff77c6..4a281995 100644 --- a/apps/ui/src/components/views/settings-view/worktrees/worktrees-section.tsx +++ b/apps/ui/src/components/views/settings-view/worktrees/worktrees-section.tsx @@ -35,6 +35,8 @@ export function WorktreesSection({ useWorktrees, onUseWorktreesChange }: Worktre 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 [scriptContent, setScriptContent] = useState(''); const [originalContent, setOriginalContent] = useState(''); const [scriptExists, setScriptExists] = useState(false); @@ -47,6 +49,11 @@ export function WorktreesSection({ useWorktrees, onUseWorktreesChange }: Worktre ? getShowInitScriptIndicator(currentProject.path) : true; + // Get the default delete branch setting + const defaultDeleteBranch = currentProject?.path + ? getDefaultDeleteBranch(currentProject.path) + : false; + // Check if there are unsaved changes const hasChanges = scriptContent !== originalContent; @@ -226,6 +233,34 @@ export function WorktreesSection({ useWorktrees, onUseWorktreesChange }: Worktre
)} + {/* Default Delete Branch Toggle */} + {currentProject && ( +
+ { + if (currentProject?.path) { + setDefaultDeleteBranch(currentProject.path, checked === true); + } + }} + className="mt-1" + /> +
+ +

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

+
+
+ )} + {/* Separator */}
diff --git a/apps/ui/src/hooks/use-init-script-events.ts b/apps/ui/src/hooks/use-init-script-events.ts index b95b485d..aa51409f 100644 --- a/apps/ui/src/hooks/use-init-script-events.ts +++ b/apps/ui/src/hooks/use-init-script-events.ts @@ -50,7 +50,7 @@ export function useInitScriptEvents(projectPath: string | null) { switch (event.type) { case 'worktree:init-started': { const startPayload = payload as InitScriptStartedPayload; - setInitScriptState(projectPath, { + setInitScriptState(projectPath, startPayload.branch, { status: 'running', branch: startPayload.branch, output: [], @@ -60,12 +60,12 @@ export function useInitScriptEvents(projectPath: string | null) { } case 'worktree:init-output': { const outputPayload = payload as InitScriptOutputPayload; - appendInitScriptOutput(projectPath, outputPayload.content); + appendInitScriptOutput(projectPath, outputPayload.branch, outputPayload.content); break; } case 'worktree:init-completed': { const completePayload = payload as InitScriptCompletedPayload; - setInitScriptState(projectPath, { + setInitScriptState(projectPath, completePayload.branch, { status: completePayload.success ? 'success' : 'failed', error: completePayload.error, }); diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index 974ffe87..2d9f42da 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -669,6 +669,10 @@ export interface AppState { // 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; + // UI State (previously in localStorage, now synced via API) /** Whether worktree panel is collapsed in board view */ worktreePanelCollapsed: boolean; @@ -677,7 +681,7 @@ export interface AppState { /** Recently accessed folders for quick access */ recentFolders: string[]; - // Init Script State (per-project, keyed by project path) + // Init Script State (keyed by "projectPath::branch" to support concurrent scripts) initScriptState: Record; } @@ -1086,6 +1090,10 @@ export interface AppActions { 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; + // UI State actions (previously in localStorage, now synced via API) setWorktreePanelCollapsed: (collapsed: boolean) => void; setLastProjectDir: (dir: string) => void; @@ -1114,11 +1122,18 @@ export interface AppActions { }> ) => void; - // Init Script State actions - setInitScriptState: (projectPath: string, state: Partial) => void; - appendInitScriptOutput: (projectPath: string, content: string) => void; - clearInitScriptState: (projectPath: string) => void; - getInitScriptState: (projectPath: string) => InitScriptState | null; + // 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; @@ -1217,6 +1232,7 @@ const initialState: AppState = { pipelineConfigByProject: {}, worktreePanelVisibleByProject: {}, showInitScriptIndicatorByProject: {}, + defaultDeleteBranchByProject: {}, // UI State (previously in localStorage, now synced via API) worktreePanelCollapsed: false, lastProjectDir: '', @@ -3148,6 +3164,21 @@ export const useAppStore = create()((set, get) => ({ 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; + }, + // UI State actions (previously in localStorage, now synced via API) setWorktreePanelCollapsed: (collapsed) => set({ worktreePanelCollapsed: collapsed }), setLastProjectDir: (dir) => set({ lastProjectDir: dir }), @@ -3161,28 +3192,30 @@ export const useAppStore = create()((set, get) => ({ set({ recentFolders: updated }); }, - // Init Script State actions - setInitScriptState: (projectPath, state) => { - const current = get().initScriptState[projectPath] || { + // Init Script State actions (keyed by "projectPath::branch") + setInitScriptState: (projectPath, branch, state) => { + const key = `${projectPath}::${branch}`; + const current = get().initScriptState[key] || { status: 'idle', - branch: '', + branch, output: [], }; set({ initScriptState: { ...get().initScriptState, - [projectPath]: { ...current, ...state }, + [key]: { ...current, ...state }, }, }); }, - appendInitScriptOutput: (projectPath, content) => { - const current = get().initScriptState[projectPath]; + appendInitScriptOutput: (projectPath, branch, content) => { + const key = `${projectPath}::${branch}`; + const current = get().initScriptState[key]; if (!current) return; set({ initScriptState: { ...get().initScriptState, - [projectPath]: { + [key]: { ...current, output: [...current.output, content], }, @@ -3190,13 +3223,23 @@ export const useAppStore = create()((set, get) => ({ }); }, - clearInitScriptState: (projectPath) => { - const { [projectPath]: _, ...rest } = get().initScriptState; + clearInitScriptState: (projectPath, branch) => { + const key = `${projectPath}::${branch}`; + const { [key]: _, ...rest } = get().initScriptState; set({ initScriptState: rest }); }, - getInitScriptState: (projectPath) => { - return get().initScriptState[projectPath] || null; + 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 diff --git a/libs/types/src/settings.ts b/libs/types/src/settings.ts index 7a46d4aa..1b8f37d4 100644 --- a/libs/types/src/settings.ts +++ b/libs/types/src/settings.ts @@ -598,6 +598,10 @@ export interface ProjectSettings { /** 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; + // Session Tracking /** Last chat session selected in this project */ lastSelectedSessionId?: string; From d98ff16c8f4d085a8c89167545fb36a67f53e6fa Mon Sep 17 00:00:00 2001 From: Kacper Date: Sat, 10 Jan 2026 23:37:39 +0100 Subject: [PATCH 06/14] feat: Enhance CreateWorktreeDialog with user-friendly error handling This commit introduces a new error parsing function to provide clearer, user-friendly error messages in the CreateWorktreeDialog component. Key changes include: 1. **Error Parsing**: Added `parseWorktreeError` function to interpret various git-related error messages and return structured titles and descriptions for better user feedback. 2. **State Management**: Updated the error state to store structured error objects instead of strings, allowing for more detailed error display. 3. **UI Updates**: Enhanced the error display in the dialog to show both title and description, improving clarity for users encountering issues during worktree creation. These improvements enhance the user experience by providing more informative error messages, helping users troubleshoot issues effectively. --- .../dialogs/create-worktree-dialog.tsx | 96 +++++++++++++++++-- 1 file changed, 86 insertions(+), 10 deletions(-) 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}

+ )} +
+
+ )}
From 09527b3b67fd3b9221532767f7e022e1a8ac06ce Mon Sep 17 00:00:00 2001 From: Kacper Date: Sat, 10 Jan 2026 23:43:52 +0100 Subject: [PATCH 07/14] feat: Add auto-dismiss functionality for Init Script Indicator This commit introduces an auto-dismiss feature for the Init Script Indicator, enhancing user experience by automatically hiding the indicator 5 seconds after the script completes. Key changes include: 1. **State Management**: Added `autoDismissInitScriptIndicatorByProject` to manage the auto-dismiss setting per project. 2. **UI Components**: Updated the WorktreesSection to include a toggle for enabling or disabling the auto-dismiss feature, allowing users to customize their experience. 3. **Indicator Logic**: Implemented logic in the SingleIndicator component to handle auto-dismiss based on the new setting. These enhancements provide users with more control over the visibility of the Init Script Indicator, streamlining project management workflows. --- .../board-view/init-script-indicator.tsx | 24 ++++++++++++- .../worktrees/worktrees-section.tsx | 34 +++++++++++++++++++ apps/ui/src/store/app-store.ts | 24 +++++++++++++ libs/types/src/settings.ts | 2 ++ 4 files changed, 83 insertions(+), 1 deletion(-) 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 index a039f58b..33298394 100644 --- a/apps/ui/src/components/views/board-view/init-script-indicator.tsx +++ b/apps/ui/src/components/views/board-view/init-script-indicator.tsx @@ -13,9 +13,16 @@ interface SingleIndicatorProps { 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 }: SingleIndicatorProps) { +function SingleIndicator({ + stateKey, + state, + onDismiss, + isOnlyOne, + autoDismiss, +}: SingleIndicatorProps) { const [showLogs, setShowLogs] = useState(false); const logsEndRef = useRef(null); @@ -35,6 +42,16 @@ function SingleIndicator({ stateKey, state, onDismiss, isOnlyOne }: SingleIndica } }, [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 ( @@ -123,8 +140,12 @@ function SingleIndicator({ stateKey, state, onDismiss, isOnlyOne }: SingleIndica 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); @@ -180,6 +201,7 @@ export function InitScriptIndicator({ projectPath }: InitScriptIndicatorProps) { state={state} onDismiss={handleDismiss} isOnlyOne={activeStates.length === 1} + autoDismiss={autoDismiss} /> ))}
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 index 4a281995..fd43f3d0 100644 --- a/apps/ui/src/components/views/settings-view/worktrees/worktrees-section.tsx +++ b/apps/ui/src/components/views/settings-view/worktrees/worktrees-section.tsx @@ -37,6 +37,8 @@ export function WorktreesSection({ useWorktrees, onUseWorktreesChange }: Worktre 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); @@ -54,6 +56,11 @@ export function WorktreesSection({ useWorktrees, onUseWorktreesChange }: Worktre ? 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; @@ -233,6 +240,33 @@ export function WorktreesSection({ useWorktrees, onUseWorktreesChange }: Worktre )} + {/* Auto-dismiss Init Script Indicator Toggle */} + {currentProject && showIndicator && ( +
+ { + if (currentProject?.path) { + setAutoDismissInitScriptIndicator(currentProject.path, checked === true); + } + }} + className="mt-1" + /> +
+ +

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

+
+
+ )} + {/* Default Delete Branch Toggle */} {currentProject && (
diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index 2d9f42da..7b19a1ab 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -673,6 +673,10 @@ export interface AppState { // 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; @@ -1094,6 +1098,10 @@ export interface AppActions { 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; @@ -1233,6 +1241,7 @@ const initialState: AppState = { worktreePanelVisibleByProject: {}, showInitScriptIndicatorByProject: {}, defaultDeleteBranchByProject: {}, + autoDismissInitScriptIndicatorByProject: {}, // UI State (previously in localStorage, now synced via API) worktreePanelCollapsed: false, lastProjectDir: '', @@ -3179,6 +3188,21 @@ export const useAppStore = create()((set, get) => ({ 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 }), diff --git a/libs/types/src/settings.ts b/libs/types/src/settings.ts index 1b8f37d4..ab16d03d 100644 --- a/libs/types/src/settings.ts +++ b/libs/types/src/settings.ts @@ -601,6 +601,8 @@ export interface ProjectSettings { // 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 */ From 861fff1aaee05370217b6e05634165d60694a2b6 Mon Sep 17 00:00:00 2001 From: Kacper Date: Sat, 10 Jan 2026 23:48:33 +0100 Subject: [PATCH 08/14] fix: broken lock file --- package-lock.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index 8c7f0355..9ec0585e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1479,7 +1479,7 @@ }, "node_modules/@electron/node-gyp": { "version": "10.2.0-electron.1", - "resolved": "git+ssh://git@github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2", + "resolved": "git+https://github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2", "integrity": "sha512-4MSBTT8y07YUDqf69/vSh80Hh791epYqGtWHO3zSKhYFwQg+gx9wi1PqbqP6YqC4WMsNxZ5l9oDmnWdK5pfCKQ==", "dev": true, "license": "MIT", From 385e7f5c1e596400d61f371dd144475649c3e79a Mon Sep 17 00:00:00 2001 From: Kacper Date: Sun, 11 Jan 2026 00:01:23 +0100 Subject: [PATCH 09/14] fix: address pr comments --- apps/server/src/routes/worktree/index.ts | 2 +- .../src/routes/worktree/routes/create.ts | 28 +++++++------------ .../src/routes/worktree/routes/init-script.ts | 21 +++++--------- .../src/services/init-script-service.ts | 6 ++-- .../worktrees/worktrees-section.tsx | 8 +++--- apps/ui/src/lib/http-api-client.ts | 3 +- 6 files changed, 28 insertions(+), 40 deletions(-) diff --git a/apps/server/src/routes/worktree/index.ts b/apps/server/src/routes/worktree/index.ts index a98377fb..54f7ba9e 100644 --- a/apps/server/src/routes/worktree/index.ts +++ b/apps/server/src/routes/worktree/index.ts @@ -95,7 +95,7 @@ export function createWorktreeRoutes(events: EventEmitter): Router { router.post('/list-dev-servers', createListDevServersHandler()); // Init script routes - router.post('/init-script', validatePathParams('projectPath'), createGetInitScriptHandler()); + router.get('/init-script', createGetInitScriptHandler()); router.put('/init-script', validatePathParams('projectPath'), createPutInitScriptHandler()); router.delete('/init-script', validatePathParams('projectPath'), createDeleteInitScriptHandler()); router.post( diff --git a/apps/server/src/routes/worktree/routes/create.ts b/apps/server/src/routes/worktree/routes/create.ts index ecc3b9b0..87ad7844 100644 --- a/apps/server/src/routes/worktree/routes/create.ts +++ b/apps/server/src/routes/worktree/routes/create.ts @@ -22,8 +22,7 @@ import { } from '../common.js'; import { trackBranch } from './branch-tracking.js'; import { createLogger } from '@automaker/utils'; -import { runInitScript, getInitScriptPath, hasInitScriptRun } from '../../../services/init-script-service.js'; -import fs from 'fs'; +import { runInitScript } from '../../../services/init-script-service.js'; const logger = createLogger('Worktree'); @@ -181,11 +180,6 @@ export function createCreateHandler(events: EventEmitter) { // normalizePath converts to forward slashes for API consistency const absoluteWorktreePath = path.resolve(worktreePath); - // Check if init script exists and should be run (only for new worktrees) - const initScriptPath = getInitScriptPath(projectPath); - const hasInitScript = fs.existsSync(initScriptPath); - const alreadyRan = await hasInitScriptRun(projectPath, branchName); - // Respond immediately (non-blocking) res.json({ success: true, @@ -197,17 +191,15 @@ export function createCreateHandler(events: EventEmitter) { }); // Trigger init script asynchronously after response - if (hasInitScript && !alreadyRan) { - logger.info(`Triggering init script for worktree: ${branchName}`); - runInitScript({ - projectPath, - worktreePath: absoluteWorktreePath, - branch: branchName, - emitter: events, - }).catch((err) => { - logger.error(`Init script failed for ${branchName}:`, err); - }); - } + // 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/init-script.ts b/apps/server/src/routes/worktree/routes/init-script.ts index 0389bce3..e97bddf3 100644 --- a/apps/server/src/routes/worktree/routes/init-script.ts +++ b/apps/server/src/routes/worktree/routes/init-script.ts @@ -33,12 +33,12 @@ function getInitScriptPath(projectPath: string): string { export function createGetInitScriptHandler() { return async (req: Request, res: Response): Promise => { try { - const { projectPath } = req.body as { projectPath: string }; + const projectPath = req.query.projectPath as string; if (!projectPath) { res.status(400).json({ success: false, - error: 'projectPath is required', + error: 'projectPath query parameter is required', }); return; } @@ -142,18 +142,11 @@ export function createDeleteInitScriptHandler() { const scriptPath = getInitScriptPath(projectPath); - try { - await secureFs.rm(scriptPath, { force: true }); - logger.info(`Deleted init script at ${scriptPath}`); - res.json({ - success: true, - }); - } catch { - // File doesn't exist - still success - res.json({ - success: true, - }); - } + 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({ diff --git a/apps/server/src/services/init-script-service.ts b/apps/server/src/services/init-script-service.ts index ac354bff..78328a1d 100644 --- a/apps/server/src/services/init-script-service.ts +++ b/apps/server/src/services/init-script-service.ts @@ -150,10 +150,12 @@ export class InitScriptService { : 'No shell found (/bin/bash or /bin/sh)'; logger.error(error); - // Update metadata with error + // Update metadata with error, preserving existing metadata + const existingMetadata = await readWorktreeMetadata(projectPath, branch); await writeWorktreeMetadata(projectPath, branch, { branch, - createdAt: new Date().toISOString(), + createdAt: existingMetadata?.createdAt || new Date().toISOString(), + pr: existingMetadata?.pr, initScriptRan: true, initScriptStatus: 'failed', initScriptError: error, 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 index fd43f3d0..20eb8680 100644 --- a/apps/ui/src/components/views/settings-view/worktrees/worktrees-section.tsx +++ b/apps/ui/src/components/views/settings-view/worktrees/worktrees-section.tsx @@ -14,7 +14,7 @@ import { PanelBottomClose, } from 'lucide-react'; import { cn } from '@/lib/utils'; -import { apiPost, apiPut, apiDelete } from '@/lib/api-fetch'; +import { apiGet, apiPut, apiDelete } from '@/lib/api-fetch'; import { toast } from 'sonner'; import { useAppStore } from '@/store/app-store'; @@ -77,9 +77,9 @@ export function WorktreesSection({ useWorktrees, onUseWorktreesChange }: Worktre const loadInitScript = async () => { setIsLoading(true); try { - const response = await apiPost('/api/worktree/init-script', { - projectPath: currentProject.path, - }); + const response = await apiGet( + `/api/worktree/init-script?projectPath=${encodeURIComponent(currentProject.path)}` + ); if (response.success) { const content = response.content || ''; setScriptContent(content); diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index f6f6e266..968e2d30 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -1613,7 +1613,8 @@ export class HttpApiClient implements ElectronAPI { getPRInfo: (worktreePath: string, branchName: string) => this.post('/api/worktree/pr-info', { worktreePath, branchName }), // Init script methods - getInitScript: (projectPath: string) => this.post('/api/worktree/init-script', { projectPath }), + 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) => From 8ed2fa07a0655cfb18a0ee3b96fb9ffc235c68a7 Mon Sep 17 00:00:00 2001 From: Shirone Date: Sun, 11 Jan 2026 01:14:07 +0100 Subject: [PATCH 10/14] security: Fix critical vulnerabilities in worktree init script feature Fix multiple command injection and security vulnerabilities in the worktree initialization script system: **Critical Fixes:** - Add branch name validation to prevent command injection in create/delete endpoints - Replace string interpolation with array-based command execution using spawnProcess - Implement safe environment variable allowlist to prevent credential exposure - Add script content validation with 1MB size limit and dangerous pattern detection **Code Quality:** - Centralize execGitCommand helper in common.ts using @automaker/platform's spawnProcess - Remove duplicate isGitRepo implementation, standardize imports to @automaker/git-utils - Follow DRY principle by reusing existing platform utilities - Add comprehensive JSDoc documentation with security examples This addresses 6 critical/high severity vulnerabilities identified in security audit: 1. Command injection via unsanitized branch names (delete.ts) 2. Command injection via unsanitized branch names (create.ts) 3. Missing branch validation in init script execution 4. Environment variable exposure (ANTHROPIC_API_KEY and other secrets) 5. Path injection via command substitution 6. Arbitrary script execution without content limits Co-Authored-By: Claude Sonnet 4.5 --- apps/server/src/routes/worktree/common.ts | 54 +++++++++++++------ apps/server/src/routes/worktree/middleware.ts | 3 +- .../src/routes/worktree/routes/create.ts | 42 +++++++++++---- .../src/routes/worktree/routes/delete.ts | 25 +++++---- .../src/routes/worktree/routes/init-script.ts | 40 +++++++++++++- .../src/services/init-script-service.ts | 53 +++++++++++++----- 6 files changed, 165 insertions(+), 52 deletions(-) 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/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 87ad7844..061fa801 100644 --- a/apps/server/src/routes/worktree/routes/create.ts +++ b/apps/server/src/routes/worktree/routes/create.ts @@ -13,12 +13,14 @@ 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'; @@ -96,6 +98,26 @@ export function createCreateHandler(events: EventEmitter) { 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, @@ -145,30 +167,28 @@ export function createCreateHandler(events: EventEmitter) { // 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 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 index e97bddf3..bb04d706 100644 --- a/apps/server/src/routes/worktree/routes/init-script.ts +++ b/apps/server/src/routes/worktree/routes/init-script.ts @@ -10,7 +10,7 @@ import type { Request, Response } from 'express'; import path from 'path'; import * as secureFs from '../../../lib/secure-fs.js'; -import { getErrorMessage, logError } from '../common.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'; @@ -20,6 +20,9 @@ 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 */ @@ -99,6 +102,31 @@ export function createPutInitScriptHandler() { 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); @@ -193,6 +221,16 @@ export function createRunInitScriptHandler(events: EventEmitter) { 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 diff --git a/apps/server/src/services/init-script-service.ts b/apps/server/src/services/init-script-service.ts index 78328a1d..7731c5ee 100644 --- a/apps/server/src/services/init-script-service.ts +++ b/apps/server/src/services/init-script-service.ts @@ -191,22 +191,47 @@ export class InitScriptService { branch, }); - // Spawn the script + // 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: { - ...process.env, - // Provide useful env vars to the script - AUTOMAKER_PROJECT_PATH: projectPath, - AUTOMAKER_WORKTREE_PATH: worktreePath, - AUTOMAKER_BRANCH: branch, - // Force color output even though we're not a TTY - FORCE_COLOR: '1', - npm_config_color: 'always', - CLICOLOR_FORCE: '1', - // Git colors - GIT_TERMINAL_PROMPT: '0', - }, + env: safeEnv, stdio: ['ignore', 'pipe', 'pipe'], }); From 4a59e901e6fc3f5e28d1edd4e6ad22f4b094ef1d Mon Sep 17 00:00:00 2001 From: Shirone Date: Sun, 11 Jan 2026 01:15:27 +0100 Subject: [PATCH 11/14] chore: format --- apps/ui/src/components/ui/ansi-output.tsx | 18 ++++++++++++++++-- apps/ui/src/components/views/settings-view.tsx | 5 +---- .../feature-defaults-section.tsx | 1 - 3 files changed, 17 insertions(+), 7 deletions(-) diff --git a/apps/ui/src/components/ui/ansi-output.tsx b/apps/ui/src/components/ui/ansi-output.tsx index af9f8667..83b3c4ab 100644 --- a/apps/ui/src/components/ui/ansi-output.tsx +++ b/apps/ui/src/components/ui/ansi-output.tsx @@ -212,8 +212,22 @@ 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', + '#000000', + '#cd0000', + '#00cd00', + '#cdcd00', + '#0000ee', + '#cd00cd', + '#00cdcd', + '#e5e5e5', + '#7f7f7f', + '#ff0000', + '#00ff00', + '#ffff00', + '#5c5cff', + '#ff00ff', + '#00ffff', + '#ffffff', ]; return standardColors[index]; } diff --git a/apps/ui/src/components/views/settings-view.tsx b/apps/ui/src/components/views/settings-view.tsx index 52e09b1a..aa6a8a84 100644 --- a/apps/ui/src/components/views/settings-view.tsx +++ b/apps/ui/src/components/views/settings-view.tsx @@ -161,10 +161,7 @@ export function SettingsView() { ); case 'worktrees': return ( - + ); case 'account': return ; 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 5a75f903..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 @@ -251,7 +251,6 @@ export function FeatureDefaultsSection({

- ); From 53f5c2b2bbe146be5ba9bccc860755fdf2b3472b Mon Sep 17 00:00:00 2001 From: Kacper Date: Sun, 11 Jan 2026 20:52:07 +0100 Subject: [PATCH 12/14] feat(backlog): add branchName support to apply handler and UI components - Updated apply handler to accept an optional branchName from the request body. - Modified BoardView and BacklogPlanDialog components to pass currentBranch to the apply API. - Enhanced ElectronAPI and HttpApiClient to include branchName in the apply method. This change allows users to specify a branch when applying backlog plans, improving flexibility in feature management. --- apps/server/src/routes/backlog-plan/routes/apply.ts | 4 +++- apps/ui/src/components/views/board-view.tsx | 1 + .../views/board-view/dialogs/backlog-plan-dialog.tsx | 6 +++++- apps/ui/src/lib/electron.ts | 3 ++- apps/ui/src/lib/http-api-client.ts | 5 +++-- 5 files changed, 14 insertions(+), 5 deletions(-) diff --git a/apps/server/src/routes/backlog-plan/routes/apply.ts b/apps/server/src/routes/backlog-plan/routes/apply.ts index 71dc3bd9..09ec6696 100644 --- a/apps/server/src/routes/backlog-plan/routes/apply.ts +++ b/apps/server/src/routes/backlog-plan/routes/apply.ts @@ -12,9 +12,10 @@ 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 } = req.body as { projectPath: string; plan: BacklogPlanResult; + branchName?: string; }; if (!projectPath) { @@ -82,6 +83,7 @@ export function createApplyHandler() { dependencies: change.feature.dependencies, priority: change.feature.priority, status: 'backlog', + branchName, }); appliedChanges.push(`added:${newFeature.id}`); diff --git a/apps/ui/src/components/views/board-view.tsx b/apps/ui/src/components/views/board-view.tsx index b24e7bc4..028e55e3 100644 --- a/apps/ui/src/components/views/board-view.tsx +++ b/apps/ui/src/components/views/board-view.tsx @@ -1449,6 +1449,7 @@ export function BoardView() { setPendingPlanResult={setPendingBacklogPlan} isGeneratingPlan={isGeneratingPlan} setIsGeneratingPlan={setIsGeneratingPlan} + currentBranch={selectedWorktreeBranch} /> {/* Plan Approval Dialog */} 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/lib/electron.ts b/apps/ui/src/lib/electron.ts index e709f9c4..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; }; diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index 5ffb6639..d3b71a74 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -2202,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); From a0669d4262b59e467ba076063d764faacf14a2c5 Mon Sep 17 00:00:00 2001 From: Kacper Date: Sun, 11 Jan 2026 23:05:32 +0100 Subject: [PATCH 13/14] feat(board-view): enhance feature and plan dialogs with worktree branch settings - Added WorktreeSettingsDialog and PlanSettingsDialog components to manage worktree branch settings. - Integrated new settings into BoardHeader for toggling worktree branch usage in feature creation. - Updated AddFeatureDialog to utilize selected worktree branch for custom mode. - Introduced new state management in app-store for handling worktree branch preferences. These changes improve user control over feature creation workflows by allowing branch selection based on the current worktree context. --- apps/ui/src/components/views/board-view.tsx | 12 +++- .../views/board-view/board-header.tsx | 66 +++++++++++++++--- .../board-view/dialogs/add-feature-dialog.tsx | 58 ++++++++++++++-- .../dialogs/plan-settings-dialog.tsx | 67 +++++++++++++++++++ .../dialogs/worktree-settings-dialog.tsx | 67 +++++++++++++++++++ apps/ui/src/store/app-store.ts | 18 +++++ 6 files changed, 273 insertions(+), 15 deletions(-) create mode 100644 apps/ui/src/components/views/board-view/dialogs/plan-settings-dialog.tsx create mode 100644 apps/ui/src/components/views/board-view/dialogs/worktree-settings-dialog.tsx diff --git a/apps/ui/src/components/views/board-view.tsx b/apps/ui/src/components/views/board-view.tsx index 028e55e3..faa3f1d4 100644 --- a/apps/ui/src/components/views/board-view.tsx +++ b/apps/ui/src/components/views/board-view.tsx @@ -101,6 +101,8 @@ export function BoardView() { useWorktrees, enableDependencyBlocking, skipVerificationInAutoMode, + planUseSelectedWorktreeBranch, + addFeatureUseSelectedWorktreeBranch, isPrimaryWorktreeBranch, getPrimaryWorktreeBranch, setPipelineConfig, @@ -1370,6 +1372,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 */} @@ -1449,7 +1459,7 @@ export function BoardView() { setPendingPlanResult={setPendingBacklogPlan} isGeneratingPlan={isGeneratingPlan} setIsGeneratingPlan={setIsGeneratingPlan} - currentBranch={selectedWorktreeBranch} + currentBranch={planUseSelectedWorktreeBranch ? selectedWorktreeBranch : undefined} /> {/* Plan Approval Dialog */} 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/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/store/app-store.ts b/apps/ui/src/store/app-store.ts index 884309cc..25c431f3 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -530,6 +530,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) @@ -913,6 +915,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; @@ -1191,6 +1195,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: {}, @@ -1816,6 +1822,18 @@ export const useAppStore = create()((set, get) => ({ const { syncSettingsToServer } = await import('@/hooks/use-settings-migration'); await syncSettingsToServer(); }, + setPlanUseSelectedWorktreeBranch: async (enabled) => { + set({ planUseSelectedWorktreeBranch: enabled }); + // Sync to server settings file + const { syncSettingsToServer } = await import('@/hooks/use-settings-migration'); + await syncSettingsToServer(); + }, + setAddFeatureUseSelectedWorktreeBranch: async (enabled) => { + set({ addFeatureUseSelectedWorktreeBranch: enabled }); + // Sync to server settings file + const { syncSettingsToServer } = await import('@/hooks/use-settings-migration'); + await syncSettingsToServer(); + }, // Worktree Settings actions setUseWorktrees: (enabled) => set({ useWorktrees: enabled }), From f41a42010c93b5b0107e5af368783d49cb69d776 Mon Sep 17 00:00:00 2001 From: Shirone Date: Mon, 12 Jan 2026 18:41:56 +0100 Subject: [PATCH 14/14] fix: address pr comments --- .../src/routes/backlog-plan/routes/apply.ts | 12 ++- .../worktrees/worktrees-section.tsx | 43 +++++++++-- .../src/hooks/use-project-settings-loader.ts | 26 +++++++ apps/ui/src/store/app-store.ts | 31 ++++++-- docs/worktree-init-script-example.sh | 8 +- libs/platform/src/system-paths.ts | 74 +++++++++++++------ 6 files changed, 158 insertions(+), 36 deletions(-) diff --git a/apps/server/src/routes/backlog-plan/routes/apply.ts b/apps/server/src/routes/backlog-plan/routes/apply.ts index 09ec6696..b6c257a0 100644 --- a/apps/server/src/routes/backlog-plan/routes/apply.ts +++ b/apps/server/src/routes/backlog-plan/routes/apply.ts @@ -12,12 +12,22 @@ const featureLoader = new FeatureLoader(); export function createApplyHandler() { return async (req: Request, res: Response): Promise => { try { - const { projectPath, plan, branchName } = 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; 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 index 20eb8680..2d232a65 100644 --- a/apps/ui/src/components/views/settings-view/worktrees/worktrees-section.tsx +++ b/apps/ui/src/components/views/settings-view/worktrees/worktrees-section.tsx @@ -17,6 +17,7 @@ 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; @@ -217,9 +218,19 @@ export function WorktreesSection({ useWorktrees, onUseWorktreesChange }: Worktre { + onCheckedChange={async (checked) => { if (currentProject?.path) { - setShowInitScriptIndicator(currentProject.path, checked === true); + 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" @@ -246,9 +257,19 @@ export function WorktreesSection({ useWorktrees, onUseWorktreesChange }: Worktre { + onCheckedChange={async (checked) => { if (currentProject?.path) { - setAutoDismissInitScriptIndicator(currentProject.path, checked === true); + 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" @@ -273,9 +294,19 @@ export function WorktreesSection({ useWorktrees, onUseWorktreesChange }: Worktre { + onCheckedChange={async (checked) => { if (currentProject?.path) { - setDefaultDeleteBranch(currentProject.path, checked === true); + 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" 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/store/app-store.ts b/apps/ui/src/store/app-store.ts index 25c431f3..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) @@ -1823,16 +1826,28 @@ export const useAppStore = create()((set, get) => ({ 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'); - await syncSettingsToServer(); + 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'); - await syncSettingsToServer(); + const ok = await syncSettingsToServer(); + if (!ok) { + logger.error( + 'Failed to sync addFeatureUseSelectedWorktreeBranch setting to server - reverting' + ); + set({ addFeatureUseSelectedWorktreeBranch: previous }); + } }, // Worktree Settings actions @@ -3282,14 +3297,20 @@ export const useAppStore = create()((set, get) => ({ appendInitScriptOutput: (projectPath, branch, content) => { const key = `${projectPath}::${branch}`; - const current = get().initScriptState[key]; - if (!current) return; + // 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: [...current.output, content], + output: newOutput, }, }, }); diff --git a/docs/worktree-init-script-example.sh b/docs/worktree-init-script-example.sh index 6e57389f..2f942544 100644 --- a/docs/worktree-init-script-example.sh +++ b/docs/worktree-init-script-example.sh @@ -14,8 +14,12 @@ echo "" # Install dependencies echo "[1/1] Installing npm dependencies..." if [ -f "package.json" ]; then - npm install - echo "Dependencies installed successfully!" + 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 diff --git a/libs/platform/src/system-paths.ts b/libs/platform/src/system-paths.ts index 3f1a1f4b..31382a33 100644 --- a/libs/platform/src/system-paths.ts +++ b/libs/platform/src/system-paths.ts @@ -232,6 +232,27 @@ 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 @@ -242,12 +263,38 @@ export function getGitBashPaths(): string[] { } 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(process.env.LOCALAPPDATA || '', 'Programs', 'Git', 'bin', 'bash.exe'), + path.join(localAppData, 'Programs', 'Git', 'bin', 'bash.exe'), // Scoop package manager path.join(homeDir, 'scoop', 'apps', 'git', 'current', 'bin', 'bash.exe'), // Chocolatey @@ -259,27 +306,10 @@ export function getGitBashPaths(): string[] { 'bin', 'bash.exe' ), - // winget typical location - path.join( - process.env.LOCALAPPDATA || '', - 'Microsoft', - 'WinGet', - 'Packages', - 'Git.Git_*', - 'bin', - 'bash.exe' - ), - // GitHub Desktop bundled Git - path.join( - process.env.LOCALAPPDATA || '', - 'GitHubDesktop', - 'app-*', - 'resources', - 'app', - 'git', - 'cmd', - 'bash.exe' - ), + // winget installations (dynamically resolved) + ...wingetGitPaths, + // GitHub Desktop bundled Git (dynamically resolved) + ...githubDesktopPaths, ].filter(Boolean); }