fix: Address code review comments

This commit is contained in:
gsxdsm
2026-02-17 23:04:42 -08:00
parent 43c19c70ca
commit dd4c738e91
43 changed files with 1128 additions and 359 deletions

View File

@@ -89,8 +89,13 @@ export function createBrowseProjectFilesHandler() {
currentRelativePath = normalized; currentRelativePath = normalized;
// Double-check the resolved path is within the project // Double-check the resolved path is within the project
// Use a separator-terminated prefix to prevent matching sibling dirs
// that share the same prefix (e.g. /projects/foo vs /projects/foobar).
const resolvedTarget = path.resolve(targetPath); const resolvedTarget = path.resolve(targetPath);
if (!resolvedTarget.startsWith(resolvedProjectPath)) { const projectPrefix = resolvedProjectPath.endsWith(path.sep)
? resolvedProjectPath
: resolvedProjectPath + path.sep;
if (!resolvedTarget.startsWith(projectPrefix) && resolvedTarget !== resolvedProjectPath) {
res.status(400).json({ res.status(400).json({
success: false, success: false,
error: 'Path traversal detected', error: 'Path traversal detected',
@@ -130,7 +135,7 @@ export function createBrowseProjectFilesHandler() {
}) })
.map((entry) => { .map((entry) => {
const entryRelativePath = currentRelativePath const entryRelativePath = currentRelativePath
? `${currentRelativePath}/${entry.name}` ? path.posix.join(currentRelativePath.replace(/\\/g, '/'), entry.name)
: entry.name; : entry.name;
return { return {

View File

@@ -111,6 +111,17 @@ export function isValidBranchName(name: string): boolean {
return /^[a-zA-Z0-9._\-/]+$/.test(name) && name.length < MAX_BRANCH_NAME_LENGTH; return /^[a-zA-Z0-9._\-/]+$/.test(name) && name.length < MAX_BRANCH_NAME_LENGTH;
} }
/**
* Validate git remote name to prevent command injection.
* Allowed characters: alphanumerics, hyphen, underscore, dot, and slash.
* Rejects empty strings and names that are too long.
*/
export function isValidRemoteName(name: string): boolean {
return (
name.length > 0 && name.length < MAX_BRANCH_NAME_LENGTH && /^[a-zA-Z0-9._\-/]+$/.test(name)
);
}
/** /**
* Check if gh CLI is available on the system * Check if gh CLI is available on the system
*/ */

View File

@@ -243,7 +243,7 @@ export function createWorktreeRoutes(
'/cherry-pick', '/cherry-pick',
validatePathParams('worktreePath'), validatePathParams('worktreePath'),
requireValidWorktree, requireValidWorktree,
createCherryPickHandler() createCherryPickHandler(events)
); );
// Generate PR description route // Generate PR description route
@@ -259,7 +259,7 @@ export function createWorktreeRoutes(
'/branch-commit-log', '/branch-commit-log',
validatePathParams('worktreePath'), validatePathParams('worktreePath'),
requireValidWorktree, requireValidWorktree,
createBranchCommitLogHandler() createBranchCommitLogHandler(events)
); );
return router; return router;

View File

@@ -5,14 +5,19 @@
* any branch, not just the currently checked out one. Useful for cherry-pick workflows * any branch, not just the currently checked out one. Useful for cherry-pick workflows
* where you need to browse commits from other branches. * where you need to browse commits from other branches.
* *
* The handler only validates input, invokes the service, streams lifecycle events
* via the EventEmitter, and sends the final JSON response.
*
* Note: Git repository validation (isGitRepo, hasCommits) is handled by * Note: Git repository validation (isGitRepo, hasCommits) is handled by
* the requireValidWorktree middleware in index.ts * the requireValidWorktree middleware in index.ts
*/ */
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import { execGitCommand, getErrorMessage, logError } from '../common.js'; import type { EventEmitter } from '../../../lib/events.js';
import { getErrorMessage, logError } from '../common.js';
import { getBranchCommitLog } from '../../../services/branch-commit-log-service.js';
export function createBranchCommitLogHandler() { export function createBranchCommitLogHandler(events: EventEmitter) {
return async (req: Request, res: Response): Promise<void> => { return async (req: Request, res: Response): Promise<void> => {
try { try {
const { const {
@@ -33,89 +38,40 @@ export function createBranchCommitLogHandler() {
return; return;
} }
// Clamp limit to a reasonable range // Emit start event so the frontend can observe progress
const commitLimit = Math.min(Math.max(1, Number(limit) || 20), 100); events.emit('branchCommitLog:start', {
worktreePath,
branchName: branchName || 'HEAD',
limit,
});
// Use the specified branch or default to HEAD // Delegate all Git work to the service
const targetRef = branchName || 'HEAD'; const result = await getBranchCommitLog(worktreePath, branchName, limit);
// Get detailed commit log for the specified branch // Emit progress with the number of commits fetched
const logOutput = await execGitCommand( events.emit('branchCommitLog:progress', {
[ worktreePath,
'log', branchName: result.branch,
targetRef, commitsLoaded: result.total,
`--max-count=${commitLimit}`, });
'--format=%H%n%h%n%an%n%ae%n%aI%n%s%n%b%n---END---',
],
worktreePath
);
// Parse the output into structured commit objects // Emit done event
const commits: Array<{ events.emit('branchCommitLog:done', {
hash: string; worktreePath,
shortHash: string; branchName: result.branch,
author: string; total: result.total,
authorEmail: string; });
date: string;
subject: string;
body: string;
files: string[];
}> = [];
const commitBlocks = logOutput.split('---END---\n').filter((block) => block.trim());
for (const block of commitBlocks) {
const lines = block.split('\n');
if (lines.length >= 6) {
const hash = lines[0].trim();
// Get list of files changed in this commit
let files: string[] = [];
try {
const filesOutput = await execGitCommand(
['diff-tree', '--no-commit-id', '--name-only', '-r', hash],
worktreePath
);
files = filesOutput
.trim()
.split('\n')
.filter((f) => f.trim());
} catch {
// Ignore errors getting file list
}
commits.push({
hash,
shortHash: lines[1].trim(),
author: lines[2].trim(),
authorEmail: lines[3].trim(),
date: lines[4].trim(),
subject: lines[5].trim(),
body: lines.slice(6).join('\n').trim(),
files,
});
}
}
// If branchName wasn't specified, get current branch for display
let displayBranch = branchName;
if (!displayBranch) {
const branchOutput = await execGitCommand(
['rev-parse', '--abbrev-ref', 'HEAD'],
worktreePath
);
displayBranch = branchOutput.trim();
}
res.json({ res.json({
success: true, success: true,
result: { result,
branch: displayBranch,
commits,
total: commits.length,
},
}); });
} catch (error) { } catch (error) {
// Emit error event so the frontend can react
events.emit('branchCommitLog:error', {
error: getErrorMessage(error),
});
logError(error, 'Get branch commit log failed'); logError(error, 'Get branch commit log failed');
res.status(500).json({ success: false, error: getErrorMessage(error) }); res.status(500).json({ success: false, error: getErrorMessage(error) });
} }

View File

@@ -98,6 +98,19 @@ export function createCheckoutBranchHandler() {
// Branch doesn't exist, good to create // Branch doesn't exist, good to create
} }
// If baseBranch is provided, verify it exists before using it
if (baseBranch) {
try {
await execGitCommand(['rev-parse', '--verify', baseBranch], resolvedPath);
} catch {
res.status(400).json({
success: false,
error: `Base branch '${baseBranch}' does not exist`,
});
return;
}
}
// Create and checkout the new branch (using argument array to avoid shell injection) // Create and checkout the new branch (using argument array to avoid shell injection)
// If baseBranch is provided, create the branch from that starting point // If baseBranch is provided, create the branch from that starting point
const checkoutArgs = ['checkout', '-b', branchName]; const checkoutArgs = ['checkout', '-b', branchName];

View File

@@ -4,17 +4,20 @@
* Applies commits from another branch onto the current branch. * Applies commits from another branch onto the current branch.
* Supports single or multiple commit cherry-picks. * Supports single or multiple commit cherry-picks.
* *
* Git business logic is delegated to cherry-pick-service.ts.
* Events are emitted at key lifecycle points for WebSocket subscribers.
*
* Note: Git repository validation (isGitRepo, hasCommits) is handled by * Note: Git repository validation (isGitRepo, hasCommits) is handled by
* the requireValidWorktree middleware in index.ts * the requireValidWorktree middleware in index.ts
*/ */
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import { execGitCommand, getErrorMessage, logError } from '../common.js'; import path from 'path';
import { createLogger } from '@automaker/utils'; import { getErrorMessage, logError } from '../common.js';
import type { EventEmitter } from '../../../lib/events.js';
import { verifyCommits, runCherryPick } from '../../../services/cherry-pick-service.js';
const logger = createLogger('Worktree'); export function createCherryPickHandler(events: EventEmitter) {
export function createCherryPickHandler() {
return async (req: Request, res: Response): Promise<void> => { return async (req: Request, res: Response): Promise<void> => {
try { try {
const { worktreePath, commitHashes, options } = req.body as { const { worktreePath, commitHashes, options } = req.body as {
@@ -33,6 +36,9 @@ export function createCherryPickHandler() {
return; return;
} }
// Normalize the path to prevent path traversal and ensure consistent paths
const resolvedWorktreePath = path.resolve(worktreePath);
if (!commitHashes || !Array.isArray(commitHashes) || commitHashes.length === 0) { if (!commitHashes || !Array.isArray(commitHashes) || commitHashes.length === 0) {
res.status(400).json({ res.status(400).json({
success: false, success: false,
@@ -52,75 +58,64 @@ export function createCherryPickHandler() {
} }
} }
// Verify each commit exists // Verify each commit exists via the service
for (const hash of commitHashes) { const invalidHash = await verifyCommits(resolvedWorktreePath, commitHashes);
try { if (invalidHash !== null) {
await execGitCommand(['rev-parse', '--verify', hash], worktreePath); res.status(400).json({
} catch { success: false,
res.status(400).json({ error: `Commit "${invalidHash}" does not exist`,
success: false, });
error: `Commit "${hash}" does not exist`, return;
});
return;
}
} }
// Build cherry-pick command args // Emit started event
const args = ['cherry-pick']; events.emit('cherry-pick:started', {
if (options?.noCommit) { worktreePath: resolvedWorktreePath,
args.push('--no-commit'); commitHashes,
} options,
// Add commit hashes in order });
args.push(...commitHashes);
// Execute the cherry-pick // Execute the cherry-pick via the service
try { const result = await runCherryPick(resolvedWorktreePath, commitHashes, options);
await execGitCommand(args, worktreePath);
// Get current branch name if (result.success) {
const branchOutput = await execGitCommand( // Emit success event
['rev-parse', '--abbrev-ref', 'HEAD'], events.emit('cherry-pick:success', {
worktreePath worktreePath: resolvedWorktreePath,
); commitHashes,
branch: result.branch,
});
res.json({ res.json({
success: true, success: true,
result: { result: {
cherryPicked: true, cherryPicked: result.cherryPicked,
commitHashes, commitHashes: result.commitHashes,
branch: branchOutput.trim(), branch: result.branch,
message: `Successfully cherry-picked ${commitHashes.length} commit(s)`, message: result.message,
}, },
}); });
} catch (cherryPickError: unknown) { } else if (result.hasConflicts) {
// Check if this is a cherry-pick conflict // Emit conflict event
const err = cherryPickError as { stdout?: string; stderr?: string; message?: string }; events.emit('cherry-pick:conflict', {
const output = `${err.stdout || ''} ${err.stderr || ''} ${err.message || ''}`; worktreePath: resolvedWorktreePath,
const hasConflicts = commitHashes,
output.includes('CONFLICT') || aborted: result.aborted,
output.includes('cherry-pick failed') || });
output.includes('could not apply');
if (hasConflicts) { res.status(409).json({
// Abort the cherry-pick to leave the repo in a clean state success: false,
try { error: result.error,
await execGitCommand(['cherry-pick', '--abort'], worktreePath); hasConflicts: true,
} catch { aborted: result.aborted,
logger.warn('Failed to abort cherry-pick after conflict'); });
}
res.status(409).json({
success: false,
error: `Cherry-pick CONFLICT: Could not apply commit(s) cleanly. Conflicts need to be resolved manually.`,
hasConflicts: true,
});
return;
}
// Re-throw non-conflict errors
throw cherryPickError;
} }
} catch (error) { } catch (error) {
// Emit failure event
events.emit('cherry-pick:failure', {
error: getErrorMessage(error),
});
logError(error, 'Cherry-pick failed'); logError(error, 'Cherry-pick failed');
res.status(500).json({ success: false, error: getErrorMessage(error) }); res.status(500).json({ success: false, error: getErrorMessage(error) });
} }

View File

@@ -45,7 +45,7 @@ export function createCommitLogHandler() {
files: string[]; files: string[];
}> = []; }> = [];
const commitBlocks = logOutput.split('---END---\n').filter((block) => block.trim()); const commitBlocks = logOutput.split('---END---').filter((block) => block.trim());
for (const block of commitBlocks) { for (const block of commitBlocks) {
const lines = block.split('\n'); const lines = block.split('\n');

View File

@@ -8,9 +8,12 @@ import {
logError, logError,
execAsync, execAsync,
execEnv, execEnv,
execGitCommand,
isValidBranchName, isValidBranchName,
isValidRemoteName,
isGhCliAvailable, isGhCliAvailable,
} from '../common.js'; } from '../common.js';
import { spawnProcess } from '@automaker/platform';
import { updateWorktreePRInfo } from '../../../lib/worktree-metadata.js'; import { updateWorktreePRInfo } from '../../../lib/worktree-metadata.js';
import { createLogger } from '@automaker/utils'; import { createLogger } from '@automaker/utils';
import { validatePRState } from '@automaker/types'; import { validatePRState } from '@automaker/types';
@@ -91,12 +94,9 @@ export function createCreatePRHandler() {
logger.debug(`Running: git add -A`); logger.debug(`Running: git add -A`);
await execAsync('git add -A', { cwd: worktreePath, env: execEnv }); await execAsync('git add -A', { cwd: worktreePath, env: execEnv });
// Create commit // Create commit — pass message as a separate arg to avoid shell injection
logger.debug(`Running: git commit`); logger.debug(`Running: git commit`);
await execAsync(`git commit -m "${message.replace(/"/g, '\\"')}"`, { await execGitCommand(['commit', '-m', message], worktreePath);
cwd: worktreePath,
env: execEnv,
});
// Get commit hash // Get commit hash
const { stdout: hashOutput } = await execAsync('git rev-parse HEAD', { const { stdout: hashOutput } = await execAsync('git rev-parse HEAD', {
@@ -119,11 +119,20 @@ export function createCreatePRHandler() {
} }
} }
// Validate remote name before use to prevent command injection
if (remote !== undefined && !isValidRemoteName(remote)) {
res.status(400).json({
success: false,
error: 'Invalid remote name contains unsafe characters',
});
return;
}
// Push the branch to remote (use selected remote or default to 'origin') // Push the branch to remote (use selected remote or default to 'origin')
const pushRemote = remote || 'origin'; const pushRemote = remote || 'origin';
let pushError: string | null = null; let pushError: string | null = null;
try { try {
await execAsync(`git push -u ${pushRemote} ${branchName}`, { await execAsync(`git push ${pushRemote} ${branchName}`, {
cwd: worktreePath, cwd: worktreePath,
env: execEnv, env: execEnv,
}); });
@@ -301,27 +310,35 @@ export function createCreatePRHandler() {
// Only create a new PR if one doesn't already exist // Only create a new PR if one doesn't already exist
if (!prUrl) { if (!prUrl) {
try { try {
// Build gh pr create command // Build gh pr create args as an array to avoid shell injection on
let prCmd = `gh pr create --base "${base}"`; // title/body (backticks, $, \ were unsafe with string interpolation)
const prArgs = ['pr', 'create', '--base', base];
// If this is a fork (has upstream remote), specify the repo and head // If this is a fork (has upstream remote), specify the repo and head
if (upstreamRepo && originOwner) { if (upstreamRepo && originOwner) {
// For forks: --repo specifies where to create PR, --head specifies source // For forks: --repo specifies where to create PR, --head specifies source
prCmd += ` --repo "${upstreamRepo}" --head "${originOwner}:${branchName}"`; prArgs.push('--repo', upstreamRepo, '--head', `${originOwner}:${branchName}`);
} else { } else {
// Not a fork, just specify the head branch // Not a fork, just specify the head branch
prCmd += ` --head "${branchName}"`; prArgs.push('--head', branchName);
} }
prCmd += ` --title "${title.replace(/"/g, '\\"')}" --body "${body.replace(/"/g, '\\"')}" ${draftFlag}`; prArgs.push('--title', title, '--body', body);
prCmd = prCmd.trim(); if (draft) prArgs.push('--draft');
logger.debug(`Creating PR with command: ${prCmd}`); logger.debug(`Creating PR with args: gh ${prArgs.join(' ')}`);
const { stdout: prOutput } = await execAsync(prCmd, { const prResult = await spawnProcess({
command: 'gh',
args: prArgs,
cwd: worktreePath, cwd: worktreePath,
env: execEnv, env: execEnv,
}); });
prUrl = prOutput.trim(); if (prResult.exitCode !== 0) {
throw Object.assign(new Error(prResult.stderr || 'gh pr create failed'), {
stderr: prResult.stderr,
});
}
prUrl = prResult.stdout.trim();
logger.info(`PR created: ${prUrl}`); logger.info(`PR created: ${prUrl}`);
// Extract PR number and store metadata for newly created PR // Extract PR number and store metadata for newly created PR

View File

@@ -11,10 +11,10 @@ import type { Request, Response } from 'express';
import { exec } from 'child_process'; import { exec } from 'child_process';
import { promisify } from 'util'; import { promisify } from 'util';
import path from 'path'; import path from 'path';
import fs from 'fs/promises';
import * as secureFs from '../../../lib/secure-fs.js'; import * as secureFs from '../../../lib/secure-fs.js';
import type { EventEmitter } from '../../../lib/events.js'; import type { EventEmitter } from '../../../lib/events.js';
import type { SettingsService } from '../../../services/settings-service.js'; import type { SettingsService } from '../../../services/settings-service.js';
import { WorktreeService } from '../../../services/worktree-service.js';
import { isGitRepo } from '@automaker/git-utils'; import { isGitRepo } from '@automaker/git-utils';
import { import {
getErrorMessage, getErrorMessage,
@@ -83,66 +83,9 @@ async function findExistingWorktreeForBranch(
} }
} }
/**
* Copy configured files from project root into the new worktree.
* Reads worktreeCopyFiles from project settings and copies each file/directory.
* Silently skips files that don't exist in the source.
*/
async function copyConfiguredFiles(
projectPath: string,
worktreePath: string,
settingsService?: SettingsService
): Promise<void> {
if (!settingsService) return;
try {
const projectSettings = await settingsService.getProjectSettings(projectPath);
const copyFiles = projectSettings.worktreeCopyFiles;
if (!copyFiles || copyFiles.length === 0) return;
for (const relativePath of copyFiles) {
// Security: prevent path traversal
const normalized = path.normalize(relativePath);
if (normalized.startsWith('..') || path.isAbsolute(normalized)) {
logger.warn(`Skipping suspicious copy path: ${relativePath}`);
continue;
}
const sourcePath = path.join(projectPath, normalized);
const destPath = path.join(worktreePath, normalized);
try {
// Check if source exists
const stat = await fs.stat(sourcePath);
// Ensure destination directory exists
const destDir = path.dirname(destPath);
await fs.mkdir(destDir, { recursive: true });
if (stat.isDirectory()) {
// Recursively copy directory
await fs.cp(sourcePath, destPath, { recursive: true, force: true });
logger.info(`Copied directory "${normalized}" to worktree`);
} else {
// Copy single file
await fs.copyFile(sourcePath, destPath);
logger.info(`Copied file "${normalized}" to worktree`);
}
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
logger.debug(`Skipping copy of "${normalized}" - file not found in project root`);
} else {
logger.warn(`Failed to copy "${normalized}" to worktree:`, err);
}
}
}
} catch (error) {
logger.warn('Failed to read project settings for file copying:', error);
}
}
export function createCreateHandler(events: EventEmitter, settingsService?: SettingsService) { export function createCreateHandler(events: EventEmitter, settingsService?: SettingsService) {
const worktreeService = new WorktreeService();
return async (req: Request, res: Response): Promise<void> => { return async (req: Request, res: Response): Promise<void> => {
try { try {
const { projectPath, branchName, baseBranch } = req.body as { const { projectPath, branchName, baseBranch } = req.body as {
@@ -263,7 +206,17 @@ export function createCreateHandler(events: EventEmitter, settingsService?: Sett
// Copy configured files into the new worktree before responding // Copy configured files into the new worktree before responding
// This runs synchronously to ensure files are in place before any init script // This runs synchronously to ensure files are in place before any init script
await copyConfiguredFiles(projectPath, absoluteWorktreePath, settingsService); try {
await worktreeService.copyConfiguredFiles(
projectPath,
absoluteWorktreePath,
settingsService,
events
);
} catch (copyErr) {
// Log but don't fail worktree creation files may be partially copied
logger.warn('Some configured files failed to copy to worktree:', copyErr);
}
// Respond immediately (non-blocking) // Respond immediately (non-blocking)
res.json({ res.json({

View File

@@ -7,7 +7,7 @@
*/ */
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import { exec } from 'child_process'; import { execFile } from 'child_process';
import { promisify } from 'util'; import { promisify } from 'util';
import { existsSync } from 'fs'; import { existsSync } from 'fs';
import { join } from 'path'; import { join } from 'path';
@@ -20,7 +20,7 @@ import { getErrorMessage, logError } from '../common.js';
import { getPhaseModelWithOverrides } from '../../../lib/settings-helpers.js'; import { getPhaseModelWithOverrides } from '../../../lib/settings-helpers.js';
const logger = createLogger('GeneratePRDescription'); const logger = createLogger('GeneratePRDescription');
const execAsync = promisify(exec); const execFileAsync = promisify(execFile);
/** Timeout for AI provider calls in milliseconds (30 seconds) */ /** Timeout for AI provider calls in milliseconds (30 seconds) */
const AI_TIMEOUT_MS = 30_000; const AI_TIMEOUT_MS = 30_000;
@@ -59,20 +59,33 @@ async function* withTimeout<T>(
generator: AsyncIterable<T>, generator: AsyncIterable<T>,
timeoutMs: number timeoutMs: number
): AsyncGenerator<T, void, unknown> { ): AsyncGenerator<T, void, unknown> {
let timerId: ReturnType<typeof setTimeout> | undefined;
const timeoutPromise = new Promise<never>((_, reject) => { const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error(`AI provider timed out after ${timeoutMs}ms`)), timeoutMs); timerId = setTimeout(
() => reject(new Error(`AI provider timed out after ${timeoutMs}ms`)),
timeoutMs
);
}); });
const iterator = generator[Symbol.asyncIterator](); const iterator = generator[Symbol.asyncIterator]();
let done = false; let done = false;
while (!done) { try {
const result = await Promise.race([iterator.next(), timeoutPromise]); while (!done) {
if (result.done) { const result = await Promise.race([iterator.next(), timeoutPromise]).catch(async (err) => {
done = true; // Timeout (or other error) — attempt to gracefully close the source generator
} else { await iterator.return?.();
yield result.value; throw err;
});
if (result.done) {
done = true;
} else {
yield result.value;
}
} }
} finally {
clearTimeout(timerId);
} }
} }
@@ -129,12 +142,24 @@ export function createGeneratePRDescriptionHandler(
return; return;
} }
// Validate baseBranch to allow only safe branch name characters
if (baseBranch !== undefined && !/^[\w.\-/]+$/.test(baseBranch)) {
const response: GeneratePRDescriptionErrorResponse = {
success: false,
error: 'baseBranch contains invalid characters',
};
res.status(400).json(response);
return;
}
logger.info(`Generating PR description for worktree: ${worktreePath}`); logger.info(`Generating PR description for worktree: ${worktreePath}`);
// Get current branch name // Get current branch name
const { stdout: branchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', { const { stdout: branchOutput } = await execFileAsync(
cwd: worktreePath, 'git',
}); ['rev-parse', '--abbrev-ref', 'HEAD'],
{ cwd: worktreePath }
);
const branchName = branchOutput.trim(); const branchName = branchOutput.trim();
// Determine the base branch for comparison // Determine the base branch for comparison
@@ -149,7 +174,7 @@ export function createGeneratePRDescriptionHandler(
let diffIncludesUncommitted = false; let diffIncludesUncommitted = false;
try { try {
// First, try to get diff against the base branch // First, try to get diff against the base branch
const { stdout: branchDiff } = await execAsync(`git diff ${base}...HEAD`, { const { stdout: branchDiff } = await execFileAsync('git', ['diff', `${base}...HEAD`], {
cwd: worktreePath, cwd: worktreePath,
maxBuffer: 1024 * 1024 * 5, // 5MB buffer maxBuffer: 1024 * 1024 * 5, // 5MB buffer
}); });
@@ -160,17 +185,21 @@ export function createGeneratePRDescriptionHandler(
// If branch comparison fails (e.g., base branch doesn't exist locally), // If branch comparison fails (e.g., base branch doesn't exist locally),
// try fetching and comparing against remote base // try fetching and comparing against remote base
try { try {
const { stdout: remoteDiff } = await execAsync(`git diff origin/${base}...HEAD`, { const { stdout: remoteDiff } = await execFileAsync(
cwd: worktreePath, 'git',
maxBuffer: 1024 * 1024 * 5, ['diff', `origin/${base}...HEAD`],
}); {
cwd: worktreePath,
maxBuffer: 1024 * 1024 * 5,
}
);
diff = remoteDiff; diff = remoteDiff;
// git diff origin/base...HEAD only shows committed changes // git diff origin/base...HEAD only shows committed changes
diffIncludesUncommitted = false; diffIncludesUncommitted = false;
} catch { } catch {
// Fall back to getting all uncommitted + committed changes // Fall back to getting all uncommitted + committed changes
try { try {
const { stdout: allDiff } = await execAsync('git diff HEAD', { const { stdout: allDiff } = await execFileAsync('git', ['diff', 'HEAD'], {
cwd: worktreePath, cwd: worktreePath,
maxBuffer: 1024 * 1024 * 5, maxBuffer: 1024 * 1024 * 5,
}); });
@@ -179,11 +208,11 @@ export function createGeneratePRDescriptionHandler(
diffIncludesUncommitted = true; diffIncludesUncommitted = true;
} catch { } catch {
// Last resort: get staged + unstaged changes // Last resort: get staged + unstaged changes
const { stdout: stagedDiff } = await execAsync('git diff --cached', { const { stdout: stagedDiff } = await execFileAsync('git', ['diff', '--cached'], {
cwd: worktreePath, cwd: worktreePath,
maxBuffer: 1024 * 1024 * 5, maxBuffer: 1024 * 1024 * 5,
}); });
const { stdout: unstagedDiff } = await execAsync('git diff', { const { stdout: unstagedDiff } = await execFileAsync('git', ['diff'], {
cwd: worktreePath, cwd: worktreePath,
maxBuffer: 1024 * 1024 * 5, maxBuffer: 1024 * 1024 * 5,
}); });
@@ -200,7 +229,7 @@ export function createGeneratePRDescriptionHandler(
// when the primary diff method (base...HEAD) was used, since it only shows committed changes. // when the primary diff method (base...HEAD) was used, since it only shows committed changes.
let hasUncommittedChanges = false; let hasUncommittedChanges = false;
try { try {
const { stdout: statusOutput } = await execAsync('git status --porcelain', { const { stdout: statusOutput } = await execFileAsync('git', ['status', '--porcelain'], {
cwd: worktreePath, cwd: worktreePath,
}); });
hasUncommittedChanges = statusOutput.trim().length > 0; hasUncommittedChanges = statusOutput.trim().length > 0;
@@ -212,7 +241,7 @@ export function createGeneratePRDescriptionHandler(
// Get staged changes // Get staged changes
try { try {
const { stdout: stagedDiff } = await execAsync('git diff --cached', { const { stdout: stagedDiff } = await execFileAsync('git', ['diff', '--cached'], {
cwd: worktreePath, cwd: worktreePath,
maxBuffer: 1024 * 1024 * 5, maxBuffer: 1024 * 1024 * 5,
}); });
@@ -225,7 +254,7 @@ export function createGeneratePRDescriptionHandler(
// Get unstaged changes (tracked files only) // Get unstaged changes (tracked files only)
try { try {
const { stdout: unstagedDiff } = await execAsync('git diff', { const { stdout: unstagedDiff } = await execFileAsync('git', ['diff'], {
cwd: worktreePath, cwd: worktreePath,
maxBuffer: 1024 * 1024 * 5, maxBuffer: 1024 * 1024 * 5,
}); });
@@ -259,8 +288,9 @@ export function createGeneratePRDescriptionHandler(
// Also get the commit log for context // Also get the commit log for context
let commitLog = ''; let commitLog = '';
try { try {
const { stdout: logOutput } = await execAsync( const { stdout: logOutput } = await execFileAsync(
`git log ${base}..HEAD --oneline --no-decorate 2>/dev/null || git log --oneline -10 --no-decorate`, 'git',
['log', `${base}..HEAD`, '--oneline', '--no-decorate'],
{ {
cwd: worktreePath, cwd: worktreePath,
maxBuffer: 1024 * 1024, maxBuffer: 1024 * 1024,
@@ -268,7 +298,20 @@ export function createGeneratePRDescriptionHandler(
); );
commitLog = logOutput.trim(); commitLog = logOutput.trim();
} catch { } catch {
// Ignore commit log errors // If comparing against base fails, fall back to recent commits
try {
const { stdout: logOutput } = await execFileAsync(
'git',
['log', '--oneline', '-10', '--no-decorate'],
{
cwd: worktreePath,
maxBuffer: 1024 * 1024,
}
);
commitLog = logOutput.trim();
} catch {
// Ignore commit log errors
}
} }
if (!diff.trim() && !commitLog.trim()) { if (!diff.trim() && !commitLog.trim()) {

View File

@@ -40,7 +40,17 @@ export function createStashApplyHandler() {
return; return;
} }
const stashRef = `stash@{${stashIndex}}`; const idx = typeof stashIndex === 'string' ? Number(stashIndex) : stashIndex;
if (!Number.isInteger(idx) || idx < 0) {
res.status(400).json({
success: false,
error: 'stashIndex must be a non-negative integer',
});
return;
}
const stashRef = `stash@{${idx}}`;
const operation = pop ? 'pop' : 'apply'; const operation = pop ? 'pop' : 'apply';
try { try {

View File

@@ -30,7 +30,7 @@ export function createStashDropHandler() {
return; return;
} }
if (stashIndex === undefined || stashIndex === null) { if (!Number.isInteger(stashIndex) || stashIndex < 0) {
res.status(400).json({ res.status(400).json({
success: false, success: false,
error: 'stashIndex required', error: 'stashIndex required',

View File

@@ -71,9 +71,10 @@ export function createStashListHandler() {
const message = parts[1].trim(); const message = parts[1].trim();
const date = parts[2].trim(); const date = parts[2].trim();
// Extract index from stash@{N} // Extract index from stash@{N}; skip entries that don't match the expected format
const indexMatch = refSpec.match(/stash@\{(\d+)\}/); const indexMatch = refSpec.match(/stash@\{(\d+)\}/);
const index = indexMatch ? parseInt(indexMatch[1], 10) : 0; if (!indexMatch) continue;
const index = parseInt(indexMatch[1], 10);
// Extract branch name from message (format: "WIP on branch: hash message" or "On branch: hash message") // Extract branch name from message (format: "WIP on branch: hash message" or "On branch: hash message")
let branch = ''; let branch = '';

View File

@@ -197,13 +197,7 @@ export function createSwitchBranchHandler() {
isRemote = true; isRemote = true;
const parsed = parseRemoteBranch(branchName); const parsed = parseRemoteBranch(branchName);
if (parsed) { if (parsed) {
// If a local branch with the same name already exists, just switch to it targetBranch = parsed.branch;
if (await localBranchExists(worktreePath, parsed.branch)) {
targetBranch = parsed.branch;
} else {
// Will create a local tracking branch from the remote
targetBranch = parsed.branch;
}
} }
} }
@@ -307,11 +301,37 @@ export function createSwitchBranchHandler() {
} catch (checkoutError) { } catch (checkoutError) {
// If checkout failed and we stashed, try to restore the stash // If checkout failed and we stashed, try to restore the stash
if (didStash) { if (didStash) {
try { const popResult = await popStash(worktreePath);
await popStash(worktreePath); if (popResult.hasConflicts) {
} catch { // Stash pop itself produced merge conflicts — the working tree is now in a
// Ignore errors restoring stash - it's still in the stash list // conflicted state even though the checkout failed. Surface this clearly so
// the caller can prompt the user (or AI) to resolve conflicts rather than
// simply retrying the branch switch.
const checkoutErrorMsg = getErrorMessage(checkoutError);
res.status(500).json({
success: false,
error: checkoutErrorMsg,
stashPopConflicts: true,
stashPopConflictMessage:
'Stash pop resulted in conflicts: your stashed changes were partially reapplied ' +
'but produced merge conflicts. Please resolve the conflicts before retrying the branch switch.',
});
return;
} else if (!popResult.success) {
// Stash pop failed for a non-conflict reason; the stash entry is still intact.
// Include this detail alongside the original checkout error.
const checkoutErrorMsg = getErrorMessage(checkoutError);
const combinedMessage =
`${checkoutErrorMsg}. Additionally, restoring your stashed changes failed: ` +
`${popResult.error ?? 'unknown error'} — your changes are still saved in the stash.`;
res.status(500).json({
success: false,
error: combinedMessage,
stashPopConflicts: false,
});
return;
} }
// popResult.success === true: stash was cleanly restored, re-throw the checkout error
} }
throw checkoutError; throw checkoutError;
} }

View File

@@ -185,14 +185,17 @@ export class AutoLoopCoordinator {
// Load all features for dependency checking (if callback provided) // Load all features for dependency checking (if callback provided)
const allFeatures = this.loadAllFeaturesFn const allFeatures = this.loadAllFeaturesFn
? await this.loadAllFeaturesFn(projectPath) ? await this.loadAllFeaturesFn(projectPath)
: pendingFeatures; : undefined;
// Filter to eligible features: not running, not finished, and dependencies satisfied // Filter to eligible features: not running, not finished, and dependencies satisfied.
// When loadAllFeaturesFn is not provided, allFeatures is undefined and we bypass
// dependency checks (returning true) to avoid false negatives caused by completed
// features being absent from pendingFeatures.
const eligibleFeatures = pendingFeatures.filter( const eligibleFeatures = pendingFeatures.filter(
(f) => (f) =>
!this.isFeatureRunningFn(f.id) && !this.isFeatureRunningFn(f.id) &&
!this.isFeatureFinishedFn(f) && !this.isFeatureFinishedFn(f) &&
areDependenciesSatisfied(f, allFeatures) (this.loadAllFeaturesFn ? areDependenciesSatisfied(f, allFeatures!) : true)
); );
// Sort eligible features by priority (lower number = higher priority, default 2) // Sort eligible features by priority (lower number = higher priority, default 2)
@@ -412,10 +415,12 @@ export class AutoLoopCoordinator {
const projectId = settings.projects?.find((p) => p.path === projectPath)?.id; const projectId = settings.projects?.find((p) => p.path === projectPath)?.id;
const autoModeByWorktree = settings.autoModeByWorktree; const autoModeByWorktree = settings.autoModeByWorktree;
if (projectId && autoModeByWorktree && typeof autoModeByWorktree === 'object') { if (projectId && autoModeByWorktree && typeof autoModeByWorktree === 'object') {
// branchName is already normalized to null for the primary branch by callers // Normalize both null and 'main' to '__main__' to match the same
// (e.g., checkWorktreeCapacity, startAutoLoopForProject), so we only // canonicalization used by getWorktreeAutoLoopKey, ensuring that
// need to convert null to '__main__' for the worktree key lookup // lookups for the primary branch always use the '__main__' sentinel
const normalizedBranch = branchName === null ? '__main__' : branchName; // regardless of whether the caller passed null or the string 'main'.
const normalizedBranch =
branchName === null || branchName === 'main' ? '__main__' : branchName;
const worktreeId = `${projectId}::${normalizedBranch}`; const worktreeId = `${projectId}::${normalizedBranch}`;
if ( if (
worktreeId in autoModeByWorktree && worktreeId in autoModeByWorktree &&

View File

@@ -0,0 +1,119 @@
/**
* Service for fetching branch commit log data.
*
* Extracts the heavy Git command execution and parsing logic from the
* branch-commit-log route handler so the handler only validates input,
* invokes this service, streams lifecycle events, and sends the response.
*/
import { execGitCommand } from '../routes/worktree/common.js';
// ============================================================================
// Types
// ============================================================================
export interface BranchCommit {
hash: string;
shortHash: string;
author: string;
authorEmail: string;
date: string;
subject: string;
body: string;
files: string[];
}
export interface BranchCommitLogResult {
branch: string;
commits: BranchCommit[];
total: number;
}
// ============================================================================
// Service
// ============================================================================
/**
* Fetch the commit log for a specific branch (or HEAD).
*
* Runs `git log`, `git diff-tree`, and `git rev-parse` inside
* the given worktree path and returns a structured result.
*
* @param worktreePath - Absolute path to the worktree / repository
* @param branchName - Branch to query (omit or pass undefined for HEAD)
* @param limit - Maximum number of commits to return (clamped 1-100)
*/
export async function getBranchCommitLog(
worktreePath: string,
branchName: string | undefined,
limit: number
): Promise<BranchCommitLogResult> {
// Clamp limit to a reasonable range
const parsedLimit = Number(limit);
const commitLimit = Math.min(Math.max(1, Number.isFinite(parsedLimit) ? parsedLimit : 20), 100);
// Use the specified branch or default to HEAD
const targetRef = branchName || 'HEAD';
// Get detailed commit log for the specified branch
const logOutput = await execGitCommand(
[
'log',
targetRef,
`--max-count=${commitLimit}`,
'--format=%H%n%h%n%an%n%ae%n%aI%n%s%n%b%n---END---',
],
worktreePath
);
// Parse the output into structured commit objects
const commits: BranchCommit[] = [];
const commitBlocks = logOutput.split('---END---\n').filter((block) => block.trim());
for (const block of commitBlocks) {
const lines = block.split('\n');
if (lines.length >= 6) {
const hash = lines[0].trim();
// Get list of files changed in this commit
let files: string[] = [];
try {
const filesOutput = await execGitCommand(
['diff-tree', '--no-commit-id', '--name-only', '-r', hash],
worktreePath
);
files = filesOutput
.trim()
.split('\n')
.filter((f) => f.trim());
} catch {
// Ignore errors getting file list
}
commits.push({
hash,
shortHash: lines[1].trim(),
author: lines[2].trim(),
authorEmail: lines[3].trim(),
date: lines[4].trim(),
subject: lines[5].trim(),
body: lines.slice(6).join('\n').trim(),
files,
});
}
}
// If branchName wasn't specified, get current branch for display
let displayBranch = branchName;
if (!displayBranch) {
const branchOutput = await execGitCommand(['rev-parse', '--abbrev-ref', 'HEAD'], worktreePath);
displayBranch = branchOutput.trim();
}
return {
branch: displayBranch,
commits,
total: commits.length,
};
}

View File

@@ -0,0 +1,162 @@
/**
* CherryPickService - Cherry-pick git operations without HTTP
*
* Extracted from worktree cherry-pick route to encapsulate all git
* cherry-pick business logic in a single service. Follows the same
* pattern as merge-service.ts.
*/
import { createLogger } from '@automaker/utils';
import { spawnProcess } from '@automaker/platform';
const logger = createLogger('CherryPickService');
// ============================================================================
// Types
// ============================================================================
export interface CherryPickOptions {
noCommit?: boolean;
}
export interface CherryPickResult {
success: boolean;
error?: string;
hasConflicts?: boolean;
aborted?: boolean;
cherryPicked?: boolean;
commitHashes?: string[];
branch?: string;
message?: string;
}
// ============================================================================
// Internal git command execution
// ============================================================================
/**
* Execute git command with array arguments to prevent command injection.
*/
async function execGitCommand(args: string[], cwd: string): Promise<string> {
const result = await spawnProcess({
command: 'git',
args,
cwd,
});
if (result.exitCode === 0) {
return result.stdout;
} else {
const errorMessage = result.stderr || `Git command failed with code ${result.exitCode}`;
throw new Error(errorMessage);
}
}
// ============================================================================
// Service Functions
// ============================================================================
/**
* Verify that each commit hash exists in the repository.
*
* @param worktreePath - Path to the git worktree
* @param commitHashes - Array of commit hashes to verify
* @returns The first invalid commit hash, or null if all are valid
*/
export async function verifyCommits(
worktreePath: string,
commitHashes: string[]
): Promise<string | null> {
for (const hash of commitHashes) {
try {
await execGitCommand(['rev-parse', '--verify', hash], worktreePath);
} catch {
return hash;
}
}
return null;
}
/**
* Run the cherry-pick operation on the given worktree.
*
* @param worktreePath - Path to the git worktree
* @param commitHashes - Array of commit hashes to cherry-pick (in order)
* @param options - Cherry-pick options (e.g., noCommit)
* @returns CherryPickResult with success/failure information
*/
export async function runCherryPick(
worktreePath: string,
commitHashes: string[],
options?: CherryPickOptions
): Promise<CherryPickResult> {
const args = ['cherry-pick'];
if (options?.noCommit) {
args.push('--no-commit');
}
args.push(...commitHashes);
try {
await execGitCommand(args, worktreePath);
const branch = await getCurrentBranch(worktreePath);
return {
success: true,
cherryPicked: true,
commitHashes,
branch,
message: `Successfully cherry-picked ${commitHashes.length} commit(s)`,
};
} catch (cherryPickError: unknown) {
// Check if this is a cherry-pick conflict
const err = cherryPickError as { stdout?: string; stderr?: string; message?: string };
const output = `${err.stdout || ''} ${err.stderr || ''} ${err.message || ''}`;
const hasConflicts =
output.includes('CONFLICT') ||
output.includes('cherry-pick failed') ||
output.includes('could not apply');
if (hasConflicts) {
// Abort the cherry-pick to leave the repo in a clean state
await abortCherryPick(worktreePath);
return {
success: false,
error: 'Cherry-pick aborted due to conflicts; no changes were applied.',
hasConflicts: true,
aborted: true,
};
}
// Non-conflict error - propagate
throw cherryPickError;
}
}
/**
* Abort an in-progress cherry-pick operation.
*
* @param worktreePath - Path to the git worktree
* @returns true if abort succeeded, false if it failed (logged as warning)
*/
export async function abortCherryPick(worktreePath: string): Promise<boolean> {
try {
await execGitCommand(['cherry-pick', '--abort'], worktreePath);
return true;
} catch {
logger.warn('Failed to abort cherry-pick after conflict');
return false;
}
}
/**
* Get the current branch name for the worktree.
*
* @param worktreePath - Path to the git worktree
* @returns The current branch name
*/
export async function getCurrentBranch(worktreePath: string): Promise<string> {
const branchOutput = await execGitCommand(['rev-parse', '--abbrev-ref', 'HEAD'], worktreePath);
return branchOutput.trim();
}

View File

@@ -0,0 +1,119 @@
/**
* WorktreeService - File-system operations for git worktrees
*
* Extracted from the worktree create route to centralise file-copy logic,
* surface errors through an EventEmitter instead of swallowing them, and
* make the behaviour testable in isolation.
*/
import path from 'path';
import fs from 'fs/promises';
import type { EventEmitter } from '../lib/events.js';
import type { SettingsService } from './settings-service.js';
/**
* Error thrown when one or more file copy operations fail during
* `copyConfiguredFiles`. The caller can inspect `failures` for details.
*/
export class CopyFilesError extends Error {
constructor(public readonly failures: Array<{ path: string; error: string }>) {
super(`Failed to copy ${failures.length} file(s): ${failures.map((f) => f.path).join(', ')}`);
this.name = 'CopyFilesError';
}
}
/**
* WorktreeService encapsulates file-system operations that run against
* git worktrees (e.g. copying project-configured files into a new worktree).
*
* All operations emit typed events so the frontend can stream progress to the
* user. Errors are collected and surfaced to the caller rather than silently
* swallowed.
*/
export class WorktreeService {
/**
* Copy files / directories listed in the project's `worktreeCopyFiles`
* setting from `projectPath` into `worktreePath`.
*
* Security: paths containing `..` segments or absolute paths are rejected.
*
* Events emitted via `emitter`:
* - `worktree:copy-files:copied` a file or directory was successfully copied
* - `worktree:copy-files:skipped` a source file was not found (ENOENT)
* - `worktree:copy-files:failed` an unexpected error occurred copying a file
*
* @throws {CopyFilesError} if any copy operation fails for a reason other
* than ENOENT (missing source file).
*/
async copyConfiguredFiles(
projectPath: string,
worktreePath: string,
settingsService: SettingsService | undefined,
emitter: EventEmitter
): Promise<void> {
if (!settingsService) return;
const projectSettings = await settingsService.getProjectSettings(projectPath);
const copyFiles = projectSettings.worktreeCopyFiles;
if (!copyFiles || copyFiles.length === 0) return;
const failures: Array<{ path: string; error: string }> = [];
for (const relativePath of copyFiles) {
// Security: prevent path traversal
const normalized = path.normalize(relativePath);
if (normalized.startsWith('..') || path.isAbsolute(normalized)) {
const reason = 'Suspicious path rejected (traversal or absolute)';
emitter.emit('worktree:copy-files:skipped', {
path: relativePath,
reason,
});
continue;
}
const sourcePath = path.join(projectPath, normalized);
const destPath = path.join(worktreePath, normalized);
try {
// Check if source exists
const stat = await fs.stat(sourcePath);
// Ensure destination directory exists
const destDir = path.dirname(destPath);
await fs.mkdir(destDir, { recursive: true });
if (stat.isDirectory()) {
// Recursively copy directory
await fs.cp(sourcePath, destPath, { recursive: true, force: true });
} else {
// Copy single file
await fs.copyFile(sourcePath, destPath);
}
emitter.emit('worktree:copy-files:copied', {
path: normalized,
type: stat.isDirectory() ? 'directory' : 'file',
});
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
emitter.emit('worktree:copy-files:skipped', {
path: normalized,
reason: 'File not found in project root',
});
} else {
const errorMessage = err instanceof Error ? err.message : String(err);
emitter.emit('worktree:copy-files:failed', {
path: normalized,
error: errorMessage,
});
failures.push({ path: normalized, error: errorMessage });
}
}
}
if (failures.length > 0) {
throw new CopyFilesError(failures);
}
}
}

View File

@@ -886,5 +886,66 @@ describe('auto-loop-coordinator.ts', () => {
expect.anything() expect.anything()
); );
}); });
it('uses pending features as fallback when loadAllFeaturesFn is null and executes eligible feature with satisfied dependencies', async () => {
// Create a completed dependency feature (will be in pendingFeatures as the allFeatures fallback)
const completedDep: Feature = {
...testFeature,
id: 'dep-feature',
status: 'completed',
title: 'Completed Dependency',
};
// Create a pending feature that depends on the completed dep
const pendingFeatureWithDep: Feature = {
...testFeature,
id: 'feature-with-dep',
dependencies: ['dep-feature'],
status: 'ready',
title: 'Feature With Dependency',
};
// loadAllFeaturesFn is NOT provided (null) so allFeatures falls back to pendingFeatures
const coordWithoutLoadAll = new AutoLoopCoordinator(
mockEventBus,
mockConcurrencyManager,
mockSettingsService,
mockExecuteFeature,
mockLoadPendingFeatures,
mockSaveExecutionState,
mockClearExecutionState,
mockResetStuckFeatures,
mockIsFeatureFinished,
mockIsFeatureRunning
// loadAllFeaturesFn omitted (undefined/null)
);
// pendingFeatures includes both the completed dep and the pending feature;
// since loadAllFeaturesFn is absent, allFeatures = pendingFeatures,
// so areDependenciesSatisfied can find 'dep-feature' with status 'completed'
vi.mocked(mockLoadPendingFeatures).mockResolvedValue([completedDep, pendingFeatureWithDep]);
vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0);
// The completed dep is finished, so it is filtered from eligible candidates;
// the pending feature with the satisfied dependency should be scheduled
vi.mocked(mockIsFeatureFinished).mockImplementation((f: Feature) => f.id === 'dep-feature');
await coordWithoutLoadAll.startAutoLoopForProject('/test/project', null, 1);
await vi.advanceTimersByTimeAsync(3000);
await coordWithoutLoadAll.stopAutoLoopForProject('/test/project', null);
// The feature whose dependency is satisfied via the pending-features fallback must be executed
expect(mockExecuteFeature).toHaveBeenCalledWith(
'/test/project',
'feature-with-dep',
true,
true
);
// The completed dependency itself must NOT be executed (filtered by isFeatureFinishedFn)
expect(mockExecuteFeature).not.toHaveBeenCalledWith(
'/test/project',
'dep-feature',
true,
true
);
});
}); });
}); });

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback, useMemo } from 'react'; import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { import {
FolderOpen, FolderOpen,
Folder, Folder,
@@ -70,6 +70,9 @@ export function ProjectFileSelectorDialog({
const [selectedPaths, setSelectedPaths] = useState<Set<string>>(new Set()); const [selectedPaths, setSelectedPaths] = useState<Set<string>>(new Set());
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
// Ref to track the current request generation; incremented to cancel stale requests
const requestGenRef = useRef(0);
// Track the path segments for breadcrumb navigation // Track the path segments for breadcrumb navigation
const breadcrumbs = useMemo(() => { const breadcrumbs = useMemo(() => {
if (!currentRelativePath) return []; if (!currentRelativePath) return [];
@@ -82,6 +85,11 @@ export function ProjectFileSelectorDialog({
const browseDirectory = useCallback( const browseDirectory = useCallback(
async (relativePath?: string) => { async (relativePath?: string) => {
// Increment the generation counter so any previously in-flight request
// knows it has been superseded and should not update state.
const generation = ++requestGenRef.current;
const isCancelled = () => requestGenRef.current !== generation;
setLoading(true); setLoading(true);
setError(''); setError('');
setWarning(''); setWarning('');
@@ -93,6 +101,8 @@ export function ProjectFileSelectorDialog({
relativePath: relativePath || '', relativePath: relativePath || '',
}); });
if (isCancelled()) return;
if (result.success) { if (result.success) {
setCurrentRelativePath(result.currentRelativePath); setCurrentRelativePath(result.currentRelativePath);
setParentRelativePath(result.parentRelativePath); setParentRelativePath(result.parentRelativePath);
@@ -102,9 +112,12 @@ export function ProjectFileSelectorDialog({
setError(result.error || 'Failed to browse directory'); setError(result.error || 'Failed to browse directory');
} }
} catch (err) { } catch (err) {
if (isCancelled()) return;
setError(err instanceof Error ? err.message : 'Failed to load directory contents'); setError(err instanceof Error ? err.message : 'Failed to load directory contents');
} finally { } finally {
setLoading(false); if (!isCancelled()) {
setLoading(false);
}
} }
}, },
[projectPath] [projectPath]
@@ -117,6 +130,8 @@ export function ProjectFileSelectorDialog({
setSearchQuery(''); setSearchQuery('');
browseDirectory(); browseDirectory();
} else { } else {
// Invalidate any in-flight request so it won't clobber the cleared state
requestGenRef.current++;
setCurrentRelativePath(''); setCurrentRelativePath('');
setParentRelativePath(null); setParentRelativePath(null);
setEntries([]); setEntries([]);

View File

@@ -42,9 +42,9 @@ export function SandboxRiskDialog({ open, onConfirm, onDeny }: SandboxRiskDialog
onEscapeKeyDown={(e) => e.preventDefault()} onEscapeKeyDown={(e) => e.preventDefault()}
showCloseButton={false} showCloseButton={false}
> >
<DialogHeader className="flex-shrink-0"> <DialogHeader className="shrink-0">
<DialogTitle className="flex items-center gap-2 text-destructive"> <DialogTitle className="flex items-center gap-2 text-destructive">
<ShieldAlert className="w-6 h-6 flex-shrink-0" /> <ShieldAlert className="w-6 h-6 shrink-0" />
Sandbox Environment Not Detected Sandbox Environment Not Detected
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
@@ -99,7 +99,7 @@ export function SandboxRiskDialog({ open, onConfirm, onDeny }: SandboxRiskDialog
</DialogDescription> </DialogDescription>
</div> </div>
<DialogFooter className="flex-col gap-4 sm:flex-col pt-4 flex-shrink-0 border-t border-border mt-4"> <DialogFooter className="flex-col gap-4 sm:flex-col pt-4 shrink-0 border-t border-border mt-4">
<div className="flex items-center space-x-2 self-start"> <div className="flex items-center space-x-2 self-start">
<Checkbox <Checkbox
id="skip-sandbox-warning" id="skip-sandbox-warning"

View File

@@ -1,6 +1,6 @@
import { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
import { useNavigate } from '@tanstack/react-router'; import { useNavigate } from '@tanstack/react-router';
import { ChevronsUpDown, Folder, Plus, FolderOpen } from 'lucide-react'; import { ChevronsUpDown, Folder, Plus, FolderOpen, LogOut } from 'lucide-react';
import * as LucideIcons from 'lucide-react'; import * as LucideIcons from 'lucide-react';
import type { LucideIcon } from 'lucide-react'; import type { LucideIcon } from 'lucide-react';
import { cn, isMac } from '@/lib/utils'; import { cn, isMac } from '@/lib/utils';
@@ -24,6 +24,7 @@ interface SidebarHeaderProps {
onNewProject: () => void; onNewProject: () => void;
onOpenFolder: () => void; onOpenFolder: () => void;
onProjectContextMenu: (project: Project, event: React.MouseEvent) => void; onProjectContextMenu: (project: Project, event: React.MouseEvent) => void;
setShowRemoveFromAutomakerDialog: (show: boolean) => void;
} }
export function SidebarHeader({ export function SidebarHeader({
@@ -32,6 +33,7 @@ export function SidebarHeader({
onNewProject, onNewProject,
onOpenFolder, onOpenFolder,
onProjectContextMenu, onProjectContextMenu,
setShowRemoveFromAutomakerDialog,
}: SidebarHeaderProps) { }: SidebarHeaderProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const { projects, setCurrentProject } = useAppStore(); const { projects, setCurrentProject } = useAppStore();
@@ -228,6 +230,22 @@ export function SidebarHeader({
<FolderOpen className="w-4 h-4 mr-2" /> <FolderOpen className="w-4 h-4 mr-2" />
<span>Open Project</span> <span>Open Project</span>
</DropdownMenuItem> </DropdownMenuItem>
{currentProject && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
setDropdownOpen(false);
setShowRemoveFromAutomakerDialog(true);
}}
className="cursor-pointer text-muted-foreground focus:text-foreground"
data-testid="collapsed-remove-from-automaker-dropdown-item"
>
<LogOut className="w-4 h-4 mr-2" />
<span>Remove from Automaker</span>
</DropdownMenuItem>
</>
)}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</> </>
@@ -372,6 +390,18 @@ export function SidebarHeader({
<FolderOpen className="w-4 h-4 mr-2" /> <FolderOpen className="w-4 h-4 mr-2" />
<span>Open Project</span> <span>Open Project</span>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
setDropdownOpen(false);
setShowRemoveFromAutomakerDialog(true);
}}
className="cursor-pointer text-muted-foreground focus:text-foreground"
data-testid="remove-from-automaker-dropdown-item"
>
<LogOut className="w-4 h-4 mr-2" />
<span>Remove from Automaker</span>
</DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
) : ( ) : (

View File

@@ -394,6 +394,7 @@ export function Sidebar() {
onNewProject={handleNewProject} onNewProject={handleNewProject}
onOpenFolder={handleOpenFolder} onOpenFolder={handleOpenFolder}
onProjectContextMenu={handleContextMenu} onProjectContextMenu={handleContextMenu}
setShowRemoveFromAutomakerDialog={setShowRemoveFromAutomakerDialog}
/> />
)} )}

View File

@@ -68,6 +68,7 @@ import type {
WorktreeInfo, WorktreeInfo,
MergeConflictInfo, MergeConflictInfo,
BranchSwitchConflictInfo, BranchSwitchConflictInfo,
StashPopConflictInfo,
} from './board-view/worktree-panel/types'; } from './board-view/worktree-panel/types';
import { COLUMNS, getColumnsWithPipeline } from './board-view/constants'; import { COLUMNS, getColumnsWithPipeline } from './board-view/constants';
import { import {
@@ -1083,6 +1084,64 @@ export function BoardView() {
[handleAddFeature, handleStartImplementation, defaultSkipTests] [handleAddFeature, handleStartImplementation, defaultSkipTests]
); );
// Handler called when checkout fails AND the stash-pop restoration produces merge conflicts.
// Creates an AI-assisted board task to guide the user through resolving the conflicts.
const handleStashPopConflict = useCallback(
async (conflictInfo: StashPopConflictInfo) => {
const description =
`Resolve merge conflicts that occurred when attempting to switch to branch "${conflictInfo.branchName}". ` +
`The checkout failed and, while restoring the previously stashed local changes, git reported merge conflicts. ` +
`${conflictInfo.stashPopConflictMessage} ` +
`Please review all conflicted files, resolve the conflicts, ensure the code compiles and tests pass, ` +
`then re-attempt the branch switch.`;
// Create the feature
const featureData = {
title: `Resolve Stash-Pop Conflicts: branch switch to ${conflictInfo.branchName}`,
category: 'Maintenance',
description,
images: [],
imagePaths: [],
skipTests: defaultSkipTests,
model: 'opus' as const,
thinkingLevel: 'none' as const,
branchName: conflictInfo.branchName,
workMode: 'custom' as const,
priority: 1,
planningMode: 'skip' as const,
requirePlanApproval: false,
};
// Capture existing feature IDs before adding
const featuresBeforeIds = new Set(useAppStore.getState().features.map((f) => f.id));
try {
await handleAddFeature(featureData);
} catch (error) {
logger.error('Failed to create stash-pop conflict resolution feature:', error);
toast.error('Failed to create feature', {
description: error instanceof Error ? error.message : 'An error occurred',
});
return;
}
// Find the newly created feature by looking for an ID that wasn't in the original set
const latestFeatures = useAppStore.getState().features;
const newFeature = latestFeatures.find((f) => !featuresBeforeIds.has(f.id));
if (newFeature) {
await handleStartImplementation(newFeature);
} else {
logger.error(
'Could not find newly created stash-pop conflict feature to start it automatically.'
);
toast.error('Failed to auto-start feature', {
description: 'The feature was created but could not be started automatically.',
});
}
},
[handleAddFeature, handleStartImplementation, defaultSkipTests]
);
// Handler for "Make" button - creates a feature and immediately starts it // Handler for "Make" button - creates a feature and immediately starts it
const handleAddAndStartFeature = useCallback( const handleAddAndStartFeature = useCallback(
async (featureData: Parameters<typeof handleAddFeature>[0]) => { async (featureData: Parameters<typeof handleAddFeature>[0]) => {
@@ -1523,6 +1582,7 @@ export function BoardView() {
onResolveConflicts={handleResolveConflicts} onResolveConflicts={handleResolveConflicts}
onCreateMergeConflictResolutionFeature={handleCreateMergeConflictResolutionFeature} onCreateMergeConflictResolutionFeature={handleCreateMergeConflictResolutionFeature}
onBranchSwitchConflict={handleBranchSwitchConflict} onBranchSwitchConflict={handleBranchSwitchConflict}
onStashPopConflict={handleStashPopConflict}
onBranchDeletedDuringMerge={(branchName) => { onBranchDeletedDuringMerge={(branchName) => {
// Reset features that were assigned to the deleted branch (same logic as onDeleted in DeleteWorktreeDialog) // Reset features that were assigned to the deleted branch (same logic as onDeleted in DeleteWorktreeDialog)
hookFeatures.forEach((feature) => { hookFeatures.forEach((feature) => {

View File

@@ -174,6 +174,7 @@ export function CherryPickDialog({
setCommitsError(null); setCommitsError(null);
setCommitLimit(30); setCommitLimit(30);
setHasMoreCommits(false); setHasMoreCommits(false);
setLoadingBranches(false);
} }
}, [open]); }, [open]);
@@ -321,9 +322,7 @@ export function CherryPickDialog({
} else { } else {
// Check for conflicts // Check for conflicts
const errorMessage = result.error || ''; const errorMessage = result.error || '';
const hasConflicts = const hasConflicts = errorMessage.toLowerCase().includes('conflict') || result.hasConflicts;
errorMessage.toLowerCase().includes('conflict') ||
(result as { hasConflicts?: boolean }).hasConflicts;
if (hasConflicts && onCreateConflictResolutionFeature) { if (hasConflicts && onCreateConflictResolutionFeature) {
setConflictInfo({ setConflictInfo({
@@ -333,7 +332,7 @@ export function CherryPickDialog({
}); });
setStep('conflict'); setStep('conflict');
toast.error('Cherry-pick conflicts detected', { toast.error('Cherry-pick conflicts detected', {
description: 'The cherry-pick has conflicts that need to be resolved.', description: 'The cherry-pick was aborted due to conflicts. No changes were applied.',
}); });
} else { } else {
toast.error('Cherry-pick failed', { toast.error('Cherry-pick failed', {
@@ -359,7 +358,7 @@ export function CherryPickDialog({
}); });
setStep('conflict'); setStep('conflict');
toast.error('Cherry-pick conflicts detected', { toast.error('Cherry-pick conflicts detected', {
description: 'The cherry-pick has conflicts that need to be resolved.', description: 'The cherry-pick was aborted due to conflicts. No changes were applied.',
}); });
} else { } else {
toast.error('Cherry-pick failed', { toast.error('Cherry-pick failed', {
@@ -469,7 +468,7 @@ export function CherryPickDialog({
<DialogContent className="w-full h-full max-w-full max-h-full sm:w-[90vw] sm:max-w-[640px] sm:max-h-[100dvh] sm:h-auto sm:rounded-xl rounded-none flex flex-col"> <DialogContent className="w-full h-full max-w-full max-h-full sm:w-[90vw] sm:max-w-[640px] sm:max-h-[100dvh] sm:h-auto sm:rounded-xl rounded-none flex flex-col">
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<Cherry className="w-5 h-5 text-black dark:text-black" /> <Cherry className="w-5 h-5 text-foreground" />
Cherry Pick Commits Cherry Pick Commits
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
@@ -673,7 +672,7 @@ export function CherryPickDialog({
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<Cherry className="w-5 h-5 text-black dark:text-black" /> <Cherry className="w-5 h-5 text-foreground" />
Cherry Pick Cherry Pick
</DialogTitle> </DialogTitle>
<DialogDescription asChild> <DialogDescription asChild>

View File

@@ -407,7 +407,7 @@ export function CommitWorktreeDialog({
const handleKeyDown = (e: React.KeyboardEvent) => { const handleKeyDown = (e: React.KeyboardEvent) => {
if ( if (
e.key === 'Enter' && e.key === 'Enter' &&
e.metaKey && (e.metaKey || e.ctrlKey) &&
!isLoading && !isLoading &&
!isGenerating && !isGenerating &&
message.trim() && message.trim() &&
@@ -658,7 +658,8 @@ export function CommitWorktreeDialog({
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Press <kbd className="px-1 py-0.5 bg-muted rounded text-xs">Cmd+Enter</kbd> to commit Press <kbd className="px-1 py-0.5 bg-muted rounded text-xs">Cmd/Ctrl+Enter</kbd> to
commit
</p> </p>
</div> </div>

View File

@@ -78,9 +78,13 @@ export function CreateBranchDialog({
if (result.success && result.result) { if (result.success && result.result) {
setBranches(result.result.branches); setBranches(result.result.branches);
// Default to current branch // Only set the default base branch if no branch is currently selected,
if (result.result.currentBranch) { // or if the currently selected branch is no longer present in the fetched list
setBaseBranch(result.result.currentBranch); const branchNames = result.result.branches.map((b: BranchInfo) => b.name);
if (!baseBranch || !branchNames.includes(baseBranch)) {
if (result.result.currentBranch) {
setBaseBranch(result.result.currentBranch);
}
} }
} }
} catch (err) { } catch (err) {
@@ -88,7 +92,7 @@ export function CreateBranchDialog({
} finally { } finally {
setIsLoadingBranches(false); setIsLoadingBranches(false);
} }
}, [worktree]); }, [worktree, baseBranch]);
// Reset state and fetch branches when dialog opens // Reset state and fetch branches when dialog opens
useEffect(() => { useEffect(() => {

View File

@@ -75,6 +75,12 @@ export function CreatePRDialog({
const [remotes, setRemotes] = useState<RemoteInfo[]>([]); const [remotes, setRemotes] = useState<RemoteInfo[]>([]);
const [selectedRemote, setSelectedRemote] = useState<string>(''); const [selectedRemote, setSelectedRemote] = useState<string>('');
const [isLoadingRemotes, setIsLoadingRemotes] = useState(false); const [isLoadingRemotes, setIsLoadingRemotes] = useState(false);
// Keep a ref in sync with selectedRemote so fetchRemotes can read the latest value
// without needing it in its dependency array (which would cause re-fetch loops)
const selectedRemoteRef = useRef<string>(selectedRemote);
useEffect(() => {
selectedRemoteRef.current = selectedRemote;
}, [selectedRemote]);
// Generate description state // Generate description state
const [isGeneratingDescription, setIsGeneratingDescription] = useState(false); const [isGeneratingDescription, setIsGeneratingDescription] = useState(false);
@@ -110,10 +116,16 @@ export function CreatePRDialog({
); );
setRemotes(remoteInfos); setRemotes(remoteInfos);
// Auto-select 'origin' if available, otherwise first remote // Preserve existing selection if it's still valid; otherwise fall back to 'origin' or first remote
if (remoteInfos.length > 0) { if (remoteInfos.length > 0) {
const defaultRemote = remoteInfos.find((r) => r.name === 'origin') || remoteInfos[0]; const remoteNames = remoteInfos.map((r) => r.name);
setSelectedRemote(defaultRemote.name); const currentSelection = selectedRemoteRef.current;
const currentSelectionStillExists =
currentSelection !== '' && remoteNames.includes(currentSelection);
if (!currentSelectionStillExists) {
const defaultRemote = remoteInfos.find((r) => r.name === 'origin') || remoteInfos[0];
setSelectedRemote(defaultRemote.name);
}
} }
} }
} catch { } catch {

View File

@@ -319,6 +319,7 @@ export function DiscardWorktreeChangesDialog({
} }
} catch (err) { } catch (err) {
console.warn('Failed to load diffs for discard dialog:', err); console.warn('Failed to load diffs for discard dialog:', err);
setError(err instanceof Error ? err.message : String(err));
} finally { } finally {
setIsLoadingDiffs(false); setIsLoadingDiffs(false);
} }
@@ -370,7 +371,7 @@ export function DiscardWorktreeChangesDialog({
if (result.success && result.result) { if (result.success && result.result) {
if (result.result.discarded) { if (result.result.discarded) {
const fileCount = filesToDiscard ? filesToDiscard.length : result.result.filesDiscarded; const fileCount = filesToDiscard ? filesToDiscard.length : selectedFiles.size;
toast.success('Changes discarded', { toast.success('Changes discarded', {
description: `Discarded ${fileCount} ${fileCount === 1 ? 'file' : 'files'} in ${worktree.branch}`, description: `Discarded ${fileCount} ${fileCount === 1 ? 'file' : 'files'} in ${worktree.branch}`,
}); });

View File

@@ -167,10 +167,15 @@ export function MergeRebaseDialog({
} }
}; };
const handleConfirm = () => { const handleConfirm = async () => {
if (!worktree || !selectedBranch) return; if (!worktree || !selectedBranch) return;
onConfirm(worktree, selectedBranch, selectedStrategy); try {
onOpenChange(false); await onConfirm(worktree, selectedBranch, selectedStrategy);
onOpenChange(false);
} catch (err) {
logger.error('Failed to confirm merge/rebase:', err);
throw err;
}
}; };
const selectedRemoteData = remotes.find((r) => r.name === selectedRemote); const selectedRemoteData = remotes.find((r) => r.name === selectedRemote);
@@ -347,7 +352,11 @@ export function MergeRebaseDialog({
className="bg-purple-600 hover:bg-purple-700 text-white" className="bg-purple-600 hover:bg-purple-700 text-white"
> >
<GitMerge className="w-4 h-4 mr-2" /> <GitMerge className="w-4 h-4 mr-2" />
Merge & Rebase {selectedStrategy === 'merge'
? 'Merge'
: selectedStrategy === 'rebase'
? 'Rebase'
: 'Merge & Rebase'}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>

View File

@@ -69,6 +69,12 @@ export function SelectRemoteDialog({
url: r.url, url: r.url,
})); }));
setRemotes(remoteInfos); setRemotes(remoteInfos);
setSelectedRemote((prev) => {
if (prev && remoteInfos.some((r) => r.name === prev)) {
return prev;
}
return remoteInfos.find((r) => r.name === 'origin')?.name ?? remoteInfos[0]?.name ?? '';
});
} else { } else {
setError(result.error || 'Failed to fetch remotes'); setError(result.error || 'Failed to fetch remotes');
} }
@@ -120,6 +126,12 @@ export function SelectRemoteDialog({
url: r.url, url: r.url,
})); }));
setRemotes(remoteInfos); setRemotes(remoteInfos);
setSelectedRemote((prev) => {
if (prev && remoteInfos.some((r) => r.name === prev)) {
return prev;
}
return remoteInfos.find((r) => r.name === 'origin')?.name ?? remoteInfos[0]?.name ?? '';
});
} else { } else {
setError(result.error || 'Failed to refresh remotes'); setError(result.error || 'Failed to refresh remotes');
} }

View File

@@ -132,6 +132,9 @@ function parseDiff(diffText: string): ParsedFileDiff[] {
for (let i = 0; i < lines.length; i++) { for (let i = 0; i < lines.length; i++) {
const line = lines[i]; const line = lines[i];
// Skip trailing empty string produced by a final newline in diffText
if (line === '' && i === lines.length - 1) continue;
if (line.startsWith('diff --git')) { if (line.startsWith('diff --git')) {
if (currentFile) { if (currentFile) {
if (currentHunk) currentFile.hunks.push(currentHunk); if (currentHunk) currentFile.hunks.push(currentHunk);

View File

@@ -47,7 +47,9 @@ interface ViewCommitsDialogProps {
} }
function formatRelativeDate(dateStr: string): string { function formatRelativeDate(dateStr: string): string {
if (!dateStr) return 'unknown date';
const date = new Date(dateStr); const date = new Date(dateStr);
if (isNaN(date.getTime())) return 'unknown date';
const now = new Date(); const now = new Date();
const diffMs = now.getTime() - date.getTime(); const diffMs = now.getTime() - date.getTime();
const diffSecs = Math.floor(diffMs / 1000); const diffSecs = Math.floor(diffMs / 1000);

View File

@@ -280,6 +280,8 @@ export function useBoardActions({
}); });
} }
} }
return createdFeature;
}, },
[ [
addFeature, addFeature,
@@ -1221,13 +1223,12 @@ export function useBoardActions({
dependencies: [parentFeature.id], dependencies: [parentFeature.id],
}; };
await handleAddFeature(duplicatedFeatureData); const newFeature = await handleAddFeature(duplicatedFeatureData);
// Get the newly created feature (last added feature) to use as parent for next iteration // Use the returned feature directly as the parent for the next iteration,
const currentFeatures = useAppStore.getState().features; // avoiding a fragile assumption that the newest feature is the last item in the store
const newestFeature = currentFeatures[currentFeatures.length - 1]; if (newFeature) {
if (newestFeature) { parentFeature = newFeature;
parentFeature = newestFeature;
} }
} }

View File

@@ -451,31 +451,28 @@ export function WorktreeActionsDropdown({
)} )}
{/* Stash operations - combined submenu */} {/* Stash operations - combined submenu */}
{(onStashChanges || onViewStashes) && ( {(onStashChanges || onViewStashes) && (
<TooltipWrapper <TooltipWrapper showTooltip={!canPerformGitOps} tooltipContent={gitOpsDisabledReason}>
showTooltip={!gitRepoStatus.isGitRepo}
tooltipContent="Not a git repository"
>
<DropdownMenuSub> <DropdownMenuSub>
<div className="flex items-center"> <div className="flex items-center">
{/* Main clickable area - stash changes (primary action) */} {/* Main clickable area - stash changes (primary action) */}
<DropdownMenuItem <DropdownMenuItem
onClick={() => { onClick={() => {
if (!gitRepoStatus.isGitRepo) return; if (!canPerformGitOps) return;
if (worktree.hasChanges && onStashChanges) { if (worktree.hasChanges && onStashChanges) {
onStashChanges(worktree); onStashChanges(worktree);
} else if (onViewStashes) { } else if (onViewStashes) {
onViewStashes(worktree); onViewStashes(worktree);
} }
}} }}
disabled={!gitRepoStatus.isGitRepo} disabled={!canPerformGitOps}
className={cn( className={cn(
'text-xs flex-1 pr-0 rounded-r-none', 'text-xs flex-1 pr-0 rounded-r-none',
!gitRepoStatus.isGitRepo && 'opacity-50 cursor-not-allowed' !canPerformGitOps && 'opacity-50 cursor-not-allowed'
)} )}
> >
<Archive className="w-3.5 h-3.5 mr-2" /> <Archive className="w-3.5 h-3.5 mr-2" />
{worktree.hasChanges && onStashChanges ? 'Stash Changes' : 'Stashes'} {worktree.hasChanges && onStashChanges ? 'Stash Changes' : 'Stashes'}
{!gitRepoStatus.isGitRepo && ( {!canPerformGitOps && (
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" /> <AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
)} )}
</DropdownMenuItem> </DropdownMenuItem>
@@ -483,9 +480,9 @@ export function WorktreeActionsDropdown({
<DropdownMenuSubTrigger <DropdownMenuSubTrigger
className={cn( className={cn(
'text-xs px-1 rounded-l-none border-l border-border/30 h-8', 'text-xs px-1 rounded-l-none border-l border-border/30 h-8',
!gitRepoStatus.isGitRepo && 'opacity-50 cursor-not-allowed' !canPerformGitOps && 'opacity-50 cursor-not-allowed'
)} )}
disabled={!gitRepoStatus.isGitRepo} disabled={!canPerformGitOps}
/> />
</div> </div>
<DropdownMenuSubContent> <DropdownMenuSubContent>

View File

@@ -20,6 +20,12 @@ interface UseWorktreeActionsOptions {
branchName: string; branchName: string;
previousBranch: string; previousBranch: string;
}) => void; }) => void;
/** Callback when checkout fails AND the stash-pop restoration produces merge conflicts */
onStashPopConflict?: (info: {
worktreePath: string;
branchName: string;
stashPopConflictMessage: string;
}) => void;
} }
export function useWorktreeActions(options?: UseWorktreeActionsOptions) { export function useWorktreeActions(options?: UseWorktreeActionsOptions) {
@@ -29,6 +35,7 @@ export function useWorktreeActions(options?: UseWorktreeActionsOptions) {
// Use React Query mutations // Use React Query mutations
const switchBranchMutation = useSwitchBranch({ const switchBranchMutation = useSwitchBranch({
onConflict: options?.onBranchSwitchConflict, onConflict: options?.onBranchSwitchConflict,
onStashPopConflict: options?.onStashPopConflict,
}); });
const pullMutation = usePullWorktree(); const pullMutation = usePullWorktree();
const pushMutation = usePushWorktree(); const pushMutation = usePushWorktree();

View File

@@ -86,6 +86,13 @@ export interface BranchSwitchConflictInfo {
previousBranch: string; previousBranch: string;
} }
/** Info passed when a checkout failure triggers a stash-pop that itself produces conflicts */
export interface StashPopConflictInfo {
worktreePath: string;
branchName: string;
stashPopConflictMessage: string;
}
export interface WorktreePanelProps { export interface WorktreePanelProps {
projectPath: string; projectPath: string;
onCreateWorktree: () => void; onCreateWorktree: () => void;
@@ -98,6 +105,8 @@ export interface WorktreePanelProps {
onCreateMergeConflictResolutionFeature?: (conflictInfo: MergeConflictInfo) => void; onCreateMergeConflictResolutionFeature?: (conflictInfo: MergeConflictInfo) => void;
/** Called when branch switch stash reapply results in merge conflicts */ /** Called when branch switch stash reapply results in merge conflicts */
onBranchSwitchConflict?: (conflictInfo: BranchSwitchConflictInfo) => void; onBranchSwitchConflict?: (conflictInfo: BranchSwitchConflictInfo) => void;
/** Called when checkout fails and the stash-pop restoration itself produces merge conflicts */
onStashPopConflict?: (conflictInfo: StashPopConflictInfo) => void;
/** Called when a branch is deleted during merge - features should be reassigned to main */ /** Called when a branch is deleted during merge - features should be reassigned to main */
onBranchDeletedDuringMerge?: (branchName: string) => void; onBranchDeletedDuringMerge?: (branchName: string) => void;
onRemovedWorktrees?: (removedWorktrees: Array<{ path: string; branch: string }>) => void; onRemovedWorktrees?: (removedWorktrees: Array<{ path: string; branch: string }>) => void;

View File

@@ -60,6 +60,7 @@ export function WorktreePanel({
onResolveConflicts, onResolveConflicts,
onCreateMergeConflictResolutionFeature, onCreateMergeConflictResolutionFeature,
onBranchSwitchConflict, onBranchSwitchConflict,
onStashPopConflict,
onBranchDeletedDuringMerge, onBranchDeletedDuringMerge,
onRemovedWorktrees, onRemovedWorktrees,
runningFeatureIds = [], runningFeatureIds = [],
@@ -113,6 +114,7 @@ export function WorktreePanel({
handleOpenInExternalTerminal, handleOpenInExternalTerminal,
} = useWorktreeActions({ } = useWorktreeActions({
onBranchSwitchConflict: onBranchSwitchConflict, onBranchSwitchConflict: onBranchSwitchConflict,
onStashPopConflict: onStashPopConflict,
}); });
const { hasRunningFeatures } = useRunningFeatures({ const { hasRunningFeatures } = useRunningFeatures({
@@ -563,8 +565,12 @@ export function WorktreePanel({
setSelectRemoteWorktree(worktree); setSelectRemoteWorktree(worktree);
setSelectRemoteOperation('pull'); setSelectRemoteOperation('pull');
setSelectRemoteDialogOpen(true); setSelectRemoteDialogOpen(true);
} else if (result.success && result.result && result.result.remotes.length === 1) {
// Exactly one remote - use it directly
const remoteName = result.result.remotes[0].name;
handlePull(worktree, remoteName);
} else { } else {
// Single or no remote - proceed with default behavior // No remotes - proceed with default behavior
handlePull(worktree); handlePull(worktree);
} }
} catch { } catch {
@@ -587,8 +593,12 @@ export function WorktreePanel({
setSelectRemoteWorktree(worktree); setSelectRemoteWorktree(worktree);
setSelectRemoteOperation('push'); setSelectRemoteOperation('push');
setSelectRemoteDialogOpen(true); setSelectRemoteDialogOpen(true);
} else if (result.success && result.result && result.result.remotes.length === 1) {
// Exactly one remote - use it directly
const remoteName = result.result.remotes[0].name;
handlePush(worktree, remoteName);
} else { } else {
// Single or no remote - proceed with default behavior // No remotes - proceed with default behavior
handlePush(worktree); handlePush(worktree);
} }
} catch { } catch {

View File

@@ -47,7 +47,7 @@ export function WorktreePreferencesSection({ project }: WorktreePreferencesSecti
const setDefaultDeleteBranch = useAppStore((s) => s.setDefaultDeleteBranch); const setDefaultDeleteBranch = useAppStore((s) => s.setDefaultDeleteBranch);
const getAutoDismissInitScriptIndicator = useAppStore((s) => s.getAutoDismissInitScriptIndicator); const getAutoDismissInitScriptIndicator = useAppStore((s) => s.getAutoDismissInitScriptIndicator);
const setAutoDismissInitScriptIndicator = useAppStore((s) => s.setAutoDismissInitScriptIndicator); const setAutoDismissInitScriptIndicator = useAppStore((s) => s.setAutoDismissInitScriptIndicator);
const getWorktreeCopyFiles = useAppStore((s) => s.getWorktreeCopyFiles); const copyFiles = useAppStore((s) => s.worktreeCopyFilesByProject[project.path] ?? []);
const setWorktreeCopyFiles = useAppStore((s) => s.setWorktreeCopyFiles); const setWorktreeCopyFiles = useAppStore((s) => s.setWorktreeCopyFiles);
// Get effective worktrees setting (project override or global fallback) // Get effective worktrees setting (project override or global fallback)
@@ -64,7 +64,6 @@ export function WorktreePreferencesSection({ project }: WorktreePreferencesSecti
// Copy files state // Copy files state
const [newCopyFilePath, setNewCopyFilePath] = useState(''); const [newCopyFilePath, setNewCopyFilePath] = useState('');
const [fileSelectorOpen, setFileSelectorOpen] = useState(false); const [fileSelectorOpen, setFileSelectorOpen] = useState(false);
const copyFiles = getWorktreeCopyFiles(project.path);
// Get the current settings for this project // Get the current settings for this project
const showIndicator = getShowInitScriptIndicator(project.path); const showIndicator = getShowInitScriptIndicator(project.path);
@@ -245,15 +244,15 @@ export function WorktreePreferencesSection({ project }: WorktreePreferencesSecti
if (!normalized) return; if (!normalized) return;
// Check for duplicates // Check for duplicates
const currentFiles = getWorktreeCopyFiles(project.path); if (copyFiles.includes(normalized)) {
if (currentFiles.includes(normalized)) {
toast.error('File already in list', { toast.error('File already in list', {
description: `"${normalized}" is already configured for copying.`, description: `"${normalized}" is already configured for copying.`,
}); });
return; return;
} }
const updatedFiles = [...currentFiles, normalized]; const prevFiles = copyFiles;
const updatedFiles = [...copyFiles, normalized];
setWorktreeCopyFiles(project.path, updatedFiles); setWorktreeCopyFiles(project.path, updatedFiles);
setNewCopyFilePath(''); setNewCopyFilePath('');
@@ -267,16 +266,19 @@ export function WorktreePreferencesSection({ project }: WorktreePreferencesSecti
description: `"${normalized}" will be copied to new worktrees.`, description: `"${normalized}" will be copied to new worktrees.`,
}); });
} catch (error) { } catch (error) {
// Rollback optimistic update on failure
setWorktreeCopyFiles(project.path, prevFiles);
setNewCopyFilePath(normalized);
console.error('Failed to persist worktreeCopyFiles:', error); console.error('Failed to persist worktreeCopyFiles:', error);
toast.error('Failed to save copy files setting'); toast.error('Failed to save copy files setting');
} }
}, [project.path, newCopyFilePath, getWorktreeCopyFiles, setWorktreeCopyFiles]); }, [project.path, newCopyFilePath, copyFiles, setWorktreeCopyFiles]);
// Remove a file path from copy list // Remove a file path from copy list
const handleRemoveCopyFile = useCallback( const handleRemoveCopyFile = useCallback(
async (filePath: string) => { async (filePath: string) => {
const currentFiles = getWorktreeCopyFiles(project.path); const prevFiles = copyFiles;
const updatedFiles = currentFiles.filter((f) => f !== filePath); const updatedFiles = copyFiles.filter((f) => f !== filePath);
setWorktreeCopyFiles(project.path, updatedFiles); setWorktreeCopyFiles(project.path, updatedFiles);
// Persist to server // Persist to server
@@ -287,26 +289,27 @@ export function WorktreePreferencesSection({ project }: WorktreePreferencesSecti
}); });
toast.success('Copy file removed'); toast.success('Copy file removed');
} catch (error) { } catch (error) {
// Rollback optimistic update on failure
setWorktreeCopyFiles(project.path, prevFiles);
console.error('Failed to persist worktreeCopyFiles:', error); console.error('Failed to persist worktreeCopyFiles:', error);
toast.error('Failed to save copy files setting'); toast.error('Failed to save copy files setting');
} }
}, },
[project.path, getWorktreeCopyFiles, setWorktreeCopyFiles] [project.path, copyFiles, setWorktreeCopyFiles]
); );
// Handle files selected from the file selector dialog // Handle files selected from the file selector dialog
const handleFileSelectorSelect = useCallback( const handleFileSelectorSelect = useCallback(
async (paths: string[]) => { async (paths: string[]) => {
const currentFiles = getWorktreeCopyFiles(project.path);
// Filter out duplicates // Filter out duplicates
const newPaths = paths.filter((p) => !currentFiles.includes(p)); const newPaths = paths.filter((p) => !copyFiles.includes(p));
if (newPaths.length === 0) { if (newPaths.length === 0) {
toast.info('All selected files are already in the list'); toast.info('All selected files are already in the list');
return; return;
} }
const updatedFiles = [...currentFiles, ...newPaths]; const prevFiles = copyFiles;
const updatedFiles = [...copyFiles, ...newPaths];
setWorktreeCopyFiles(project.path, updatedFiles); setWorktreeCopyFiles(project.path, updatedFiles);
// Persist to server // Persist to server
@@ -319,11 +322,13 @@ export function WorktreePreferencesSection({ project }: WorktreePreferencesSecti
description: newPaths.map((p) => `"${p}"`).join(', '), description: newPaths.map((p) => `"${p}"`).join(', '),
}); });
} catch (error) { } catch (error) {
// Rollback optimistic update on failure
setWorktreeCopyFiles(project.path, prevFiles);
console.error('Failed to persist worktreeCopyFiles:', error); console.error('Failed to persist worktreeCopyFiles:', error);
toast.error('Failed to save copy files setting'); toast.error('Failed to save copy files setting');
} }
}, },
[project.path, getWorktreeCopyFiles, setWorktreeCopyFiles] [project.path, copyFiles, setWorktreeCopyFiles]
); );
return ( return (

View File

@@ -298,11 +298,21 @@ export function useMergeWorktree(projectPath: string) {
* If the reapply causes merge conflicts, the onConflict callback is called so * If the reapply causes merge conflicts, the onConflict callback is called so
* the UI can create a conflict resolution task. * the UI can create a conflict resolution task.
* *
* @param options.onConflict - Callback when merge conflicts occur after stash reapply * If the checkout itself fails and the stash-pop used to restore changes also
* produces conflicts, the onStashPopConflict callback is called so the UI can
* create an AI-assisted conflict resolution task on the board.
*
* @param options.onConflict - Callback when merge conflicts occur after stash reapply (success path)
* @param options.onStashPopConflict - Callback when checkout fails AND stash-pop restoration has conflicts
* @returns Mutation for switching branches * @returns Mutation for switching branches
*/ */
export function useSwitchBranch(options?: { export function useSwitchBranch(options?: {
onConflict?: (info: { worktreePath: string; branchName: string; previousBranch: string }) => void; onConflict?: (info: { worktreePath: string; branchName: string; previousBranch: string }) => void;
onStashPopConflict?: (info: {
worktreePath: string;
branchName: string;
stashPopConflictMessage: string;
}) => void;
}) { }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -318,6 +328,47 @@ export function useSwitchBranch(options?: {
if (!api.worktree) throw new Error('Worktree API not available'); if (!api.worktree) throw new Error('Worktree API not available');
const result = await api.worktree.switchBranch(worktreePath, branchName); const result = await api.worktree.switchBranch(worktreePath, branchName);
if (!result.success) { if (!result.success) {
// When the checkout failed and restoring the stash produced conflicts, surface
// this as a structured error so the caller can create a board task for resolution.
if (result.stashPopConflicts) {
const conflictError = new Error(result.error || 'Failed to switch branch');
// Attach the extra metadata so onError can forward it to the callback.
(
conflictError as Error & {
stashPopConflicts: boolean;
stashPopConflictMessage: string;
worktreePath: string;
branchName: string;
}
).stashPopConflicts = true;
(
conflictError as Error & {
stashPopConflicts: boolean;
stashPopConflictMessage: string;
worktreePath: string;
branchName: string;
}
).stashPopConflictMessage =
result.stashPopConflictMessage ??
'Stash pop resulted in conflicts: please resolve conflicts before retrying.';
(
conflictError as Error & {
stashPopConflicts: boolean;
stashPopConflictMessage: string;
worktreePath: string;
branchName: string;
}
).worktreePath = worktreePath;
(
conflictError as Error & {
stashPopConflicts: boolean;
stashPopConflictMessage: string;
worktreePath: string;
branchName: string;
}
).branchName = branchName;
throw conflictError;
}
throw new Error(result.error || 'Failed to switch branch'); throw new Error(result.error || 'Failed to switch branch');
} }
if (!result.result) { if (!result.result) {
@@ -345,9 +396,38 @@ export function useSwitchBranch(options?: {
} }
}, },
onError: (error: Error) => { onError: (error: Error) => {
toast.error('Failed to switch branch', { const enrichedError = error as Error & {
description: error.message, stashPopConflicts?: boolean;
}); stashPopConflictMessage?: string;
worktreePath?: string;
branchName?: string;
};
if (
enrichedError.stashPopConflicts &&
enrichedError.worktreePath &&
enrichedError.branchName
) {
// Checkout failed AND the stash-pop produced conflicts — notify the UI so it
// can create an AI-assisted board task to guide the user through resolution.
toast.error('Branch switch failed with stash conflicts', {
description:
enrichedError.stashPopConflictMessage ??
'Stash pop resulted in conflicts. Please resolve the conflicts in your working tree.',
duration: 10000,
});
options?.onStashPopConflict?.({
worktreePath: enrichedError.worktreePath,
branchName: enrichedError.branchName,
stashPopConflictMessage:
enrichedError.stashPopConflictMessage ??
'Stash pop resulted in conflicts. Please resolve the conflicts in your working tree.',
});
} else {
toast.error('Failed to switch branch', {
description: error.message,
});
}
}, },
}); });
} }

View File

@@ -148,43 +148,48 @@ export function useProviderAuthInit() {
// 4. Gemini Auth Status // 4. Gemini Auth Status
try { try {
const result = await api.setup.getGeminiStatus(); const result = await api.setup.getGeminiStatus();
if (result.success) {
// Set CLI status // Always set CLI status if any CLI info is available
if (
result.installed !== undefined ||
result.version !== undefined ||
result.path !== undefined
) {
setGeminiCliStatus({ setGeminiCliStatus({
installed: result.installed ?? false, installed: result.installed ?? false,
version: result.version, version: result.version,
path: result.path, path: result.path,
}); });
}
// Set Auth status - always set a status to mark initialization as complete // Always set auth status regardless of result.success
if (result.auth) { if (result.success && result.auth) {
const auth = result.auth; const auth = result.auth;
const validMethods: GeminiAuthStatus['method'][] = [ const validMethods: GeminiAuthStatus['method'][] = [
'google_login', 'google_login',
'api_key', 'api_key',
'vertex_ai', 'vertex_ai',
'none', 'none',
]; ];
const method = validMethods.includes(auth.method as GeminiAuthStatus['method']) const method = validMethods.includes(auth.method as GeminiAuthStatus['method'])
? (auth.method as GeminiAuthStatus['method']) ? (auth.method as GeminiAuthStatus['method'])
: ((auth.authenticated ? 'google_login' : 'none') as GeminiAuthStatus['method']); : ((auth.authenticated ? 'google_login' : 'none') as GeminiAuthStatus['method']);
setGeminiAuthStatus({ setGeminiAuthStatus({
authenticated: auth.authenticated, authenticated: auth.authenticated,
method, method,
hasApiKey: auth.hasApiKey ?? false, hasApiKey: auth.hasApiKey ?? false,
hasEnvApiKey: auth.hasEnvApiKey ?? false, hasEnvApiKey: auth.hasEnvApiKey ?? false,
}); });
} else { } else {
// No auth info available, set default unauthenticated status // result.success is false or result.auth is missing — set default unauthenticated status
setGeminiAuthStatus({ setGeminiAuthStatus({
authenticated: false, authenticated: false,
method: 'none', method: 'none',
hasApiKey: false, hasApiKey: false,
hasEnvApiKey: false, hasEnvApiKey: false,
}); });
}
} }
} catch (error) { } catch (error) {
logger.error('Failed to init Gemini auth status:', error); logger.error('Failed to init Gemini auth status:', error);

View File

@@ -1034,6 +1034,10 @@ export interface WorktreeAPI {
}; };
error?: string; error?: string;
code?: 'NOT_GIT_REPO' | 'NO_COMMITS' | 'UNCOMMITTED_CHANGES'; code?: 'NOT_GIT_REPO' | 'NO_COMMITS' | 'UNCOMMITTED_CHANGES';
/** True when the checkout failed AND the stash-pop used to restore changes produced merge conflicts */
stashPopConflicts?: boolean;
/** Human-readable message describing the stash-pop conflict situation */
stashPopConflictMessage?: string;
}>; }>;
// List all remotes and their branches // List all remotes and their branches
@@ -1524,6 +1528,7 @@ export interface WorktreeAPI {
}; };
error?: string; error?: string;
hasConflicts?: boolean; hasConflicts?: boolean;
aborted?: boolean;
}>; }>;
// Get commit log for a specific branch (not just the current one) // Get commit log for a specific branch (not just the current one)

View File

@@ -40,6 +40,9 @@ export type EventType =
| 'ideation:idea-updated' | 'ideation:idea-updated'
| 'ideation:idea-deleted' | 'ideation:idea-deleted'
| 'ideation:idea-converted' | 'ideation:idea-converted'
| 'worktree:copy-files:copied'
| 'worktree:copy-files:skipped'
| 'worktree:copy-files:failed'
| 'worktree:init-started' | 'worktree:init-started'
| 'worktree:init-output' | 'worktree:init-output'
| 'worktree:init-completed' | 'worktree:init-completed'
@@ -53,6 +56,14 @@ export type EventType =
| 'test-runner:completed' | 'test-runner:completed'
| 'test-runner:error' | 'test-runner:error'
| 'test-runner:result' | 'test-runner:result'
| 'cherry-pick:started'
| 'cherry-pick:success'
| 'cherry-pick:conflict'
| 'cherry-pick:failure'
| 'branchCommitLog:start'
| 'branchCommitLog:progress'
| 'branchCommitLog:done'
| 'branchCommitLog:error'
| 'notification:created'; | 'notification:created';
export type EventCallback = (type: EventType, payload: unknown) => void; export type EventCallback = (type: EventType, payload: unknown) => void;