mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-21 23:33:07 +00:00
fix: Address code review comments
This commit is contained in:
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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) });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|||||||
@@ -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) });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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()) {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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 = '';
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 &&
|
||||||
|
|||||||
119
apps/server/src/services/branch-commit-log-service.ts
Normal file
119
apps/server/src/services/branch-commit-log-service.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
162
apps/server/src/services/cherry-pick-service.ts
Normal file
162
apps/server/src/services/cherry-pick-service.ts
Normal 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();
|
||||||
|
}
|
||||||
119
apps/server/src/services/worktree-service.ts
Normal file
119
apps/server/src/services/worktree-service.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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([]);
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -394,6 +394,7 @@ export function Sidebar() {
|
|||||||
onNewProject={handleNewProject}
|
onNewProject={handleNewProject}
|
||||||
onOpenFolder={handleOpenFolder}
|
onOpenFolder={handleOpenFolder}
|
||||||
onProjectContextMenu={handleContextMenu}
|
onProjectContextMenu={handleContextMenu}
|
||||||
|
setShowRemoveFromAutomakerDialog={setShowRemoveFromAutomakerDialog}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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}`,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
5
apps/ui/src/types/electron.d.ts
vendored
5
apps/ui/src/types/electron.d.ts
vendored
@@ -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)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user