mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-18 10:23:07 +00:00
fix: Address code review comments
This commit is contained in:
@@ -89,8 +89,13 @@ export function createBrowseProjectFilesHandler() {
|
||||
currentRelativePath = normalized;
|
||||
|
||||
// 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);
|
||||
if (!resolvedTarget.startsWith(resolvedProjectPath)) {
|
||||
const projectPrefix = resolvedProjectPath.endsWith(path.sep)
|
||||
? resolvedProjectPath
|
||||
: resolvedProjectPath + path.sep;
|
||||
if (!resolvedTarget.startsWith(projectPrefix) && resolvedTarget !== resolvedProjectPath) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Path traversal detected',
|
||||
@@ -130,7 +135,7 @@ export function createBrowseProjectFilesHandler() {
|
||||
})
|
||||
.map((entry) => {
|
||||
const entryRelativePath = currentRelativePath
|
||||
? `${currentRelativePath}/${entry.name}`
|
||||
? path.posix.join(currentRelativePath.replace(/\\/g, '/'), entry.name)
|
||||
: entry.name;
|
||||
|
||||
return {
|
||||
|
||||
@@ -111,6 +111,17 @@ export function isValidBranchName(name: string): boolean {
|
||||
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
|
||||
*/
|
||||
|
||||
@@ -243,7 +243,7 @@ export function createWorktreeRoutes(
|
||||
'/cherry-pick',
|
||||
validatePathParams('worktreePath'),
|
||||
requireValidWorktree,
|
||||
createCherryPickHandler()
|
||||
createCherryPickHandler(events)
|
||||
);
|
||||
|
||||
// Generate PR description route
|
||||
@@ -259,7 +259,7 @@ export function createWorktreeRoutes(
|
||||
'/branch-commit-log',
|
||||
validatePathParams('worktreePath'),
|
||||
requireValidWorktree,
|
||||
createBranchCommitLogHandler()
|
||||
createBranchCommitLogHandler(events)
|
||||
);
|
||||
|
||||
return router;
|
||||
|
||||
@@ -5,14 +5,19 @@
|
||||
* any branch, not just the currently checked out one. Useful for cherry-pick workflows
|
||||
* 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
|
||||
* the requireValidWorktree middleware in index.ts
|
||||
*/
|
||||
|
||||
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> => {
|
||||
try {
|
||||
const {
|
||||
@@ -33,89 +38,40 @@ export function createBranchCommitLogHandler() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clamp limit to a reasonable range
|
||||
const commitLimit = Math.min(Math.max(1, Number(limit) || 20), 100);
|
||||
// Emit start event so the frontend can observe progress
|
||||
events.emit('branchCommitLog:start', {
|
||||
worktreePath,
|
||||
branchName: branchName || 'HEAD',
|
||||
limit,
|
||||
});
|
||||
|
||||
// Use the specified branch or default to HEAD
|
||||
const targetRef = branchName || 'HEAD';
|
||||
// Delegate all Git work to the service
|
||||
const result = await getBranchCommitLog(worktreePath, branchName, limit);
|
||||
|
||||
// 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
|
||||
);
|
||||
// Emit progress with the number of commits fetched
|
||||
events.emit('branchCommitLog:progress', {
|
||||
worktreePath,
|
||||
branchName: result.branch,
|
||||
commitsLoaded: result.total,
|
||||
});
|
||||
|
||||
// Parse the output into structured commit objects
|
||||
const commits: Array<{
|
||||
hash: string;
|
||||
shortHash: string;
|
||||
author: string;
|
||||
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();
|
||||
}
|
||||
// Emit done event
|
||||
events.emit('branchCommitLog:done', {
|
||||
worktreePath,
|
||||
branchName: result.branch,
|
||||
total: result.total,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
result: {
|
||||
branch: displayBranch,
|
||||
commits,
|
||||
total: commits.length,
|
||||
},
|
||||
result,
|
||||
});
|
||||
} catch (error) {
|
||||
// Emit error event so the frontend can react
|
||||
events.emit('branchCommitLog:error', {
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
|
||||
logError(error, 'Get branch commit log failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
|
||||
@@ -98,6 +98,19 @@ export function createCheckoutBranchHandler() {
|
||||
// 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)
|
||||
// If baseBranch is provided, create the branch from that starting point
|
||||
const checkoutArgs = ['checkout', '-b', branchName];
|
||||
|
||||
@@ -4,17 +4,20 @@
|
||||
* Applies commits from another branch onto the current branch.
|
||||
* 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
|
||||
* the requireValidWorktree middleware in index.ts
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { execGitCommand, getErrorMessage, logError } from '../common.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import path from 'path';
|
||||
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() {
|
||||
export function createCherryPickHandler(events: EventEmitter) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { worktreePath, commitHashes, options } = req.body as {
|
||||
@@ -33,6 +36,9 @@ export function createCherryPickHandler() {
|
||||
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) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
@@ -52,75 +58,64 @@ export function createCherryPickHandler() {
|
||||
}
|
||||
}
|
||||
|
||||
// Verify each commit exists
|
||||
for (const hash of commitHashes) {
|
||||
try {
|
||||
await execGitCommand(['rev-parse', '--verify', hash], worktreePath);
|
||||
} catch {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: `Commit "${hash}" does not exist`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Verify each commit exists via the service
|
||||
const invalidHash = await verifyCommits(resolvedWorktreePath, commitHashes);
|
||||
if (invalidHash !== null) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: `Commit "${invalidHash}" does not exist`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Build cherry-pick command args
|
||||
const args = ['cherry-pick'];
|
||||
if (options?.noCommit) {
|
||||
args.push('--no-commit');
|
||||
}
|
||||
// Add commit hashes in order
|
||||
args.push(...commitHashes);
|
||||
// Emit started event
|
||||
events.emit('cherry-pick:started', {
|
||||
worktreePath: resolvedWorktreePath,
|
||||
commitHashes,
|
||||
options,
|
||||
});
|
||||
|
||||
// Execute the cherry-pick
|
||||
try {
|
||||
await execGitCommand(args, worktreePath);
|
||||
// Execute the cherry-pick via the service
|
||||
const result = await runCherryPick(resolvedWorktreePath, commitHashes, options);
|
||||
|
||||
// Get current branch name
|
||||
const branchOutput = await execGitCommand(
|
||||
['rev-parse', '--abbrev-ref', 'HEAD'],
|
||||
worktreePath
|
||||
);
|
||||
if (result.success) {
|
||||
// Emit success event
|
||||
events.emit('cherry-pick:success', {
|
||||
worktreePath: resolvedWorktreePath,
|
||||
commitHashes,
|
||||
branch: result.branch,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
result: {
|
||||
cherryPicked: true,
|
||||
commitHashes,
|
||||
branch: branchOutput.trim(),
|
||||
message: `Successfully cherry-picked ${commitHashes.length} commit(s)`,
|
||||
cherryPicked: result.cherryPicked,
|
||||
commitHashes: result.commitHashes,
|
||||
branch: result.branch,
|
||||
message: result.message,
|
||||
},
|
||||
});
|
||||
} 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');
|
||||
} else if (result.hasConflicts) {
|
||||
// Emit conflict event
|
||||
events.emit('cherry-pick:conflict', {
|
||||
worktreePath: resolvedWorktreePath,
|
||||
commitHashes,
|
||||
aborted: result.aborted,
|
||||
});
|
||||
|
||||
if (hasConflicts) {
|
||||
// Abort the cherry-pick to leave the repo in a clean state
|
||||
try {
|
||||
await execGitCommand(['cherry-pick', '--abort'], worktreePath);
|
||||
} catch {
|
||||
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;
|
||||
res.status(409).json({
|
||||
success: false,
|
||||
error: result.error,
|
||||
hasConflicts: true,
|
||||
aborted: result.aborted,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// Emit failure event
|
||||
events.emit('cherry-pick:failure', {
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
|
||||
logError(error, 'Cherry-pick failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ export function createCommitLogHandler() {
|
||||
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) {
|
||||
const lines = block.split('\n');
|
||||
|
||||
@@ -8,9 +8,12 @@ import {
|
||||
logError,
|
||||
execAsync,
|
||||
execEnv,
|
||||
execGitCommand,
|
||||
isValidBranchName,
|
||||
isValidRemoteName,
|
||||
isGhCliAvailable,
|
||||
} from '../common.js';
|
||||
import { spawnProcess } from '@automaker/platform';
|
||||
import { updateWorktreePRInfo } from '../../../lib/worktree-metadata.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { validatePRState } from '@automaker/types';
|
||||
@@ -91,12 +94,9 @@ export function createCreatePRHandler() {
|
||||
logger.debug(`Running: git add -A`);
|
||||
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`);
|
||||
await execAsync(`git commit -m "${message.replace(/"/g, '\\"')}"`, {
|
||||
cwd: worktreePath,
|
||||
env: execEnv,
|
||||
});
|
||||
await execGitCommand(['commit', '-m', message], worktreePath);
|
||||
|
||||
// Get commit hash
|
||||
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')
|
||||
const pushRemote = remote || 'origin';
|
||||
let pushError: string | null = null;
|
||||
try {
|
||||
await execAsync(`git push -u ${pushRemote} ${branchName}`, {
|
||||
await execAsync(`git push ${pushRemote} ${branchName}`, {
|
||||
cwd: worktreePath,
|
||||
env: execEnv,
|
||||
});
|
||||
@@ -301,27 +310,35 @@ export function createCreatePRHandler() {
|
||||
// Only create a new PR if one doesn't already exist
|
||||
if (!prUrl) {
|
||||
try {
|
||||
// Build gh pr create command
|
||||
let prCmd = `gh pr create --base "${base}"`;
|
||||
// Build gh pr create args as an array to avoid shell injection on
|
||||
// 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 (upstreamRepo && originOwner) {
|
||||
// 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 {
|
||||
// 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}`;
|
||||
prCmd = prCmd.trim();
|
||||
prArgs.push('--title', title, '--body', body);
|
||||
if (draft) prArgs.push('--draft');
|
||||
|
||||
logger.debug(`Creating PR with command: ${prCmd}`);
|
||||
const { stdout: prOutput } = await execAsync(prCmd, {
|
||||
logger.debug(`Creating PR with args: gh ${prArgs.join(' ')}`);
|
||||
const prResult = await spawnProcess({
|
||||
command: 'gh',
|
||||
args: prArgs,
|
||||
cwd: worktreePath,
|
||||
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}`);
|
||||
|
||||
// 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 { promisify } from 'util';
|
||||
import path from 'path';
|
||||
import fs from 'fs/promises';
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import type { EventEmitter } from '../../../lib/events.js';
|
||||
import type { SettingsService } from '../../../services/settings-service.js';
|
||||
import { WorktreeService } from '../../../services/worktree-service.js';
|
||||
import { isGitRepo } from '@automaker/git-utils';
|
||||
import {
|
||||
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) {
|
||||
const worktreeService = new WorktreeService();
|
||||
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
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
|
||||
// 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)
|
||||
res.json({
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { exec } from 'child_process';
|
||||
import { execFile } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
@@ -20,7 +20,7 @@ import { getErrorMessage, logError } from '../common.js';
|
||||
import { getPhaseModelWithOverrides } from '../../../lib/settings-helpers.js';
|
||||
|
||||
const logger = createLogger('GeneratePRDescription');
|
||||
const execAsync = promisify(exec);
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/** Timeout for AI provider calls in milliseconds (30 seconds) */
|
||||
const AI_TIMEOUT_MS = 30_000;
|
||||
@@ -59,20 +59,33 @@ async function* withTimeout<T>(
|
||||
generator: AsyncIterable<T>,
|
||||
timeoutMs: number
|
||||
): AsyncGenerator<T, void, unknown> {
|
||||
let timerId: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
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]();
|
||||
let done = false;
|
||||
|
||||
while (!done) {
|
||||
const result = await Promise.race([iterator.next(), timeoutPromise]);
|
||||
if (result.done) {
|
||||
done = true;
|
||||
} else {
|
||||
yield result.value;
|
||||
try {
|
||||
while (!done) {
|
||||
const result = await Promise.race([iterator.next(), timeoutPromise]).catch(async (err) => {
|
||||
// Timeout (or other error) — attempt to gracefully close the source generator
|
||||
await iterator.return?.();
|
||||
throw err;
|
||||
});
|
||||
if (result.done) {
|
||||
done = true;
|
||||
} else {
|
||||
yield result.value;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timerId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,12 +142,24 @@ export function createGeneratePRDescriptionHandler(
|
||||
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}`);
|
||||
|
||||
// Get current branch name
|
||||
const { stdout: branchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', {
|
||||
cwd: worktreePath,
|
||||
});
|
||||
const { stdout: branchOutput } = await execFileAsync(
|
||||
'git',
|
||||
['rev-parse', '--abbrev-ref', 'HEAD'],
|
||||
{ cwd: worktreePath }
|
||||
);
|
||||
const branchName = branchOutput.trim();
|
||||
|
||||
// Determine the base branch for comparison
|
||||
@@ -149,7 +174,7 @@ export function createGeneratePRDescriptionHandler(
|
||||
let diffIncludesUncommitted = false;
|
||||
try {
|
||||
// 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,
|
||||
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),
|
||||
// try fetching and comparing against remote base
|
||||
try {
|
||||
const { stdout: remoteDiff } = await execAsync(`git diff origin/${base}...HEAD`, {
|
||||
cwd: worktreePath,
|
||||
maxBuffer: 1024 * 1024 * 5,
|
||||
});
|
||||
const { stdout: remoteDiff } = await execFileAsync(
|
||||
'git',
|
||||
['diff', `origin/${base}...HEAD`],
|
||||
{
|
||||
cwd: worktreePath,
|
||||
maxBuffer: 1024 * 1024 * 5,
|
||||
}
|
||||
);
|
||||
diff = remoteDiff;
|
||||
// git diff origin/base...HEAD only shows committed changes
|
||||
diffIncludesUncommitted = false;
|
||||
} catch {
|
||||
// Fall back to getting all uncommitted + committed changes
|
||||
try {
|
||||
const { stdout: allDiff } = await execAsync('git diff HEAD', {
|
||||
const { stdout: allDiff } = await execFileAsync('git', ['diff', 'HEAD'], {
|
||||
cwd: worktreePath,
|
||||
maxBuffer: 1024 * 1024 * 5,
|
||||
});
|
||||
@@ -179,11 +208,11 @@ export function createGeneratePRDescriptionHandler(
|
||||
diffIncludesUncommitted = true;
|
||||
} catch {
|
||||
// Last resort: get staged + unstaged changes
|
||||
const { stdout: stagedDiff } = await execAsync('git diff --cached', {
|
||||
const { stdout: stagedDiff } = await execFileAsync('git', ['diff', '--cached'], {
|
||||
cwd: worktreePath,
|
||||
maxBuffer: 1024 * 1024 * 5,
|
||||
});
|
||||
const { stdout: unstagedDiff } = await execAsync('git diff', {
|
||||
const { stdout: unstagedDiff } = await execFileAsync('git', ['diff'], {
|
||||
cwd: worktreePath,
|
||||
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.
|
||||
let hasUncommittedChanges = false;
|
||||
try {
|
||||
const { stdout: statusOutput } = await execAsync('git status --porcelain', {
|
||||
const { stdout: statusOutput } = await execFileAsync('git', ['status', '--porcelain'], {
|
||||
cwd: worktreePath,
|
||||
});
|
||||
hasUncommittedChanges = statusOutput.trim().length > 0;
|
||||
@@ -212,7 +241,7 @@ export function createGeneratePRDescriptionHandler(
|
||||
|
||||
// Get staged changes
|
||||
try {
|
||||
const { stdout: stagedDiff } = await execAsync('git diff --cached', {
|
||||
const { stdout: stagedDiff } = await execFileAsync('git', ['diff', '--cached'], {
|
||||
cwd: worktreePath,
|
||||
maxBuffer: 1024 * 1024 * 5,
|
||||
});
|
||||
@@ -225,7 +254,7 @@ export function createGeneratePRDescriptionHandler(
|
||||
|
||||
// Get unstaged changes (tracked files only)
|
||||
try {
|
||||
const { stdout: unstagedDiff } = await execAsync('git diff', {
|
||||
const { stdout: unstagedDiff } = await execFileAsync('git', ['diff'], {
|
||||
cwd: worktreePath,
|
||||
maxBuffer: 1024 * 1024 * 5,
|
||||
});
|
||||
@@ -259,8 +288,9 @@ export function createGeneratePRDescriptionHandler(
|
||||
// Also get the commit log for context
|
||||
let commitLog = '';
|
||||
try {
|
||||
const { stdout: logOutput } = await execAsync(
|
||||
`git log ${base}..HEAD --oneline --no-decorate 2>/dev/null || git log --oneline -10 --no-decorate`,
|
||||
const { stdout: logOutput } = await execFileAsync(
|
||||
'git',
|
||||
['log', `${base}..HEAD`, '--oneline', '--no-decorate'],
|
||||
{
|
||||
cwd: worktreePath,
|
||||
maxBuffer: 1024 * 1024,
|
||||
@@ -268,7 +298,20 @@ export function createGeneratePRDescriptionHandler(
|
||||
);
|
||||
commitLog = logOutput.trim();
|
||||
} 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()) {
|
||||
|
||||
@@ -40,7 +40,17 @@ export function createStashApplyHandler() {
|
||||
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';
|
||||
|
||||
try {
|
||||
|
||||
@@ -30,7 +30,7 @@ export function createStashDropHandler() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (stashIndex === undefined || stashIndex === null) {
|
||||
if (!Number.isInteger(stashIndex) || stashIndex < 0) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'stashIndex required',
|
||||
|
||||
@@ -71,9 +71,10 @@ export function createStashListHandler() {
|
||||
const message = parts[1].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 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")
|
||||
let branch = '';
|
||||
|
||||
@@ -197,13 +197,7 @@ export function createSwitchBranchHandler() {
|
||||
isRemote = true;
|
||||
const parsed = parseRemoteBranch(branchName);
|
||||
if (parsed) {
|
||||
// If a local branch with the same name already exists, just switch to it
|
||||
if (await localBranchExists(worktreePath, parsed.branch)) {
|
||||
targetBranch = parsed.branch;
|
||||
} else {
|
||||
// Will create a local tracking branch from the remote
|
||||
targetBranch = parsed.branch;
|
||||
}
|
||||
targetBranch = parsed.branch;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -307,11 +301,37 @@ export function createSwitchBranchHandler() {
|
||||
} catch (checkoutError) {
|
||||
// If checkout failed and we stashed, try to restore the stash
|
||||
if (didStash) {
|
||||
try {
|
||||
await popStash(worktreePath);
|
||||
} catch {
|
||||
// Ignore errors restoring stash - it's still in the stash list
|
||||
const popResult = await popStash(worktreePath);
|
||||
if (popResult.hasConflicts) {
|
||||
// Stash pop itself produced merge conflicts — the working tree is now in a
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -185,14 +185,17 @@ export class AutoLoopCoordinator {
|
||||
// Load all features for dependency checking (if callback provided)
|
||||
const allFeatures = this.loadAllFeaturesFn
|
||||
? 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(
|
||||
(f) =>
|
||||
!this.isFeatureRunningFn(f.id) &&
|
||||
!this.isFeatureFinishedFn(f) &&
|
||||
areDependenciesSatisfied(f, allFeatures)
|
||||
(this.loadAllFeaturesFn ? areDependenciesSatisfied(f, allFeatures!) : true)
|
||||
);
|
||||
|
||||
// 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 autoModeByWorktree = settings.autoModeByWorktree;
|
||||
if (projectId && autoModeByWorktree && typeof autoModeByWorktree === 'object') {
|
||||
// branchName is already normalized to null for the primary branch by callers
|
||||
// (e.g., checkWorktreeCapacity, startAutoLoopForProject), so we only
|
||||
// need to convert null to '__main__' for the worktree key lookup
|
||||
const normalizedBranch = branchName === null ? '__main__' : branchName;
|
||||
// Normalize both null and 'main' to '__main__' to match the same
|
||||
// canonicalization used by getWorktreeAutoLoopKey, ensuring that
|
||||
// lookups for the primary branch always use the '__main__' sentinel
|
||||
// regardless of whether the caller passed null or the string 'main'.
|
||||
const normalizedBranch =
|
||||
branchName === null || branchName === 'main' ? '__main__' : branchName;
|
||||
const worktreeId = `${projectId}::${normalizedBranch}`;
|
||||
if (
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user