mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-16 21:53:07 +00:00
feat: Add GPT-5 model variants and improve Codex execution logic. Addressed code review comments
This commit is contained in:
@@ -15,7 +15,14 @@ export function parseGitLogOutput(output: string): CommitFields[] {
|
|||||||
const commitBlocks = output.split('\0').filter((block) => block.trim());
|
const commitBlocks = output.split('\0').filter((block) => block.trim());
|
||||||
|
|
||||||
for (const block of commitBlocks) {
|
for (const block of commitBlocks) {
|
||||||
const fields = block.split('\n');
|
const allLines = block.split('\n');
|
||||||
|
|
||||||
|
// Skip leading empty lines that may appear at block boundaries
|
||||||
|
let startIndex = 0;
|
||||||
|
while (startIndex < allLines.length && allLines[startIndex].trim() === '') {
|
||||||
|
startIndex++;
|
||||||
|
}
|
||||||
|
const fields = allLines.slice(startIndex);
|
||||||
|
|
||||||
// Validate we have all expected fields (at least hash, shortHash, author, authorEmail, date, subject)
|
// Validate we have all expected fields (at least hash, shortHash, author, authorEmail, date, subject)
|
||||||
if (fields.length < 6) {
|
if (fields.length < 6) {
|
||||||
|
|||||||
82
apps/server/src/lib/git.ts
Normal file
82
apps/server/src/lib/git.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
/**
|
||||||
|
* Shared git command execution utilities.
|
||||||
|
*
|
||||||
|
* This module provides the canonical `execGitCommand` helper and common
|
||||||
|
* git utilities used across services and routes. All consumers should
|
||||||
|
* import from here rather than defining their own copy.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { spawnProcess } from '@automaker/platform';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Secure Command Execution
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
* @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' });
|
||||||
|
*
|
||||||
|
* // Instead of unsafe:
|
||||||
|
* // await execAsync(`git branch -D ${branchName}`, { cwd });
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export async function execGitCommand(
|
||||||
|
args: string[],
|
||||||
|
cwd: string,
|
||||||
|
env?: Record<string, string>
|
||||||
|
): Promise<string> {
|
||||||
|
const result = await spawnProcess({
|
||||||
|
command: 'git',
|
||||||
|
args,
|
||||||
|
cwd,
|
||||||
|
...(env !== undefined ? { env } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Common Git Utilities
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current branch name for the given worktree.
|
||||||
|
*
|
||||||
|
* This is the canonical implementation shared across services. Services
|
||||||
|
* should import this rather than duplicating the logic locally.
|
||||||
|
*
|
||||||
|
* @param worktreePath - Path to the git worktree
|
||||||
|
* @returns The current branch name (trimmed)
|
||||||
|
*/
|
||||||
|
export async function getCurrentBranch(worktreePath: string): Promise<string> {
|
||||||
|
const branchOutput = await execGitCommand(['rev-parse', '--abbrev-ref', 'HEAD'], worktreePath);
|
||||||
|
return branchOutput.trim();
|
||||||
|
}
|
||||||
@@ -32,6 +32,19 @@ export const CODEX_MODELS: ModelDefinition[] = [
|
|||||||
default: true,
|
default: true,
|
||||||
hasReasoning: true,
|
hasReasoning: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: CODEX_MODEL_MAP.gpt53CodexSpark,
|
||||||
|
name: 'GPT-5.3-Codex-Spark',
|
||||||
|
modelString: CODEX_MODEL_MAP.gpt53CodexSpark,
|
||||||
|
provider: 'openai',
|
||||||
|
description: 'Near-instant real-time coding model, 1000+ tokens/sec.',
|
||||||
|
contextWindow: CONTEXT_WINDOW_256K,
|
||||||
|
maxOutputTokens: MAX_OUTPUT_32K,
|
||||||
|
supportsVision: true,
|
||||||
|
supportsTools: true,
|
||||||
|
tier: 'premium' as const,
|
||||||
|
hasReasoning: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: CODEX_MODEL_MAP.gpt52Codex,
|
id: CODEX_MODEL_MAP.gpt52Codex,
|
||||||
name: 'GPT-5.2-Codex',
|
name: 'GPT-5.2-Codex',
|
||||||
@@ -71,6 +84,45 @@ export const CODEX_MODELS: ModelDefinition[] = [
|
|||||||
tier: 'basic' as const,
|
tier: 'basic' as const,
|
||||||
hasReasoning: false,
|
hasReasoning: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: CODEX_MODEL_MAP.gpt51Codex,
|
||||||
|
name: 'GPT-5.1-Codex',
|
||||||
|
modelString: CODEX_MODEL_MAP.gpt51Codex,
|
||||||
|
provider: 'openai',
|
||||||
|
description: 'Original GPT-5.1 Codex agentic coding model.',
|
||||||
|
contextWindow: CONTEXT_WINDOW_256K,
|
||||||
|
maxOutputTokens: MAX_OUTPUT_32K,
|
||||||
|
supportsVision: true,
|
||||||
|
supportsTools: true,
|
||||||
|
tier: 'standard' as const,
|
||||||
|
hasReasoning: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: CODEX_MODEL_MAP.gpt5Codex,
|
||||||
|
name: 'GPT-5-Codex',
|
||||||
|
modelString: CODEX_MODEL_MAP.gpt5Codex,
|
||||||
|
provider: 'openai',
|
||||||
|
description: 'Original GPT-5 Codex model.',
|
||||||
|
contextWindow: CONTEXT_WINDOW_128K,
|
||||||
|
maxOutputTokens: MAX_OUTPUT_16K,
|
||||||
|
supportsVision: true,
|
||||||
|
supportsTools: true,
|
||||||
|
tier: 'standard' as const,
|
||||||
|
hasReasoning: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: CODEX_MODEL_MAP.gpt5CodexMini,
|
||||||
|
name: 'GPT-5-Codex-Mini',
|
||||||
|
modelString: CODEX_MODEL_MAP.gpt5CodexMini,
|
||||||
|
provider: 'openai',
|
||||||
|
description: 'Smaller, cheaper GPT-5 Codex variant.',
|
||||||
|
contextWindow: CONTEXT_WINDOW_128K,
|
||||||
|
maxOutputTokens: MAX_OUTPUT_16K,
|
||||||
|
supportsVision: true,
|
||||||
|
supportsTools: true,
|
||||||
|
tier: 'basic' as const,
|
||||||
|
hasReasoning: false,
|
||||||
|
},
|
||||||
|
|
||||||
// ========== General-Purpose GPT Models ==========
|
// ========== General-Purpose GPT Models ==========
|
||||||
{
|
{
|
||||||
@@ -99,6 +151,19 @@ export const CODEX_MODELS: ModelDefinition[] = [
|
|||||||
tier: 'standard' as const,
|
tier: 'standard' as const,
|
||||||
hasReasoning: true,
|
hasReasoning: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: CODEX_MODEL_MAP.gpt5,
|
||||||
|
name: 'GPT-5',
|
||||||
|
modelString: CODEX_MODEL_MAP.gpt5,
|
||||||
|
provider: 'openai',
|
||||||
|
description: 'Base GPT-5 model.',
|
||||||
|
contextWindow: CONTEXT_WINDOW_128K,
|
||||||
|
maxOutputTokens: MAX_OUTPUT_16K,
|
||||||
|
supportsVision: true,
|
||||||
|
supportsTools: true,
|
||||||
|
tier: 'standard' as const,
|
||||||
|
hasReasoning: true,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -205,10 +205,28 @@ async function resolveCodexExecutionPlan(options: ExecuteOptions): Promise<Codex
|
|||||||
const authIndicators = await getCodexAuthIndicators();
|
const authIndicators = await getCodexAuthIndicators();
|
||||||
const openAiApiKey = await resolveOpenAiApiKey();
|
const openAiApiKey = await resolveOpenAiApiKey();
|
||||||
const hasApiKey = Boolean(openAiApiKey);
|
const hasApiKey = Boolean(openAiApiKey);
|
||||||
const cliAuthenticated = authIndicators.hasOAuthToken || authIndicators.hasApiKey || hasApiKey;
|
|
||||||
const sdkEligible = isSdkEligible(options);
|
|
||||||
const cliAvailable = Boolean(cliPath);
|
const cliAvailable = Boolean(cliPath);
|
||||||
|
// CLI OAuth login takes priority: if the user has logged in via `codex login`,
|
||||||
|
// use the CLI regardless of whether an API key is also stored.
|
||||||
|
// hasOAuthToken = OAuth session from `codex login`
|
||||||
|
// authIndicators.hasApiKey = API key stored in Codex's own auth file (via `codex login --api-key`)
|
||||||
|
// Both are "CLI-native" auth — distinct from an API key stored in Automaker's credentials.
|
||||||
|
const hasCliNativeAuth = authIndicators.hasOAuthToken || authIndicators.hasApiKey;
|
||||||
|
const cliAuthenticated = hasCliNativeAuth || hasApiKey;
|
||||||
|
const sdkEligible = isSdkEligible(options);
|
||||||
|
|
||||||
|
// If CLI is available and the user authenticated via the CLI (`codex login`),
|
||||||
|
// prefer CLI mode over SDK. This ensures `codex login` sessions take priority
|
||||||
|
// over API keys stored in Automaker's credentials.
|
||||||
|
if (cliAvailable && hasCliNativeAuth) {
|
||||||
|
return {
|
||||||
|
mode: CODEX_EXECUTION_MODE_CLI,
|
||||||
|
cliPath,
|
||||||
|
openAiApiKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// No CLI-native auth — fall back to API key via SDK if available.
|
||||||
if (hasApiKey) {
|
if (hasApiKey) {
|
||||||
return {
|
return {
|
||||||
mode: CODEX_EXECUTION_MODE_SDK,
|
mode: CODEX_EXECUTION_MODE_SDK,
|
||||||
@@ -854,16 +872,35 @@ export class CodexProvider extends BaseProvider {
|
|||||||
|
|
||||||
// Enhance error message with helpful context
|
// Enhance error message with helpful context
|
||||||
let enhancedError = errorText;
|
let enhancedError = errorText;
|
||||||
if (errorText.toLowerCase().includes('rate limit')) {
|
const errorLower = errorText.toLowerCase();
|
||||||
|
if (errorLower.includes('rate limit')) {
|
||||||
enhancedError = `${errorText}\n\nTip: You're being rate limited. Try reducing concurrent tasks or waiting a few minutes before retrying.`;
|
enhancedError = `${errorText}\n\nTip: You're being rate limited. Try reducing concurrent tasks or waiting a few minutes before retrying.`;
|
||||||
} else if (
|
} else if (errorLower.includes('authentication') || errorLower.includes('unauthorized')) {
|
||||||
errorText.toLowerCase().includes('authentication') ||
|
|
||||||
errorText.toLowerCase().includes('unauthorized')
|
|
||||||
) {
|
|
||||||
enhancedError = `${errorText}\n\nTip: Check that your OPENAI_API_KEY is set correctly or run 'codex auth login' to authenticate.`;
|
enhancedError = `${errorText}\n\nTip: Check that your OPENAI_API_KEY is set correctly or run 'codex auth login' to authenticate.`;
|
||||||
} else if (
|
} else if (
|
||||||
errorText.toLowerCase().includes('not found') ||
|
errorLower.includes('does not exist') ||
|
||||||
errorText.toLowerCase().includes('command not found')
|
errorLower.includes('do not have access') ||
|
||||||
|
errorLower.includes('model_not_found') ||
|
||||||
|
errorLower.includes('invalid_model')
|
||||||
|
) {
|
||||||
|
enhancedError =
|
||||||
|
`${errorText}\n\nTip: The model '${options.model}' may not be available on your OpenAI plan. ` +
|
||||||
|
`Some models (like gpt-5.3-codex) require a ChatGPT Pro/Plus subscription and OAuth login via 'codex login'. ` +
|
||||||
|
`Try using a different model (e.g., gpt-5.1 or gpt-5.2), or authenticate with 'codex login' instead of an API key.`;
|
||||||
|
} else if (
|
||||||
|
errorLower.includes('stream disconnected') ||
|
||||||
|
errorLower.includes('stream ended') ||
|
||||||
|
errorLower.includes('connection reset')
|
||||||
|
) {
|
||||||
|
enhancedError =
|
||||||
|
`${errorText}\n\nTip: The connection to OpenAI was interrupted. This can happen due to:\n` +
|
||||||
|
`- Network instability\n` +
|
||||||
|
`- The model not being available on your plan\n` +
|
||||||
|
`- Server-side timeouts for long-running requests\n` +
|
||||||
|
`Try again, or switch to a different model.`;
|
||||||
|
} else if (
|
||||||
|
errorLower.includes('command not found') ||
|
||||||
|
(errorLower.includes('not found') && !errorLower.includes('model'))
|
||||||
) {
|
) {
|
||||||
enhancedError = `${errorText}\n\nTip: Make sure the Codex CLI is installed. Run 'npm install -g @openai/codex-cli' to install.`;
|
enhancedError = `${errorText}\n\nTip: Make sure the Codex CLI is installed. Run 'npm install -g @openai/codex-cli' to install.`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,38 +99,54 @@ export async function* executeCodexSdkQuery(
|
|||||||
const apiKey = resolveApiKey();
|
const apiKey = resolveApiKey();
|
||||||
const codex = new Codex({ apiKey });
|
const codex = new Codex({ apiKey });
|
||||||
|
|
||||||
|
// Build thread options with model
|
||||||
|
// The model must be passed to startThread/resumeThread so the SDK
|
||||||
|
// knows which model to use for the conversation. Without this,
|
||||||
|
// the SDK may use a default model that the user doesn't have access to.
|
||||||
|
type SdkReasoningEffort = 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
|
||||||
|
const SDK_REASONING_EFFORTS = new Set<string>(['minimal', 'low', 'medium', 'high', 'xhigh']);
|
||||||
|
|
||||||
|
const threadOptions: {
|
||||||
|
model?: string;
|
||||||
|
modelReasoningEffort?: SdkReasoningEffort;
|
||||||
|
} = {};
|
||||||
|
|
||||||
|
if (options.model) {
|
||||||
|
threadOptions.model = options.model;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add reasoning effort to thread options if model supports it
|
||||||
|
if (
|
||||||
|
options.reasoningEffort &&
|
||||||
|
supportsReasoningEffort(options.model) &&
|
||||||
|
options.reasoningEffort !== 'none' &&
|
||||||
|
SDK_REASONING_EFFORTS.has(options.reasoningEffort)
|
||||||
|
) {
|
||||||
|
threadOptions.modelReasoningEffort = options.reasoningEffort as SdkReasoningEffort;
|
||||||
|
}
|
||||||
|
|
||||||
// Resume existing thread or start new one
|
// Resume existing thread or start new one
|
||||||
let thread;
|
let thread;
|
||||||
if (options.sdkSessionId) {
|
if (options.sdkSessionId) {
|
||||||
try {
|
try {
|
||||||
thread = codex.resumeThread(options.sdkSessionId);
|
thread = codex.resumeThread(options.sdkSessionId, threadOptions);
|
||||||
} catch {
|
} catch {
|
||||||
// If resume fails, start a new thread
|
// If resume fails, start a new thread
|
||||||
thread = codex.startThread();
|
thread = codex.startThread(threadOptions);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
thread = codex.startThread();
|
thread = codex.startThread(threadOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
const promptText = buildPromptText(options, systemPrompt);
|
const promptText = buildPromptText(options, systemPrompt);
|
||||||
|
|
||||||
// Build run options with reasoning effort if supported
|
// Build run options
|
||||||
const runOptions: {
|
const runOptions: {
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
reasoning?: { effort: string };
|
|
||||||
} = {
|
} = {
|
||||||
signal: options.abortController?.signal,
|
signal: options.abortController?.signal,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add reasoning effort if model supports it and reasoningEffort is specified
|
|
||||||
if (
|
|
||||||
options.reasoningEffort &&
|
|
||||||
supportsReasoningEffort(options.model) &&
|
|
||||||
options.reasoningEffort !== 'none'
|
|
||||||
) {
|
|
||||||
runOptions.reasoning = { effort: options.reasoningEffort };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run the query
|
// Run the query
|
||||||
const result = await thread.run(promptText, runOptions);
|
const result = await thread.run(promptText, runOptions);
|
||||||
|
|
||||||
@@ -160,10 +176,40 @@ export async function* executeCodexSdkQuery(
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorInfo = classifyError(error);
|
const errorInfo = classifyError(error);
|
||||||
const userMessage = getUserFriendlyErrorMessage(error);
|
const userMessage = getUserFriendlyErrorMessage(error);
|
||||||
const combinedMessage = buildSdkErrorMessage(errorInfo.message, userMessage);
|
let combinedMessage = buildSdkErrorMessage(errorInfo.message, userMessage);
|
||||||
|
|
||||||
|
// Enhance error messages with actionable tips for common Codex issues
|
||||||
|
const errorLower = errorInfo.message.toLowerCase();
|
||||||
|
|
||||||
|
if (
|
||||||
|
errorLower.includes('does not exist') ||
|
||||||
|
errorLower.includes('model_not_found') ||
|
||||||
|
errorLower.includes('invalid_model')
|
||||||
|
) {
|
||||||
|
// Model not found - provide helpful guidance
|
||||||
|
combinedMessage +=
|
||||||
|
`\n\nTip: The model '${options.model}' may not be available on your OpenAI plan. ` +
|
||||||
|
`Some models (like gpt-5.3-codex) require a ChatGPT Pro/Plus subscription and OAuth login via 'codex login'. ` +
|
||||||
|
`Try using a different model (e.g., gpt-5.1 or gpt-5.2), or authenticate with 'codex login' instead of an API key.`;
|
||||||
|
} else if (
|
||||||
|
errorLower.includes('stream disconnected') ||
|
||||||
|
errorLower.includes('stream ended') ||
|
||||||
|
errorLower.includes('connection reset') ||
|
||||||
|
errorLower.includes('socket hang up')
|
||||||
|
) {
|
||||||
|
// Stream disconnection - provide helpful guidance
|
||||||
|
combinedMessage +=
|
||||||
|
`\n\nTip: The connection to OpenAI was interrupted. This can happen due to:\n` +
|
||||||
|
`- Network instability\n` +
|
||||||
|
`- The model not being available on your plan (try 'codex login' for OAuth authentication)\n` +
|
||||||
|
`- Server-side timeouts for long-running requests\n` +
|
||||||
|
`Try again, or switch to a different model.`;
|
||||||
|
}
|
||||||
|
|
||||||
console.error('[CodexSDK] executeQuery() error during execution:', {
|
console.error('[CodexSDK] executeQuery() error during execution:', {
|
||||||
type: errorInfo.type,
|
type: errorInfo.type,
|
||||||
message: errorInfo.message,
|
message: errorInfo.message,
|
||||||
|
model: options.model,
|
||||||
isRateLimit: errorInfo.isRateLimit,
|
isRateLimit: errorInfo.isRateLimit,
|
||||||
retryAfter: errorInfo.retryAfter,
|
retryAfter: errorInfo.retryAfter,
|
||||||
stack: error instanceof Error ? error.stack : undefined,
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
|
|||||||
@@ -3,57 +3,17 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { createLogger } from '@automaker/utils';
|
import { createLogger } from '@automaker/utils';
|
||||||
import { spawnProcess } from '@automaker/platform';
|
|
||||||
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';
|
||||||
|
|
||||||
|
// Re-export execGitCommand from the canonical shared module so any remaining
|
||||||
|
// consumers that import from this file continue to work.
|
||||||
|
export { execGitCommand } from '../../lib/git.js';
|
||||||
|
|
||||||
const logger = createLogger('Worktree');
|
const logger = createLogger('Worktree');
|
||||||
export const execAsync = promisify(exec);
|
export const execAsync = promisify(exec);
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Secure Command Execution
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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
|
|
||||||
* @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);
|
|
||||||
*
|
|
||||||
* // Instead of unsafe:
|
|
||||||
* // await execAsync(`git branch -D ${branchName}`, { cwd });
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export async function execGitCommand(args: string[], cwd: string): Promise<string> {
|
|
||||||
const result = await spawnProcess({
|
|
||||||
command: 'git',
|
|
||||||
args,
|
|
||||||
cwd,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Constants
|
// Constants
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -111,9 +71,12 @@ export const execEnv = {
|
|||||||
* Validate branch name to prevent command injection.
|
* Validate branch name to prevent command injection.
|
||||||
* Git branch names cannot contain: space, ~, ^, :, ?, *, [, \, or control chars.
|
* Git branch names cannot contain: space, ~, ^, :, ?, *, [, \, or control chars.
|
||||||
* We also reject shell metacharacters for safety.
|
* We also reject shell metacharacters for safety.
|
||||||
|
* The first character must not be '-' to prevent git argument injection.
|
||||||
*/
|
*/
|
||||||
export function isValidBranchName(name: string): boolean {
|
export function isValidBranchName(name: string): boolean {
|
||||||
return /^[a-zA-Z0-9._\-/]+$/.test(name) && name.length < MAX_BRANCH_NAME_LENGTH;
|
// 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ export function createWorktreeRoutes(
|
|||||||
requireValidWorktree,
|
requireValidWorktree,
|
||||||
createListBranchesHandler()
|
createListBranchesHandler()
|
||||||
);
|
);
|
||||||
router.post('/switch-branch', requireValidWorktree, createSwitchBranchHandler());
|
router.post('/switch-branch', requireValidWorktree, createSwitchBranchHandler(events));
|
||||||
router.post('/open-in-editor', validatePathParams('worktreePath'), createOpenInEditorHandler());
|
router.post('/open-in-editor', validatePathParams('worktreePath'), createOpenInEditorHandler());
|
||||||
router.post(
|
router.post(
|
||||||
'/open-in-terminal',
|
'/open-in-terminal',
|
||||||
@@ -210,7 +210,7 @@ export function createWorktreeRoutes(
|
|||||||
'/commit-log',
|
'/commit-log',
|
||||||
validatePathParams('worktreePath'),
|
validatePathParams('worktreePath'),
|
||||||
requireValidWorktree,
|
requireValidWorktree,
|
||||||
createCommitLogHandler()
|
createCommitLogHandler(events)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Stash routes
|
// Stash routes
|
||||||
@@ -218,13 +218,13 @@ export function createWorktreeRoutes(
|
|||||||
'/stash-push',
|
'/stash-push',
|
||||||
validatePathParams('worktreePath'),
|
validatePathParams('worktreePath'),
|
||||||
requireGitRepoOnly,
|
requireGitRepoOnly,
|
||||||
createStashPushHandler()
|
createStashPushHandler(events)
|
||||||
);
|
);
|
||||||
router.post(
|
router.post(
|
||||||
'/stash-list',
|
'/stash-list',
|
||||||
validatePathParams('worktreePath'),
|
validatePathParams('worktreePath'),
|
||||||
requireGitRepoOnly,
|
requireGitRepoOnly,
|
||||||
createStashListHandler()
|
createStashListHandler(events)
|
||||||
);
|
);
|
||||||
router.post(
|
router.post(
|
||||||
'/stash-apply',
|
'/stash-apply',
|
||||||
@@ -236,7 +236,7 @@ export function createWorktreeRoutes(
|
|||||||
'/stash-drop',
|
'/stash-drop',
|
||||||
validatePathParams('worktreePath'),
|
validatePathParams('worktreePath'),
|
||||||
requireGitRepoOnly,
|
requireGitRepoOnly,
|
||||||
createStashDropHandler()
|
createStashDropHandler(events)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Cherry-pick route
|
// Cherry-pick route
|
||||||
|
|||||||
@@ -10,7 +10,8 @@
|
|||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { stat } from 'fs/promises';
|
import { stat } from 'fs/promises';
|
||||||
import { getErrorMessage, logError, isValidBranchName, execGitCommand } from '../common.js';
|
import { getErrorMessage, logError, isValidBranchName } from '../common.js';
|
||||||
|
import { execGitCommand } from '../../../lib/git.js';
|
||||||
|
|
||||||
export function createCheckoutBranchHandler() {
|
export function createCheckoutBranchHandler() {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
|
|||||||
@@ -6,6 +6,9 @@
|
|||||||
*
|
*
|
||||||
* Git business logic is delegated to cherry-pick-service.ts.
|
* Git business logic is delegated to cherry-pick-service.ts.
|
||||||
* Events are emitted at key lifecycle points for WebSocket subscribers.
|
* Events are emitted at key lifecycle points for WebSocket subscribers.
|
||||||
|
* The global event emitter is passed into the service so all lifecycle
|
||||||
|
* events (started, success, conflict, abort, verify-failed) are broadcast
|
||||||
|
* to WebSocket clients.
|
||||||
*
|
*
|
||||||
* 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
|
||||||
@@ -58,8 +61,8 @@ export function createCherryPickHandler(events: EventEmitter) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify each commit exists via the service
|
// Verify each commit exists via the service; emits cherry-pick:verify-failed if any hash is missing
|
||||||
const invalidHash = await verifyCommits(resolvedWorktreePath, commitHashes);
|
const invalidHash = await verifyCommits(resolvedWorktreePath, commitHashes, events);
|
||||||
if (invalidHash !== null) {
|
if (invalidHash !== null) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
@@ -68,24 +71,12 @@ export function createCherryPickHandler(events: EventEmitter) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Emit started event
|
// Execute the cherry-pick via the service.
|
||||||
events.emit('cherry-pick:started', {
|
// The service emits: cherry-pick:started, cherry-pick:success, cherry-pick:conflict,
|
||||||
worktreePath: resolvedWorktreePath,
|
// and cherry-pick:abort at the appropriate lifecycle points.
|
||||||
commitHashes,
|
const result = await runCherryPick(resolvedWorktreePath, commitHashes, options, events);
|
||||||
options,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Execute the cherry-pick via the service
|
|
||||||
const result = await runCherryPick(resolvedWorktreePath, commitHashes, options);
|
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
// Emit success event
|
|
||||||
events.emit('cherry-pick:success', {
|
|
||||||
worktreePath: resolvedWorktreePath,
|
|
||||||
commitHashes,
|
|
||||||
branch: result.branch,
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
result: {
|
result: {
|
||||||
@@ -96,13 +87,6 @@ export function createCherryPickHandler(events: EventEmitter) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
} else if (result.hasConflicts) {
|
} else if (result.hasConflicts) {
|
||||||
// Emit conflict event
|
|
||||||
events.emit('cherry-pick:conflict', {
|
|
||||||
worktreePath: resolvedWorktreePath,
|
|
||||||
commitHashes,
|
|
||||||
aborted: result.aborted,
|
|
||||||
});
|
|
||||||
|
|
||||||
res.status(409).json({
|
res.status(409).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: result.error,
|
error: result.error,
|
||||||
@@ -111,7 +95,7 @@ export function createCherryPickHandler(events: EventEmitter) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Emit failure event
|
// Emit failure event for unexpected (non-conflict) errors
|
||||||
events.emit('cherry-pick:failure', {
|
events.emit('cherry-pick:failure', {
|
||||||
error: getErrorMessage(error),
|
error: getErrorMessage(error),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,29 +1,22 @@
|
|||||||
/**
|
/**
|
||||||
* POST /commit-log endpoint - Get recent commit history for a worktree
|
* POST /commit-log endpoint - Get recent commit history for a worktree
|
||||||
*
|
*
|
||||||
* Uses the same robust parsing approach as branch-commit-log-service:
|
* The handler only validates input, invokes the service, streams lifecycle
|
||||||
* a single `git log --name-only` call with custom separators to fetch
|
* events via the EventEmitter, and sends the final JSON response.
|
||||||
* both commit metadata and file lists, avoiding N+1 git invocations.
|
*
|
||||||
|
* Git business logic is delegated to commit-log-service.ts.
|
||||||
|
* Events are emitted at key lifecycle points for WebSocket subscribers.
|
||||||
*
|
*
|
||||||
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
|
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
|
||||||
* the requireValidWorktree middleware in index.ts
|
* the requireValidWorktree middleware in index.ts
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { execGitCommand, getErrorMessage, logError } from '../common.js';
|
import type { EventEmitter } from '../../../lib/events.js';
|
||||||
|
import { getErrorMessage, logError } from '../common.js';
|
||||||
|
import { getCommitLog } from '../../../services/commit-log-service.js';
|
||||||
|
|
||||||
interface CommitResult {
|
export function createCommitLogHandler(events: EventEmitter) {
|
||||||
hash: string;
|
|
||||||
shortHash: string;
|
|
||||||
author: string;
|
|
||||||
authorEmail: string;
|
|
||||||
date: string;
|
|
||||||
subject: string;
|
|
||||||
body: string;
|
|
||||||
files: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createCommitLogHandler() {
|
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const { worktreePath, limit = 20 } = req.body as {
|
const { worktreePath, limit = 20 } = req.body as {
|
||||||
@@ -39,112 +32,39 @@ export function createCommitLogHandler() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clamp limit to a reasonable range
|
// Emit start event so the frontend can observe progress
|
||||||
const commitLimit = Math.min(Math.max(1, Number(limit) || 20), 100);
|
events.emit('commitLog:start', {
|
||||||
|
worktreePath,
|
||||||
|
limit,
|
||||||
|
});
|
||||||
|
|
||||||
// Use custom separators to parse both metadata and file lists from
|
// Delegate all Git work to the service
|
||||||
// a single git log invocation (same approach as branch-commit-log-service).
|
const result = await getCommitLog(worktreePath, limit);
|
||||||
//
|
|
||||||
// -m causes merge commits to be diffed against each parent so all
|
|
||||||
// files touched by the merge are listed (without -m, --name-only
|
|
||||||
// produces no file output for merge commits because they have 2+ parents).
|
|
||||||
// This means merge commits appear multiple times in the output (once per
|
|
||||||
// parent), so we deduplicate by hash below and merge their file lists.
|
|
||||||
// We over-fetch (2x the limit) to compensate for -m duplicating merge
|
|
||||||
// commit entries, then trim the result to the requested limit.
|
|
||||||
const COMMIT_SEP = '---COMMIT---';
|
|
||||||
const META_END = '---META_END---';
|
|
||||||
const fetchLimit = commitLimit * 2;
|
|
||||||
|
|
||||||
const logOutput = await execGitCommand(
|
// Emit progress with the number of commits fetched
|
||||||
[
|
events.emit('commitLog:progress', {
|
||||||
'log',
|
worktreePath,
|
||||||
`--max-count=${fetchLimit}`,
|
branch: result.branch,
|
||||||
'-m',
|
commitsLoaded: result.total,
|
||||||
'--name-only',
|
});
|
||||||
`--format=${COMMIT_SEP}%n%H%n%h%n%an%n%ae%n%aI%n%s%n%b${META_END}`,
|
|
||||||
],
|
|
||||||
worktreePath
|
|
||||||
);
|
|
||||||
|
|
||||||
// Split output into per-commit blocks and drop the empty first chunk
|
// Emit complete event
|
||||||
// (the output starts with ---COMMIT---).
|
events.emit('commitLog:complete', {
|
||||||
const commitBlocks = logOutput.split(COMMIT_SEP).filter((block) => block.trim());
|
worktreePath,
|
||||||
|
branch: result.branch,
|
||||||
// Use a Map to deduplicate merge commit entries (which appear once per
|
total: result.total,
|
||||||
// parent when -m is used) while preserving insertion order.
|
});
|
||||||
const commitMap = new Map<string, CommitResult>();
|
|
||||||
|
|
||||||
for (const block of commitBlocks) {
|
|
||||||
const metaEndIdx = block.indexOf(META_END);
|
|
||||||
if (metaEndIdx === -1) continue; // malformed block, skip
|
|
||||||
|
|
||||||
// --- Parse metadata (everything before ---META_END---) ---
|
|
||||||
const metaRaw = block.substring(0, metaEndIdx);
|
|
||||||
const metaLines = metaRaw.split('\n');
|
|
||||||
|
|
||||||
// The first line may be empty (newline right after COMMIT_SEP), skip it
|
|
||||||
const nonEmptyStart = metaLines.findIndex((l) => l.trim() !== '');
|
|
||||||
if (nonEmptyStart === -1) continue;
|
|
||||||
|
|
||||||
const fields = metaLines.slice(nonEmptyStart);
|
|
||||||
if (fields.length < 6) continue; // need at least hash..subject
|
|
||||||
|
|
||||||
const hash = fields[0].trim();
|
|
||||||
const shortHash = fields[1].trim();
|
|
||||||
const author = fields[2].trim();
|
|
||||||
const authorEmail = fields[3].trim();
|
|
||||||
const date = fields[4].trim();
|
|
||||||
const subject = fields[5].trim();
|
|
||||||
const body = fields.slice(6).join('\n').trim();
|
|
||||||
|
|
||||||
// --- Parse file list (everything after ---META_END---) ---
|
|
||||||
const filesRaw = block.substring(metaEndIdx + META_END.length);
|
|
||||||
const blockFiles = filesRaw
|
|
||||||
.trim()
|
|
||||||
.split('\n')
|
|
||||||
.filter((f) => f.trim());
|
|
||||||
|
|
||||||
// Merge file lists for duplicate entries (merge commits with -m)
|
|
||||||
const existing = commitMap.get(hash);
|
|
||||||
if (existing) {
|
|
||||||
// Add new files to the existing entry's file set
|
|
||||||
const fileSet = new Set(existing.files);
|
|
||||||
for (const f of blockFiles) fileSet.add(f);
|
|
||||||
existing.files = [...fileSet];
|
|
||||||
} else {
|
|
||||||
commitMap.set(hash, {
|
|
||||||
hash,
|
|
||||||
shortHash,
|
|
||||||
author,
|
|
||||||
authorEmail,
|
|
||||||
date,
|
|
||||||
subject,
|
|
||||||
body,
|
|
||||||
files: [...new Set(blockFiles)],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Trim to the requested limit (we over-fetched to account for -m duplicates)
|
|
||||||
const commits = [...commitMap.values()].slice(0, commitLimit);
|
|
||||||
|
|
||||||
// Get current branch name
|
|
||||||
const branchOutput = await execGitCommand(
|
|
||||||
['rev-parse', '--abbrev-ref', 'HEAD'],
|
|
||||||
worktreePath
|
|
||||||
);
|
|
||||||
const branch = branchOutput.trim();
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
result: {
|
result,
|
||||||
branch,
|
|
||||||
commits,
|
|
||||||
total: commits.length,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// Emit error event so the frontend can react
|
||||||
|
events.emit('commitLog:error', {
|
||||||
|
error: getErrorMessage(error),
|
||||||
|
});
|
||||||
|
|
||||||
logError(error, 'Get commit log failed');
|
logError(error, 'Get commit log failed');
|
||||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,11 +8,11 @@ import {
|
|||||||
logError,
|
logError,
|
||||||
execAsync,
|
execAsync,
|
||||||
execEnv,
|
execEnv,
|
||||||
execGitCommand,
|
|
||||||
isValidBranchName,
|
isValidBranchName,
|
||||||
isValidRemoteName,
|
isValidRemoteName,
|
||||||
isGhCliAvailable,
|
isGhCliAvailable,
|
||||||
} from '../common.js';
|
} from '../common.js';
|
||||||
|
import { execGitCommand } from '../../../lib/git.js';
|
||||||
import { spawnProcess } from '@automaker/platform';
|
import { spawnProcess } from '@automaker/platform';
|
||||||
import { updateWorktreePRInfo } from '../../../lib/worktree-metadata.js';
|
import { updateWorktreePRInfo } from '../../../lib/worktree-metadata.js';
|
||||||
import { createLogger } from '@automaker/utils';
|
import { createLogger } from '@automaker/utils';
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ import {
|
|||||||
normalizePath,
|
normalizePath,
|
||||||
ensureInitialCommit,
|
ensureInitialCommit,
|
||||||
isValidBranchName,
|
isValidBranchName,
|
||||||
execGitCommand,
|
|
||||||
} from '../common.js';
|
} from '../common.js';
|
||||||
|
import { execGitCommand } from '../../../lib/git.js';
|
||||||
import { trackBranch } from './branch-tracking.js';
|
import { trackBranch } from './branch-tracking.js';
|
||||||
import { createLogger } from '@automaker/utils';
|
import { createLogger } from '@automaker/utils';
|
||||||
import { runInitScript } from '../../../services/init-script-service.js';
|
import { runInitScript } from '../../../services/init-script-service.js';
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import type { Request, Response } from 'express';
|
|||||||
import { exec } from 'child_process';
|
import { exec } from 'child_process';
|
||||||
import { promisify } from 'util';
|
import { promisify } from 'util';
|
||||||
import { isGitRepo } from '@automaker/git-utils';
|
import { isGitRepo } from '@automaker/git-utils';
|
||||||
import { getErrorMessage, logError, isValidBranchName, execGitCommand } from '../common.js';
|
import { getErrorMessage, logError, isValidBranchName } from '../common.js';
|
||||||
|
import { execGitCommand } from '../../../lib/git.js';
|
||||||
import { createLogger } from '@automaker/utils';
|
import { createLogger } from '@automaker/utils';
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
|
|||||||
@@ -17,13 +17,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { execFile } from 'child_process';
|
|
||||||
import { promisify } from 'util';
|
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import { getErrorMessage, logError } from '../common.js';
|
import { getErrorMessage, logError } from '@automaker/utils';
|
||||||
|
import { execGitCommand } from '../../../lib/git.js';
|
||||||
const execFileAsync = promisify(execFile);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate that a file path does not escape the worktree directory.
|
* Validate that a file path does not escape the worktree directory.
|
||||||
@@ -72,9 +69,7 @@ export function createDiscardChangesHandler() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check for uncommitted changes first
|
// Check for uncommitted changes first
|
||||||
const { stdout: status } = await execFileAsync('git', ['status', '--porcelain'], {
|
const status = await execGitCommand(['status', '--porcelain'], worktreePath);
|
||||||
cwd: worktreePath,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!status.trim()) {
|
if (!status.trim()) {
|
||||||
res.json({
|
res.json({
|
||||||
@@ -88,12 +83,9 @@ export function createDiscardChangesHandler() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get branch name before discarding
|
// Get branch name before discarding
|
||||||
const { stdout: branchOutput } = await execFileAsync(
|
const branchOutput = await execGitCommand(
|
||||||
'git',
|
|
||||||
['rev-parse', '--abbrev-ref', 'HEAD'],
|
['rev-parse', '--abbrev-ref', 'HEAD'],
|
||||||
{
|
worktreePath
|
||||||
cwd: worktreePath,
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
const branchName = branchOutput.trim();
|
const branchName = branchOutput.trim();
|
||||||
|
|
||||||
@@ -162,9 +154,7 @@ export function createDiscardChangesHandler() {
|
|||||||
// 1. Unstage selected staged files (using execFile to bypass shell)
|
// 1. Unstage selected staged files (using execFile to bypass shell)
|
||||||
if (stagedFiles.length > 0) {
|
if (stagedFiles.length > 0) {
|
||||||
try {
|
try {
|
||||||
await execFileAsync('git', ['reset', 'HEAD', '--', ...stagedFiles], {
|
await execGitCommand(['reset', 'HEAD', '--', ...stagedFiles], worktreePath);
|
||||||
cwd: worktreePath,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const msg = getErrorMessage(error);
|
const msg = getErrorMessage(error);
|
||||||
logError(error, `Failed to unstage files: ${msg}`);
|
logError(error, `Failed to unstage files: ${msg}`);
|
||||||
@@ -175,9 +165,7 @@ export function createDiscardChangesHandler() {
|
|||||||
// 2. Revert selected tracked file changes
|
// 2. Revert selected tracked file changes
|
||||||
if (trackedModified.length > 0) {
|
if (trackedModified.length > 0) {
|
||||||
try {
|
try {
|
||||||
await execFileAsync('git', ['checkout', '--', ...trackedModified], {
|
await execGitCommand(['checkout', '--', ...trackedModified], worktreePath);
|
||||||
cwd: worktreePath,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const msg = getErrorMessage(error);
|
const msg = getErrorMessage(error);
|
||||||
logError(error, `Failed to revert tracked files: ${msg}`);
|
logError(error, `Failed to revert tracked files: ${msg}`);
|
||||||
@@ -188,9 +176,7 @@ export function createDiscardChangesHandler() {
|
|||||||
// 3. Remove selected untracked files
|
// 3. Remove selected untracked files
|
||||||
if (untrackedFiles.length > 0) {
|
if (untrackedFiles.length > 0) {
|
||||||
try {
|
try {
|
||||||
await execFileAsync('git', ['clean', '-fd', '--', ...untrackedFiles], {
|
await execGitCommand(['clean', '-fd', '--', ...untrackedFiles], worktreePath);
|
||||||
cwd: worktreePath,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const msg = getErrorMessage(error);
|
const msg = getErrorMessage(error);
|
||||||
logError(error, `Failed to clean untracked files: ${msg}`);
|
logError(error, `Failed to clean untracked files: ${msg}`);
|
||||||
@@ -201,9 +187,7 @@ export function createDiscardChangesHandler() {
|
|||||||
const fileCount = files.length;
|
const fileCount = files.length;
|
||||||
|
|
||||||
// Verify the remaining state
|
// Verify the remaining state
|
||||||
const { stdout: finalStatus } = await execFileAsync('git', ['status', '--porcelain'], {
|
const finalStatus = await execGitCommand(['status', '--porcelain'], worktreePath);
|
||||||
cwd: worktreePath,
|
|
||||||
});
|
|
||||||
|
|
||||||
const remainingCount = finalStatus.trim()
|
const remainingCount = finalStatus.trim()
|
||||||
? finalStatus.trim().split('\n').filter(Boolean).length
|
? finalStatus.trim().split('\n').filter(Boolean).length
|
||||||
@@ -233,7 +217,7 @@ export function createDiscardChangesHandler() {
|
|||||||
|
|
||||||
// 1. Reset any staged changes
|
// 1. Reset any staged changes
|
||||||
try {
|
try {
|
||||||
await execFileAsync('git', ['reset', 'HEAD'], { cwd: worktreePath });
|
await execGitCommand(['reset', 'HEAD'], worktreePath);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const msg = getErrorMessage(error);
|
const msg = getErrorMessage(error);
|
||||||
logError(error, `git reset HEAD failed: ${msg}`);
|
logError(error, `git reset HEAD failed: ${msg}`);
|
||||||
@@ -242,7 +226,7 @@ export function createDiscardChangesHandler() {
|
|||||||
|
|
||||||
// 2. Discard changes in tracked files
|
// 2. Discard changes in tracked files
|
||||||
try {
|
try {
|
||||||
await execFileAsync('git', ['checkout', '.'], { cwd: worktreePath });
|
await execGitCommand(['checkout', '.'], worktreePath);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const msg = getErrorMessage(error);
|
const msg = getErrorMessage(error);
|
||||||
logError(error, `git checkout . failed: ${msg}`);
|
logError(error, `git checkout . failed: ${msg}`);
|
||||||
@@ -251,7 +235,7 @@ export function createDiscardChangesHandler() {
|
|||||||
|
|
||||||
// 3. Remove untracked files and directories
|
// 3. Remove untracked files and directories
|
||||||
try {
|
try {
|
||||||
await execFileAsync('git', ['clean', '-fd'], { cwd: worktreePath });
|
await execGitCommand(['clean', '-fd'], worktreePath);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const msg = getErrorMessage(error);
|
const msg = getErrorMessage(error);
|
||||||
logError(error, `git clean -fd failed: ${msg}`);
|
logError(error, `git clean -fd failed: ${msg}`);
|
||||||
@@ -259,9 +243,7 @@ export function createDiscardChangesHandler() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify all changes were discarded
|
// Verify all changes were discarded
|
||||||
const { stdout: finalStatus } = await execFileAsync('git', ['status', '--porcelain'], {
|
const finalStatus = await execGitCommand(['status', '--porcelain'], worktreePath);
|
||||||
cwd: worktreePath,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (finalStatus.trim()) {
|
if (finalStatus.trim()) {
|
||||||
const remainingCount = finalStatus.trim().split('\n').filter(Boolean).length;
|
const remainingCount = finalStatus.trim().split('\n').filter(Boolean).length;
|
||||||
|
|||||||
@@ -8,13 +8,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { exec } from 'child_process';
|
import { getErrorMessage, logError } from '../common.js';
|
||||||
import { promisify } from 'util';
|
import { performMerge } from '../../../services/merge-service.js';
|
||||||
import { getErrorMessage, logError, isValidBranchName, execGitCommand } from '../common.js';
|
|
||||||
import { createLogger } from '@automaker/utils';
|
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
|
||||||
const logger = createLogger('Worktree');
|
|
||||||
|
|
||||||
export function createMergeHandler() {
|
export function createMergeHandler() {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
@@ -38,118 +33,34 @@ export function createMergeHandler() {
|
|||||||
// Determine the target branch (default to 'main')
|
// Determine the target branch (default to 'main')
|
||||||
const mergeTo = targetBranch || 'main';
|
const mergeTo = targetBranch || 'main';
|
||||||
|
|
||||||
// Validate source branch exists
|
// Delegate all merge logic to the service
|
||||||
try {
|
const result = await performMerge(projectPath, branchName, worktreePath, mergeTo, options);
|
||||||
await execAsync(`git rev-parse --verify ${branchName}`, { cwd: projectPath });
|
|
||||||
} catch {
|
|
||||||
res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error: `Branch "${branchName}" does not exist`,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate target branch exists
|
|
||||||
try {
|
|
||||||
await execAsync(`git rev-parse --verify ${mergeTo}`, { cwd: projectPath });
|
|
||||||
} catch {
|
|
||||||
res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error: `Target branch "${mergeTo}" does not exist`,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Merge the feature branch into the target branch
|
|
||||||
const mergeCmd = options?.squash
|
|
||||||
? `git merge --squash ${branchName}`
|
|
||||||
: `git merge ${branchName} -m "${options?.message || `Merge ${branchName} into ${mergeTo}`}"`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await execAsync(mergeCmd, { cwd: projectPath });
|
|
||||||
} catch (mergeError: unknown) {
|
|
||||||
// Check if this is a merge conflict
|
|
||||||
const err = mergeError as { stdout?: string; stderr?: string; message?: string };
|
|
||||||
const output = `${err.stdout || ''} ${err.stderr || ''} ${err.message || ''}`;
|
|
||||||
const hasConflicts =
|
|
||||||
output.includes('CONFLICT') || output.includes('Automatic merge failed');
|
|
||||||
|
|
||||||
if (hasConflicts) {
|
|
||||||
// Get list of conflicted files
|
|
||||||
let conflictFiles: string[] = [];
|
|
||||||
try {
|
|
||||||
const diffOutput = await execGitCommand(
|
|
||||||
['diff', '--name-only', '--diff-filter=U'],
|
|
||||||
projectPath
|
|
||||||
);
|
|
||||||
conflictFiles = diffOutput
|
|
||||||
.trim()
|
|
||||||
.split('\n')
|
|
||||||
.filter((f: string) => f.trim().length > 0);
|
|
||||||
} catch {
|
|
||||||
// If we can't get the file list, that's okay
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
if (result.hasConflicts) {
|
||||||
// Return conflict-specific error message that frontend can detect
|
// Return conflict-specific error message that frontend can detect
|
||||||
res.status(409).json({
|
res.status(409).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: `Merge CONFLICT: Automatic merge of "${branchName}" into "${mergeTo}" failed. Please resolve conflicts manually.`,
|
error: result.error,
|
||||||
hasConflicts: true,
|
hasConflicts: true,
|
||||||
conflictFiles,
|
conflictFiles: result.conflictFiles,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-throw non-conflict errors to be handled by outer catch
|
// Non-conflict service errors (e.g. branch not found, invalid name)
|
||||||
throw mergeError;
|
res.status(400).json({
|
||||||
}
|
success: false,
|
||||||
|
error: result.error,
|
||||||
// If squash merge, need to commit
|
|
||||||
if (options?.squash) {
|
|
||||||
await execAsync(`git commit -m "${options?.message || `Merge ${branchName} (squash)`}"`, {
|
|
||||||
cwd: projectPath,
|
|
||||||
});
|
});
|
||||||
}
|
return;
|
||||||
|
|
||||||
// Optionally delete the worktree and branch after merging
|
|
||||||
let worktreeDeleted = false;
|
|
||||||
let branchDeleted = false;
|
|
||||||
|
|
||||||
if (options?.deleteWorktreeAndBranch) {
|
|
||||||
// Remove the worktree
|
|
||||||
try {
|
|
||||||
await execGitCommand(['worktree', 'remove', worktreePath, '--force'], projectPath);
|
|
||||||
worktreeDeleted = true;
|
|
||||||
} catch {
|
|
||||||
// Try with prune if remove fails
|
|
||||||
try {
|
|
||||||
await execGitCommand(['worktree', 'prune'], projectPath);
|
|
||||||
worktreeDeleted = true;
|
|
||||||
} catch {
|
|
||||||
logger.warn(`Failed to remove worktree: ${worktreePath}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete the branch (but not main/master)
|
|
||||||
if (branchName !== 'main' && branchName !== 'master') {
|
|
||||||
if (!isValidBranchName(branchName)) {
|
|
||||||
logger.warn(`Invalid branch name detected, skipping deletion: ${branchName}`);
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
await execGitCommand(['branch', '-D', branchName], projectPath);
|
|
||||||
branchDeleted = true;
|
|
||||||
} catch {
|
|
||||||
logger.warn(`Failed to delete branch: ${branchName}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
mergedBranch: branchName,
|
mergedBranch: result.mergedBranch,
|
||||||
targetBranch: mergeTo,
|
targetBranch: result.targetBranch,
|
||||||
deleted: options?.deleteWorktreeAndBranch ? { worktreeDeleted, branchDeleted } : undefined,
|
deleted: result.deleted,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError(error, 'Merge worktree failed');
|
logError(error, 'Merge worktree failed');
|
||||||
|
|||||||
@@ -9,12 +9,16 @@
|
|||||||
* 5. Detects merge conflicts from both pull and stash reapplication
|
* 5. Detects merge conflicts from both pull and stash reapplication
|
||||||
* 6. Returns structured conflict information for AI-assisted resolution
|
* 6. Returns structured conflict information for AI-assisted resolution
|
||||||
*
|
*
|
||||||
|
* Git business logic is delegated to pull-service.ts.
|
||||||
|
*
|
||||||
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
|
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
|
||||||
* the requireValidWorktree middleware in index.ts
|
* the requireValidWorktree middleware in index.ts
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { execGitCommand, getErrorMessage, logError } from '../common.js';
|
import { getErrorMessage, logError } from '../common.js';
|
||||||
|
import { performPull } from '../../../services/pull-service.js';
|
||||||
|
import type { PullResult } from '../../../services/pull-service.js';
|
||||||
|
|
||||||
export function createPullHandler() {
|
export function createPullHandler() {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
@@ -34,323 +38,66 @@ export function createPullHandler() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get current branch name
|
// Execute the pull via the service
|
||||||
const branchOutput = await execGitCommand(
|
const result = await performPull(worktreePath, { remote, stashIfNeeded });
|
||||||
['rev-parse', '--abbrev-ref', 'HEAD'],
|
|
||||||
worktreePath
|
|
||||||
);
|
|
||||||
const branchName = branchOutput.trim();
|
|
||||||
|
|
||||||
// Check for detached HEAD state
|
// Map service result to HTTP response
|
||||||
if (branchName === 'HEAD') {
|
mapResultToResponse(res, result);
|
||||||
res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error: 'Cannot pull in detached HEAD state. Please checkout a branch first.',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use specified remote or default to 'origin'
|
|
||||||
const targetRemote = remote || 'origin';
|
|
||||||
|
|
||||||
// Fetch latest from remote
|
|
||||||
try {
|
|
||||||
await execGitCommand(['fetch', targetRemote], worktreePath);
|
|
||||||
} catch (fetchError) {
|
|
||||||
const errorMsg = getErrorMessage(fetchError);
|
|
||||||
res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
error: `Failed to fetch from remote '${targetRemote}': ${errorMsg}`,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if there are local changes that would be overwritten
|
|
||||||
const statusOutput = await execGitCommand(['status', '--porcelain'], worktreePath);
|
|
||||||
const hasLocalChanges = statusOutput.trim().length > 0;
|
|
||||||
|
|
||||||
// Parse changed files for the response
|
|
||||||
let localChangedFiles: string[] = [];
|
|
||||||
if (hasLocalChanges) {
|
|
||||||
localChangedFiles = statusOutput
|
|
||||||
.trim()
|
|
||||||
.split('\n')
|
|
||||||
.filter((line) => line.trim().length > 0)
|
|
||||||
.map((line) => line.substring(3).trim());
|
|
||||||
}
|
|
||||||
|
|
||||||
// If there are local changes and stashIfNeeded is not requested, return info
|
|
||||||
if (hasLocalChanges && !stashIfNeeded) {
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
result: {
|
|
||||||
branch: branchName,
|
|
||||||
pulled: false,
|
|
||||||
hasLocalChanges: true,
|
|
||||||
localChangedFiles,
|
|
||||||
message:
|
|
||||||
'Local changes detected. Use stashIfNeeded to automatically stash and reapply changes.',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stash local changes if needed
|
|
||||||
let didStash = false;
|
|
||||||
if (hasLocalChanges && stashIfNeeded) {
|
|
||||||
try {
|
|
||||||
const stashMessage = `automaker-pull-stash: Pre-pull stash on ${branchName}`;
|
|
||||||
await execGitCommand(
|
|
||||||
['stash', 'push', '--include-untracked', '-m', stashMessage],
|
|
||||||
worktreePath
|
|
||||||
);
|
|
||||||
didStash = true;
|
|
||||||
} catch (stashError) {
|
|
||||||
const errorMsg = getErrorMessage(stashError);
|
|
||||||
res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
error: `Failed to stash local changes: ${errorMsg}`,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the branch has upstream tracking
|
|
||||||
let hasUpstream = false;
|
|
||||||
try {
|
|
||||||
await execGitCommand(
|
|
||||||
['rev-parse', '--abbrev-ref', `${branchName}@{upstream}`],
|
|
||||||
worktreePath
|
|
||||||
);
|
|
||||||
hasUpstream = true;
|
|
||||||
} catch {
|
|
||||||
// No upstream tracking - check if the remote branch exists
|
|
||||||
try {
|
|
||||||
await execGitCommand(
|
|
||||||
['rev-parse', '--verify', `${targetRemote}/${branchName}`],
|
|
||||||
worktreePath
|
|
||||||
);
|
|
||||||
hasUpstream = true; // Remote branch exists, we can pull from it
|
|
||||||
} catch {
|
|
||||||
// Remote branch doesn't exist either
|
|
||||||
if (didStash) {
|
|
||||||
// Reapply stash since we won't be pulling
|
|
||||||
try {
|
|
||||||
await execGitCommand(['stash', 'pop'], worktreePath);
|
|
||||||
} catch {
|
|
||||||
// Stash pop failed - leave it in stash list for manual recovery
|
|
||||||
}
|
|
||||||
}
|
|
||||||
res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error: `Branch '${branchName}' has no upstream branch on remote '${targetRemote}'. Push it first or set upstream with: git branch --set-upstream-to=${targetRemote}/${branchName}`,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pull latest changes
|
|
||||||
let pullConflict = false;
|
|
||||||
let pullConflictFiles: string[] = [];
|
|
||||||
try {
|
|
||||||
const pullOutput = await execGitCommand(['pull', targetRemote, branchName], worktreePath);
|
|
||||||
|
|
||||||
// Check if we pulled any changes
|
|
||||||
const alreadyUpToDate = pullOutput.includes('Already up to date');
|
|
||||||
|
|
||||||
// If no stash to reapply, return success
|
|
||||||
if (!didStash) {
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
result: {
|
|
||||||
branch: branchName,
|
|
||||||
pulled: !alreadyUpToDate,
|
|
||||||
hasLocalChanges: false,
|
|
||||||
stashed: false,
|
|
||||||
stashRestored: false,
|
|
||||||
message: alreadyUpToDate ? 'Already up to date' : 'Pulled latest changes',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch (pullError: unknown) {
|
|
||||||
const err = pullError as { stderr?: string; stdout?: string; message?: string };
|
|
||||||
const errorOutput = `${err.stderr || ''} ${err.stdout || ''} ${err.message || ''}`;
|
|
||||||
|
|
||||||
// Check for merge conflicts from the pull itself
|
|
||||||
if (errorOutput.includes('CONFLICT') || errorOutput.includes('Automatic merge failed')) {
|
|
||||||
pullConflict = true;
|
|
||||||
// Get list of conflicted files
|
|
||||||
try {
|
|
||||||
const diffOutput = await execGitCommand(
|
|
||||||
['diff', '--name-only', '--diff-filter=U'],
|
|
||||||
worktreePath
|
|
||||||
);
|
|
||||||
pullConflictFiles = diffOutput
|
|
||||||
.trim()
|
|
||||||
.split('\n')
|
|
||||||
.filter((f) => f.trim().length > 0);
|
|
||||||
} catch {
|
|
||||||
// If we can't get the file list, that's okay
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Non-conflict pull error
|
|
||||||
if (didStash) {
|
|
||||||
// Try to restore stash since pull failed
|
|
||||||
try {
|
|
||||||
await execGitCommand(['stash', 'pop'], worktreePath);
|
|
||||||
} catch {
|
|
||||||
// Leave stash in place for manual recovery
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for common errors
|
|
||||||
const errorMsg = err.stderr || err.message || 'Pull failed';
|
|
||||||
if (errorMsg.includes('no tracking information')) {
|
|
||||||
res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error: `Branch '${branchName}' has no upstream branch. Push it first or set upstream with: git branch --set-upstream-to=${targetRemote}/${branchName}`,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
error: errorMsg,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If pull had conflicts, return conflict info (don't try stash pop)
|
|
||||||
if (pullConflict) {
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
result: {
|
|
||||||
branch: branchName,
|
|
||||||
pulled: true,
|
|
||||||
hasConflicts: true,
|
|
||||||
conflictSource: 'pull',
|
|
||||||
conflictFiles: pullConflictFiles,
|
|
||||||
stashed: didStash,
|
|
||||||
stashRestored: false,
|
|
||||||
message:
|
|
||||||
`Pull resulted in merge conflicts. ${didStash ? 'Your local changes are still stashed.' : ''}`.trim(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pull succeeded, now try to reapply stash
|
|
||||||
if (didStash) {
|
|
||||||
try {
|
|
||||||
const stashPopOutput = await execGitCommand(['stash', 'pop'], worktreePath);
|
|
||||||
const stashPopCombined = stashPopOutput || '';
|
|
||||||
|
|
||||||
// Check if stash pop had conflicts
|
|
||||||
if (
|
|
||||||
stashPopCombined.includes('CONFLICT') ||
|
|
||||||
stashPopCombined.includes('Merge conflict')
|
|
||||||
) {
|
|
||||||
// Get conflicted files
|
|
||||||
let stashConflictFiles: string[] = [];
|
|
||||||
try {
|
|
||||||
const diffOutput = await execGitCommand(
|
|
||||||
['diff', '--name-only', '--diff-filter=U'],
|
|
||||||
worktreePath
|
|
||||||
);
|
|
||||||
stashConflictFiles = diffOutput
|
|
||||||
.trim()
|
|
||||||
.split('\n')
|
|
||||||
.filter((f) => f.trim().length > 0);
|
|
||||||
} catch {
|
|
||||||
// If we can't get the file list, that's okay
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
result: {
|
|
||||||
branch: branchName,
|
|
||||||
pulled: true,
|
|
||||||
hasConflicts: true,
|
|
||||||
conflictSource: 'stash',
|
|
||||||
conflictFiles: stashConflictFiles,
|
|
||||||
stashed: true,
|
|
||||||
stashRestored: true, // Stash was applied but with conflicts
|
|
||||||
message:
|
|
||||||
'Pull succeeded but reapplying your stashed changes resulted in merge conflicts.',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stash pop succeeded cleanly
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
result: {
|
|
||||||
branch: branchName,
|
|
||||||
pulled: true,
|
|
||||||
hasConflicts: false,
|
|
||||||
stashed: true,
|
|
||||||
stashRestored: true,
|
|
||||||
message: 'Pulled latest changes and restored your stashed changes.',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (stashPopError: unknown) {
|
|
||||||
const err = stashPopError as { stderr?: string; stdout?: string; message?: string };
|
|
||||||
const errorOutput = `${err.stderr || ''} ${err.stdout || ''} ${err.message || ''}`;
|
|
||||||
|
|
||||||
// Check if stash pop failed due to conflicts
|
|
||||||
if (errorOutput.includes('CONFLICT') || errorOutput.includes('Merge conflict')) {
|
|
||||||
let stashConflictFiles: string[] = [];
|
|
||||||
try {
|
|
||||||
const diffOutput = await execGitCommand(
|
|
||||||
['diff', '--name-only', '--diff-filter=U'],
|
|
||||||
worktreePath
|
|
||||||
);
|
|
||||||
stashConflictFiles = diffOutput
|
|
||||||
.trim()
|
|
||||||
.split('\n')
|
|
||||||
.filter((f) => f.trim().length > 0);
|
|
||||||
} catch {
|
|
||||||
// If we can't get the file list, that's okay
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
result: {
|
|
||||||
branch: branchName,
|
|
||||||
pulled: true,
|
|
||||||
hasConflicts: true,
|
|
||||||
conflictSource: 'stash',
|
|
||||||
conflictFiles: stashConflictFiles,
|
|
||||||
stashed: true,
|
|
||||||
stashRestored: true,
|
|
||||||
message:
|
|
||||||
'Pull succeeded but reapplying your stashed changes resulted in merge conflicts.',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Non-conflict stash pop error - stash is still in the stash list
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
result: {
|
|
||||||
branch: branchName,
|
|
||||||
pulled: true,
|
|
||||||
hasConflicts: false,
|
|
||||||
stashed: true,
|
|
||||||
stashRestored: false,
|
|
||||||
message:
|
|
||||||
'Pull succeeded but failed to reapply stashed changes. Your changes are still in the stash list.',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError(error, 'Pull failed');
|
logError(error, 'Pull failed');
|
||||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a PullResult from the service to the appropriate HTTP response.
|
||||||
|
*
|
||||||
|
* - Successful results (including local-changes-detected info) → 200
|
||||||
|
* - Validation/state errors (detached HEAD, no upstream) → 400
|
||||||
|
* - Operational errors (fetch/stash/pull failures) → 500
|
||||||
|
*/
|
||||||
|
function mapResultToResponse(res: Response, result: PullResult): void {
|
||||||
|
if (!result.success && result.error) {
|
||||||
|
// Determine the appropriate HTTP status for errors
|
||||||
|
const statusCode = isClientError(result.error) ? 400 : 500;
|
||||||
|
res.status(statusCode).json({
|
||||||
|
success: false,
|
||||||
|
error: result.error,
|
||||||
|
...(result.stashRecoveryFailed && { stashRecoveryFailed: true }),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success case (includes partial success like local changes detected, conflicts, etc.)
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
result: {
|
||||||
|
branch: result.branch,
|
||||||
|
pulled: result.pulled,
|
||||||
|
hasLocalChanges: result.hasLocalChanges,
|
||||||
|
localChangedFiles: result.localChangedFiles,
|
||||||
|
hasConflicts: result.hasConflicts,
|
||||||
|
conflictSource: result.conflictSource,
|
||||||
|
conflictFiles: result.conflictFiles,
|
||||||
|
stashed: result.stashed,
|
||||||
|
stashRestored: result.stashRestored,
|
||||||
|
message: result.message,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether an error message represents a client error (400)
|
||||||
|
* vs a server error (500).
|
||||||
|
*
|
||||||
|
* Client errors are validation issues or invalid git state that the user
|
||||||
|
* needs to resolve (e.g. detached HEAD, no upstream, no tracking info).
|
||||||
|
*/
|
||||||
|
function isClientError(errorMessage: string): boolean {
|
||||||
|
return (
|
||||||
|
errorMessage.includes('detached HEAD') ||
|
||||||
|
errorMessage.includes('has no upstream branch') ||
|
||||||
|
errorMessage.includes('no tracking information')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -96,6 +96,20 @@ export function createRebaseHandler(events: EventEmitter) {
|
|||||||
conflictFiles: result.conflictFiles,
|
conflictFiles: result.conflictFiles,
|
||||||
aborted: result.aborted,
|
aborted: result.aborted,
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
// Emit failure event for non-conflict failures
|
||||||
|
events.emit('rebase:failed', {
|
||||||
|
worktreePath: resolvedWorktreePath,
|
||||||
|
branch: result.branch,
|
||||||
|
ontoBranch: result.ontoBranch,
|
||||||
|
error: result.error,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: result.error ?? 'Rebase failed',
|
||||||
|
hasConflicts: false,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Emit failure event
|
// Emit failure event
|
||||||
|
|||||||
@@ -4,34 +4,15 @@
|
|||||||
* Applies a specific stash entry to the working directory.
|
* Applies a specific stash entry to the working directory.
|
||||||
* Can either "apply" (keep stash) or "pop" (remove stash after applying).
|
* Can either "apply" (keep stash) or "pop" (remove stash after applying).
|
||||||
*
|
*
|
||||||
|
* All git operations and conflict detection are delegated to StashService.
|
||||||
|
*
|
||||||
* Note: Git repository validation (isGitRepo) is handled by
|
* Note: Git repository validation (isGitRepo) is handled by
|
||||||
* the requireGitRepoOnly middleware in index.ts
|
* the requireGitRepoOnly middleware in index.ts
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { execFile } from 'child_process';
|
|
||||||
import { promisify } from 'util';
|
|
||||||
import { getErrorMessage, logError } from '../common.js';
|
import { getErrorMessage, logError } from '../common.js';
|
||||||
|
import { applyOrPop } from '../../../services/stash-service.js';
|
||||||
const execFileAsync = promisify(execFile);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieves the list of files with unmerged (conflicted) entries using git diff.
|
|
||||||
*/
|
|
||||||
async function getConflictedFiles(worktreePath: string): Promise<string[]> {
|
|
||||||
try {
|
|
||||||
const { stdout } = await execFileAsync('git', ['diff', '--name-only', '--diff-filter=U'], {
|
|
||||||
cwd: worktreePath,
|
|
||||||
});
|
|
||||||
return stdout
|
|
||||||
.trim()
|
|
||||||
.split('\n')
|
|
||||||
.filter((f) => f.trim().length > 0);
|
|
||||||
} catch {
|
|
||||||
// If we can't get the file list, return empty array
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createStashApplyHandler() {
|
export function createStashApplyHandler() {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
@@ -68,65 +49,26 @@ export function createStashApplyHandler() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const stashRef = `stash@{${idx}}`;
|
// Delegate all stash apply/pop logic to the service
|
||||||
const operation = pop ? 'pop' : 'apply';
|
const result = await applyOrPop(worktreePath, idx, { pop });
|
||||||
|
|
||||||
try {
|
if (!result.success) {
|
||||||
const { stdout, stderr } = await execFileAsync('git', ['stash', operation, stashRef], {
|
logError(new Error(result.error ?? 'Stash apply failed'), 'Stash apply failed');
|
||||||
cwd: worktreePath,
|
res.status(500).json({ success: false, error: result.error });
|
||||||
});
|
return;
|
||||||
|
|
||||||
const output = `${stdout}\n${stderr}`;
|
|
||||||
|
|
||||||
// Check for conflict markers in the output
|
|
||||||
if (output.includes('CONFLICT') || output.includes('Merge conflict')) {
|
|
||||||
const conflictFiles = await getConflictedFiles(worktreePath);
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
result: {
|
|
||||||
applied: true,
|
|
||||||
hasConflicts: true,
|
|
||||||
conflictFiles,
|
|
||||||
operation,
|
|
||||||
stashIndex,
|
|
||||||
message: `Stash ${operation === 'pop' ? 'popped' : 'applied'} with conflicts. Please resolve the conflicts.`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
result: {
|
|
||||||
applied: true,
|
|
||||||
hasConflicts: false,
|
|
||||||
operation,
|
|
||||||
stashIndex,
|
|
||||||
message: `Stash ${operation === 'pop' ? 'popped' : 'applied'} successfully`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
const errorMsg = getErrorMessage(error);
|
|
||||||
|
|
||||||
// Check if the error is due to conflicts
|
|
||||||
if (errorMsg.includes('CONFLICT') || errorMsg.includes('Merge conflict')) {
|
|
||||||
const conflictFiles = await getConflictedFiles(worktreePath);
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
result: {
|
|
||||||
applied: true,
|
|
||||||
hasConflicts: true,
|
|
||||||
conflictFiles,
|
|
||||||
operation,
|
|
||||||
stashIndex,
|
|
||||||
message: `Stash ${operation === 'pop' ? 'popped' : 'applied'} with conflicts. Please resolve the conflicts.`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
result: {
|
||||||
|
applied: result.applied,
|
||||||
|
hasConflicts: result.hasConflicts,
|
||||||
|
conflictFiles: result.conflictFiles,
|
||||||
|
operation: result.operation,
|
||||||
|
stashIndex: result.stashIndex,
|
||||||
|
message: result.message,
|
||||||
|
},
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError(error, 'Stash apply failed');
|
logError(error, 'Stash apply failed');
|
||||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||||
|
|||||||
@@ -1,20 +1,22 @@
|
|||||||
/**
|
/**
|
||||||
* POST /stash-drop endpoint - Drop (delete) a stash entry
|
* POST /stash-drop endpoint - Drop (delete) a stash entry
|
||||||
*
|
*
|
||||||
* Removes a specific stash entry from the stash list.
|
* The handler only validates input, invokes the service, streams lifecycle
|
||||||
|
* events via the EventEmitter, and sends the final JSON response.
|
||||||
|
*
|
||||||
|
* Git business logic is delegated to stash-service.ts.
|
||||||
|
* Events are emitted at key lifecycle points for WebSocket subscribers.
|
||||||
*
|
*
|
||||||
* Note: Git repository validation (isGitRepo) is handled by
|
* Note: Git repository validation (isGitRepo) is handled by
|
||||||
* the requireGitRepoOnly middleware in index.ts
|
* the requireGitRepoOnly middleware in index.ts
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { execFile } from 'child_process';
|
import type { EventEmitter } from '../../../lib/events.js';
|
||||||
import { promisify } from 'util';
|
|
||||||
import { getErrorMessage, logError } from '../common.js';
|
import { getErrorMessage, logError } from '../common.js';
|
||||||
|
import { dropStash } from '../../../services/stash-service.js';
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
export function createStashDropHandler(events: EventEmitter) {
|
||||||
|
|
||||||
export function createStashDropHandler() {
|
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const { worktreePath, stashIndex } = req.body as {
|
const { worktreePath, stashIndex } = req.body as {
|
||||||
@@ -38,21 +40,42 @@ export function createStashDropHandler() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const stashRef = `stash@{${stashIndex}}`;
|
// Emit start event so the frontend can observe progress
|
||||||
|
events.emit('stash:start', {
|
||||||
|
worktreePath,
|
||||||
|
stashIndex,
|
||||||
|
stashRef: `stash@{${stashIndex}}`,
|
||||||
|
operation: 'drop',
|
||||||
|
});
|
||||||
|
|
||||||
await execFileAsync('git', ['stash', 'drop', stashRef], {
|
// Delegate all Git work to the service
|
||||||
cwd: worktreePath,
|
const result = await dropStash(worktreePath, stashIndex);
|
||||||
|
|
||||||
|
// Emit success event
|
||||||
|
events.emit('stash:success', {
|
||||||
|
worktreePath,
|
||||||
|
stashIndex,
|
||||||
|
operation: 'drop',
|
||||||
|
dropped: result.dropped,
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
result: {
|
result: {
|
||||||
dropped: true,
|
dropped: result.dropped,
|
||||||
stashIndex,
|
stashIndex: result.stashIndex,
|
||||||
message: `Stash ${stashRef} dropped successfully`,
|
message: result.message,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// Emit error event so the frontend can react
|
||||||
|
events.emit('stash:failure', {
|
||||||
|
worktreePath: req.body?.worktreePath,
|
||||||
|
stashIndex: req.body?.stashIndex,
|
||||||
|
operation: 'drop',
|
||||||
|
error: getErrorMessage(error),
|
||||||
|
});
|
||||||
|
|
||||||
logError(error, 'Stash drop failed');
|
logError(error, 'Stash drop failed');
|
||||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +1,22 @@
|
|||||||
/**
|
/**
|
||||||
* POST /stash-list endpoint - List all stashes in a worktree
|
* POST /stash-list endpoint - List all stashes in a worktree
|
||||||
*
|
*
|
||||||
* Returns a list of all stash entries with their index, message, branch, and date.
|
* The handler only validates input, invokes the service, streams lifecycle
|
||||||
* Also includes the list of files changed in each stash.
|
* events via the EventEmitter, and sends the final JSON response.
|
||||||
|
*
|
||||||
|
* Git business logic is delegated to stash-service.ts.
|
||||||
|
* Events are emitted at key lifecycle points for WebSocket subscribers.
|
||||||
*
|
*
|
||||||
* Note: Git repository validation (isGitRepo) is handled by
|
* Note: Git repository validation (isGitRepo) is handled by
|
||||||
* the requireGitRepoOnly middleware in index.ts
|
* the requireGitRepoOnly middleware in index.ts
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { execFile } from 'child_process';
|
import type { EventEmitter } from '../../../lib/events.js';
|
||||||
import { promisify } from 'util';
|
|
||||||
import { getErrorMessage, logError } from '../common.js';
|
import { getErrorMessage, logError } from '../common.js';
|
||||||
|
import { listStash } from '../../../services/stash-service.js';
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
export function createStashListHandler(events: EventEmitter) {
|
||||||
|
|
||||||
interface StashEntry {
|
|
||||||
index: number;
|
|
||||||
message: string;
|
|
||||||
branch: string;
|
|
||||||
date: string;
|
|
||||||
files: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createStashListHandler() {
|
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const { worktreePath } = req.body as {
|
const { worktreePath } = req.body as {
|
||||||
@@ -38,84 +31,44 @@ export function createStashListHandler() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get stash list with format: index, message, date
|
// Emit start event so the frontend can observe progress
|
||||||
// Use %aI (strict ISO 8601) instead of %ai to ensure cross-browser compatibility
|
events.emit('stash:start', {
|
||||||
const { stdout: stashOutput } = await execFileAsync(
|
worktreePath,
|
||||||
'git',
|
operation: 'list',
|
||||||
['stash', 'list', '--format=%gd|||%s|||%aI'],
|
});
|
||||||
{ cwd: worktreePath }
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!stashOutput.trim()) {
|
// Delegate all Git work to the service
|
||||||
res.json({
|
const result = await listStash(worktreePath);
|
||||||
success: true,
|
|
||||||
result: {
|
|
||||||
stashes: [],
|
|
||||||
total: 0,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const stashLines = stashOutput
|
// Emit progress with stash count
|
||||||
.trim()
|
events.emit('stash:progress', {
|
||||||
.split('\n')
|
worktreePath,
|
||||||
.filter((l) => l.trim());
|
operation: 'list',
|
||||||
const stashes: StashEntry[] = [];
|
total: result.total,
|
||||||
|
});
|
||||||
|
|
||||||
for (const line of stashLines) {
|
// Emit success event
|
||||||
const parts = line.split('|||');
|
events.emit('stash:success', {
|
||||||
if (parts.length < 3) continue;
|
worktreePath,
|
||||||
|
operation: 'list',
|
||||||
const refSpec = parts[0].trim(); // e.g., "stash@{0}"
|
total: result.total,
|
||||||
const message = parts[1].trim();
|
});
|
||||||
const date = parts[2].trim();
|
|
||||||
|
|
||||||
// Extract index from stash@{N}; skip entries that don't match the expected format
|
|
||||||
const indexMatch = refSpec.match(/stash@\{(\d+)\}/);
|
|
||||||
if (!indexMatch) continue;
|
|
||||||
const index = parseInt(indexMatch[1], 10);
|
|
||||||
|
|
||||||
// Extract branch name from message (format: "WIP on branch: hash message" or "On branch: hash message")
|
|
||||||
let branch = '';
|
|
||||||
const branchMatch = message.match(/^(?:WIP on|On) ([^:]+):/);
|
|
||||||
if (branchMatch) {
|
|
||||||
branch = branchMatch[1];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get list of files in this stash
|
|
||||||
let files: string[] = [];
|
|
||||||
try {
|
|
||||||
const { stdout: filesOutput } = await execFileAsync(
|
|
||||||
'git',
|
|
||||||
['stash', 'show', refSpec, '--name-only'],
|
|
||||||
{ cwd: worktreePath }
|
|
||||||
);
|
|
||||||
files = filesOutput
|
|
||||||
.trim()
|
|
||||||
.split('\n')
|
|
||||||
.filter((f) => f.trim());
|
|
||||||
} catch {
|
|
||||||
// Ignore errors getting file list
|
|
||||||
}
|
|
||||||
|
|
||||||
stashes.push({
|
|
||||||
index,
|
|
||||||
message,
|
|
||||||
branch,
|
|
||||||
date,
|
|
||||||
files,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
result: {
|
result: {
|
||||||
stashes,
|
stashes: result.stashes,
|
||||||
total: stashes.length,
|
total: result.total,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// Emit error event so the frontend can react
|
||||||
|
events.emit('stash:failure', {
|
||||||
|
worktreePath: req.body?.worktreePath,
|
||||||
|
operation: 'list',
|
||||||
|
error: getErrorMessage(error),
|
||||||
|
});
|
||||||
|
|
||||||
logError(error, 'Stash list failed');
|
logError(error, 'Stash list failed');
|
||||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,22 @@
|
|||||||
/**
|
/**
|
||||||
* POST /stash-push endpoint - Stash changes in a worktree
|
* POST /stash-push endpoint - Stash changes in a worktree
|
||||||
*
|
*
|
||||||
* Stashes uncommitted changes (including untracked files) with an optional message.
|
* The handler only validates input, invokes the service, streams lifecycle
|
||||||
* Supports selective file stashing when a files array is provided.
|
* events via the EventEmitter, and sends the final JSON response.
|
||||||
|
*
|
||||||
|
* Git business logic is delegated to stash-service.ts.
|
||||||
|
* Events are emitted at key lifecycle points for WebSocket subscribers.
|
||||||
*
|
*
|
||||||
* Note: Git repository validation (isGitRepo) is handled by
|
* Note: Git repository validation (isGitRepo) is handled by
|
||||||
* the requireGitRepoOnly middleware in index.ts
|
* the requireGitRepoOnly middleware in index.ts
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { execFile } from 'child_process';
|
import type { EventEmitter } from '../../../lib/events.js';
|
||||||
import { promisify } from 'util';
|
|
||||||
import { getErrorMessage, logError } from '../common.js';
|
import { getErrorMessage, logError } from '../common.js';
|
||||||
|
import { pushStash } from '../../../services/stash-service.js';
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
export function createStashPushHandler(events: EventEmitter) {
|
||||||
|
|
||||||
export function createStashPushHandler() {
|
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const { worktreePath, message, files } = req.body as {
|
const { worktreePath, message, files } = req.body as {
|
||||||
@@ -32,54 +33,47 @@ export function createStashPushHandler() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for any changes to stash
|
// Emit start event so the frontend can observe progress
|
||||||
const { stdout: status } = await execFileAsync('git', ['status', '--porcelain'], {
|
events.emit('stash:start', {
|
||||||
cwd: worktreePath,
|
worktreePath,
|
||||||
|
operation: 'push',
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!status.trim()) {
|
// Delegate all Git work to the service
|
||||||
res.json({
|
const result = await pushStash(worktreePath, { message, files });
|
||||||
success: true,
|
|
||||||
result: {
|
|
||||||
stashed: false,
|
|
||||||
message: 'No changes to stash',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build stash push command args
|
// Emit progress with stash result
|
||||||
const args = ['stash', 'push', '--include-untracked'];
|
events.emit('stash:progress', {
|
||||||
if (message && message.trim()) {
|
worktreePath,
|
||||||
args.push('-m', message.trim());
|
operation: 'push',
|
||||||
}
|
stashed: result.stashed,
|
||||||
|
branch: result.branch,
|
||||||
|
});
|
||||||
|
|
||||||
// If specific files are provided, add them as pathspecs after '--'
|
// Emit success event
|
||||||
if (files && files.length > 0) {
|
events.emit('stash:success', {
|
||||||
args.push('--');
|
worktreePath,
|
||||||
args.push(...files);
|
operation: 'push',
|
||||||
}
|
stashed: result.stashed,
|
||||||
|
branch: result.branch,
|
||||||
// Execute stash push
|
});
|
||||||
await execFileAsync('git', args, { cwd: worktreePath });
|
|
||||||
|
|
||||||
// Get current branch name
|
|
||||||
const { stdout: branchOutput } = await execFileAsync(
|
|
||||||
'git',
|
|
||||||
['rev-parse', '--abbrev-ref', 'HEAD'],
|
|
||||||
{ cwd: worktreePath }
|
|
||||||
);
|
|
||||||
const branchName = branchOutput.trim();
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
result: {
|
result: {
|
||||||
stashed: true,
|
stashed: result.stashed,
|
||||||
branch: branchName,
|
branch: result.branch,
|
||||||
message: message?.trim() || `WIP on ${branchName}`,
|
message: result.message,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// Emit error event so the frontend can react
|
||||||
|
events.emit('stash:failure', {
|
||||||
|
worktreePath: req.body?.worktreePath,
|
||||||
|
operation: 'push',
|
||||||
|
error: getErrorMessage(error),
|
||||||
|
});
|
||||||
|
|
||||||
logError(error, 'Stash push failed');
|
logError(error, 'Stash push failed');
|
||||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,152 +11,19 @@
|
|||||||
*
|
*
|
||||||
* Also fetches the latest remote refs after switching.
|
* Also fetches the latest remote refs after switching.
|
||||||
*
|
*
|
||||||
|
* Git business logic is delegated to worktree-branch-service.ts.
|
||||||
|
* Events are emitted at key lifecycle points for WebSocket subscribers.
|
||||||
|
*
|
||||||
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
|
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
|
||||||
* the requireValidWorktree middleware in index.ts
|
* the requireValidWorktree middleware in index.ts
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { execFile } from 'child_process';
|
import { getErrorMessage, logError, isValidBranchName } from '../common.js';
|
||||||
import { promisify } from 'util';
|
import type { EventEmitter } from '../../../lib/events.js';
|
||||||
import { getErrorMessage, logError } from '../common.js';
|
import { performSwitchBranch } from '../../../services/worktree-branch-service.js';
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
export function createSwitchBranchHandler(events?: EventEmitter) {
|
||||||
|
|
||||||
function isExcludedWorktreeLine(line: string): boolean {
|
|
||||||
return line.includes('.worktrees/') || line.endsWith('.worktrees');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if there are any changes at all (including untracked) that should be stashed
|
|
||||||
*/
|
|
||||||
async function hasAnyChanges(cwd: string): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const { stdout } = await execFileAsync('git', ['status', '--porcelain'], { cwd });
|
|
||||||
const lines = stdout
|
|
||||||
.trim()
|
|
||||||
.split('\n')
|
|
||||||
.filter((line) => {
|
|
||||||
if (!line.trim()) return false;
|
|
||||||
if (isExcludedWorktreeLine(line)) return false;
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
return lines.length > 0;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stash all local changes (including untracked files)
|
|
||||||
* Returns true if a stash was created, false if there was nothing to stash
|
|
||||||
*/
|
|
||||||
async function stashChanges(cwd: string, message: string): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
// Get stash count before
|
|
||||||
const { stdout: beforeCount } = await execFileAsync('git', ['stash', 'list'], { cwd });
|
|
||||||
const countBefore = beforeCount
|
|
||||||
.trim()
|
|
||||||
.split('\n')
|
|
||||||
.filter((l) => l.trim()).length;
|
|
||||||
|
|
||||||
// Stash including untracked files
|
|
||||||
await execFileAsync('git', ['stash', 'push', '--include-untracked', '-m', message], { cwd });
|
|
||||||
|
|
||||||
// Get stash count after to verify something was stashed
|
|
||||||
const { stdout: afterCount } = await execFileAsync('git', ['stash', 'list'], { cwd });
|
|
||||||
const countAfter = afterCount
|
|
||||||
.trim()
|
|
||||||
.split('\n')
|
|
||||||
.filter((l) => l.trim()).length;
|
|
||||||
|
|
||||||
return countAfter > countBefore;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 {
|
|
||||||
const { stdout, stderr } = await execFileAsync('git', ['stash', 'pop'], { cwd });
|
|
||||||
const output = `${stdout}\n${stderr}`;
|
|
||||||
// Check for conflict markers in the output
|
|
||||||
if (output.includes('CONFLICT') || output.includes('Merge conflict')) {
|
|
||||||
return { success: false, hasConflicts: true };
|
|
||||||
}
|
|
||||||
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 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch latest from all remotes (silently, with timeout)
|
|
||||||
*/
|
|
||||||
async function fetchRemotes(cwd: string): Promise<void> {
|
|
||||||
try {
|
|
||||||
await execFileAsync('git', ['fetch', '--all', '--quiet'], {
|
|
||||||
cwd,
|
|
||||||
timeout: 15000, // 15 second timeout
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
// Ignore fetch errors - we may be offline
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse a remote branch name like "origin/feature-branch" into its parts
|
|
||||||
*/
|
|
||||||
function parseRemoteBranch(branchName: string): { remote: string; branch: string } | null {
|
|
||||||
const slashIndex = branchName.indexOf('/');
|
|
||||||
if (slashIndex === -1) return null;
|
|
||||||
return {
|
|
||||||
remote: branchName.substring(0, slashIndex),
|
|
||||||
branch: branchName.substring(slashIndex + 1),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a branch name refers to a remote branch
|
|
||||||
*/
|
|
||||||
async function isRemoteBranch(cwd: string, branchName: string): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const { stdout } = await execFileAsync('git', ['branch', '-r', '--format=%(refname:short)'], {
|
|
||||||
cwd,
|
|
||||||
});
|
|
||||||
const remoteBranches = stdout
|
|
||||||
.trim()
|
|
||||||
.split('\n')
|
|
||||||
.map((b) => b.trim().replace(/^['"]|['"]$/g, ''))
|
|
||||||
.filter((b) => b);
|
|
||||||
return remoteBranches.includes(branchName);
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a local branch already exists
|
|
||||||
*/
|
|
||||||
async function localBranchExists(cwd: string, branchName: string): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
await execFileAsync('git', ['rev-parse', '--verify', `refs/heads/${branchName}`], { cwd });
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createSwitchBranchHandler() {
|
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const { worktreePath, branchName } = req.body as {
|
const { worktreePath, branchName } = req.body as {
|
||||||
@@ -180,186 +47,58 @@ export function createSwitchBranchHandler() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get current branch
|
// Validate branch name using shared allowlist to prevent Git option injection
|
||||||
const { stdout: currentBranchOutput } = await execFileAsync(
|
if (!isValidBranchName(branchName)) {
|
||||||
'git',
|
res.status(400).json({
|
||||||
['rev-parse', '--abbrev-ref', 'HEAD'],
|
success: false,
|
||||||
{ cwd: worktreePath }
|
error: 'Invalid branch name',
|
||||||
);
|
|
||||||
const previousBranch = currentBranchOutput.trim();
|
|
||||||
|
|
||||||
// Determine the actual target branch name for checkout
|
|
||||||
let targetBranch = branchName;
|
|
||||||
let isRemote = false;
|
|
||||||
|
|
||||||
// Check if this is a remote branch (e.g., "origin/feature-branch")
|
|
||||||
let parsedRemote: { remote: string; branch: string } | null = null;
|
|
||||||
if (await isRemoteBranch(worktreePath, branchName)) {
|
|
||||||
isRemote = true;
|
|
||||||
parsedRemote = parseRemoteBranch(branchName);
|
|
||||||
if (parsedRemote) {
|
|
||||||
targetBranch = parsedRemote.branch;
|
|
||||||
} else {
|
|
||||||
res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error: `Failed to parse remote branch name '${branchName}'`,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (previousBranch === targetBranch) {
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
result: {
|
|
||||||
previousBranch,
|
|
||||||
currentBranch: targetBranch,
|
|
||||||
message: `Already on branch '${targetBranch}'`,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if target branch exists (locally or as remote ref)
|
// Execute the branch switch via the service
|
||||||
if (!isRemote) {
|
const result = await performSwitchBranch(worktreePath, branchName, events);
|
||||||
try {
|
|
||||||
await execFileAsync('git', ['rev-parse', '--verify', branchName], {
|
// Map service result to HTTP response
|
||||||
cwd: worktreePath,
|
if (!result.success) {
|
||||||
});
|
// Determine status code based on error type
|
||||||
} catch {
|
const statusCode = isBranchNotFoundError(result.error) ? 400 : 500;
|
||||||
res.status(400).json({
|
res.status(statusCode).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: `Branch '${branchName}' does not exist`,
|
error: result.error,
|
||||||
});
|
...(result.stashPopConflicts !== undefined && {
|
||||||
return;
|
stashPopConflicts: result.stashPopConflicts,
|
||||||
}
|
}),
|
||||||
|
...(result.stashPopConflictMessage && {
|
||||||
|
stashPopConflictMessage: result.stashPopConflictMessage,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stash local changes if any exist
|
res.json({
|
||||||
const hadChanges = await hasAnyChanges(worktreePath);
|
success: true,
|
||||||
let didStash = false;
|
result: result.result,
|
||||||
|
});
|
||||||
if (hadChanges) {
|
|
||||||
const stashMessage = `automaker-branch-switch: ${previousBranch} → ${targetBranch}`;
|
|
||||||
didStash = await stashChanges(worktreePath, stashMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Switch to the target branch
|
|
||||||
if (isRemote) {
|
|
||||||
if (!parsedRemote) {
|
|
||||||
throw new Error(`Failed to parse remote branch name '${branchName}'`);
|
|
||||||
}
|
|
||||||
if (await localBranchExists(worktreePath, parsedRemote.branch)) {
|
|
||||||
// Local branch exists, just checkout
|
|
||||||
await execFileAsync('git', ['checkout', parsedRemote.branch], { cwd: worktreePath });
|
|
||||||
} else {
|
|
||||||
// Create local tracking branch from remote
|
|
||||||
await execFileAsync('git', ['checkout', '-b', parsedRemote.branch, branchName], {
|
|
||||||
cwd: worktreePath,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
await execFileAsync('git', ['checkout', targetBranch], { cwd: worktreePath });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch latest from remotes after switching
|
|
||||||
await fetchRemotes(worktreePath);
|
|
||||||
|
|
||||||
// Reapply stashed changes if we stashed earlier
|
|
||||||
let hasConflicts = false;
|
|
||||||
let conflictMessage = '';
|
|
||||||
let stashReapplied = false;
|
|
||||||
|
|
||||||
if (didStash) {
|
|
||||||
const popResult = await popStash(worktreePath);
|
|
||||||
hasConflicts = popResult.hasConflicts;
|
|
||||||
if (popResult.hasConflicts) {
|
|
||||||
conflictMessage = `Switched to branch '${targetBranch}' but merge conflicts occurred when reapplying your local changes. Please resolve the conflicts.`;
|
|
||||||
} else if (!popResult.success) {
|
|
||||||
// Stash pop failed for a non-conflict reason - the stash is still there
|
|
||||||
conflictMessage = `Switched to branch '${targetBranch}' but failed to reapply stashed changes: ${popResult.error}. Your changes are still in the stash.`;
|
|
||||||
} else {
|
|
||||||
stashReapplied = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasConflicts) {
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
result: {
|
|
||||||
previousBranch,
|
|
||||||
currentBranch: targetBranch,
|
|
||||||
message: conflictMessage,
|
|
||||||
hasConflicts: true,
|
|
||||||
stashedChanges: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} else if (didStash && !stashReapplied) {
|
|
||||||
// Stash pop failed for a non-conflict reason — stash is still present
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
result: {
|
|
||||||
previousBranch,
|
|
||||||
currentBranch: targetBranch,
|
|
||||||
message: conflictMessage,
|
|
||||||
hasConflicts: false,
|
|
||||||
stashedChanges: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const stashNote = stashReapplied ? ' (local changes stashed and reapplied)' : '';
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
result: {
|
|
||||||
previousBranch,
|
|
||||||
currentBranch: targetBranch,
|
|
||||||
message: `Switched to branch '${targetBranch}'${stashNote}`,
|
|
||||||
hasConflicts: false,
|
|
||||||
stashedChanges: stashReapplied,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (checkoutError) {
|
|
||||||
// If checkout failed and we stashed, try to restore the stash
|
|
||||||
if (didStash) {
|
|
||||||
const popResult = await popStash(worktreePath);
|
|
||||||
if (popResult.hasConflicts) {
|
|
||||||
// Stash pop itself produced merge conflicts — the working tree is now in a
|
|
||||||
// conflicted state even though the checkout failed. Surface this clearly so
|
|
||||||
// the caller can prompt the user (or AI) to resolve conflicts rather than
|
|
||||||
// simply retrying the branch switch.
|
|
||||||
const checkoutErrorMsg = getErrorMessage(checkoutError);
|
|
||||||
res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
error: checkoutErrorMsg,
|
|
||||||
stashPopConflicts: true,
|
|
||||||
stashPopConflictMessage:
|
|
||||||
'Stash pop resulted in conflicts: your stashed changes were partially reapplied ' +
|
|
||||||
'but produced merge conflicts. Please resolve the conflicts before retrying the branch switch.',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
} else if (!popResult.success) {
|
|
||||||
// Stash pop failed for a non-conflict reason; the stash entry is still intact.
|
|
||||||
// Include this detail alongside the original checkout error.
|
|
||||||
const checkoutErrorMsg = getErrorMessage(checkoutError);
|
|
||||||
const combinedMessage =
|
|
||||||
`${checkoutErrorMsg}. Additionally, restoring your stashed changes failed: ` +
|
|
||||||
`${popResult.error ?? 'unknown error'} — your changes are still saved in the stash.`;
|
|
||||||
res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
error: combinedMessage,
|
|
||||||
stashPopConflicts: false,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// popResult.success === true: stash was cleanly restored, re-throw the checkout error
|
|
||||||
}
|
|
||||||
throw checkoutError;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
events?.emit('switch:error', {
|
||||||
|
error: getErrorMessage(error),
|
||||||
|
});
|
||||||
|
|
||||||
logError(error, 'Switch branch failed');
|
logError(error, 'Switch 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)
|
||||||
|
* vs a server error (500).
|
||||||
|
*
|
||||||
|
* Client errors are validation issues like non-existent branches or
|
||||||
|
* unparseable remote branch names.
|
||||||
|
*/
|
||||||
|
function isBranchNotFoundError(error?: string): boolean {
|
||||||
|
if (!error) return false;
|
||||||
|
return error.includes('does not exist') || error.includes('Failed to parse remote branch name');
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,10 +17,11 @@ import { promisify } from 'util';
|
|||||||
import type { Feature, PlanningMode, ThinkingLevel } from '@automaker/types';
|
import type { Feature, PlanningMode, ThinkingLevel } from '@automaker/types';
|
||||||
import { DEFAULT_MAX_CONCURRENCY, stripProviderPrefix } from '@automaker/types';
|
import { DEFAULT_MAX_CONCURRENCY, stripProviderPrefix } from '@automaker/types';
|
||||||
import { createLogger, loadContextFiles, classifyError } from '@automaker/utils';
|
import { createLogger, loadContextFiles, classifyError } from '@automaker/utils';
|
||||||
import { getFeatureDir, spawnProcess } from '@automaker/platform';
|
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 { 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';
|
||||||
@@ -49,24 +50,6 @@ import type {
|
|||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
const logger = createLogger('AutoModeServiceFacade');
|
const logger = createLogger('AutoModeServiceFacade');
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute git command with array arguments to prevent command injection.
|
|
||||||
*/
|
|
||||||
async function execGitCommand(args: string[], cwd: string): Promise<string> {
|
|
||||||
const result = await spawnProcess({
|
|
||||||
command: 'git',
|
|
||||||
args,
|
|
||||||
cwd,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.exitCode === 0) {
|
|
||||||
return result.stdout;
|
|
||||||
} else {
|
|
||||||
const errorMessage = result.stderr || `Git command failed with code ${result.exitCode}`;
|
|
||||||
throw new Error(errorMessage);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AutoModeServiceFacade provides a clean interface for auto-mode functionality.
|
* AutoModeServiceFacade provides a clean interface for auto-mode functionality.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
* invokes this service, streams lifecycle events, and sends the response.
|
* invokes this service, streams lifecycle events, and sends the response.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { execGitCommand } from '../routes/worktree/common.js';
|
import { execGitCommand } from '../lib/git.js';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Types
|
// Types
|
||||||
@@ -68,8 +68,18 @@ export async function getBranchCommitLog(
|
|||||||
// parent), so we deduplicate by hash below and merge their file lists.
|
// parent), so we deduplicate by hash below and merge their file lists.
|
||||||
// We over-fetch (2× the limit) to compensate for -m duplicating merge
|
// We over-fetch (2× the limit) to compensate for -m duplicating merge
|
||||||
// commit entries, then trim the result to the requested limit.
|
// commit entries, then trim the result to the requested limit.
|
||||||
const COMMIT_SEP = '---COMMIT---';
|
// Use ASCII control characters as record separators – these cannot appear in
|
||||||
const META_END = '---META_END---';
|
// git commit messages, so these delimiters are safe regardless of commit
|
||||||
|
// body content. %x00 and %x01 in git's format string emit literal NUL /
|
||||||
|
// SOH bytes respectively.
|
||||||
|
//
|
||||||
|
// COMMIT_SEP (\x00) – marks the start of each commit record.
|
||||||
|
// META_END (\x01) – separates commit metadata from the --name-only file list.
|
||||||
|
//
|
||||||
|
// Full per-commit layout emitted by git:
|
||||||
|
// \x00\n<hash>\n<shorthash>\n...\n<subject>\n<body>\x01<files...>
|
||||||
|
const COMMIT_SEP = '\x00';
|
||||||
|
const META_END = '\x01';
|
||||||
const fetchLimit = commitLimit * 2;
|
const fetchLimit = commitLimit * 2;
|
||||||
|
|
||||||
const logOutput = await execGitCommand(
|
const logOutput = await execGitCommand(
|
||||||
@@ -79,13 +89,13 @@ export async function getBranchCommitLog(
|
|||||||
`--max-count=${fetchLimit}`,
|
`--max-count=${fetchLimit}`,
|
||||||
'-m',
|
'-m',
|
||||||
'--name-only',
|
'--name-only',
|
||||||
`--format=${COMMIT_SEP}%n%H%n%h%n%an%n%ae%n%aI%n%s%n%b${META_END}`,
|
`--format=%x00%n%H%n%h%n%an%n%ae%n%aI%n%s%n%b%x01`,
|
||||||
],
|
],
|
||||||
worktreePath
|
worktreePath
|
||||||
);
|
);
|
||||||
|
|
||||||
// Split output into per-commit blocks and drop the empty first chunk
|
// Split output into per-commit blocks and drop the empty first chunk
|
||||||
// (the output starts with ---COMMIT---).
|
// (the output starts with a NUL commit separator).
|
||||||
const commitBlocks = logOutput.split(COMMIT_SEP).filter((block) => block.trim());
|
const commitBlocks = logOutput.split(COMMIT_SEP).filter((block) => block.trim());
|
||||||
|
|
||||||
// Use a Map to deduplicate merge commit entries (which appear once per
|
// Use a Map to deduplicate merge commit entries (which appear once per
|
||||||
@@ -96,7 +106,7 @@ export async function getBranchCommitLog(
|
|||||||
const metaEndIdx = block.indexOf(META_END);
|
const metaEndIdx = block.indexOf(META_END);
|
||||||
if (metaEndIdx === -1) continue; // malformed block, skip
|
if (metaEndIdx === -1) continue; // malformed block, skip
|
||||||
|
|
||||||
// --- Parse metadata (everything before ---META_END---) ---
|
// --- Parse metadata (everything before the META_END delimiter) ---
|
||||||
const metaRaw = block.substring(0, metaEndIdx);
|
const metaRaw = block.substring(0, metaEndIdx);
|
||||||
const metaLines = metaRaw.split('\n');
|
const metaLines = metaRaw.split('\n');
|
||||||
|
|
||||||
@@ -108,14 +118,15 @@ export async function getBranchCommitLog(
|
|||||||
if (fields.length < 6) continue; // need at least hash..subject
|
if (fields.length < 6) continue; // need at least hash..subject
|
||||||
|
|
||||||
const hash = fields[0].trim();
|
const hash = fields[0].trim();
|
||||||
const shortHash = fields[1].trim();
|
if (!hash) continue; // defensive: skip if hash is empty
|
||||||
const author = fields[2].trim();
|
const shortHash = fields[1]?.trim() ?? '';
|
||||||
const authorEmail = fields[3].trim();
|
const author = fields[2]?.trim() ?? '';
|
||||||
const date = fields[4].trim();
|
const authorEmail = fields[3]?.trim() ?? '';
|
||||||
const subject = fields[5].trim();
|
const date = fields[4]?.trim() ?? '';
|
||||||
|
const subject = fields[5]?.trim() ?? '';
|
||||||
const body = fields.slice(6).join('\n').trim();
|
const body = fields.slice(6).join('\n').trim();
|
||||||
|
|
||||||
// --- Parse file list (everything after ---META_END---) ---
|
// --- Parse file list (everything after the META_END delimiter) ---
|
||||||
const filesRaw = block.substring(metaEndIdx + META_END.length);
|
const filesRaw = block.substring(metaEndIdx + META_END.length);
|
||||||
const blockFiles = filesRaw
|
const blockFiles = filesRaw
|
||||||
.trim()
|
.trim()
|
||||||
|
|||||||
@@ -7,7 +7,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { createLogger } from '@automaker/utils';
|
import { createLogger } from '@automaker/utils';
|
||||||
import { execGitCommand } from '../routes/worktree/common.js';
|
import { execGitCommand, getCurrentBranch } from '../lib/git.js';
|
||||||
|
import { type EventEmitter } from '../lib/events.js';
|
||||||
|
|
||||||
const logger = createLogger('CherryPickService');
|
const logger = createLogger('CherryPickService');
|
||||||
|
|
||||||
@@ -39,16 +40,19 @@ export interface CherryPickResult {
|
|||||||
*
|
*
|
||||||
* @param worktreePath - Path to the git worktree
|
* @param worktreePath - Path to the git worktree
|
||||||
* @param commitHashes - Array of commit hashes to verify
|
* @param commitHashes - Array of commit hashes to verify
|
||||||
|
* @param emitter - Optional event emitter for lifecycle events
|
||||||
* @returns The first invalid commit hash, or null if all are valid
|
* @returns The first invalid commit hash, or null if all are valid
|
||||||
*/
|
*/
|
||||||
export async function verifyCommits(
|
export async function verifyCommits(
|
||||||
worktreePath: string,
|
worktreePath: string,
|
||||||
commitHashes: string[]
|
commitHashes: string[],
|
||||||
|
emitter?: EventEmitter
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
for (const hash of commitHashes) {
|
for (const hash of commitHashes) {
|
||||||
try {
|
try {
|
||||||
await execGitCommand(['rev-parse', '--verify', hash], worktreePath);
|
await execGitCommand(['rev-parse', '--verify', hash], worktreePath);
|
||||||
} catch {
|
} catch {
|
||||||
|
emitter?.emit('cherry-pick:verify-failed', { worktreePath, hash });
|
||||||
return hash;
|
return hash;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -61,12 +65,14 @@ export async function verifyCommits(
|
|||||||
* @param worktreePath - Path to the git worktree
|
* @param worktreePath - Path to the git worktree
|
||||||
* @param commitHashes - Array of commit hashes to cherry-pick (in order)
|
* @param commitHashes - Array of commit hashes to cherry-pick (in order)
|
||||||
* @param options - Cherry-pick options (e.g., noCommit)
|
* @param options - Cherry-pick options (e.g., noCommit)
|
||||||
|
* @param emitter - Optional event emitter for lifecycle events
|
||||||
* @returns CherryPickResult with success/failure information
|
* @returns CherryPickResult with success/failure information
|
||||||
*/
|
*/
|
||||||
export async function runCherryPick(
|
export async function runCherryPick(
|
||||||
worktreePath: string,
|
worktreePath: string,
|
||||||
commitHashes: string[],
|
commitHashes: string[],
|
||||||
options?: CherryPickOptions
|
options?: CherryPickOptions,
|
||||||
|
emitter?: EventEmitter
|
||||||
): Promise<CherryPickResult> {
|
): Promise<CherryPickResult> {
|
||||||
const args = ['cherry-pick'];
|
const args = ['cherry-pick'];
|
||||||
if (options?.noCommit) {
|
if (options?.noCommit) {
|
||||||
@@ -74,28 +80,34 @@ export async function runCherryPick(
|
|||||||
}
|
}
|
||||||
args.push(...commitHashes);
|
args.push(...commitHashes);
|
||||||
|
|
||||||
|
emitter?.emit('cherry-pick:started', { worktreePath, commitHashes });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await execGitCommand(args, worktreePath);
|
await execGitCommand(args, worktreePath);
|
||||||
|
|
||||||
const branch = await getCurrentBranch(worktreePath);
|
const branch = await getCurrentBranch(worktreePath);
|
||||||
|
|
||||||
if (options?.noCommit) {
|
if (options?.noCommit) {
|
||||||
return {
|
const result: CherryPickResult = {
|
||||||
success: true,
|
success: true,
|
||||||
cherryPicked: false,
|
cherryPicked: false,
|
||||||
commitHashes,
|
commitHashes,
|
||||||
branch,
|
branch,
|
||||||
message: `Staged changes from ${commitHashes.length} commit(s); no commit created due to --no-commit`,
|
message: `Staged changes from ${commitHashes.length} commit(s); no commit created due to --no-commit`,
|
||||||
};
|
};
|
||||||
|
emitter?.emit('cherry-pick:success', { worktreePath, commitHashes, branch });
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
const result: CherryPickResult = {
|
||||||
success: true,
|
success: true,
|
||||||
cherryPicked: true,
|
cherryPicked: true,
|
||||||
commitHashes,
|
commitHashes,
|
||||||
branch,
|
branch,
|
||||||
message: `Successfully cherry-picked ${commitHashes.length} commit(s)`,
|
message: `Successfully cherry-picked ${commitHashes.length} commit(s)`,
|
||||||
};
|
};
|
||||||
|
emitter?.emit('cherry-pick:success', { worktreePath, commitHashes, branch });
|
||||||
|
return result;
|
||||||
} catch (cherryPickError: unknown) {
|
} catch (cherryPickError: unknown) {
|
||||||
// Check if this is a cherry-pick conflict
|
// Check if this is a cherry-pick conflict
|
||||||
const err = cherryPickError as { stdout?: string; stderr?: string; message?: string };
|
const err = cherryPickError as { stdout?: string; stderr?: string; message?: string };
|
||||||
@@ -107,7 +119,7 @@ export async function runCherryPick(
|
|||||||
|
|
||||||
if (hasConflicts) {
|
if (hasConflicts) {
|
||||||
// Abort the cherry-pick to leave the repo in a clean state
|
// Abort the cherry-pick to leave the repo in a clean state
|
||||||
const aborted = await abortCherryPick(worktreePath);
|
const aborted = await abortCherryPick(worktreePath, emitter);
|
||||||
|
|
||||||
if (!aborted) {
|
if (!aborted) {
|
||||||
logger.error(
|
logger.error(
|
||||||
@@ -116,6 +128,14 @@ export async function runCherryPick(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
emitter?.emit('cherry-pick:conflict', {
|
||||||
|
worktreePath,
|
||||||
|
commitHashes,
|
||||||
|
aborted,
|
||||||
|
stdout: err.stdout,
|
||||||
|
stderr: err.stderr,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: aborted
|
error: aborted
|
||||||
@@ -135,25 +155,25 @@ export async function runCherryPick(
|
|||||||
* Abort an in-progress cherry-pick operation.
|
* Abort an in-progress cherry-pick operation.
|
||||||
*
|
*
|
||||||
* @param worktreePath - Path to the git worktree
|
* @param worktreePath - Path to the git worktree
|
||||||
|
* @param emitter - Optional event emitter for lifecycle events
|
||||||
* @returns true if abort succeeded, false if it failed (logged as warning)
|
* @returns true if abort succeeded, false if it failed (logged as warning)
|
||||||
*/
|
*/
|
||||||
export async function abortCherryPick(worktreePath: string): Promise<boolean> {
|
export async function abortCherryPick(
|
||||||
|
worktreePath: string,
|
||||||
|
emitter?: EventEmitter
|
||||||
|
): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
await execGitCommand(['cherry-pick', '--abort'], worktreePath);
|
await execGitCommand(['cherry-pick', '--abort'], worktreePath);
|
||||||
|
emitter?.emit('cherry-pick:abort', { worktreePath, aborted: true });
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch (err: unknown) {
|
||||||
|
const error = err as { message?: string };
|
||||||
logger.warn('Failed to abort cherry-pick after conflict');
|
logger.warn('Failed to abort cherry-pick after conflict');
|
||||||
|
emitter?.emit('cherry-pick:abort', {
|
||||||
|
worktreePath,
|
||||||
|
aborted: false,
|
||||||
|
error: error.message ?? 'Unknown error during cherry-pick abort',
|
||||||
|
});
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the current branch name for the worktree.
|
|
||||||
*
|
|
||||||
* @param worktreePath - Path to the git worktree
|
|
||||||
* @returns The current branch name
|
|
||||||
*/
|
|
||||||
export async function getCurrentBranch(worktreePath: string): Promise<string> {
|
|
||||||
const branchOutput = await execGitCommand(['rev-parse', '--abbrev-ref', 'HEAD'], worktreePath);
|
|
||||||
return branchOutput.trim();
|
|
||||||
}
|
|
||||||
|
|||||||
161
apps/server/src/services/commit-log-service.ts
Normal file
161
apps/server/src/services/commit-log-service.ts
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
/**
|
||||||
|
* Service for fetching commit log data from a worktree.
|
||||||
|
*
|
||||||
|
* Extracts the heavy Git command execution and parsing logic from the
|
||||||
|
* commit-log route handler so the handler only validates input,
|
||||||
|
* invokes this service, streams lifecycle events, and sends the response.
|
||||||
|
*
|
||||||
|
* Follows the same approach as branch-commit-log-service: a single
|
||||||
|
* `git log --name-only` call with custom separators to fetch both
|
||||||
|
* commit metadata and file lists, avoiding N+1 git invocations.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { execGitCommand } from '../lib/git.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface CommitLogEntry {
|
||||||
|
hash: string;
|
||||||
|
shortHash: string;
|
||||||
|
author: string;
|
||||||
|
authorEmail: string;
|
||||||
|
date: string;
|
||||||
|
subject: string;
|
||||||
|
body: string;
|
||||||
|
files: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommitLogResult {
|
||||||
|
branch: string;
|
||||||
|
commits: CommitLogEntry[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Service
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the commit log for a worktree (HEAD).
|
||||||
|
*
|
||||||
|
* Runs a single `git log --name-only` invocation plus `git rev-parse`
|
||||||
|
* inside the given worktree path and returns a structured result.
|
||||||
|
*
|
||||||
|
* @param worktreePath - Absolute path to the worktree / repository
|
||||||
|
* @param limit - Maximum number of commits to return (clamped 1-100)
|
||||||
|
*/
|
||||||
|
export async function getCommitLog(worktreePath: string, limit: number): Promise<CommitLogResult> {
|
||||||
|
// Clamp limit to a reasonable range
|
||||||
|
const parsedLimit = Number(limit);
|
||||||
|
const commitLimit = Math.min(Math.max(1, Number.isFinite(parsedLimit) ? parsedLimit : 20), 100);
|
||||||
|
|
||||||
|
// Use custom separators to parse both metadata and file lists from
|
||||||
|
// a single git log invocation (same approach as branch-commit-log-service).
|
||||||
|
//
|
||||||
|
// -m causes merge commits to be diffed against each parent so all
|
||||||
|
// files touched by the merge are listed (without -m, --name-only
|
||||||
|
// produces no file output for merge commits because they have 2+ parents).
|
||||||
|
// This means merge commits appear multiple times in the output (once per
|
||||||
|
// parent), so we deduplicate by hash below and merge their file lists.
|
||||||
|
// We over-fetch (2x the limit) to compensate for -m duplicating merge
|
||||||
|
// commit entries, then trim the result to the requested limit.
|
||||||
|
// Use ASCII control characters as record separators – these cannot appear in
|
||||||
|
// git commit messages, so these delimiters are safe regardless of commit
|
||||||
|
// body content. %x00 and %x01 in git's format string emit literal NUL /
|
||||||
|
// SOH bytes respectively.
|
||||||
|
//
|
||||||
|
// COMMIT_SEP (\x00) – marks the start of each commit record.
|
||||||
|
// META_END (\x01) – separates commit metadata from the --name-only file list.
|
||||||
|
//
|
||||||
|
// Full per-commit layout emitted by git:
|
||||||
|
// \x00\n<hash>\n<shorthash>\n...\n<subject>\n<body>\x01<files...>
|
||||||
|
const COMMIT_SEP = '\x00';
|
||||||
|
const META_END = '\x01';
|
||||||
|
const fetchLimit = commitLimit * 2;
|
||||||
|
|
||||||
|
const logOutput = await execGitCommand(
|
||||||
|
[
|
||||||
|
'log',
|
||||||
|
`--max-count=${fetchLimit}`,
|
||||||
|
'-m',
|
||||||
|
'--name-only',
|
||||||
|
`--format=%x00%n%H%n%h%n%an%n%ae%n%aI%n%s%n%b%x01`,
|
||||||
|
],
|
||||||
|
worktreePath
|
||||||
|
);
|
||||||
|
|
||||||
|
// Split output into per-commit blocks and drop the empty first chunk
|
||||||
|
// (the output starts with a NUL commit separator).
|
||||||
|
const commitBlocks = logOutput.split(COMMIT_SEP).filter((block) => block.trim());
|
||||||
|
|
||||||
|
// Use a Map to deduplicate merge commit entries (which appear once per
|
||||||
|
// parent when -m is used) while preserving insertion order.
|
||||||
|
const commitMap = new Map<string, CommitLogEntry>();
|
||||||
|
|
||||||
|
for (const block of commitBlocks) {
|
||||||
|
const metaEndIdx = block.indexOf(META_END);
|
||||||
|
if (metaEndIdx === -1) continue; // malformed block, skip
|
||||||
|
|
||||||
|
// --- Parse metadata (everything before the META_END delimiter) ---
|
||||||
|
const metaRaw = block.substring(0, metaEndIdx);
|
||||||
|
const metaLines = metaRaw.split('\n');
|
||||||
|
|
||||||
|
// The first line may be empty (newline right after COMMIT_SEP), skip it
|
||||||
|
const nonEmptyStart = metaLines.findIndex((l) => l.trim() !== '');
|
||||||
|
if (nonEmptyStart === -1) continue;
|
||||||
|
|
||||||
|
const fields = metaLines.slice(nonEmptyStart);
|
||||||
|
if (fields.length < 6) continue; // need at least hash..subject
|
||||||
|
|
||||||
|
const hash = fields[0].trim();
|
||||||
|
if (!hash) continue; // defensive: skip if hash is empty
|
||||||
|
const shortHash = fields[1]?.trim() ?? '';
|
||||||
|
const author = fields[2]?.trim() ?? '';
|
||||||
|
const authorEmail = fields[3]?.trim() ?? '';
|
||||||
|
const date = fields[4]?.trim() ?? '';
|
||||||
|
const subject = fields[5]?.trim() ?? '';
|
||||||
|
const body = fields.slice(6).join('\n').trim();
|
||||||
|
|
||||||
|
// --- Parse file list (everything after the META_END delimiter) ---
|
||||||
|
const filesRaw = block.substring(metaEndIdx + META_END.length);
|
||||||
|
const blockFiles = filesRaw
|
||||||
|
.trim()
|
||||||
|
.split('\n')
|
||||||
|
.filter((f) => f.trim());
|
||||||
|
|
||||||
|
// Merge file lists for duplicate entries (merge commits with -m)
|
||||||
|
const existing = commitMap.get(hash);
|
||||||
|
if (existing) {
|
||||||
|
// Add new files to the existing entry's file set
|
||||||
|
const fileSet = new Set(existing.files);
|
||||||
|
for (const f of blockFiles) fileSet.add(f);
|
||||||
|
existing.files = [...fileSet];
|
||||||
|
} else {
|
||||||
|
commitMap.set(hash, {
|
||||||
|
hash,
|
||||||
|
shortHash,
|
||||||
|
author,
|
||||||
|
authorEmail,
|
||||||
|
date,
|
||||||
|
subject,
|
||||||
|
body,
|
||||||
|
files: [...new Set(blockFiles)],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trim to the requested limit (we over-fetched to account for -m duplicates)
|
||||||
|
const commits = [...commitMap.values()].slice(0, commitLimit);
|
||||||
|
|
||||||
|
// Get current branch name
|
||||||
|
const branchOutput = await execGitCommand(['rev-parse', '--abbrev-ref', 'HEAD'], worktreePath);
|
||||||
|
const branch = branchOutput.trim();
|
||||||
|
|
||||||
|
return {
|
||||||
|
branch,
|
||||||
|
commits,
|
||||||
|
total: commits.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -5,7 +5,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { createLogger } from '@automaker/utils';
|
import { createLogger } from '@automaker/utils';
|
||||||
import { spawnProcess } from '@automaker/platform';
|
import { createEventEmitter } from '../lib/events';
|
||||||
|
import { execGitCommand } from '../lib/git.js';
|
||||||
const logger = createLogger('MergeService');
|
const logger = createLogger('MergeService');
|
||||||
|
|
||||||
export interface MergeOptions {
|
export interface MergeOptions {
|
||||||
@@ -27,33 +28,14 @@ export interface MergeServiceResult {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute git command with array arguments to prevent command injection.
|
|
||||||
*/
|
|
||||||
async function execGitCommand(args: string[], cwd: string): Promise<string> {
|
|
||||||
const result = await spawnProcess({
|
|
||||||
command: 'git',
|
|
||||||
args,
|
|
||||||
cwd,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.exitCode === 0) {
|
|
||||||
return result.stdout;
|
|
||||||
} else {
|
|
||||||
const errorMessage =
|
|
||||||
result.stderr || result.stdout || `Git command failed with code ${result.exitCode}`;
|
|
||||||
throw Object.assign(new Error(errorMessage), {
|
|
||||||
stdout: result.stdout,
|
|
||||||
stderr: result.stderr,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate branch name to prevent command injection.
|
* 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 {
|
function isValidBranchName(name: string): boolean {
|
||||||
return /^[a-zA-Z0-9._\-/]+$/.test(name) && name.length < 250;
|
// First char must be alphanumeric, dot, underscore, or slash (not dash)
|
||||||
|
return /^[a-zA-Z0-9._/][a-zA-Z0-9._\-/]*$/.test(name) && name.length < 250;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -72,6 +54,8 @@ export async function performMerge(
|
|||||||
targetBranch: string = 'main',
|
targetBranch: string = 'main',
|
||||||
options?: MergeOptions
|
options?: MergeOptions
|
||||||
): Promise<MergeServiceResult> {
|
): Promise<MergeServiceResult> {
|
||||||
|
const emitter = createEventEmitter();
|
||||||
|
|
||||||
if (!projectPath || !branchName || !worktreePath) {
|
if (!projectPath || !branchName || !worktreePath) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
@@ -115,6 +99,9 @@ export async function performMerge(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Emit merge:start after validating inputs
|
||||||
|
emitter.emit('merge:start', { branchName, targetBranch: mergeTo, worktreePath });
|
||||||
|
|
||||||
// Merge the feature branch into the target branch (using safe array-based commands)
|
// Merge the feature branch into the target branch (using safe array-based commands)
|
||||||
const mergeMessage = options?.message || `Merge ${branchName} into ${mergeTo}`;
|
const mergeMessage = options?.message || `Merge ${branchName} into ${mergeTo}`;
|
||||||
const mergeArgs = options?.squash
|
const mergeArgs = options?.squash
|
||||||
@@ -131,7 +118,7 @@ export async function performMerge(
|
|||||||
|
|
||||||
if (hasConflicts) {
|
if (hasConflicts) {
|
||||||
// Get list of conflicted files
|
// Get list of conflicted files
|
||||||
let conflictFiles: string[] = [];
|
let conflictFiles: string[] | undefined;
|
||||||
try {
|
try {
|
||||||
const diffOutput = await execGitCommand(
|
const diffOutput = await execGitCommand(
|
||||||
['diff', '--name-only', '--diff-filter=U'],
|
['diff', '--name-only', '--diff-filter=U'],
|
||||||
@@ -142,9 +129,13 @@ export async function performMerge(
|
|||||||
.split('\n')
|
.split('\n')
|
||||||
.filter((f) => f.trim().length > 0);
|
.filter((f) => f.trim().length > 0);
|
||||||
} catch {
|
} catch {
|
||||||
// If we can't get the file list, that's okay - continue without it
|
// If we can't get the file list, leave conflictFiles undefined so callers
|
||||||
|
// can distinguish "no conflicts" (empty array) from "unknown due to diff failure" (undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Emit merge:conflict event with conflict details
|
||||||
|
emitter.emit('merge:conflict', { branchName, targetBranch: mergeTo, conflictFiles });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: `Merge CONFLICT: Automatic merge of "${branchName}" into "${mergeTo}" failed. Please resolve conflicts manually.`,
|
error: `Merge CONFLICT: Automatic merge of "${branchName}" into "${mergeTo}" failed. Please resolve conflicts manually.`,
|
||||||
@@ -153,6 +144,13 @@ export async function performMerge(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Emit merge:error for non-conflict errors before re-throwing
|
||||||
|
emitter.emit('merge:error', {
|
||||||
|
branchName,
|
||||||
|
targetBranch: mergeTo,
|
||||||
|
error: err.message || String(mergeError),
|
||||||
|
});
|
||||||
|
|
||||||
// Re-throw non-conflict errors
|
// Re-throw non-conflict errors
|
||||||
throw mergeError;
|
throw mergeError;
|
||||||
}
|
}
|
||||||
@@ -197,6 +195,13 @@ export async function performMerge(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Emit merge:success with merged branch, target branch, and deletion info
|
||||||
|
emitter.emit('merge:success', {
|
||||||
|
mergedBranch: branchName,
|
||||||
|
targetBranch: mergeTo,
|
||||||
|
deleted: options?.deleteWorktreeAndBranch ? { worktreeDeleted, branchDeleted } : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
mergedBranch: branchName,
|
mergedBranch: branchName,
|
||||||
|
|||||||
457
apps/server/src/services/pull-service.ts
Normal file
457
apps/server/src/services/pull-service.ts
Normal file
@@ -0,0 +1,457 @@
|
|||||||
|
/**
|
||||||
|
* PullService - Pull git operations without HTTP
|
||||||
|
*
|
||||||
|
* Encapsulates the full git pull workflow including:
|
||||||
|
* - Branch name and detached HEAD detection
|
||||||
|
* - Fetching from remote
|
||||||
|
* - Status parsing and local change detection
|
||||||
|
* - Stash push/pop logic
|
||||||
|
* - Upstream verification (rev-parse / --verify)
|
||||||
|
* - Pull execution and conflict detection
|
||||||
|
* - Conflict file list collection
|
||||||
|
*
|
||||||
|
* Extracted from the worktree pull route to improve organization
|
||||||
|
* and testability. Follows the same pattern as rebase-service.ts
|
||||||
|
* and cherry-pick-service.ts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createLogger } from '@automaker/utils';
|
||||||
|
import { execGitCommand } from '../lib/git.js';
|
||||||
|
import { getErrorMessage } from '../routes/worktree/common.js';
|
||||||
|
|
||||||
|
const logger = createLogger('PullService');
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface PullOptions {
|
||||||
|
/** Remote name to pull from (defaults to 'origin') */
|
||||||
|
remote?: string;
|
||||||
|
/** When true, automatically stash local changes before pulling and reapply after */
|
||||||
|
stashIfNeeded?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PullResult {
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
branch?: string;
|
||||||
|
pulled?: boolean;
|
||||||
|
hasLocalChanges?: boolean;
|
||||||
|
localChangedFiles?: string[];
|
||||||
|
stashed?: boolean;
|
||||||
|
stashRestored?: boolean;
|
||||||
|
stashRecoveryFailed?: boolean;
|
||||||
|
hasConflicts?: boolean;
|
||||||
|
conflictSource?: 'pull' | 'stash';
|
||||||
|
conflictFiles?: string[];
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 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.
|
||||||
|
*
|
||||||
|
* @param worktreePath - Path to the git worktree
|
||||||
|
* @param remote - Remote name (e.g. 'origin')
|
||||||
|
*/
|
||||||
|
export async function fetchRemote(worktreePath: string, remote: string): Promise<void> {
|
||||||
|
await execGitCommand(['fetch', remote], worktreePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse `git status --porcelain` output into a list of changed file paths.
|
||||||
|
*
|
||||||
|
* @param worktreePath - Path to the git worktree
|
||||||
|
* @returns Object with hasLocalChanges flag and list of changed file paths
|
||||||
|
*/
|
||||||
|
export async function getLocalChanges(
|
||||||
|
worktreePath: string
|
||||||
|
): Promise<{ hasLocalChanges: boolean; localChangedFiles: string[] }> {
|
||||||
|
const statusOutput = await execGitCommand(['status', '--porcelain'], worktreePath);
|
||||||
|
const hasLocalChanges = statusOutput.trim().length > 0;
|
||||||
|
|
||||||
|
let localChangedFiles: string[] = [];
|
||||||
|
if (hasLocalChanges) {
|
||||||
|
localChangedFiles = statusOutput
|
||||||
|
.trim()
|
||||||
|
.split('\n')
|
||||||
|
.filter((line) => line.trim().length > 0)
|
||||||
|
.map((line) => line.substring(3).trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
return { hasLocalChanges, localChangedFiles };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stash local changes with a descriptive message.
|
||||||
|
*
|
||||||
|
* @param worktreePath - Path to the git worktree
|
||||||
|
* @param branchName - Current branch name (used in stash message)
|
||||||
|
* @returns true if stash was created
|
||||||
|
*/
|
||||||
|
export async function stashChanges(worktreePath: string, branchName: string): Promise<void> {
|
||||||
|
const stashMessage = `automaker-pull-stash: Pre-pull stash on ${branchName}`;
|
||||||
|
await execGitCommand(['stash', 'push', '--include-untracked', '-m', stashMessage], worktreePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pop the top stash entry.
|
||||||
|
*
|
||||||
|
* @param worktreePath - Path to the git worktree
|
||||||
|
* @returns The stdout from stash pop
|
||||||
|
*/
|
||||||
|
export async function popStash(worktreePath: string): Promise<string> {
|
||||||
|
return await execGitCommand(['stash', 'pop'], worktreePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try to pop the stash, returning whether the pop succeeded.
|
||||||
|
*
|
||||||
|
* @param worktreePath - Path to the git worktree
|
||||||
|
* @returns true if stash pop succeeded, false if it failed
|
||||||
|
*/
|
||||||
|
async function tryPopStash(worktreePath: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await execGitCommand(['stash', 'pop'], worktreePath);
|
||||||
|
return true;
|
||||||
|
} catch (stashPopError) {
|
||||||
|
// Stash pop failed - leave it in stash list for manual recovery
|
||||||
|
logger.error('Failed to reapply stash during error recovery', {
|
||||||
|
worktreePath,
|
||||||
|
error: getErrorMessage(stashPopError),
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether the branch has an upstream tracking ref, or whether
|
||||||
|
* the remote branch exists.
|
||||||
|
*
|
||||||
|
* @param worktreePath - Path to the git worktree
|
||||||
|
* @param branchName - Current branch name
|
||||||
|
* @param remote - Remote name
|
||||||
|
* @returns true if upstream or remote branch exists
|
||||||
|
*/
|
||||||
|
export async function hasUpstreamOrRemoteBranch(
|
||||||
|
worktreePath: string,
|
||||||
|
branchName: string,
|
||||||
|
remote: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await execGitCommand(['rev-parse', '--abbrev-ref', `${branchName}@{upstream}`], worktreePath);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
// No upstream tracking - check if the remote branch exists
|
||||||
|
try {
|
||||||
|
await execGitCommand(['rev-parse', '--verify', `${remote}/${branchName}`], worktreePath);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the list of files with unresolved merge conflicts.
|
||||||
|
*
|
||||||
|
* @param worktreePath - Path to the git worktree
|
||||||
|
* @returns Array of file paths with conflicts
|
||||||
|
*/
|
||||||
|
export async function getConflictFiles(worktreePath: string): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
const diffOutput = await execGitCommand(
|
||||||
|
['diff', '--name-only', '--diff-filter=U'],
|
||||||
|
worktreePath
|
||||||
|
);
|
||||||
|
return diffOutput
|
||||||
|
.trim()
|
||||||
|
.split('\n')
|
||||||
|
.filter((f) => f.trim().length > 0);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether an error output string indicates a merge conflict.
|
||||||
|
*/
|
||||||
|
function isConflictError(errorOutput: string): boolean {
|
||||||
|
return errorOutput.includes('CONFLICT') || errorOutput.includes('Automatic merge failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether an output string indicates a stash conflict.
|
||||||
|
*/
|
||||||
|
function isStashConflict(output: string): boolean {
|
||||||
|
return output.includes('CONFLICT') || output.includes('Merge conflict');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Main Service Function
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perform a full git pull workflow on the given worktree.
|
||||||
|
*
|
||||||
|
* The workflow:
|
||||||
|
* 1. Get current branch name (detect detached HEAD)
|
||||||
|
* 2. Fetch from remote
|
||||||
|
* 3. Check for local changes
|
||||||
|
* 4. If local changes and stashIfNeeded, stash them
|
||||||
|
* 5. Verify upstream tracking or remote branch exists
|
||||||
|
* 6. Execute `git pull`
|
||||||
|
* 7. If stash was created and pull succeeded, reapply stash
|
||||||
|
* 8. Detect and report conflicts from pull or stash reapplication
|
||||||
|
*
|
||||||
|
* @param worktreePath - Path to the git worktree
|
||||||
|
* @param options - Pull options (remote, stashIfNeeded)
|
||||||
|
* @returns PullResult with detailed status information
|
||||||
|
*/
|
||||||
|
export async function performPull(
|
||||||
|
worktreePath: string,
|
||||||
|
options?: PullOptions
|
||||||
|
): Promise<PullResult> {
|
||||||
|
const targetRemote = options?.remote || 'origin';
|
||||||
|
const stashIfNeeded = options?.stashIfNeeded ?? false;
|
||||||
|
|
||||||
|
// 1. Get current branch name
|
||||||
|
const branchName = await getCurrentBranch(worktreePath);
|
||||||
|
|
||||||
|
// 2. Check for detached HEAD state
|
||||||
|
if (branchName === 'HEAD') {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Cannot pull in detached HEAD state. Please checkout a branch first.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Fetch latest from remote
|
||||||
|
try {
|
||||||
|
await fetchRemote(worktreePath, targetRemote);
|
||||||
|
} catch (fetchError) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Failed to fetch from remote '${targetRemote}': ${getErrorMessage(fetchError)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Check for local changes
|
||||||
|
const { hasLocalChanges, localChangedFiles } = await getLocalChanges(worktreePath);
|
||||||
|
|
||||||
|
// 5. If there are local changes and stashIfNeeded is not requested, return info
|
||||||
|
if (hasLocalChanges && !stashIfNeeded) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
branch: branchName,
|
||||||
|
pulled: false,
|
||||||
|
hasLocalChanges: true,
|
||||||
|
localChangedFiles,
|
||||||
|
message:
|
||||||
|
'Local changes detected. Use stashIfNeeded to automatically stash and reapply changes.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Stash local changes if needed
|
||||||
|
let didStash = false;
|
||||||
|
if (hasLocalChanges && stashIfNeeded) {
|
||||||
|
try {
|
||||||
|
await stashChanges(worktreePath, branchName);
|
||||||
|
didStash = true;
|
||||||
|
} catch (stashError) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Failed to stash local changes: ${getErrorMessage(stashError)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Verify upstream tracking or remote branch exists
|
||||||
|
const hasUpstream = await hasUpstreamOrRemoteBranch(worktreePath, branchName, targetRemote);
|
||||||
|
if (!hasUpstream) {
|
||||||
|
let stashRecoveryFailed = false;
|
||||||
|
if (didStash) {
|
||||||
|
const stashPopped = await tryPopStash(worktreePath);
|
||||||
|
stashRecoveryFailed = !stashPopped;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Branch '${branchName}' has no upstream branch on remote '${targetRemote}'. Push it first or set upstream with: git branch --set-upstream-to=${targetRemote}/${branchName}${stashRecoveryFailed ? ' Local changes remain stashed and need manual recovery (run: git stash pop).' : ''}`,
|
||||||
|
stashRecoveryFailed: stashRecoveryFailed || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. Pull latest changes
|
||||||
|
let pullConflict = false;
|
||||||
|
let pullConflictFiles: string[] = [];
|
||||||
|
try {
|
||||||
|
const pullOutput = await execGitCommand(['pull', targetRemote, branchName], worktreePath);
|
||||||
|
|
||||||
|
const alreadyUpToDate = pullOutput.includes('Already up to date');
|
||||||
|
|
||||||
|
// If no stash to reapply, return success
|
||||||
|
if (!didStash) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
branch: branchName,
|
||||||
|
pulled: !alreadyUpToDate,
|
||||||
|
hasLocalChanges: false,
|
||||||
|
stashed: false,
|
||||||
|
stashRestored: false,
|
||||||
|
message: alreadyUpToDate ? 'Already up to date' : 'Pulled latest changes',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (pullError: unknown) {
|
||||||
|
const err = pullError as { stderr?: string; stdout?: string; message?: string };
|
||||||
|
const errorOutput = `${err.stderr || ''} ${err.stdout || ''} ${err.message || ''}`;
|
||||||
|
|
||||||
|
if (isConflictError(errorOutput)) {
|
||||||
|
pullConflict = true;
|
||||||
|
pullConflictFiles = await getConflictFiles(worktreePath);
|
||||||
|
} else {
|
||||||
|
// Non-conflict pull error
|
||||||
|
let stashRecoveryFailed = false;
|
||||||
|
if (didStash) {
|
||||||
|
const stashPopped = await tryPopStash(worktreePath);
|
||||||
|
stashRecoveryFailed = !stashPopped;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for common errors
|
||||||
|
const errorMsg = err.stderr || err.message || 'Pull failed';
|
||||||
|
if (errorMsg.includes('no tracking information')) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Branch '${branchName}' has no upstream branch. Push it first or set upstream with: git branch --set-upstream-to=${targetRemote}/${branchName}${stashRecoveryFailed ? ' Local changes remain stashed and need manual recovery (run: git stash pop).' : ''}`,
|
||||||
|
stashRecoveryFailed: stashRecoveryFailed || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `${errorMsg}${stashRecoveryFailed ? ' Local changes remain stashed and need manual recovery (run: git stash pop).' : ''}`,
|
||||||
|
stashRecoveryFailed: stashRecoveryFailed || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 9. If pull had conflicts, return conflict info (don't try stash pop)
|
||||||
|
if (pullConflict) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
branch: branchName,
|
||||||
|
pulled: true,
|
||||||
|
hasConflicts: true,
|
||||||
|
conflictSource: 'pull',
|
||||||
|
conflictFiles: pullConflictFiles,
|
||||||
|
stashed: didStash,
|
||||||
|
stashRestored: false,
|
||||||
|
message:
|
||||||
|
`Pull resulted in merge conflicts. ${didStash ? 'Your local changes are still stashed.' : ''}`.trim(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 10. Pull succeeded, now try to reapply stash
|
||||||
|
if (didStash) {
|
||||||
|
return await reapplyStash(worktreePath, branchName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shouldn't reach here, but return a safe default
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
branch: branchName,
|
||||||
|
pulled: true,
|
||||||
|
message: 'Pulled latest changes',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempt to reapply stashed changes after a successful pull.
|
||||||
|
* Handles both clean reapplication and conflict scenarios.
|
||||||
|
*
|
||||||
|
* @param worktreePath - Path to the git worktree
|
||||||
|
* @param branchName - Current branch name
|
||||||
|
* @returns PullResult reflecting stash reapplication status
|
||||||
|
*/
|
||||||
|
async function reapplyStash(worktreePath: string, branchName: string): Promise<PullResult> {
|
||||||
|
try {
|
||||||
|
const stashPopOutput = await popStash(worktreePath);
|
||||||
|
const stashPopCombined = stashPopOutput || '';
|
||||||
|
|
||||||
|
// Check if stash pop had conflicts
|
||||||
|
if (isStashConflict(stashPopCombined)) {
|
||||||
|
const stashConflictFiles = await getConflictFiles(worktreePath);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
branch: branchName,
|
||||||
|
pulled: true,
|
||||||
|
hasConflicts: true,
|
||||||
|
conflictSource: 'stash',
|
||||||
|
conflictFiles: stashConflictFiles,
|
||||||
|
stashed: true,
|
||||||
|
stashRestored: true, // Stash was applied but with conflicts
|
||||||
|
message: 'Pull succeeded but reapplying your stashed changes resulted in merge conflicts.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stash pop succeeded cleanly
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
branch: branchName,
|
||||||
|
pulled: true,
|
||||||
|
hasConflicts: false,
|
||||||
|
stashed: true,
|
||||||
|
stashRestored: true,
|
||||||
|
message: 'Pulled latest changes and restored your stashed changes.',
|
||||||
|
};
|
||||||
|
} catch (stashPopError: unknown) {
|
||||||
|
const err = stashPopError as { stderr?: string; stdout?: string; message?: string };
|
||||||
|
const errorOutput = `${err.stderr || ''} ${err.stdout || ''} ${err.message || ''}`;
|
||||||
|
|
||||||
|
// Check if stash pop failed due to conflicts
|
||||||
|
if (isStashConflict(errorOutput)) {
|
||||||
|
const stashConflictFiles = await getConflictFiles(worktreePath);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
branch: branchName,
|
||||||
|
pulled: true,
|
||||||
|
hasConflicts: true,
|
||||||
|
conflictSource: 'stash',
|
||||||
|
conflictFiles: stashConflictFiles,
|
||||||
|
stashed: true,
|
||||||
|
stashRestored: true,
|
||||||
|
message: 'Pull succeeded but reapplying your stashed changes resulted in merge conflicts.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-conflict stash pop error - stash is still in the stash list
|
||||||
|
logger.warn('Failed to reapply stash after pull', { worktreePath, error: errorOutput });
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
branch: branchName,
|
||||||
|
pulled: true,
|
||||||
|
hasConflicts: false,
|
||||||
|
stashed: true,
|
||||||
|
stashRestored: false,
|
||||||
|
message:
|
||||||
|
'Pull succeeded but failed to reapply stashed changes. Your changes are still in the stash list.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,8 +5,10 @@
|
|||||||
* Follows the same pattern as merge-service.ts and cherry-pick-service.ts.
|
* Follows the same pattern as merge-service.ts and cherry-pick-service.ts.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import fs from 'fs/promises';
|
||||||
|
import path from 'path';
|
||||||
import { createLogger } from '@automaker/utils';
|
import { createLogger } from '@automaker/utils';
|
||||||
import { execGitCommand } from '../routes/worktree/common.js';
|
import { execGitCommand, getCurrentBranch } from '../lib/git.js';
|
||||||
|
|
||||||
const logger = createLogger('RebaseService');
|
const logger = createLogger('RebaseService');
|
||||||
|
|
||||||
@@ -37,11 +39,23 @@ export interface RebaseResult {
|
|||||||
* @returns RebaseResult with success/failure information
|
* @returns RebaseResult with success/failure information
|
||||||
*/
|
*/
|
||||||
export async function runRebase(worktreePath: string, ontoBranch: string): Promise<RebaseResult> {
|
export async function runRebase(worktreePath: string, ontoBranch: string): Promise<RebaseResult> {
|
||||||
|
// Reject branch names that start with a dash to prevent them from being
|
||||||
|
// misinterpreted as git options.
|
||||||
|
if (ontoBranch.startsWith('-')) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Invalid branch name: "${ontoBranch}" must not start with a dash.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Get current branch name before rebase
|
// Get current branch name before rebase
|
||||||
const currentBranch = await getCurrentBranch(worktreePath);
|
const currentBranch = await getCurrentBranch(worktreePath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await execGitCommand(['rebase', ontoBranch], worktreePath);
|
// Pass ontoBranch after '--' so git treats it as a ref, not an option.
|
||||||
|
// Set LC_ALL=C so git always emits English output regardless of the system
|
||||||
|
// locale, making text-based conflict detection reliable.
|
||||||
|
await execGitCommand(['rebase', '--', ontoBranch], worktreePath, { LC_ALL: 'C' });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -50,15 +64,82 @@ export async function runRebase(worktreePath: string, ontoBranch: string): Promi
|
|||||||
message: `Successfully rebased ${currentBranch} onto ${ontoBranch}`,
|
message: `Successfully rebased ${currentBranch} onto ${ontoBranch}`,
|
||||||
};
|
};
|
||||||
} catch (rebaseError: unknown) {
|
} catch (rebaseError: unknown) {
|
||||||
// Check if this is a rebase conflict
|
// Check if this is a rebase 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. Repository-state check: run `git rev-parse --git-dir` to find the
|
||||||
|
// actual .git directory, then verify whether the in-progress rebase
|
||||||
|
// state directories (.git/rebase-merge or .git/rebase-apply) exist.
|
||||||
|
// These are created by git at the start of a rebase and are the most
|
||||||
|
// reliable indicator that a rebase is still in progress (i.e. stopped
|
||||||
|
// due to conflicts).
|
||||||
|
//
|
||||||
|
// 3. Unmerged-path check: run `git status --porcelain` (machine-readable,
|
||||||
|
// locale-independent) 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 = rebaseError as { stdout?: string; stderr?: string; message?: string };
|
const err = rebaseError 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 =
|
|
||||||
|
// Layer 1 – text matching (locale-safe because we set LC_ALL=C above).
|
||||||
|
const textIndicatesConflict =
|
||||||
output.includes('CONFLICT') ||
|
output.includes('CONFLICT') ||
|
||||||
output.includes('could not apply') ||
|
output.includes('could not apply') ||
|
||||||
output.includes('Resolve all conflicts') ||
|
output.includes('Resolve all conflicts') ||
|
||||||
output.includes('fix conflicts');
|
output.includes('fix conflicts');
|
||||||
|
|
||||||
|
// Layers 2 & 3 – repository state inspection (locale-independent).
|
||||||
|
let rebaseStateExists = false;
|
||||||
|
let hasUnmergedPaths = false;
|
||||||
|
try {
|
||||||
|
// Find the canonical .git directory for this worktree.
|
||||||
|
const gitDir = (await execGitCommand(['rev-parse', '--git-dir'], worktreePath)).trim();
|
||||||
|
// git rev-parse --git-dir returns a path relative to cwd when the repo is
|
||||||
|
// a worktree, so we resolve it against worktreePath.
|
||||||
|
const resolvedGitDir = path.resolve(worktreePath, gitDir);
|
||||||
|
|
||||||
|
// Layer 2: check for rebase state directories.
|
||||||
|
const rebaseMergeDir = path.join(resolvedGitDir, 'rebase-merge');
|
||||||
|
const rebaseApplyDir = path.join(resolvedGitDir, 'rebase-apply');
|
||||||
|
const [rebaseMergeExists, rebaseApplyExists] = await Promise.all([
|
||||||
|
fs
|
||||||
|
.access(rebaseMergeDir)
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false),
|
||||||
|
fs
|
||||||
|
.access(rebaseApplyDir)
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false),
|
||||||
|
]);
|
||||||
|
rebaseStateExists = rebaseMergeExists || rebaseApplyExists;
|
||||||
|
} catch {
|
||||||
|
// If rev-parse fails the repo may be in an unexpected state; fall back to
|
||||||
|
// text-based detection only.
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Layer 3: check for unmerged paths via machine-readable git status.
|
||||||
|
const statusOutput = await execGitCommand(['status', '--porcelain'], worktreePath, {
|
||||||
|
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 || rebaseStateExists || hasUnmergedPaths;
|
||||||
|
|
||||||
if (hasConflicts) {
|
if (hasConflicts) {
|
||||||
// Get list of conflicted files
|
// Get list of conflicted files
|
||||||
const conflictFiles = await getConflictFiles(worktreePath);
|
const conflictFiles = await getConflictFiles(worktreePath);
|
||||||
@@ -100,8 +181,8 @@ export async function abortRebase(worktreePath: string): Promise<boolean> {
|
|||||||
try {
|
try {
|
||||||
await execGitCommand(['rebase', '--abort'], worktreePath);
|
await execGitCommand(['rebase', '--abort'], worktreePath);
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch (err) {
|
||||||
logger.warn('Failed to abort rebase after conflict');
|
logger.warn('Failed to abort rebase after conflict', err instanceof Error ? err.message : err);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -126,14 +207,3 @@ export async function getConflictFiles(worktreePath: string): Promise<string[]>
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the current branch name for the worktree.
|
|
||||||
*
|
|
||||||
* @param worktreePath - Path to the git worktree
|
|
||||||
* @returns The current branch name
|
|
||||||
*/
|
|
||||||
export async function getCurrentBranch(worktreePath: string): Promise<string> {
|
|
||||||
const branchOutput = await execGitCommand(['rev-parse', '--abbrev-ref', 'HEAD'], worktreePath);
|
|
||||||
return branchOutput.trim();
|
|
||||||
}
|
|
||||||
|
|||||||
462
apps/server/src/services/stash-service.ts
Normal file
462
apps/server/src/services/stash-service.ts
Normal file
@@ -0,0 +1,462 @@
|
|||||||
|
/**
|
||||||
|
* StashService - Stash operations without HTTP
|
||||||
|
*
|
||||||
|
* Encapsulates stash workflows including:
|
||||||
|
* - Push (create) stashes with optional message and file selection
|
||||||
|
* - List all stash entries with metadata and changed files
|
||||||
|
* - Apply or pop a stash entry with conflict detection
|
||||||
|
* - Drop (delete) a stash entry
|
||||||
|
* - Conflict detection from command output and git diff
|
||||||
|
* - Lifecycle event emission (start, progress, conflicts, success, failure)
|
||||||
|
*
|
||||||
|
* Extracted from the worktree stash route handlers to improve organisation
|
||||||
|
* and testability. Follows the same pattern as pull-service.ts and
|
||||||
|
* merge-service.ts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createLogger } from '@automaker/utils';
|
||||||
|
import { createEventEmitter } from '../lib/events.js';
|
||||||
|
import { execGitCommand } from '../lib/git.js';
|
||||||
|
import { getErrorMessage, logError } from '../routes/worktree/common.js';
|
||||||
|
|
||||||
|
const logger = createLogger('StashService');
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface StashApplyOptions {
|
||||||
|
/** When true, remove the stash entry after applying (git stash pop) */
|
||||||
|
pop?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StashApplyResult {
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
applied?: boolean;
|
||||||
|
hasConflicts?: boolean;
|
||||||
|
conflictFiles?: string[];
|
||||||
|
operation?: 'apply' | 'pop';
|
||||||
|
stashIndex?: number;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StashPushResult {
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
stashed: boolean;
|
||||||
|
branch?: string;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StashEntry {
|
||||||
|
index: number;
|
||||||
|
message: string;
|
||||||
|
branch: string;
|
||||||
|
date: string;
|
||||||
|
files: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StashListResult {
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
stashes: StashEntry[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StashDropResult {
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
dropped: boolean;
|
||||||
|
stashIndex?: number;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Helper Functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve the list of files with unmerged (conflicted) entries using git diff.
|
||||||
|
*
|
||||||
|
* @param worktreePath - Path to the git worktree
|
||||||
|
* @returns Array of file paths that have unresolved conflicts
|
||||||
|
*/
|
||||||
|
export async function getConflictedFiles(worktreePath: string): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
const diffOutput = await execGitCommand(
|
||||||
|
['diff', '--name-only', '--diff-filter=U'],
|
||||||
|
worktreePath
|
||||||
|
);
|
||||||
|
return diffOutput
|
||||||
|
.trim()
|
||||||
|
.split('\n')
|
||||||
|
.filter((f) => f.trim().length > 0);
|
||||||
|
} catch {
|
||||||
|
// If we cannot get the file list, return an empty array
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether command output indicates a merge conflict.
|
||||||
|
*/
|
||||||
|
function isConflictOutput(output: string): boolean {
|
||||||
|
return output.includes('CONFLICT') || output.includes('Merge conflict');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Main Service Function
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply or pop a stash entry in the given worktree.
|
||||||
|
*
|
||||||
|
* The workflow:
|
||||||
|
* 1. Validate inputs
|
||||||
|
* 2. Emit stash:start event
|
||||||
|
* 3. Run `git stash apply` or `git stash pop`
|
||||||
|
* 4. Emit stash:progress event with raw command output
|
||||||
|
* 5. Check output for conflict markers; if conflicts found, collect files and
|
||||||
|
* emit stash:conflicts event
|
||||||
|
* 6. Emit stash:success or stash:failure depending on outcome
|
||||||
|
* 7. Return a structured StashApplyResult
|
||||||
|
*
|
||||||
|
* @param worktreePath - Absolute path to the git worktree
|
||||||
|
* @param stashIndex - Zero-based stash index (stash@{N})
|
||||||
|
* @param options - Optional flags (pop)
|
||||||
|
* @returns StashApplyResult with detailed status information
|
||||||
|
*/
|
||||||
|
export async function applyOrPop(
|
||||||
|
worktreePath: string,
|
||||||
|
stashIndex: number,
|
||||||
|
options?: StashApplyOptions
|
||||||
|
): Promise<StashApplyResult> {
|
||||||
|
const emitter = createEventEmitter();
|
||||||
|
const operation: 'apply' | 'pop' = options?.pop ? 'pop' : 'apply';
|
||||||
|
const stashRef = `stash@{${stashIndex}}`;
|
||||||
|
|
||||||
|
logger.info(`[StashService] ${operation} ${stashRef} in ${worktreePath}`);
|
||||||
|
|
||||||
|
// 1. Emit start event
|
||||||
|
emitter.emit('stash:start', { worktreePath, stashIndex, stashRef, operation });
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 2. Run git stash apply / pop
|
||||||
|
let stdout = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
stdout = await execGitCommand(['stash', operation, stashRef], worktreePath);
|
||||||
|
} catch (gitError: unknown) {
|
||||||
|
const err = gitError as { stdout?: string; stderr?: string; message?: string };
|
||||||
|
const errStdout = err.stdout || '';
|
||||||
|
const errStderr = err.stderr || err.message || '';
|
||||||
|
|
||||||
|
const combinedOutput = `${errStdout}\n${errStderr}`;
|
||||||
|
|
||||||
|
// 3. Emit progress with raw output
|
||||||
|
emitter.emit('stash:progress', {
|
||||||
|
worktreePath,
|
||||||
|
stashIndex,
|
||||||
|
operation,
|
||||||
|
output: combinedOutput,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. Check if the error is a conflict
|
||||||
|
if (isConflictOutput(combinedOutput)) {
|
||||||
|
const conflictFiles = await getConflictedFiles(worktreePath);
|
||||||
|
|
||||||
|
emitter.emit('stash:conflicts', {
|
||||||
|
worktreePath,
|
||||||
|
stashIndex,
|
||||||
|
operation,
|
||||||
|
conflictFiles,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result: StashApplyResult = {
|
||||||
|
success: true,
|
||||||
|
applied: true,
|
||||||
|
hasConflicts: true,
|
||||||
|
conflictFiles,
|
||||||
|
operation,
|
||||||
|
stashIndex,
|
||||||
|
message: `Stash ${operation === 'pop' ? 'popped' : 'applied'} with conflicts. Please resolve the conflicts.`,
|
||||||
|
};
|
||||||
|
|
||||||
|
emitter.emit('stash:success', {
|
||||||
|
worktreePath,
|
||||||
|
stashIndex,
|
||||||
|
operation,
|
||||||
|
hasConflicts: true,
|
||||||
|
conflictFiles,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Non-conflict git error – re-throw so the outer catch logs and handles it
|
||||||
|
throw gitError;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Command succeeded – check stdout for conflict markers (some git versions
|
||||||
|
// exit 0 even when conflicts occur during apply)
|
||||||
|
const combinedOutput = stdout;
|
||||||
|
|
||||||
|
emitter.emit('stash:progress', { worktreePath, stashIndex, operation, output: combinedOutput });
|
||||||
|
|
||||||
|
if (isConflictOutput(combinedOutput)) {
|
||||||
|
const conflictFiles = await getConflictedFiles(worktreePath);
|
||||||
|
|
||||||
|
emitter.emit('stash:conflicts', {
|
||||||
|
worktreePath,
|
||||||
|
stashIndex,
|
||||||
|
operation,
|
||||||
|
conflictFiles,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result: StashApplyResult = {
|
||||||
|
success: true,
|
||||||
|
applied: true,
|
||||||
|
hasConflicts: true,
|
||||||
|
conflictFiles,
|
||||||
|
operation,
|
||||||
|
stashIndex,
|
||||||
|
message: `Stash ${operation === 'pop' ? 'popped' : 'applied'} with conflicts. Please resolve the conflicts.`,
|
||||||
|
};
|
||||||
|
|
||||||
|
emitter.emit('stash:success', {
|
||||||
|
worktreePath,
|
||||||
|
stashIndex,
|
||||||
|
operation,
|
||||||
|
hasConflicts: true,
|
||||||
|
conflictFiles,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Clean success
|
||||||
|
const result: StashApplyResult = {
|
||||||
|
success: true,
|
||||||
|
applied: true,
|
||||||
|
hasConflicts: false,
|
||||||
|
operation,
|
||||||
|
stashIndex,
|
||||||
|
message: `Stash ${operation === 'pop' ? 'popped' : 'applied'} successfully`,
|
||||||
|
};
|
||||||
|
|
||||||
|
emitter.emit('stash:success', {
|
||||||
|
worktreePath,
|
||||||
|
stashIndex,
|
||||||
|
operation,
|
||||||
|
hasConflicts: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = getErrorMessage(error);
|
||||||
|
|
||||||
|
logError(error, `Stash ${operation} failed`);
|
||||||
|
|
||||||
|
emitter.emit('stash:failure', {
|
||||||
|
worktreePath,
|
||||||
|
stashIndex,
|
||||||
|
operation,
|
||||||
|
error: errorMessage,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: errorMessage,
|
||||||
|
applied: false,
|
||||||
|
operation,
|
||||||
|
stashIndex,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Push Stash
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stash uncommitted changes (including untracked files) with an optional
|
||||||
|
* message and optional file selection.
|
||||||
|
*
|
||||||
|
* Workflow:
|
||||||
|
* 1. Check for uncommitted changes via `git status --porcelain`
|
||||||
|
* 2. If no changes, return early with stashed: false
|
||||||
|
* 3. Build and run `git stash push --include-untracked [-m message] [-- files]`
|
||||||
|
* 4. Retrieve the current branch name
|
||||||
|
* 5. Return a structured StashPushResult
|
||||||
|
*
|
||||||
|
* @param worktreePath - Absolute path to the git worktree
|
||||||
|
* @param options - Optional message and files to selectively stash
|
||||||
|
* @returns StashPushResult with stash status and branch info
|
||||||
|
*/
|
||||||
|
export async function pushStash(
|
||||||
|
worktreePath: string,
|
||||||
|
options?: { message?: string; files?: string[] }
|
||||||
|
): Promise<StashPushResult> {
|
||||||
|
const message = options?.message;
|
||||||
|
const files = options?.files;
|
||||||
|
|
||||||
|
logger.info(`[StashService] push stash in ${worktreePath}`);
|
||||||
|
|
||||||
|
// 1. Check for any changes to stash
|
||||||
|
const status = await execGitCommand(['status', '--porcelain'], worktreePath);
|
||||||
|
|
||||||
|
if (!status.trim()) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
stashed: false,
|
||||||
|
message: 'No changes to stash',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Build stash push command args
|
||||||
|
const args = ['stash', 'push', '--include-untracked'];
|
||||||
|
if (message && message.trim()) {
|
||||||
|
args.push('-m', message.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
// If specific files are provided, add them as pathspecs after '--'
|
||||||
|
if (files && files.length > 0) {
|
||||||
|
args.push('--');
|
||||||
|
args.push(...files);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Execute stash push
|
||||||
|
await execGitCommand(args, worktreePath);
|
||||||
|
|
||||||
|
// 4. Get current branch name
|
||||||
|
const branchOutput = await execGitCommand(['rev-parse', '--abbrev-ref', 'HEAD'], worktreePath);
|
||||||
|
const branchName = branchOutput.trim();
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
stashed: true,
|
||||||
|
branch: branchName,
|
||||||
|
message: message?.trim() || `WIP on ${branchName}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// List Stashes
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all stash entries for a worktree with metadata and changed files.
|
||||||
|
*
|
||||||
|
* Workflow:
|
||||||
|
* 1. Run `git stash list` with a custom format to get index, message, and date
|
||||||
|
* 2. Parse each stash line into a structured StashEntry
|
||||||
|
* 3. For each entry, fetch the list of files changed via `git stash show`
|
||||||
|
* 4. Return the full list as a StashListResult
|
||||||
|
*
|
||||||
|
* @param worktreePath - Absolute path to the git worktree
|
||||||
|
* @returns StashListResult with all stash entries and their metadata
|
||||||
|
*/
|
||||||
|
export async function listStash(worktreePath: string): Promise<StashListResult> {
|
||||||
|
logger.info(`[StashService] list stashes in ${worktreePath}`);
|
||||||
|
|
||||||
|
// 1. Get stash list with format: index, message, date
|
||||||
|
// Use %aI (strict ISO 8601) instead of %ai to ensure cross-browser compatibility
|
||||||
|
const stashOutput = await execGitCommand(
|
||||||
|
['stash', 'list', '--format=%gd|||%s|||%aI'],
|
||||||
|
worktreePath
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!stashOutput.trim()) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
stashes: [],
|
||||||
|
total: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const stashLines = stashOutput
|
||||||
|
.trim()
|
||||||
|
.split('\n')
|
||||||
|
.filter((l) => l.trim());
|
||||||
|
const stashes: StashEntry[] = [];
|
||||||
|
|
||||||
|
for (const line of stashLines) {
|
||||||
|
const parts = line.split('|||');
|
||||||
|
if (parts.length < 3) continue;
|
||||||
|
|
||||||
|
const refSpec = parts[0].trim(); // e.g., "stash@{0}"
|
||||||
|
const stashMessage = parts[1].trim();
|
||||||
|
const date = parts[2].trim();
|
||||||
|
|
||||||
|
// Extract index from stash@{N}; skip entries that don't match the expected format
|
||||||
|
const indexMatch = refSpec.match(/stash@\{(\d+)\}/);
|
||||||
|
if (!indexMatch) continue;
|
||||||
|
const index = parseInt(indexMatch[1], 10);
|
||||||
|
|
||||||
|
// Extract branch name from message (format: "WIP on branch: hash message" or "On branch: hash message")
|
||||||
|
let branch = '';
|
||||||
|
const branchMatch = stashMessage.match(/^(?:WIP on|On) ([^:]+):/);
|
||||||
|
if (branchMatch) {
|
||||||
|
branch = branchMatch[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get list of files in this stash
|
||||||
|
let files: string[] = [];
|
||||||
|
try {
|
||||||
|
const filesOutput = await execGitCommand(
|
||||||
|
['stash', 'show', refSpec, '--name-only'],
|
||||||
|
worktreePath
|
||||||
|
);
|
||||||
|
files = filesOutput
|
||||||
|
.trim()
|
||||||
|
.split('\n')
|
||||||
|
.filter((f) => f.trim());
|
||||||
|
} catch {
|
||||||
|
// Ignore errors getting file list
|
||||||
|
}
|
||||||
|
|
||||||
|
stashes.push({
|
||||||
|
index,
|
||||||
|
message: stashMessage,
|
||||||
|
branch,
|
||||||
|
date,
|
||||||
|
files,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
stashes,
|
||||||
|
total: stashes.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Drop Stash
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop (delete) a stash entry by index.
|
||||||
|
*
|
||||||
|
* @param worktreePath - Absolute path to the git worktree
|
||||||
|
* @param stashIndex - Zero-based stash index (stash@{N})
|
||||||
|
* @returns StashDropResult with drop status
|
||||||
|
*/
|
||||||
|
export async function dropStash(
|
||||||
|
worktreePath: string,
|
||||||
|
stashIndex: number
|
||||||
|
): Promise<StashDropResult> {
|
||||||
|
const stashRef = `stash@{${stashIndex}}`;
|
||||||
|
|
||||||
|
logger.info(`[StashService] drop ${stashRef} in ${worktreePath}`);
|
||||||
|
|
||||||
|
await execGitCommand(['stash', 'drop', stashRef], worktreePath);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
dropped: true,
|
||||||
|
stashIndex,
|
||||||
|
message: `Stash ${stashRef} dropped successfully`,
|
||||||
|
};
|
||||||
|
}
|
||||||
441
apps/server/src/services/worktree-branch-service.ts
Normal file
441
apps/server/src/services/worktree-branch-service.ts
Normal file
@@ -0,0 +1,441 @@
|
|||||||
|
/**
|
||||||
|
* WorktreeBranchService - Switch branch operations without HTTP
|
||||||
|
*
|
||||||
|
* Handles branch switching with automatic stash/reapply of local changes.
|
||||||
|
* If there are uncommitted changes, they are stashed before switching and
|
||||||
|
* reapplied after. If the stash pop results in merge conflicts, returns
|
||||||
|
* a special response so the UI can create a conflict resolution task.
|
||||||
|
*
|
||||||
|
* For remote branches (e.g., "origin/feature"), automatically creates a
|
||||||
|
* local tracking branch and checks it out.
|
||||||
|
*
|
||||||
|
* Also fetches the latest remote refs after switching.
|
||||||
|
*
|
||||||
|
* Extracted from the worktree switch-branch route to improve organization
|
||||||
|
* and testability. Follows the same pattern as pull-service.ts and
|
||||||
|
* rebase-service.ts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createLogger } from '@automaker/utils';
|
||||||
|
import { execGitCommand } from '../lib/git.js';
|
||||||
|
import { getErrorMessage } from '../routes/worktree/common.js';
|
||||||
|
import type { EventEmitter } from '../lib/events.js';
|
||||||
|
|
||||||
|
const logger = createLogger('WorktreeBranchService');
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface SwitchBranchResult {
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
result?: {
|
||||||
|
previousBranch: string;
|
||||||
|
currentBranch: 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
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
function isExcludedWorktreeLine(line: string): boolean {
|
||||||
|
return line.includes('.worktrees/') || line.endsWith('.worktrees');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if there are any changes at all (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) => {
|
||||||
|
if (!line.trim()) return false;
|
||||||
|
if (isExcludedWorktreeLine(line)) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
return lines.length > 0;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stash all local changes (including untracked files)
|
||||||
|
* Returns true if a stash was created, false if there was nothing to stash
|
||||||
|
*/
|
||||||
|
async function stashChanges(cwd: string, message: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
// Get stash count before
|
||||||
|
const beforeOutput = await execGitCommand(['stash', 'list'], cwd);
|
||||||
|
const countBefore = beforeOutput
|
||||||
|
.trim()
|
||||||
|
.split('\n')
|
||||||
|
.filter((l) => l.trim()).length;
|
||||||
|
|
||||||
|
// Stash including untracked files
|
||||||
|
await execGitCommand(['stash', 'push', '--include-untracked', '-m', message], cwd);
|
||||||
|
|
||||||
|
// Get stash count after to verify something was stashed
|
||||||
|
const afterOutput = await execGitCommand(['stash', 'list'], cwd);
|
||||||
|
const countAfter = afterOutput
|
||||||
|
.trim()
|
||||||
|
.split('\n')
|
||||||
|
.filter((l) => l.trim()).length;
|
||||||
|
|
||||||
|
return countAfter > countBefore;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 {
|
||||||
|
const stdout = await execGitCommand(['stash', 'pop'], cwd);
|
||||||
|
// Check for conflict markers in the output
|
||||||
|
if (stdout.includes('CONFLICT') || stdout.includes('Merge conflict')) {
|
||||||
|
return { success: false, hasConflicts: true };
|
||||||
|
}
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch latest from all remotes (silently, with timeout)
|
||||||
|
*/
|
||||||
|
async function fetchRemotes(cwd: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await execGitCommand(['fetch', '--all', '--quiet'], cwd);
|
||||||
|
} catch {
|
||||||
|
// Ignore fetch errors - we may be offline
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a remote branch name like "origin/feature-branch" into its parts
|
||||||
|
*/
|
||||||
|
function parseRemoteBranch(branchName: string): { remote: string; branch: string } | null {
|
||||||
|
const slashIndex = branchName.indexOf('/');
|
||||||
|
if (slashIndex === -1) return null;
|
||||||
|
return {
|
||||||
|
remote: branchName.substring(0, slashIndex),
|
||||||
|
branch: branchName.substring(slashIndex + 1),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a branch name refers to a remote branch
|
||||||
|
*/
|
||||||
|
async function isRemoteBranch(cwd: string, branchName: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const stdout = await execGitCommand(['branch', '-r', '--format=%(refname:short)'], cwd);
|
||||||
|
const remoteBranches = stdout
|
||||||
|
.trim()
|
||||||
|
.split('\n')
|
||||||
|
.map((b) => b.trim().replace(/^['"]|['"]$/g, ''))
|
||||||
|
.filter((b) => b);
|
||||||
|
return remoteBranches.includes(branchName);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perform a full branch switch workflow on the given worktree.
|
||||||
|
*
|
||||||
|
* The workflow:
|
||||||
|
* 1. Get current branch name
|
||||||
|
* 2. Detect remote vs local branch and determine target
|
||||||
|
* 3. Return early if already on target branch
|
||||||
|
* 4. Validate branch existence
|
||||||
|
* 5. Stash local changes if any
|
||||||
|
* 6. Checkout the target branch
|
||||||
|
* 7. Fetch latest from remotes
|
||||||
|
* 8. Reapply stashed changes (detect conflicts)
|
||||||
|
* 9. Handle error recovery (restore stash if checkout fails)
|
||||||
|
*
|
||||||
|
* @param worktreePath - Path to the git worktree
|
||||||
|
* @param branchName - Branch to switch to (can be local or remote like "origin/feature")
|
||||||
|
* @param events - Optional event emitter for lifecycle events
|
||||||
|
* @returns SwitchBranchResult with detailed status information
|
||||||
|
*/
|
||||||
|
export async function performSwitchBranch(
|
||||||
|
worktreePath: string,
|
||||||
|
branchName: string,
|
||||||
|
events?: EventEmitter
|
||||||
|
): Promise<SwitchBranchResult> {
|
||||||
|
// Emit start event
|
||||||
|
events?.emit('switch:start', { worktreePath, branchName });
|
||||||
|
|
||||||
|
// 1. Get current branch
|
||||||
|
const currentBranchOutput = await execGitCommand(
|
||||||
|
['rev-parse', '--abbrev-ref', 'HEAD'],
|
||||||
|
worktreePath
|
||||||
|
);
|
||||||
|
const previousBranch = currentBranchOutput.trim();
|
||||||
|
|
||||||
|
// 2. Determine the actual target branch name for checkout
|
||||||
|
let targetBranch = branchName;
|
||||||
|
let isRemote = false;
|
||||||
|
|
||||||
|
// Check if this is a remote branch (e.g., "origin/feature-branch")
|
||||||
|
let parsedRemote: { remote: string; branch: string } | null = null;
|
||||||
|
if (await isRemoteBranch(worktreePath, branchName)) {
|
||||||
|
isRemote = true;
|
||||||
|
parsedRemote = parseRemoteBranch(branchName);
|
||||||
|
if (parsedRemote) {
|
||||||
|
targetBranch = parsedRemote.branch;
|
||||||
|
} else {
|
||||||
|
events?.emit('switch:error', {
|
||||||
|
worktreePath,
|
||||||
|
branchName,
|
||||||
|
error: `Failed to parse remote branch name '${branchName}'`,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Failed to parse remote branch name '${branchName}'`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Return early if already on the target branch
|
||||||
|
if (previousBranch === targetBranch) {
|
||||||
|
events?.emit('switch:done', {
|
||||||
|
worktreePath,
|
||||||
|
previousBranch,
|
||||||
|
currentBranch: targetBranch,
|
||||||
|
alreadyOnBranch: true,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
result: {
|
||||||
|
previousBranch,
|
||||||
|
currentBranch: targetBranch,
|
||||||
|
message: `Already on branch '${targetBranch}'`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Check if target branch exists (locally or as remote ref)
|
||||||
|
if (!isRemote) {
|
||||||
|
try {
|
||||||
|
await execGitCommand(['rev-parse', '--verify', branchName], worktreePath);
|
||||||
|
} catch {
|
||||||
|
events?.emit('switch:error', {
|
||||||
|
worktreePath,
|
||||||
|
branchName,
|
||||||
|
error: `Branch '${branchName}' does not exist`,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Branch '${branchName}' does not exist`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Stash local changes if any exist
|
||||||
|
const hadChanges = await hasAnyChanges(worktreePath);
|
||||||
|
let didStash = false;
|
||||||
|
|
||||||
|
if (hadChanges) {
|
||||||
|
events?.emit('switch:stash', {
|
||||||
|
worktreePath,
|
||||||
|
previousBranch,
|
||||||
|
targetBranch,
|
||||||
|
action: 'push',
|
||||||
|
});
|
||||||
|
const stashMessage = `automaker-branch-switch: ${previousBranch} → ${targetBranch}`;
|
||||||
|
didStash = await stashChanges(worktreePath, stashMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 6. Switch to the target branch
|
||||||
|
events?.emit('switch:checkout', {
|
||||||
|
worktreePath,
|
||||||
|
targetBranch,
|
||||||
|
isRemote,
|
||||||
|
previousBranch,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isRemote) {
|
||||||
|
if (!parsedRemote) {
|
||||||
|
throw new Error(`Failed to parse remote branch name '${branchName}'`);
|
||||||
|
}
|
||||||
|
if (await localBranchExists(worktreePath, parsedRemote.branch)) {
|
||||||
|
// Local branch exists, just checkout
|
||||||
|
await execGitCommand(['checkout', parsedRemote.branch], worktreePath);
|
||||||
|
} else {
|
||||||
|
// Create local tracking branch from remote
|
||||||
|
await execGitCommand(['checkout', '-b', parsedRemote.branch, branchName], worktreePath);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await execGitCommand(['checkout', targetBranch], worktreePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Fetch latest from remotes after switching
|
||||||
|
await fetchRemotes(worktreePath);
|
||||||
|
|
||||||
|
// 8. Reapply stashed changes if we stashed earlier
|
||||||
|
let hasConflicts = false;
|
||||||
|
let conflictMessage = '';
|
||||||
|
let stashReapplied = false;
|
||||||
|
|
||||||
|
if (didStash) {
|
||||||
|
events?.emit('switch:pop', {
|
||||||
|
worktreePath,
|
||||||
|
targetBranch,
|
||||||
|
action: 'pop',
|
||||||
|
});
|
||||||
|
|
||||||
|
const popResult = await popStash(worktreePath);
|
||||||
|
hasConflicts = popResult.hasConflicts;
|
||||||
|
if (popResult.hasConflicts) {
|
||||||
|
conflictMessage = `Switched to branch '${targetBranch}' but merge conflicts occurred when reapplying your local changes. Please resolve the conflicts.`;
|
||||||
|
} else if (!popResult.success) {
|
||||||
|
// Stash pop failed for a non-conflict reason - the stash is still there
|
||||||
|
conflictMessage = `Switched to branch '${targetBranch}' 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: targetBranch,
|
||||||
|
hasConflicts: true,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
result: {
|
||||||
|
previousBranch,
|
||||||
|
currentBranch: targetBranch,
|
||||||
|
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: targetBranch,
|
||||||
|
stashPopFailed: true,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
result: {
|
||||||
|
previousBranch,
|
||||||
|
currentBranch: targetBranch,
|
||||||
|
message: conflictMessage,
|
||||||
|
hasConflicts: false,
|
||||||
|
stashedChanges: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
const stashNote = stashReapplied ? ' (local changes stashed and reapplied)' : '';
|
||||||
|
events?.emit('switch:done', {
|
||||||
|
worktreePath,
|
||||||
|
previousBranch,
|
||||||
|
currentBranch: targetBranch,
|
||||||
|
stashReapplied,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
result: {
|
||||||
|
previousBranch,
|
||||||
|
currentBranch: targetBranch,
|
||||||
|
message: `Switched to branch '${targetBranch}'${stashNote}`,
|
||||||
|
hasConflicts: false,
|
||||||
|
stashedChanges: stashReapplied,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (checkoutError) {
|
||||||
|
// 9. If checkout failed and we stashed, try to restore the stash
|
||||||
|
if (didStash) {
|
||||||
|
const popResult = await popStash(worktreePath);
|
||||||
|
if (popResult.hasConflicts) {
|
||||||
|
// Stash pop itself produced merge conflicts — the working tree is now in a
|
||||||
|
// conflicted state even though the checkout failed. Surface this clearly so
|
||||||
|
// the caller can prompt the user (or AI) to resolve conflicts rather than
|
||||||
|
// simply retrying the branch switch.
|
||||||
|
const checkoutErrorMsg = getErrorMessage(checkoutError);
|
||||||
|
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 the branch switch.',
|
||||||
|
};
|
||||||
|
} else if (!popResult.success) {
|
||||||
|
// Stash pop failed for a non-conflict reason; the stash entry is still intact.
|
||||||
|
// Include this detail alongside the original checkout error.
|
||||||
|
const checkoutErrorMsg = getErrorMessage(checkoutError);
|
||||||
|
const combinedMessage =
|
||||||
|
`${checkoutErrorMsg}. Additionally, restoring your stashed changes failed: ` +
|
||||||
|
`${popResult.error ?? 'unknown error'} — your changes are still saved in the stash.`;
|
||||||
|
events?.emit('switch:error', {
|
||||||
|
worktreePath,
|
||||||
|
branchName,
|
||||||
|
error: combinedMessage,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: combinedMessage,
|
||||||
|
stashPopConflicts: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// popResult.success === true: stash was cleanly restored, re-throw the checkout error
|
||||||
|
}
|
||||||
|
throw checkoutError;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,60 +1,196 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
import { parseGitLogOutput } from '../src/lib/git-log-parser.js';
|
import { parseGitLogOutput } from '../src/lib/git-log-parser.js';
|
||||||
|
|
||||||
// Mock data with NUL-based separator
|
// Mock data: fields within each commit are newline-separated,
|
||||||
const mockGitOutput = `a1b2c3d4e5f67890abcd1234567890abcd1234\x00a1b2c3\x00John Doe\x00john@example.com\x002023-01-01T12:00:00Z\x00Initial commit\x00This is the commit body\x00e5f6g7h8i9j0klmnoprstuv\x00e5f6g7\x00Jane Smith\x00jane@example.com\x002023-01-02T12:00:00Z\x00Fix bug\x00Fixed the bug with ---END--- in the message\x00q1w2e3r4t5y6u7i8o9p0asdfghjkl\x00q1w2e3\x00Bob Johnson\x00bob@example.com\x002023-01-03T12:00:00Z\x00Another commit\x00Empty body\x00`;
|
// commits are NUL-separated (matching the parser contract).
|
||||||
|
const mockGitOutput = [
|
||||||
|
'a1b2c3d4e5f67890abcd1234567890abcd1234\na1b2c3\nJohn Doe\njohn@example.com\n2023-01-01T12:00:00Z\nInitial commit\nThis is the commit body',
|
||||||
|
'e5f6g7h8i9j0klmnoprstuv\ne5f6g7\nJane Smith\njane@example.com\n2023-01-02T12:00:00Z\nFix bug\nFixed the bug with ---END--- in the message',
|
||||||
|
'q1w2e3r4t5y6u7i8o9p0asdfghjkl\nq1w2e3\nBob Johnson\nbob@example.com\n2023-01-03T12:00:00Z\nAnother commit\nEmpty body',
|
||||||
|
].join('\0');
|
||||||
|
|
||||||
// Mock data with problematic ---END--- in commit message
|
// Mock data where commit bodies contain ---END--- markers
|
||||||
const mockOutputWithEndMarker = `a1b2c3d4e5f67890abcd1234567890abcd1234\x00a1b2c3\x00John Doe\x00john@example.com\x002023-01-01T12:00:00Z\x00Initial commit\x00This is the commit body\x00---END--- is in this message\x00e5f6g7h8i9j0klmnoprstuv\x00e5f6g7\x00Jane Smith\x00jane@example.com\x002023-01-02T12:00:00Z\x00Fix bug\x00Fixed the bug with ---END--- in the message\x00q1w2e3r4t5y6u7i8o9p0asdfghjkl\x00q1w2e3\x00Bob Johnson\x00bob@example.com\x002023-01-03T12:00:00Z\x00Another commit\x00Empty body\x00`;
|
const mockOutputWithEndMarker = [
|
||||||
|
'a1b2c3d4e5f67890abcd1234567890abcd1234\na1b2c3\nJohn Doe\njohn@example.com\n2023-01-01T12:00:00Z\nInitial commit\nThis is the commit body\n---END--- is in this message',
|
||||||
|
'e5f6g7h8i9j0klmnoprstuv\ne5f6g7\nJane Smith\njane@example.com\n2023-01-02T12:00:00Z\nFix bug\nFixed the bug with ---END--- in the message',
|
||||||
|
'q1w2e3r4t5y6u7i8o9p0asdfghjkl\nq1w2e3\nBob Johnson\nbob@example.com\n2023-01-03T12:00:00Z\nAnother commit\nEmpty body',
|
||||||
|
].join('\0');
|
||||||
|
|
||||||
console.log('Testing parseGitLogOutput with NUL-based separator...\n');
|
// Single-commit mock: fields newline-separated, no trailing NUL needed
|
||||||
|
const singleCommitOutput =
|
||||||
|
'a1b2c3d4e5f67890abcd1234567890abcd1234\na1b2c3\nJohn Doe\njohn@example.com\n2023-01-01T12:00:00Z\nSingle commit\nSingle commit body';
|
||||||
|
|
||||||
// Test 1: Normal parsing
|
describe('parseGitLogOutput', () => {
|
||||||
console.log('Test 1: Normal parsing');
|
describe('normal parsing (three commits)', () => {
|
||||||
try {
|
it('returns the correct number of commits', () => {
|
||||||
const commits = parseGitLogOutput(mockGitOutput);
|
const commits = parseGitLogOutput(mockGitOutput);
|
||||||
console.log(`✓ Parsed ${commits.length} commits successfully`);
|
expect(commits.length).toBe(3);
|
||||||
console.log('First commit:', commits[0]);
|
});
|
||||||
console.log('Second commit:', commits[1]);
|
|
||||||
console.log('Third commit:', commits[2]);
|
|
||||||
console.log('');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('✗ Test 1 failed:', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test 2: Parsing with ---END--- in commit messages
|
it('parses the first commit fields correctly', () => {
|
||||||
console.log('Test 2: Parsing with ---END--- in commit messages');
|
const commits = parseGitLogOutput(mockGitOutput);
|
||||||
try {
|
expect(commits[0].hash).toBe('a1b2c3d4e5f67890abcd1234567890abcd1234');
|
||||||
const commits = parseGitLogOutput(mockOutputWithEndMarker);
|
expect(commits[0].shortHash).toBe('a1b2c3');
|
||||||
console.log(`✓ Parsed ${commits.length} commits successfully`);
|
expect(commits[0].author).toBe('John Doe');
|
||||||
console.log('Commits with ---END--- in messages:');
|
expect(commits[0].authorEmail).toBe('john@example.com');
|
||||||
commits.forEach((commit, index) => {
|
expect(commits[0].date).toBe('2023-01-01T12:00:00Z');
|
||||||
console.log(`${index + 1}. ${commit.subject}: "${commit.body}"`);
|
expect(commits[0].subject).toBe('Initial commit');
|
||||||
|
expect(commits[0].body).toBe('This is the commit body');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses the second commit fields correctly', () => {
|
||||||
|
const commits = parseGitLogOutput(mockGitOutput);
|
||||||
|
expect(commits[1].hash).toBe('e5f6g7h8i9j0klmnoprstuv');
|
||||||
|
expect(commits[1].shortHash).toBe('e5f6g7');
|
||||||
|
expect(commits[1].author).toBe('Jane Smith');
|
||||||
|
expect(commits[1].subject).toBe('Fix bug');
|
||||||
|
expect(commits[1].body).toMatch(/---END---/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses the third commit fields correctly', () => {
|
||||||
|
const commits = parseGitLogOutput(mockGitOutput);
|
||||||
|
expect(commits[2].hash).toBe('q1w2e3r4t5y6u7i8o9p0asdfghjkl');
|
||||||
|
expect(commits[2].shortHash).toBe('q1w2e3');
|
||||||
|
expect(commits[2].author).toBe('Bob Johnson');
|
||||||
|
expect(commits[2].subject).toBe('Another commit');
|
||||||
|
expect(commits[2].body).toBe('Empty body');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
console.log('');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('✗ Test 2 failed:', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test 3: Empty output
|
describe('parsing with ---END--- in commit messages', () => {
|
||||||
console.log('Test 3: Empty output');
|
it('returns the correct number of commits', () => {
|
||||||
try {
|
const commits = parseGitLogOutput(mockOutputWithEndMarker);
|
||||||
const commits = parseGitLogOutput('');
|
expect(commits.length).toBe(3);
|
||||||
console.log(`✓ Parsed ${commits.length} commits from empty output`);
|
});
|
||||||
console.log('');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('✗ Test 3 failed:', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test 4: Output with only one commit
|
it('preserves ---END--- text in the body of the first commit', () => {
|
||||||
console.log('Test 4: Output with only one commit');
|
const commits = parseGitLogOutput(mockOutputWithEndMarker);
|
||||||
const singleCommitOutput = `a1b2c3d4e5f67890abcd1234567890abcd1234\x00a1b2c3\x00John Doe\x00john@example.com\x002023-01-01T12:00:00Z\x00Single commit\x00Single commit body\x00`;
|
expect(commits[0].subject).toBe('Initial commit');
|
||||||
try {
|
expect(commits[0].body).toMatch(/---END---/);
|
||||||
const commits = parseGitLogOutput(singleCommitOutput);
|
});
|
||||||
console.log(`✓ Parsed ${commits.length} commits successfully`);
|
|
||||||
console.log('Single commit:', commits[0]);
|
|
||||||
console.log('');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('✗ Test 4 failed:', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('All tests completed!');
|
it('preserves ---END--- text in the body of the second commit', () => {
|
||||||
|
const commits = parseGitLogOutput(mockOutputWithEndMarker);
|
||||||
|
expect(commits[1].subject).toBe('Fix bug');
|
||||||
|
expect(commits[1].body).toMatch(/---END---/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses the third commit without ---END--- interference', () => {
|
||||||
|
const commits = parseGitLogOutput(mockOutputWithEndMarker);
|
||||||
|
expect(commits[2].subject).toBe('Another commit');
|
||||||
|
expect(commits[2].body).toBe('Empty body');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('empty output', () => {
|
||||||
|
it('returns an empty array for an empty string', () => {
|
||||||
|
const commits = parseGitLogOutput('');
|
||||||
|
expect(commits).toEqual([]);
|
||||||
|
expect(commits.length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('single-commit output', () => {
|
||||||
|
it('returns exactly one commit', () => {
|
||||||
|
const commits = parseGitLogOutput(singleCommitOutput);
|
||||||
|
expect(commits.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses the single commit fields correctly', () => {
|
||||||
|
const commits = parseGitLogOutput(singleCommitOutput);
|
||||||
|
expect(commits[0].hash).toBe('a1b2c3d4e5f67890abcd1234567890abcd1234');
|
||||||
|
expect(commits[0].shortHash).toBe('a1b2c3');
|
||||||
|
expect(commits[0].author).toBe('John Doe');
|
||||||
|
expect(commits[0].authorEmail).toBe('john@example.com');
|
||||||
|
expect(commits[0].date).toBe('2023-01-01T12:00:00Z');
|
||||||
|
expect(commits[0].subject).toBe('Single commit');
|
||||||
|
expect(commits[0].body).toBe('Single commit body');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('multi-line commit body', () => {
|
||||||
|
// Test vector from test-proper-nul-format.js: commit with a 3-line body
|
||||||
|
const multiLineBodyOutput =
|
||||||
|
[
|
||||||
|
'abc123\nabc1\nJohn Doe\njohn@example.com\n2023-01-01T12:00:00Z\nInitial commit\nThis is a normal commit body',
|
||||||
|
'def456\ndef4\nJane Smith\njane@example.com\n2023-01-02T12:00:00Z\nFix bug\nFixed the bug with ---END--- in this message',
|
||||||
|
'ghi789\nghi7\nBob Johnson\nbob@example.com\n2023-01-03T12:00:00Z\nAnother commit\nThis body has multiple lines\nSecond line\nThird line',
|
||||||
|
].join('\0') + '\0';
|
||||||
|
|
||||||
|
it('returns 3 commits', () => {
|
||||||
|
const commits = parseGitLogOutput(multiLineBodyOutput);
|
||||||
|
expect(commits.length).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses the first commit correctly', () => {
|
||||||
|
const commits = parseGitLogOutput(multiLineBodyOutput);
|
||||||
|
expect(commits[0].hash).toBe('abc123');
|
||||||
|
expect(commits[0].shortHash).toBe('abc1');
|
||||||
|
expect(commits[0].author).toBe('John Doe');
|
||||||
|
expect(commits[0].authorEmail).toBe('john@example.com');
|
||||||
|
expect(commits[0].date).toBe('2023-01-01T12:00:00Z');
|
||||||
|
expect(commits[0].subject).toBe('Initial commit');
|
||||||
|
expect(commits[0].body).toBe('This is a normal commit body');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses the second commit with ---END--- in body correctly', () => {
|
||||||
|
const commits = parseGitLogOutput(multiLineBodyOutput);
|
||||||
|
expect(commits[1].hash).toBe('def456');
|
||||||
|
expect(commits[1].shortHash).toBe('def4');
|
||||||
|
expect(commits[1].author).toBe('Jane Smith');
|
||||||
|
expect(commits[1].subject).toBe('Fix bug');
|
||||||
|
expect(commits[1].body).toContain('---END---');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses the third commit with a multi-line body correctly', () => {
|
||||||
|
const commits = parseGitLogOutput(multiLineBodyOutput);
|
||||||
|
expect(commits[2].hash).toBe('ghi789');
|
||||||
|
expect(commits[2].shortHash).toBe('ghi7');
|
||||||
|
expect(commits[2].author).toBe('Bob Johnson');
|
||||||
|
expect(commits[2].subject).toBe('Another commit');
|
||||||
|
expect(commits[2].body).toBe('This body has multiple lines\nSecond line\nThird line');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('commit with empty body (trailing blank lines after subject)', () => {
|
||||||
|
// Test vector from test-proper-nul-format.js: empty body commit
|
||||||
|
const emptyBodyOutput =
|
||||||
|
'empty123\nempty1\nAlice Brown\nalice@example.com\n2023-01-04T12:00:00Z\nEmpty body commit\n\n\0';
|
||||||
|
|
||||||
|
it('returns 1 commit', () => {
|
||||||
|
const commits = parseGitLogOutput(emptyBodyOutput);
|
||||||
|
expect(commits.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses the commit subject correctly', () => {
|
||||||
|
const commits = parseGitLogOutput(emptyBodyOutput);
|
||||||
|
expect(commits[0].hash).toBe('empty123');
|
||||||
|
expect(commits[0].shortHash).toBe('empty1');
|
||||||
|
expect(commits[0].author).toBe('Alice Brown');
|
||||||
|
expect(commits[0].subject).toBe('Empty body commit');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('produces an empty body string when only blank lines follow the subject', () => {
|
||||||
|
const commits = parseGitLogOutput(emptyBodyOutput);
|
||||||
|
expect(commits[0].body).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('leading empty lines in a commit block', () => {
|
||||||
|
// Blocks that start with blank lines before the hash field
|
||||||
|
const outputWithLeadingBlanks =
|
||||||
|
'\n\nabc123\nabc1\nJohn Doe\njohn@example.com\n2023-01-01T12:00:00Z\nSubject here\nBody here';
|
||||||
|
|
||||||
|
it('returns 1 commit despite leading blank lines', () => {
|
||||||
|
const commits = parseGitLogOutput(outputWithLeadingBlanks);
|
||||||
|
expect(commits.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses the commit fields correctly when block has leading empty lines', () => {
|
||||||
|
const commits = parseGitLogOutput(outputWithLeadingBlanks);
|
||||||
|
expect(commits[0].hash).toBe('abc123');
|
||||||
|
expect(commits[0].subject).toBe('Subject here');
|
||||||
|
expect(commits[0].body).toBe('Body here');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,107 +0,0 @@
|
|||||||
// Test to verify the NUL-based delimiter functionality
|
|
||||||
// This simulates exactly what git would produce with the new format
|
|
||||||
|
|
||||||
console.log('Testing NUL-based delimiter functionality...\n');
|
|
||||||
|
|
||||||
// Simulate git log output with proper NUL-based separator format
|
|
||||||
// Each commit has 7 fields separated by NUL: hash, shortHash, author, authorEmail, date, subject, body
|
|
||||||
const gitOutput = `abc123\x00abc1\x00John Doe\x00john@example.com\x002023-01-01T12:00:00Z\x00Initial commit\x00This is a normal commit body\x00def456\x00def4\x00Jane Smith\x00jane@example.com\x002023-01-02T12:00:00Z\x00Fix bug\x00Fixed the bug with ---END--- in this message\x00ghi789\x00ghi7\x00Bob Johnson\x00bob@example.com\x002023-01-03T12:00:00Z\x00Another commit\x00This body has multiple lines\nSecond line\nThird line\x00`;
|
|
||||||
|
|
||||||
// Test the parsing logic
|
|
||||||
console.log('1. Testing split on NUL character...');
|
|
||||||
const commitBlocks = gitOutput.split('\0').filter((block) => block.trim());
|
|
||||||
console.log(` ✓ Found ${commitBlocks.length} commit blocks`);
|
|
||||||
|
|
||||||
console.log('\n2. Testing parsing of each commit block...');
|
|
||||||
const commits = [];
|
|
||||||
for (const block of commitBlocks) {
|
|
||||||
const fields = block.split('\n');
|
|
||||||
|
|
||||||
// Validate we have all expected fields
|
|
||||||
if (fields.length >= 6) {
|
|
||||||
const commit = {
|
|
||||||
hash: fields[0].trim(),
|
|
||||||
shortHash: fields[1].trim(),
|
|
||||||
author: fields[2].trim(),
|
|
||||||
authorEmail: fields[3].trim(),
|
|
||||||
date: fields[4].trim(),
|
|
||||||
subject: fields[5].trim(),
|
|
||||||
body: fields.slice(6).join('\n').trim(),
|
|
||||||
};
|
|
||||||
commits.push(commit);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`\n3. Successfully parsed ${commits.length} commits:`);
|
|
||||||
commits.forEach((commit, index) => {
|
|
||||||
console.log(`\n Commit ${index + 1}:`);
|
|
||||||
console.log(` - Hash: ${commit.hash}`);
|
|
||||||
console.log(` - Short hash: ${commit.shortHash}`);
|
|
||||||
console.log(` - Author: ${commit.author}`);
|
|
||||||
console.log(` - Email: ${commit.authorEmail}`);
|
|
||||||
console.log(` - Date: ${commit.date}`);
|
|
||||||
console.log(` - Subject: ${commit.subject}`);
|
|
||||||
console.log(` - Body: "${commit.body}"`);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test with problematic ---END--- in commit message
|
|
||||||
console.log('\n4. Testing with ---END--- in commit message...');
|
|
||||||
const problematicOutput = `test123\x00test1\x00John Doe\x00john@example.com\x002023-01-01T12:00:00Z\x00Initial commit\x00This contains ---END--- but should be parsed correctly\x00`;
|
|
||||||
const problematicCommits = problematicOutput
|
|
||||||
.split('\0')
|
|
||||||
.filter((block) => block.trim())
|
|
||||||
.map((block) => {
|
|
||||||
const fields = block.split('\n');
|
|
||||||
if (fields.length >= 6) {
|
|
||||||
return {
|
|
||||||
hash: fields[0].trim(),
|
|
||||||
shortHash: fields[1].trim(),
|
|
||||||
author: fields[2].trim(),
|
|
||||||
authorEmail: fields[3].trim(),
|
|
||||||
date: fields[4].trim(),
|
|
||||||
subject: fields[5].trim(),
|
|
||||||
body: fields.slice(6).join('\n').trim(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
})
|
|
||||||
.filter((commit) => commit !== null);
|
|
||||||
|
|
||||||
console.log(` ✓ Found ${problematicCommits.length} commits`);
|
|
||||||
console.log(` Subject: "${problematicCommits[0].subject}"`);
|
|
||||||
console.log(` Body: "${problematicCommits[0].body}"`);
|
|
||||||
|
|
||||||
// Test with empty body
|
|
||||||
console.log('\n5. Testing commit with empty body...');
|
|
||||||
const emptyBodyOutput = `empty123\x00empty1\x00Alice Brown\x00alice@example.com\x002023-01-04T12:00:00Z\x00Empty body commit\x00\x00`;
|
|
||||||
const emptyBodyCommits = emptyBodyOutput
|
|
||||||
.split('\0')
|
|
||||||
.filter((block) => block.trim())
|
|
||||||
.map((block) => {
|
|
||||||
const fields = block.split('\n');
|
|
||||||
if (fields.length >= 6) {
|
|
||||||
return {
|
|
||||||
hash: fields[0].trim(),
|
|
||||||
shortHash: fields[1].trim(),
|
|
||||||
author: fields[2].trim(),
|
|
||||||
authorEmail: fields[3].trim(),
|
|
||||||
date: fields[4].trim(),
|
|
||||||
subject: fields[5].trim(),
|
|
||||||
body: fields.slice(6).join('\n').trim(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
})
|
|
||||||
.filter((commit) => commit !== null);
|
|
||||||
|
|
||||||
console.log(` ✓ Found ${emptyBodyCommits.length} commits`);
|
|
||||||
console.log(` Subject: "${emptyBodyCommits[0].subject}"`);
|
|
||||||
console.log(` Body: "${emptyBodyCommits[0].body}" (should be empty)`);
|
|
||||||
|
|
||||||
console.log('\n✅ All tests passed! NUL-based delimiter works correctly.');
|
|
||||||
console.log('\nSummary:');
|
|
||||||
console.log('- NUL character (\\x00) properly separates commits');
|
|
||||||
console.log('- Each commit is split into exactly 7 fields');
|
|
||||||
console.log('- ---END--- in commit messages is handled correctly');
|
|
||||||
console.log('- Empty commit bodies are preserved as empty strings');
|
|
||||||
console.log('- Multi-line commit bodies are preserved correctly');
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
// Simple test to verify the NUL-based delimiter works
|
|
||||||
// This simulates what git would produce with the new format
|
|
||||||
|
|
||||||
console.log('Testing NUL-based delimiter functionality...\n');
|
|
||||||
|
|
||||||
// Simulate git log output with NUL-based separator
|
|
||||||
const gitOutputWithNul = `abc123\x00abc1\x00John Doe\x00john@example.com\x002023-01-01T12:00:00Z\x00Initial commit\x00This is a normal commit body\x00def456\x00def4\x00Jane Smith\x00jane@example.com\x002023-01-02T12:00:00Z\x00Fix bug\x00Fixed the bug with ---END--- in this message\x00ghi789\x00ghi7\x00Bob Johnson\x00bob@example.com\x002023-01-03T12:00:00Z\x00Another commit\x00This body has multiple lines\nSecond line\nThird line\x00`;
|
|
||||||
|
|
||||||
// Test splitting on NUL
|
|
||||||
console.log('1. Testing split on NUL character...');
|
|
||||||
const commits = gitOutputWithNul.split('\0').filter((block) => block.trim());
|
|
||||||
console.log(` ✓ Found ${commits.length} commits`);
|
|
||||||
|
|
||||||
console.log('\n2. Testing parsing of each commit...');
|
|
||||||
commits.forEach((commit, index) => {
|
|
||||||
const fields = commit.split('\n');
|
|
||||||
console.log(`\n Commit ${index + 1}:`);
|
|
||||||
console.log(` - Hash: ${fields[0]}`);
|
|
||||||
console.log(` - Short hash: ${fields[1]}`);
|
|
||||||
console.log(` - Author: ${fields[2]}`);
|
|
||||||
console.log(` - Email: ${fields[3]}`);
|
|
||||||
console.log(` - Date: ${fields[4]}`);
|
|
||||||
console.log(` - Subject: ${fields[5]}`);
|
|
||||||
console.log(` - Body: "${fields.slice(6).join('\n')}"`);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test with problematic ---END--- in message
|
|
||||||
console.log('\n3. Testing with ---END--- in commit message...');
|
|
||||||
const problematicOutput = `abc123\x00abc1\x00John Doe\x00john@example.com\x002023-01-01T12:00:00Z\x00Initial commit\x00This contains ---END--- but should be parsed correctly\x00`;
|
|
||||||
const problematicCommits = problematicOutput.split('\0').filter((block) => block.trim());
|
|
||||||
console.log(
|
|
||||||
` ✓ Found ${problematicCommits.length} commits (correctly ignoring ---END--- in message)`
|
|
||||||
);
|
|
||||||
|
|
||||||
// Test empty blocks
|
|
||||||
console.log('\n4. Testing with empty blocks...');
|
|
||||||
const outputWithEmptyBlocks = `abc123\x00abc1\x00John Doe\x00john@example.com\x002023-01-01T12:00:00Z\x00Valid commit\x00Body here\x00\x00def456\x00def4\x00Jane Smith\x00jane@example.com\x002023-01-02T12:00:00Z\x00Another valid commit\x00Another body\x00`;
|
|
||||||
const outputWithEmptyBlocksParsed = outputWithEmptyBlocks
|
|
||||||
.split('\0')
|
|
||||||
.filter((block) => block.trim());
|
|
||||||
console.log(` ✓ Found ${outputWithEmptyBlocksParsed.length} commits (empty blocks filtered out)`);
|
|
||||||
|
|
||||||
console.log('\n✅ All tests passed! NUL-based delimiter works correctly.');
|
|
||||||
console.log('\nSummary:');
|
|
||||||
console.log('- NUL character (\\x00) properly separates commits');
|
|
||||||
console.log('- ---END--- in commit messages is handled correctly');
|
|
||||||
console.log('- Empty blocks are filtered out');
|
|
||||||
console.log('- Multi-line commit bodies are preserved');
|
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
// Test to verify the proper NUL-based delimiter functionality
|
|
||||||
// Each commit: field1\nfield2\nfield3\x00field1\nfield2\nfield3\x00...
|
|
||||||
|
|
||||||
console.log('Testing proper NUL-based delimiter format...\n');
|
|
||||||
|
|
||||||
// Proper git output format with NUL between commits
|
|
||||||
const gitOutput = `abc123
|
|
||||||
abc1
|
|
||||||
John Doe
|
|
||||||
john@example.com
|
|
||||||
2023-01-01T12:00:00Z
|
|
||||||
Initial commit
|
|
||||||
This is a normal commit body\x00def456
|
|
||||||
def4
|
|
||||||
Jane Smith
|
|
||||||
jane@example.com
|
|
||||||
2023-01-02T12:00:00Z
|
|
||||||
Fix bug
|
|
||||||
Fixed the bug with ---END--- in this message\x00ghi789
|
|
||||||
ghi7
|
|
||||||
Bob Johnson
|
|
||||||
bob@example.com
|
|
||||||
2023-01-03T12:00:00Z
|
|
||||||
Another commit
|
|
||||||
This body has multiple lines
|
|
||||||
Second line
|
|
||||||
Third line\x00`;
|
|
||||||
|
|
||||||
console.log('1. Testing split on NUL character...');
|
|
||||||
const commitBlocks = gitOutput.split('\0').filter((block) => block.trim());
|
|
||||||
console.log(` ✓ Found ${commitBlocks.length} commit blocks`);
|
|
||||||
|
|
||||||
console.log('\n2. Testing parsing of each commit block...');
|
|
||||||
const commits = [];
|
|
||||||
for (const block of commitBlocks) {
|
|
||||||
const allLines = block.split('\n');
|
|
||||||
|
|
||||||
// Skip leading empty lines
|
|
||||||
let startIndex = 0;
|
|
||||||
while (startIndex < allLines.length && allLines[startIndex].trim() === '') {
|
|
||||||
startIndex++;
|
|
||||||
}
|
|
||||||
const lines = allLines.slice(startIndex);
|
|
||||||
|
|
||||||
if (lines.length >= 6) {
|
|
||||||
const commit = {
|
|
||||||
hash: lines[0].trim(),
|
|
||||||
shortHash: lines[1].trim(),
|
|
||||||
author: lines[2].trim(),
|
|
||||||
authorEmail: lines[3].trim(),
|
|
||||||
date: lines[4].trim(),
|
|
||||||
subject: lines[5].trim(),
|
|
||||||
body: lines.slice(6).join('\n').trim(),
|
|
||||||
};
|
|
||||||
commits.push(commit);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`\n3. Successfully parsed ${commits.length} commits:`);
|
|
||||||
commits.forEach((commit, index) => {
|
|
||||||
console.log(`\n Commit ${index + 1}:`);
|
|
||||||
console.log(` - Hash: ${commit.hash}`);
|
|
||||||
console.log(` - Short hash: ${commit.shortHash}`);
|
|
||||||
console.log(` - Author: ${commit.author}`);
|
|
||||||
console.log(` - Email: ${commit.authorEmail}`);
|
|
||||||
console.log(` - Date: ${commit.date}`);
|
|
||||||
console.log(` - Subject: ${commit.subject}`);
|
|
||||||
console.log(` - Body: "${commit.body}"`);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test with problematic ---END--- in commit message
|
|
||||||
console.log('\n4. Testing with ---END--- in commit message...');
|
|
||||||
const problematicOutput = `test123
|
|
||||||
test1
|
|
||||||
John Doe
|
|
||||||
john@example.com
|
|
||||||
2023-01-01T12:00:00Z
|
|
||||||
Initial commit
|
|
||||||
This contains ---END--- but should be parsed correctly\x00`;
|
|
||||||
const problematicCommits = problematicOutput
|
|
||||||
.split('\0')
|
|
||||||
.filter((block) => block.trim())
|
|
||||||
.map((block) => {
|
|
||||||
const allLines = block.split('\n');
|
|
||||||
|
|
||||||
// Skip leading empty lines
|
|
||||||
let startIndex = 0;
|
|
||||||
while (startIndex < allLines.length && allLines[startIndex].trim() === '') {
|
|
||||||
startIndex++;
|
|
||||||
}
|
|
||||||
const lines = allLines.slice(startIndex);
|
|
||||||
|
|
||||||
if (lines.length >= 6) {
|
|
||||||
return {
|
|
||||||
hash: lines[0].trim(),
|
|
||||||
shortHash: lines[1].trim(),
|
|
||||||
author: lines[2].trim(),
|
|
||||||
authorEmail: lines[3].trim(),
|
|
||||||
date: lines[4].trim(),
|
|
||||||
subject: lines[5].trim(),
|
|
||||||
body: lines.slice(6).join('\n').trim(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
})
|
|
||||||
.filter((commit) => commit !== null);
|
|
||||||
|
|
||||||
console.log(` ✓ Found ${problematicCommits.length} commits`);
|
|
||||||
if (problematicCommits.length > 0) {
|
|
||||||
console.log(` Subject: "${problematicCommits[0].subject}"`);
|
|
||||||
console.log(` Body: "${problematicCommits[0].body}"`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test with empty body
|
|
||||||
console.log('\n5. Testing commit with empty body...');
|
|
||||||
const emptyBodyOutput = `empty123
|
|
||||||
empty1
|
|
||||||
Alice Brown
|
|
||||||
alice@example.com
|
|
||||||
2023-01-04T12:00:00Z
|
|
||||||
Empty body commit
|
|
||||||
|
|
||||||
\x00`;
|
|
||||||
const emptyBodyCommits = emptyBodyOutput
|
|
||||||
.split('\0')
|
|
||||||
.filter((block) => block.trim())
|
|
||||||
.map((block) => {
|
|
||||||
const allLines = block.split('\n');
|
|
||||||
|
|
||||||
// Skip leading empty lines
|
|
||||||
let startIndex = 0;
|
|
||||||
while (startIndex < allLines.length && allLines[startIndex].trim() === '') {
|
|
||||||
startIndex++;
|
|
||||||
}
|
|
||||||
const lines = allLines.slice(startIndex);
|
|
||||||
|
|
||||||
if (lines.length >= 6) {
|
|
||||||
return {
|
|
||||||
hash: lines[0].trim(),
|
|
||||||
shortHash: lines[1].trim(),
|
|
||||||
author: lines[2].trim(),
|
|
||||||
authorEmail: lines[3].trim(),
|
|
||||||
date: lines[4].trim(),
|
|
||||||
subject: lines[5].trim(),
|
|
||||||
body: lines.slice(6).join('\n').trim(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
})
|
|
||||||
.filter((commit) => commit !== null);
|
|
||||||
|
|
||||||
console.log(` ✓ Found ${emptyBodyCommits.length} commits`);
|
|
||||||
if (emptyBodyCommits.length > 0) {
|
|
||||||
console.log(` Subject: "${emptyBodyCommits[0].subject}"`);
|
|
||||||
console.log(` Body: "${emptyBodyCommits[0].body}" (should be empty)`);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('\n✅ All tests passed! NUL-based delimiter works correctly.');
|
|
||||||
console.log('\nKey insights:');
|
|
||||||
console.log('- NUL character (\\x00) separates commits');
|
|
||||||
console.log('- Newlines (\\n) separate fields within a commit');
|
|
||||||
console.log('- The parsing logic handles leading empty lines correctly');
|
|
||||||
console.log('- ---END--- in commit messages is handled correctly');
|
|
||||||
console.log('- Empty commit bodies are preserved as empty strings');
|
|
||||||
console.log('- Multi-line commit bodies are preserved correctly');
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
// Simple test to understand the NUL character behavior
|
|
||||||
|
|
||||||
console.log('Testing NUL character behavior...\n');
|
|
||||||
|
|
||||||
// Create a string with NUL characters
|
|
||||||
const str1 =
|
|
||||||
'abc123\x00abc1\x00John Doe\x00john@example.com\x002023-01-01T12:00:00Z\x00Initial commit\x00This is a normal commit body\x00';
|
|
||||||
|
|
||||||
console.log('Original string length:', str1.length);
|
|
||||||
console.log('String representation:', str1);
|
|
||||||
|
|
||||||
// Split on NUL
|
|
||||||
console.log('\n1. Split on NUL character:');
|
|
||||||
const parts = str1.split('\0');
|
|
||||||
console.log('Number of parts:', parts.length);
|
|
||||||
parts.forEach((part, index) => {
|
|
||||||
console.log(`Part ${index}: "${part}" (length: ${part.length})`);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test with actual git format
|
|
||||||
console.log('\n2. Testing with actual git format:');
|
|
||||||
const gitFormat = `abc123\x00abc1\x00John Doe\x00john@example.com\x002023-01-01T12:00:00Z\x00Initial commit\x00Body text here\x00def456\x00def4\x00Jane Smith\x00jane@example.com\x002023-01-02T12:00:00Z\x00Second commit\x00Body with ---END--- text\x00`;
|
|
||||||
|
|
||||||
const gitParts = gitFormat.split('\0').filter((block) => block.trim());
|
|
||||||
console.log('Number of commits found:', gitParts.length);
|
|
||||||
|
|
||||||
console.log('\nAnalyzing each commit:');
|
|
||||||
gitParts.forEach((block, index) => {
|
|
||||||
console.log(`\nCommit ${index + 1}:`);
|
|
||||||
console.log(`Block: "${block}"`);
|
|
||||||
const fields = block.split('\n');
|
|
||||||
console.log(`Number of fields: ${fields.length}`);
|
|
||||||
fields.forEach((field, fieldIndex) => {
|
|
||||||
const fieldNames = ['hash', 'shortHash', 'author', 'authorEmail', 'date', 'subject', 'body'];
|
|
||||||
console.log(` ${fieldNames[fieldIndex] || `field${fieldIndex}`}: "${field}"`);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
196
apps/server/tests/unit/lib/git-log-parser.test.ts
Normal file
196
apps/server/tests/unit/lib/git-log-parser.test.ts
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { parseGitLogOutput } from '../../../src/lib/git-log-parser.js';
|
||||||
|
|
||||||
|
// Mock data: fields within each commit are newline-separated,
|
||||||
|
// commits are NUL-separated (matching the parser contract).
|
||||||
|
const mockGitOutput = [
|
||||||
|
'a1b2c3d4e5f67890abcd1234567890abcd1234\na1b2c3\nJohn Doe\njohn@example.com\n2023-01-01T12:00:00Z\nInitial commit\nThis is the commit body',
|
||||||
|
'e5f6g7h8i9j0klmnoprstuv\ne5f6g7\nJane Smith\njane@example.com\n2023-01-02T12:00:00Z\nFix bug\nFixed the bug with ---END--- in the message',
|
||||||
|
'q1w2e3r4t5y6u7i8o9p0asdfghjkl\nq1w2e3\nBob Johnson\nbob@example.com\n2023-01-03T12:00:00Z\nAnother commit\nEmpty body',
|
||||||
|
].join('\0');
|
||||||
|
|
||||||
|
// Mock data where commit bodies contain ---END--- markers
|
||||||
|
const mockOutputWithEndMarker = [
|
||||||
|
'a1b2c3d4e5f67890abcd1234567890abcd1234\na1b2c3\nJohn Doe\njohn@example.com\n2023-01-01T12:00:00Z\nInitial commit\nThis is the commit body\n---END--- is in this message',
|
||||||
|
'e5f6g7h8i9j0klmnoprstuv\ne5f6g7\nJane Smith\njane@example.com\n2023-01-02T12:00:00Z\nFix bug\nFixed the bug with ---END--- in the message',
|
||||||
|
'q1w2e3r4t5y6u7i8o9p0asdfghjkl\nq1w2e3\nBob Johnson\nbob@example.com\n2023-01-03T12:00:00Z\nAnother commit\nEmpty body',
|
||||||
|
].join('\0');
|
||||||
|
|
||||||
|
// Single-commit mock: fields newline-separated, no trailing NUL needed
|
||||||
|
const singleCommitOutput =
|
||||||
|
'a1b2c3d4e5f67890abcd1234567890abcd1234\na1b2c3\nJohn Doe\njohn@example.com\n2023-01-01T12:00:00Z\nSingle commit\nSingle commit body';
|
||||||
|
|
||||||
|
describe('parseGitLogOutput', () => {
|
||||||
|
describe('normal parsing (three commits)', () => {
|
||||||
|
it('returns the correct number of commits', () => {
|
||||||
|
const commits = parseGitLogOutput(mockGitOutput);
|
||||||
|
expect(commits.length).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses the first commit fields correctly', () => {
|
||||||
|
const commits = parseGitLogOutput(mockGitOutput);
|
||||||
|
expect(commits[0].hash).toBe('a1b2c3d4e5f67890abcd1234567890abcd1234');
|
||||||
|
expect(commits[0].shortHash).toBe('a1b2c3');
|
||||||
|
expect(commits[0].author).toBe('John Doe');
|
||||||
|
expect(commits[0].authorEmail).toBe('john@example.com');
|
||||||
|
expect(commits[0].date).toBe('2023-01-01T12:00:00Z');
|
||||||
|
expect(commits[0].subject).toBe('Initial commit');
|
||||||
|
expect(commits[0].body).toBe('This is the commit body');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses the second commit fields correctly', () => {
|
||||||
|
const commits = parseGitLogOutput(mockGitOutput);
|
||||||
|
expect(commits[1].hash).toBe('e5f6g7h8i9j0klmnoprstuv');
|
||||||
|
expect(commits[1].shortHash).toBe('e5f6g7');
|
||||||
|
expect(commits[1].author).toBe('Jane Smith');
|
||||||
|
expect(commits[1].subject).toBe('Fix bug');
|
||||||
|
expect(commits[1].body).toMatch(/---END---/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses the third commit fields correctly', () => {
|
||||||
|
const commits = parseGitLogOutput(mockGitOutput);
|
||||||
|
expect(commits[2].hash).toBe('q1w2e3r4t5y6u7i8o9p0asdfghjkl');
|
||||||
|
expect(commits[2].shortHash).toBe('q1w2e3');
|
||||||
|
expect(commits[2].author).toBe('Bob Johnson');
|
||||||
|
expect(commits[2].subject).toBe('Another commit');
|
||||||
|
expect(commits[2].body).toBe('Empty body');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parsing with ---END--- in commit messages', () => {
|
||||||
|
it('returns the correct number of commits', () => {
|
||||||
|
const commits = parseGitLogOutput(mockOutputWithEndMarker);
|
||||||
|
expect(commits.length).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves ---END--- text in the body of the first commit', () => {
|
||||||
|
const commits = parseGitLogOutput(mockOutputWithEndMarker);
|
||||||
|
expect(commits[0].subject).toBe('Initial commit');
|
||||||
|
expect(commits[0].body).toMatch(/---END---/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves ---END--- text in the body of the second commit', () => {
|
||||||
|
const commits = parseGitLogOutput(mockOutputWithEndMarker);
|
||||||
|
expect(commits[1].subject).toBe('Fix bug');
|
||||||
|
expect(commits[1].body).toMatch(/---END---/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses the third commit without ---END--- interference', () => {
|
||||||
|
const commits = parseGitLogOutput(mockOutputWithEndMarker);
|
||||||
|
expect(commits[2].subject).toBe('Another commit');
|
||||||
|
expect(commits[2].body).toBe('Empty body');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('empty output', () => {
|
||||||
|
it('returns an empty array for an empty string', () => {
|
||||||
|
const commits = parseGitLogOutput('');
|
||||||
|
expect(commits).toEqual([]);
|
||||||
|
expect(commits.length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('single-commit output', () => {
|
||||||
|
it('returns exactly one commit', () => {
|
||||||
|
const commits = parseGitLogOutput(singleCommitOutput);
|
||||||
|
expect(commits.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses the single commit fields correctly', () => {
|
||||||
|
const commits = parseGitLogOutput(singleCommitOutput);
|
||||||
|
expect(commits[0].hash).toBe('a1b2c3d4e5f67890abcd1234567890abcd1234');
|
||||||
|
expect(commits[0].shortHash).toBe('a1b2c3');
|
||||||
|
expect(commits[0].author).toBe('John Doe');
|
||||||
|
expect(commits[0].authorEmail).toBe('john@example.com');
|
||||||
|
expect(commits[0].date).toBe('2023-01-01T12:00:00Z');
|
||||||
|
expect(commits[0].subject).toBe('Single commit');
|
||||||
|
expect(commits[0].body).toBe('Single commit body');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('multi-line commit body', () => {
|
||||||
|
// Test vector from test-proper-nul-format.js: commit with a 3-line body
|
||||||
|
const multiLineBodyOutput =
|
||||||
|
[
|
||||||
|
'abc123\nabc1\nJohn Doe\njohn@example.com\n2023-01-01T12:00:00Z\nInitial commit\nThis is a normal commit body',
|
||||||
|
'def456\ndef4\nJane Smith\njane@example.com\n2023-01-02T12:00:00Z\nFix bug\nFixed the bug with ---END--- in this message',
|
||||||
|
'ghi789\nghi7\nBob Johnson\nbob@example.com\n2023-01-03T12:00:00Z\nAnother commit\nThis body has multiple lines\nSecond line\nThird line',
|
||||||
|
].join('\0') + '\0';
|
||||||
|
|
||||||
|
it('returns 3 commits', () => {
|
||||||
|
const commits = parseGitLogOutput(multiLineBodyOutput);
|
||||||
|
expect(commits.length).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses the first commit correctly', () => {
|
||||||
|
const commits = parseGitLogOutput(multiLineBodyOutput);
|
||||||
|
expect(commits[0].hash).toBe('abc123');
|
||||||
|
expect(commits[0].shortHash).toBe('abc1');
|
||||||
|
expect(commits[0].author).toBe('John Doe');
|
||||||
|
expect(commits[0].authorEmail).toBe('john@example.com');
|
||||||
|
expect(commits[0].date).toBe('2023-01-01T12:00:00Z');
|
||||||
|
expect(commits[0].subject).toBe('Initial commit');
|
||||||
|
expect(commits[0].body).toBe('This is a normal commit body');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses the second commit with ---END--- in body correctly', () => {
|
||||||
|
const commits = parseGitLogOutput(multiLineBodyOutput);
|
||||||
|
expect(commits[1].hash).toBe('def456');
|
||||||
|
expect(commits[1].shortHash).toBe('def4');
|
||||||
|
expect(commits[1].author).toBe('Jane Smith');
|
||||||
|
expect(commits[1].subject).toBe('Fix bug');
|
||||||
|
expect(commits[1].body).toContain('---END---');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses the third commit with a multi-line body correctly', () => {
|
||||||
|
const commits = parseGitLogOutput(multiLineBodyOutput);
|
||||||
|
expect(commits[2].hash).toBe('ghi789');
|
||||||
|
expect(commits[2].shortHash).toBe('ghi7');
|
||||||
|
expect(commits[2].author).toBe('Bob Johnson');
|
||||||
|
expect(commits[2].subject).toBe('Another commit');
|
||||||
|
expect(commits[2].body).toBe('This body has multiple lines\nSecond line\nThird line');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('commit with empty body (trailing blank lines after subject)', () => {
|
||||||
|
// Test vector from test-proper-nul-format.js: empty body commit
|
||||||
|
const emptyBodyOutput =
|
||||||
|
'empty123\nempty1\nAlice Brown\nalice@example.com\n2023-01-04T12:00:00Z\nEmpty body commit\n\n\0';
|
||||||
|
|
||||||
|
it('returns 1 commit', () => {
|
||||||
|
const commits = parseGitLogOutput(emptyBodyOutput);
|
||||||
|
expect(commits.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses the commit subject correctly', () => {
|
||||||
|
const commits = parseGitLogOutput(emptyBodyOutput);
|
||||||
|
expect(commits[0].hash).toBe('empty123');
|
||||||
|
expect(commits[0].shortHash).toBe('empty1');
|
||||||
|
expect(commits[0].author).toBe('Alice Brown');
|
||||||
|
expect(commits[0].subject).toBe('Empty body commit');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('produces an empty body string when only blank lines follow the subject', () => {
|
||||||
|
const commits = parseGitLogOutput(emptyBodyOutput);
|
||||||
|
expect(commits[0].body).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('leading empty lines in a commit block', () => {
|
||||||
|
// Blocks that start with blank lines before the hash field
|
||||||
|
const outputWithLeadingBlanks =
|
||||||
|
'\n\nabc123\nabc1\nJohn Doe\njohn@example.com\n2023-01-01T12:00:00Z\nSubject here\nBody here';
|
||||||
|
|
||||||
|
it('returns 1 commit despite leading blank lines', () => {
|
||||||
|
const commits = parseGitLogOutput(outputWithLeadingBlanks);
|
||||||
|
expect(commits.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses the commit fields correctly when block has leading empty lines', () => {
|
||||||
|
const commits = parseGitLogOutput(outputWithLeadingBlanks);
|
||||||
|
expect(commits[0].hash).toBe('abc123');
|
||||||
|
expect(commits[0].subject).toBe('Subject here');
|
||||||
|
expect(commits[0].body).toBe('Body here');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
83
apps/server/tests/unit/lib/nul-delimiter.test.ts
Normal file
83
apps/server/tests/unit/lib/nul-delimiter.test.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
// Automated tests for NUL character behavior in git commit parsing
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
|
||||||
|
describe('NUL character behavior', () => {
|
||||||
|
// Create a string with NUL characters
|
||||||
|
const str1 =
|
||||||
|
'abc123\x00abc1\x00John Doe\x00john@example.com\x002023-01-01T12:00:00Z\x00Initial commit\x00This is a normal commit body\x00';
|
||||||
|
|
||||||
|
describe('split on NUL character', () => {
|
||||||
|
const parts = str1.split('\0');
|
||||||
|
|
||||||
|
it('should produce the expected number of parts', () => {
|
||||||
|
// 7 fields + 1 trailing empty string from the trailing \x00
|
||||||
|
expect(parts.length).toBe(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should contain the expected part values', () => {
|
||||||
|
expect(parts[0]).toBe('abc123');
|
||||||
|
expect(parts[1]).toBe('abc1');
|
||||||
|
expect(parts[2]).toBe('John Doe');
|
||||||
|
expect(parts[3]).toBe('john@example.com');
|
||||||
|
expect(parts[4]).toBe('2023-01-01T12:00:00Z');
|
||||||
|
expect(parts[5]).toBe('Initial commit');
|
||||||
|
expect(parts[6]).toBe('This is a normal commit body');
|
||||||
|
expect(parts[7]).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have correct lengths for each part', () => {
|
||||||
|
expect(parts[0].length).toBe(6); // 'abc123'
|
||||||
|
expect(parts[1].length).toBe(4); // 'abc1'
|
||||||
|
expect(parts[2].length).toBe(8); // 'John Doe'
|
||||||
|
expect(parts[3].length).toBe(16); // 'john@example.com'
|
||||||
|
expect(parts[4].length).toBe(20); // '2023-01-01T12:00:00Z'
|
||||||
|
expect(parts[5].length).toBe(14); // 'Initial commit'
|
||||||
|
expect(parts[6].length).toBe(28); // 'This is a normal commit body'
|
||||||
|
expect(parts[7].length).toBe(0); // trailing empty
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('git format split and filter', () => {
|
||||||
|
const gitFormat = `abc123\x00abc1\x00John Doe\x00john@example.com\x002023-01-01T12:00:00Z\x00Initial commit\x00Body text here\x00def456\x00def4\x00Jane Smith\x00jane@example.com\x002023-01-02T12:00:00Z\x00Second commit\x00Body with ---END--- text\x00`;
|
||||||
|
|
||||||
|
const gitParts = gitFormat.split('\0').filter((block) => block.trim());
|
||||||
|
|
||||||
|
it('should produce the expected number of non-empty parts after filtering', () => {
|
||||||
|
// 14 non-empty field strings (7 fields per commit × 2 commits); trailing empty is filtered out
|
||||||
|
expect(gitParts.length).toBe(14);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should contain correct field values for the first commit', () => {
|
||||||
|
const fields = gitParts.slice(0, 7);
|
||||||
|
expect(fields.length).toBe(7);
|
||||||
|
expect(fields[0]).toBe('abc123'); // hash
|
||||||
|
expect(fields[1]).toBe('abc1'); // shortHash
|
||||||
|
expect(fields[2]).toBe('John Doe'); // author
|
||||||
|
expect(fields[3]).toBe('john@example.com'); // authorEmail
|
||||||
|
expect(fields[4]).toBe('2023-01-01T12:00:00Z'); // date
|
||||||
|
expect(fields[5]).toBe('Initial commit'); // subject
|
||||||
|
expect(fields[6]).toBe('Body text here'); // body
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should contain correct field values for the second commit', () => {
|
||||||
|
const fields = gitParts.slice(7, 14);
|
||||||
|
expect(fields.length).toBe(7);
|
||||||
|
expect(fields[0]).toBe('def456'); // hash
|
||||||
|
expect(fields[1]).toBe('def4'); // shortHash
|
||||||
|
expect(fields[2]).toBe('Jane Smith'); // author
|
||||||
|
expect(fields[3]).toBe('jane@example.com'); // authorEmail
|
||||||
|
expect(fields[4]).toBe('2023-01-02T12:00:00Z'); // date
|
||||||
|
expect(fields[5]).toBe('Second commit'); // subject
|
||||||
|
expect(fields[6]).toBe('Body with ---END--- text'); // body (---END--- handled correctly)
|
||||||
|
});
|
||||||
|
|
||||||
|
it('each part should have the expected number of newline-delimited fields', () => {
|
||||||
|
// Each gitPart is a single field value (no internal newlines), so split('\n') yields 1 field
|
||||||
|
gitParts.forEach((block) => {
|
||||||
|
const fields = block.split('\n');
|
||||||
|
expect(fields.length).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -247,6 +247,12 @@ describe('codex-provider.ts', () => {
|
|||||||
|
|
||||||
it('uses the SDK when no tools are requested and an API key is present', async () => {
|
it('uses the SDK when no tools are requested and an API key is present', async () => {
|
||||||
process.env[OPENAI_API_KEY_ENV] = 'sk-test';
|
process.env[OPENAI_API_KEY_ENV] = 'sk-test';
|
||||||
|
// Override auth indicators so CLI-native auth doesn't take priority over API key
|
||||||
|
vi.mocked(getCodexAuthIndicators).mockResolvedValue({
|
||||||
|
hasAuthFile: false,
|
||||||
|
hasOAuthToken: false,
|
||||||
|
hasApiKey: false,
|
||||||
|
});
|
||||||
codexRunMock.mockResolvedValue({ finalResponse: 'Hello from SDK' });
|
codexRunMock.mockResolvedValue({ finalResponse: 'Hello from SDK' });
|
||||||
|
|
||||||
const results = await collectAsyncGenerator<ProviderMessage>(
|
const results = await collectAsyncGenerator<ProviderMessage>(
|
||||||
@@ -264,6 +270,12 @@ describe('codex-provider.ts', () => {
|
|||||||
|
|
||||||
it('uses the SDK when API key is present, even for tool requests (to avoid OAuth issues)', async () => {
|
it('uses the SDK when API key is present, even for tool requests (to avoid OAuth issues)', async () => {
|
||||||
process.env[OPENAI_API_KEY_ENV] = 'sk-test';
|
process.env[OPENAI_API_KEY_ENV] = 'sk-test';
|
||||||
|
// Override auth indicators so CLI-native auth doesn't take priority over API key
|
||||||
|
vi.mocked(getCodexAuthIndicators).mockResolvedValue({
|
||||||
|
hasAuthFile: false,
|
||||||
|
hasOAuthToken: false,
|
||||||
|
hasApiKey: false,
|
||||||
|
});
|
||||||
vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})());
|
vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})());
|
||||||
|
|
||||||
await collectAsyncGenerator(
|
await collectAsyncGenerator(
|
||||||
|
|||||||
@@ -1,27 +1,15 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { createMockExpressContext } from '../../../utils/mocks.js';
|
import { createMockExpressContext } from '../../../utils/mocks.js';
|
||||||
|
|
||||||
vi.mock('child_process', async (importOriginal) => {
|
vi.mock('@/services/worktree-branch-service.js', () => ({
|
||||||
const actual = await importOriginal<typeof import('child_process')>();
|
performSwitchBranch: vi.fn(),
|
||||||
return {
|
}));
|
||||||
...actual,
|
|
||||||
execFile: vi.fn(),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock('util', async (importOriginal) => {
|
import { performSwitchBranch } from '@/services/worktree-branch-service.js';
|
||||||
const actual = await importOriginal<typeof import('util')>();
|
|
||||||
return {
|
|
||||||
...actual,
|
|
||||||
promisify: (fn: unknown) => fn,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
import { execFile } from 'child_process';
|
|
||||||
import { createSwitchBranchHandler } from '@/routes/worktree/routes/switch-branch.js';
|
import { createSwitchBranchHandler } from '@/routes/worktree/routes/switch-branch.js';
|
||||||
|
|
||||||
const mockExecFile = execFile as Mock;
|
const mockPerformSwitchBranch = vi.mocked(performSwitchBranch);
|
||||||
|
|
||||||
describe('switch-branch route', () => {
|
describe('switch-branch route', () => {
|
||||||
let req: Request;
|
let req: Request;
|
||||||
@@ -34,42 +22,77 @@ describe('switch-branch route', () => {
|
|||||||
res = context.res;
|
res = context.res;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should return 400 when branchName is missing', async () => {
|
||||||
|
req.body = { worktreePath: '/repo/path' };
|
||||||
|
|
||||||
|
const handler = createSwitchBranchHandler();
|
||||||
|
await handler(req, res);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(400);
|
||||||
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
|
success: false,
|
||||||
|
error: 'branchName required',
|
||||||
|
});
|
||||||
|
expect(mockPerformSwitchBranch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 400 when branchName starts with a dash', async () => {
|
||||||
|
req.body = { worktreePath: '/repo/path', branchName: '-flag' };
|
||||||
|
|
||||||
|
const handler = createSwitchBranchHandler();
|
||||||
|
await handler(req, res);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(400);
|
||||||
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
|
success: false,
|
||||||
|
error: 'Invalid branch name',
|
||||||
|
});
|
||||||
|
expect(mockPerformSwitchBranch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 400 when branchName starts with double dash', async () => {
|
||||||
|
req.body = { worktreePath: '/repo/path', branchName: '--option' };
|
||||||
|
|
||||||
|
const handler = createSwitchBranchHandler();
|
||||||
|
await handler(req, res);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(400);
|
||||||
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
|
success: false,
|
||||||
|
error: 'Invalid branch name',
|
||||||
|
});
|
||||||
|
expect(mockPerformSwitchBranch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 400 when branchName contains invalid characters', async () => {
|
||||||
|
req.body = { worktreePath: '/repo/path', branchName: 'branch name with spaces' };
|
||||||
|
|
||||||
|
const handler = createSwitchBranchHandler();
|
||||||
|
await handler(req, res);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(400);
|
||||||
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
|
success: false,
|
||||||
|
error: 'Invalid branch name',
|
||||||
|
});
|
||||||
|
expect(mockPerformSwitchBranch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('should allow switching when only untracked files exist', async () => {
|
it('should allow switching when only untracked files exist', async () => {
|
||||||
req.body = {
|
req.body = {
|
||||||
worktreePath: '/repo/path',
|
worktreePath: '/repo/path',
|
||||||
branchName: 'feature/test',
|
branchName: 'feature/test',
|
||||||
};
|
};
|
||||||
|
|
||||||
mockExecFile.mockImplementation(async (file: string, args: string[]) => {
|
mockPerformSwitchBranch.mockResolvedValue({
|
||||||
const command = `${file} ${args.join(' ')}`;
|
success: true,
|
||||||
if (command === 'git rev-parse --abbrev-ref HEAD') {
|
result: {
|
||||||
return { stdout: 'main\n', stderr: '' };
|
previousBranch: 'main',
|
||||||
}
|
currentBranch: 'feature/test',
|
||||||
if (command === 'git rev-parse --verify feature/test') {
|
message: "Switched to branch 'feature/test'",
|
||||||
return { stdout: 'abc123\n', stderr: '' };
|
hasConflicts: false,
|
||||||
}
|
stashedChanges: false,
|
||||||
if (command === 'git branch -r --format=%(refname:short)') {
|
},
|
||||||
return { stdout: '', stderr: '' };
|
|
||||||
}
|
|
||||||
if (command === 'git status --porcelain') {
|
|
||||||
return { stdout: '?? .automaker/\n?? notes.txt\n', stderr: '' };
|
|
||||||
}
|
|
||||||
if (command === 'git checkout feature/test') {
|
|
||||||
return { stdout: '', stderr: '' };
|
|
||||||
}
|
|
||||||
if (command === 'git fetch --all --quiet') {
|
|
||||||
return { stdout: '', stderr: '' };
|
|
||||||
}
|
|
||||||
if (command === 'git stash list') {
|
|
||||||
return { stdout: '', stderr: '' };
|
|
||||||
}
|
|
||||||
if (command.startsWith('git stash push')) {
|
|
||||||
return { stdout: '', stderr: '' };
|
|
||||||
}
|
|
||||||
if (command === 'git stash pop') {
|
|
||||||
return { stdout: '', stderr: '' };
|
|
||||||
}
|
|
||||||
return { stdout: '', stderr: '' };
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const handler = createSwitchBranchHandler();
|
const handler = createSwitchBranchHandler();
|
||||||
@@ -85,11 +108,7 @@ describe('switch-branch route', () => {
|
|||||||
stashedChanges: false,
|
stashedChanges: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(mockExecFile).toHaveBeenCalledWith(
|
expect(mockPerformSwitchBranch).toHaveBeenCalledWith('/repo/path', 'feature/test', undefined);
|
||||||
'git',
|
|
||||||
['checkout', 'feature/test'],
|
|
||||||
expect.objectContaining({ cwd: '/repo/path' })
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should stash changes and switch when tracked files are modified', async () => {
|
it('should stash changes and switch when tracked files are modified', async () => {
|
||||||
@@ -98,42 +117,15 @@ describe('switch-branch route', () => {
|
|||||||
branchName: 'feature/test',
|
branchName: 'feature/test',
|
||||||
};
|
};
|
||||||
|
|
||||||
let stashListCallCount = 0;
|
mockPerformSwitchBranch.mockResolvedValue({
|
||||||
|
success: true,
|
||||||
mockExecFile.mockImplementation(async (file: string, args: string[]) => {
|
result: {
|
||||||
const command = `${file} ${args.join(' ')}`;
|
previousBranch: 'main',
|
||||||
if (command === 'git rev-parse --abbrev-ref HEAD') {
|
currentBranch: 'feature/test',
|
||||||
return { stdout: 'main\n', stderr: '' };
|
message: "Switched to branch 'feature/test' (local changes stashed and reapplied)",
|
||||||
}
|
hasConflicts: false,
|
||||||
if (command === 'git rev-parse --verify feature/test') {
|
stashedChanges: true,
|
||||||
return { stdout: 'abc123\n', stderr: '' };
|
},
|
||||||
}
|
|
||||||
if (command === 'git status --porcelain') {
|
|
||||||
return { stdout: ' M src/index.ts\n?? notes.txt\n', stderr: '' };
|
|
||||||
}
|
|
||||||
if (command === 'git branch -r --format=%(refname:short)') {
|
|
||||||
return { stdout: '', stderr: '' };
|
|
||||||
}
|
|
||||||
if (command === 'git stash list') {
|
|
||||||
stashListCallCount++;
|
|
||||||
if (stashListCallCount === 1) {
|
|
||||||
return { stdout: '', stderr: '' };
|
|
||||||
}
|
|
||||||
return { stdout: 'stash@{0}: automaker-branch-switch\n', stderr: '' };
|
|
||||||
}
|
|
||||||
if (command.startsWith('git stash push')) {
|
|
||||||
return { stdout: '', stderr: '' };
|
|
||||||
}
|
|
||||||
if (command === 'git checkout feature/test') {
|
|
||||||
return { stdout: '', stderr: '' };
|
|
||||||
}
|
|
||||||
if (command === 'git fetch --all --quiet') {
|
|
||||||
return { stdout: '', stderr: '' };
|
|
||||||
}
|
|
||||||
if (command === 'git stash pop') {
|
|
||||||
return { stdout: 'Already applied.\n', stderr: '' };
|
|
||||||
}
|
|
||||||
return { stdout: '', stderr: '' };
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const handler = createSwitchBranchHandler();
|
const handler = createSwitchBranchHandler();
|
||||||
|
|||||||
@@ -127,6 +127,7 @@ const eslintConfig = defineConfig([
|
|||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
...ts.configs.recommended.rules,
|
...ts.configs.recommended.rules,
|
||||||
|
'react-hooks/rules-of-hooks': 'error',
|
||||||
'react-hooks/exhaustive-deps': 'warn',
|
'react-hooks/exhaustive-deps': 'warn',
|
||||||
'@typescript-eslint/no-unused-vars': [
|
'@typescript-eslint/no-unused-vars': [
|
||||||
'warn',
|
'warn',
|
||||||
@@ -160,6 +161,13 @@ const eslintConfig = defineConfig([
|
|||||||
URL: 'readonly',
|
URL: 'readonly',
|
||||||
setTimeout: 'readonly',
|
setTimeout: 'readonly',
|
||||||
console: 'readonly',
|
console: 'readonly',
|
||||||
|
// Built-in globals used in sw.js
|
||||||
|
Date: 'readonly',
|
||||||
|
Promise: 'readonly',
|
||||||
|
Set: 'readonly',
|
||||||
|
JSON: 'readonly',
|
||||||
|
String: 'readonly',
|
||||||
|
Array: 'readonly',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
AlertCircle,
|
AlertCircle,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
|
import { TruncatedFilePath } from '@/components/ui/truncated-file-path';
|
||||||
import { Button } from './button';
|
import { Button } from './button';
|
||||||
import { useWorktreeDiffs, useGitDiffs } from '@/hooks/queries';
|
import { useWorktreeDiffs, useGitDiffs } from '@/hooks/queries';
|
||||||
import type { FileStatus } from '@/types/electron';
|
import type { FileStatus } from '@/types/electron';
|
||||||
@@ -299,9 +300,10 @@ function FileDiffSection({
|
|||||||
<ChevronRight className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
<ChevronRight className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||||
)}
|
)}
|
||||||
<FileText className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
<FileText className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||||
<span className="flex-1 text-sm font-mono truncate text-foreground">
|
<TruncatedFilePath
|
||||||
{fileDiff.filePath}
|
path={fileDiff.filePath}
|
||||||
</span>
|
className="flex-1 text-sm font-mono text-foreground"
|
||||||
|
/>
|
||||||
<div className="flex items-center gap-2 flex-shrink-0">
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
{fileDiff.isNew && (
|
{fileDiff.isNew && (
|
||||||
<span className="text-xs px-1.5 py-0.5 rounded bg-green-500/20 text-green-400">
|
<span className="text-xs px-1.5 py-0.5 rounded bg-green-500/20 text-green-400">
|
||||||
@@ -596,9 +598,10 @@ export function GitDiffPanel({
|
|||||||
>
|
>
|
||||||
<div className="w-full px-3 py-2 flex items-center gap-2 text-left bg-card">
|
<div className="w-full px-3 py-2 flex items-center gap-2 text-left bg-card">
|
||||||
{getFileIcon(file.status)}
|
{getFileIcon(file.status)}
|
||||||
<span className="flex-1 text-sm font-mono truncate text-foreground">
|
<TruncatedFilePath
|
||||||
{file.path}
|
path={file.path}
|
||||||
</span>
|
className="flex-1 text-sm font-mono text-foreground"
|
||||||
|
/>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-xs px-1.5 py-0.5 rounded border font-medium',
|
'text-xs px-1.5 py-0.5 rounded border font-medium',
|
||||||
|
|||||||
40
apps/ui/src/components/ui/truncated-file-path.tsx
Normal file
40
apps/ui/src/components/ui/truncated-file-path.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface TruncatedFilePathProps {
|
||||||
|
/** The full file path to display */
|
||||||
|
path: string;
|
||||||
|
/** Additional CSS class names */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a file path with middle truncation.
|
||||||
|
*
|
||||||
|
* When the path is too long to fit in its container, the middle portion
|
||||||
|
* (directory path) is truncated with an ellipsis while preserving both
|
||||||
|
* the beginning of the path and the filename at the end.
|
||||||
|
*
|
||||||
|
* Example: "src/components/...dialog.tsx" instead of "src/components/views/boa..."
|
||||||
|
*/
|
||||||
|
export function TruncatedFilePath({ path, className }: TruncatedFilePathProps) {
|
||||||
|
const lastSlash = path.lastIndexOf('/');
|
||||||
|
|
||||||
|
// If there's no directory component, just render with normal truncation
|
||||||
|
if (lastSlash === -1) {
|
||||||
|
return (
|
||||||
|
<span className={cn('truncate', className)} title={path}>
|
||||||
|
{path}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dirPart = path.slice(0, lastSlash + 1); // includes trailing slash
|
||||||
|
const filePart = path.slice(lastSlash + 1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={cn('flex min-w-0', className)} title={path}>
|
||||||
|
<span className="truncate flex-shrink">{dirPart}</span>
|
||||||
|
<span className="flex-shrink-0 whitespace-nowrap">{filePart}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -37,6 +37,7 @@ import { BoardBackgroundModal } from '@/components/dialogs/board-background-moda
|
|||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import { useAutoMode } from '@/hooks/use-auto-mode';
|
import { useAutoMode } from '@/hooks/use-auto-mode';
|
||||||
|
import { resolveModelString } from '@automaker/model-resolver';
|
||||||
import { useWindowState } from '@/hooks/use-window-state';
|
import { useWindowState } from '@/hooks/use-window-state';
|
||||||
// Board-view specific imports
|
// Board-view specific imports
|
||||||
import { BoardHeader } from './board-view/board-header';
|
import { BoardHeader } from './board-view/board-header';
|
||||||
@@ -868,358 +869,7 @@ export function BoardView() {
|
|||||||
}
|
}
|
||||||
}, [currentProject, selectedFeatureIds, loadFeatures, exitSelectionMode]);
|
}, [currentProject, selectedFeatureIds, loadFeatures, exitSelectionMode]);
|
||||||
|
|
||||||
// Handler for addressing PR comments - creates a feature and starts it automatically
|
// Helper that creates a feature and immediately starts it (used by conflict handlers and the Make button)
|
||||||
const handleAddressPRComments = useCallback(
|
|
||||||
async (worktree: WorktreeInfo, prInfo: PRInfo) => {
|
|
||||||
// Use a simple prompt that instructs the agent to read and address PR feedback
|
|
||||||
// The agent will fetch the PR comments directly, which is more reliable and up-to-date
|
|
||||||
const prNumber = prInfo.number;
|
|
||||||
const description = `Read the review requests on PR #${prNumber} and address any feedback the best you can.`;
|
|
||||||
|
|
||||||
// Create the feature
|
|
||||||
const featureData = {
|
|
||||||
title: `Address PR #${prNumber} Review Comments`,
|
|
||||||
category: 'PR Review',
|
|
||||||
description,
|
|
||||||
images: [],
|
|
||||||
imagePaths: [],
|
|
||||||
skipTests: defaultSkipTests,
|
|
||||||
model: 'opus' as const,
|
|
||||||
thinkingLevel: 'none' as const,
|
|
||||||
branchName: worktree.branch,
|
|
||||||
workMode: 'custom' as const, // Use the worktree's branch
|
|
||||||
priority: 1, // High priority for PR feedback
|
|
||||||
planningMode: 'skip' as const,
|
|
||||||
requirePlanApproval: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Capture existing feature IDs before adding
|
|
||||||
const featuresBeforeIds = new Set(useAppStore.getState().features.map((f) => f.id));
|
|
||||||
try {
|
|
||||||
await handleAddFeature(featureData);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed to create PR comments feature:', error);
|
|
||||||
toast.error('Failed to create feature', {
|
|
||||||
description: error instanceof Error ? error.message : 'An error occurred',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the newly created feature by looking for an ID that wasn't in the original set
|
|
||||||
const latestFeatures = useAppStore.getState().features;
|
|
||||||
const newFeature = latestFeatures.find((f) => !featuresBeforeIds.has(f.id));
|
|
||||||
|
|
||||||
if (newFeature) {
|
|
||||||
await handleStartImplementation(newFeature);
|
|
||||||
} else {
|
|
||||||
logger.error('Could not find newly created feature to start it automatically.');
|
|
||||||
toast.error('Failed to auto-start feature', {
|
|
||||||
description: 'The feature was created but could not be started automatically.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[handleAddFeature, handleStartImplementation, defaultSkipTests]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Handler for resolving conflicts - opens dialog to select remote branch, then creates a feature
|
|
||||||
const handleResolveConflicts = useCallback((worktree: WorktreeInfo) => {
|
|
||||||
setSelectedWorktreeForAction(worktree);
|
|
||||||
setShowMergeRebaseDialog(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Handler called when user confirms the merge & rebase dialog
|
|
||||||
const handleConfirmResolveConflicts = useCallback(
|
|
||||||
async (worktree: WorktreeInfo, remoteBranch: string, strategy: PullStrategy) => {
|
|
||||||
const isRebase = strategy === 'rebase';
|
|
||||||
|
|
||||||
const description = isRebase
|
|
||||||
? `Fetch the latest changes from ${remoteBranch} and rebase the current branch (${worktree.branch}) onto ${remoteBranch}. Use "git fetch" followed by "git rebase ${remoteBranch}" to replay commits on top of the remote branch for a linear history. If rebase conflicts arise, resolve them one commit at a time using "git rebase --continue" after fixing each conflict. After completing the rebase, ensure the code compiles and tests pass.`
|
|
||||||
: `Pull latest from ${remoteBranch} and resolve conflicts. Merge ${remoteBranch} into the current branch (${worktree.branch}), resolving any merge conflicts that arise. After resolving conflicts, ensure the code compiles and tests pass.`;
|
|
||||||
|
|
||||||
const title = isRebase
|
|
||||||
? `Rebase & Resolve Conflicts: ${worktree.branch} onto ${remoteBranch}`
|
|
||||||
: `Resolve Merge Conflicts: ${remoteBranch} → ${worktree.branch}`;
|
|
||||||
|
|
||||||
// Create the feature
|
|
||||||
const featureData = {
|
|
||||||
title,
|
|
||||||
category: 'Maintenance',
|
|
||||||
description,
|
|
||||||
images: [],
|
|
||||||
imagePaths: [],
|
|
||||||
skipTests: defaultSkipTests,
|
|
||||||
model: 'opus' as const,
|
|
||||||
thinkingLevel: 'none' as const,
|
|
||||||
branchName: worktree.branch,
|
|
||||||
workMode: 'custom' as const, // Use the worktree's branch
|
|
||||||
priority: 1, // High priority for conflict resolution
|
|
||||||
planningMode: 'skip' as const,
|
|
||||||
requirePlanApproval: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Capture existing feature IDs before adding
|
|
||||||
const featuresBeforeIds = new Set(useAppStore.getState().features.map((f) => f.id));
|
|
||||||
try {
|
|
||||||
await handleAddFeature(featureData);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed to create resolve conflicts feature:', error);
|
|
||||||
toast.error('Failed to create feature', {
|
|
||||||
description: error instanceof Error ? error.message : 'An error occurred',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the newly created feature by looking for an ID that wasn't in the original set
|
|
||||||
const latestFeatures = useAppStore.getState().features;
|
|
||||||
const newFeature = latestFeatures.find((f) => !featuresBeforeIds.has(f.id));
|
|
||||||
|
|
||||||
if (newFeature) {
|
|
||||||
await handleStartImplementation(newFeature);
|
|
||||||
} else {
|
|
||||||
logger.error('Could not find newly created feature to start it automatically.');
|
|
||||||
toast.error('Failed to auto-start feature', {
|
|
||||||
description: 'The feature was created but could not be started automatically.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[handleAddFeature, handleStartImplementation, defaultSkipTests]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Handler called when merge/rebase fails due to conflicts and user wants to create a feature to resolve them
|
|
||||||
const handleCreateMergeConflictResolutionFeature = useCallback(
|
|
||||||
async (conflictInfo: MergeConflictInfo) => {
|
|
||||||
const isRebase = conflictInfo.operationType === 'rebase';
|
|
||||||
const conflictFilesInfo =
|
|
||||||
conflictInfo.conflictFiles && conflictInfo.conflictFiles.length > 0
|
|
||||||
? `\n\nConflicting files:\n${conflictInfo.conflictFiles.map((f) => `- ${f}`).join('\n')}`
|
|
||||||
: '';
|
|
||||||
|
|
||||||
const description = isRebase
|
|
||||||
? `Fetch the latest changes from ${conflictInfo.sourceBranch} and rebase the current branch (${conflictInfo.targetBranch}) onto ${conflictInfo.sourceBranch}. Use "git fetch" followed by "git rebase ${conflictInfo.sourceBranch}" to replay commits on top of the remote branch for a linear history. If rebase conflicts arise, resolve them one commit at a time using "git rebase --continue" after fixing each conflict. After completing the rebase, ensure the code compiles and tests pass.${conflictFilesInfo}`
|
|
||||||
: `Resolve merge conflicts when merging "${conflictInfo.sourceBranch}" into "${conflictInfo.targetBranch}". The merge was started but encountered conflicts that need to be resolved manually. After resolving all conflicts, ensure the code compiles and tests pass, then complete the merge by committing the resolved changes.${conflictFilesInfo}`;
|
|
||||||
|
|
||||||
const title = isRebase
|
|
||||||
? `Rebase & Resolve Conflicts: ${conflictInfo.targetBranch} onto ${conflictInfo.sourceBranch}`
|
|
||||||
: `Resolve Merge Conflicts: ${conflictInfo.sourceBranch} → ${conflictInfo.targetBranch}`;
|
|
||||||
|
|
||||||
// Create the feature
|
|
||||||
const featureData = {
|
|
||||||
title,
|
|
||||||
category: 'Maintenance',
|
|
||||||
description,
|
|
||||||
images: [],
|
|
||||||
imagePaths: [],
|
|
||||||
skipTests: defaultSkipTests,
|
|
||||||
model: 'opus' as const,
|
|
||||||
thinkingLevel: 'none' as const,
|
|
||||||
branchName: conflictInfo.targetBranch,
|
|
||||||
workMode: 'custom' as const, // Use the target branch where conflicts need to be resolved
|
|
||||||
priority: 1, // High priority for conflict resolution
|
|
||||||
planningMode: 'skip' as const,
|
|
||||||
requirePlanApproval: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Capture existing feature IDs before adding
|
|
||||||
const featuresBeforeIds = new Set(useAppStore.getState().features.map((f) => f.id));
|
|
||||||
try {
|
|
||||||
await handleAddFeature(featureData);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed to create merge conflict resolution feature:', error);
|
|
||||||
toast.error('Failed to create feature', {
|
|
||||||
description: error instanceof Error ? error.message : 'An error occurred',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the newly created feature by looking for an ID that wasn't in the original set
|
|
||||||
const latestFeatures = useAppStore.getState().features;
|
|
||||||
const newFeature = latestFeatures.find((f) => !featuresBeforeIds.has(f.id));
|
|
||||||
|
|
||||||
if (newFeature) {
|
|
||||||
await handleStartImplementation(newFeature);
|
|
||||||
} else {
|
|
||||||
logger.error('Could not find newly created feature to start it automatically.');
|
|
||||||
toast.error('Failed to auto-start feature', {
|
|
||||||
description: 'The feature was created but could not be started automatically.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[handleAddFeature, handleStartImplementation, defaultSkipTests]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Handler called when branch switch stash reapply causes merge conflicts
|
|
||||||
const handleBranchSwitchConflict = useCallback(
|
|
||||||
async (conflictInfo: BranchSwitchConflictInfo) => {
|
|
||||||
const description = `Resolve merge conflicts that occurred when switching from "${conflictInfo.previousBranch}" to "${conflictInfo.branchName}". Local changes were stashed before switching and reapplying them caused conflicts. Please resolve all merge conflicts, ensure the code compiles and tests pass.`;
|
|
||||||
|
|
||||||
// Create the feature
|
|
||||||
const featureData = {
|
|
||||||
title: `Resolve Stash Conflicts: switch to ${conflictInfo.branchName}`,
|
|
||||||
category: 'Maintenance',
|
|
||||||
description,
|
|
||||||
images: [],
|
|
||||||
imagePaths: [],
|
|
||||||
skipTests: defaultSkipTests,
|
|
||||||
model: 'opus' as const,
|
|
||||||
thinkingLevel: 'none' as const,
|
|
||||||
branchName: conflictInfo.branchName,
|
|
||||||
workMode: 'custom' as const,
|
|
||||||
priority: 1,
|
|
||||||
planningMode: 'skip' as const,
|
|
||||||
requirePlanApproval: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Capture existing feature IDs before adding
|
|
||||||
const featuresBeforeIds = new Set(useAppStore.getState().features.map((f) => f.id));
|
|
||||||
try {
|
|
||||||
await handleAddFeature(featureData);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed to create branch switch conflict resolution feature:', error);
|
|
||||||
toast.error('Failed to create feature', {
|
|
||||||
description: error instanceof Error ? error.message : 'An error occurred',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the newly created feature by looking for an ID that wasn't in the original set
|
|
||||||
const latestFeatures = useAppStore.getState().features;
|
|
||||||
const newFeature = latestFeatures.find((f) => !featuresBeforeIds.has(f.id));
|
|
||||||
|
|
||||||
if (newFeature) {
|
|
||||||
await handleStartImplementation(newFeature);
|
|
||||||
} else {
|
|
||||||
logger.error('Could not find newly created feature to start it automatically.');
|
|
||||||
toast.error('Failed to auto-start feature', {
|
|
||||||
description: 'The feature was created but could not be started automatically.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[handleAddFeature, handleStartImplementation, defaultSkipTests]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Handler called when checkout fails AND the stash-pop restoration produces merge conflicts.
|
|
||||||
// Creates an AI-assisted board task to guide the user through resolving the conflicts.
|
|
||||||
const handleStashPopConflict = useCallback(
|
|
||||||
async (conflictInfo: StashPopConflictInfo) => {
|
|
||||||
const description =
|
|
||||||
`Resolve merge conflicts that occurred when attempting to switch to branch "${conflictInfo.branchName}". ` +
|
|
||||||
`The checkout failed and, while restoring the previously stashed local changes, git reported merge conflicts. ` +
|
|
||||||
`${conflictInfo.stashPopConflictMessage} ` +
|
|
||||||
`Please review all conflicted files, resolve the conflicts, ensure the code compiles and tests pass, ` +
|
|
||||||
`then re-attempt the branch switch.`;
|
|
||||||
|
|
||||||
// Create the feature
|
|
||||||
const featureData = {
|
|
||||||
title: `Resolve Stash-Pop Conflicts: branch switch to ${conflictInfo.branchName}`,
|
|
||||||
category: 'Maintenance',
|
|
||||||
description,
|
|
||||||
images: [],
|
|
||||||
imagePaths: [],
|
|
||||||
skipTests: defaultSkipTests,
|
|
||||||
model: 'opus' as const,
|
|
||||||
thinkingLevel: 'none' as const,
|
|
||||||
branchName: conflictInfo.branchName,
|
|
||||||
workMode: 'custom' as const,
|
|
||||||
priority: 1,
|
|
||||||
planningMode: 'skip' as const,
|
|
||||||
requirePlanApproval: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Capture existing feature IDs before adding
|
|
||||||
const featuresBeforeIds = new Set(useAppStore.getState().features.map((f) => f.id));
|
|
||||||
try {
|
|
||||||
await handleAddFeature(featureData);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed to create stash-pop conflict resolution feature:', error);
|
|
||||||
toast.error('Failed to create feature', {
|
|
||||||
description: error instanceof Error ? error.message : 'An error occurred',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the newly created feature by looking for an ID that wasn't in the original set
|
|
||||||
const latestFeatures = useAppStore.getState().features;
|
|
||||||
const newFeature = latestFeatures.find((f) => !featuresBeforeIds.has(f.id));
|
|
||||||
|
|
||||||
if (newFeature) {
|
|
||||||
await handleStartImplementation(newFeature);
|
|
||||||
} else {
|
|
||||||
logger.error(
|
|
||||||
'Could not find newly created stash-pop conflict feature to start it automatically.'
|
|
||||||
);
|
|
||||||
toast.error('Failed to auto-start feature', {
|
|
||||||
description: 'The feature was created but could not be started automatically.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[handleAddFeature, handleStartImplementation, defaultSkipTests]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Handler called when stash apply/pop results in merge conflicts and user wants AI resolution
|
|
||||||
const handleStashApplyConflict = useCallback(
|
|
||||||
async (conflictInfo: StashApplyConflictInfo) => {
|
|
||||||
const operationLabel = conflictInfo.operation === 'pop' ? 'popping' : 'applying';
|
|
||||||
const conflictFilesList =
|
|
||||||
conflictInfo.conflictFiles.length > 0
|
|
||||||
? `\n\nConflicted files:\n${conflictInfo.conflictFiles.map((f) => `- ${f}`).join('\n')}`
|
|
||||||
: '';
|
|
||||||
|
|
||||||
const description =
|
|
||||||
`Resolve merge conflicts that occurred when ${operationLabel} stash "${conflictInfo.stashRef}" ` +
|
|
||||||
`on branch "${conflictInfo.branchName}". ` +
|
|
||||||
`The stash was ${conflictInfo.operation === 'pop' ? 'popped' : 'applied'} but resulted in merge conflicts ` +
|
|
||||||
`that need to be resolved. Please review all conflicted files, resolve the conflicts, ` +
|
|
||||||
`ensure the code compiles and tests pass, then commit the resolved changes.` +
|
|
||||||
conflictFilesList;
|
|
||||||
|
|
||||||
// Create the feature
|
|
||||||
const featureData = {
|
|
||||||
title: `Resolve Stash Apply Conflicts: ${conflictInfo.stashRef} on ${conflictInfo.branchName}`,
|
|
||||||
category: 'Maintenance',
|
|
||||||
description,
|
|
||||||
images: [],
|
|
||||||
imagePaths: [],
|
|
||||||
skipTests: defaultSkipTests,
|
|
||||||
model: 'opus' as const,
|
|
||||||
thinkingLevel: 'none' as const,
|
|
||||||
branchName: conflictInfo.branchName,
|
|
||||||
workMode: 'custom' as const,
|
|
||||||
priority: 1, // High priority for conflict resolution
|
|
||||||
planningMode: 'skip' as const,
|
|
||||||
requirePlanApproval: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Capture existing feature IDs before adding
|
|
||||||
const featuresBeforeIds = new Set(useAppStore.getState().features.map((f) => f.id));
|
|
||||||
try {
|
|
||||||
await handleAddFeature(featureData);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed to create stash apply conflict resolution feature:', error);
|
|
||||||
toast.error('Failed to create feature', {
|
|
||||||
description: error instanceof Error ? error.message : 'An error occurred',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the newly created feature by looking for an ID that wasn't in the original set
|
|
||||||
const latestFeatures = useAppStore.getState().features;
|
|
||||||
const newFeature = latestFeatures.find((f) => !featuresBeforeIds.has(f.id));
|
|
||||||
|
|
||||||
if (newFeature) {
|
|
||||||
await handleStartImplementation(newFeature);
|
|
||||||
} else {
|
|
||||||
logger.error(
|
|
||||||
'Could not find newly created stash apply conflict feature to start it automatically.'
|
|
||||||
);
|
|
||||||
toast.error('Failed to auto-start feature', {
|
|
||||||
description: 'The feature was created but could not be started automatically.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[handleAddFeature, handleStartImplementation, defaultSkipTests]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Handler for "Make" button - creates a feature and immediately starts it
|
|
||||||
const handleAddAndStartFeature = useCallback(
|
const handleAddAndStartFeature = useCallback(
|
||||||
async (featureData: Parameters<typeof handleAddFeature>[0]) => {
|
async (featureData: Parameters<typeof handleAddFeature>[0]) => {
|
||||||
// Capture existing feature IDs before adding
|
// Capture existing feature IDs before adding
|
||||||
@@ -1250,6 +900,209 @@ export function BoardView() {
|
|||||||
[handleAddFeature, handleStartImplementation]
|
[handleAddFeature, handleStartImplementation]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Handler for addressing PR comments - creates a feature and starts it automatically
|
||||||
|
const handleAddressPRComments = useCallback(
|
||||||
|
async (worktree: WorktreeInfo, prInfo: PRInfo) => {
|
||||||
|
// Use a simple prompt that instructs the agent to read and address PR feedback
|
||||||
|
// The agent will fetch the PR comments directly, which is more reliable and up-to-date
|
||||||
|
const prNumber = prInfo.number;
|
||||||
|
const description = `Read the review requests on PR #${prNumber} and address any feedback the best you can.`;
|
||||||
|
|
||||||
|
const featureData = {
|
||||||
|
title: `Address PR #${prNumber} Review Comments`,
|
||||||
|
category: 'PR Review',
|
||||||
|
description,
|
||||||
|
images: [],
|
||||||
|
imagePaths: [],
|
||||||
|
skipTests: defaultSkipTests,
|
||||||
|
model: 'opus' as const,
|
||||||
|
thinkingLevel: 'none' as const,
|
||||||
|
branchName: worktree.branch,
|
||||||
|
workMode: 'custom' as const, // Use the worktree's branch
|
||||||
|
priority: 1, // High priority for PR feedback
|
||||||
|
planningMode: 'skip' as const,
|
||||||
|
requirePlanApproval: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
await handleAddAndStartFeature(featureData);
|
||||||
|
},
|
||||||
|
[handleAddAndStartFeature, defaultSkipTests]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handler for resolving conflicts - opens dialog to select remote branch, then creates a feature
|
||||||
|
const handleResolveConflicts = useCallback((worktree: WorktreeInfo) => {
|
||||||
|
setSelectedWorktreeForAction(worktree);
|
||||||
|
setShowMergeRebaseDialog(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Handler called when user confirms the merge & rebase dialog
|
||||||
|
const handleConfirmResolveConflicts = useCallback(
|
||||||
|
async (worktree: WorktreeInfo, remoteBranch: string, strategy: PullStrategy) => {
|
||||||
|
const isRebase = strategy === 'rebase';
|
||||||
|
|
||||||
|
const description = isRebase
|
||||||
|
? `Fetch the latest changes from ${remoteBranch} and rebase the current branch (${worktree.branch}) onto ${remoteBranch}. Use "git fetch" followed by "git rebase ${remoteBranch}" to replay commits on top of the remote branch for a linear history. If rebase conflicts arise, resolve them one commit at a time using "git rebase --continue" after fixing each conflict. After completing the rebase, ensure the code compiles and tests pass.`
|
||||||
|
: `Pull latest from ${remoteBranch} and resolve conflicts. Merge ${remoteBranch} into the current branch (${worktree.branch}), resolving any merge conflicts that arise. After resolving conflicts, ensure the code compiles and tests pass.`;
|
||||||
|
|
||||||
|
const title = isRebase
|
||||||
|
? `Rebase & Resolve Conflicts: ${worktree.branch} onto ${remoteBranch}`
|
||||||
|
: `Resolve Merge Conflicts: ${remoteBranch} → ${worktree.branch}`;
|
||||||
|
|
||||||
|
const featureData = {
|
||||||
|
title,
|
||||||
|
category: 'Maintenance',
|
||||||
|
description,
|
||||||
|
images: [],
|
||||||
|
imagePaths: [],
|
||||||
|
skipTests: defaultSkipTests,
|
||||||
|
model: 'opus' as const,
|
||||||
|
thinkingLevel: 'none' as const,
|
||||||
|
branchName: worktree.branch,
|
||||||
|
workMode: 'custom' as const, // Use the worktree's branch
|
||||||
|
priority: 1, // High priority for conflict resolution
|
||||||
|
planningMode: 'skip' as const,
|
||||||
|
requirePlanApproval: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
await handleAddAndStartFeature(featureData);
|
||||||
|
},
|
||||||
|
[handleAddAndStartFeature, defaultSkipTests]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handler called when merge/rebase fails due to conflicts and user wants to create a feature to resolve them
|
||||||
|
const handleCreateMergeConflictResolutionFeature = useCallback(
|
||||||
|
async (conflictInfo: MergeConflictInfo) => {
|
||||||
|
const isRebase = conflictInfo.operationType === 'rebase';
|
||||||
|
const conflictFilesInfo =
|
||||||
|
conflictInfo.conflictFiles && conflictInfo.conflictFiles.length > 0
|
||||||
|
? `\n\nConflicting files:\n${conflictInfo.conflictFiles.map((f) => `- ${f}`).join('\n')}`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const description = isRebase
|
||||||
|
? `Fetch the latest changes from ${conflictInfo.sourceBranch} and rebase the current branch (${conflictInfo.targetBranch}) onto ${conflictInfo.sourceBranch}. Use "git fetch" followed by "git rebase ${conflictInfo.sourceBranch}" to replay commits on top of the remote branch for a linear history. If rebase conflicts arise, resolve them one commit at a time using "git rebase --continue" after fixing each conflict. After completing the rebase, ensure the code compiles and tests pass.${conflictFilesInfo}`
|
||||||
|
: `Resolve merge conflicts when merging "${conflictInfo.sourceBranch}" into "${conflictInfo.targetBranch}". The merge was started but encountered conflicts that need to be resolved manually. After resolving all conflicts, ensure the code compiles and tests pass, then complete the merge by committing the resolved changes.${conflictFilesInfo}`;
|
||||||
|
|
||||||
|
const title = isRebase
|
||||||
|
? `Rebase & Resolve Conflicts: ${conflictInfo.targetBranch} onto ${conflictInfo.sourceBranch}`
|
||||||
|
: `Resolve Merge Conflicts: ${conflictInfo.sourceBranch} → ${conflictInfo.targetBranch}`;
|
||||||
|
|
||||||
|
const featureData = {
|
||||||
|
title,
|
||||||
|
category: 'Maintenance',
|
||||||
|
description,
|
||||||
|
images: [],
|
||||||
|
imagePaths: [],
|
||||||
|
skipTests: defaultSkipTests,
|
||||||
|
model: 'opus' as const,
|
||||||
|
thinkingLevel: 'none' as const,
|
||||||
|
branchName: conflictInfo.targetBranch,
|
||||||
|
workMode: 'custom' as const, // Use the target branch where conflicts need to be resolved
|
||||||
|
priority: 1, // High priority for conflict resolution
|
||||||
|
planningMode: 'skip' as const,
|
||||||
|
requirePlanApproval: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
await handleAddAndStartFeature(featureData);
|
||||||
|
},
|
||||||
|
[handleAddAndStartFeature, defaultSkipTests]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handler called when branch switch stash reapply causes merge conflicts
|
||||||
|
const handleBranchSwitchConflict = useCallback(
|
||||||
|
async (conflictInfo: BranchSwitchConflictInfo) => {
|
||||||
|
const description = `Resolve merge conflicts that occurred when switching from "${conflictInfo.previousBranch}" to "${conflictInfo.branchName}". Local changes were stashed before switching and reapplying them caused conflicts. Please resolve all merge conflicts, ensure the code compiles and tests pass.`;
|
||||||
|
|
||||||
|
const featureData = {
|
||||||
|
title: `Resolve Stash Conflicts: switch to ${conflictInfo.branchName}`,
|
||||||
|
category: 'Maintenance',
|
||||||
|
description,
|
||||||
|
images: [],
|
||||||
|
imagePaths: [],
|
||||||
|
skipTests: defaultSkipTests,
|
||||||
|
model: resolveModelString('opus'),
|
||||||
|
thinkingLevel: 'none' as const,
|
||||||
|
branchName: conflictInfo.branchName,
|
||||||
|
workMode: 'custom' as const,
|
||||||
|
priority: 1,
|
||||||
|
planningMode: 'skip' as const,
|
||||||
|
requirePlanApproval: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
await handleAddAndStartFeature(featureData);
|
||||||
|
},
|
||||||
|
[handleAddAndStartFeature, defaultSkipTests]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handler called when checkout fails AND the stash-pop restoration produces merge conflicts.
|
||||||
|
// Creates an AI-assisted board task to guide the user through resolving the conflicts.
|
||||||
|
const handleStashPopConflict = useCallback(
|
||||||
|
async (conflictInfo: StashPopConflictInfo) => {
|
||||||
|
const description =
|
||||||
|
`Resolve merge conflicts that occurred when attempting to switch to branch "${conflictInfo.branchName}". ` +
|
||||||
|
`The checkout failed and, while restoring the previously stashed local changes, git reported merge conflicts. ` +
|
||||||
|
`${conflictInfo.stashPopConflictMessage} ` +
|
||||||
|
`Please review all conflicted files, resolve the conflicts, ensure the code compiles and tests pass, ` +
|
||||||
|
`then re-attempt the branch switch.`;
|
||||||
|
|
||||||
|
const featureData = {
|
||||||
|
title: `Resolve Stash-Pop Conflicts: branch switch to ${conflictInfo.branchName}`,
|
||||||
|
category: 'Maintenance',
|
||||||
|
description,
|
||||||
|
images: [],
|
||||||
|
imagePaths: [],
|
||||||
|
skipTests: defaultSkipTests,
|
||||||
|
model: resolveModelString('opus'),
|
||||||
|
thinkingLevel: 'none' as const,
|
||||||
|
branchName: conflictInfo.branchName,
|
||||||
|
workMode: 'custom' as const,
|
||||||
|
priority: 1,
|
||||||
|
planningMode: 'skip' as const,
|
||||||
|
requirePlanApproval: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
await handleAddAndStartFeature(featureData);
|
||||||
|
},
|
||||||
|
[handleAddAndStartFeature, defaultSkipTests]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handler called when stash apply/pop results in merge conflicts and user wants AI resolution
|
||||||
|
const handleStashApplyConflict = useCallback(
|
||||||
|
async (conflictInfo: StashApplyConflictInfo) => {
|
||||||
|
const operationLabel = conflictInfo.operation === 'pop' ? 'popping' : 'applying';
|
||||||
|
const conflictFilesList =
|
||||||
|
conflictInfo.conflictFiles.length > 0
|
||||||
|
? `\n\nConflicted files:\n${conflictInfo.conflictFiles.map((f) => `- ${f}`).join('\n')}`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const description =
|
||||||
|
`Resolve merge conflicts that occurred when ${operationLabel} stash "${conflictInfo.stashRef}" ` +
|
||||||
|
`on branch "${conflictInfo.branchName}". ` +
|
||||||
|
`The stash was ${conflictInfo.operation === 'pop' ? 'popped' : 'applied'} but resulted in merge conflicts ` +
|
||||||
|
`that need to be resolved. Please review all conflicted files, resolve the conflicts, ` +
|
||||||
|
`ensure the code compiles and tests pass, then commit the resolved changes.` +
|
||||||
|
conflictFilesList;
|
||||||
|
|
||||||
|
const featureData = {
|
||||||
|
title: `Resolve Stash Apply Conflicts: ${conflictInfo.stashRef} on ${conflictInfo.branchName}`,
|
||||||
|
category: 'Maintenance',
|
||||||
|
description,
|
||||||
|
images: [],
|
||||||
|
imagePaths: [],
|
||||||
|
skipTests: defaultSkipTests,
|
||||||
|
model: resolveModelString('opus'),
|
||||||
|
thinkingLevel: 'none' as const,
|
||||||
|
branchName: conflictInfo.branchName,
|
||||||
|
workMode: 'custom' as const,
|
||||||
|
priority: 1, // High priority for conflict resolution
|
||||||
|
planningMode: 'skip' as const,
|
||||||
|
requirePlanApproval: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
await handleAddAndStartFeature(featureData);
|
||||||
|
},
|
||||||
|
[handleAddAndStartFeature, defaultSkipTests]
|
||||||
|
);
|
||||||
|
|
||||||
// NOTE: Auto mode polling loop has been moved to the backend.
|
// NOTE: Auto mode polling loop has been moved to the backend.
|
||||||
// The frontend now just toggles the backend's auto loop via API calls.
|
// The frontend now just toggles the backend's auto loop via API calls.
|
||||||
// See use-auto-mode.ts for the start/stop logic that calls the backend.
|
// See use-auto-mode.ts for the start/stop logic that calls the backend.
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { getElectronAPI } from '@/lib/electron';
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { TruncatedFilePath } from '@/components/ui/truncated-file-path';
|
||||||
import type { FileStatus } from '@/types/electron';
|
import type { FileStatus } from '@/types/electron';
|
||||||
|
|
||||||
interface WorktreeInfo {
|
interface WorktreeInfo {
|
||||||
@@ -566,9 +567,10 @@ export function CommitWorktreeDialog({
|
|||||||
<ChevronRight className="w-3 h-3 text-muted-foreground flex-shrink-0" />
|
<ChevronRight className="w-3 h-3 text-muted-foreground flex-shrink-0" />
|
||||||
)}
|
)}
|
||||||
{getFileIcon(file.status)}
|
{getFileIcon(file.status)}
|
||||||
<span className="text-xs font-mono truncate flex-1 text-foreground">
|
<TruncatedFilePath
|
||||||
{file.path}
|
path={file.path}
|
||||||
</span>
|
className="text-xs font-mono flex-1 text-foreground"
|
||||||
|
/>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-[10px] px-1.5 py-0.5 rounded border font-medium flex-shrink-0',
|
'text-[10px] px-1.5 py-0.5 rounded border font-medium flex-shrink-0',
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { getElectronAPI } from '@/lib/electron';
|
|||||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { TruncatedFilePath } from '@/components/ui/truncated-file-path';
|
||||||
import type { FileStatus } from '@/types/electron';
|
import type { FileStatus } from '@/types/electron';
|
||||||
|
|
||||||
interface WorktreeInfo {
|
interface WorktreeInfo {
|
||||||
@@ -313,9 +314,12 @@ export function DiscardWorktreeChangesDialog({
|
|||||||
const result = await api.git.getDiffs(worktree.path);
|
const result = await api.git.getDiffs(worktree.path);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
const fileList = result.files ?? [];
|
const fileList = result.files ?? [];
|
||||||
|
if (!cancelled) setError(null);
|
||||||
if (!cancelled) setFiles(fileList);
|
if (!cancelled) setFiles(fileList);
|
||||||
if (!cancelled) setDiffContent(result.diff ?? '');
|
if (!cancelled) setDiffContent(result.diff ?? '');
|
||||||
if (!cancelled) setSelectedFiles(new Set());
|
if (!cancelled) setSelectedFiles(new Set());
|
||||||
|
} else {
|
||||||
|
if (!cancelled) setError(result.error || 'Failed to fetch diffs');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -495,9 +499,10 @@ export function DiscardWorktreeChangesDialog({
|
|||||||
<ChevronRight className="w-3 h-3 text-muted-foreground flex-shrink-0" />
|
<ChevronRight className="w-3 h-3 text-muted-foreground flex-shrink-0" />
|
||||||
)}
|
)}
|
||||||
{getFileIcon(file.status)}
|
{getFileIcon(file.status)}
|
||||||
<span className="text-xs font-mono truncate flex-1 text-foreground">
|
<TruncatedFilePath
|
||||||
{file.path}
|
path={file.path}
|
||||||
</span>
|
className="text-xs font-mono flex-1 text-foreground"
|
||||||
|
/>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-[10px] px-1.5 py-0.5 rounded border font-medium flex-shrink-0',
|
'text-[10px] px-1.5 py-0.5 rounded border font-medium flex-shrink-0',
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ type PullPhase =
|
|||||||
|
|
||||||
interface PullResult {
|
interface PullResult {
|
||||||
branch: string;
|
branch: string;
|
||||||
|
remote?: string;
|
||||||
pulled: boolean;
|
pulled: boolean;
|
||||||
message: string;
|
message: string;
|
||||||
hasLocalChanges?: boolean;
|
hasLocalChanges?: boolean;
|
||||||
@@ -115,6 +116,11 @@ export function GitPullDialog({
|
|||||||
setPullResult(result.result);
|
setPullResult(result.result);
|
||||||
setPhase('success');
|
setPhase('success');
|
||||||
onPulled?.();
|
onPulled?.();
|
||||||
|
} else {
|
||||||
|
// Unexpected response: success but no recognizable fields
|
||||||
|
setPullResult(result.result ?? null);
|
||||||
|
setErrorMessage('Unexpected pull response');
|
||||||
|
setPhase('error');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setErrorMessage(err instanceof Error ? err.message : 'Failed to check for changes');
|
setErrorMessage(err instanceof Error ? err.message : 'Failed to check for changes');
|
||||||
@@ -160,8 +166,9 @@ export function GitPullDialog({
|
|||||||
const handleResolveWithAI = useCallback(() => {
|
const handleResolveWithAI = useCallback(() => {
|
||||||
if (!worktree || !pullResult || !onCreateConflictResolutionFeature) return;
|
if (!worktree || !pullResult || !onCreateConflictResolutionFeature) return;
|
||||||
|
|
||||||
|
const effectiveRemote = pullResult.remote || remote;
|
||||||
const conflictInfo: MergeConflictInfo = {
|
const conflictInfo: MergeConflictInfo = {
|
||||||
sourceBranch: `${remote || 'origin'}/${pullResult.branch}`,
|
sourceBranch: effectiveRemote ? `${effectiveRemote}/${pullResult.branch}` : pullResult.branch,
|
||||||
targetBranch: pullResult.branch,
|
targetBranch: pullResult.branch,
|
||||||
targetWorktreePath: worktree.path,
|
targetWorktreePath: worktree.path,
|
||||||
conflictFiles: pullResult.conflictFiles || [],
|
conflictFiles: pullResult.conflictFiles || [],
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { Spinner } from '@/components/ui/spinner';
|
|||||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { TruncatedFilePath } from '@/components/ui/truncated-file-path';
|
||||||
import type { FileStatus } from '@/types/electron';
|
import type { FileStatus } from '@/types/electron';
|
||||||
|
|
||||||
interface WorktreeInfo {
|
interface WorktreeInfo {
|
||||||
@@ -514,9 +515,10 @@ export function StashChangesDialog({
|
|||||||
<ChevronRight className="w-3 h-3 text-muted-foreground flex-shrink-0" />
|
<ChevronRight className="w-3 h-3 text-muted-foreground flex-shrink-0" />
|
||||||
)}
|
)}
|
||||||
{getFileIcon(file.status)}
|
{getFileIcon(file.status)}
|
||||||
<span className="text-xs font-mono truncate flex-1 text-foreground">
|
<TruncatedFilePath
|
||||||
{file.path}
|
path={file.path}
|
||||||
</span>
|
className="text-xs font-mono flex-1 text-foreground"
|
||||||
|
/>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-[10px] px-1.5 py-0.5 rounded border font-medium flex-shrink-0',
|
'text-[10px] px-1.5 py-0.5 rounded border font-medium flex-shrink-0',
|
||||||
|
|||||||
@@ -930,6 +930,7 @@ export function useBoardActions({
|
|||||||
// - If the feature had a branch assigned, keep it (preserves worktree context)
|
// - If the feature had a branch assigned, keep it (preserves worktree context)
|
||||||
// - If no branch was assigned, it will show on the primary worktree
|
// - If no branch was assigned, it will show on the primary worktree
|
||||||
const featureBranch = feature.branchName;
|
const featureBranch = feature.branchName;
|
||||||
|
const branchLabel = featureBranch ?? 'primary worktree';
|
||||||
|
|
||||||
// Check if the feature will be visible on the current worktree view
|
// Check if the feature will be visible on the current worktree view
|
||||||
const willBeVisibleOnCurrentView = !featureBranch
|
const willBeVisibleOnCurrentView = !featureBranch
|
||||||
@@ -949,7 +950,7 @@ export function useBoardActions({
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
toast.success('Feature restored', {
|
toast.success('Feature restored', {
|
||||||
description: `Moved back to verified on branch "${featureBranch}": ${truncateDescription(feature.description)}`,
|
description: `Moved back to verified on branch "${branchLabel}": ${truncateDescription(feature.description)}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -197,11 +197,23 @@ export function WorktreeActionsDropdown({
|
|||||||
|
|
||||||
// Check git operations availability
|
// Check git operations availability
|
||||||
const canPerformGitOps = gitRepoStatus.isGitRepo && gitRepoStatus.hasCommits;
|
const canPerformGitOps = gitRepoStatus.isGitRepo && gitRepoStatus.hasCommits;
|
||||||
const gitOpsDisabledReason = !gitRepoStatus.isGitRepo
|
// While git status is loading, treat git ops as unavailable to avoid stale state enabling actions
|
||||||
? 'Not a git repository'
|
const isGitOpsAvailable = !isLoadingGitStatus && canPerformGitOps;
|
||||||
: !gitRepoStatus.hasCommits
|
const gitOpsDisabledReason = isLoadingGitStatus
|
||||||
? 'Repository has no commits yet'
|
? 'Checking git status...'
|
||||||
: null;
|
: !gitRepoStatus.isGitRepo
|
||||||
|
? 'Not a git repository'
|
||||||
|
: !gitRepoStatus.hasCommits
|
||||||
|
? 'Repository has no commits yet'
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Determine if the changes/PR section has any visible items
|
||||||
|
const showCreatePR = (!worktree.isMain || worktree.hasChanges) && !hasPR;
|
||||||
|
const showPRInfo = hasPR && !!worktree.pr;
|
||||||
|
const hasChangesSectionContent = worktree.hasChanges || showCreatePR || showPRInfo;
|
||||||
|
|
||||||
|
// Determine if the destructive/bottom section has any visible items
|
||||||
|
const hasDestructiveSectionContent = worktree.hasChanges || !worktree.isMain;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu onOpenChange={onOpenChange}>
|
<DropdownMenu onOpenChange={onOpenChange}>
|
||||||
@@ -232,7 +244,7 @@ export function WorktreeActionsDropdown({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{/* Warning label when git operations are not available (only show once loaded) */}
|
{/* Warning label when git operations are not available (only show once loaded) */}
|
||||||
{!isLoadingGitStatus && !canPerformGitOps && (
|
{!isLoadingGitStatus && !isGitOpsAvailable && (
|
||||||
<>
|
<>
|
||||||
<DropdownMenuLabel className="text-xs flex items-center gap-2 text-amber-600 dark:text-amber-400">
|
<DropdownMenuLabel className="text-xs flex items-center gap-2 text-amber-600 dark:text-amber-400">
|
||||||
<AlertCircle className="w-3.5 h-3.5" />
|
<AlertCircle className="w-3.5 h-3.5" />
|
||||||
@@ -362,14 +374,16 @@ export function WorktreeActionsDropdown({
|
|||||||
)}
|
)}
|
||||||
<TooltipWrapper showTooltip={!!gitOpsDisabledReason} tooltipContent={gitOpsDisabledReason}>
|
<TooltipWrapper showTooltip={!!gitOpsDisabledReason} tooltipContent={gitOpsDisabledReason}>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => canPerformGitOps && onPull(worktree)}
|
onClick={() => isGitOpsAvailable && onPull(worktree)}
|
||||||
disabled={isPulling || !canPerformGitOps}
|
disabled={isPulling || !isGitOpsAvailable}
|
||||||
className={cn('text-xs', !canPerformGitOps && 'opacity-50 cursor-not-allowed')}
|
className={cn('text-xs', !isGitOpsAvailable && 'opacity-50 cursor-not-allowed')}
|
||||||
>
|
>
|
||||||
<Download className={cn('w-3.5 h-3.5 mr-2', isPulling && 'animate-pulse')} />
|
<Download className={cn('w-3.5 h-3.5 mr-2', isPulling && 'animate-pulse')} />
|
||||||
{isPulling ? 'Pulling...' : 'Pull'}
|
{isPulling ? 'Pulling...' : 'Pull'}
|
||||||
{!canPerformGitOps && <AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />}
|
{!isGitOpsAvailable && (
|
||||||
{canPerformGitOps && behindCount > 0 && (
|
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
{isGitOpsAvailable && behindCount > 0 && (
|
||||||
<span className="ml-auto text-[10px] bg-muted px-1.5 py-0.5 rounded">
|
<span className="ml-auto text-[10px] bg-muted px-1.5 py-0.5 rounded">
|
||||||
{behindCount} behind
|
{behindCount} behind
|
||||||
</span>
|
</span>
|
||||||
@@ -379,26 +393,28 @@ export function WorktreeActionsDropdown({
|
|||||||
<TooltipWrapper showTooltip={!!gitOpsDisabledReason} tooltipContent={gitOpsDisabledReason}>
|
<TooltipWrapper showTooltip={!!gitOpsDisabledReason} tooltipContent={gitOpsDisabledReason}>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!canPerformGitOps) return;
|
if (!isGitOpsAvailable) return;
|
||||||
if (!hasRemoteBranch) {
|
if (!hasRemoteBranch) {
|
||||||
onPushNewBranch(worktree);
|
onPushNewBranch(worktree);
|
||||||
} else {
|
} else {
|
||||||
onPush(worktree);
|
onPush(worktree);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
disabled={isPushing || (hasRemoteBranch && aheadCount === 0) || !canPerformGitOps}
|
disabled={isPushing || (hasRemoteBranch && aheadCount === 0) || !isGitOpsAvailable}
|
||||||
className={cn('text-xs', !canPerformGitOps && 'opacity-50 cursor-not-allowed')}
|
className={cn('text-xs', !isGitOpsAvailable && 'opacity-50 cursor-not-allowed')}
|
||||||
>
|
>
|
||||||
<Upload className={cn('w-3.5 h-3.5 mr-2', isPushing && 'animate-pulse')} />
|
<Upload className={cn('w-3.5 h-3.5 mr-2', isPushing && 'animate-pulse')} />
|
||||||
{isPushing ? 'Pushing...' : 'Push'}
|
{isPushing ? 'Pushing...' : 'Push'}
|
||||||
{!canPerformGitOps && <AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />}
|
{!isGitOpsAvailable && (
|
||||||
{canPerformGitOps && !hasRemoteBranch && (
|
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
{isGitOpsAvailable && !hasRemoteBranch && (
|
||||||
<span className="ml-auto inline-flex items-center gap-0.5 text-[10px] bg-amber-500/20 text-amber-600 dark:text-amber-400 px-1.5 py-0.5 rounded">
|
<span className="ml-auto inline-flex items-center gap-0.5 text-[10px] bg-amber-500/20 text-amber-600 dark:text-amber-400 px-1.5 py-0.5 rounded">
|
||||||
<CloudOff className="w-2.5 h-2.5" />
|
<CloudOff className="w-2.5 h-2.5" />
|
||||||
local only
|
local only
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{canPerformGitOps && hasRemoteBranch && aheadCount > 0 && (
|
{isGitOpsAvailable && hasRemoteBranch && aheadCount > 0 && (
|
||||||
<span className="ml-auto text-[10px] bg-primary/20 text-primary px-1.5 py-0.5 rounded">
|
<span className="ml-auto text-[10px] bg-primary/20 text-primary px-1.5 py-0.5 rounded">
|
||||||
{aheadCount} ahead
|
{aheadCount} ahead
|
||||||
</span>
|
</span>
|
||||||
@@ -407,27 +423,31 @@ export function WorktreeActionsDropdown({
|
|||||||
</TooltipWrapper>
|
</TooltipWrapper>
|
||||||
<TooltipWrapper showTooltip={!!gitOpsDisabledReason} tooltipContent={gitOpsDisabledReason}>
|
<TooltipWrapper showTooltip={!!gitOpsDisabledReason} tooltipContent={gitOpsDisabledReason}>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => canPerformGitOps && onResolveConflicts(worktree)}
|
onClick={() => isGitOpsAvailable && onResolveConflicts(worktree)}
|
||||||
disabled={!canPerformGitOps}
|
disabled={!isGitOpsAvailable}
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-xs text-purple-500 focus:text-purple-600',
|
'text-xs text-purple-500 focus:text-purple-600',
|
||||||
!canPerformGitOps && 'opacity-50 cursor-not-allowed'
|
!isGitOpsAvailable && 'opacity-50 cursor-not-allowed'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<GitMerge className="w-3.5 h-3.5 mr-2" />
|
<GitMerge className="w-3.5 h-3.5 mr-2" />
|
||||||
Merge & Rebase
|
Merge & Rebase
|
||||||
{!canPerformGitOps && <AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />}
|
{!isGitOpsAvailable && (
|
||||||
|
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
||||||
|
)}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</TooltipWrapper>
|
</TooltipWrapper>
|
||||||
<TooltipWrapper showTooltip={!!gitOpsDisabledReason} tooltipContent={gitOpsDisabledReason}>
|
<TooltipWrapper showTooltip={!!gitOpsDisabledReason} tooltipContent={gitOpsDisabledReason}>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => canPerformGitOps && onViewCommits(worktree)}
|
onClick={() => isGitOpsAvailable && onViewCommits(worktree)}
|
||||||
disabled={!canPerformGitOps}
|
disabled={!isGitOpsAvailable}
|
||||||
className={cn('text-xs', !canPerformGitOps && 'opacity-50 cursor-not-allowed')}
|
className={cn('text-xs', !isGitOpsAvailable && 'opacity-50 cursor-not-allowed')}
|
||||||
>
|
>
|
||||||
<History className="w-3.5 h-3.5 mr-2" />
|
<History className="w-3.5 h-3.5 mr-2" />
|
||||||
View Commits
|
View Commits
|
||||||
{!canPerformGitOps && <AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />}
|
{!isGitOpsAvailable && (
|
||||||
|
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
||||||
|
)}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</TooltipWrapper>
|
</TooltipWrapper>
|
||||||
{/* Cherry-pick commits from another branch */}
|
{/* Cherry-pick commits from another branch */}
|
||||||
@@ -437,13 +457,13 @@ export function WorktreeActionsDropdown({
|
|||||||
tooltipContent={gitOpsDisabledReason}
|
tooltipContent={gitOpsDisabledReason}
|
||||||
>
|
>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => canPerformGitOps && onCherryPick(worktree)}
|
onClick={() => isGitOpsAvailable && onCherryPick(worktree)}
|
||||||
disabled={!canPerformGitOps}
|
disabled={!isGitOpsAvailable}
|
||||||
className={cn('text-xs', !canPerformGitOps && 'opacity-50 cursor-not-allowed')}
|
className={cn('text-xs', !isGitOpsAvailable && 'opacity-50 cursor-not-allowed')}
|
||||||
>
|
>
|
||||||
<Cherry className="w-3.5 h-3.5 mr-2" />
|
<Cherry className="w-3.5 h-3.5 mr-2" />
|
||||||
Cherry Pick
|
Cherry Pick
|
||||||
{!canPerformGitOps && (
|
{!isGitOpsAvailable && (
|
||||||
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
||||||
)}
|
)}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@@ -451,7 +471,7 @@ export function WorktreeActionsDropdown({
|
|||||||
)}
|
)}
|
||||||
{/* Stash operations - combined submenu or simple item */}
|
{/* Stash operations - combined submenu or simple item */}
|
||||||
{(onStashChanges || onViewStashes) && (
|
{(onStashChanges || onViewStashes) && (
|
||||||
<TooltipWrapper showTooltip={!canPerformGitOps} tooltipContent={gitOpsDisabledReason}>
|
<TooltipWrapper showTooltip={!isGitOpsAvailable} tooltipContent={gitOpsDisabledReason}>
|
||||||
{onViewStashes && worktree.hasChanges && onStashChanges ? (
|
{onViewStashes && worktree.hasChanges && onStashChanges ? (
|
||||||
// Both "Stash Changes" (primary) and "View Stashes" (secondary) are available - show split submenu
|
// Both "Stash Changes" (primary) and "View Stashes" (secondary) are available - show split submenu
|
||||||
<DropdownMenuSub>
|
<DropdownMenuSub>
|
||||||
@@ -459,18 +479,18 @@ export function WorktreeActionsDropdown({
|
|||||||
{/* Main clickable area - stash changes (primary action) */}
|
{/* Main clickable area - stash changes (primary action) */}
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!canPerformGitOps) return;
|
if (!isGitOpsAvailable) return;
|
||||||
onStashChanges(worktree);
|
onStashChanges(worktree);
|
||||||
}}
|
}}
|
||||||
disabled={!canPerformGitOps}
|
disabled={!isGitOpsAvailable}
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-xs flex-1 pr-0 rounded-r-none',
|
'text-xs flex-1 pr-0 rounded-r-none',
|
||||||
!canPerformGitOps && 'opacity-50 cursor-not-allowed'
|
!isGitOpsAvailable && 'opacity-50 cursor-not-allowed'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Archive className="w-3.5 h-3.5 mr-2" />
|
<Archive className="w-3.5 h-3.5 mr-2" />
|
||||||
Stash Changes
|
Stash Changes
|
||||||
{!canPerformGitOps && (
|
{!isGitOpsAvailable && (
|
||||||
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
||||||
)}
|
)}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@@ -478,9 +498,9 @@ export function WorktreeActionsDropdown({
|
|||||||
<DropdownMenuSubTrigger
|
<DropdownMenuSubTrigger
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-xs px-1 rounded-l-none border-l border-border/30 h-8',
|
'text-xs px-1 rounded-l-none border-l border-border/30 h-8',
|
||||||
!canPerformGitOps && 'opacity-50 cursor-not-allowed'
|
!isGitOpsAvailable && 'opacity-50 cursor-not-allowed'
|
||||||
)}
|
)}
|
||||||
disabled={!canPerformGitOps}
|
disabled={!isGitOpsAvailable}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<DropdownMenuSubContent>
|
<DropdownMenuSubContent>
|
||||||
@@ -494,19 +514,19 @@ export function WorktreeActionsDropdown({
|
|||||||
// Only one action is meaningful - render a simple menu item without submenu
|
// Only one action is meaningful - render a simple menu item without submenu
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!canPerformGitOps) return;
|
if (!isGitOpsAvailable) return;
|
||||||
if (worktree.hasChanges && onStashChanges) {
|
if (worktree.hasChanges && onStashChanges) {
|
||||||
onStashChanges(worktree);
|
onStashChanges(worktree);
|
||||||
} else if (onViewStashes) {
|
} else if (onViewStashes) {
|
||||||
onViewStashes(worktree);
|
onViewStashes(worktree);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
disabled={!canPerformGitOps}
|
disabled={!isGitOpsAvailable}
|
||||||
className={cn('text-xs', !canPerformGitOps && 'opacity-50 cursor-not-allowed')}
|
className={cn('text-xs', !isGitOpsAvailable && 'opacity-50 cursor-not-allowed')}
|
||||||
>
|
>
|
||||||
<Archive className="w-3.5 h-3.5 mr-2" />
|
<Archive className="w-3.5 h-3.5 mr-2" />
|
||||||
{worktree.hasChanges && onStashChanges ? 'Stash Changes' : 'Stashes'}
|
{worktree.hasChanges && onStashChanges ? 'Stash Changes' : 'Stashes'}
|
||||||
{!canPerformGitOps && (
|
{!isGitOpsAvailable && (
|
||||||
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
||||||
)}
|
)}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@@ -639,7 +659,7 @@ export function WorktreeActionsDropdown({
|
|||||||
Re-run Init Script
|
Re-run Init Script
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
<DropdownMenuSeparator />
|
{(hasChangesSectionContent || hasDestructiveSectionContent) && <DropdownMenuSeparator />}
|
||||||
|
|
||||||
{worktree.hasChanges && (
|
{worktree.hasChanges && (
|
||||||
<DropdownMenuItem onClick={() => onViewChanges(worktree)} className="text-xs">
|
<DropdownMenuItem onClick={() => onViewChanges(worktree)} className="text-xs">
|
||||||
@@ -649,43 +669,43 @@ export function WorktreeActionsDropdown({
|
|||||||
)}
|
)}
|
||||||
{worktree.hasChanges && (
|
{worktree.hasChanges && (
|
||||||
<TooltipWrapper
|
<TooltipWrapper
|
||||||
showTooltip={!gitRepoStatus.isGitRepo}
|
showTooltip={!!gitOpsDisabledReason}
|
||||||
tooltipContent="Not a git repository"
|
tooltipContent={gitOpsDisabledReason}
|
||||||
>
|
>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => gitRepoStatus.isGitRepo && onCommit(worktree)}
|
onClick={() => isGitOpsAvailable && onCommit(worktree)}
|
||||||
disabled={!gitRepoStatus.isGitRepo}
|
disabled={!isGitOpsAvailable}
|
||||||
className={cn('text-xs', !gitRepoStatus.isGitRepo && 'opacity-50 cursor-not-allowed')}
|
className={cn('text-xs', !isGitOpsAvailable && 'opacity-50 cursor-not-allowed')}
|
||||||
>
|
>
|
||||||
<GitCommit className="w-3.5 h-3.5 mr-2" />
|
<GitCommit className="w-3.5 h-3.5 mr-2" />
|
||||||
Commit Changes
|
Commit Changes
|
||||||
{!gitRepoStatus.isGitRepo && (
|
{!isGitOpsAvailable && (
|
||||||
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
||||||
)}
|
)}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</TooltipWrapper>
|
</TooltipWrapper>
|
||||||
)}
|
)}
|
||||||
{/* Show PR option for non-primary worktrees, or primary worktree with changes */}
|
{/* Show PR option for non-primary worktrees, or primary worktree with changes */}
|
||||||
{(!worktree.isMain || worktree.hasChanges) && !hasPR && (
|
{showCreatePR && (
|
||||||
<TooltipWrapper
|
<TooltipWrapper
|
||||||
showTooltip={!!gitOpsDisabledReason}
|
showTooltip={!!gitOpsDisabledReason}
|
||||||
tooltipContent={gitOpsDisabledReason}
|
tooltipContent={gitOpsDisabledReason}
|
||||||
>
|
>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => canPerformGitOps && onCreatePR(worktree)}
|
onClick={() => isGitOpsAvailable && onCreatePR(worktree)}
|
||||||
disabled={!canPerformGitOps}
|
disabled={!isGitOpsAvailable}
|
||||||
className={cn('text-xs', !canPerformGitOps && 'opacity-50 cursor-not-allowed')}
|
className={cn('text-xs', !isGitOpsAvailable && 'opacity-50 cursor-not-allowed')}
|
||||||
>
|
>
|
||||||
<GitPullRequest className="w-3.5 h-3.5 mr-2" />
|
<GitPullRequest className="w-3.5 h-3.5 mr-2" />
|
||||||
Create Pull Request
|
Create Pull Request
|
||||||
{!canPerformGitOps && (
|
{!isGitOpsAvailable && (
|
||||||
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
||||||
)}
|
)}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</TooltipWrapper>
|
</TooltipWrapper>
|
||||||
)}
|
)}
|
||||||
{/* Show PR info and Address Comments button if PR exists */}
|
{/* Show PR info and Address Comments button if PR exists */}
|
||||||
{hasPR && worktree.pr && (
|
{showPRInfo && worktree.pr && (
|
||||||
<>
|
<>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -722,23 +742,23 @@ export function WorktreeActionsDropdown({
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<DropdownMenuSeparator />
|
{hasChangesSectionContent && hasDestructiveSectionContent && <DropdownMenuSeparator />}
|
||||||
{worktree.hasChanges && (
|
{worktree.hasChanges && (
|
||||||
<TooltipWrapper
|
<TooltipWrapper
|
||||||
showTooltip={!gitRepoStatus.isGitRepo}
|
showTooltip={!!gitOpsDisabledReason}
|
||||||
tooltipContent="Not a git repository"
|
tooltipContent={gitOpsDisabledReason}
|
||||||
>
|
>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => gitRepoStatus.isGitRepo && onDiscardChanges(worktree)}
|
onClick={() => isGitOpsAvailable && onDiscardChanges(worktree)}
|
||||||
disabled={!gitRepoStatus.isGitRepo}
|
disabled={!isGitOpsAvailable}
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-xs text-destructive focus:text-destructive',
|
'text-xs text-destructive focus:text-destructive',
|
||||||
!gitRepoStatus.isGitRepo && 'opacity-50 cursor-not-allowed'
|
!isGitOpsAvailable && 'opacity-50 cursor-not-allowed'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Undo2 className="w-3.5 h-3.5 mr-2" />
|
<Undo2 className="w-3.5 h-3.5 mr-2" />
|
||||||
Discard Changes
|
Discard Changes
|
||||||
{!gitRepoStatus.isGitRepo && (
|
{!isGitOpsAvailable && (
|
||||||
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
||||||
)}
|
)}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@@ -751,16 +771,16 @@ export function WorktreeActionsDropdown({
|
|||||||
tooltipContent={gitOpsDisabledReason}
|
tooltipContent={gitOpsDisabledReason}
|
||||||
>
|
>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => canPerformGitOps && onMerge(worktree)}
|
onClick={() => isGitOpsAvailable && onMerge(worktree)}
|
||||||
disabled={!canPerformGitOps}
|
disabled={!isGitOpsAvailable}
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-xs text-green-600 focus:text-green-700',
|
'text-xs text-green-600 focus:text-green-700',
|
||||||
!canPerformGitOps && 'opacity-50 cursor-not-allowed'
|
!isGitOpsAvailable && 'opacity-50 cursor-not-allowed'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<GitMerge className="w-3.5 h-3.5 mr-2" />
|
<GitMerge className="w-3.5 h-3.5 mr-2" />
|
||||||
Merge Branch
|
Merge Branch
|
||||||
{!canPerformGitOps && (
|
{!isGitOpsAvailable && (
|
||||||
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
||||||
)}
|
)}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|||||||
@@ -633,14 +633,14 @@ export function WorktreePanel({
|
|||||||
setPullDialogRemote(remote);
|
setPullDialogRemote(remote);
|
||||||
setPullDialogWorktree(worktree);
|
setPullDialogWorktree(worktree);
|
||||||
setPullDialogOpen(true);
|
setPullDialogOpen(true);
|
||||||
await handlePull(worktree, remote);
|
await _handlePull(worktree, remote);
|
||||||
} else {
|
} else {
|
||||||
await handlePush(worktree, remote);
|
await handlePush(worktree, remote);
|
||||||
}
|
}
|
||||||
fetchBranches(worktree.path);
|
fetchBranches(worktree.path);
|
||||||
fetchWorktrees();
|
fetchWorktrees();
|
||||||
},
|
},
|
||||||
[selectRemoteOperation, handlePush, fetchBranches, fetchWorktrees]
|
[selectRemoteOperation, _handlePull, handlePush, fetchBranches, fetchWorktrees]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handle confirming the push to remote dialog
|
// Handle confirming the push to remote dialog
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ const CODEX_MODEL_INFO: Record<CodexModelId, CodexModelInfo> = {
|
|||||||
label: 'GPT-5.3-Codex',
|
label: 'GPT-5.3-Codex',
|
||||||
description: 'Latest frontier agentic coding model',
|
description: 'Latest frontier agentic coding model',
|
||||||
},
|
},
|
||||||
|
'codex-gpt-5.3-codex-spark': {
|
||||||
|
id: 'codex-gpt-5.3-codex-spark',
|
||||||
|
label: 'GPT-5.3-Codex-Spark',
|
||||||
|
description: 'Near-instant real-time coding model, 1000+ tokens/sec',
|
||||||
|
},
|
||||||
'codex-gpt-5.2-codex': {
|
'codex-gpt-5.2-codex': {
|
||||||
id: 'codex-gpt-5.2-codex',
|
id: 'codex-gpt-5.2-codex',
|
||||||
label: 'GPT-5.2-Codex',
|
label: 'GPT-5.2-Codex',
|
||||||
@@ -47,6 +52,21 @@ const CODEX_MODEL_INFO: Record<CodexModelId, CodexModelInfo> = {
|
|||||||
label: 'GPT-5.1-Codex-Mini',
|
label: 'GPT-5.1-Codex-Mini',
|
||||||
description: 'Optimized for codex. Cheaper, faster, but less capable',
|
description: 'Optimized for codex. Cheaper, faster, but less capable',
|
||||||
},
|
},
|
||||||
|
'codex-gpt-5.1-codex': {
|
||||||
|
id: 'codex-gpt-5.1-codex',
|
||||||
|
label: 'GPT-5.1-Codex',
|
||||||
|
description: 'Original GPT-5.1 Codex agentic coding model',
|
||||||
|
},
|
||||||
|
'codex-gpt-5-codex': {
|
||||||
|
id: 'codex-gpt-5-codex',
|
||||||
|
label: 'GPT-5-Codex',
|
||||||
|
description: 'Original GPT-5 Codex model',
|
||||||
|
},
|
||||||
|
'codex-gpt-5-codex-mini': {
|
||||||
|
id: 'codex-gpt-5-codex-mini',
|
||||||
|
label: 'GPT-5-Codex-Mini',
|
||||||
|
description: 'Smaller, cheaper GPT-5 Codex variant',
|
||||||
|
},
|
||||||
'codex-gpt-5.2': {
|
'codex-gpt-5.2': {
|
||||||
id: 'codex-gpt-5.2',
|
id: 'codex-gpt-5.2',
|
||||||
label: 'GPT-5.2',
|
label: 'GPT-5.2',
|
||||||
@@ -57,6 +77,11 @@ const CODEX_MODEL_INFO: Record<CodexModelId, CodexModelInfo> = {
|
|||||||
label: 'GPT-5.1',
|
label: 'GPT-5.1',
|
||||||
description: 'Great for coding and agentic tasks across domains',
|
description: 'Great for coding and agentic tasks across domains',
|
||||||
},
|
},
|
||||||
|
'codex-gpt-5': {
|
||||||
|
id: 'codex-gpt-5',
|
||||||
|
label: 'GPT-5',
|
||||||
|
description: 'Base GPT-5 model via Codex',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export function CodexModelConfiguration({
|
export function CodexModelConfiguration({
|
||||||
|
|||||||
@@ -182,6 +182,13 @@ export function parseLocalStorageSettings(): Partial<GlobalSettings> | null {
|
|||||||
state.enabledDynamicModelIds as GlobalSettings['enabledDynamicModelIds'],
|
state.enabledDynamicModelIds as GlobalSettings['enabledDynamicModelIds'],
|
||||||
disabledProviders: (state.disabledProviders ?? []) as GlobalSettings['disabledProviders'],
|
disabledProviders: (state.disabledProviders ?? []) as GlobalSettings['disabledProviders'],
|
||||||
autoLoadClaudeMd: state.autoLoadClaudeMd as boolean,
|
autoLoadClaudeMd: state.autoLoadClaudeMd as boolean,
|
||||||
|
codexAutoLoadAgents: state.codexAutoLoadAgents as GlobalSettings['codexAutoLoadAgents'],
|
||||||
|
codexSandboxMode: state.codexSandboxMode as GlobalSettings['codexSandboxMode'],
|
||||||
|
codexApprovalPolicy: state.codexApprovalPolicy as GlobalSettings['codexApprovalPolicy'],
|
||||||
|
codexEnableWebSearch: state.codexEnableWebSearch as GlobalSettings['codexEnableWebSearch'],
|
||||||
|
codexEnableImages: state.codexEnableImages as GlobalSettings['codexEnableImages'],
|
||||||
|
codexAdditionalDirs: state.codexAdditionalDirs as GlobalSettings['codexAdditionalDirs'],
|
||||||
|
codexThreadId: state.codexThreadId as GlobalSettings['codexThreadId'],
|
||||||
keyboardShortcuts: state.keyboardShortcuts as GlobalSettings['keyboardShortcuts'],
|
keyboardShortcuts: state.keyboardShortcuts as GlobalSettings['keyboardShortcuts'],
|
||||||
mcpServers: state.mcpServers as GlobalSettings['mcpServers'],
|
mcpServers: state.mcpServers as GlobalSettings['mcpServers'],
|
||||||
promptCustomization: state.promptCustomization as GlobalSettings['promptCustomization'],
|
promptCustomization: state.promptCustomization as GlobalSettings['promptCustomization'],
|
||||||
@@ -719,6 +726,13 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
|
|||||||
disabledProviders: settings.disabledProviders ?? [],
|
disabledProviders: settings.disabledProviders ?? [],
|
||||||
autoLoadClaudeMd: settings.autoLoadClaudeMd ?? false,
|
autoLoadClaudeMd: settings.autoLoadClaudeMd ?? false,
|
||||||
skipSandboxWarning: settings.skipSandboxWarning ?? false,
|
skipSandboxWarning: settings.skipSandboxWarning ?? false,
|
||||||
|
codexAutoLoadAgents: settings.codexAutoLoadAgents ?? false,
|
||||||
|
codexSandboxMode: settings.codexSandboxMode ?? 'workspace-write',
|
||||||
|
codexApprovalPolicy: settings.codexApprovalPolicy ?? 'on-request',
|
||||||
|
codexEnableWebSearch: settings.codexEnableWebSearch ?? false,
|
||||||
|
codexEnableImages: settings.codexEnableImages ?? true,
|
||||||
|
codexAdditionalDirs: settings.codexAdditionalDirs ?? [],
|
||||||
|
codexThreadId: settings.codexThreadId,
|
||||||
keyboardShortcuts: {
|
keyboardShortcuts: {
|
||||||
...current.keyboardShortcuts,
|
...current.keyboardShortcuts,
|
||||||
...(settings.keyboardShortcuts as unknown as Partial<typeof current.keyboardShortcuts>),
|
...(settings.keyboardShortcuts as unknown as Partial<typeof current.keyboardShortcuts>),
|
||||||
@@ -802,6 +816,13 @@ function buildSettingsUpdateFromStore(): Record<string, unknown> {
|
|||||||
disabledProviders: state.disabledProviders,
|
disabledProviders: state.disabledProviders,
|
||||||
autoLoadClaudeMd: state.autoLoadClaudeMd,
|
autoLoadClaudeMd: state.autoLoadClaudeMd,
|
||||||
skipSandboxWarning: state.skipSandboxWarning,
|
skipSandboxWarning: state.skipSandboxWarning,
|
||||||
|
codexAutoLoadAgents: state.codexAutoLoadAgents,
|
||||||
|
codexSandboxMode: state.codexSandboxMode,
|
||||||
|
codexApprovalPolicy: state.codexApprovalPolicy,
|
||||||
|
codexEnableWebSearch: state.codexEnableWebSearch,
|
||||||
|
codexEnableImages: state.codexEnableImages,
|
||||||
|
codexAdditionalDirs: state.codexAdditionalDirs,
|
||||||
|
codexThreadId: state.codexThreadId,
|
||||||
keyboardShortcuts: state.keyboardShortcuts,
|
keyboardShortcuts: state.keyboardShortcuts,
|
||||||
mcpServers: state.mcpServers,
|
mcpServers: state.mcpServers,
|
||||||
promptCustomization: state.promptCustomization,
|
promptCustomization: state.promptCustomization,
|
||||||
|
|||||||
@@ -95,6 +95,14 @@ const SETTINGS_FIELDS_TO_SYNC = [
|
|||||||
'projectHistory',
|
'projectHistory',
|
||||||
'projectHistoryIndex',
|
'projectHistoryIndex',
|
||||||
'lastSelectedSessionByProject',
|
'lastSelectedSessionByProject',
|
||||||
|
// Codex CLI Settings
|
||||||
|
'codexAutoLoadAgents',
|
||||||
|
'codexSandboxMode',
|
||||||
|
'codexApprovalPolicy',
|
||||||
|
'codexEnableWebSearch',
|
||||||
|
'codexEnableImages',
|
||||||
|
'codexAdditionalDirs',
|
||||||
|
'codexThreadId',
|
||||||
// UI State (previously in localStorage)
|
// UI State (previously in localStorage)
|
||||||
'worktreePanelCollapsed',
|
'worktreePanelCollapsed',
|
||||||
'lastProjectDir',
|
'lastProjectDir',
|
||||||
@@ -736,6 +744,14 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
|
|||||||
recentFolders: serverSettings.recentFolders ?? [],
|
recentFolders: serverSettings.recentFolders ?? [],
|
||||||
// Event hooks
|
// Event hooks
|
||||||
eventHooks: serverSettings.eventHooks ?? [],
|
eventHooks: serverSettings.eventHooks ?? [],
|
||||||
|
// Codex CLI Settings
|
||||||
|
codexAutoLoadAgents: serverSettings.codexAutoLoadAgents ?? false,
|
||||||
|
codexSandboxMode: serverSettings.codexSandboxMode ?? 'workspace-write',
|
||||||
|
codexApprovalPolicy: serverSettings.codexApprovalPolicy ?? 'on-request',
|
||||||
|
codexEnableWebSearch: serverSettings.codexEnableWebSearch ?? false,
|
||||||
|
codexEnableImages: serverSettings.codexEnableImages ?? true,
|
||||||
|
codexAdditionalDirs: serverSettings.codexAdditionalDirs ?? [],
|
||||||
|
codexThreadId: serverSettings.codexThreadId,
|
||||||
// Terminal settings (nested in terminalState)
|
// Terminal settings (nested in terminalState)
|
||||||
...((serverSettings.terminalFontFamily || serverSettings.openTerminalMode) && {
|
...((serverSettings.terminalFontFamily || serverSettings.openTerminalMode) && {
|
||||||
terminalState: {
|
terminalState: {
|
||||||
|
|||||||
@@ -276,6 +276,8 @@ const initialState: AppState = {
|
|||||||
codexApprovalPolicy: 'on-request',
|
codexApprovalPolicy: 'on-request',
|
||||||
codexEnableWebSearch: false,
|
codexEnableWebSearch: false,
|
||||||
codexEnableImages: false,
|
codexEnableImages: false,
|
||||||
|
codexAdditionalDirs: [],
|
||||||
|
codexThreadId: undefined,
|
||||||
enabledOpencodeModels: getAllOpencodeModelIds(),
|
enabledOpencodeModels: getAllOpencodeModelIds(),
|
||||||
opencodeDefaultModel: DEFAULT_OPENCODE_MODEL,
|
opencodeDefaultModel: DEFAULT_OPENCODE_MODEL,
|
||||||
dynamicOpencodeModels: [],
|
dynamicOpencodeModels: [],
|
||||||
|
|||||||
@@ -187,6 +187,8 @@ export interface AppState {
|
|||||||
codexApprovalPolicy: 'untrusted' | 'on-failure' | 'on-request' | 'never'; // Approval policy
|
codexApprovalPolicy: 'untrusted' | 'on-failure' | 'on-request' | 'never'; // Approval policy
|
||||||
codexEnableWebSearch: boolean; // Enable web search capability
|
codexEnableWebSearch: boolean; // Enable web search capability
|
||||||
codexEnableImages: boolean; // Enable image processing
|
codexEnableImages: boolean; // Enable image processing
|
||||||
|
codexAdditionalDirs: string[]; // Additional directories with write access (--add-dir flags)
|
||||||
|
codexThreadId: string | undefined; // Last thread ID for session resumption
|
||||||
|
|
||||||
// OpenCode CLI Settings (global)
|
// OpenCode CLI Settings (global)
|
||||||
// Static OpenCode settings are persisted via SETTINGS_FIELDS_TO_SYNC
|
// Static OpenCode settings are persisted via SETTINGS_FIELDS_TO_SYNC
|
||||||
|
|||||||
@@ -7,11 +7,16 @@
|
|||||||
*/
|
*/
|
||||||
export type CodexModelId =
|
export type CodexModelId =
|
||||||
| 'codex-gpt-5.3-codex'
|
| 'codex-gpt-5.3-codex'
|
||||||
|
| 'codex-gpt-5.3-codex-spark'
|
||||||
| 'codex-gpt-5.2-codex'
|
| 'codex-gpt-5.2-codex'
|
||||||
| 'codex-gpt-5.1-codex-max'
|
| 'codex-gpt-5.1-codex-max'
|
||||||
| 'codex-gpt-5.1-codex-mini'
|
| 'codex-gpt-5.1-codex-mini'
|
||||||
|
| 'codex-gpt-5.1-codex'
|
||||||
|
| 'codex-gpt-5-codex'
|
||||||
|
| 'codex-gpt-5-codex-mini'
|
||||||
| 'codex-gpt-5.2'
|
| 'codex-gpt-5.2'
|
||||||
| 'codex-gpt-5.1';
|
| 'codex-gpt-5.1'
|
||||||
|
| 'codex-gpt-5';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Codex model metadata
|
* Codex model metadata
|
||||||
@@ -37,6 +42,13 @@ export const CODEX_MODEL_CONFIG_MAP: Record<CodexModelId, CodexModelConfig> = {
|
|||||||
hasThinking: true,
|
hasThinking: true,
|
||||||
supportsVision: true,
|
supportsVision: true,
|
||||||
},
|
},
|
||||||
|
'codex-gpt-5.3-codex-spark': {
|
||||||
|
id: 'codex-gpt-5.3-codex-spark',
|
||||||
|
label: 'GPT-5.3-Codex-Spark',
|
||||||
|
description: 'Near-instant real-time coding model, 1000+ tokens/sec',
|
||||||
|
hasThinking: true,
|
||||||
|
supportsVision: true,
|
||||||
|
},
|
||||||
'codex-gpt-5.2-codex': {
|
'codex-gpt-5.2-codex': {
|
||||||
id: 'codex-gpt-5.2-codex',
|
id: 'codex-gpt-5.2-codex',
|
||||||
label: 'GPT-5.2-Codex',
|
label: 'GPT-5.2-Codex',
|
||||||
@@ -58,6 +70,27 @@ export const CODEX_MODEL_CONFIG_MAP: Record<CodexModelId, CodexModelConfig> = {
|
|||||||
hasThinking: false,
|
hasThinking: false,
|
||||||
supportsVision: true,
|
supportsVision: true,
|
||||||
},
|
},
|
||||||
|
'codex-gpt-5.1-codex': {
|
||||||
|
id: 'codex-gpt-5.1-codex',
|
||||||
|
label: 'GPT-5.1-Codex',
|
||||||
|
description: 'Original GPT-5.1 Codex agentic coding model',
|
||||||
|
hasThinking: true,
|
||||||
|
supportsVision: true,
|
||||||
|
},
|
||||||
|
'codex-gpt-5-codex': {
|
||||||
|
id: 'codex-gpt-5-codex',
|
||||||
|
label: 'GPT-5-Codex',
|
||||||
|
description: 'Original GPT-5 Codex model',
|
||||||
|
hasThinking: true,
|
||||||
|
supportsVision: true,
|
||||||
|
},
|
||||||
|
'codex-gpt-5-codex-mini': {
|
||||||
|
id: 'codex-gpt-5-codex-mini',
|
||||||
|
label: 'GPT-5-Codex-Mini',
|
||||||
|
description: 'Smaller, cheaper GPT-5 Codex variant',
|
||||||
|
hasThinking: false,
|
||||||
|
supportsVision: true,
|
||||||
|
},
|
||||||
'codex-gpt-5.2': {
|
'codex-gpt-5.2': {
|
||||||
id: 'codex-gpt-5.2',
|
id: 'codex-gpt-5.2',
|
||||||
label: 'GPT-5.2 (Codex)',
|
label: 'GPT-5.2 (Codex)',
|
||||||
@@ -72,6 +105,13 @@ export const CODEX_MODEL_CONFIG_MAP: Record<CodexModelId, CodexModelConfig> = {
|
|||||||
hasThinking: true,
|
hasThinking: true,
|
||||||
supportsVision: true,
|
supportsVision: true,
|
||||||
},
|
},
|
||||||
|
'codex-gpt-5': {
|
||||||
|
id: 'codex-gpt-5',
|
||||||
|
label: 'GPT-5 (Codex)',
|
||||||
|
description: 'Base GPT-5 model via Codex',
|
||||||
|
hasThinking: true,
|
||||||
|
supportsVision: true,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ export type ErrorType =
|
|||||||
| 'execution'
|
| 'execution'
|
||||||
| 'rate_limit'
|
| 'rate_limit'
|
||||||
| 'quota_exhausted'
|
| 'quota_exhausted'
|
||||||
|
| 'model_not_found'
|
||||||
|
| 'stream_disconnected'
|
||||||
| 'unknown';
|
| 'unknown';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -21,6 +23,8 @@ export interface ErrorInfo {
|
|||||||
isCancellation: boolean;
|
isCancellation: boolean;
|
||||||
isRateLimit: boolean;
|
isRateLimit: boolean;
|
||||||
isQuotaExhausted: boolean; // Session/weekly usage limit reached
|
isQuotaExhausted: boolean; // Session/weekly usage limit reached
|
||||||
|
isModelNotFound: boolean; // Model does not exist or user lacks access
|
||||||
|
isStreamDisconnected: boolean; // Stream disconnected before completion
|
||||||
retryAfter?: number; // Seconds to wait before retrying (for rate limit errors)
|
retryAfter?: number; // Seconds to wait before retrying (for rate limit errors)
|
||||||
originalError: unknown;
|
originalError: unknown;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,14 +60,35 @@ export type EventType =
|
|||||||
| 'cherry-pick:success'
|
| 'cherry-pick:success'
|
||||||
| 'cherry-pick:conflict'
|
| 'cherry-pick:conflict'
|
||||||
| 'cherry-pick:failure'
|
| 'cherry-pick:failure'
|
||||||
|
| 'cherry-pick:verify-failed'
|
||||||
|
| 'cherry-pick:abort'
|
||||||
| 'rebase:started'
|
| 'rebase:started'
|
||||||
| 'rebase:success'
|
| 'rebase:success'
|
||||||
| 'rebase:conflict'
|
| 'rebase:conflict'
|
||||||
| 'rebase:failure'
|
| 'rebase:failure'
|
||||||
|
| 'stash:start'
|
||||||
|
| 'stash:progress'
|
||||||
|
| 'stash:conflicts'
|
||||||
|
| 'stash:success'
|
||||||
|
| 'stash:failure'
|
||||||
|
| 'merge:start'
|
||||||
|
| 'merge:success'
|
||||||
|
| 'merge:conflict'
|
||||||
|
| 'merge:error'
|
||||||
| 'branchCommitLog:start'
|
| 'branchCommitLog:start'
|
||||||
| 'branchCommitLog:progress'
|
| 'branchCommitLog:progress'
|
||||||
| 'branchCommitLog:done'
|
| 'branchCommitLog:done'
|
||||||
| 'branchCommitLog:error'
|
| 'branchCommitLog:error'
|
||||||
|
| 'commitLog:start'
|
||||||
|
| 'commitLog:progress'
|
||||||
|
| 'commitLog:complete'
|
||||||
|
| 'commitLog:error'
|
||||||
|
| 'switch:start'
|
||||||
|
| 'switch:stash'
|
||||||
|
| 'switch:checkout'
|
||||||
|
| 'switch:pop'
|
||||||
|
| 'switch:done'
|
||||||
|
| 'switch:error'
|
||||||
| 'notification:created';
|
| 'notification:created';
|
||||||
|
|
||||||
export type EventCallback = (type: EventType, payload: unknown) => void;
|
export type EventCallback = (type: EventType, payload: unknown) => void;
|
||||||
|
|||||||
@@ -80,6 +80,14 @@ export const CODEX_MODELS: (ModelOption & { hasReasoning?: boolean })[] = [
|
|||||||
provider: 'codex',
|
provider: 'codex',
|
||||||
hasReasoning: true,
|
hasReasoning: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: CODEX_MODEL_MAP.gpt53CodexSpark,
|
||||||
|
label: 'GPT-5.3-Codex-Spark',
|
||||||
|
description: 'Near-instant real-time coding model, 1000+ tokens/sec.',
|
||||||
|
badge: 'Speed',
|
||||||
|
provider: 'codex',
|
||||||
|
hasReasoning: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: CODEX_MODEL_MAP.gpt52Codex,
|
id: CODEX_MODEL_MAP.gpt52Codex,
|
||||||
label: 'GPT-5.2-Codex',
|
label: 'GPT-5.2-Codex',
|
||||||
@@ -104,6 +112,30 @@ export const CODEX_MODELS: (ModelOption & { hasReasoning?: boolean })[] = [
|
|||||||
provider: 'codex',
|
provider: 'codex',
|
||||||
hasReasoning: false,
|
hasReasoning: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: CODEX_MODEL_MAP.gpt51Codex,
|
||||||
|
label: 'GPT-5.1-Codex',
|
||||||
|
description: 'Original GPT-5.1 Codex agentic coding model.',
|
||||||
|
badge: 'Balanced',
|
||||||
|
provider: 'codex',
|
||||||
|
hasReasoning: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: CODEX_MODEL_MAP.gpt5Codex,
|
||||||
|
label: 'GPT-5-Codex',
|
||||||
|
description: 'Original GPT-5 Codex model.',
|
||||||
|
badge: 'Balanced',
|
||||||
|
provider: 'codex',
|
||||||
|
hasReasoning: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: CODEX_MODEL_MAP.gpt5CodexMini,
|
||||||
|
label: 'GPT-5-Codex-Mini',
|
||||||
|
description: 'Smaller, cheaper GPT-5 Codex variant.',
|
||||||
|
badge: 'Speed',
|
||||||
|
provider: 'codex',
|
||||||
|
hasReasoning: false,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: CODEX_MODEL_MAP.gpt52,
|
id: CODEX_MODEL_MAP.gpt52,
|
||||||
label: 'GPT-5.2',
|
label: 'GPT-5.2',
|
||||||
@@ -120,6 +152,14 @@ export const CODEX_MODELS: (ModelOption & { hasReasoning?: boolean })[] = [
|
|||||||
provider: 'codex',
|
provider: 'codex',
|
||||||
hasReasoning: true,
|
hasReasoning: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: CODEX_MODEL_MAP.gpt5,
|
||||||
|
label: 'GPT-5',
|
||||||
|
description: 'Base GPT-5 model.',
|
||||||
|
badge: 'Balanced',
|
||||||
|
provider: 'codex',
|
||||||
|
hasReasoning: true,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -222,11 +262,16 @@ export function getModelDisplayName(model: ModelAlias | string): string {
|
|||||||
sonnet: 'Claude Sonnet',
|
sonnet: 'Claude Sonnet',
|
||||||
opus: 'Claude Opus',
|
opus: 'Claude Opus',
|
||||||
[CODEX_MODEL_MAP.gpt53Codex]: 'GPT-5.3-Codex',
|
[CODEX_MODEL_MAP.gpt53Codex]: 'GPT-5.3-Codex',
|
||||||
|
[CODEX_MODEL_MAP.gpt53CodexSpark]: 'GPT-5.3-Codex-Spark',
|
||||||
[CODEX_MODEL_MAP.gpt52Codex]: 'GPT-5.2-Codex',
|
[CODEX_MODEL_MAP.gpt52Codex]: 'GPT-5.2-Codex',
|
||||||
[CODEX_MODEL_MAP.gpt51CodexMax]: 'GPT-5.1-Codex-Max',
|
[CODEX_MODEL_MAP.gpt51CodexMax]: 'GPT-5.1-Codex-Max',
|
||||||
[CODEX_MODEL_MAP.gpt51CodexMini]: 'GPT-5.1-Codex-Mini',
|
[CODEX_MODEL_MAP.gpt51CodexMini]: 'GPT-5.1-Codex-Mini',
|
||||||
|
[CODEX_MODEL_MAP.gpt51Codex]: 'GPT-5.1-Codex',
|
||||||
|
[CODEX_MODEL_MAP.gpt5Codex]: 'GPT-5-Codex',
|
||||||
|
[CODEX_MODEL_MAP.gpt5CodexMini]: 'GPT-5-Codex-Mini',
|
||||||
[CODEX_MODEL_MAP.gpt52]: 'GPT-5.2',
|
[CODEX_MODEL_MAP.gpt52]: 'GPT-5.2',
|
||||||
[CODEX_MODEL_MAP.gpt51]: 'GPT-5.1',
|
[CODEX_MODEL_MAP.gpt51]: 'GPT-5.1',
|
||||||
|
[CODEX_MODEL_MAP.gpt5]: 'GPT-5',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check direct match first
|
// Check direct match first
|
||||||
|
|||||||
@@ -52,18 +52,28 @@ export const CODEX_MODEL_MAP = {
|
|||||||
// Recommended Codex-specific models
|
// Recommended Codex-specific models
|
||||||
/** Latest frontier agentic coding model */
|
/** Latest frontier agentic coding model */
|
||||||
gpt53Codex: 'codex-gpt-5.3-codex',
|
gpt53Codex: 'codex-gpt-5.3-codex',
|
||||||
|
/** Smaller, near-instant version of GPT-5.3-Codex for real-time coding */
|
||||||
|
gpt53CodexSpark: 'codex-gpt-5.3-codex-spark',
|
||||||
/** Frontier agentic coding model */
|
/** Frontier agentic coding model */
|
||||||
gpt52Codex: 'codex-gpt-5.2-codex',
|
gpt52Codex: 'codex-gpt-5.2-codex',
|
||||||
/** Codex-optimized flagship for deep and fast reasoning */
|
/** Codex-optimized flagship for deep and fast reasoning */
|
||||||
gpt51CodexMax: 'codex-gpt-5.1-codex-max',
|
gpt51CodexMax: 'codex-gpt-5.1-codex-max',
|
||||||
/** Optimized for codex. Cheaper, faster, but less capable */
|
/** Optimized for codex. Cheaper, faster, but less capable */
|
||||||
gpt51CodexMini: 'codex-gpt-5.1-codex-mini',
|
gpt51CodexMini: 'codex-gpt-5.1-codex-mini',
|
||||||
|
/** Original GPT-5.1 Codex model */
|
||||||
|
gpt51Codex: 'codex-gpt-5.1-codex',
|
||||||
|
/** Original GPT-5 Codex model */
|
||||||
|
gpt5Codex: 'codex-gpt-5-codex',
|
||||||
|
/** Smaller, cheaper GPT-5 Codex variant */
|
||||||
|
gpt5CodexMini: 'codex-gpt-5-codex-mini',
|
||||||
|
|
||||||
// General-purpose GPT models (also available in Codex)
|
// General-purpose GPT models (also available in Codex)
|
||||||
/** Latest frontier model with improvements across knowledge, reasoning and coding */
|
/** Latest frontier model with improvements across knowledge, reasoning and coding */
|
||||||
gpt52: 'codex-gpt-5.2',
|
gpt52: 'codex-gpt-5.2',
|
||||||
/** Great for coding and agentic tasks across domains */
|
/** Great for coding and agentic tasks across domains */
|
||||||
gpt51: 'codex-gpt-5.1',
|
gpt51: 'codex-gpt-5.1',
|
||||||
|
/** Base GPT-5 model */
|
||||||
|
gpt5: 'codex-gpt-5',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const CODEX_MODEL_IDS = Object.values(CODEX_MODEL_MAP);
|
export const CODEX_MODEL_IDS = Object.values(CODEX_MODEL_MAP);
|
||||||
@@ -74,10 +84,14 @@ export const CODEX_MODEL_IDS = Object.values(CODEX_MODEL_MAP);
|
|||||||
*/
|
*/
|
||||||
export const REASONING_CAPABLE_MODELS = new Set([
|
export const REASONING_CAPABLE_MODELS = new Set([
|
||||||
CODEX_MODEL_MAP.gpt53Codex,
|
CODEX_MODEL_MAP.gpt53Codex,
|
||||||
|
CODEX_MODEL_MAP.gpt53CodexSpark,
|
||||||
CODEX_MODEL_MAP.gpt52Codex,
|
CODEX_MODEL_MAP.gpt52Codex,
|
||||||
CODEX_MODEL_MAP.gpt51CodexMax,
|
CODEX_MODEL_MAP.gpt51CodexMax,
|
||||||
|
CODEX_MODEL_MAP.gpt51Codex,
|
||||||
|
CODEX_MODEL_MAP.gpt5Codex,
|
||||||
CODEX_MODEL_MAP.gpt52,
|
CODEX_MODEL_MAP.gpt52,
|
||||||
CODEX_MODEL_MAP.gpt51,
|
CODEX_MODEL_MAP.gpt51,
|
||||||
|
CODEX_MODEL_MAP.gpt5,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -117,6 +117,44 @@ export function isQuotaExhaustedError(error: unknown): boolean {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an error indicates a model-not-found or model access issue
|
||||||
|
*
|
||||||
|
* @param error - The error to check
|
||||||
|
* @returns True if the error indicates the model doesn't exist or user lacks access
|
||||||
|
*/
|
||||||
|
export function isModelNotFoundError(error: unknown): boolean {
|
||||||
|
const message = error instanceof Error ? error.message : String(error || '');
|
||||||
|
const lowerMessage = message.toLowerCase();
|
||||||
|
|
||||||
|
return (
|
||||||
|
lowerMessage.includes('does not exist or you do not have access') ||
|
||||||
|
lowerMessage.includes('model_not_found') ||
|
||||||
|
lowerMessage.includes('invalid_model') ||
|
||||||
|
(lowerMessage.includes('model') &&
|
||||||
|
(lowerMessage.includes('does not exist') || lowerMessage.includes('not found')))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an error indicates a stream disconnection
|
||||||
|
*
|
||||||
|
* @param error - The error to check
|
||||||
|
* @returns True if the error indicates the stream was disconnected
|
||||||
|
*/
|
||||||
|
export function isStreamDisconnectedError(error: unknown): boolean {
|
||||||
|
const message = error instanceof Error ? error.message : String(error || '');
|
||||||
|
const lowerMessage = message.toLowerCase();
|
||||||
|
|
||||||
|
return (
|
||||||
|
lowerMessage.includes('stream disconnected') ||
|
||||||
|
lowerMessage.includes('stream ended') ||
|
||||||
|
lowerMessage.includes('connection reset') ||
|
||||||
|
lowerMessage.includes('socket hang up') ||
|
||||||
|
lowerMessage.includes('econnreset')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract retry-after duration from rate limit error
|
* Extract retry-after duration from rate limit error
|
||||||
*
|
*
|
||||||
@@ -154,11 +192,17 @@ export function classifyError(error: unknown): ErrorInfo {
|
|||||||
const isCancellation = isCancellationError(message);
|
const isCancellation = isCancellationError(message);
|
||||||
const isRateLimit = isRateLimitError(error);
|
const isRateLimit = isRateLimitError(error);
|
||||||
const isQuotaExhausted = isQuotaExhaustedError(error);
|
const isQuotaExhausted = isQuotaExhaustedError(error);
|
||||||
|
const isModelNotFound = isModelNotFoundError(error);
|
||||||
|
const isStreamDisconnected = isStreamDisconnectedError(error);
|
||||||
const retryAfter = isRateLimit ? (extractRetryAfter(error) ?? 60) : undefined;
|
const retryAfter = isRateLimit ? (extractRetryAfter(error) ?? 60) : undefined;
|
||||||
|
|
||||||
let type: ErrorType;
|
let type: ErrorType;
|
||||||
if (isAuth) {
|
if (isAuth) {
|
||||||
type = 'authentication';
|
type = 'authentication';
|
||||||
|
} else if (isModelNotFound) {
|
||||||
|
type = 'model_not_found';
|
||||||
|
} else if (isStreamDisconnected) {
|
||||||
|
type = 'stream_disconnected';
|
||||||
} else if (isQuotaExhausted) {
|
} else if (isQuotaExhausted) {
|
||||||
// Quota exhaustion takes priority over rate limit since it's more specific
|
// Quota exhaustion takes priority over rate limit since it's more specific
|
||||||
type = 'quota_exhausted';
|
type = 'quota_exhausted';
|
||||||
@@ -182,6 +226,8 @@ export function classifyError(error: unknown): ErrorInfo {
|
|||||||
isCancellation,
|
isCancellation,
|
||||||
isRateLimit,
|
isRateLimit,
|
||||||
isQuotaExhausted,
|
isQuotaExhausted,
|
||||||
|
isModelNotFound,
|
||||||
|
isStreamDisconnected,
|
||||||
retryAfter,
|
retryAfter,
|
||||||
originalError: error,
|
originalError: error,
|
||||||
};
|
};
|
||||||
@@ -204,6 +250,14 @@ export function getUserFriendlyErrorMessage(error: unknown): string {
|
|||||||
return 'Authentication failed. Please check your API key.';
|
return 'Authentication failed. Please check your API key.';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (info.isModelNotFound) {
|
||||||
|
return `Model not available: ${info.message}\n\nSome models require specific subscription plans or authentication methods. Try authenticating with 'codex login' or switch to a different model.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (info.isStreamDisconnected) {
|
||||||
|
return `Connection interrupted: ${info.message}\n\nThe stream was disconnected before the response could complete. This may be caused by network issues, model access restrictions, or server timeouts. Try again or switch to a different model.`;
|
||||||
|
}
|
||||||
|
|
||||||
if (info.isQuotaExhausted) {
|
if (info.isQuotaExhausted) {
|
||||||
return 'Usage limit reached. Auto Mode has been paused. Please wait for your quota to reset or upgrade your plan.';
|
return 'Usage limit reached. Auto Mode has been paused. Please wait for your quota to reset or upgrade your plan.';
|
||||||
}
|
}
|
||||||
@@ -241,3 +295,25 @@ export function getUserFriendlyErrorMessage(error: unknown): string {
|
|||||||
export function getErrorMessage(error: unknown): string {
|
export function getErrorMessage(error: unknown): string {
|
||||||
return error instanceof Error ? error.message : 'Unknown error';
|
return error instanceof Error ? error.message : 'Unknown error';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log an error with a context message to stderr.
|
||||||
|
*
|
||||||
|
* Convenience utility for consistent error logging throughout the codebase.
|
||||||
|
* Outputs a formatted error line to stderr with an ❌ prefix and the context.
|
||||||
|
*
|
||||||
|
* @param error - The error value to log
|
||||||
|
* @param context - Descriptive context message indicating where/why the error occurred
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* try {
|
||||||
|
* await someOperation();
|
||||||
|
* } catch (error) {
|
||||||
|
* logError(error, 'Failed to perform some operation');
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function logError(error: unknown, context: string): void {
|
||||||
|
console.error(`❌ ${context}:`, error);
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,10 +10,13 @@ export {
|
|||||||
isAuthenticationError,
|
isAuthenticationError,
|
||||||
isRateLimitError,
|
isRateLimitError,
|
||||||
isQuotaExhaustedError,
|
isQuotaExhaustedError,
|
||||||
|
isModelNotFoundError,
|
||||||
|
isStreamDisconnectedError,
|
||||||
extractRetryAfter,
|
extractRetryAfter,
|
||||||
classifyError,
|
classifyError,
|
||||||
getUserFriendlyErrorMessage,
|
getUserFriendlyErrorMessage,
|
||||||
getErrorMessage,
|
getErrorMessage,
|
||||||
|
logError,
|
||||||
} from './error-handler.js';
|
} from './error-handler.js';
|
||||||
|
|
||||||
// Conversation utilities
|
// Conversation utilities
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import {
|
|||||||
isAuthenticationError,
|
isAuthenticationError,
|
||||||
isRateLimitError,
|
isRateLimitError,
|
||||||
isQuotaExhaustedError,
|
isQuotaExhaustedError,
|
||||||
|
isModelNotFoundError,
|
||||||
|
isStreamDisconnectedError,
|
||||||
extractRetryAfter,
|
extractRetryAfter,
|
||||||
classifyError,
|
classifyError,
|
||||||
getUserFriendlyErrorMessage,
|
getUserFriendlyErrorMessage,
|
||||||
@@ -179,6 +181,76 @@ describe('error-handler.ts', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('isModelNotFoundError', () => {
|
||||||
|
it('should return true for "does not exist or you do not have access" errors', () => {
|
||||||
|
expect(
|
||||||
|
isModelNotFoundError(
|
||||||
|
new Error('The model `gpt-5.3-codex` does not exist or you do not have access to it.')
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true for model_not_found errors', () => {
|
||||||
|
expect(isModelNotFoundError(new Error('model_not_found: gpt-5.3-codex'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true for invalid_model errors', () => {
|
||||||
|
expect(isModelNotFoundError(new Error('invalid_model: unknown model'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true for "model does not exist" errors', () => {
|
||||||
|
expect(isModelNotFoundError(new Error('The model does not exist'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true for "model not found" errors', () => {
|
||||||
|
expect(isModelNotFoundError(new Error('model not found'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for regular errors', () => {
|
||||||
|
expect(isModelNotFoundError(new Error('Something went wrong'))).toBe(false);
|
||||||
|
expect(isModelNotFoundError(new Error('Network error'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for null/undefined', () => {
|
||||||
|
expect(isModelNotFoundError(null)).toBe(false);
|
||||||
|
expect(isModelNotFoundError(undefined)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isStreamDisconnectedError', () => {
|
||||||
|
it('should return true for "stream disconnected" errors', () => {
|
||||||
|
expect(isStreamDisconnectedError(new Error('stream disconnected before completion'))).toBe(
|
||||||
|
true
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true for "stream ended" errors', () => {
|
||||||
|
expect(isStreamDisconnectedError(new Error('stream ended unexpectedly'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true for "connection reset" errors', () => {
|
||||||
|
expect(isStreamDisconnectedError(new Error('connection reset by peer'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true for "socket hang up" errors', () => {
|
||||||
|
expect(isStreamDisconnectedError(new Error('socket hang up'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true for ECONNRESET errors', () => {
|
||||||
|
expect(isStreamDisconnectedError(new Error('ECONNRESET'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for regular errors', () => {
|
||||||
|
expect(isStreamDisconnectedError(new Error('Something went wrong'))).toBe(false);
|
||||||
|
expect(isStreamDisconnectedError(new Error('Network error'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for null/undefined', () => {
|
||||||
|
expect(isStreamDisconnectedError(null)).toBe(false);
|
||||||
|
expect(isStreamDisconnectedError(undefined)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('extractRetryAfter', () => {
|
describe('extractRetryAfter', () => {
|
||||||
it('should extract retry-after from error message', () => {
|
it('should extract retry-after from error message', () => {
|
||||||
const error = new Error('Rate limit exceeded. retry-after: 60');
|
const error = new Error('Rate limit exceeded. retry-after: 60');
|
||||||
@@ -298,6 +370,28 @@ describe('error-handler.ts', () => {
|
|||||||
expect(result.isAbort).toBe(false);
|
expect(result.isAbort).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should classify model not found errors', () => {
|
||||||
|
const error = new Error(
|
||||||
|
'The model `gpt-5.3-codex` does not exist or you do not have access to it.'
|
||||||
|
);
|
||||||
|
const result = classifyError(error);
|
||||||
|
|
||||||
|
expect(result.type).toBe('model_not_found');
|
||||||
|
expect(result.isModelNotFound).toBe(true);
|
||||||
|
expect(result.isStreamDisconnected).toBe(false);
|
||||||
|
expect(result.isAuth).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should classify stream disconnected errors', () => {
|
||||||
|
const error = new Error('stream disconnected before completion');
|
||||||
|
const result = classifyError(error);
|
||||||
|
|
||||||
|
expect(result.type).toBe('stream_disconnected');
|
||||||
|
expect(result.isStreamDisconnected).toBe(true);
|
||||||
|
expect(result.isModelNotFound).toBe(false);
|
||||||
|
expect(result.isAuth).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it('should classify execution errors (regular Error)', () => {
|
it('should classify execution errors (regular Error)', () => {
|
||||||
const error = new Error('Something went wrong');
|
const error = new Error('Something went wrong');
|
||||||
const result = classifyError(error);
|
const result = classifyError(error);
|
||||||
@@ -397,6 +491,24 @@ describe('error-handler.ts', () => {
|
|||||||
expect(message).toBe('Authentication failed. Please check your API key.');
|
expect(message).toBe('Authentication failed. Please check your API key.');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should return friendly message for model not found errors', () => {
|
||||||
|
const error = new Error(
|
||||||
|
'The model `gpt-5.3-codex` does not exist or you do not have access to it.'
|
||||||
|
);
|
||||||
|
const message = getUserFriendlyErrorMessage(error);
|
||||||
|
|
||||||
|
expect(message).toContain('Model not available');
|
||||||
|
expect(message).toContain('codex login');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return friendly message for stream disconnected errors', () => {
|
||||||
|
const error = new Error('stream disconnected before completion');
|
||||||
|
const message = getUserFriendlyErrorMessage(error);
|
||||||
|
|
||||||
|
expect(message).toContain('Connection interrupted');
|
||||||
|
expect(message).toContain('stream was disconnected');
|
||||||
|
});
|
||||||
|
|
||||||
it('should return friendly message for quota exhausted errors', () => {
|
it('should return friendly message for quota exhausted errors', () => {
|
||||||
const error = new Error('overloaded_error');
|
const error = new Error('overloaded_error');
|
||||||
const message = getUserFriendlyErrorMessage(error);
|
const message = getUserFriendlyErrorMessage(error);
|
||||||
|
|||||||
Reference in New Issue
Block a user