mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-16 21:53: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_VERSION_FLAG = '--version';
|
||||||
const CODEX_CONFIG_FLAG = '--config';
|
const CODEX_CONFIG_FLAG = '--config';
|
||||||
const CODEX_ADD_DIR_FLAG = '--add-dir';
|
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_SKIP_GIT_REPO_CHECK_FLAG = '--skip-git-repo-check';
|
||||||
const CODEX_REASONING_EFFORT_KEY = 'reasoning_effort';
|
const CODEX_REASONING_EFFORT_KEY = 'reasoning_effort';
|
||||||
const CODEX_YOLO_FLAG = '--dangerously-bypass-approvals-and-sandbox';
|
const CODEX_YOLO_FLAG = '--dangerously-bypass-approvals-and-sandbox';
|
||||||
@@ -200,6 +201,12 @@ function isSdkEligible(options: ExecuteOptions): boolean {
|
|||||||
return isNoToolsRequested(options) && !hasMcpServersConfigured(options);
|
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> {
|
async function resolveCodexExecutionPlan(options: ExecuteOptions): Promise<CodexExecutionPlan> {
|
||||||
const cliPath = await findCodexCliPath();
|
const cliPath = await findCodexCliPath();
|
||||||
const authIndicators = await getCodexAuthIndicators();
|
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.
|
// No CLI-native auth — prefer SDK when an API key is available.
|
||||||
if (hasApiKey) {
|
// 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 {
|
return {
|
||||||
mode: CODEX_EXECUTION_MODE_SDK,
|
mode: CODEX_EXECUTION_MODE_SDK,
|
||||||
cliPath,
|
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 (sdkEligible) {
|
||||||
if (!cliAvailable) {
|
if (!cliAvailable) {
|
||||||
throw new Error(ERROR_CODEX_SDK_AUTH_REQUIRED);
|
throw new Error(ERROR_CODEX_SDK_AUTH_REQUIRED);
|
||||||
@@ -764,7 +783,7 @@ export class CodexProvider extends BaseProvider {
|
|||||||
}
|
}
|
||||||
const searchEnabled =
|
const searchEnabled =
|
||||||
codexSettings.enableWebSearch || resolveSearchEnabled(resolvedAllowedTools, restrictTools);
|
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 imageBlocks = codexSettings.enableImages ? extractImageBlocks(options.prompt) : [];
|
||||||
const imagePaths = await writeImageFiles(options.cwd, imageBlocks);
|
const imagePaths = await writeImageFiles(options.cwd, imageBlocks);
|
||||||
const approvalPolicy =
|
const approvalPolicy =
|
||||||
@@ -827,6 +846,9 @@ export class CodexProvider extends BaseProvider {
|
|||||||
...configOverrideArgs,
|
...configOverrideArgs,
|
||||||
'-', // Read prompt from stdin to avoid shell escaping issues
|
'-', // Read prompt from stdin to avoid shell escaping issues
|
||||||
];
|
];
|
||||||
|
if (schemaPath) {
|
||||||
|
args.push(CODEX_OUTPUT_SCHEMA_FLAG, schemaPath);
|
||||||
|
}
|
||||||
|
|
||||||
const envOverrides = buildEnv();
|
const envOverrides = buildEnv();
|
||||||
if (executionPlan.openAiApiKey && !envOverrides[OPENAI_API_KEY_ENV]) {
|
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')) {
|
} 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.`;
|
enhancedError = `${errorText}\n\nTip: Check that your OPENAI_API_KEY is set correctly or run 'codex login' to authenticate.`;
|
||||||
} else if (
|
} 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('do not have access') ||
|
||||||
errorLower.includes('model_not_found') ||
|
errorLower.includes('model_not_found') ||
|
||||||
errorLower.includes('invalid_model')
|
errorLower.includes('invalid_model')
|
||||||
) {
|
) {
|
||||||
enhancedError =
|
enhancedError =
|
||||||
`${errorText}\n\nTip: The model '${options.model}' may not be available on your OpenAI plan. ` +
|
`${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(', ')}. ` +
|
`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. ` +
|
`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.`;
|
|
||||||
} else if (
|
} else if (
|
||||||
errorLower.includes('stream disconnected') ||
|
errorLower.includes('stream disconnected') ||
|
||||||
errorLower.includes('stream ended') ||
|
errorLower.includes('stream ended') ||
|
||||||
|
|||||||
@@ -2,11 +2,9 @@
|
|||||||
* POST /stage-files endpoint - Stage or unstage files in the main project
|
* 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 type { Request, Response } from 'express';
|
||||||
import { getErrorMessage, logError } from '../common.js';
|
import { getErrorMessage, logError } from '../common.js';
|
||||||
import { execGitCommand } from '../../../lib/git.js';
|
import { stageFiles, StageFilesValidationError } from '../../../services/stage-files-service.js';
|
||||||
|
|
||||||
export function createStageFilesHandler() {
|
export function createStageFilesHandler() {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
@@ -51,67 +49,17 @@ export function createStageFilesHandler() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve the canonical (symlink-dereferenced) project path so that
|
const result = await stageFiles(projectPath, files, operation);
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
result: {
|
result,
|
||||||
operation,
|
|
||||||
filesCount: sanitizedFiles.length,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} 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`);
|
logError(error, `${(req.body as { operation?: string })?.operation ?? 'stage'} files failed`);
|
||||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* Common utilities for worktree routes
|
* 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 { exec } from 'child_process';
|
||||||
import { promisify } from 'util';
|
import { promisify } from 'util';
|
||||||
import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';
|
import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';
|
||||||
@@ -14,12 +14,9 @@ export { execGitCommand } from '../../lib/git.js';
|
|||||||
const logger = createLogger('Worktree');
|
const logger = createLogger('Worktree');
|
||||||
export const execAsync = promisify(exec);
|
export const execAsync = promisify(exec);
|
||||||
|
|
||||||
// ============================================================================
|
// Re-export git validation utilities from the canonical shared module so
|
||||||
// Constants
|
// existing consumers that import from this file continue to work.
|
||||||
// ============================================================================
|
export { isValidBranchName, MAX_BRANCH_NAME_LENGTH };
|
||||||
|
|
||||||
/** Maximum allowed length for git branch names */
|
|
||||||
export const MAX_BRANCH_NAME_LENGTH = 250;
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Extended PATH configuration for Electron apps
|
// Extended PATH configuration for Electron apps
|
||||||
@@ -63,22 +60,6 @@ export const execEnv = {
|
|||||||
PATH: extendedPath,
|
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.
|
* Validate git remote name to prevent command injection.
|
||||||
* Allowed characters: alphanumerics, hyphen, underscore, dot, and slash.
|
* 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 { createAbortOperationHandler } from './routes/abort-operation.js';
|
||||||
import { createContinueOperationHandler } from './routes/continue-operation.js';
|
import { createContinueOperationHandler } from './routes/continue-operation.js';
|
||||||
import { createStageFilesHandler } from './routes/stage-files.js';
|
import { createStageFilesHandler } from './routes/stage-files.js';
|
||||||
|
import { createCheckChangesHandler } from './routes/check-changes.js';
|
||||||
import type { SettingsService } from '../../services/settings-service.js';
|
import type { SettingsService } from '../../services/settings-service.js';
|
||||||
|
|
||||||
export function createWorktreeRoutes(
|
export function createWorktreeRoutes(
|
||||||
@@ -121,7 +122,13 @@ export function createWorktreeRoutes(
|
|||||||
'/checkout-branch',
|
'/checkout-branch',
|
||||||
validatePathParams('worktreePath'),
|
validatePathParams('worktreePath'),
|
||||||
requireValidWorktree,
|
requireValidWorktree,
|
||||||
createCheckoutBranchHandler()
|
createCheckoutBranchHandler(events)
|
||||||
|
);
|
||||||
|
router.post(
|
||||||
|
'/check-changes',
|
||||||
|
validatePathParams('worktreePath'),
|
||||||
|
requireGitRepoOnly,
|
||||||
|
createCheckChangesHandler()
|
||||||
);
|
);
|
||||||
router.post(
|
router.post(
|
||||||
'/list-branches',
|
'/list-branches',
|
||||||
|
|||||||
@@ -16,31 +16,7 @@ import type { Request, Response } from 'express';
|
|||||||
import type { EventEmitter } from '../../../lib/events.js';
|
import type { EventEmitter } from '../../../lib/events.js';
|
||||||
import { getErrorMessage, logError } from '../common.js';
|
import { getErrorMessage, logError } from '../common.js';
|
||||||
import { getBranchCommitLog } from '../../../services/branch-commit-log-service.js';
|
import { getBranchCommitLog } from '../../../services/branch-commit-log-service.js';
|
||||||
|
import { isValidBranchName } from '@automaker/utils';
|
||||||
/**
|
|
||||||
* 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createBranchCommitLogHandler(events: EventEmitter) {
|
export function createBranchCommitLogHandler(events: EventEmitter) {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
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
|
* 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
|
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
|
||||||
* the requireValidWorktree middleware in index.ts.
|
* the requireValidWorktree middleware in index.ts.
|
||||||
* Path validation (ALLOWED_ROOT_DIRECTORY) is handled by validatePathParams
|
* Path validation (ALLOWED_ROOT_DIRECTORY) is handled by validatePathParams
|
||||||
@@ -12,14 +20,20 @@ import path from 'path';
|
|||||||
import { stat } from 'fs/promises';
|
import { stat } from 'fs/promises';
|
||||||
import { getErrorMessage, logError, isValidBranchName } from '../common.js';
|
import { getErrorMessage, logError, isValidBranchName } from '../common.js';
|
||||||
import { execGitCommand } from '../../../lib/git.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> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const { worktreePath, branchName, baseBranch } = req.body as {
|
const { worktreePath, branchName, baseBranch, stashChanges, includeUntracked } = req.body as {
|
||||||
worktreePath: string;
|
worktreePath: string;
|
||||||
branchName: 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) {
|
if (!worktreePath) {
|
||||||
@@ -59,8 +73,6 @@ export function createCheckoutBranchHandler() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Resolve and validate worktreePath to prevent traversal attacks.
|
// 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);
|
const resolvedPath = path.resolve(worktreePath);
|
||||||
try {
|
try {
|
||||||
const stats = await stat(resolvedPath);
|
const stats = await stat(resolvedPath);
|
||||||
@@ -79,7 +91,42 @@ export function createCheckoutBranchHandler() {
|
|||||||
return;
|
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(
|
const currentBranchOutput = await execGitCommand(
|
||||||
['rev-parse', '--abbrev-ref', 'HEAD'],
|
['rev-parse', '--abbrev-ref', 'HEAD'],
|
||||||
resolvedPath
|
resolvedPath
|
||||||
@@ -89,7 +136,6 @@ export function createCheckoutBranchHandler() {
|
|||||||
// Check if branch already exists
|
// Check if branch already exists
|
||||||
try {
|
try {
|
||||||
await execGitCommand(['rev-parse', '--verify', branchName], resolvedPath);
|
await execGitCommand(['rev-parse', '--verify', branchName], resolvedPath);
|
||||||
// Branch exists
|
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: `Branch '${branchName}' already exists`,
|
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)
|
// Create and checkout the new branch
|
||||||
// If baseBranch is provided, create the branch from that starting point
|
|
||||||
const checkoutArgs = ['checkout', '-b', branchName];
|
const checkoutArgs = ['checkout', '-b', branchName];
|
||||||
if (baseBranch) {
|
if (baseBranch) {
|
||||||
checkoutArgs.push(baseBranch);
|
checkoutArgs.push(baseBranch);
|
||||||
@@ -129,8 +174,24 @@ export function createCheckoutBranchHandler() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
events?.emit('switch:error', {
|
||||||
|
error: getErrorMessage(error),
|
||||||
|
});
|
||||||
|
|
||||||
logError(error, 'Checkout branch failed');
|
logError(error, 'Checkout branch failed');
|
||||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
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 * as secureFs from '../../lib/secure-fs.js';
|
||||||
import { validateWorkingDirectory } from '../../lib/sdk-options.js';
|
import { validateWorkingDirectory } from '../../lib/sdk-options.js';
|
||||||
import { getPromptCustomization, getProviderByModelId } from '../../lib/settings-helpers.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 { TypedEventBus } from '../typed-event-bus.js';
|
||||||
import { ConcurrencyManager } from '../concurrency-manager.js';
|
import { ConcurrencyManager } from '../concurrency-manager.js';
|
||||||
import { WorktreeResolver } from '../worktree-resolver.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.
|
* 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 { type EventEmitter } from '../lib/events.js';
|
||||||
import { execGitCommand } from '../lib/git.js';
|
import { execGitCommand } from '../lib/git.js';
|
||||||
const logger = createLogger('MergeService');
|
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.
|
* Perform a git merge operation directly without HTTP.
|
||||||
*
|
*
|
||||||
@@ -111,30 +98,76 @@ export async function performMerge(
|
|||||||
: ['merge', branchName, '-m', mergeMessage];
|
: ['merge', branchName, '-m', mergeMessage];
|
||||||
|
|
||||||
try {
|
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) {
|
} 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 err = mergeError as { stdout?: string; stderr?: string; message?: string };
|
||||||
const output = `${err.stdout || ''} ${err.stderr || ''} ${err.message || ''}`;
|
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) {
|
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
|
// Emit merge:conflict event with conflict details
|
||||||
emitter?.emit('merge:conflict', { branchName, targetBranch: mergeTo, conflictFiles });
|
emitter?.emit('merge:conflict', { branchName, targetBranch: mergeTo, conflictFiles });
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
|
|
||||||
import { createLogger, getErrorMessage } from '@automaker/utils';
|
import { createLogger, getErrorMessage } from '@automaker/utils';
|
||||||
import { getConflictFiles } from '@automaker/git-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');
|
const logger = createLogger('PullService');
|
||||||
|
|
||||||
@@ -52,17 +52,6 @@ export interface PullResult {
|
|||||||
// Helper Functions
|
// 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.
|
* Fetch the latest refs from a remote.
|
||||||
*
|
*
|
||||||
@@ -102,7 +91,7 @@ export async function getLocalChanges(
|
|||||||
*
|
*
|
||||||
* @param worktreePath - Path to the git worktree
|
* @param worktreePath - Path to the git worktree
|
||||||
* @param branchName - Current branch name (used in stash message)
|
* @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> {
|
export async function stashChanges(worktreePath: string, branchName: string): Promise<void> {
|
||||||
const stashMessage = `automaker-pull-stash: Pre-pull stash on ${branchName}`;
|
const stashMessage = `automaker-pull-stash: Pre-pull stash on ${branchName}`;
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
import fs from 'fs/promises';
|
import fs from 'fs/promises';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { createLogger } from '@automaker/utils';
|
import { createLogger, getErrorMessage } from '@automaker/utils';
|
||||||
import { getConflictFiles } from '@automaker/git-utils';
|
import { getConflictFiles } from '@automaker/git-utils';
|
||||||
import { execGitCommand, getCurrentBranch } from '../lib/git.js';
|
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
|
// 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 {
|
try {
|
||||||
// Pass ontoBranch after '--' so git treats it as a ref, not an option.
|
// 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 base = canonicalRoot + path.sep;
|
||||||
const sanitizedFiles: string[] = [];
|
const sanitizedFiles: string[] = [];
|
||||||
for (const file of files) {
|
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
|
// Reject absolute paths
|
||||||
if (path.isAbsolute(file)) {
|
if (path.isAbsolute(file)) {
|
||||||
throw new StageFilesValidationError(
|
throw new StageFilesValidationError(
|
||||||
|
|||||||
@@ -190,7 +190,12 @@ async function isRemoteBranch(cwd: string, branchName: string): Promise<boolean>
|
|||||||
.map((b) => b.trim().replace(/^['"]|['"]$/g, ''))
|
.map((b) => b.trim().replace(/^['"]|['"]$/g, ''))
|
||||||
.filter((b) => b);
|
.filter((b) => b);
|
||||||
return remoteBranches.includes(branchName);
|
return remoteBranches.includes(branchName);
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
logger.error('isRemoteBranch: failed to list remote branches — returning false', {
|
||||||
|
branchName,
|
||||||
|
cwd,
|
||||||
|
error: getErrorMessage(err),
|
||||||
|
});
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -534,8 +534,14 @@ export function GitDiffPanel({
|
|||||||
onStart: () => void,
|
onStart: () => void,
|
||||||
onFinally: () => void
|
onFinally: () => void
|
||||||
) => {
|
) => {
|
||||||
if (!worktreePath && !projectPath) return;
|
|
||||||
onStart();
|
onStart();
|
||||||
|
if (!worktreePath && !projectPath) {
|
||||||
|
toast.error(failurePrefix, {
|
||||||
|
description: 'No project or worktree path configured',
|
||||||
|
});
|
||||||
|
onFinally();
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const api = getElectronAPI();
|
const api = getElectronAPI();
|
||||||
let result: { success: boolean; error?: string } | undefined;
|
let result: { success: boolean; error?: string } | undefined;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useMemo } from 'react';
|
import { useState, useEffect, useRef, useMemo } from 'react';
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
@@ -55,11 +55,6 @@ function formatResetTime(unixTimestamp: number, isMilliseconds = false): string
|
|||||||
return `Resets ${date.toLocaleDateString()} at ${date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`;
|
return `Resets ${date.toLocaleDateString()} at ${date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Legacy alias for Codex
|
|
||||||
function formatCodexResetTime(unixTimestamp: number): string {
|
|
||||||
return formatResetTime(unixTimestamp, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper to format window duration for Codex
|
// Helper to format window duration for Codex
|
||||||
function getCodexWindowLabel(durationMins: number): { title: string; subtitle: string } {
|
function getCodexWindowLabel(durationMins: number): { title: string; subtitle: string } {
|
||||||
if (durationMins < 60) {
|
if (durationMins < 60) {
|
||||||
@@ -95,6 +90,8 @@ export function UsagePopover() {
|
|||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [activeTab, setActiveTab] = useState<'claude' | 'codex' | 'zai' | 'gemini'>('claude');
|
const [activeTab, setActiveTab] = useState<'claude' | 'codex' | 'zai' | 'gemini'>('claude');
|
||||||
|
// Track whether the user has manually selected a tab so we don't override their choice
|
||||||
|
const userHasSelected = useRef(false);
|
||||||
|
|
||||||
// Check authentication status
|
// Check authentication status
|
||||||
const isClaudeAuthenticated = !!claudeAuthStatus?.authenticated;
|
const isClaudeAuthenticated = !!claudeAuthStatus?.authenticated;
|
||||||
@@ -190,8 +187,24 @@ export function UsagePopover() {
|
|||||||
return { code: ERROR_CODES.AUTH_ERROR, message };
|
return { code: ERROR_CODES.AUTH_ERROR, message };
|
||||||
}, [geminiQueryError]);
|
}, [geminiQueryError]);
|
||||||
|
|
||||||
// Determine which tab to show by default
|
// Determine which tab to show by default.
|
||||||
|
// Only apply the default when the popover opens (open transitions to true) and the user has
|
||||||
|
// not yet made a manual selection during this session. This prevents auth-flag re-renders from
|
||||||
|
// overriding a tab the user explicitly clicked.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
// Reset the user-selection guard each time the popover closes so the next open always gets
|
||||||
|
// a fresh default.
|
||||||
|
userHasSelected.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The user already picked a tab – respect their choice.
|
||||||
|
if (userHasSelected.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pick the first available provider in priority order.
|
||||||
if (isClaudeAuthenticated) {
|
if (isClaudeAuthenticated) {
|
||||||
setActiveTab('claude');
|
setActiveTab('claude');
|
||||||
} else if (isCodexAuthenticated) {
|
} else if (isCodexAuthenticated) {
|
||||||
@@ -201,7 +214,13 @@ export function UsagePopover() {
|
|||||||
} else if (isGeminiAuthenticated) {
|
} else if (isGeminiAuthenticated) {
|
||||||
setActiveTab('gemini');
|
setActiveTab('gemini');
|
||||||
}
|
}
|
||||||
}, [isClaudeAuthenticated, isCodexAuthenticated, isZaiAuthenticated, isGeminiAuthenticated]);
|
}, [
|
||||||
|
open,
|
||||||
|
isClaudeAuthenticated,
|
||||||
|
isCodexAuthenticated,
|
||||||
|
isZaiAuthenticated,
|
||||||
|
isGeminiAuthenticated,
|
||||||
|
]);
|
||||||
|
|
||||||
// Check if data is stale (older than 2 minutes)
|
// Check if data is stale (older than 2 minutes)
|
||||||
const isClaudeStale = useMemo(() => {
|
const isClaudeStale = useMemo(() => {
|
||||||
@@ -463,7 +482,10 @@ export function UsagePopover() {
|
|||||||
>
|
>
|
||||||
<Tabs
|
<Tabs
|
||||||
value={activeTab}
|
value={activeTab}
|
||||||
onValueChange={(v) => setActiveTab(v as 'claude' | 'codex' | 'zai' | 'gemini')}
|
onValueChange={(v) => {
|
||||||
|
userHasSelected.current = true;
|
||||||
|
setActiveTab(v as 'claude' | 'codex' | 'zai' | 'gemini');
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{/* Tabs Header */}
|
{/* Tabs Header */}
|
||||||
{tabCount > 1 && (
|
{tabCount > 1 && (
|
||||||
@@ -684,7 +706,7 @@ export function UsagePopover() {
|
|||||||
.subtitle
|
.subtitle
|
||||||
}
|
}
|
||||||
percentage={codexUsage.rateLimits.primary.usedPercent}
|
percentage={codexUsage.rateLimits.primary.usedPercent}
|
||||||
resetText={formatCodexResetTime(codexUsage.rateLimits.primary.resetsAt)}
|
resetText={formatResetTime(codexUsage.rateLimits.primary.resetsAt)}
|
||||||
isPrimary={true}
|
isPrimary={true}
|
||||||
stale={isCodexStale}
|
stale={isCodexStale}
|
||||||
pacePercentage={getExpectedCodexPacePercentage(
|
pacePercentage={getExpectedCodexPacePercentage(
|
||||||
@@ -705,7 +727,7 @@ export function UsagePopover() {
|
|||||||
.subtitle
|
.subtitle
|
||||||
}
|
}
|
||||||
percentage={codexUsage.rateLimits.secondary.usedPercent}
|
percentage={codexUsage.rateLimits.secondary.usedPercent}
|
||||||
resetText={formatCodexResetTime(codexUsage.rateLimits.secondary.resetsAt)}
|
resetText={formatResetTime(codexUsage.rateLimits.secondary.resetsAt)}
|
||||||
stale={isCodexStale}
|
stale={isCodexStale}
|
||||||
pacePercentage={getExpectedCodexPacePercentage(
|
pacePercentage={getExpectedCodexPacePercentage(
|
||||||
codexUsage.rateLimits.secondary.resetsAt,
|
codexUsage.rateLimits.secondary.resetsAt,
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ import { toast } from 'sonner';
|
|||||||
import { Check, ChevronsUpDown, GitBranchPlus, Globe, RefreshCw } from 'lucide-react';
|
import { Check, ChevronsUpDown, GitBranchPlus, Globe, RefreshCw } from 'lucide-react';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import {
|
||||||
|
StashConfirmDialog,
|
||||||
|
type UncommittedChangesInfo,
|
||||||
|
type StashConfirmAction,
|
||||||
|
} from './stash-confirm-dialog';
|
||||||
|
|
||||||
interface WorktreeInfo {
|
interface WorktreeInfo {
|
||||||
path: string;
|
path: string;
|
||||||
@@ -67,6 +72,17 @@ export function CreateBranchDialog({
|
|||||||
const baseBranchTriggerRef = useRef<HTMLButtonElement>(null);
|
const baseBranchTriggerRef = useRef<HTMLButtonElement>(null);
|
||||||
const [baseBranchTriggerWidth, setBaseBranchTriggerWidth] = useState<number>(0);
|
const [baseBranchTriggerWidth, setBaseBranchTriggerWidth] = useState<number>(0);
|
||||||
|
|
||||||
|
// Stash confirmation state
|
||||||
|
const [showStashConfirm, setShowStashConfirm] = useState(false);
|
||||||
|
const [uncommittedChanges, setUncommittedChanges] = useState<UncommittedChangesInfo | null>(null);
|
||||||
|
|
||||||
|
// Keep a ref in sync with baseBranch so fetchBranches can read the latest value
|
||||||
|
// without needing it in its dependency array (which would cause re-fetch loops)
|
||||||
|
const baseBranchRef = useRef<string>(baseBranch);
|
||||||
|
useEffect(() => {
|
||||||
|
baseBranchRef.current = baseBranch;
|
||||||
|
}, [baseBranch]);
|
||||||
|
|
||||||
const fetchBranches = useCallback(async () => {
|
const fetchBranches = useCallback(async () => {
|
||||||
if (!worktree) return;
|
if (!worktree) return;
|
||||||
|
|
||||||
@@ -81,7 +97,8 @@ export function CreateBranchDialog({
|
|||||||
// Only set the default base branch if no branch is currently selected,
|
// Only set the default base branch if no branch is currently selected,
|
||||||
// or if the currently selected branch is no longer present in the fetched list
|
// or if the currently selected branch is no longer present in the fetched list
|
||||||
const branchNames = result.result.branches.map((b: BranchInfo) => b.name);
|
const branchNames = result.result.branches.map((b: BranchInfo) => b.name);
|
||||||
if (!baseBranch || !branchNames.includes(baseBranch)) {
|
const currentBaseBranch = baseBranchRef.current;
|
||||||
|
if (!currentBaseBranch || !branchNames.includes(currentBaseBranch)) {
|
||||||
if (result.result.currentBranch) {
|
if (result.result.currentBranch) {
|
||||||
setBaseBranch(result.result.currentBranch);
|
setBaseBranch(result.result.currentBranch);
|
||||||
}
|
}
|
||||||
@@ -92,7 +109,7 @@ export function CreateBranchDialog({
|
|||||||
} finally {
|
} finally {
|
||||||
setIsLoadingBranches(false);
|
setIsLoadingBranches(false);
|
||||||
}
|
}
|
||||||
}, [worktree, baseBranch]);
|
}, [worktree]);
|
||||||
|
|
||||||
// Reset state and fetch branches when dialog opens
|
// Reset state and fetch branches when dialog opens
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -102,6 +119,8 @@ export function CreateBranchDialog({
|
|||||||
setError(null);
|
setError(null);
|
||||||
setBranches([]);
|
setBranches([]);
|
||||||
setBaseBranchPopoverOpen(false);
|
setBaseBranchPopoverOpen(false);
|
||||||
|
setShowStashConfirm(false);
|
||||||
|
setUncommittedChanges(null);
|
||||||
fetchBranches();
|
fetchBranches();
|
||||||
}
|
}
|
||||||
}, [open, fetchBranches]);
|
}, [open, fetchBranches]);
|
||||||
@@ -118,6 +137,65 @@ export function CreateBranchDialog({
|
|||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, [baseBranchPopoverOpen]);
|
}, [baseBranchPopoverOpen]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the actual branch creation, optionally with stash handling
|
||||||
|
*/
|
||||||
|
const doCreate = useCallback(
|
||||||
|
async (stashChanges: boolean) => {
|
||||||
|
if (!worktree || !branchName.trim()) return;
|
||||||
|
|
||||||
|
setIsCreating(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (!api?.worktree?.checkoutBranch) {
|
||||||
|
toast.error('Branch API not available');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedBase = baseBranch || undefined;
|
||||||
|
const result = await api.worktree.checkoutBranch(
|
||||||
|
worktree.path,
|
||||||
|
branchName.trim(),
|
||||||
|
selectedBase,
|
||||||
|
stashChanges,
|
||||||
|
true // includeUntracked
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.success && result.result) {
|
||||||
|
// Check if there were conflicts from stash reapply
|
||||||
|
if (result.result.hasConflicts) {
|
||||||
|
toast.warning('Branch created with conflicts', {
|
||||||
|
description: result.result.message,
|
||||||
|
duration: 8000,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const desc = result.result.stashedChanges
|
||||||
|
? 'Local changes were stashed and reapplied'
|
||||||
|
: undefined;
|
||||||
|
toast.success(result.result.message, { description: desc });
|
||||||
|
}
|
||||||
|
onCreated();
|
||||||
|
onOpenChange(false);
|
||||||
|
} else {
|
||||||
|
setError(result.error || 'Failed to create branch');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Create branch failed:', err);
|
||||||
|
setError('Failed to create branch');
|
||||||
|
} finally {
|
||||||
|
setIsCreating(false);
|
||||||
|
setShowStashConfirm(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[worktree, branchName, baseBranch, onCreated, onOpenChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle the initial "Create Branch" click.
|
||||||
|
* Checks for uncommitted changes first and shows confirmation if needed.
|
||||||
|
*/
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
if (!worktree || !branchName.trim()) return;
|
if (!worktree || !branchName.trim()) return;
|
||||||
|
|
||||||
@@ -128,39 +206,53 @@ export function CreateBranchDialog({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsCreating(true);
|
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
|
// Check for uncommitted changes before proceeding
|
||||||
try {
|
try {
|
||||||
const api = getElectronAPI();
|
const api = getHttpApiClient();
|
||||||
if (!api?.worktree?.checkoutBranch) {
|
const changesResult = await api.worktree.checkChanges(worktree.path);
|
||||||
toast.error('Branch API not available');
|
|
||||||
|
if (changesResult.success && changesResult.result?.hasChanges) {
|
||||||
|
// Show the stash confirmation dialog
|
||||||
|
setUncommittedChanges({
|
||||||
|
staged: changesResult.result.staged,
|
||||||
|
unstaged: changesResult.result.unstaged,
|
||||||
|
untracked: changesResult.result.untracked,
|
||||||
|
totalFiles: changesResult.result.totalFiles,
|
||||||
|
});
|
||||||
|
setShowStashConfirm(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pass baseBranch if user selected one different from the current branch
|
|
||||||
const selectedBase = baseBranch || undefined;
|
|
||||||
const result = await api.worktree.checkoutBranch(
|
|
||||||
worktree.path,
|
|
||||||
branchName.trim(),
|
|
||||||
selectedBase
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result.success && result.result) {
|
|
||||||
toast.success(result.result.message);
|
|
||||||
onCreated();
|
|
||||||
onOpenChange(false);
|
|
||||||
} else {
|
|
||||||
setError(result.error || 'Failed to create branch');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('Create branch failed:', err);
|
// If we can't check for changes, proceed without stashing
|
||||||
setError('Failed to create branch');
|
logger.warn('Failed to check for uncommitted changes, proceeding without stash:', err);
|
||||||
} finally {
|
|
||||||
setIsCreating(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// No changes detected, proceed directly
|
||||||
|
doCreate(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle the user's decision in the stash confirmation dialog
|
||||||
|
*/
|
||||||
|
const handleStashConfirmAction = useCallback(
|
||||||
|
(action: StashConfirmAction) => {
|
||||||
|
switch (action) {
|
||||||
|
case 'stash-and-proceed':
|
||||||
|
doCreate(true);
|
||||||
|
break;
|
||||||
|
case 'proceed-without-stash':
|
||||||
|
doCreate(false);
|
||||||
|
break;
|
||||||
|
case 'cancel':
|
||||||
|
setShowStashConfirm(false);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[doCreate]
|
||||||
|
);
|
||||||
|
|
||||||
// Separate local and remote branches
|
// Separate local and remote branches
|
||||||
const localBranches = useMemo(() => branches.filter((b) => !b.isRemote), [branches]);
|
const localBranches = useMemo(() => branches.filter((b) => !b.isRemote), [branches]);
|
||||||
const remoteBranches = useMemo(() => branches.filter((b) => b.isRemote), [branches]);
|
const remoteBranches = useMemo(() => branches.filter((b) => b.isRemote), [branches]);
|
||||||
@@ -174,124 +266,94 @@ export function CreateBranchDialog({
|
|||||||
}, [baseBranch, branches]);
|
}, [baseBranch, branches]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<>
|
||||||
<DialogContent className="sm:max-w-[425px]">
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogHeader>
|
<DialogContent className="sm:max-w-[425px]">
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogHeader>
|
||||||
<GitBranchPlus className="w-5 h-5" />
|
<DialogTitle className="flex items-center gap-2">
|
||||||
Create New Branch
|
<GitBranchPlus className="w-5 h-5" />
|
||||||
</DialogTitle>
|
Create New Branch
|
||||||
<DialogDescription>Create a new branch from a base branch</DialogDescription>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
<DialogDescription>Create a new branch from a base branch</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="grid gap-4 py-4">
|
<div className="grid gap-4 py-4">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="branch-name">Branch Name</Label>
|
<Label htmlFor="branch-name">Branch Name</Label>
|
||||||
<Input
|
<Input
|
||||||
id="branch-name"
|
id="branch-name"
|
||||||
placeholder="feature/my-new-feature"
|
placeholder="feature/my-new-feature"
|
||||||
value={branchName}
|
value={branchName}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setBranchName(e.target.value);
|
setBranchName(e.target.value);
|
||||||
setError(null);
|
setError(null);
|
||||||
}}
|
}}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter' && branchName.trim() && !isCreating) {
|
if (e.key === 'Enter' && branchName.trim() && !isCreating) {
|
||||||
handleCreate();
|
handleCreate();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
disabled={isCreating}
|
disabled={isCreating}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Label htmlFor="base-branch">Base Branch</Label>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={fetchBranches}
|
|
||||||
disabled={isLoadingBranches || isCreating}
|
|
||||||
className="h-6 px-2 text-xs"
|
|
||||||
>
|
|
||||||
{isLoadingBranches ? (
|
|
||||||
<Spinner size="xs" className="mr-1" />
|
|
||||||
) : (
|
|
||||||
<RefreshCw className="w-3 h-3 mr-1" />
|
|
||||||
)}
|
|
||||||
Refresh
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
{isLoadingBranches && branches.length === 0 ? (
|
|
||||||
<div className="flex items-center justify-center py-3 border rounded-md border-input">
|
<div className="grid gap-2">
|
||||||
<Spinner size="sm" className="mr-2" />
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm text-muted-foreground">Loading branches...</span>
|
<Label htmlFor="base-branch">Base Branch</Label>
|
||||||
</div>
|
<Button
|
||||||
) : (
|
variant="ghost"
|
||||||
<Popover open={baseBranchPopoverOpen} onOpenChange={setBaseBranchPopoverOpen}>
|
size="sm"
|
||||||
<PopoverTrigger asChild>
|
onClick={fetchBranches}
|
||||||
<Button
|
disabled={isLoadingBranches || isCreating}
|
||||||
id="base-branch"
|
className="h-6 px-2 text-xs"
|
||||||
ref={baseBranchTriggerRef}
|
|
||||||
variant="outline"
|
|
||||||
role="combobox"
|
|
||||||
aria-expanded={baseBranchPopoverOpen}
|
|
||||||
disabled={isCreating}
|
|
||||||
className="w-full justify-between font-normal"
|
|
||||||
>
|
|
||||||
<span className="truncate text-sm">
|
|
||||||
{baseBranchDisplayLabel ?? (
|
|
||||||
<span className="text-muted-foreground">Select base branch</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent
|
|
||||||
className="p-0"
|
|
||||||
style={{ width: Math.max(baseBranchTriggerWidth, 200) }}
|
|
||||||
onWheel={(e) => e.stopPropagation()}
|
|
||||||
onTouchMove={(e) => e.stopPropagation()}
|
|
||||||
>
|
>
|
||||||
<Command shouldFilter={true}>
|
{isLoadingBranches ? (
|
||||||
<CommandInput placeholder="Filter branches..." className="h-9" />
|
<Spinner size="xs" className="mr-1" />
|
||||||
<CommandList>
|
) : (
|
||||||
<CommandEmpty>No matching branches</CommandEmpty>
|
<RefreshCw className="w-3 h-3 mr-1" />
|
||||||
{localBranches.length > 0 && (
|
)}
|
||||||
<CommandGroup heading="Local Branches">
|
Refresh
|
||||||
{localBranches.map((branch) => (
|
</Button>
|
||||||
<CommandItem
|
</div>
|
||||||
key={branch.name}
|
{isLoadingBranches && branches.length === 0 ? (
|
||||||
value={branch.name}
|
<div className="flex items-center justify-center py-3 border rounded-md border-input">
|
||||||
onSelect={(value) => {
|
<Spinner size="sm" className="mr-2" />
|
||||||
setBaseBranch(value);
|
<span className="text-sm text-muted-foreground">Loading branches...</span>
|
||||||
setBaseBranchPopoverOpen(false);
|
</div>
|
||||||
}}
|
) : (
|
||||||
>
|
<Popover open={baseBranchPopoverOpen} onOpenChange={setBaseBranchPopoverOpen}>
|
||||||
<Check
|
<PopoverTrigger asChild>
|
||||||
className={cn(
|
<Button
|
||||||
'mr-2 h-4 w-4 shrink-0',
|
id="base-branch"
|
||||||
baseBranch === branch.name ? 'opacity-100' : 'opacity-0'
|
ref={baseBranchTriggerRef}
|
||||||
)}
|
variant="outline"
|
||||||
/>
|
role="combobox"
|
||||||
<span className={cn('truncate', branch.isCurrent && 'font-medium')}>
|
aria-expanded={baseBranchPopoverOpen}
|
||||||
{branch.name}
|
disabled={isCreating}
|
||||||
</span>
|
className="w-full justify-between font-normal"
|
||||||
{branch.isCurrent && (
|
>
|
||||||
<span className="ml-1.5 text-xs text-muted-foreground shrink-0">
|
<span className="truncate text-sm">
|
||||||
(current)
|
{baseBranchDisplayLabel ?? (
|
||||||
</span>
|
<span className="text-muted-foreground">Select base branch</span>
|
||||||
)}
|
)}
|
||||||
</CommandItem>
|
</span>
|
||||||
))}
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
</CommandGroup>
|
</Button>
|
||||||
)}
|
</PopoverTrigger>
|
||||||
{remoteBranches.length > 0 && (
|
<PopoverContent
|
||||||
<>
|
className="p-0"
|
||||||
{localBranches.length > 0 && <CommandSeparator />}
|
style={{ width: Math.max(baseBranchTriggerWidth, 200) }}
|
||||||
<CommandGroup heading="Remote Branches">
|
onWheel={(e) => e.stopPropagation()}
|
||||||
{remoteBranches.map((branch) => (
|
onTouchMove={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Command shouldFilter={true}>
|
||||||
|
<CommandInput placeholder="Filter branches..." className="h-9" />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>No matching branches</CommandEmpty>
|
||||||
|
{localBranches.length > 0 && (
|
||||||
|
<CommandGroup heading="Local Branches">
|
||||||
|
{localBranches.map((branch) => (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={branch.name}
|
key={branch.name}
|
||||||
value={branch.name}
|
value={branch.name}
|
||||||
@@ -306,39 +368,81 @@ export function CreateBranchDialog({
|
|||||||
baseBranch === branch.name ? 'opacity-100' : 'opacity-0'
|
baseBranch === branch.name ? 'opacity-100' : 'opacity-0'
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<Globe className="mr-1.5 h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
<span className={cn('truncate', branch.isCurrent && 'font-medium')}>
|
||||||
<span className="truncate">{branch.name}</span>
|
{branch.name}
|
||||||
|
</span>
|
||||||
|
{branch.isCurrent && (
|
||||||
|
<span className="ml-1.5 text-xs text-muted-foreground shrink-0">
|
||||||
|
(current)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
))}
|
))}
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
</>
|
)}
|
||||||
)}
|
{remoteBranches.length > 0 && (
|
||||||
</CommandList>
|
<>
|
||||||
</Command>
|
{localBranches.length > 0 && <CommandSeparator />}
|
||||||
</PopoverContent>
|
<CommandGroup heading="Remote Branches">
|
||||||
</Popover>
|
{remoteBranches.map((branch) => (
|
||||||
)}
|
<CommandItem
|
||||||
|
key={branch.name}
|
||||||
|
value={branch.name}
|
||||||
|
onSelect={(value) => {
|
||||||
|
setBaseBranch(value);
|
||||||
|
setBaseBranchPopoverOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
'mr-2 h-4 w-4 shrink-0',
|
||||||
|
baseBranch === branch.name ? 'opacity-100' : 'opacity-0'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Globe className="mr-1.5 h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
<span className="truncate">{branch.name}</span>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
<DialogFooter>
|
||||||
</div>
|
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isCreating}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleCreate} disabled={!branchName.trim() || isCreating}>
|
||||||
|
{isCreating ? (
|
||||||
|
<>
|
||||||
|
<Spinner size="sm" className="mr-2" />
|
||||||
|
Creating...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Create Branch'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
<DialogFooter>
|
{/* Stash confirmation dialog - shown when uncommitted changes are detected */}
|
||||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isCreating}>
|
<StashConfirmDialog
|
||||||
Cancel
|
open={showStashConfirm}
|
||||||
</Button>
|
onOpenChange={setShowStashConfirm}
|
||||||
<Button onClick={handleCreate} disabled={!branchName.trim() || isCreating}>
|
operationDescription={`create branch '${branchName.trim()}'`}
|
||||||
{isCreating ? (
|
changesInfo={uncommittedChanges}
|
||||||
<>
|
onConfirm={handleStashConfirmAction}
|
||||||
<Spinner size="sm" className="mr-2" />
|
isLoading={isCreating}
|
||||||
Creating...
|
/>
|
||||||
</>
|
</>
|
||||||
) : (
|
|
||||||
'Create Branch'
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,3 +29,8 @@ export {
|
|||||||
type BranchConflictData,
|
type BranchConflictData,
|
||||||
type BranchConflictType,
|
type BranchConflictType,
|
||||||
} from './branch-conflict-dialog';
|
} from './branch-conflict-dialog';
|
||||||
|
export {
|
||||||
|
StashConfirmDialog,
|
||||||
|
type UncommittedChangesInfo,
|
||||||
|
type StashConfirmAction,
|
||||||
|
} from './stash-confirm-dialog';
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
/**
|
||||||
|
* Dialog shown when uncommitted changes are detected before a branch operation.
|
||||||
|
* Presents the user with options to:
|
||||||
|
* 1. Stash and proceed - stash changes, perform the operation, then restore
|
||||||
|
* 2. Proceed without stashing - discard local changes and proceed
|
||||||
|
* 3. Cancel - abort the operation
|
||||||
|
*
|
||||||
|
* Displays a summary of affected files (staged, unstaged, untracked) so the
|
||||||
|
* user can make an informed decision.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { AlertTriangle, Archive, XCircle, FileEdit, FilePlus, FileQuestion } from 'lucide-react';
|
||||||
|
|
||||||
|
export interface UncommittedChangesInfo {
|
||||||
|
staged: string[];
|
||||||
|
unstaged: string[];
|
||||||
|
untracked: string[];
|
||||||
|
totalFiles: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StashConfirmAction = 'stash-and-proceed' | 'proceed-without-stash' | 'cancel';
|
||||||
|
|
||||||
|
interface StashConfirmDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
/** The branch operation being attempted (e.g., "switch to feature/xyz" or "create feature/xyz") */
|
||||||
|
operationDescription: string;
|
||||||
|
/** Summary of uncommitted changes */
|
||||||
|
changesInfo: UncommittedChangesInfo | null;
|
||||||
|
/** Called with the user's decision */
|
||||||
|
onConfirm: (action: StashConfirmAction) => void;
|
||||||
|
/** Whether the operation is currently in progress */
|
||||||
|
isLoading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StashConfirmDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
operationDescription,
|
||||||
|
changesInfo,
|
||||||
|
onConfirm,
|
||||||
|
isLoading = false,
|
||||||
|
}: StashConfirmDialogProps) {
|
||||||
|
const handleStashAndProceed = useCallback(() => {
|
||||||
|
onConfirm('stash-and-proceed');
|
||||||
|
}, [onConfirm]);
|
||||||
|
|
||||||
|
const handleProceedWithoutStash = useCallback(() => {
|
||||||
|
onConfirm('proceed-without-stash');
|
||||||
|
}, [onConfirm]);
|
||||||
|
|
||||||
|
const handleCancel = useCallback(() => {
|
||||||
|
onConfirm('cancel');
|
||||||
|
onOpenChange(false);
|
||||||
|
}, [onConfirm, onOpenChange]);
|
||||||
|
|
||||||
|
if (!changesInfo) return null;
|
||||||
|
|
||||||
|
const { staged, unstaged, untracked } = changesInfo;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(isOpen) => !isLoading && onOpenChange(isOpen)}>
|
||||||
|
<DialogContent className="sm:max-w-[520px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<AlertTriangle className="w-5 h-5 text-amber-500" />
|
||||||
|
Uncommitted Changes Detected
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription asChild>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<span className="block">
|
||||||
|
You have uncommitted changes that may be affected when you{' '}
|
||||||
|
<strong>{operationDescription}</strong>.
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* File summary */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{staged.length > 0 && (
|
||||||
|
<FileSection
|
||||||
|
icon={<FileEdit className="w-3.5 h-3.5 text-green-500" />}
|
||||||
|
label="Staged"
|
||||||
|
files={staged}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{unstaged.length > 0 && (
|
||||||
|
<FileSection
|
||||||
|
icon={<XCircle className="w-3.5 h-3.5 text-orange-500" />}
|
||||||
|
label="Unstaged"
|
||||||
|
files={unstaged}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{untracked.length > 0 && (
|
||||||
|
<FileSection
|
||||||
|
icon={<FilePlus className="w-3.5 h-3.5 text-blue-500" />}
|
||||||
|
label="Untracked"
|
||||||
|
files={untracked}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 p-3 rounded-md bg-muted/50 border border-border">
|
||||||
|
<p className="text-sm text-muted-foreground font-medium mb-2">
|
||||||
|
Choose how to proceed:
|
||||||
|
</p>
|
||||||
|
<ul className="text-sm text-muted-foreground list-disc list-inside space-y-1">
|
||||||
|
<li>
|
||||||
|
<strong>Stash & Proceed</strong> — Saves your changes, performs the
|
||||||
|
operation, then restores them
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Proceed Without Stashing</strong> — Carries your uncommitted
|
||||||
|
changes into the new branch as-is
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<DialogFooter className="flex-col sm:flex-row gap-2">
|
||||||
|
<Button variant="outline" onClick={handleCancel} disabled={isLoading}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" onClick={handleProceedWithoutStash} disabled={isLoading}>
|
||||||
|
<FileQuestion className="w-4 h-4 mr-2" />
|
||||||
|
Proceed Without Stashing
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleStashAndProceed} disabled={isLoading}>
|
||||||
|
<Archive className="w-4 h-4 mr-2" />
|
||||||
|
Stash & Proceed
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Renders a collapsible section of files with a category label */
|
||||||
|
function FileSection({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
files,
|
||||||
|
}: {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
label: string;
|
||||||
|
files: string[];
|
||||||
|
}) {
|
||||||
|
const maxDisplay = 5;
|
||||||
|
const displayFiles = files.slice(0, maxDisplay);
|
||||||
|
const remaining = files.length - maxDisplay;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<span className="flex items-center gap-1.5 text-sm font-medium text-foreground">
|
||||||
|
{icon}
|
||||||
|
{label} ({files.length})
|
||||||
|
</span>
|
||||||
|
<div className="border border-border rounded-lg overflow-hidden max-h-[120px] overflow-y-auto scrollbar-visible">
|
||||||
|
{displayFiles.map((file) => (
|
||||||
|
<div
|
||||||
|
key={file}
|
||||||
|
className="flex items-center px-3 py-1 text-xs font-mono border-b border-border last:border-b-0 hover:bg-accent/30"
|
||||||
|
>
|
||||||
|
<span className="truncate">{file}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{remaining > 0 && (
|
||||||
|
<div className="px-3 py-1 text-xs text-muted-foreground border-b border-border last:border-b-0">
|
||||||
|
...and {remaining} more {remaining === 1 ? 'file' : 'files'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { useState, useCallback } from 'react';
|
|||||||
import { useNavigate } from '@tanstack/react-router';
|
import { useNavigate } from '@tanstack/react-router';
|
||||||
import { createLogger } from '@automaker/utils/logger';
|
import { createLogger } from '@automaker/utils/logger';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
|
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import {
|
import {
|
||||||
useSwitchBranch,
|
useSwitchBranch,
|
||||||
@@ -10,9 +11,17 @@ import {
|
|||||||
useOpenInEditor,
|
useOpenInEditor,
|
||||||
} from '@/hooks/mutations';
|
} from '@/hooks/mutations';
|
||||||
import type { WorktreeInfo } from '../types';
|
import type { WorktreeInfo } from '../types';
|
||||||
|
import type { UncommittedChangesInfo } from '../../dialogs/stash-confirm-dialog';
|
||||||
|
|
||||||
const logger = createLogger('WorktreeActions');
|
const logger = createLogger('WorktreeActions');
|
||||||
|
|
||||||
|
/** Pending branch switch details, stored while awaiting user confirmation */
|
||||||
|
export interface PendingSwitchInfo {
|
||||||
|
worktree: WorktreeInfo;
|
||||||
|
branchName: string;
|
||||||
|
changesInfo: UncommittedChangesInfo;
|
||||||
|
}
|
||||||
|
|
||||||
interface UseWorktreeActionsOptions {
|
interface UseWorktreeActionsOptions {
|
||||||
/** Callback when merge conflicts occur after branch switch stash reapply */
|
/** Callback when merge conflicts occur after branch switch stash reapply */
|
||||||
onBranchSwitchConflict?: (info: {
|
onBranchSwitchConflict?: (info: {
|
||||||
@@ -32,6 +41,9 @@ export function useWorktreeActions(options?: UseWorktreeActionsOptions) {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [isActivating, setIsActivating] = useState(false);
|
const [isActivating, setIsActivating] = useState(false);
|
||||||
|
|
||||||
|
// Pending branch switch state (waiting for user stash decision)
|
||||||
|
const [pendingSwitch, setPendingSwitch] = useState<PendingSwitchInfo | null>(null);
|
||||||
|
|
||||||
// Use React Query mutations
|
// Use React Query mutations
|
||||||
const switchBranchMutation = useSwitchBranch({
|
const switchBranchMutation = useSwitchBranch({
|
||||||
onConflict: options?.onBranchSwitchConflict,
|
onConflict: options?.onBranchSwitchConflict,
|
||||||
@@ -41,9 +53,40 @@ export function useWorktreeActions(options?: UseWorktreeActionsOptions) {
|
|||||||
const pushMutation = usePushWorktree();
|
const pushMutation = usePushWorktree();
|
||||||
const openInEditorMutation = useOpenInEditor();
|
const openInEditorMutation = useOpenInEditor();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initiate a branch switch.
|
||||||
|
* First checks for uncommitted changes and, if found, stores the pending
|
||||||
|
* switch so the caller can show a confirmation dialog.
|
||||||
|
*/
|
||||||
const handleSwitchBranch = useCallback(
|
const handleSwitchBranch = useCallback(
|
||||||
async (worktree: WorktreeInfo, branchName: string) => {
|
async (worktree: WorktreeInfo, branchName: string) => {
|
||||||
if (switchBranchMutation.isPending || branchName === worktree.branch) return;
|
if (switchBranchMutation.isPending || branchName === worktree.branch) return;
|
||||||
|
|
||||||
|
// Check for uncommitted changes before switching
|
||||||
|
try {
|
||||||
|
const api = getHttpApiClient();
|
||||||
|
const changesResult = await api.worktree.checkChanges(worktree.path);
|
||||||
|
|
||||||
|
if (changesResult.success && changesResult.result?.hasChanges) {
|
||||||
|
// Store the pending switch and let the UI show the confirmation dialog
|
||||||
|
setPendingSwitch({
|
||||||
|
worktree,
|
||||||
|
branchName,
|
||||||
|
changesInfo: {
|
||||||
|
staged: changesResult.result.staged,
|
||||||
|
unstaged: changesResult.result.unstaged,
|
||||||
|
untracked: changesResult.result.untracked,
|
||||||
|
totalFiles: changesResult.result.totalFiles,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// If we can't check for changes, proceed with the switch (server will auto-stash)
|
||||||
|
logger.warn('Failed to check for uncommitted changes, proceeding with switch:', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
// No changes detected, proceed directly (server still handles stash as safety net)
|
||||||
switchBranchMutation.mutate({
|
switchBranchMutation.mutate({
|
||||||
worktreePath: worktree.path,
|
worktreePath: worktree.path,
|
||||||
branchName,
|
branchName,
|
||||||
@@ -52,6 +95,39 @@ export function useWorktreeActions(options?: UseWorktreeActionsOptions) {
|
|||||||
[switchBranchMutation]
|
[switchBranchMutation]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirm the pending branch switch after the user chooses an action.
|
||||||
|
* The server-side performSwitchBranch always auto-stashes when there are changes,
|
||||||
|
* so when the user chooses "proceed without stashing" we still switch (the server
|
||||||
|
* detects and stashes automatically). When "cancel", we just clear the pending state.
|
||||||
|
*/
|
||||||
|
const confirmPendingSwitch = useCallback(
|
||||||
|
(action: 'stash-and-proceed' | 'proceed-without-stash' | 'cancel') => {
|
||||||
|
if (!pendingSwitch) return;
|
||||||
|
|
||||||
|
if (action === 'cancel') {
|
||||||
|
setPendingSwitch(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both 'stash-and-proceed' and 'proceed-without-stash' trigger the switch.
|
||||||
|
// The server-side performSwitchBranch handles the stash/pop cycle automatically.
|
||||||
|
// 'proceed-without-stash' means the user is OK with the server's auto-stash behavior.
|
||||||
|
switchBranchMutation.mutate({
|
||||||
|
worktreePath: pendingSwitch.worktree.path,
|
||||||
|
branchName: pendingSwitch.branchName,
|
||||||
|
});
|
||||||
|
|
||||||
|
setPendingSwitch(null);
|
||||||
|
},
|
||||||
|
[pendingSwitch, switchBranchMutation]
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Clear the pending switch without performing any action */
|
||||||
|
const cancelPendingSwitch = useCallback(() => {
|
||||||
|
setPendingSwitch(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handlePull = useCallback(
|
const handlePull = useCallback(
|
||||||
async (worktree: WorktreeInfo, remote?: string) => {
|
async (worktree: WorktreeInfo, remote?: string) => {
|
||||||
if (pullMutation.isPending) return;
|
if (pullMutation.isPending) return;
|
||||||
@@ -130,5 +206,9 @@ export function useWorktreeActions(options?: UseWorktreeActionsOptions) {
|
|||||||
handleOpenInIntegratedTerminal,
|
handleOpenInIntegratedTerminal,
|
||||||
handleOpenInEditor,
|
handleOpenInEditor,
|
||||||
handleOpenInExternalTerminal,
|
handleOpenInExternalTerminal,
|
||||||
|
// Stash confirmation state for branch switching
|
||||||
|
pendingSwitch,
|
||||||
|
confirmPendingSwitch,
|
||||||
|
cancelPendingSwitch,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import {
|
|||||||
CherryPickDialog,
|
CherryPickDialog,
|
||||||
GitPullDialog,
|
GitPullDialog,
|
||||||
} from '../dialogs';
|
} from '../dialogs';
|
||||||
|
import { StashConfirmDialog } from '../dialogs/stash-confirm-dialog';
|
||||||
import type { SelectRemoteOperation } from '../dialogs';
|
import type { SelectRemoteOperation } from '../dialogs';
|
||||||
import { TestLogsPanel } from '@/components/ui/test-logs-panel';
|
import { TestLogsPanel } from '@/components/ui/test-logs-panel';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
@@ -114,6 +115,9 @@ export function WorktreePanel({
|
|||||||
handleOpenInIntegratedTerminal,
|
handleOpenInIntegratedTerminal,
|
||||||
handleOpenInEditor,
|
handleOpenInEditor,
|
||||||
handleOpenInExternalTerminal,
|
handleOpenInExternalTerminal,
|
||||||
|
pendingSwitch,
|
||||||
|
confirmPendingSwitch,
|
||||||
|
cancelPendingSwitch,
|
||||||
} = useWorktreeActions({
|
} = useWorktreeActions({
|
||||||
onBranchSwitchConflict: onBranchSwitchConflict,
|
onBranchSwitchConflict: onBranchSwitchConflict,
|
||||||
onStashPopConflict: onStashPopConflict,
|
onStashPopConflict: onStashPopConflict,
|
||||||
@@ -880,6 +884,20 @@ export function WorktreePanel({
|
|||||||
onStashed={handleStashCompleted}
|
onStashed={handleStashCompleted}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Stash Confirm Dialog for Branch Switching */}
|
||||||
|
<StashConfirmDialog
|
||||||
|
open={!!pendingSwitch}
|
||||||
|
onOpenChange={(isOpen) => {
|
||||||
|
if (!isOpen) cancelPendingSwitch();
|
||||||
|
}}
|
||||||
|
operationDescription={
|
||||||
|
pendingSwitch ? `switch to branch '${pendingSwitch.branchName}'` : ''
|
||||||
|
}
|
||||||
|
changesInfo={pendingSwitch?.changesInfo ?? null}
|
||||||
|
onConfirm={confirmPendingSwitch}
|
||||||
|
isLoading={isSwitching}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* View Stashes Dialog */}
|
{/* View Stashes Dialog */}
|
||||||
<ViewStashesDialog
|
<ViewStashesDialog
|
||||||
open={viewStashesDialogOpen}
|
open={viewStashesDialogOpen}
|
||||||
@@ -1328,6 +1346,18 @@ export function WorktreePanel({
|
|||||||
onStashed={handleStashCompleted}
|
onStashed={handleStashCompleted}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Stash Confirm Dialog for Branch Switching */}
|
||||||
|
<StashConfirmDialog
|
||||||
|
open={!!pendingSwitch}
|
||||||
|
onOpenChange={(isOpen) => {
|
||||||
|
if (!isOpen) cancelPendingSwitch();
|
||||||
|
}}
|
||||||
|
operationDescription={pendingSwitch ? `switch to branch '${pendingSwitch.branchName}'` : ''}
|
||||||
|
changesInfo={pendingSwitch?.changesInfo ?? null}
|
||||||
|
onConfirm={confirmPendingSwitch}
|
||||||
|
isLoading={isSwitching}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* View Stashes Dialog */}
|
{/* View Stashes Dialog */}
|
||||||
<ViewStashesDialog
|
<ViewStashesDialog
|
||||||
open={viewStashesDialogOpen}
|
open={viewStashesDialogOpen}
|
||||||
|
|||||||
@@ -447,35 +447,143 @@ export function useSwitchBranch(options?: {
|
|||||||
/**
|
/**
|
||||||
* Checkout a new branch
|
* Checkout a new branch
|
||||||
*
|
*
|
||||||
|
* Supports automatic stash handling. When stashChanges is true in the mutation
|
||||||
|
* variables, local changes are stashed before creating the branch and reapplied
|
||||||
|
* after. If the reapply causes merge conflicts, the onConflict callback is called.
|
||||||
|
*
|
||||||
|
* If the checkout itself fails and the stash-pop used to restore changes also
|
||||||
|
* produces conflicts, the onStashPopConflict callback is called.
|
||||||
|
*
|
||||||
|
* @param options.onConflict - Callback when merge conflicts occur after stash reapply
|
||||||
|
* @param options.onStashPopConflict - Callback when checkout fails AND stash-pop restoration has conflicts
|
||||||
* @returns Mutation for creating and checking out a new branch
|
* @returns Mutation for creating and checking out a new branch
|
||||||
*/
|
*/
|
||||||
export function useCheckoutBranch() {
|
export function useCheckoutBranch(options?: {
|
||||||
|
onConflict?: (info: { worktreePath: string; branchName: string; previousBranch: string }) => void;
|
||||||
|
onStashPopConflict?: (info: {
|
||||||
|
worktreePath: string;
|
||||||
|
branchName: string;
|
||||||
|
stashPopConflictMessage: string;
|
||||||
|
}) => void;
|
||||||
|
}) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async ({
|
mutationFn: async ({
|
||||||
worktreePath,
|
worktreePath,
|
||||||
branchName,
|
branchName,
|
||||||
|
baseBranch,
|
||||||
|
stashChanges,
|
||||||
|
includeUntracked,
|
||||||
}: {
|
}: {
|
||||||
worktreePath: string;
|
worktreePath: string;
|
||||||
branchName: string;
|
branchName: string;
|
||||||
|
baseBranch?: string;
|
||||||
|
stashChanges?: boolean;
|
||||||
|
includeUntracked?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const api = getElectronAPI();
|
const api = getElectronAPI();
|
||||||
if (!api.worktree) throw new Error('Worktree API not available');
|
if (!api.worktree) throw new Error('Worktree API not available');
|
||||||
const result = await api.worktree.checkoutBranch(worktreePath, branchName);
|
const result = await api.worktree.checkoutBranch(
|
||||||
|
worktreePath,
|
||||||
|
branchName,
|
||||||
|
baseBranch,
|
||||||
|
stashChanges,
|
||||||
|
includeUntracked
|
||||||
|
);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
|
// When the checkout failed and restoring the stash produced conflicts
|
||||||
|
if (result.stashPopConflicts) {
|
||||||
|
const conflictError = new Error(result.error || 'Failed to checkout branch');
|
||||||
|
(
|
||||||
|
conflictError as Error & {
|
||||||
|
stashPopConflicts: boolean;
|
||||||
|
stashPopConflictMessage: string;
|
||||||
|
worktreePath: string;
|
||||||
|
branchName: string;
|
||||||
|
}
|
||||||
|
).stashPopConflicts = true;
|
||||||
|
(
|
||||||
|
conflictError as Error & {
|
||||||
|
stashPopConflicts: boolean;
|
||||||
|
stashPopConflictMessage: string;
|
||||||
|
worktreePath: string;
|
||||||
|
branchName: string;
|
||||||
|
}
|
||||||
|
).stashPopConflictMessage =
|
||||||
|
result.stashPopConflictMessage ??
|
||||||
|
'Stash pop resulted in conflicts: please resolve conflicts before retrying.';
|
||||||
|
(
|
||||||
|
conflictError as Error & {
|
||||||
|
stashPopConflicts: boolean;
|
||||||
|
stashPopConflictMessage: string;
|
||||||
|
worktreePath: string;
|
||||||
|
branchName: string;
|
||||||
|
}
|
||||||
|
).worktreePath = worktreePath;
|
||||||
|
(
|
||||||
|
conflictError as Error & {
|
||||||
|
stashPopConflicts: boolean;
|
||||||
|
stashPopConflictMessage: string;
|
||||||
|
worktreePath: string;
|
||||||
|
branchName: string;
|
||||||
|
}
|
||||||
|
).branchName = branchName;
|
||||||
|
throw conflictError;
|
||||||
|
}
|
||||||
throw new Error(result.error || 'Failed to checkout branch');
|
throw new Error(result.error || 'Failed to checkout branch');
|
||||||
}
|
}
|
||||||
return result.result;
|
return result.result;
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: (data, variables) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['worktrees'] });
|
queryClient.invalidateQueries({ queryKey: ['worktrees'] });
|
||||||
toast.success('New branch created and checked out');
|
|
||||||
|
if (data?.hasConflicts) {
|
||||||
|
toast.warning('Branch created with conflicts', {
|
||||||
|
description: data.message,
|
||||||
|
duration: 8000,
|
||||||
|
});
|
||||||
|
options?.onConflict?.({
|
||||||
|
worktreePath: variables.worktreePath,
|
||||||
|
branchName: data.newBranch ?? variables.branchName,
|
||||||
|
previousBranch: data.previousBranch ?? '',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const desc = data?.stashedChanges ? 'Local changes were stashed and reapplied' : undefined;
|
||||||
|
toast.success('New branch created and checked out', { description: desc });
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onError: (error: Error) => {
|
onError: (error: Error) => {
|
||||||
toast.error('Failed to checkout branch', {
|
const enrichedError = error as Error & {
|
||||||
description: error.message,
|
stashPopConflicts?: boolean;
|
||||||
});
|
stashPopConflictMessage?: string;
|
||||||
|
worktreePath?: string;
|
||||||
|
branchName?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (
|
||||||
|
enrichedError.stashPopConflicts &&
|
||||||
|
enrichedError.worktreePath &&
|
||||||
|
enrichedError.branchName
|
||||||
|
) {
|
||||||
|
toast.error('Branch creation failed with stash conflicts', {
|
||||||
|
description:
|
||||||
|
enrichedError.stashPopConflictMessage ??
|
||||||
|
'Stash pop resulted in conflicts. Please resolve the conflicts in your working tree.',
|
||||||
|
duration: 10000,
|
||||||
|
});
|
||||||
|
options?.onStashPopConflict?.({
|
||||||
|
worktreePath: enrichedError.worktreePath,
|
||||||
|
branchName: enrichedError.branchName,
|
||||||
|
stashPopConflictMessage:
|
||||||
|
enrichedError.stashPopConflictMessage ??
|
||||||
|
'Stash pop resulted in conflicts. Please resolve the conflicts in your working tree.',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast.error('Failed to checkout branch', {
|
||||||
|
description: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2291,11 +2291,18 @@ function createMockWorktreeAPI(): WorktreeAPI {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
checkoutBranch: async (worktreePath: string, branchName: string, baseBranch?: string) => {
|
checkoutBranch: async (
|
||||||
|
worktreePath: string,
|
||||||
|
branchName: string,
|
||||||
|
baseBranch?: string,
|
||||||
|
stashChanges?: boolean,
|
||||||
|
_includeUntracked?: boolean
|
||||||
|
) => {
|
||||||
console.log('[Mock] Creating and checking out branch:', {
|
console.log('[Mock] Creating and checking out branch:', {
|
||||||
worktreePath,
|
worktreePath,
|
||||||
branchName,
|
branchName,
|
||||||
baseBranch,
|
baseBranch,
|
||||||
|
stashChanges,
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -2303,6 +2310,22 @@ function createMockWorktreeAPI(): WorktreeAPI {
|
|||||||
previousBranch: 'main',
|
previousBranch: 'main',
|
||||||
newBranch: branchName,
|
newBranch: branchName,
|
||||||
message: `Created and checked out branch '${branchName}'`,
|
message: `Created and checked out branch '${branchName}'`,
|
||||||
|
hasConflicts: false,
|
||||||
|
stashedChanges: stashChanges ?? false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
checkChanges: async (worktreePath: string) => {
|
||||||
|
console.log('[Mock] Checking for uncommitted changes:', worktreePath);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
result: {
|
||||||
|
hasChanges: false,
|
||||||
|
staged: [],
|
||||||
|
unstaged: [],
|
||||||
|
untracked: [],
|
||||||
|
totalFiles: 0,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2139,8 +2139,22 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
this.post('/api/worktree/stage-files', { worktreePath, files, operation }),
|
this.post('/api/worktree/stage-files', { worktreePath, files, operation }),
|
||||||
pull: (worktreePath: string, remote?: string, stashIfNeeded?: boolean) =>
|
pull: (worktreePath: string, remote?: string, stashIfNeeded?: boolean) =>
|
||||||
this.post('/api/worktree/pull', { worktreePath, remote, stashIfNeeded }),
|
this.post('/api/worktree/pull', { worktreePath, remote, stashIfNeeded }),
|
||||||
checkoutBranch: (worktreePath: string, branchName: string, baseBranch?: string) =>
|
checkoutBranch: (
|
||||||
this.post('/api/worktree/checkout-branch', { worktreePath, branchName, baseBranch }),
|
worktreePath: string,
|
||||||
|
branchName: string,
|
||||||
|
baseBranch?: string,
|
||||||
|
stashChanges?: boolean,
|
||||||
|
includeUntracked?: boolean
|
||||||
|
) =>
|
||||||
|
this.post('/api/worktree/checkout-branch', {
|
||||||
|
worktreePath,
|
||||||
|
branchName,
|
||||||
|
baseBranch,
|
||||||
|
stashChanges,
|
||||||
|
includeUntracked,
|
||||||
|
}),
|
||||||
|
checkChanges: (worktreePath: string) =>
|
||||||
|
this.post('/api/worktree/check-changes', { worktreePath }),
|
||||||
listBranches: (worktreePath: string, includeRemote?: boolean) =>
|
listBranches: (worktreePath: string, includeRemote?: boolean) =>
|
||||||
this.post('/api/worktree/list-branches', { worktreePath, includeRemote }),
|
this.post('/api/worktree/list-branches', { worktreePath, includeRemote }),
|
||||||
switchBranch: (worktreePath: string, branchName: string) =>
|
switchBranch: (worktreePath: string, branchName: string) =>
|
||||||
|
|||||||
23
apps/ui/src/types/electron.d.ts
vendored
23
apps/ui/src/types/electron.d.ts
vendored
@@ -1026,20 +1026,39 @@ export interface WorktreeAPI {
|
|||||||
code?: 'NOT_GIT_REPO' | 'NO_COMMITS';
|
code?: 'NOT_GIT_REPO' | 'NO_COMMITS';
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
// Create and checkout a new branch
|
// Check for uncommitted changes in a worktree
|
||||||
|
checkChanges: (worktreePath: string) => Promise<{
|
||||||
|
success: boolean;
|
||||||
|
result?: {
|
||||||
|
hasChanges: boolean;
|
||||||
|
staged: string[];
|
||||||
|
unstaged: string[];
|
||||||
|
untracked: string[];
|
||||||
|
totalFiles: number;
|
||||||
|
};
|
||||||
|
error?: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
// Create and checkout a new branch (with optional stash handling)
|
||||||
checkoutBranch: (
|
checkoutBranch: (
|
||||||
worktreePath: string,
|
worktreePath: string,
|
||||||
branchName: string,
|
branchName: string,
|
||||||
baseBranch?: string
|
baseBranch?: string,
|
||||||
|
stashChanges?: boolean,
|
||||||
|
includeUntracked?: boolean
|
||||||
) => Promise<{
|
) => Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
result?: {
|
result?: {
|
||||||
previousBranch: string;
|
previousBranch: string;
|
||||||
newBranch: string;
|
newBranch: string;
|
||||||
message: string;
|
message: string;
|
||||||
|
hasConflicts?: boolean;
|
||||||
|
stashedChanges?: boolean;
|
||||||
};
|
};
|
||||||
error?: string;
|
error?: string;
|
||||||
code?: 'NOT_GIT_REPO' | 'NO_COMMITS';
|
code?: 'NOT_GIT_REPO' | 'NO_COMMITS';
|
||||||
|
stashPopConflicts?: boolean;
|
||||||
|
stashPopConflictMessage?: string;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
// List branches (local and optionally remote)
|
// List branches (local and optionally remote)
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
"node": ">=22.0.0 <23.0.0"
|
"node": ">=22.0.0 <23.0.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@automaker/platform": "1.0.0",
|
||||||
"@automaker/types": "1.0.0",
|
"@automaker/types": "1.0.0",
|
||||||
"@automaker/utils": "1.0.0"
|
"@automaker/utils": "1.0.0"
|
||||||
},
|
},
|
||||||
|
|||||||
70
libs/git-utils/src/exec.ts
Normal file
70
libs/git-utils/src/exec.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* Git command execution utilities
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { spawnProcess } from '@automaker/platform';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute git command with array arguments to prevent command injection.
|
||||||
|
* Uses spawnProcess from @automaker/platform for secure, cross-platform execution.
|
||||||
|
*
|
||||||
|
* @param args - Array of git command arguments (e.g., ['worktree', 'add', path])
|
||||||
|
* @param cwd - Working directory to execute the command in
|
||||||
|
* @param env - Optional additional environment variables to pass to the git process.
|
||||||
|
* These are merged on top of the current process environment. Pass
|
||||||
|
* `{ LC_ALL: 'C' }` to force git to emit English output regardless of the
|
||||||
|
* system locale so that text-based output parsing remains reliable.
|
||||||
|
* @param abortController - Optional AbortController to cancel the git process.
|
||||||
|
* When the controller is aborted the underlying process is sent SIGTERM and
|
||||||
|
* the returned promise rejects with an Error whose message is 'Process aborted'.
|
||||||
|
* @returns Promise resolving to stdout output
|
||||||
|
* @throws Error with stderr/stdout message if command fails. The thrown error
|
||||||
|
* also has `stdout` and `stderr` string properties for structured access.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* // Safe: no injection possible
|
||||||
|
* await execGitCommand(['branch', '-D', branchName], projectPath);
|
||||||
|
*
|
||||||
|
* // Force English output for reliable text parsing:
|
||||||
|
* await execGitCommand(['rebase', '--', 'main'], worktreePath, { LC_ALL: 'C' });
|
||||||
|
*
|
||||||
|
* // With a process-level timeout:
|
||||||
|
* const controller = new AbortController();
|
||||||
|
* const timerId = setTimeout(() => controller.abort(), 30_000);
|
||||||
|
* try {
|
||||||
|
* await execGitCommand(['fetch', '--all', '--quiet'], cwd, undefined, controller);
|
||||||
|
* } finally {
|
||||||
|
* clearTimeout(timerId);
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* // Instead of unsafe:
|
||||||
|
* // await execAsync(`git branch -D ${branchName}`, { cwd });
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export async function execGitCommand(
|
||||||
|
args: string[],
|
||||||
|
cwd: string,
|
||||||
|
env?: Record<string, string>,
|
||||||
|
abortController?: AbortController
|
||||||
|
): Promise<string> {
|
||||||
|
const result = await spawnProcess({
|
||||||
|
command: 'git',
|
||||||
|
args,
|
||||||
|
cwd,
|
||||||
|
...(env !== undefined ? { env } : {}),
|
||||||
|
...(abortController !== undefined ? { abortController } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// spawnProcess returns { stdout, stderr, exitCode }
|
||||||
|
if (result.exitCode === 0) {
|
||||||
|
return result.stdout;
|
||||||
|
} else {
|
||||||
|
const errorMessage =
|
||||||
|
result.stderr || result.stdout || `Git command failed with code ${result.exitCode}`;
|
||||||
|
throw Object.assign(new Error(errorMessage), {
|
||||||
|
stdout: result.stdout,
|
||||||
|
stderr: result.stderr,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,9 @@
|
|||||||
* Git operations utilities for AutoMaker
|
* Git operations utilities for AutoMaker
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// Export command execution utilities
|
||||||
|
export { execGitCommand } from './exec.js';
|
||||||
|
|
||||||
// Export types and constants
|
// Export types and constants
|
||||||
export { BINARY_EXTENSIONS, GIT_STATUS_MAP, type FileStatus } from './types.js';
|
export { BINARY_EXTENSIONS, GIT_STATUS_MAP, type FileStatus } from './types.js';
|
||||||
|
|
||||||
|
|||||||
43
libs/utils/src/git-validation.ts
Normal file
43
libs/utils/src/git-validation.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* Git validation utilities
|
||||||
|
*
|
||||||
|
* Canonical validators for git-related inputs (branch names, etc.)
|
||||||
|
* used across the server codebase.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Maximum allowed length for git branch names */
|
||||||
|
export const MAX_BRANCH_NAME_LENGTH = 250;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a git branch name to prevent command injection and ensure
|
||||||
|
* it conforms to safe git ref naming rules.
|
||||||
|
*
|
||||||
|
* Enforces:
|
||||||
|
* - Allowed characters: alphanumeric, dot (.), underscore (_), slash (/), dash (-)
|
||||||
|
* - First character must NOT be a dash (prevents git argument injection via
|
||||||
|
* names like "-flag" or "--option")
|
||||||
|
* - Rejects path-traversal sequences (..)
|
||||||
|
* - Rejects NUL bytes (\0)
|
||||||
|
* - Enforces a maximum length of {@link MAX_BRANCH_NAME_LENGTH} characters
|
||||||
|
*
|
||||||
|
* @param name - The branch name to validate
|
||||||
|
* @returns `true` when the name is safe to pass to git commands
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* isValidBranchName('feature/my-branch'); // true
|
||||||
|
* isValidBranchName('-flag'); // false (starts with dash)
|
||||||
|
* isValidBranchName('a..b'); // false (contains ..)
|
||||||
|
* isValidBranchName('a\0b'); // false (contains NUL)
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function isValidBranchName(name: string): boolean {
|
||||||
|
// Must not contain NUL bytes
|
||||||
|
if (name.includes('\0')) return false;
|
||||||
|
// Must not contain path-traversal sequences
|
||||||
|
if (name.includes('..')) return false;
|
||||||
|
// First char must be alphanumeric, dot, underscore, or slash (not dash).
|
||||||
|
// Remaining chars may also include dash.
|
||||||
|
// Must be within the length limit.
|
||||||
|
return /^[a-zA-Z0-9._/][a-zA-Z0-9._\-/]*$/.test(name) && name.length < MAX_BRANCH_NAME_LENGTH;
|
||||||
|
}
|
||||||
@@ -117,3 +117,6 @@ export {
|
|||||||
type ThrottleOptions,
|
type ThrottleOptions,
|
||||||
type DebouncedFunction,
|
type DebouncedFunction,
|
||||||
} from './debounce.js';
|
} from './debounce.js';
|
||||||
|
|
||||||
|
// Git validation utilities
|
||||||
|
export { isValidBranchName, MAX_BRANCH_NAME_LENGTH } from './git-validation.js';
|
||||||
|
|||||||
Reference in New Issue
Block a user