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,
});
});
}