mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-18 10:23:07 +00:00
feat: Fix new branch issues and address code review comments
This commit is contained in:
@@ -57,6 +57,7 @@ const CODEX_MODEL_FLAG = '--model';
|
||||
const CODEX_VERSION_FLAG = '--version';
|
||||
const CODEX_CONFIG_FLAG = '--config';
|
||||
const CODEX_ADD_DIR_FLAG = '--add-dir';
|
||||
const CODEX_OUTPUT_SCHEMA_FLAG = '--output-schema';
|
||||
const CODEX_SKIP_GIT_REPO_CHECK_FLAG = '--skip-git-repo-check';
|
||||
const CODEX_REASONING_EFFORT_KEY = 'reasoning_effort';
|
||||
const CODEX_YOLO_FLAG = '--dangerously-bypass-approvals-and-sandbox';
|
||||
@@ -200,6 +201,12 @@ function isSdkEligible(options: ExecuteOptions): boolean {
|
||||
return isNoToolsRequested(options) && !hasMcpServersConfigured(options);
|
||||
}
|
||||
|
||||
function isSdkEligibleWithApiKey(options: ExecuteOptions): boolean {
|
||||
// When using an API key (not CLI OAuth), prefer SDK over CLI to avoid OAuth issues.
|
||||
// SDK mode is used when MCP servers are not configured (MCP requires CLI).
|
||||
return !hasMcpServersConfigured(options);
|
||||
}
|
||||
|
||||
async function resolveCodexExecutionPlan(options: ExecuteOptions): Promise<CodexExecutionPlan> {
|
||||
const cliPath = await findCodexCliPath();
|
||||
const authIndicators = await getCodexAuthIndicators();
|
||||
@@ -225,8 +232,10 @@ async function resolveCodexExecutionPlan(options: ExecuteOptions): Promise<Codex
|
||||
};
|
||||
}
|
||||
|
||||
// No CLI-native auth — fall back to API key via SDK if available.
|
||||
if (hasApiKey) {
|
||||
// No CLI-native auth — prefer SDK when an API key is available.
|
||||
// Using SDK with an API key avoids OAuth issues that can arise with the CLI.
|
||||
// MCP servers still require CLI mode since the SDK doesn't support MCP.
|
||||
if (hasApiKey && isSdkEligibleWithApiKey(options)) {
|
||||
return {
|
||||
mode: CODEX_EXECUTION_MODE_SDK,
|
||||
cliPath,
|
||||
@@ -234,6 +243,16 @@ async function resolveCodexExecutionPlan(options: ExecuteOptions): Promise<Codex
|
||||
};
|
||||
}
|
||||
|
||||
// MCP servers are requested with an API key but no CLI-native auth — use CLI mode
|
||||
// with the API key passed as an environment variable.
|
||||
if (hasApiKey && cliAvailable) {
|
||||
return {
|
||||
mode: CODEX_EXECUTION_MODE_CLI,
|
||||
cliPath,
|
||||
openAiApiKey,
|
||||
};
|
||||
}
|
||||
|
||||
if (sdkEligible) {
|
||||
if (!cliAvailable) {
|
||||
throw new Error(ERROR_CODEX_SDK_AUTH_REQUIRED);
|
||||
@@ -764,7 +783,7 @@ export class CodexProvider extends BaseProvider {
|
||||
}
|
||||
const searchEnabled =
|
||||
codexSettings.enableWebSearch || resolveSearchEnabled(resolvedAllowedTools, restrictTools);
|
||||
await writeOutputSchemaFile(options.cwd, options.outputFormat);
|
||||
const schemaPath = await writeOutputSchemaFile(options.cwd, options.outputFormat);
|
||||
const imageBlocks = codexSettings.enableImages ? extractImageBlocks(options.prompt) : [];
|
||||
const imagePaths = await writeImageFiles(options.cwd, imageBlocks);
|
||||
const approvalPolicy =
|
||||
@@ -827,6 +846,9 @@ export class CodexProvider extends BaseProvider {
|
||||
...configOverrideArgs,
|
||||
'-', // Read prompt from stdin to avoid shell escaping issues
|
||||
];
|
||||
if (schemaPath) {
|
||||
args.push(CODEX_OUTPUT_SCHEMA_FLAG, schemaPath);
|
||||
}
|
||||
|
||||
const envOverrides = buildEnv();
|
||||
if (executionPlan.openAiApiKey && !envOverrides[OPENAI_API_KEY_ENV]) {
|
||||
@@ -877,16 +899,16 @@ export class CodexProvider extends BaseProvider {
|
||||
} else if (errorLower.includes('authentication') || errorLower.includes('unauthorized')) {
|
||||
enhancedError = `${errorText}\n\nTip: Check that your OPENAI_API_KEY is set correctly or run 'codex login' to authenticate.`;
|
||||
} else if (
|
||||
errorLower.includes('does not exist') ||
|
||||
errorLower.includes('model does not exist') ||
|
||||
errorLower.includes('requested model does not exist') ||
|
||||
errorLower.includes('do not have access') ||
|
||||
errorLower.includes('model_not_found') ||
|
||||
errorLower.includes('invalid_model')
|
||||
) {
|
||||
enhancedError =
|
||||
`${errorText}\n\nTip: The model '${options.model}' may not be available on your OpenAI plan. ` +
|
||||
`Available models include: ${CODEX_MODELS.map((m) => m.id).join(', ')}. ` +
|
||||
`Some models require a ChatGPT Pro/Plus subscription—authenticate with 'codex login' instead of an API key. ` +
|
||||
`For the current list of compatible models, visit https://platform.openai.com/docs/models.`;
|
||||
`See https://platform.openai.com/docs/models for available models. ` +
|
||||
`Some models require a ChatGPT Pro/Plus subscription—authenticate with 'codex login' instead of an API key.`;
|
||||
} else if (
|
||||
errorLower.includes('stream disconnected') ||
|
||||
errorLower.includes('stream ended') ||
|
||||
|
||||
@@ -2,11 +2,9 @@
|
||||
* POST /stage-files endpoint - Stage or unstage files in the main project
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import type { Request, Response } from 'express';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
import { execGitCommand } from '../../../lib/git.js';
|
||||
import { stageFiles, StageFilesValidationError } from '../../../services/stage-files-service.js';
|
||||
|
||||
export function createStageFilesHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
@@ -51,67 +49,17 @@ export function createStageFilesHandler() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve the canonical (symlink-dereferenced) project path so that
|
||||
// startsWith(base) reliably prevents symlink traversal attacks.
|
||||
// If projectPath does not exist or is unreadable, realpath rejects and
|
||||
// we return a 400 instead of letting the error propagate as a 500.
|
||||
let canonicalRoot: string;
|
||||
try {
|
||||
canonicalRoot = await fs.promises.realpath(projectPath);
|
||||
} catch {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: `Invalid projectPath (non-existent or unreadable): ${projectPath}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate and sanitize each file path to prevent path traversal attacks
|
||||
const base = path.resolve(canonicalRoot) + path.sep;
|
||||
const sanitizedFiles: string[] = [];
|
||||
for (const file of files) {
|
||||
// Reject absolute paths
|
||||
if (path.isAbsolute(file)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: `Invalid file path (absolute paths not allowed): ${file}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Reject entries containing '..'
|
||||
if (file.includes('..')) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: `Invalid file path (path traversal not allowed): ${file}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Ensure the resolved path stays within the project directory
|
||||
const resolved = path.resolve(path.join(canonicalRoot, file));
|
||||
if (resolved !== path.resolve(canonicalRoot) && !resolved.startsWith(base)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: `Invalid file path (outside project directory): ${file}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
sanitizedFiles.push(file);
|
||||
}
|
||||
|
||||
if (operation === 'stage') {
|
||||
await execGitCommand(['add', '--', ...sanitizedFiles], canonicalRoot);
|
||||
} else {
|
||||
await execGitCommand(['reset', 'HEAD', '--', ...sanitizedFiles], canonicalRoot);
|
||||
}
|
||||
const result = await stageFiles(projectPath, files, operation);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
result: {
|
||||
operation,
|
||||
filesCount: sanitizedFiles.length,
|
||||
},
|
||||
result,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof StageFilesValidationError) {
|
||||
res.status(400).json({ success: false, error: error.message });
|
||||
return;
|
||||
}
|
||||
logError(error, `${(req.body as { operation?: string })?.operation ?? 'stage'} files failed`);
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Common utilities for worktree routes
|
||||
*/
|
||||
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { createLogger, isValidBranchName, MAX_BRANCH_NAME_LENGTH } from '@automaker/utils';
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';
|
||||
@@ -14,12 +14,9 @@ export { execGitCommand } from '../../lib/git.js';
|
||||
const logger = createLogger('Worktree');
|
||||
export const execAsync = promisify(exec);
|
||||
|
||||
// ============================================================================
|
||||
// Constants
|
||||
// ============================================================================
|
||||
|
||||
/** Maximum allowed length for git branch names */
|
||||
export const MAX_BRANCH_NAME_LENGTH = 250;
|
||||
// Re-export git validation utilities from the canonical shared module so
|
||||
// existing consumers that import from this file continue to work.
|
||||
export { isValidBranchName, MAX_BRANCH_NAME_LENGTH };
|
||||
|
||||
// ============================================================================
|
||||
// Extended PATH configuration for Electron apps
|
||||
@@ -63,22 +60,6 @@ export const execEnv = {
|
||||
PATH: extendedPath,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Validation utilities
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Validate branch name to prevent command injection.
|
||||
* Git branch names cannot contain: space, ~, ^, :, ?, *, [, \, or control chars.
|
||||
* We also reject shell metacharacters for safety.
|
||||
* The first character must not be '-' to prevent git argument injection.
|
||||
*/
|
||||
export function isValidBranchName(name: string): boolean {
|
||||
// First char must be alphanumeric, dot, underscore, or slash (not dash)
|
||||
// to prevent git option injection via names like "-flag" or "--option".
|
||||
return /^[a-zA-Z0-9._/][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.
|
||||
|
||||
@@ -66,6 +66,7 @@ import { createRebaseHandler } from './routes/rebase.js';
|
||||
import { createAbortOperationHandler } from './routes/abort-operation.js';
|
||||
import { createContinueOperationHandler } from './routes/continue-operation.js';
|
||||
import { createStageFilesHandler } from './routes/stage-files.js';
|
||||
import { createCheckChangesHandler } from './routes/check-changes.js';
|
||||
import type { SettingsService } from '../../services/settings-service.js';
|
||||
|
||||
export function createWorktreeRoutes(
|
||||
@@ -121,7 +122,13 @@ export function createWorktreeRoutes(
|
||||
'/checkout-branch',
|
||||
validatePathParams('worktreePath'),
|
||||
requireValidWorktree,
|
||||
createCheckoutBranchHandler()
|
||||
createCheckoutBranchHandler(events)
|
||||
);
|
||||
router.post(
|
||||
'/check-changes',
|
||||
validatePathParams('worktreePath'),
|
||||
requireGitRepoOnly,
|
||||
createCheckChangesHandler()
|
||||
);
|
||||
router.post(
|
||||
'/list-branches',
|
||||
|
||||
@@ -16,31 +16,7 @@ import type { Request, Response } from 'express';
|
||||
import type { EventEmitter } from '../../../lib/events.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
import { getBranchCommitLog } from '../../../services/branch-commit-log-service.js';
|
||||
|
||||
/**
|
||||
* Validates a branchName value before it is forwarded to execGitCommand.
|
||||
*
|
||||
* Rejects values that:
|
||||
* - Start with '-' (would be interpreted as a git flag/option)
|
||||
* - Contain NUL bytes (\0)
|
||||
* - Contain path-traversal sequences (..)
|
||||
*
|
||||
* Only allows characters from a safe whitelist:
|
||||
* alphanumerics, dot (.), slash (/), underscore (_), dash (-), plus (+),
|
||||
* at-sign (@), tilde (~), caret (^), and colon (:).
|
||||
*
|
||||
* Returns `true` when the value is safe to pass to execGitCommand.
|
||||
*/
|
||||
function isValidBranchName(branchName: string): boolean {
|
||||
// Must not start with '-' (git option injection)
|
||||
if (branchName.startsWith('-')) return false;
|
||||
// Must not contain NUL bytes
|
||||
if (branchName.includes('\0')) return false;
|
||||
// Must not contain path-traversal sequences
|
||||
if (branchName.includes('..')) return false;
|
||||
// Whitelist: alphanumerics and common ref characters
|
||||
return /^[a-zA-Z0-9._/\-+@~^:]+$/.test(branchName);
|
||||
}
|
||||
import { isValidBranchName } from '@automaker/utils';
|
||||
|
||||
export function createBranchCommitLogHandler(events: EventEmitter) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
|
||||
100
apps/server/src/routes/worktree/routes/check-changes.ts
Normal file
100
apps/server/src/routes/worktree/routes/check-changes.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* POST /check-changes endpoint - Check for uncommitted changes in a worktree
|
||||
*
|
||||
* Returns a summary of staged, unstaged, and untracked files to help
|
||||
* the user decide whether to stash before a branch operation.
|
||||
*
|
||||
* Note: Git repository validation (isGitRepo) is handled by
|
||||
* the requireGitRepoOnly middleware in index.ts
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
import { execGitCommand } from '../../../lib/git.js';
|
||||
|
||||
/**
|
||||
* Parse `git status --porcelain` output into categorised file lists.
|
||||
*
|
||||
* Porcelain format gives two status characters per line:
|
||||
* XY filename
|
||||
* where X is the index (staged) status and Y is the worktree (unstaged) status.
|
||||
*
|
||||
* - '?' in both columns → untracked
|
||||
* - Non-space/non-'?' in X → staged change
|
||||
* - Non-space/non-'?' in Y (when not untracked) → unstaged change
|
||||
*
|
||||
* A file can appear in both staged and unstaged if it was partially staged.
|
||||
*/
|
||||
function parseStatusOutput(stdout: string): {
|
||||
staged: string[];
|
||||
unstaged: string[];
|
||||
untracked: string[];
|
||||
} {
|
||||
const staged: string[] = [];
|
||||
const unstaged: string[] = [];
|
||||
const untracked: string[] = [];
|
||||
|
||||
const lines = stdout.trim().split('\n').filter(Boolean);
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.length < 3) continue;
|
||||
|
||||
const x = line[0]; // index status
|
||||
const y = line[1]; // worktree status
|
||||
// Handle renames which use " -> " separator
|
||||
const rawPath = line.slice(3);
|
||||
const filePath = rawPath.includes(' -> ') ? rawPath.split(' -> ')[1] : rawPath;
|
||||
|
||||
if (x === '?' && y === '?') {
|
||||
untracked.push(filePath);
|
||||
} else {
|
||||
if (x !== ' ' && x !== '?') {
|
||||
staged.push(filePath);
|
||||
}
|
||||
if (y !== ' ' && y !== '?') {
|
||||
unstaged.push(filePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { staged, unstaged, untracked };
|
||||
}
|
||||
|
||||
export function createCheckChangesHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { worktreePath } = req.body as {
|
||||
worktreePath: string;
|
||||
};
|
||||
|
||||
if (!worktreePath) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'worktreePath required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Get porcelain status (includes staged, unstaged, and untracked files)
|
||||
const stdout = await execGitCommand(['status', '--porcelain'], worktreePath);
|
||||
|
||||
const { staged, unstaged, untracked } = parseStatusOutput(stdout);
|
||||
|
||||
const hasChanges = staged.length > 0 || unstaged.length > 0 || untracked.length > 0;
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
result: {
|
||||
hasChanges,
|
||||
staged,
|
||||
unstaged,
|
||||
untracked,
|
||||
totalFiles: staged.length + unstaged.length + untracked.length,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Check changes failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,14 @@
|
||||
/**
|
||||
* POST /checkout-branch endpoint - Create and checkout a new branch
|
||||
*
|
||||
* Supports automatic stash handling: when `stashChanges` is true, local changes
|
||||
* are stashed before creating the branch and reapplied after. If the stash pop
|
||||
* results in merge conflicts, returns a special response so the UI can create a
|
||||
* conflict resolution task.
|
||||
*
|
||||
* Git business logic is delegated to checkout-branch-service.ts when stash
|
||||
* handling is requested. Otherwise, falls back to the original simple flow.
|
||||
*
|
||||
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
|
||||
* the requireValidWorktree middleware in index.ts.
|
||||
* Path validation (ALLOWED_ROOT_DIRECTORY) is handled by validatePathParams
|
||||
@@ -12,14 +20,20 @@ import path from 'path';
|
||||
import { stat } from 'fs/promises';
|
||||
import { getErrorMessage, logError, isValidBranchName } from '../common.js';
|
||||
import { execGitCommand } from '../../../lib/git.js';
|
||||
import type { EventEmitter } from '../../../lib/events.js';
|
||||
import { performCheckoutBranch } from '../../../services/checkout-branch-service.js';
|
||||
|
||||
export function createCheckoutBranchHandler() {
|
||||
export function createCheckoutBranchHandler(events?: EventEmitter) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { worktreePath, branchName, baseBranch } = req.body as {
|
||||
const { worktreePath, branchName, baseBranch, stashChanges, includeUntracked } = req.body as {
|
||||
worktreePath: string;
|
||||
branchName: string;
|
||||
baseBranch?: string; // Optional base branch to create from (defaults to current HEAD)
|
||||
baseBranch?: string;
|
||||
/** When true, stash local changes before checkout and reapply after */
|
||||
stashChanges?: boolean;
|
||||
/** When true, include untracked files in the stash (defaults to true) */
|
||||
includeUntracked?: boolean;
|
||||
};
|
||||
|
||||
if (!worktreePath) {
|
||||
@@ -59,8 +73,6 @@ export function createCheckoutBranchHandler() {
|
||||
}
|
||||
|
||||
// Resolve and validate worktreePath to prevent traversal attacks.
|
||||
// The validatePathParams middleware checks against ALLOWED_ROOT_DIRECTORY,
|
||||
// but we also resolve the path and verify it exists as a directory.
|
||||
const resolvedPath = path.resolve(worktreePath);
|
||||
try {
|
||||
const stats = await stat(resolvedPath);
|
||||
@@ -79,7 +91,42 @@ export function createCheckoutBranchHandler() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get current branch for reference (using argument array to avoid shell injection)
|
||||
// Use the service for stash-aware checkout
|
||||
if (stashChanges) {
|
||||
const result = await performCheckoutBranch(
|
||||
resolvedPath,
|
||||
branchName,
|
||||
baseBranch,
|
||||
{
|
||||
stashChanges: true,
|
||||
includeUntracked: includeUntracked ?? true,
|
||||
},
|
||||
events
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
const statusCode = isBranchError(result.error) ? 400 : 500;
|
||||
res.status(statusCode).json({
|
||||
success: false,
|
||||
error: result.error,
|
||||
...(result.stashPopConflicts !== undefined && {
|
||||
stashPopConflicts: result.stashPopConflicts,
|
||||
}),
|
||||
...(result.stashPopConflictMessage && {
|
||||
stashPopConflictMessage: result.stashPopConflictMessage,
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
result: result.result,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Original simple flow (no stash handling)
|
||||
const currentBranchOutput = await execGitCommand(
|
||||
['rev-parse', '--abbrev-ref', 'HEAD'],
|
||||
resolvedPath
|
||||
@@ -89,7 +136,6 @@ export function createCheckoutBranchHandler() {
|
||||
// Check if branch already exists
|
||||
try {
|
||||
await execGitCommand(['rev-parse', '--verify', branchName], resolvedPath);
|
||||
// Branch exists
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: `Branch '${branchName}' already exists`,
|
||||
@@ -112,8 +158,7 @@ export function createCheckoutBranchHandler() {
|
||||
}
|
||||
}
|
||||
|
||||
// Create and checkout the new branch (using argument array to avoid shell injection)
|
||||
// If baseBranch is provided, create the branch from that starting point
|
||||
// Create and checkout the new branch
|
||||
const checkoutArgs = ['checkout', '-b', branchName];
|
||||
if (baseBranch) {
|
||||
checkoutArgs.push(baseBranch);
|
||||
@@ -129,8 +174,24 @@ export function createCheckoutBranchHandler() {
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
events?.emit('switch:error', {
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
|
||||
logError(error, 'Checkout branch failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether an error message represents a client error (400)
|
||||
*/
|
||||
function isBranchError(error?: string): boolean {
|
||||
if (!error) return false;
|
||||
return (
|
||||
error.includes('already exists') ||
|
||||
error.includes('does not exist') ||
|
||||
error.includes('Failed to stash')
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ import { getFeatureDir } from '@automaker/platform';
|
||||
import * as secureFs from '../../lib/secure-fs.js';
|
||||
import { validateWorkingDirectory } from '../../lib/sdk-options.js';
|
||||
import { getPromptCustomization, getProviderByModelId } from '../../lib/settings-helpers.js';
|
||||
import { execGitCommand } from '../../lib/git.js';
|
||||
import { execGitCommand } from '@automaker/git-utils';
|
||||
import { TypedEventBus } from '../typed-event-bus.js';
|
||||
import { ConcurrencyManager } from '../concurrency-manager.js';
|
||||
import { WorktreeResolver } from '../worktree-resolver.js';
|
||||
|
||||
383
apps/server/src/services/checkout-branch-service.ts
Normal file
383
apps/server/src/services/checkout-branch-service.ts
Normal file
@@ -0,0 +1,383 @@
|
||||
/**
|
||||
* CheckoutBranchService - Create and checkout a new branch with stash handling
|
||||
*
|
||||
* Handles new branch creation with automatic stash/reapply of local changes.
|
||||
* If there are uncommitted changes and the caller requests stashing, they are
|
||||
* stashed before creating the branch and reapplied after. If the stash pop
|
||||
* results in merge conflicts, returns a special response so the UI can create
|
||||
* a conflict resolution task.
|
||||
*
|
||||
* Follows the same pattern as worktree-branch-service.ts (performSwitchBranch).
|
||||
*
|
||||
* The workflow:
|
||||
* 1. Validate inputs (branch name, base branch)
|
||||
* 2. Get current branch name
|
||||
* 3. Check if target branch already exists
|
||||
* 4. Optionally stash local changes
|
||||
* 5. Create and checkout the new branch
|
||||
* 6. Reapply stashed changes (detect conflicts)
|
||||
* 7. Handle error recovery (restore stash if checkout fails)
|
||||
*/
|
||||
|
||||
import { createLogger, getErrorMessage } from '@automaker/utils';
|
||||
import { execGitCommand, execGitCommandWithLockRetry } from '../lib/git.js';
|
||||
import type { EventEmitter } from '../lib/events.js';
|
||||
|
||||
const logger = createLogger('CheckoutBranchService');
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
export interface CheckoutBranchOptions {
|
||||
/** When true, stash local changes before checkout and reapply after */
|
||||
stashChanges?: boolean;
|
||||
/** When true, include untracked files in the stash */
|
||||
includeUntracked?: boolean;
|
||||
}
|
||||
|
||||
export interface CheckoutBranchResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
result?: {
|
||||
previousBranch: string;
|
||||
newBranch: string;
|
||||
message: string;
|
||||
hasConflicts?: boolean;
|
||||
stashedChanges?: boolean;
|
||||
};
|
||||
/** Set when checkout fails and stash pop produced conflicts during recovery */
|
||||
stashPopConflicts?: boolean;
|
||||
/** Human-readable message when stash pop conflicts occur during error recovery */
|
||||
stashPopConflictMessage?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helper Functions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Check if there are any changes (including untracked) that should be stashed
|
||||
*/
|
||||
async function hasAnyChanges(cwd: string): Promise<boolean> {
|
||||
try {
|
||||
const stdout = await execGitCommand(['status', '--porcelain'], cwd);
|
||||
const lines = stdout
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter((line) => line.trim());
|
||||
return lines.length > 0;
|
||||
} catch (err) {
|
||||
logger.error('hasAnyChanges: execGitCommand failed — returning false', {
|
||||
cwd,
|
||||
error: getErrorMessage(err),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stash all local changes (including untracked files if requested)
|
||||
* Returns true if a stash was created, false if there was nothing to stash.
|
||||
* Throws on unexpected errors so callers abort rather than proceeding silently.
|
||||
*/
|
||||
async function stashChanges(
|
||||
cwd: string,
|
||||
message: string,
|
||||
includeUntracked: boolean
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const args = ['stash', 'push'];
|
||||
if (includeUntracked) {
|
||||
args.push('--include-untracked');
|
||||
}
|
||||
args.push('-m', message);
|
||||
|
||||
await execGitCommandWithLockRetry(args, cwd);
|
||||
return true;
|
||||
} catch (error) {
|
||||
const errorMsg = getErrorMessage(error);
|
||||
|
||||
// "Nothing to stash" is benign
|
||||
if (
|
||||
errorMsg.toLowerCase().includes('no local changes to save') ||
|
||||
errorMsg.toLowerCase().includes('nothing to stash')
|
||||
) {
|
||||
logger.debug('stashChanges: nothing to stash', { cwd, message, error: errorMsg });
|
||||
return false;
|
||||
}
|
||||
|
||||
logger.error('stashChanges: unexpected error during stash', {
|
||||
cwd,
|
||||
message,
|
||||
error: errorMsg,
|
||||
});
|
||||
throw new Error(`Failed to stash changes in ${cwd}: ${errorMsg}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pop the most recent stash entry
|
||||
* Returns an object indicating success and whether there were conflicts
|
||||
*/
|
||||
async function popStash(
|
||||
cwd: string
|
||||
): Promise<{ success: boolean; hasConflicts: boolean; error?: string }> {
|
||||
try {
|
||||
await execGitCommand(['stash', 'pop'], cwd);
|
||||
return { success: true, hasConflicts: false };
|
||||
} catch (error) {
|
||||
const errorMsg = getErrorMessage(error);
|
||||
if (errorMsg.includes('CONFLICT') || errorMsg.includes('Merge conflict')) {
|
||||
return { success: false, hasConflicts: true, error: errorMsg };
|
||||
}
|
||||
return { success: false, hasConflicts: false, error: errorMsg };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a local branch already exists
|
||||
*/
|
||||
async function localBranchExists(cwd: string, branchName: string): Promise<boolean> {
|
||||
try {
|
||||
await execGitCommand(['rev-parse', '--verify', `refs/heads/${branchName}`], cwd);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Main Service Function
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Create and checkout a new branch, optionally stashing and restoring local changes.
|
||||
*
|
||||
* @param worktreePath - Path to the git worktree
|
||||
* @param branchName - Name of the new branch to create
|
||||
* @param baseBranch - Optional base branch to create from (defaults to current HEAD)
|
||||
* @param options - Stash handling options
|
||||
* @param events - Optional event emitter for lifecycle events
|
||||
* @returns CheckoutBranchResult with detailed status information
|
||||
*/
|
||||
export async function performCheckoutBranch(
|
||||
worktreePath: string,
|
||||
branchName: string,
|
||||
baseBranch?: string,
|
||||
options?: CheckoutBranchOptions,
|
||||
events?: EventEmitter
|
||||
): Promise<CheckoutBranchResult> {
|
||||
const shouldStash = options?.stashChanges ?? false;
|
||||
const includeUntracked = options?.includeUntracked ?? true;
|
||||
|
||||
// Emit start event
|
||||
events?.emit('switch:start', { worktreePath, branchName, operation: 'checkout' });
|
||||
|
||||
// 1. Get current branch
|
||||
const currentBranchOutput = await execGitCommand(
|
||||
['rev-parse', '--abbrev-ref', 'HEAD'],
|
||||
worktreePath
|
||||
);
|
||||
const previousBranch = currentBranchOutput.trim();
|
||||
|
||||
// 2. Check if branch already exists
|
||||
if (await localBranchExists(worktreePath, branchName)) {
|
||||
events?.emit('switch:error', {
|
||||
worktreePath,
|
||||
branchName,
|
||||
error: `Branch '${branchName}' already exists`,
|
||||
});
|
||||
return {
|
||||
success: false,
|
||||
error: `Branch '${branchName}' already exists`,
|
||||
};
|
||||
}
|
||||
|
||||
// 3. Validate base branch if provided
|
||||
if (baseBranch) {
|
||||
try {
|
||||
await execGitCommand(['rev-parse', '--verify', baseBranch], worktreePath);
|
||||
} catch {
|
||||
events?.emit('switch:error', {
|
||||
worktreePath,
|
||||
branchName,
|
||||
error: `Base branch '${baseBranch}' does not exist`,
|
||||
});
|
||||
return {
|
||||
success: false,
|
||||
error: `Base branch '${baseBranch}' does not exist`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Stash local changes if requested and there are changes
|
||||
let didStash = false;
|
||||
|
||||
if (shouldStash) {
|
||||
const hadChanges = await hasAnyChanges(worktreePath);
|
||||
if (hadChanges) {
|
||||
events?.emit('switch:stash', {
|
||||
worktreePath,
|
||||
previousBranch,
|
||||
targetBranch: branchName,
|
||||
action: 'push',
|
||||
});
|
||||
|
||||
const stashMessage = `Auto-stash before switching to ${branchName}`;
|
||||
try {
|
||||
didStash = await stashChanges(worktreePath, stashMessage, includeUntracked);
|
||||
} catch (stashError) {
|
||||
const stashErrorMsg = getErrorMessage(stashError);
|
||||
events?.emit('switch:error', {
|
||||
worktreePath,
|
||||
branchName,
|
||||
error: `Failed to stash local changes: ${stashErrorMsg}`,
|
||||
});
|
||||
return {
|
||||
success: false,
|
||||
error: `Failed to stash local changes before creating branch: ${stashErrorMsg}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// 5. Create and checkout the new branch
|
||||
events?.emit('switch:checkout', {
|
||||
worktreePath,
|
||||
targetBranch: branchName,
|
||||
isRemote: false,
|
||||
previousBranch,
|
||||
});
|
||||
|
||||
const checkoutArgs = ['checkout', '-b', branchName];
|
||||
if (baseBranch) {
|
||||
checkoutArgs.push(baseBranch);
|
||||
}
|
||||
await execGitCommand(checkoutArgs, worktreePath);
|
||||
|
||||
// 6. Reapply stashed changes if we stashed earlier
|
||||
let hasConflicts = false;
|
||||
let conflictMessage = '';
|
||||
let stashReapplied = false;
|
||||
|
||||
if (didStash) {
|
||||
events?.emit('switch:pop', {
|
||||
worktreePath,
|
||||
targetBranch: branchName,
|
||||
action: 'pop',
|
||||
});
|
||||
|
||||
const popResult = await popStash(worktreePath);
|
||||
hasConflicts = popResult.hasConflicts;
|
||||
if (popResult.hasConflicts) {
|
||||
conflictMessage = `Created branch '${branchName}' but merge conflicts occurred when reapplying your local changes. Please resolve the conflicts.`;
|
||||
} else if (!popResult.success) {
|
||||
conflictMessage = `Created branch '${branchName}' but failed to reapply stashed changes: ${popResult.error}. Your changes are still in the stash.`;
|
||||
} else {
|
||||
stashReapplied = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasConflicts) {
|
||||
events?.emit('switch:done', {
|
||||
worktreePath,
|
||||
previousBranch,
|
||||
currentBranch: branchName,
|
||||
hasConflicts: true,
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
previousBranch,
|
||||
newBranch: branchName,
|
||||
message: conflictMessage,
|
||||
hasConflicts: true,
|
||||
stashedChanges: true,
|
||||
},
|
||||
};
|
||||
} else if (didStash && !stashReapplied) {
|
||||
// Stash pop failed for a non-conflict reason — stash is still present
|
||||
events?.emit('switch:done', {
|
||||
worktreePath,
|
||||
previousBranch,
|
||||
currentBranch: branchName,
|
||||
stashPopFailed: true,
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
previousBranch,
|
||||
newBranch: branchName,
|
||||
message: conflictMessage,
|
||||
hasConflicts: false,
|
||||
stashedChanges: true,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
const stashNote = stashReapplied ? ' (local changes stashed and reapplied)' : '';
|
||||
events?.emit('switch:done', {
|
||||
worktreePath,
|
||||
previousBranch,
|
||||
currentBranch: branchName,
|
||||
stashReapplied,
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
previousBranch,
|
||||
newBranch: branchName,
|
||||
message: `Created and checked out branch '${branchName}'${stashNote}`,
|
||||
hasConflicts: false,
|
||||
stashedChanges: stashReapplied,
|
||||
},
|
||||
};
|
||||
}
|
||||
} catch (checkoutError) {
|
||||
// 7. If checkout failed and we stashed, try to restore the stash
|
||||
if (didStash) {
|
||||
const popResult = await popStash(worktreePath);
|
||||
if (popResult.hasConflicts) {
|
||||
const checkoutErrorMsg = getErrorMessage(checkoutError);
|
||||
events?.emit('switch:error', {
|
||||
worktreePath,
|
||||
branchName,
|
||||
error: checkoutErrorMsg,
|
||||
stashPopConflicts: true,
|
||||
});
|
||||
return {
|
||||
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.',
|
||||
};
|
||||
} else if (!popResult.success) {
|
||||
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.`;
|
||||
events?.emit('switch:error', {
|
||||
worktreePath,
|
||||
branchName,
|
||||
error: combinedMessage,
|
||||
});
|
||||
return {
|
||||
success: false,
|
||||
error: combinedMessage,
|
||||
stashPopConflicts: false,
|
||||
};
|
||||
}
|
||||
// popResult.success === true: stash was cleanly restored
|
||||
}
|
||||
const checkoutErrorMsg = getErrorMessage(checkoutError);
|
||||
events?.emit('switch:error', {
|
||||
worktreePath,
|
||||
branchName,
|
||||
error: checkoutErrorMsg,
|
||||
});
|
||||
throw checkoutError;
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
* Extracted from worktree merge route to allow internal service calls.
|
||||
*/
|
||||
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { createLogger, isValidBranchName } from '@automaker/utils';
|
||||
import { type EventEmitter } from '../lib/events.js';
|
||||
import { execGitCommand } from '../lib/git.js';
|
||||
const logger = createLogger('MergeService');
|
||||
@@ -28,19 +28,6 @@ export interface MergeServiceResult {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate branch name to prevent command injection.
|
||||
* The first character must not be '-' to prevent git argument injection
|
||||
* via names like "-flag" or "--option".
|
||||
*/
|
||||
function isValidBranchName(name: string): boolean {
|
||||
// First char must be alphanumeric, dot, underscore, or slash (not dash)
|
||||
// Reject names containing '..' to prevent git ref traversal
|
||||
return (
|
||||
/^[a-zA-Z0-9._/][a-zA-Z0-9._\-/]*$/.test(name) && name.length < 250 && !name.includes('..')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a git merge operation directly without HTTP.
|
||||
*
|
||||
@@ -111,30 +98,76 @@ export async function performMerge(
|
||||
: ['merge', branchName, '-m', mergeMessage];
|
||||
|
||||
try {
|
||||
await execGitCommand(mergeArgs, projectPath);
|
||||
// Set LC_ALL=C so git always emits English output regardless of the system
|
||||
// locale, making text-based conflict detection reliable.
|
||||
await execGitCommand(mergeArgs, projectPath, { LC_ALL: 'C' });
|
||||
} catch (mergeError: unknown) {
|
||||
// Check if this is a merge conflict
|
||||
// Check if this is a merge conflict. We use a multi-layer strategy so
|
||||
// that detection is reliable even when locale settings vary or git's text
|
||||
// output changes across versions:
|
||||
//
|
||||
// 1. Primary (text-based): scan the error output for well-known English
|
||||
// conflict markers. Because we pass LC_ALL=C above these strings are
|
||||
// always in English, but we keep the check as one layer among several.
|
||||
//
|
||||
// 2. Unmerged-path check: run `git diff --name-only --diff-filter=U`
|
||||
// (locale-stable) and treat any non-empty output as a conflict
|
||||
// indicator, capturing the file list at the same time.
|
||||
//
|
||||
// 3. Fallback status check: run `git status --porcelain` and look for
|
||||
// lines whose first two characters indicate an unmerged state
|
||||
// (UU, AA, DD, AU, UA, DU, UD).
|
||||
//
|
||||
// hasConflicts is true when ANY of the three layers returns positive.
|
||||
const err = mergeError as { stdout?: string; stderr?: string; message?: string };
|
||||
const output = `${err.stdout || ''} ${err.stderr || ''} ${err.message || ''}`;
|
||||
const hasConflicts = output.includes('CONFLICT') || output.includes('Automatic merge failed');
|
||||
|
||||
// Layer 1 – text matching (locale-safe because we set LC_ALL=C above).
|
||||
const textIndicatesConflict =
|
||||
output.includes('CONFLICT') || output.includes('Automatic merge failed');
|
||||
|
||||
// Layers 2 & 3 – repository state inspection (locale-independent).
|
||||
// Layer 2: get conflicted files via diff (also locale-stable output).
|
||||
let conflictFiles: string[] | undefined;
|
||||
let diffIndicatesConflict = false;
|
||||
try {
|
||||
const diffOutput = await execGitCommand(
|
||||
['diff', '--name-only', '--diff-filter=U'],
|
||||
projectPath,
|
||||
{ LC_ALL: 'C' }
|
||||
);
|
||||
const files = diffOutput
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter((f) => f.trim().length > 0);
|
||||
if (files.length > 0) {
|
||||
diffIndicatesConflict = true;
|
||||
conflictFiles = files;
|
||||
}
|
||||
} catch {
|
||||
// If we can't get the file list, leave conflictFiles undefined so callers
|
||||
// can distinguish "no conflicts" (empty array) from "unknown due to diff failure" (undefined)
|
||||
}
|
||||
|
||||
// Layer 3: check for unmerged paths via machine-readable git status.
|
||||
let hasUnmergedPaths = false;
|
||||
try {
|
||||
const statusOutput = await execGitCommand(['status', '--porcelain'], projectPath, {
|
||||
LC_ALL: 'C',
|
||||
});
|
||||
// Unmerged status codes occupy the first two characters of each line.
|
||||
// Standard unmerged codes: UU, AA, DD, AU, UA, DU, UD.
|
||||
hasUnmergedPaths = statusOutput
|
||||
.split('\n')
|
||||
.some((line) => /^(UU|AA|DD|AU|UA|DU|UD)/.test(line));
|
||||
} catch {
|
||||
// git status failing is itself a sign something is wrong; leave
|
||||
// hasUnmergedPaths as false and rely on the other layers.
|
||||
}
|
||||
|
||||
const hasConflicts = textIndicatesConflict || diffIndicatesConflict || hasUnmergedPaths;
|
||||
|
||||
if (hasConflicts) {
|
||||
// Get list of conflicted files
|
||||
let conflictFiles: string[] | undefined;
|
||||
try {
|
||||
const diffOutput = await execGitCommand(
|
||||
['diff', '--name-only', '--diff-filter=U'],
|
||||
projectPath
|
||||
);
|
||||
conflictFiles = diffOutput
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter((f) => f.trim().length > 0);
|
||||
} catch {
|
||||
// If we can't get the file list, leave conflictFiles undefined so callers
|
||||
// can distinguish "no conflicts" (empty array) from "unknown due to diff failure" (undefined)
|
||||
}
|
||||
|
||||
// Emit merge:conflict event with conflict details
|
||||
emitter?.emit('merge:conflict', { branchName, targetBranch: mergeTo, conflictFiles });
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
import { createLogger, getErrorMessage } from '@automaker/utils';
|
||||
import { getConflictFiles } from '@automaker/git-utils';
|
||||
import { execGitCommand, execGitCommandWithLockRetry } from '../lib/git.js';
|
||||
import { execGitCommand, execGitCommandWithLockRetry, getCurrentBranch } from '../lib/git.js';
|
||||
|
||||
const logger = createLogger('PullService');
|
||||
|
||||
@@ -52,17 +52,6 @@ export interface PullResult {
|
||||
// Helper Functions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get the current branch name for the worktree.
|
||||
*
|
||||
* @param worktreePath - Path to the git worktree
|
||||
* @returns The current branch name (returns 'HEAD' for detached HEAD state)
|
||||
*/
|
||||
export async function getCurrentBranch(worktreePath: string): Promise<string> {
|
||||
const branchOutput = await execGitCommand(['rev-parse', '--abbrev-ref', 'HEAD'], worktreePath);
|
||||
return branchOutput.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the latest refs from a remote.
|
||||
*
|
||||
@@ -102,7 +91,7 @@ export async function getLocalChanges(
|
||||
*
|
||||
* @param worktreePath - Path to the git worktree
|
||||
* @param branchName - Current branch name (used in stash message)
|
||||
* @returns true if stash was created
|
||||
* @returns Promise<void> — resolves on success, throws on failure
|
||||
*/
|
||||
export async function stashChanges(worktreePath: string, branchName: string): Promise<void> {
|
||||
const stashMessage = `automaker-pull-stash: Pre-pull stash on ${branchName}`;
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { createLogger, getErrorMessage } from '@automaker/utils';
|
||||
import { getConflictFiles } from '@automaker/git-utils';
|
||||
import { execGitCommand, getCurrentBranch } from '../lib/git.js';
|
||||
|
||||
@@ -50,7 +50,15 @@ export async function runRebase(worktreePath: string, ontoBranch: string): Promi
|
||||
}
|
||||
|
||||
// Get current branch name before rebase
|
||||
const currentBranch = await getCurrentBranch(worktreePath);
|
||||
let currentBranch: string;
|
||||
try {
|
||||
currentBranch = await getCurrentBranch(worktreePath);
|
||||
} catch (branchErr) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Failed to resolve current branch for worktree "${worktreePath}": ${getErrorMessage(branchErr)}`,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Pass ontoBranch after '--' so git treats it as a ref, not an option.
|
||||
|
||||
@@ -68,6 +68,14 @@ export async function stageFiles(
|
||||
const base = canonicalRoot + path.sep;
|
||||
const sanitizedFiles: string[] = [];
|
||||
for (const file of files) {
|
||||
// Reject empty or whitespace-only paths — path.resolve(canonicalRoot, '')
|
||||
// returns canonicalRoot itself, so without this guard an empty string would
|
||||
// pass all subsequent checks and be forwarded to git unchanged.
|
||||
if (file.trim() === '') {
|
||||
throw new StageFilesValidationError(
|
||||
'Invalid file path (empty or whitespace-only paths not allowed)'
|
||||
);
|
||||
}
|
||||
// Reject absolute paths
|
||||
if (path.isAbsolute(file)) {
|
||||
throw new StageFilesValidationError(
|
||||
|
||||
@@ -190,7 +190,12 @@ async function isRemoteBranch(cwd: string, branchName: string): Promise<boolean>
|
||||
.map((b) => b.trim().replace(/^['"]|['"]$/g, ''))
|
||||
.filter((b) => b);
|
||||
return remoteBranches.includes(branchName);
|
||||
} catch {
|
||||
} catch (err) {
|
||||
logger.error('isRemoteBranch: failed to list remote branches — returning false', {
|
||||
branchName,
|
||||
cwd,
|
||||
error: getErrorMessage(err),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user