Merge pull request #409 from AutoMaker-Org/feat/worktrees-init-script

feat: worktrees init script
This commit is contained in:
Shirone
2026-01-12 17:52:31 +00:00
committed by GitHub
43 changed files with 2859 additions and 117 deletions

View File

@@ -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());

View File

@@ -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;
}
/**

View File

@@ -12,11 +12,22 @@ const featureLoader = new FeatureLoader();
export function createApplyHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, plan } = req.body as {
const {
projectPath,
plan,
branchName: rawBranchName,
} = req.body as {
projectPath: string;
plan: BacklogPlanResult;
branchName?: string;
};
// Validate branchName: must be undefined or a non-empty trimmed string
const branchName =
typeof rawBranchName === 'string' && rawBranchName.trim().length > 0
? rawBranchName.trim()
: undefined;
if (!projectPath) {
res.status(400).json({ success: false, error: 'projectPath required' });
return;
@@ -82,6 +93,7 @@ export function createApplyHandler() {
dependencies: change.feature.dependencies,
priority: change.feature.priority,
status: 'backlog',
branchName,
});
appliedChanges.push(`added:${newFeature.id}`);

View File

@@ -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<string> {
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<boolean> {
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

View File

@@ -3,6 +3,7 @@
*/
import { Router } from 'express';
import type { EventEmitter } from '../../lib/events.js';
import { validatePathParams } from '../../middleware/validate-paths.js';
import { requireValidWorktree, requireValidProject, requireGitRepoOnly } from './middleware.js';
import { createInfoHandler } from './routes/info.js';
@@ -32,8 +33,14 @@ import { createMigrateHandler } from './routes/migrate.js';
import { createStartDevHandler } from './routes/start-dev.js';
import { createStopDevHandler } from './routes/stop-dev.js';
import { createListDevServersHandler } from './routes/list-dev-servers.js';
import {
createGetInitScriptHandler,
createPutInitScriptHandler,
createDeleteInitScriptHandler,
createRunInitScriptHandler,
} from './routes/init-script.js';
export function createWorktreeRoutes(): Router {
export function createWorktreeRoutes(events: EventEmitter): Router {
const router = Router();
router.post('/info', validatePathParams('projectPath'), createInfoHandler());
@@ -47,7 +54,7 @@ export function createWorktreeRoutes(): Router {
requireValidProject,
createMergeHandler()
);
router.post('/create', validatePathParams('projectPath'), createCreateHandler());
router.post('/create', validatePathParams('projectPath'), createCreateHandler(events));
router.post('/delete', validatePathParams('projectPath', 'worktreePath'), createDeleteHandler());
router.post('/create-pr', createCreatePRHandler());
router.post('/pr-info', createPRInfoHandler());
@@ -91,5 +98,15 @@ export function createWorktreeRoutes(): Router {
router.post('/stop-dev', createStopDevHandler());
router.post('/list-dev-servers', createListDevServersHandler());
// Init script routes
router.get('/init-script', createGetInitScriptHandler());
router.put('/init-script', validatePathParams('projectPath'), createPutInitScriptHandler());
router.delete('/init-script', validatePathParams('projectPath'), createDeleteInitScriptHandler());
router.post(
'/run-init-script',
validatePathParams('projectPath', 'worktreePath'),
createRunInitScriptHandler(events)
);
return router;
}

View File

@@ -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) */

View File

@@ -12,15 +12,19 @@ import { exec } from 'child_process';
import { promisify } from 'util';
import path from 'path';
import * as secureFs from '../../../lib/secure-fs.js';
import type { EventEmitter } from '../../../lib/events.js';
import { isGitRepo } from '@automaker/git-utils';
import {
isGitRepo,
getErrorMessage,
logError,
normalizePath,
ensureInitialCommit,
isValidBranchName,
execGitCommand,
} from '../common.js';
import { trackBranch } from './branch-tracking.js';
import { createLogger } from '@automaker/utils';
import { runInitScript } from '../../../services/init-script-service.js';
const logger = createLogger('Worktree');
@@ -77,7 +81,7 @@ async function findExistingWorktreeForBranch(
}
}
export function createCreateHandler() {
export function createCreateHandler(events: EventEmitter) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, branchName, baseBranch } = req.body as {
@@ -94,6 +98,26 @@ export function createCreateHandler() {
return;
}
// Validate branch name to prevent command injection
if (!isValidBranchName(branchName)) {
res.status(400).json({
success: false,
error:
'Invalid branch name. Branch names must contain only letters, numbers, dots, hyphens, underscores, and forward slashes.',
});
return;
}
// Validate base branch if provided
if (baseBranch && !isValidBranchName(baseBranch) && baseBranch !== 'HEAD') {
res.status(400).json({
success: false,
error:
'Invalid base branch name. Branch names must contain only letters, numbers, dots, hyphens, underscores, and forward slashes.',
});
return;
}
if (!(await isGitRepo(projectPath))) {
res.status(400).json({
success: false,
@@ -143,30 +167,28 @@ export function createCreateHandler() {
// Create worktrees directory if it doesn't exist
await secureFs.mkdir(worktreesDir, { recursive: true });
// Check if branch exists
// Check if branch exists (using array arguments to prevent injection)
let branchExists = false;
try {
await execAsync(`git rev-parse --verify ${branchName}`, {
cwd: projectPath,
});
await execGitCommand(['rev-parse', '--verify', branchName], projectPath);
branchExists = true;
} catch {
// Branch doesn't exist
}
// Create worktree
let createCmd: string;
// Create worktree (using array arguments to prevent injection)
if (branchExists) {
// Use existing branch
createCmd = `git worktree add "${worktreePath}" ${branchName}`;
await execGitCommand(['worktree', 'add', worktreePath, branchName], projectPath);
} else {
// Create new branch from base or HEAD
const base = baseBranch || 'HEAD';
createCmd = `git worktree add -b ${branchName} "${worktreePath}" ${base}`;
await execGitCommand(
['worktree', 'add', '-b', branchName, worktreePath, base],
projectPath
);
}
await execAsync(createCmd, { cwd: projectPath });
// Note: We intentionally do NOT symlink .automaker to worktrees
// Features and config are always accessed from the main project path
// This avoids symlink loop issues when activating worktrees
@@ -177,6 +199,8 @@ export function createCreateHandler() {
// Resolve to absolute path for cross-platform compatibility
// normalizePath converts to forward slashes for API consistency
const absoluteWorktreePath = path.resolve(worktreePath);
// Respond immediately (non-blocking)
res.json({
success: true,
worktree: {
@@ -185,6 +209,17 @@ export function createCreateHandler() {
isNew: !branchExists,
},
});
// Trigger init script asynchronously after response
// runInitScript internally checks if script exists and hasn't already run
runInitScript({
projectPath,
worktreePath: absoluteWorktreePath,
branch: branchName,
emitter: events,
}).catch((err) => {
logger.error(`Init script failed for ${branchName}:`, err);
});
} catch (error) {
logError(error, 'Create worktree failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });

View File

@@ -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<void> => {
@@ -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
}
}
}

View File

@@ -0,0 +1,270 @@
/**
* Init Script routes - Read/write/run the worktree-init.sh file
*
* POST /init-script - Read the init script content
* PUT /init-script - Write content to the init script file
* DELETE /init-script - Delete the init script file
* POST /run-init-script - Run the init script for a worktree
*/
import type { Request, Response } from 'express';
import path from 'path';
import * as secureFs from '../../../lib/secure-fs.js';
import { getErrorMessage, logError, isValidBranchName } from '../common.js';
import { createLogger } from '@automaker/utils';
import type { EventEmitter } from '../../../lib/events.js';
import { forceRunInitScript } from '../../../services/init-script-service.js';
const logger = createLogger('InitScript');
/** Fixed path for init script within .automaker directory */
const INIT_SCRIPT_FILENAME = 'worktree-init.sh';
/** Maximum allowed size for init scripts (1MB) */
const MAX_SCRIPT_SIZE_BYTES = 1024 * 1024;
/**
* Get the full path to the init script for a project
*/
function getInitScriptPath(projectPath: string): string {
return path.join(projectPath, '.automaker', INIT_SCRIPT_FILENAME);
}
/**
* GET /init-script - Read the init script content
*/
export function createGetInitScriptHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const projectPath = req.query.projectPath as string;
if (!projectPath) {
res.status(400).json({
success: false,
error: 'projectPath query parameter is required',
});
return;
}
const scriptPath = getInitScriptPath(projectPath);
try {
const content = await secureFs.readFile(scriptPath, 'utf-8');
res.json({
success: true,
exists: true,
content: content as string,
path: scriptPath,
});
} catch {
// File doesn't exist
res.json({
success: true,
exists: false,
content: '',
path: scriptPath,
});
}
} catch (error) {
logError(error, 'Read init script failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
});
}
};
}
/**
* PUT /init-script - Write content to the init script file
*/
export function createPutInitScriptHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, content } = req.body as {
projectPath: string;
content: string;
};
if (!projectPath) {
res.status(400).json({
success: false,
error: 'projectPath is required',
});
return;
}
if (typeof content !== 'string') {
res.status(400).json({
success: false,
error: 'content must be a string',
});
return;
}
// Validate script size to prevent disk exhaustion
const sizeBytes = Buffer.byteLength(content, 'utf-8');
if (sizeBytes > MAX_SCRIPT_SIZE_BYTES) {
res.status(400).json({
success: false,
error: `Script size (${Math.round(sizeBytes / 1024)}KB) exceeds maximum allowed size (${Math.round(MAX_SCRIPT_SIZE_BYTES / 1024)}KB)`,
});
return;
}
// Log warning if potentially dangerous patterns are detected (non-blocking)
const dangerousPatterns = [
/rm\s+-rf\s+\/(?!\s*\$)/i, // rm -rf / (not followed by variable)
/curl\s+.*\|\s*(?:bash|sh)/i, // curl | bash
/wget\s+.*\|\s*(?:bash|sh)/i, // wget | sh
];
for (const pattern of dangerousPatterns) {
if (pattern.test(content)) {
logger.warn(
`Init script contains potentially dangerous pattern: ${pattern.source}. User responsibility to verify script safety.`
);
}
}
const scriptPath = getInitScriptPath(projectPath);
const automakerDir = path.dirname(scriptPath);
// Ensure .automaker directory exists
await secureFs.mkdir(automakerDir, { recursive: true });
// Write the script content
await secureFs.writeFile(scriptPath, content, 'utf-8');
logger.info(`Wrote init script to ${scriptPath}`);
res.json({
success: true,
path: scriptPath,
});
} catch (error) {
logError(error, 'Write init script failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
});
}
};
}
/**
* DELETE /init-script - Delete the init script file
*/
export function createDeleteInitScriptHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath } = req.body as { projectPath: string };
if (!projectPath) {
res.status(400).json({
success: false,
error: 'projectPath is required',
});
return;
}
const scriptPath = getInitScriptPath(projectPath);
await secureFs.rm(scriptPath, { force: true });
logger.info(`Deleted init script at ${scriptPath}`);
res.json({
success: true,
});
} catch (error) {
logError(error, 'Delete init script failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
});
}
};
}
/**
* POST /run-init-script - Run (or re-run) the init script for a worktree
*/
export function createRunInitScriptHandler(events: EventEmitter) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, worktreePath, branch } = req.body as {
projectPath: string;
worktreePath: string;
branch: string;
};
if (!projectPath) {
res.status(400).json({
success: false,
error: 'projectPath is required',
});
return;
}
if (!worktreePath) {
res.status(400).json({
success: false,
error: 'worktreePath is required',
});
return;
}
if (!branch) {
res.status(400).json({
success: false,
error: 'branch is required',
});
return;
}
// Validate branch name to prevent injection via environment variables
if (!isValidBranchName(branch)) {
res.status(400).json({
success: false,
error:
'Invalid branch name. Branch names must contain only letters, numbers, dots, hyphens, underscores, and forward slashes.',
});
return;
}
const scriptPath = getInitScriptPath(projectPath);
// Check if script exists
try {
await secureFs.access(scriptPath);
} catch {
res.status(404).json({
success: false,
error: 'No init script found. Create one in Settings > Worktrees.',
});
return;
}
logger.info(`Running init script for branch "${branch}" (forced)`);
// Run the script asynchronously (non-blocking)
forceRunInitScript({
projectPath,
worktreePath,
branch,
emitter: events,
});
// Return immediately - progress will be streamed via WebSocket events
res.json({
success: true,
message: 'Init script started',
});
} catch (error) {
logError(error, 'Run init script failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
});
}
};
}

View File

@@ -0,0 +1,360 @@
/**
* Init Script Service - Executes worktree initialization scripts
*
* Runs the .automaker/worktree-init.sh script after worktree creation.
* Uses Git Bash on Windows for cross-platform shell script compatibility.
*/
import { spawn } from 'child_process';
import path from 'path';
import { createLogger } from '@automaker/utils';
import { systemPathExists, getShellPaths, findGitBashPath } from '@automaker/platform';
import { findCommand } from '../lib/cli-detection.js';
import type { EventEmitter } from '../lib/events.js';
import { readWorktreeMetadata, writeWorktreeMetadata } from '../lib/worktree-metadata.js';
import * as secureFs from '../lib/secure-fs.js';
const logger = createLogger('InitScript');
export interface InitScriptOptions {
/** Absolute path to the project root */
projectPath: string;
/** Absolute path to the worktree directory */
worktreePath: string;
/** Branch name for this worktree */
branch: string;
/** Event emitter for streaming output */
emitter: EventEmitter;
}
interface ShellCommand {
shell: string;
args: string[];
}
/**
* Init Script Service
*
* Handles execution of worktree initialization scripts with cross-platform
* shell detection and proper streaming of output via WebSocket events.
*/
export class InitScriptService {
private cachedShellCommand: ShellCommand | null | undefined = undefined;
/**
* Get the path to the init script for a project
*/
getInitScriptPath(projectPath: string): string {
return path.join(projectPath, '.automaker', 'worktree-init.sh');
}
/**
* Check if the init script has already been run for a worktree
*/
async hasInitScriptRun(projectPath: string, branch: string): Promise<boolean> {
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<ShellCommand | null> {
// 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<void> {
const { projectPath, worktreePath, branch, emitter } = options;
const scriptPath = this.getInitScriptPath(projectPath);
// Check if script exists using secureFs (respects ALLOWED_ROOT_DIRECTORY)
try {
await secureFs.access(scriptPath);
} catch {
logger.debug(`No init script found at ${scriptPath}`);
return;
}
// Check if already run
if (await this.hasInitScriptRun(projectPath, branch)) {
logger.info(`Init script already ran for branch "${branch}", skipping`);
return;
}
// Get shell command
const shellCmd = await this.findShellCommand();
if (!shellCmd) {
const error =
process.platform === 'win32'
? 'Git Bash not found. Please install Git for Windows to run init scripts.'
: 'No shell found (/bin/bash or /bin/sh)';
logger.error(error);
// Update metadata with error, preserving existing metadata
const existingMetadata = await readWorktreeMetadata(projectPath, branch);
await writeWorktreeMetadata(projectPath, branch, {
branch,
createdAt: existingMetadata?.createdAt || new Date().toISOString(),
pr: existingMetadata?.pr,
initScriptRan: true,
initScriptStatus: 'failed',
initScriptError: error,
});
emitter.emit('worktree:init-completed', {
projectPath,
worktreePath,
branch,
success: false,
error,
});
return;
}
logger.info(`Running init script for branch "${branch}" in ${worktreePath}`);
logger.debug(`Using shell: ${shellCmd.shell}`);
// Update metadata to mark as running
const existingMetadata = await readWorktreeMetadata(projectPath, branch);
await writeWorktreeMetadata(projectPath, branch, {
branch,
createdAt: existingMetadata?.createdAt || new Date().toISOString(),
pr: existingMetadata?.pr,
initScriptRan: false,
initScriptStatus: 'running',
});
// Emit started event
emitter.emit('worktree:init-started', {
projectPath,
worktreePath,
branch,
});
// Build safe environment - only pass necessary variables, not all of process.env
// This prevents exposure of sensitive credentials like ANTHROPIC_API_KEY
const safeEnv: Record<string, string> = {
// Automaker-specific variables
AUTOMAKER_PROJECT_PATH: projectPath,
AUTOMAKER_WORKTREE_PATH: worktreePath,
AUTOMAKER_BRANCH: branch,
// Essential system variables
PATH: process.env.PATH || '',
HOME: process.env.HOME || '',
USER: process.env.USER || '',
TMPDIR: process.env.TMPDIR || process.env.TEMP || process.env.TMP || '/tmp',
// Shell and locale
SHELL: process.env.SHELL || '',
LANG: process.env.LANG || 'en_US.UTF-8',
LC_ALL: process.env.LC_ALL || '',
// Force color output even though we're not a TTY
FORCE_COLOR: '1',
npm_config_color: 'always',
CLICOLOR_FORCE: '1',
// Git configuration
GIT_TERMINAL_PROMPT: '0',
};
// Platform-specific additions
if (process.platform === 'win32') {
safeEnv.USERPROFILE = process.env.USERPROFILE || '';
safeEnv.APPDATA = process.env.APPDATA || '';
safeEnv.LOCALAPPDATA = process.env.LOCALAPPDATA || '';
safeEnv.SystemRoot = process.env.SystemRoot || 'C:\\Windows';
safeEnv.TEMP = process.env.TEMP || '';
}
// Spawn the script with safe environment
const child = spawn(shellCmd.shell, [...shellCmd.args, scriptPath], {
cwd: worktreePath,
env: safeEnv,
stdio: ['ignore', 'pipe', 'pipe'],
});
// Stream stdout
child.stdout?.on('data', (data: Buffer) => {
const content = data.toString();
emitter.emit('worktree:init-output', {
projectPath,
branch,
type: 'stdout',
content,
});
});
// Stream stderr
child.stderr?.on('data', (data: Buffer) => {
const content = data.toString();
emitter.emit('worktree:init-output', {
projectPath,
branch,
type: 'stderr',
content,
});
});
// Handle completion
child.on('exit', async (code) => {
const success = code === 0;
const status = success ? 'success' : 'failed';
logger.info(`Init script for branch "${branch}" ${status} with exit code ${code}`);
// Update metadata
const metadata = await readWorktreeMetadata(projectPath, branch);
await writeWorktreeMetadata(projectPath, branch, {
branch,
createdAt: metadata?.createdAt || new Date().toISOString(),
pr: metadata?.pr,
initScriptRan: true,
initScriptStatus: status,
initScriptError: success ? undefined : `Exit code: ${code}`,
});
// Emit completion event
emitter.emit('worktree:init-completed', {
projectPath,
worktreePath,
branch,
success,
exitCode: code,
});
});
child.on('error', async (error) => {
logger.error(`Init script error for branch "${branch}":`, error);
// Update metadata
const metadata = await readWorktreeMetadata(projectPath, branch);
await writeWorktreeMetadata(projectPath, branch, {
branch,
createdAt: metadata?.createdAt || new Date().toISOString(),
pr: metadata?.pr,
initScriptRan: true,
initScriptStatus: 'failed',
initScriptError: error.message,
});
// Emit completion with error
emitter.emit('worktree:init-completed', {
projectPath,
worktreePath,
branch,
success: false,
error: error.message,
});
});
}
/**
* Force re-run the worktree initialization script
* Ignores the initScriptRan flag - useful for testing or re-setup
*/
async forceRunInitScript(options: InitScriptOptions): Promise<void> {
const { projectPath, branch } = options;
// Reset the initScriptRan flag so the script will run
const metadata = await readWorktreeMetadata(projectPath, branch);
if (metadata) {
await writeWorktreeMetadata(projectPath, branch, {
...metadata,
initScriptRan: false,
initScriptStatus: undefined,
initScriptError: undefined,
});
}
// Now run the script
await this.runInitScript(options);
}
}
// Singleton instance for convenience
let initScriptService: InitScriptService | null = null;
/**
* Get the singleton InitScriptService instance
*/
export function getInitScriptService(): InitScriptService {
if (!initScriptService) {
initScriptService = new InitScriptService();
}
return initScriptService;
}
// Export convenience functions that use the singleton
export const getInitScriptPath = (projectPath: string) =>
getInitScriptService().getInitScriptPath(projectPath);
export const hasInitScriptRun = (projectPath: string, branch: string) =>
getInitScriptService().hasInitScriptRun(projectPath, branch);
export const runInitScript = (options: InitScriptOptions) =>
getInitScriptService().runInitScript(options);
export const forceRunInitScript = (options: InitScriptOptions) =>
getInitScriptService().forceRunInitScript(options);

View File

@@ -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",

View File

@@ -0,0 +1,276 @@
import { useMemo } from 'react';
import { cn } from '@/lib/utils';
interface AnsiOutputProps {
text: string;
className?: string;
}
// ANSI color codes to CSS color mappings
const ANSI_COLORS: Record<number, string> = {
// 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<number, string> = {
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 (
<pre
className={cn(
'font-mono text-xs whitespace-pre-wrap break-words text-muted-foreground',
className
)}
>
{segments.map((segment, index) => (
<span
key={index}
style={{
color: segment.style.color,
backgroundColor: segment.style.backgroundColor,
fontWeight: segment.style.fontWeight,
fontStyle: segment.style.fontStyle,
textDecoration: segment.style.textDecoration,
}}
>
{segment.text}
</span>
))}
</pre>
);
}

View File

@@ -0,0 +1,142 @@
import CodeMirror from '@uiw/react-codemirror';
import { StreamLanguage } from '@codemirror/language';
import { shell } from '@codemirror/legacy-modes/mode/shell';
import { EditorView } from '@codemirror/view';
import { Extension } from '@codemirror/state';
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language';
import { tags as t } from '@lezer/highlight';
import { cn } from '@/lib/utils';
interface ShellSyntaxEditorProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
className?: string;
minHeight?: string;
maxHeight?: string;
'data-testid'?: string;
}
// Syntax highlighting using CSS variables for theme compatibility
const syntaxColors = HighlightStyle.define([
// Keywords (if, then, else, fi, for, while, do, done, case, esac, etc.)
{ tag: t.keyword, color: 'var(--chart-4, oklch(0.7 0.15 280))' },
// Strings (single and double quoted)
{ tag: t.string, color: 'var(--chart-1, oklch(0.646 0.222 41.116))' },
// Comments
{ tag: t.comment, color: 'var(--muted-foreground)', fontStyle: 'italic' },
// Variables ($VAR, ${VAR})
{ tag: t.variableName, color: 'var(--chart-2, oklch(0.6 0.118 184.704))' },
// Operators
{ tag: t.operator, color: 'var(--muted-foreground)' },
// Numbers
{ tag: t.number, color: 'var(--chart-3, oklch(0.7 0.15 150))' },
// Function names / commands
{ tag: t.function(t.variableName), color: 'var(--primary)' },
{ tag: t.attributeName, color: 'var(--chart-5, oklch(0.65 0.2 30))' },
// Default text
{ tag: t.content, color: 'var(--foreground)' },
]);
// Editor theme using CSS variables
const editorTheme = EditorView.theme({
'&': {
height: '100%',
fontSize: '0.875rem',
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
backgroundColor: 'transparent',
color: 'var(--foreground)',
},
'.cm-scroller': {
overflow: 'auto',
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
},
'.cm-content': {
padding: '0.75rem',
minHeight: '100%',
caretColor: 'var(--primary)',
},
'.cm-cursor, .cm-dropCursor': {
borderLeftColor: 'var(--primary)',
},
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection': {
backgroundColor: 'oklch(0.55 0.25 265 / 0.3)',
},
'.cm-activeLine': {
backgroundColor: 'var(--accent)',
opacity: '0.3',
},
'.cm-line': {
padding: '0 0.25rem',
},
'&.cm-focused': {
outline: 'none',
},
'.cm-gutters': {
backgroundColor: 'transparent',
color: 'var(--muted-foreground)',
border: 'none',
paddingRight: '0.5rem',
},
'.cm-lineNumbers .cm-gutterElement': {
minWidth: '2rem',
textAlign: 'right',
paddingRight: '0.5rem',
},
'.cm-placeholder': {
color: 'var(--muted-foreground)',
fontStyle: 'italic',
},
});
// Combine all extensions
const extensions: Extension[] = [
StreamLanguage.define(shell),
syntaxHighlighting(syntaxColors),
editorTheme,
];
export function ShellSyntaxEditor({
value,
onChange,
placeholder,
className,
minHeight = '200px',
maxHeight,
'data-testid': testId,
}: ShellSyntaxEditorProps) {
return (
<div
className={cn('w-full rounded-lg border border-border bg-muted/30', className)}
style={{ minHeight }}
data-testid={testId}
>
<CodeMirror
value={value}
onChange={onChange}
extensions={extensions}
theme="none"
placeholder={placeholder}
height={maxHeight}
minHeight={minHeight}
className="[&_.cm-editor]:min-h-[inherit]"
basicSetup={{
lineNumbers: true,
foldGutter: false,
highlightActiveLine: true,
highlightSelectionMatches: true,
autocompletion: false,
bracketMatching: true,
indentOnInput: true,
}}
/>
</div>
);
}

View File

@@ -75,6 +75,8 @@ import {
} from './board-view/hooks';
import { SelectionActionBar } from './board-view/components';
import { MassEditDialog } from './board-view/dialogs';
import { InitScriptIndicator } from './board-view/init-script-indicator';
import { useInitScriptEvents } from '@/hooks/use-init-script-events';
// Stable empty array to avoid infinite loop in selector
const EMPTY_WORKTREES: ReturnType<ReturnType<typeof useAppStore.getState>['getWorktrees']> = [];
@@ -99,6 +101,8 @@ export function BoardView() {
useWorktrees,
enableDependencyBlocking,
skipVerificationInAutoMode,
planUseSelectedWorktreeBranch,
addFeatureUseSelectedWorktreeBranch,
isPrimaryWorktreeBranch,
getPrimaryWorktreeBranch,
setPipelineConfig,
@@ -107,6 +111,12 @@ export function BoardView() {
const pipelineConfigByProject = useAppStore((state) => state.pipelineConfigByProject);
// Subscribe to worktreePanelVisibleByProject to trigger re-renders when it changes
const worktreePanelVisibleByProject = useAppStore((state) => state.worktreePanelVisibleByProject);
// Subscribe to showInitScriptIndicatorByProject to trigger re-renders when it changes
const showInitScriptIndicatorByProject = useAppStore(
(state) => state.showInitScriptIndicatorByProject
);
const getShowInitScriptIndicator = useAppStore((state) => state.getShowInitScriptIndicator);
const getDefaultDeleteBranch = useAppStore((state) => state.getDefaultDeleteBranch);
const shortcuts = useKeyboardShortcutsConfig();
const {
features: hookFeatures,
@@ -252,6 +262,9 @@ export function BoardView() {
// Window state hook for compact dialog mode
const { isMaximized } = useWindowState();
// Init script events hook - subscribe to worktree init script events
useInitScriptEvents(currentProject?.path ?? null);
// Keyboard shortcuts hook will be initialized after actions hook
// Prevent hydration issues
@@ -1361,6 +1374,14 @@ export function BoardView() {
isMaximized={isMaximized}
parentFeature={spawnParentFeature}
allFeatures={hookFeatures}
// When setting is enabled and a non-main worktree is selected, pass its branch to default to 'custom' work mode
selectedNonMainWorktreeBranch={
addFeatureUseSelectedWorktreeBranch && currentWorktreePath !== null
? currentWorktreeBranch || undefined
: undefined
}
// When the worktree setting is disabled, force 'current' branch mode
forceCurrentBranchMode={!addFeatureUseSelectedWorktreeBranch}
/>
{/* Edit Feature Dialog */}
@@ -1440,6 +1461,7 @@ export function BoardView() {
setPendingPlanResult={setPendingBacklogPlan}
isGeneratingPlan={isGeneratingPlan}
setIsGeneratingPlan={setIsGeneratingPlan}
currentBranch={planUseSelectedWorktreeBranch ? selectedWorktreeBranch : undefined}
/>
{/* Plan Approval Dialog */}
@@ -1507,6 +1529,7 @@ export function BoardView() {
? hookFeatures.filter((f) => f.branchName === selectedWorktreeForAction.branch).length
: 0
}
defaultDeleteBranch={getDefaultDeleteBranch(currentProject.path)}
onDeleted={(deletedWorktree, _deletedBranch) => {
// Reset features that were assigned to the deleted worktree (by branch)
hookFeatures.forEach((feature) => {
@@ -1574,6 +1597,11 @@ export function BoardView() {
setSelectedWorktreeForAction(null);
}}
/>
{/* Init Script Indicator - floating overlay for worktree init script status */}
{getShowInitScriptIndicator(currentProject.path) && (
<InitScriptIndicator projectPath={currentProject.path} />
)}
</div>
);
}

View File

@@ -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"
/>
<button
onClick={() => setShowWorktreeSettings(true)}
className="p-1 rounded hover:bg-accent/50 transition-colors"
title="Worktree Settings"
data-testid="worktree-settings-button"
>
<Settings2 className="w-4 h-4 text-muted-foreground" />
</button>
</div>
)}
{/* Worktree Settings Dialog */}
<WorktreeSettingsDialog
open={showWorktreeSettings}
onOpenChange={setShowWorktreeSettings}
addFeatureUseSelectedWorktreeBranch={addFeatureUseSelectedWorktreeBranch}
onAddFeatureUseSelectedWorktreeBranchChange={setAddFeatureUseSelectedWorktreeBranch}
/>
{/* Concurrency Control - only show after mount to prevent hydration issues */}
{isMounted && (
<Popover>
@@ -209,15 +239,33 @@ export function BoardHeader({
onSkipVerificationChange={setSkipVerificationInAutoMode}
/>
<Button
size="sm"
variant="outline"
onClick={onOpenPlanDialog}
data-testid="plan-backlog-button"
>
<Wand2 className="w-4 h-4 mr-2" />
Plan
</Button>
{/* Plan Button with Settings */}
<div className={controlContainerClass} data-testid="plan-button-container">
<button
onClick={onOpenPlanDialog}
className="flex items-center gap-1.5 hover:text-foreground transition-colors"
data-testid="plan-backlog-button"
>
<Wand2 className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-medium">Plan</span>
</button>
<button
onClick={() => setShowPlanSettings(true)}
className="p-1 rounded hover:bg-accent/50 transition-colors"
title="Plan Settings"
data-testid="plan-settings-button"
>
<Settings2 className="w-4 h-4 text-muted-foreground" />
</button>
</div>
{/* Plan Settings Dialog */}
<PlanSettingsDialog
open={showPlanSettings}
onOpenChange={setShowPlanSettings}
planUseSelectedWorktreeBranch={planUseSelectedWorktreeBranch}
onPlanUseSelectedWorktreeBranchChange={setPlanUseSelectedWorktreeBranch}
/>
</div>
</div>
);

View File

@@ -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<WorkMode>('current');
@@ -149,7 +187,7 @@ export function AddFeatureDialog({
const [selectedAncestorIds, setSelectedAncestorIds] = useState<Set<string>>(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());

View File

@@ -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<DialogMode>('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(() => {

View File

@@ -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<string | null>(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 && <p className="text-sm text-destructive">{error}</p>}
{error && (
<div className="flex items-start gap-2 p-3 rounded-md bg-destructive/10 border border-destructive/20">
<AlertCircle className="w-4 h-4 text-destructive mt-0.5 flex-shrink-0" />
<div className="space-y-1">
<p className="text-sm font-medium text-destructive">{error.title}</p>
{error.description && (
<p className="text-xs text-destructive/80">{error.description}</p>
)}
</div>
</div>
)}
</div>
<div className="text-xs text-muted-foreground space-y-1">

View File

@@ -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;

View File

@@ -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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md" data-testid="plan-settings-dialog">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Settings2 className="w-5 h-5" />
Plan Settings
</DialogTitle>
<DialogDescription>
Configure how the Plan feature creates and organizes new features.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* Use Selected Worktree Branch Setting */}
<div className="flex items-start space-x-3 p-3 rounded-lg bg-secondary/50">
<div className="flex-1 space-y-1">
<div className="flex items-center justify-between">
<Label
htmlFor="plan-worktree-branch-toggle"
className="text-sm font-medium cursor-pointer flex items-center gap-2"
>
<GitBranch className="w-4 h-4 text-brand-500" />
Use selected worktree branch
</Label>
<Switch
id="plan-worktree-branch-toggle"
checked={planUseSelectedWorktreeBranch}
onCheckedChange={onPlanUseSelectedWorktreeBranchChange}
data-testid="plan-worktree-branch-toggle"
/>
</div>
<p className="text-xs text-muted-foreground leading-relaxed">
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.
</p>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md" data-testid="worktree-settings-dialog">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Settings2 className="w-5 h-5" />
Worktree Settings
</DialogTitle>
<DialogDescription>
Configure how worktrees affect feature creation and organization.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* Use Selected Worktree Branch Setting */}
<div className="flex items-start space-x-3 p-3 rounded-lg bg-secondary/50">
<div className="flex-1 space-y-1">
<div className="flex items-center justify-between">
<Label
htmlFor="worktree-branch-toggle"
className="text-sm font-medium cursor-pointer flex items-center gap-2"
>
<GitBranch className="w-4 h-4 text-brand-500" />
Use selected worktree branch
</Label>
<Switch
id="worktree-branch-toggle"
checked={addFeatureUseSelectedWorktreeBranch}
onCheckedChange={onAddFeatureUseSelectedWorktreeBranchChange}
data-testid="worktree-branch-toggle"
/>
</div>
<p className="text-xs text-muted-foreground leading-relaxed">
When enabled, the Add Feature dialog will default to custom branch mode with the
currently selected worktree branch pre-filled.
</p>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,209 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { Terminal, Check, X, Loader2, ChevronDown, ChevronUp } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useAppStore, type InitScriptState } from '@/store/app-store';
import { AnsiOutput } from '@/components/ui/ansi-output';
interface InitScriptIndicatorProps {
projectPath: string;
}
interface SingleIndicatorProps {
stateKey: string;
state: InitScriptState;
onDismiss: (key: string) => void;
isOnlyOne: boolean; // Whether this is the only indicator shown
autoDismiss: boolean; // Whether to auto-dismiss after completion
}
function SingleIndicator({
stateKey,
state,
onDismiss,
isOnlyOne,
autoDismiss,
}: SingleIndicatorProps) {
const [showLogs, setShowLogs] = useState(false);
const logsEndRef = useRef<HTMLDivElement>(null);
const { status, output, branch, error } = state;
// Auto-scroll to bottom when new output arrives
useEffect(() => {
if (showLogs && logsEndRef.current) {
logsEndRef.current.scrollIntoView({ behavior: 'smooth' });
}
}, [output, showLogs]);
// Auto-expand logs when script starts (only if it's the only one or running)
useEffect(() => {
if (status === 'running' && isOnlyOne) {
setShowLogs(true);
}
}, [status, isOnlyOne]);
// Auto-dismiss after completion (5 seconds)
useEffect(() => {
if (autoDismiss && (status === 'success' || status === 'failed')) {
const timer = setTimeout(() => {
onDismiss(stateKey);
}, 5000);
return () => clearTimeout(timer);
}
}, [status, autoDismiss, stateKey, onDismiss]);
if (status === 'idle') return null;
return (
<div
className={cn(
'bg-card border border-border rounded-lg shadow-lg',
'min-w-[350px] max-w-[500px]',
'animate-in slide-in-from-right-5 duration-200'
)}
>
{/* Header */}
<div className="flex items-center justify-between p-3 border-b border-border/50">
<div className="flex items-center gap-2">
{status === 'running' && <Loader2 className="w-4 h-4 animate-spin text-blue-500" />}
{status === 'success' && <Check className="w-4 h-4 text-green-500" />}
{status === 'failed' && <X className="w-4 h-4 text-red-500" />}
<span className="font-medium text-sm">
Init Script{' '}
{status === 'running' ? 'Running' : status === 'success' ? 'Completed' : 'Failed'}
</span>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => setShowLogs(!showLogs)}
className="p-1 hover:bg-accent rounded transition-colors"
title={showLogs ? 'Hide logs' : 'Show logs'}
>
{showLogs ? (
<ChevronDown className="w-4 h-4 text-muted-foreground" />
) : (
<ChevronUp className="w-4 h-4 text-muted-foreground" />
)}
</button>
{status !== 'running' && (
<button
onClick={() => onDismiss(stateKey)}
className="p-1 hover:bg-accent rounded transition-colors"
title="Dismiss"
>
<X className="w-4 h-4 text-muted-foreground" />
</button>
)}
</div>
</div>
{/* Branch info */}
<div className="px-3 py-2 text-xs text-muted-foreground flex items-center gap-2">
<Terminal className="w-3.5 h-3.5" />
<span>Branch: {branch}</span>
</div>
{/* Logs (collapsible) */}
{showLogs && (
<div className="border-t border-border/50">
<div className="p-3 max-h-[300px] overflow-y-auto">
{output.length > 0 ? (
<AnsiOutput text={output.join('')} />
) : (
<div className="text-xs text-muted-foreground/60 text-center py-2">
{status === 'running' ? 'Waiting for output...' : 'No output'}
</div>
)}
{error && <div className="mt-2 text-red-500 text-xs font-medium">Error: {error}</div>}
<div ref={logsEndRef} />
</div>
</div>
)}
{/* Status bar for completed states */}
{status !== 'running' && (
<div
className={cn(
'px-3 py-2 text-xs',
status === 'success' ? 'bg-green-500/10 text-green-600' : 'bg-red-500/10 text-red-600'
)}
>
{status === 'success'
? 'Initialization completed successfully'
: 'Initialization failed - worktree is still usable'}
</div>
)}
</div>
);
}
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<Set<string>>(new Set());
// Get auto-dismiss setting
const autoDismiss = getAutoDismissInitScriptIndicator(projectPath);
// Get all init script states for this project
const allStates = getInitScriptStatesForProject(projectPath);
// Filter out dismissed and idle states
const activeStates = allStates.filter(
({ key, state }) => !dismissedKeys.has(key) && state.status !== 'idle'
);
// Reset dismissed keys when a new script starts for a branch
useEffect(() => {
const runningKeys = allStates
.filter(({ state }) => state.status === 'running')
.map(({ key }) => key);
if (runningKeys.length > 0) {
setDismissedKeys((prev) => {
const newSet = new Set(prev);
runningKeys.forEach((key) => newSet.delete(key));
return newSet;
});
}
}, [allStates]);
const handleDismiss = useCallback(
(key: string) => {
setDismissedKeys((prev) => new Set(prev).add(key));
// Extract branch from key (format: "projectPath::branch")
const branch = key.split('::')[1];
if (branch) {
// Clear state after a delay to allow for future scripts
setTimeout(() => {
clearInitScriptState(projectPath, branch);
}, 100);
}
},
[projectPath, clearInitScriptState]
);
if (activeStates.length === 0) return null;
return (
<div
className={cn(
'fixed bottom-4 right-4 z-50 flex flex-col gap-2',
'max-h-[calc(100vh-120px)] overflow-y-auto',
'scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent'
)}
>
{activeStates.map(({ key, state }) => (
<SingleIndicator
key={key}
stateKey={key}
state={state}
onDismiss={handleDismiss}
isOnlyOne={activeStates.length === 1}
autoDismiss={autoDismiss}
/>
))}
</div>
);
}

View File

@@ -23,6 +23,7 @@ import {
MessageSquare,
GitMerge,
AlertCircle,
RefreshCw,
Copy,
} from 'lucide-react';
import { toast } from 'sonner';
@@ -55,6 +56,8 @@ interface WorktreeActionsDropdownProps {
onStartDevServer: (worktree: WorktreeInfo) => void;
onStopDevServer: (worktree: WorktreeInfo) => void;
onOpenDevServerUrl: (worktree: WorktreeInfo) => void;
onRunInitScript: (worktree: WorktreeInfo) => void;
hasInitScript: boolean;
}
export function WorktreeActionsDropdown({
@@ -80,6 +83,8 @@ export function WorktreeActionsDropdown({
onStartDevServer,
onStopDevServer,
onOpenDevServerUrl,
onRunInitScript,
hasInitScript,
}: WorktreeActionsDropdownProps) {
// Get available editors for the "Open In" submenu
const { editors } = useAvailableEditors();
@@ -266,6 +271,12 @@ export function WorktreeActionsDropdown({
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
{!worktree.isMain && hasInitScript && (
<DropdownMenuItem onClick={() => onRunInitScript(worktree)} className="text-xs">
<RefreshCw className="w-3.5 h-3.5 mr-2" />
Re-run Init Script
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
{worktree.hasChanges && (
<TooltipWrapper

View File

@@ -45,6 +45,8 @@ interface WorktreeTabProps {
onStartDevServer: (worktree: WorktreeInfo) => void;
onStopDevServer: (worktree: WorktreeInfo) => void;
onOpenDevServerUrl: (worktree: WorktreeInfo) => void;
onRunInitScript: (worktree: WorktreeInfo) => void;
hasInitScript: boolean;
}
export function WorktreeTab({
@@ -85,6 +87,8 @@ export function WorktreeTab({
onStartDevServer,
onStopDevServer,
onOpenDevServerUrl,
onRunInitScript,
hasInitScript,
}: WorktreeTabProps) {
let prBadge: JSX.Element | null = null;
if (worktree.pr) {
@@ -333,6 +337,8 @@ export function WorktreeTab({
onStartDevServer={onStartDevServer}
onStopDevServer={onStopDevServer}
onOpenDevServerUrl={onOpenDevServerUrl}
onRunInitScript={onRunInitScript}
hasInitScript={hasInitScript}
/>
</div>
);

View File

@@ -1,7 +1,9 @@
import { useEffect, useRef } from 'react';
import { useEffect, useRef, useCallback, useState } from 'react';
import { Button } from '@/components/ui/button';
import { GitBranch, Plus, RefreshCw } from 'lucide-react';
import { cn, pathsEqual } from '@/lib/utils';
import { toast } from 'sonner';
import { getHttpApiClient } from '@/lib/http-api-client';
import type { WorktreePanelProps, WorktreeInfo } from './types';
import {
useWorktrees,
@@ -79,6 +81,28 @@ export function WorktreePanel({
features,
});
// Track whether init script exists for the project
const [hasInitScript, setHasInitScript] = useState(false);
useEffect(() => {
if (!projectPath) {
setHasInitScript(false);
return;
}
const checkInitScript = async () => {
try {
const api = getHttpApiClient();
const result = await api.worktree.getInitScript(projectPath);
setHasInitScript(result.success && result.exists);
} catch {
setHasInitScript(false);
}
};
checkInitScript();
}, [projectPath]);
// Periodic interval check (5 seconds) to detect branch changes on disk
// Reduced from 1s to 5s to minimize GPU/CPU usage from frequent re-renders
const intervalRef = useRef<NodeJS.Timeout | null>(null);
@@ -113,6 +137,33 @@ export function WorktreePanel({
}
};
const handleRunInitScript = useCallback(
async (worktree: WorktreeInfo) => {
if (!projectPath) return;
try {
const api = getHttpApiClient();
const result = await api.worktree.runInitScript(
projectPath,
worktree.path,
worktree.branch
);
if (!result.success) {
toast.error('Failed to run init script', {
description: result.error,
});
}
// Success feedback will come via WebSocket events (init-started, init-output, init-completed)
} catch (error) {
toast.error('Failed to run init script', {
description: error instanceof Error ? error.message : 'Unknown error',
});
}
},
[projectPath]
);
const mainWorktree = worktrees.find((w) => w.isMain);
const nonMainWorktrees = worktrees.filter((w) => !w.isMain);
@@ -162,6 +213,8 @@ export function WorktreePanel({
onStartDevServer={handleStartDevServer}
onStopDevServer={handleStopDevServer}
onOpenDevServerUrl={handleOpenDevServerUrl}
onRunInitScript={handleRunInitScript}
hasInitScript={hasInitScript}
/>
)}
</div>
@@ -216,6 +269,8 @@ export function WorktreePanel({
onStartDevServer={handleStartDevServer}
onStopDevServer={handleStopDevServer}
onOpenDevServerUrl={handleOpenDevServerUrl}
onRunInitScript={handleRunInitScript}
hasInitScript={hasInitScript}
/>
);
})}

View File

@@ -15,6 +15,7 @@ import { TerminalSection } from './settings-view/terminal/terminal-section';
import { AudioSection } from './settings-view/audio/audio-section';
import { KeyboardShortcutsSection } from './settings-view/keyboard-shortcuts/keyboard-shortcuts-section';
import { FeatureDefaultsSection } from './settings-view/feature-defaults/feature-defaults-section';
import { WorktreesSection } from './settings-view/worktrees';
import { DangerZoneSection } from './settings-view/danger-zone/danger-zone-section';
import { AccountSection } from './settings-view/account';
import { SecuritySection } from './settings-view/security';
@@ -149,17 +150,19 @@ export function SettingsView() {
defaultSkipTests={defaultSkipTests}
enableDependencyBlocking={enableDependencyBlocking}
skipVerificationInAutoMode={skipVerificationInAutoMode}
useWorktrees={useWorktrees}
defaultPlanningMode={defaultPlanningMode}
defaultRequirePlanApproval={defaultRequirePlanApproval}
onDefaultSkipTestsChange={setDefaultSkipTests}
onEnableDependencyBlockingChange={setEnableDependencyBlocking}
onSkipVerificationInAutoModeChange={setSkipVerificationInAutoMode}
onUseWorktreesChange={setUseWorktrees}
onDefaultPlanningModeChange={setDefaultPlanningMode}
onDefaultRequirePlanApprovalChange={setDefaultRequirePlanApproval}
/>
);
case 'worktrees':
return (
<WorktreesSection useWorktrees={useWorktrees} onUseWorktreesChange={setUseWorktrees} />
);
case 'account':
return <AccountSection />;
case 'security':

View File

@@ -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 },
{

View File

@@ -3,7 +3,6 @@ import { Checkbox } from '@/components/ui/checkbox';
import {
FlaskConical,
TestTube,
GitBranch,
AlertCircle,
Zap,
ClipboardList,
@@ -27,13 +26,11 @@ interface FeatureDefaultsSectionProps {
defaultSkipTests: boolean;
enableDependencyBlocking: boolean;
skipVerificationInAutoMode: boolean;
useWorktrees: boolean;
defaultPlanningMode: PlanningMode;
defaultRequirePlanApproval: boolean;
onDefaultSkipTestsChange: (value: boolean) => void;
onEnableDependencyBlockingChange: (value: boolean) => void;
onSkipVerificationInAutoModeChange: (value: boolean) => void;
onUseWorktreesChange: (value: boolean) => void;
onDefaultPlanningModeChange: (value: PlanningMode) => void;
onDefaultRequirePlanApprovalChange: (value: boolean) => void;
}
@@ -42,13 +39,11 @@ export function FeatureDefaultsSection({
defaultSkipTests,
enableDependencyBlocking,
skipVerificationInAutoMode,
useWorktrees,
defaultPlanningMode,
defaultRequirePlanApproval,
onDefaultSkipTestsChange,
onEnableDependencyBlockingChange,
onSkipVerificationInAutoModeChange,
onUseWorktreesChange,
onDefaultPlanningModeChange,
onDefaultRequirePlanApprovalChange,
}: FeatureDefaultsSectionProps) {
@@ -256,33 +251,6 @@ export function FeatureDefaultsSection({
</p>
</div>
</div>
{/* Separator */}
<div className="border-t border-border/30" />
{/* Worktree Isolation Setting */}
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
<Checkbox
id="use-worktrees"
checked={useWorktrees}
onCheckedChange={(checked) => onUseWorktreesChange(checked === true)}
className="mt-1"
data-testid="use-worktrees-checkbox"
/>
<div className="space-y-1.5">
<Label
htmlFor="use-worktrees"
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
>
<GitBranch className="w-4 h-4 text-brand-500" />
Enable Git Worktree Isolation
</Label>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
Creates isolated git branches for each feature. When disabled, agents work directly in
the main project directory.
</p>
</div>
</div>
</div>
</div>
);

View File

@@ -16,6 +16,7 @@ export type SettingsViewId =
| 'keyboard'
| 'audio'
| 'defaults'
| 'worktrees'
| 'account'
| 'security'
| 'danger';

View File

@@ -0,0 +1 @@
export { WorktreesSection } from './worktrees-section';

View File

@@ -0,0 +1,430 @@
import { useState, useEffect, useCallback } from 'react';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { Button } from '@/components/ui/button';
import { ShellSyntaxEditor } from '@/components/ui/shell-syntax-editor';
import {
GitBranch,
Terminal,
FileCode,
Save,
RotateCcw,
Trash2,
Loader2,
PanelBottomClose,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { apiGet, apiPut, apiDelete } from '@/lib/api-fetch';
import { toast } from 'sonner';
import { useAppStore } from '@/store/app-store';
import { getHttpApiClient } from '@/lib/http-api-client';
interface WorktreesSectionProps {
useWorktrees: boolean;
onUseWorktreesChange: (value: boolean) => void;
}
interface InitScriptResponse {
success: boolean;
exists: boolean;
content: string;
path: string;
error?: string;
}
export function WorktreesSection({ useWorktrees, onUseWorktreesChange }: WorktreesSectionProps) {
const currentProject = useAppStore((s) => s.currentProject);
const getShowInitScriptIndicator = useAppStore((s) => s.getShowInitScriptIndicator);
const setShowInitScriptIndicator = useAppStore((s) => s.setShowInitScriptIndicator);
const getDefaultDeleteBranch = useAppStore((s) => s.getDefaultDeleteBranch);
const setDefaultDeleteBranch = useAppStore((s) => s.setDefaultDeleteBranch);
const getAutoDismissInitScriptIndicator = useAppStore((s) => s.getAutoDismissInitScriptIndicator);
const setAutoDismissInitScriptIndicator = useAppStore((s) => s.setAutoDismissInitScriptIndicator);
const [scriptContent, setScriptContent] = useState('');
const [originalContent, setOriginalContent] = useState('');
const [scriptExists, setScriptExists] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
// Get the current show indicator setting
const showIndicator = currentProject?.path
? getShowInitScriptIndicator(currentProject.path)
: true;
// Get the default delete branch setting
const defaultDeleteBranch = currentProject?.path
? getDefaultDeleteBranch(currentProject.path)
: false;
// Get the auto-dismiss setting
const autoDismiss = currentProject?.path
? getAutoDismissInitScriptIndicator(currentProject.path)
: true;
// Check if there are unsaved changes
const hasChanges = scriptContent !== originalContent;
// Load init script content when project changes
useEffect(() => {
if (!currentProject?.path) {
setScriptContent('');
setOriginalContent('');
setScriptExists(false);
setIsLoading(false);
return;
}
const loadInitScript = async () => {
setIsLoading(true);
try {
const response = await apiGet<InitScriptResponse>(
`/api/worktree/init-script?projectPath=${encodeURIComponent(currentProject.path)}`
);
if (response.success) {
const content = response.content || '';
setScriptContent(content);
setOriginalContent(content);
setScriptExists(response.exists);
}
} catch (error) {
console.error('Failed to load init script:', error);
} finally {
setIsLoading(false);
}
};
loadInitScript();
}, [currentProject?.path]);
// Save script
const handleSave = useCallback(async () => {
if (!currentProject?.path) return;
setIsSaving(true);
try {
const response = await apiPut<{ success: boolean; error?: string }>(
'/api/worktree/init-script',
{
projectPath: currentProject.path,
content: scriptContent,
}
);
if (response.success) {
setOriginalContent(scriptContent);
setScriptExists(true);
toast.success('Init script saved');
} else {
toast.error('Failed to save init script', {
description: response.error,
});
}
} catch (error) {
console.error('Failed to save init script:', error);
toast.error('Failed to save init script');
} finally {
setIsSaving(false);
}
}, [currentProject?.path, scriptContent]);
// Reset to original content
const handleReset = useCallback(() => {
setScriptContent(originalContent);
}, [originalContent]);
// Delete script
const handleDelete = useCallback(async () => {
if (!currentProject?.path) return;
setIsDeleting(true);
try {
const response = await apiDelete<{ success: boolean; error?: string }>(
'/api/worktree/init-script',
{
body: { projectPath: currentProject.path },
}
);
if (response.success) {
setScriptContent('');
setOriginalContent('');
setScriptExists(false);
toast.success('Init script deleted');
} else {
toast.error('Failed to delete init script', {
description: response.error,
});
}
} catch (error) {
console.error('Failed to delete init script:', error);
toast.error('Failed to delete init script');
} finally {
setIsDeleting(false);
}
}, [currentProject?.path]);
// Handle content change (no auto-save)
const handleContentChange = useCallback((value: string) => {
setScriptContent(value);
}, []);
return (
<div
className={cn(
'rounded-2xl overflow-hidden',
'border border-border/50',
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
'shadow-sm shadow-black/5'
)}
>
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
<div className="flex items-center gap-3 mb-2">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
<GitBranch className="w-5 h-5 text-brand-500" />
</div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">Worktrees</h2>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">
Configure git worktree isolation and initialization scripts.
</p>
</div>
<div className="p-6 space-y-5">
{/* Enable Worktrees Toggle */}
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
<Checkbox
id="use-worktrees"
checked={useWorktrees}
onCheckedChange={(checked) => onUseWorktreesChange(checked === true)}
className="mt-1"
data-testid="use-worktrees-checkbox"
/>
<div className="space-y-1.5">
<Label
htmlFor="use-worktrees"
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
>
<GitBranch className="w-4 h-4 text-brand-500" />
Enable Git Worktree Isolation
</Label>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
Creates isolated git branches for each feature. When disabled, agents work directly in
the main project directory.
</p>
</div>
</div>
{/* Show Init Script Indicator Toggle */}
{currentProject && (
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3 mt-4">
<Checkbox
id="show-init-script-indicator"
checked={showIndicator}
onCheckedChange={async (checked) => {
if (currentProject?.path) {
const value = checked === true;
setShowInitScriptIndicator(currentProject.path, value);
// Persist to server
try {
const httpClient = getHttpApiClient();
await httpClient.settings.updateProject(currentProject.path, {
showInitScriptIndicator: value,
});
} catch (error) {
console.error('Failed to persist showInitScriptIndicator:', error);
}
}
}}
className="mt-1"
/>
<div className="space-y-1.5">
<Label
htmlFor="show-init-script-indicator"
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
>
<PanelBottomClose className="w-4 h-4 text-brand-500" />
Show Init Script Indicator
</Label>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
Display a floating panel in the bottom-right corner showing init script execution
status and output when a worktree is created.
</p>
</div>
</div>
)}
{/* Auto-dismiss Init Script Indicator Toggle */}
{currentProject && showIndicator && (
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3 ml-6">
<Checkbox
id="auto-dismiss-indicator"
checked={autoDismiss}
onCheckedChange={async (checked) => {
if (currentProject?.path) {
const value = checked === true;
setAutoDismissInitScriptIndicator(currentProject.path, value);
// Persist to server
try {
const httpClient = getHttpApiClient();
await httpClient.settings.updateProject(currentProject.path, {
autoDismissInitScriptIndicator: value,
});
} catch (error) {
console.error('Failed to persist autoDismissInitScriptIndicator:', error);
}
}
}}
className="mt-1"
/>
<div className="space-y-1.5">
<Label
htmlFor="auto-dismiss-indicator"
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
>
Auto-dismiss After Completion
</Label>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
Automatically hide the indicator 5 seconds after the script completes.
</p>
</div>
</div>
)}
{/* Default Delete Branch Toggle */}
{currentProject && (
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
<Checkbox
id="default-delete-branch"
checked={defaultDeleteBranch}
onCheckedChange={async (checked) => {
if (currentProject?.path) {
const value = checked === true;
setDefaultDeleteBranch(currentProject.path, value);
// Persist to server
try {
const httpClient = getHttpApiClient();
await httpClient.settings.updateProject(currentProject.path, {
defaultDeleteBranch: value,
});
} catch (error) {
console.error('Failed to persist defaultDeleteBranch:', error);
}
}
}}
className="mt-1"
/>
<div className="space-y-1.5">
<Label
htmlFor="default-delete-branch"
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
>
<Trash2 className="w-4 h-4 text-brand-500" />
Delete Branch by Default
</Label>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
When deleting a worktree, automatically check the "Also delete the branch" option.
</p>
</div>
</div>
)}
{/* Separator */}
<div className="border-t border-border/30" />
{/* Init Script Section */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Terminal className="w-4 h-4 text-brand-500" />
<Label className="text-foreground font-medium">Initialization Script</Label>
</div>
</div>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
Shell commands to run after a worktree is created. Runs once per worktree. Uses Git Bash
on Windows for cross-platform compatibility.
</p>
{currentProject ? (
<>
{/* File path indicator */}
<div className="flex items-center gap-2 text-xs text-muted-foreground/60">
<FileCode className="w-3.5 h-3.5" />
<code className="font-mono">.automaker/worktree-init.sh</code>
{hasChanges && (
<span className="text-amber-500 font-medium">(unsaved changes)</span>
)}
</div>
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
</div>
) : (
<>
<ShellSyntaxEditor
value={scriptContent}
onChange={handleContentChange}
placeholder={`# Example initialization commands
npm install
# Or use pnpm
# pnpm install
# Copy environment file
# cp .env.example .env`}
minHeight="200px"
maxHeight="500px"
data-testid="init-script-editor"
/>
{/* Action buttons */}
<div className="flex items-center justify-end gap-2 pt-2">
<Button
variant="outline"
size="sm"
onClick={handleReset}
disabled={!hasChanges || isSaving || isDeleting}
className="gap-1.5"
>
<RotateCcw className="w-3.5 h-3.5" />
Reset
</Button>
<Button
variant="outline"
size="sm"
onClick={handleDelete}
disabled={!scriptExists || isSaving || isDeleting}
className="gap-1.5 text-destructive hover:text-destructive hover:bg-destructive/10"
>
{isDeleting ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Trash2 className="w-3.5 h-3.5" />
)}
Delete
</Button>
<Button
size="sm"
onClick={handleSave}
disabled={!hasChanges || isSaving || isDeleting}
className="gap-1.5"
>
{isSaving ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Save className="w-3.5 h-3.5" />
)}
Save
</Button>
</div>
</>
)}
</>
) : (
<div className="text-sm text-muted-foreground/60 py-4 text-center">
Select a project to configure the init script.
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,79 @@
import { useEffect } from 'react';
import { useAppStore } from '@/store/app-store';
import { getHttpApiClient } from '@/lib/http-api-client';
import { pathsEqual } from '@/lib/utils';
interface InitScriptStartedPayload {
projectPath: string;
worktreePath: string;
branch: string;
}
interface InitScriptOutputPayload {
projectPath: string;
branch: string;
type: 'stdout' | 'stderr';
content: string;
}
interface InitScriptCompletedPayload {
projectPath: string;
worktreePath: string;
branch: string;
success: boolean;
exitCode?: number;
error?: string;
}
/**
* Hook to subscribe to init script WebSocket events and update the store.
* Should be used in a component that's always mounted (e.g., board-view).
*/
export function useInitScriptEvents(projectPath: string | null) {
const setInitScriptState = useAppStore((s) => s.setInitScriptState);
const appendInitScriptOutput = useAppStore((s) => s.appendInitScriptOutput);
useEffect(() => {
if (!projectPath) return;
const api = getHttpApiClient();
const unsubscribe = api.worktree.onInitScriptEvent((event) => {
const payload = event.payload as
| InitScriptStartedPayload
| InitScriptOutputPayload
| InitScriptCompletedPayload;
// Only handle events for the current project (use pathsEqual for cross-platform path comparison)
if (!pathsEqual(payload.projectPath, projectPath)) return;
switch (event.type) {
case 'worktree:init-started': {
const startPayload = payload as InitScriptStartedPayload;
setInitScriptState(projectPath, startPayload.branch, {
status: 'running',
branch: startPayload.branch,
output: [],
error: undefined,
});
break;
}
case 'worktree:init-output': {
const outputPayload = payload as InitScriptOutputPayload;
appendInitScriptOutput(projectPath, outputPayload.branch, outputPayload.content);
break;
}
case 'worktree:init-completed': {
const completePayload = payload as InitScriptCompletedPayload;
setInitScriptState(projectPath, completePayload.branch, {
status: completePayload.success ? 'success' : 'failed',
error: completePayload.error,
});
break;
}
}
});
return unsubscribe;
}, [projectPath, setInitScriptState, appendInitScriptOutput]);
}

View File

@@ -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<string | null>(null);
const currentProjectRef = useRef<string | null>(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);

View File

@@ -651,7 +651,8 @@ export interface ElectronAPI {
removedDependencies: string[];
addedDependencies: string[];
}>;
}
},
branchName?: string
) => Promise<{ success: boolean; appliedChanges?: string[]; error?: string }>;
onEvent: (callback: (data: unknown) => void) => () => void;
};
@@ -1769,6 +1770,47 @@ function createMockWorktreeAPI(): WorktreeAPI {
},
};
},
getInitScript: async (projectPath: string) => {
console.log('[Mock] Getting init script:', { projectPath });
return {
success: true,
exists: false,
content: '',
path: `${projectPath}/.automaker/worktree-init.sh`,
};
},
setInitScript: async (projectPath: string, content: string) => {
console.log('[Mock] Setting init script:', { projectPath, content });
return {
success: true,
path: `${projectPath}/.automaker/worktree-init.sh`,
};
},
deleteInitScript: async (projectPath: string) => {
console.log('[Mock] Deleting init script:', { projectPath });
return {
success: true,
};
},
runInitScript: async (projectPath: string, worktreePath: string, branch: string) => {
console.log('[Mock] Running init script:', { projectPath, worktreePath, branch });
return {
success: true,
message: 'Init script started (mock)',
};
},
onInitScriptEvent: (callback) => {
console.log('[Mock] Subscribing to init script events');
// Return unsubscribe function
return () => {
console.log('[Mock] Unsubscribing from init script events');
};
},
};
}

View File

@@ -507,7 +507,10 @@ type EventType =
| 'issue-validation:event'
| 'backlog-plan:event'
| 'ideation:stream'
| 'ideation:analysis';
| 'ideation:analysis'
| 'worktree:init-started'
| 'worktree:init-output'
| 'worktree:init-completed';
type EventCallback = (payload: unknown) => void;
@@ -846,13 +849,14 @@ export class HttpApiClient implements ElectronAPI {
return response.json();
}
private async httpDelete<T>(endpoint: string): Promise<T> {
private async httpDelete<T>(endpoint: string, body?: unknown): Promise<T> {
// Ensure API key is initialized before making request
await waitForApiKeyInit();
const response = await fetch(`${this.serverUrl}${endpoint}`, {
method: 'DELETE',
headers: this.getHeaders(),
credentials: 'include', // Include cookies for session auth
body: body ? JSON.stringify(body) : undefined,
});
if (response.status === 401 || response.status === 403) {
@@ -1647,6 +1651,37 @@ export class HttpApiClient implements ElectronAPI {
listDevServers: () => this.post('/api/worktree/list-dev-servers', {}),
getPRInfo: (worktreePath: string, branchName: string) =>
this.post('/api/worktree/pr-info', { worktreePath, branchName }),
// Init script methods
getInitScript: (projectPath: string) =>
this.get(`/api/worktree/init-script?projectPath=${encodeURIComponent(projectPath)}`),
setInitScript: (projectPath: string, content: string) =>
this.put('/api/worktree/init-script', { projectPath, content }),
deleteInitScript: (projectPath: string) =>
this.httpDelete('/api/worktree/init-script', { projectPath }),
runInitScript: (projectPath: string, worktreePath: string, branch: string) =>
this.post('/api/worktree/run-init-script', { projectPath, worktreePath, branch }),
onInitScriptEvent: (
callback: (event: {
type: 'worktree:init-started' | 'worktree:init-output' | 'worktree:init-completed';
payload: unknown;
}) => void
) => {
// Note: subscribeToEvent callback receives (payload) not (_, payload)
const unsub1 = this.subscribeToEvent('worktree:init-started', (payload) =>
callback({ type: 'worktree:init-started', payload })
);
const unsub2 = this.subscribeToEvent('worktree:init-output', (payload) =>
callback({ type: 'worktree:init-output', payload })
);
const unsub3 = this.subscribeToEvent('worktree:init-completed', (payload) =>
callback({ type: 'worktree:init-completed', payload })
);
return () => {
unsub1();
unsub2();
unsub3();
};
},
};
// Git API
@@ -2167,9 +2202,10 @@ export class HttpApiClient implements ElectronAPI {
removedDependencies: string[];
addedDependencies: string[];
}>;
}
},
branchName?: string
): Promise<{ success: boolean; appliedChanges?: string[]; error?: string }> =>
this.post('/api/backlog-plan/apply', { projectPath, plan }),
this.post('/api/backlog-plan/apply', { projectPath, plan, branchName }),
onEvent: (callback: (data: unknown) => void): (() => void) => {
return this.subscribeToEvent('backlog-plan:event', callback as EventCallback);

View File

@@ -80,6 +80,9 @@ export type ThemeMode =
// LocalStorage key for theme persistence (fallback when server settings aren't available)
export const THEME_STORAGE_KEY = 'automaker:theme';
// Maximum number of output lines to keep in init script state (prevents unbounded memory growth)
export const MAX_INIT_OUTPUT_LINES = 500;
/**
* Get the theme from localStorage as a fallback
* Used before server settings are loaded (e.g., on login/setup pages)
@@ -469,6 +472,14 @@ export interface PersistedTerminalSettings {
maxSessions: number;
}
/** State for worktree init script execution */
export interface InitScriptState {
status: 'idle' | 'running' | 'success' | 'failed';
branch: string;
output: string[];
error?: string;
}
export interface AppState {
// Project state
projects: Project[];
@@ -522,6 +533,8 @@ export interface AppState {
defaultSkipTests: boolean; // Default value for skip tests when creating new features
enableDependencyBlocking: boolean; // When true, show blocked badges and warnings for features with incomplete dependencies (default: true)
skipVerificationInAutoMode: boolean; // When true, auto-mode grabs features even if dependencies are not verified (only checks they're not running)
planUseSelectedWorktreeBranch: boolean; // When true, Plan dialog creates features on the currently selected worktree branch
addFeatureUseSelectedWorktreeBranch: boolean; // When true, Add Feature dialog defaults to custom mode with selected worktree branch
// Worktree Settings
useWorktrees: boolean; // Whether to use git worktree isolation for features (default: true)
@@ -670,6 +683,18 @@ export interface AppState {
// Whether the worktree panel row is visible (default: true)
worktreePanelVisibleByProject: Record<string, boolean>;
// Init Script Indicator Visibility (per-project, keyed by project path)
// Whether to show the floating init script indicator panel (default: true)
showInitScriptIndicatorByProject: Record<string, boolean>;
// 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<string, boolean>;
// Auto-dismiss Init Script Indicator (per-project, keyed by project path)
// Whether to auto-dismiss the indicator after completion (default: true)
autoDismissInitScriptIndicatorByProject: Record<string, boolean>;
// UI State (previously in localStorage, now synced via API)
/** Whether worktree panel is collapsed in board view */
worktreePanelCollapsed: boolean;
@@ -677,6 +702,9 @@ export interface AppState {
lastProjectDir: string;
/** Recently accessed folders for quick access */
recentFolders: string[];
// Init Script State (keyed by "projectPath::branch" to support concurrent scripts)
initScriptState: Record<string, InitScriptState>;
}
// Claude Usage interface matching the server response
@@ -890,6 +918,8 @@ export interface AppActions {
setDefaultSkipTests: (skip: boolean) => void;
setEnableDependencyBlocking: (enabled: boolean) => void;
setSkipVerificationInAutoMode: (enabled: boolean) => Promise<void>;
setPlanUseSelectedWorktreeBranch: (enabled: boolean) => Promise<void>;
setAddFeatureUseSelectedWorktreeBranch: (enabled: boolean) => Promise<void>;
// Worktree Settings actions
setUseWorktrees: (enabled: boolean) => void;
@@ -1083,6 +1113,18 @@ export interface AppActions {
setWorktreePanelVisible: (projectPath: string, visible: boolean) => void;
getWorktreePanelVisible: (projectPath: string) => boolean;
// Init Script Indicator Visibility actions (per-project)
setShowInitScriptIndicator: (projectPath: string, visible: boolean) => void;
getShowInitScriptIndicator: (projectPath: string) => boolean;
// Default Delete Branch actions (per-project)
setDefaultDeleteBranch: (projectPath: string, deleteBranch: boolean) => void;
getDefaultDeleteBranch: (projectPath: string) => boolean;
// Auto-dismiss Init Script Indicator actions (per-project)
setAutoDismissInitScriptIndicator: (projectPath: string, autoDismiss: boolean) => void;
getAutoDismissInitScriptIndicator: (projectPath: string) => boolean;
// UI State actions (previously in localStorage, now synced via API)
setWorktreePanelCollapsed: (collapsed: boolean) => void;
setLastProjectDir: (dir: string) => void;
@@ -1111,6 +1153,19 @@ export interface AppActions {
}>
) => void;
// Init Script State actions (keyed by projectPath::branch to support concurrent scripts)
setInitScriptState: (
projectPath: string,
branch: string,
state: Partial<InitScriptState>
) => void;
appendInitScriptOutput: (projectPath: string, branch: string, content: string) => void;
clearInitScriptState: (projectPath: string, branch: string) => void;
getInitScriptState: (projectPath: string, branch: string) => InitScriptState | null;
getInitScriptStatesForProject: (
projectPath: string
) => Array<{ key: string; state: InitScriptState }>;
// Reset
reset: () => void;
}
@@ -1143,6 +1198,8 @@ const initialState: AppState = {
defaultSkipTests: true, // Default to manual verification (tests disabled)
enableDependencyBlocking: true, // Default to enabled (show dependency blocking UI)
skipVerificationInAutoMode: false, // Default to disabled (require dependencies to be verified)
planUseSelectedWorktreeBranch: true, // Default to enabled (Plan creates features on selected worktree branch)
addFeatureUseSelectedWorktreeBranch: false, // Default to disabled (Add Feature uses normal defaults)
useWorktrees: true, // Default to enabled (git worktree isolation)
currentWorktreeByProject: {},
worktreesByProject: {},
@@ -1208,10 +1265,14 @@ const initialState: AppState = {
codexModelsLastFetched: null,
pipelineConfigByProject: {},
worktreePanelVisibleByProject: {},
showInitScriptIndicatorByProject: {},
defaultDeleteBranchByProject: {},
autoDismissInitScriptIndicatorByProject: {},
// UI State (previously in localStorage, now synced via API)
worktreePanelCollapsed: false,
lastProjectDir: '',
recentFolders: [],
initScriptState: {},
};
export const useAppStore = create<AppState & AppActions>()((set, get) => ({
@@ -1764,6 +1825,30 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
const { syncSettingsToServer } = await import('@/hooks/use-settings-migration');
await syncSettingsToServer();
},
setPlanUseSelectedWorktreeBranch: async (enabled) => {
const previous = get().planUseSelectedWorktreeBranch;
set({ planUseSelectedWorktreeBranch: enabled });
// Sync to server settings file
const { syncSettingsToServer } = await import('@/hooks/use-settings-migration');
const ok = await syncSettingsToServer();
if (!ok) {
logger.error('Failed to sync planUseSelectedWorktreeBranch setting to server - reverting');
set({ planUseSelectedWorktreeBranch: previous });
}
},
setAddFeatureUseSelectedWorktreeBranch: async (enabled) => {
const previous = get().addFeatureUseSelectedWorktreeBranch;
set({ addFeatureUseSelectedWorktreeBranch: enabled });
// Sync to server settings file
const { syncSettingsToServer } = await import('@/hooks/use-settings-migration');
const ok = await syncSettingsToServer();
if (!ok) {
logger.error(
'Failed to sync addFeatureUseSelectedWorktreeBranch setting to server - reverting'
);
set({ addFeatureUseSelectedWorktreeBranch: previous });
}
},
// Worktree Settings actions
setUseWorktrees: (enabled) => set({ useWorktrees: enabled }),
@@ -3136,6 +3221,51 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
return get().worktreePanelVisibleByProject[projectPath] ?? true;
},
// Init Script Indicator Visibility actions (per-project)
setShowInitScriptIndicator: (projectPath, visible) => {
set({
showInitScriptIndicatorByProject: {
...get().showInitScriptIndicatorByProject,
[projectPath]: visible,
},
});
},
getShowInitScriptIndicator: (projectPath) => {
// Default to true (visible) if not set
return get().showInitScriptIndicatorByProject[projectPath] ?? true;
},
// Default Delete Branch actions (per-project)
setDefaultDeleteBranch: (projectPath, deleteBranch) => {
set({
defaultDeleteBranchByProject: {
...get().defaultDeleteBranchByProject,
[projectPath]: deleteBranch,
},
});
},
getDefaultDeleteBranch: (projectPath) => {
// Default to false (don't delete branch) if not set
return get().defaultDeleteBranchByProject[projectPath] ?? false;
},
// Auto-dismiss Init Script Indicator actions (per-project)
setAutoDismissInitScriptIndicator: (projectPath, autoDismiss) => {
set({
autoDismissInitScriptIndicatorByProject: {
...get().autoDismissInitScriptIndicatorByProject,
[projectPath]: autoDismiss,
},
});
},
getAutoDismissInitScriptIndicator: (projectPath) => {
// Default to true (auto-dismiss enabled) if not set
return get().autoDismissInitScriptIndicatorByProject[projectPath] ?? true;
},
// UI State actions (previously in localStorage, now synced via API)
setWorktreePanelCollapsed: (collapsed) => set({ worktreePanelCollapsed: collapsed }),
setLastProjectDir: (dir) => set({ lastProjectDir: dir }),
@@ -3149,6 +3279,62 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
set({ recentFolders: updated });
},
// Init Script State actions (keyed by "projectPath::branch")
setInitScriptState: (projectPath, branch, state) => {
const key = `${projectPath}::${branch}`;
const current = get().initScriptState[key] || {
status: 'idle',
branch,
output: [],
};
set({
initScriptState: {
...get().initScriptState,
[key]: { ...current, ...state },
},
});
},
appendInitScriptOutput: (projectPath, branch, content) => {
const key = `${projectPath}::${branch}`;
// Initialize state if absent to avoid dropping output due to event-order races
const current = get().initScriptState[key] || {
status: 'idle' as const,
branch,
output: [],
};
// Append new content and enforce fixed-size buffer to prevent memory bloat
const newOutput = [...current.output, content].slice(-MAX_INIT_OUTPUT_LINES);
set({
initScriptState: {
...get().initScriptState,
[key]: {
...current,
output: newOutput,
},
},
});
},
clearInitScriptState: (projectPath, branch) => {
const key = `${projectPath}::${branch}`;
const { [key]: _, ...rest } = get().initScriptState;
set({ initScriptState: rest });
},
getInitScriptState: (projectPath, branch) => {
const key = `${projectPath}::${branch}`;
return get().initScriptState[key] || null;
},
getInitScriptStatesForProject: (projectPath) => {
const prefix = `${projectPath}::`;
const states = get().initScriptState;
return Object.entries(states)
.filter(([key]) => key.startsWith(prefix))
.map(([key, state]) => ({ key, state }));
},
// Reset
reset: () => set(initialState),
}));

View File

@@ -1015,6 +1015,50 @@ export interface WorktreeAPI {
};
error?: string;
}>;
// Get init script content for a project
getInitScript: (projectPath: string) => Promise<{
success: boolean;
exists: boolean;
content: string;
path: string;
error?: string;
}>;
// Set init script content for a project
setInitScript: (
projectPath: string,
content: string
) => Promise<{
success: boolean;
path?: string;
error?: string;
}>;
// Delete init script for a project
deleteInitScript: (projectPath: string) => Promise<{
success: boolean;
error?: string;
}>;
// Run (or re-run) init script for a worktree
runInitScript: (
projectPath: string,
worktreePath: string,
branch: string
) => Promise<{
success: boolean;
message?: string;
error?: string;
}>;
// Subscribe to init script events
onInitScriptEvent: (
callback: (event: {
type: 'worktree:init-started' | 'worktree:init-output' | 'worktree:init-completed';
payload: unknown;
}) => void
) => () => void;
}
export interface GitAPI {

View File

@@ -0,0 +1,30 @@
#!/bin/bash
# Example worktree init script for Automaker
# Copy this content to Settings > Worktrees > Init Script
# Or save directly as .automaker/worktree-init.sh in your project
echo "=========================================="
echo " Worktree Init Script Starting..."
echo "=========================================="
echo ""
echo "Current directory: $(pwd)"
echo "Branch: $(git branch --show-current 2>/dev/null || echo 'unknown')"
echo ""
# Install dependencies
echo "[1/1] Installing npm dependencies..."
if [ -f "package.json" ]; then
if npm install; then
echo "Dependencies installed successfully!"
else
echo "ERROR: npm install failed with exit code $?"
exit 1
fi
else
echo "No package.json found, skipping npm install"
fi
echo ""
echo "=========================================="
echo " Worktree initialization complete!"
echo "=========================================="

View File

@@ -97,6 +97,7 @@ export {
getCodexCliPaths,
getCodexConfigDir,
getCodexAuthPath,
getGitBashPaths,
getOpenCodeCliPaths,
getOpenCodeConfigDir,
getOpenCodeAuthPath,
@@ -130,6 +131,7 @@ export {
findCodexCliPath,
getCodexAuthIndicators,
type CodexAuthIndicators,
findGitBashPath,
findOpenCodeCliPath,
getOpenCodeAuthIndicators,
type OpenCodeAuthIndicators,

View File

@@ -232,6 +232,87 @@ export function getClaudeProjectsDir(): string {
return path.join(getClaudeConfigDir(), 'projects');
}
/**
* Enumerate directories matching a prefix pattern and return full paths
* Used to resolve dynamic directory names like version numbers
*/
function enumerateMatchingPaths(
parentDir: string,
prefix: string,
...subPathParts: string[]
): string[] {
try {
if (!fsSync.existsSync(parentDir)) {
return [];
}
const entries = fsSync.readdirSync(parentDir);
const matching = entries.filter((entry) => entry.startsWith(prefix));
return matching.map((entry) => path.join(parentDir, entry, ...subPathParts));
} catch {
return [];
}
}
/**
* Get common Git Bash installation paths on Windows
* Git Bash is needed for running shell scripts cross-platform
*/
export function getGitBashPaths(): string[] {
if (process.platform !== 'win32') {
return [];
}
const homeDir = os.homedir();
const localAppData = process.env.LOCALAPPDATA || '';
// Dynamic paths that require directory enumeration
// winget installs to: LocalAppData\Microsoft\WinGet\Packages\Git.Git_<hash>\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-<version>\resources\app\git\cmd\bash.exe
const githubDesktopPaths = localAppData
? enumerateMatchingPaths(
path.join(localAppData, 'GitHubDesktop'),
'app-',
'resources',
'app',
'git',
'cmd',
'bash.exe'
)
: [];
return [
// Standard Git for Windows installations
'C:\\Program Files\\Git\\bin\\bash.exe',
'C:\\Program Files (x86)\\Git\\bin\\bash.exe',
// User-local installations
path.join(localAppData, 'Programs', 'Git', 'bin', 'bash.exe'),
// Scoop package manager
path.join(homeDir, 'scoop', 'apps', 'git', 'current', 'bin', 'bash.exe'),
// Chocolatey
path.join(
process.env.ChocolateyInstall || 'C:\\ProgramData\\chocolatey',
'lib',
'git',
'tools',
'bin',
'bash.exe'
),
// winget installations (dynamically resolved)
...wingetGitPaths,
// GitHub Desktop bundled Git (dynamically resolved)
...githubDesktopPaths,
].filter(Boolean);
}
/**
* Get common shell paths for shell detection
* Includes both full paths and short names to match $SHELL or PATH entries
@@ -550,6 +631,8 @@ function getAllAllowedSystemPaths(): string[] {
getOpenCodeAuthPath(),
// Shell paths
...getShellPaths(),
// Git Bash paths (for Windows cross-platform shell script execution)
...getGitBashPaths(),
// Node.js system paths
...getNodeSystemPaths(),
getScoopNodePath(),
@@ -883,6 +966,13 @@ export async function findCodexCliPath(): Promise<string | null> {
return findFirstExistingPath(getCodexCliPaths());
}
/**
* Find Git Bash on Windows and return its path
*/
export async function findGitBashPath(): Promise<string | null> {
return findFirstExistingPath(getGitBashPaths());
}
/**
* Get Claude authentication status by checking various indicators
*/

View File

@@ -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;

View File

@@ -599,6 +599,14 @@ export interface ProjectSettings {
// UI Visibility
/** Whether the worktree panel row is visible (default: true) */
worktreePanelVisible?: boolean;
/** Whether to show the init script indicator panel (default: true) */
showInitScriptIndicator?: boolean;
// Worktree Behavior
/** Default value for "delete branch" checkbox when deleting a worktree (default: false) */
defaultDeleteBranchWithWorktree?: boolean;
/** Auto-dismiss init script indicator after completion (default: true) */
autoDismissInitScriptIndicator?: boolean;
// Session Tracking
/** Last chat session selected in this project */

25
package-lock.json generated
View File

@@ -87,6 +87,8 @@
"@automaker/dependency-resolver": "1.0.0",
"@automaker/types": "1.0.0",
"@codemirror/lang-xml": "6.1.0",
"@codemirror/language": "^6.12.1",
"@codemirror/legacy-modes": "^6.5.2",
"@codemirror/theme-one-dark": "6.1.3",
"@dnd-kit/core": "6.3.1",
"@dnd-kit/sortable": "10.0.0",
@@ -1199,19 +1201,28 @@
}
},
"node_modules/@codemirror/language": {
"version": "6.11.3",
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.11.3.tgz",
"integrity": "sha512-9HBM2XnwDj7fnu0551HkGdrUrrqmYq/WC5iv6nbY2WdicXdGbhR/gfbZOH73Aqj4351alY1+aoG9rCNfiwS1RA==",
"version": "6.12.1",
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.1.tgz",
"integrity": "sha512-Fa6xkSiuGKc8XC8Cn96T+TQHYj4ZZ7RdFmXA3i9xe/3hLHfwPZdM+dqfX0Cp0zQklBKhVD8Yzc8LS45rkqcwpQ==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.23.0",
"@lezer/common": "^1.1.0",
"@lezer/common": "^1.5.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0",
"style-mod": "^4.0.0"
}
},
"node_modules/@codemirror/legacy-modes": {
"version": "6.5.2",
"resolved": "https://registry.npmjs.org/@codemirror/legacy-modes/-/legacy-modes-6.5.2.tgz",
"integrity": "sha512-/jJbwSTazlQEDOQw2FJ8LEEKVS72pU0lx6oM54kGpL8t/NJ2Jda3CZ4pcltiKTdqYSRk3ug1B3pil1gsjA6+8Q==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0"
}
},
"node_modules/@codemirror/lint": {
"version": "6.9.2",
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.2.tgz",
@@ -3604,9 +3615,9 @@
}
},
"node_modules/@lezer/common": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.4.0.tgz",
"integrity": "sha512-DVeMRoGrgn/k45oQNu189BoW4SZwgZFzJ1+1TV5j2NJ/KFC83oa/enRqZSGshyeMk5cPWMhsKs9nx+8o0unwGg==",
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.0.tgz",
"integrity": "sha512-PNGcolp9hr4PJdXR4ix7XtixDrClScvtSCYW3rQG106oVMOOI+jFb+0+J3mbeL/53g1Zd6s0kJzaw6Ri68GmAA==",
"license": "MIT"
},
"node_modules/@lezer/highlight": {