feat: Implement worktree initialization script functionality

This commit introduces a new feature for managing worktree initialization scripts, allowing users to configure and execute scripts upon worktree creation. Key changes include:

1. **New API Endpoints**: Added endpoints for getting, setting, and deleting init scripts.
2. **Worktree Routes**: Updated worktree routes to include init script handling.
3. **Init Script Service**: Created a service to execute the init scripts asynchronously, with support for cross-platform compatibility.
4. **UI Components**: Added UI components for displaying and editing init scripts, including a dedicated section in the settings view.
5. **Event Handling**: Implemented event handling for init script execution status, providing real-time feedback in the UI.

This enhancement improves the user experience by allowing automated setup processes for new worktrees, streamlining project workflows.
This commit is contained in:
Kacper
2026-01-10 22:19:34 +01:00
parent 427832e72e
commit 05d96a7d6e
23 changed files with 1481 additions and 46 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

@@ -3,6 +3,7 @@
*/
import { Router } from 'express';
import type { EventEmitter } from '../../lib/events.js';
import { validatePathParams } from '../../middleware/validate-paths.js';
import { requireValidWorktree, requireValidProject, requireGitRepoOnly } from './middleware.js';
import { createInfoHandler } from './routes/info.js';
@@ -30,8 +31,13 @@ import { createMigrateHandler } from './routes/migrate.js';
import { createStartDevHandler } from './routes/start-dev.js';
import { createStopDevHandler } from './routes/stop-dev.js';
import { createListDevServersHandler } from './routes/list-dev-servers.js';
import {
createGetInitScriptHandler,
createPutInitScriptHandler,
createDeleteInitScriptHandler,
} from './routes/init-script.js';
export function createWorktreeRoutes(): Router {
export function createWorktreeRoutes(events: EventEmitter): Router {
const router = Router();
router.post('/info', validatePathParams('projectPath'), createInfoHandler());
@@ -45,7 +51,7 @@ export function createWorktreeRoutes(): Router {
requireValidProject,
createMergeHandler()
);
router.post('/create', validatePathParams('projectPath'), createCreateHandler());
router.post('/create', validatePathParams('projectPath'), createCreateHandler(events));
router.post('/delete', validatePathParams('projectPath', 'worktreePath'), createDeleteHandler());
router.post('/create-pr', createCreatePRHandler());
router.post('/pr-info', createPRInfoHandler());
@@ -87,5 +93,10 @@ export function createWorktreeRoutes(): Router {
router.post('/stop-dev', createStopDevHandler());
router.post('/list-dev-servers', createListDevServersHandler());
// Init script routes
router.post('/init-script', validatePathParams('projectPath'), createGetInitScriptHandler());
router.put('/init-script', validatePathParams('projectPath'), createPutInitScriptHandler());
router.delete('/init-script', validatePathParams('projectPath'), createDeleteInitScriptHandler());
return router;
}

View File

@@ -12,6 +12,7 @@ import { exec } from 'child_process';
import { promisify } from 'util';
import path from 'path';
import * as secureFs from '../../../lib/secure-fs.js';
import type { EventEmitter } from '../../../lib/events.js';
import {
isGitRepo,
getErrorMessage,
@@ -21,6 +22,8 @@ import {
} from '../common.js';
import { trackBranch } from './branch-tracking.js';
import { createLogger } from '@automaker/utils';
import { runInitScript, getInitScriptPath, hasInitScriptRun } from '../../../services/init-script-service.js';
import fs from 'fs';
const logger = createLogger('Worktree');
@@ -77,7 +80,7 @@ async function findExistingWorktreeForBranch(
}
}
export function createCreateHandler() {
export function createCreateHandler(events: EventEmitter) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, branchName, baseBranch } = req.body as {
@@ -177,6 +180,13 @@ export function createCreateHandler() {
// Resolve to absolute path for cross-platform compatibility
// normalizePath converts to forward slashes for API consistency
const absoluteWorktreePath = path.resolve(worktreePath);
// Check if init script exists and should be run (only for new worktrees)
const initScriptPath = getInitScriptPath(projectPath);
const hasInitScript = fs.existsSync(initScriptPath);
const alreadyRan = await hasInitScriptRun(projectPath, branchName);
// Respond immediately (non-blocking)
res.json({
success: true,
worktree: {
@@ -185,6 +195,19 @@ export function createCreateHandler() {
isNew: !branchExists,
},
});
// Trigger init script asynchronously after response
if (hasInitScript && !alreadyRan) {
logger.info(`Triggering init script for worktree: ${branchName}`);
runInitScript({
projectPath,
worktreePath: absoluteWorktreePath,
branch: branchName,
emitter: events,
}).catch((err) => {
logger.error(`Init script failed for ${branchName}:`, err);
});
}
} catch (error) {
logError(error, 'Create worktree failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });

View File

@@ -0,0 +1,162 @@
/**
* Init Script routes - Read/write the worktree-init.sh file
*
* GET /init-script - Read the init script content
* PUT /init-script - Write content to the init script file
* DELETE /init-script - Delete the init script file
*/
import type { Request, Response } from 'express';
import path from 'path';
import * as secureFs from '../../../lib/secure-fs.js';
import { getErrorMessage, logError } from '../common.js';
import { createLogger } from '@automaker/utils';
const logger = createLogger('InitScript');
/** Fixed path for init script within .automaker directory */
const INIT_SCRIPT_FILENAME = 'worktree-init.sh';
/**
* Get the full path to the init script for a project
*/
function getInitScriptPath(projectPath: string): string {
return path.join(projectPath, '.automaker', INIT_SCRIPT_FILENAME);
}
/**
* GET /init-script - Read the init script content
*/
export function createGetInitScriptHandler() {
return async (req: Request, res: Response): Promise<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);
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;
}
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);
try {
await secureFs.rm(scriptPath, { force: true });
logger.info(`Deleted init script at ${scriptPath}`);
res.json({
success: true,
});
} catch {
// File doesn't exist - still success
res.json({
success: true,
});
}
} catch (error) {
logError(error, 'Delete init script failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
});
}
};
}

View File

@@ -0,0 +1,258 @@
/**
* Init Script Service - Executes worktree initialization scripts
*
* Runs the .automaker/worktree-init.sh script after worktree creation.
* Uses Git Bash on Windows for cross-platform shell script compatibility.
*/
import { spawn } from 'child_process';
import path from 'path';
import fs from 'fs';
import { createLogger } from '@automaker/utils';
import type { EventEmitter } from '../lib/events.js';
import {
readWorktreeMetadata,
writeWorktreeMetadata,
} from '../lib/worktree-metadata.js';
const logger = createLogger('InitScript');
/** Common Git Bash installation paths on Windows */
const GIT_BASH_PATHS = [
'C:\\Program Files\\Git\\bin\\bash.exe',
'C:\\Program Files (x86)\\Git\\bin\\bash.exe',
path.join(process.env.LOCALAPPDATA || '', 'Programs', 'Git', 'bin', 'bash.exe'),
path.join(process.env.USERPROFILE || '', 'scoop', 'apps', 'git', 'current', 'bin', 'bash.exe'),
];
/**
* Find Git Bash executable on Windows
*/
function findGitBash(): string | null {
if (process.platform !== 'win32') {
return null;
}
for (const bashPath of GIT_BASH_PATHS) {
if (fs.existsSync(bashPath)) {
return bashPath;
}
}
return null;
}
/**
* Get the shell command for running scripts
* Returns [shellPath, shellArgs] for cross-platform compatibility
*/
function getShellCommand(): { shell: string; args: string[] } | null {
if (process.platform === 'win32') {
const gitBash = findGitBash();
if (!gitBash) {
return null;
}
return { shell: gitBash, args: [] };
}
// Unix-like systems: prefer bash, fall back to sh
if (fs.existsSync('/bin/bash')) {
return { shell: '/bin/bash', args: [] };
}
if (fs.existsSync('/bin/sh')) {
return { shell: '/bin/sh', args: [] };
}
return null;
}
export interface InitScriptOptions {
/** Absolute path to the project root */
projectPath: string;
/** Absolute path to the worktree directory */
worktreePath: string;
/** Branch name for this worktree */
branch: string;
/** Event emitter for streaming output */
emitter: EventEmitter;
}
/**
* Check if init script exists for a project
*/
export function getInitScriptPath(projectPath: string): string {
return path.join(projectPath, '.automaker', 'worktree-init.sh');
}
/**
* Check if the init script has already been run for a worktree
*/
export async function hasInitScriptRun(
projectPath: string,
branch: string
): Promise<boolean> {
const metadata = await readWorktreeMetadata(projectPath, branch);
return metadata?.initScriptRan === true;
}
/**
* Run the worktree initialization script
* Non-blocking - returns immediately after spawning
*/
export async function runInitScript(options: InitScriptOptions): Promise<void> {
const { projectPath, worktreePath, branch, emitter } = options;
const scriptPath = getInitScriptPath(projectPath);
// Check if script exists
if (!fs.existsSync(scriptPath)) {
logger.debug(`No init script found at ${scriptPath}`);
return;
}
// Check if already run
if (await hasInitScriptRun(projectPath, branch)) {
logger.info(`Init script already ran for branch "${branch}", skipping`);
return;
}
// Get shell command
const shellCmd = getShellCommand();
if (!shellCmd) {
const error =
process.platform === 'win32'
? 'Git Bash not found. Please install Git for Windows to run init scripts.'
: 'No shell found (/bin/bash or /bin/sh)';
logger.error(error);
// Update metadata with error
await writeWorktreeMetadata(projectPath, branch, {
branch,
createdAt: new Date().toISOString(),
initScriptRan: true,
initScriptStatus: 'failed',
initScriptError: error,
});
emitter.emit('worktree:init-completed', {
projectPath,
worktreePath,
branch,
success: false,
error,
});
return;
}
logger.info(`Running init script for branch "${branch}" in ${worktreePath}`);
// Update metadata to mark as running
const existingMetadata = await readWorktreeMetadata(projectPath, branch);
await writeWorktreeMetadata(projectPath, branch, {
branch,
createdAt: existingMetadata?.createdAt || new Date().toISOString(),
pr: existingMetadata?.pr,
initScriptRan: false,
initScriptStatus: 'running',
});
// Emit started event
emitter.emit('worktree:init-started', {
projectPath,
worktreePath,
branch,
});
// Spawn the script
const child = spawn(shellCmd.shell, [...shellCmd.args, scriptPath], {
cwd: worktreePath,
env: {
...process.env,
// Provide useful env vars to the script
AUTOMAKER_PROJECT_PATH: projectPath,
AUTOMAKER_WORKTREE_PATH: worktreePath,
AUTOMAKER_BRANCH: branch,
// Force color output even though we're not a TTY
FORCE_COLOR: '1',
npm_config_color: 'always',
CLICOLOR_FORCE: '1',
// Git colors
GIT_TERMINAL_PROMPT: '0',
},
stdio: ['ignore', 'pipe', 'pipe'],
});
// Stream stdout
child.stdout?.on('data', (data: Buffer) => {
const content = data.toString();
emitter.emit('worktree:init-output', {
projectPath,
branch,
type: 'stdout',
content,
});
});
// Stream stderr
child.stderr?.on('data', (data: Buffer) => {
const content = data.toString();
emitter.emit('worktree:init-output', {
projectPath,
branch,
type: 'stderr',
content,
});
});
// Handle completion
child.on('exit', async (code) => {
const success = code === 0;
const status = success ? 'success' : 'failed';
logger.info(`Init script for branch "${branch}" ${status} with exit code ${code}`);
// Update metadata
const metadata = await readWorktreeMetadata(projectPath, branch);
await writeWorktreeMetadata(projectPath, branch, {
branch,
createdAt: metadata?.createdAt || new Date().toISOString(),
pr: metadata?.pr,
initScriptRan: true,
initScriptStatus: status,
initScriptError: success ? undefined : `Exit code: ${code}`,
});
// Emit completion event
emitter.emit('worktree:init-completed', {
projectPath,
worktreePath,
branch,
success,
exitCode: code,
});
});
child.on('error', async (error) => {
logger.error(`Init script error for branch "${branch}":`, error);
// Update metadata
const metadata = await readWorktreeMetadata(projectPath, branch);
await writeWorktreeMetadata(projectPath, branch, {
branch,
createdAt: metadata?.createdAt || new Date().toISOString(),
pr: metadata?.pr,
initScriptRan: true,
initScriptStatus: 'failed',
initScriptError: error.message,
});
// Emit completion with error
emitter.emit('worktree:init-completed', {
projectPath,
worktreePath,
branch,
success: false,
error: error.message,
});
});
}

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

View File

@@ -78,6 +78,8 @@ import {
} from './board-view/hooks';
import { SelectionActionBar } from './board-view/components';
import { MassEditDialog } from './board-view/dialogs';
import { InitScriptIndicator } from './board-view/init-script-indicator';
import { useInitScriptEvents } from '@/hooks/use-init-script-events';
// Stable empty array to avoid infinite loop in selector
const EMPTY_WORKTREES: ReturnType<ReturnType<typeof useAppStore.getState>['getWorktrees']> = [];
@@ -255,6 +257,9 @@ export function BoardView() {
// Window state hook for compact dialog mode
const { isMaximized } = useWindowState();
// Init script events hook - subscribe to worktree init script events
useInitScriptEvents(currentProject?.path ?? null);
// Keyboard shortcuts hook will be initialized after actions hook
// Prevent hydration issues
@@ -1570,6 +1575,9 @@ export function BoardView() {
setSelectedWorktreeForAction(null);
}}
/>
{/* Init Script Indicator - floating overlay for worktree init script status */}
<InitScriptIndicator projectPath={currentProject.path} />
</div>
);
}

View File

@@ -0,0 +1,140 @@
import { useState, useRef, useEffect } from 'react';
import { Terminal, Check, X, Loader2, ChevronDown, ChevronUp } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useAppStore, type InitScriptState } from '@/store/app-store';
import { AnsiOutput } from '@/components/ui/ansi-output';
interface InitScriptIndicatorProps {
projectPath: string;
}
export function InitScriptIndicator({ projectPath }: InitScriptIndicatorProps) {
const initScriptState = useAppStore((s) => s.initScriptState[projectPath]);
const clearInitScriptState = useAppStore((s) => s.clearInitScriptState);
const [showLogs, setShowLogs] = useState(false);
const [dismissed, setDismissed] = useState(false);
const logsEndRef = useRef<HTMLDivElement>(null);
// Auto-scroll to bottom when new output arrives
useEffect(() => {
if (showLogs && logsEndRef.current) {
logsEndRef.current.scrollIntoView({ behavior: 'smooth' });
}
}, [initScriptState?.output, showLogs]);
// Reset dismissed state when a new script starts
useEffect(() => {
if (initScriptState?.status === 'running') {
setDismissed(false);
setShowLogs(true);
}
}, [initScriptState?.status]);
if (!initScriptState || dismissed) return null;
if (initScriptState.status === 'idle') return null;
const { status, output, branch, error } = initScriptState;
const handleDismiss = () => {
setDismissed(true);
// Clear state after a delay to allow for future scripts
setTimeout(() => {
clearInitScriptState(projectPath);
}, 100);
};
return (
<div
className={cn(
'fixed bottom-4 right-4 z-50',
'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={handleDismiss}
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>
);
}

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,22 @@ export function SettingsView() {
defaultSkipTests={defaultSkipTests}
enableDependencyBlocking={enableDependencyBlocking}
skipVerificationInAutoMode={skipVerificationInAutoMode}
useWorktrees={useWorktrees}
defaultPlanningMode={defaultPlanningMode}
defaultRequirePlanApproval={defaultRequirePlanApproval}
onDefaultSkipTestsChange={setDefaultSkipTests}
onEnableDependencyBlockingChange={setEnableDependencyBlocking}
onSkipVerificationInAutoModeChange={setSkipVerificationInAutoMode}
onUseWorktreesChange={setUseWorktrees}
onDefaultPlanningModeChange={setDefaultPlanningMode}
onDefaultRequirePlanApprovalChange={setDefaultRequirePlanApproval}
/>
);
case 'worktrees':
return (
<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) {
@@ -257,32 +252,6 @@ export function FeatureDefaultsSection({
</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,238 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { ShellSyntaxEditor } from '@/components/ui/shell-syntax-editor';
import { GitBranch, Terminal, FileCode, Check, Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { apiPost, apiPut } from '@/lib/api-fetch';
import { toast } from 'sonner';
import { useAppStore } from '@/store/app-store';
interface WorktreesSectionProps {
useWorktrees: boolean;
onUseWorktreesChange: (value: boolean) => void;
}
interface InitScriptResponse {
success: boolean;
exists: boolean;
content: string;
path: string;
error?: string;
}
export function WorktreesSection({ useWorktrees, onUseWorktreesChange }: WorktreesSectionProps) {
const currentProject = useAppStore((s) => s.currentProject);
const [scriptContent, setScriptContent] = useState('');
const [scriptPath, setScriptPath] = useState('');
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [showSaved, setShowSaved] = useState(false);
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const savedTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Load init script content when project changes
useEffect(() => {
if (!currentProject?.path) {
setScriptContent('');
setScriptPath('');
setIsLoading(false);
return;
}
const loadInitScript = async () => {
setIsLoading(true);
try {
const response = await apiPost<InitScriptResponse>('/api/worktree/init-script', {
projectPath: currentProject.path,
});
if (response.success) {
setScriptContent(response.content || '');
setScriptPath(response.path || '');
}
} catch (error) {
console.error('Failed to load init script:', error);
} finally {
setIsLoading(false);
}
};
loadInitScript();
}, [currentProject?.path]);
// Debounced save function
const saveScript = useCallback(
async (content: string) => {
if (!currentProject?.path) return;
setIsSaving(true);
try {
const response = await apiPut<{ success: boolean; error?: string }>(
'/api/worktree/init-script',
{
projectPath: currentProject.path,
content,
}
);
if (response.success) {
setShowSaved(true);
savedTimeoutRef.current = setTimeout(() => setShowSaved(false), 2000);
} else {
toast.error('Failed to save init script', {
description: response.error,
});
}
} catch (error) {
console.error('Failed to save init script:', error);
toast.error('Failed to save init script');
} finally {
setIsSaving(false);
}
},
[currentProject?.path]
);
// Handle content change with debounce
const handleContentChange = useCallback(
(value: string) => {
setScriptContent(value);
setShowSaved(false);
// Clear existing timeouts
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
if (savedTimeoutRef.current) {
clearTimeout(savedTimeoutRef.current);
}
// Debounce save
saveTimeoutRef.current = setTimeout(() => {
saveScript(value);
}, 1000);
},
[saveScript]
);
// Cleanup timeouts
useEffect(() => {
return () => {
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
if (savedTimeoutRef.current) clearTimeout(savedTimeoutRef.current);
};
}, []);
return (
<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>
{/* 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 className="flex items-center gap-2 text-xs text-muted-foreground">
{isSaving && (
<span className="flex items-center gap-1">
<Loader2 className="w-3 h-3 animate-spin" />
Saving...
</span>
)}
{showSaved && !isSaving && (
<span className="flex items-center gap-1 text-green-500">
<Check className="w-3 h-3" />
Saved
</span>
)}
</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>
</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"
data-testid="init-script-editor"
/>
)}
</>
) : (
<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, {
status: 'running',
branch: startPayload.branch,
output: [],
error: undefined,
});
break;
}
case 'worktree:init-output': {
const outputPayload = payload as InitScriptOutputPayload;
appendInitScriptOutput(projectPath, outputPayload.content);
break;
}
case 'worktree:init-completed': {
const completePayload = payload as InitScriptCompletedPayload;
setInitScriptState(projectPath, {
status: completePayload.success ? 'success' : 'failed',
error: completePayload.error,
});
break;
}
}
});
return unsubscribe;
}, [projectPath, setInitScriptState, appendInitScriptOutput]);
}

View File

@@ -1608,6 +1608,35 @@ export class HttpApiClient implements ElectronAPI {
listDevServers: () => this.post('/api/worktree/list-dev-servers', {}),
getPRInfo: (worktreePath: string, branchName: string) =>
this.post('/api/worktree/pr-info', { worktreePath, branchName }),
// Init script methods
getInitScript: (projectPath: string) =>
this.post('/api/worktree/init-script', { projectPath }),
setInitScript: (projectPath: string, content: string) =>
this.put('/api/worktree/init-script', { projectPath, content }),
deleteInitScript: (projectPath: string) =>
this.delete('/api/worktree/init-script', { projectPath }),
onInitScriptEvent: (
callback: (event: {
type: 'worktree:init-started' | 'worktree:init-output' | 'worktree:init-completed';
payload: unknown;
}) => void
) => {
// Note: subscribeToEvent callback receives (payload) not (_, payload)
const unsub1 = this.subscribeToEvent('worktree:init-started', (payload) =>
callback({ type: 'worktree:init-started', payload })
);
const unsub2 = this.subscribeToEvent('worktree:init-output', (payload) =>
callback({ type: 'worktree:init-output', payload })
);
const unsub3 = this.subscribeToEvent('worktree:init-completed', (payload) =>
callback({ type: 'worktree:init-completed', payload })
);
return () => {
unsub1();
unsub2();
unsub3();
};
},
};
// Git API

View File

@@ -459,6 +459,14 @@ export interface PersistedTerminalSettings {
maxSessions: number;
}
/** State for worktree init script execution */
export interface InitScriptState {
status: 'idle' | 'running' | 'success' | 'failed';
branch: string;
output: string[];
error?: string;
}
export interface AppState {
// Project state
projects: Project[];
@@ -664,6 +672,9 @@ export interface AppState {
lastProjectDir: string;
/** Recently accessed folders for quick access */
recentFolders: string[];
// Init Script State (per-project, keyed by project path)
initScriptState: Record<string, InitScriptState>;
}
// Claude Usage interface matching the server response
@@ -1095,6 +1106,12 @@ export interface AppActions {
}>
) => void;
// Init Script State actions
setInitScriptState: (projectPath: string, state: Partial<InitScriptState>) => void;
appendInitScriptOutput: (projectPath: string, content: string) => void;
clearInitScriptState: (projectPath: string) => void;
getInitScriptState: (projectPath: string) => InitScriptState | null;
// Reset
reset: () => void;
}
@@ -1195,6 +1212,7 @@ const initialState: AppState = {
worktreePanelCollapsed: false,
lastProjectDir: '',
recentFolders: [],
initScriptState: {},
};
export const useAppStore = create<AppState & AppActions>()((set, get) => ({
@@ -3119,6 +3137,44 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
set({ recentFolders: updated });
},
// Init Script State actions
setInitScriptState: (projectPath, state) => {
const current = get().initScriptState[projectPath] || {
status: 'idle',
branch: '',
output: [],
};
set({
initScriptState: {
...get().initScriptState,
[projectPath]: { ...current, ...state },
},
});
},
appendInitScriptOutput: (projectPath, content) => {
const current = get().initScriptState[projectPath];
if (!current) return;
set({
initScriptState: {
...get().initScriptState,
[projectPath]: {
...current,
output: [...current.output, content],
},
},
});
},
clearInitScriptState: (projectPath) => {
const { [projectPath]: _, ...rest } = get().initScriptState;
set({ initScriptState: rest });
},
getInitScriptState: (projectPath) => {
return get().initScriptState[projectPath] || null;
},
// Reset
reset: () => set(initialState),
}));

View File

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

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;

27
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",
@@ -1468,7 +1479,7 @@
},
"node_modules/@electron/node-gyp": {
"version": "10.2.0-electron.1",
"resolved": "git+https://github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2",
"resolved": "git+ssh://git@github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2",
"integrity": "sha512-4MSBTT8y07YUDqf69/vSh80Hh791epYqGtWHO3zSKhYFwQg+gx9wi1PqbqP6YqC4WMsNxZ5l9oDmnWdK5pfCKQ==",
"dev": true,
"license": "MIT",
@@ -3604,9 +3615,9 @@
}
},
"node_modules/@lezer/common": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.4.0.tgz",
"integrity": "sha512-DVeMRoGrgn/k45oQNu189BoW4SZwgZFzJ1+1TV5j2NJ/KFC83oa/enRqZSGshyeMk5cPWMhsKs9nx+8o0unwGg==",
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.0.tgz",
"integrity": "sha512-PNGcolp9hr4PJdXR4ix7XtixDrClScvtSCYW3rQG106oVMOOI+jFb+0+J3mbeL/53g1Zd6s0kJzaw6Ri68GmAA==",
"license": "MIT"
},
"node_modules/@lezer/highlight": {