mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-16 21:53:07 +00:00
feat: Address review comments, add stage/unstage functionality, conflict resolution improvements, support for Sonnet 4.6
This commit is contained in:
@@ -6,7 +6,12 @@
|
||||
* import from here rather than defining their own copy.
|
||||
*/
|
||||
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { spawnProcess } from '@automaker/platform';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
|
||||
const logger = createLogger('GitLib');
|
||||
|
||||
// ============================================================================
|
||||
// Secure Command Execution
|
||||
@@ -80,3 +85,110 @@ export async function getCurrentBranch(worktreePath: string): Promise<string> {
|
||||
const branchOutput = await execGitCommand(['rev-parse', '--abbrev-ref', 'HEAD'], worktreePath);
|
||||
return branchOutput.trim();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Index Lock Recovery
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Check whether an error message indicates a stale git index lock file.
|
||||
*
|
||||
* Git operations that write to the index (e.g. `git stash push`) will fail
|
||||
* with "could not write index" or "Unable to create ... .lock" when a
|
||||
* `.git/index.lock` file exists from a previously interrupted operation.
|
||||
*
|
||||
* @param errorMessage - The error string from a failed git command
|
||||
* @returns true if the error looks like a stale index lock issue
|
||||
*/
|
||||
export function isIndexLockError(errorMessage: string): boolean {
|
||||
const lower = errorMessage.toLowerCase();
|
||||
return (
|
||||
lower.includes('could not write index') ||
|
||||
(lower.includes('unable to create') && lower.includes('index.lock')) ||
|
||||
lower.includes('index.lock')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to remove a stale `.git/index.lock` file for the given worktree.
|
||||
*
|
||||
* Uses `git rev-parse --git-dir` to locate the correct `.git` directory,
|
||||
* which works for both regular repositories and linked worktrees.
|
||||
*
|
||||
* @param worktreePath - Path to the git worktree (or main repo)
|
||||
* @returns true if a lock file was found and removed, false otherwise
|
||||
*/
|
||||
export async function removeStaleIndexLock(worktreePath: string): Promise<boolean> {
|
||||
try {
|
||||
// Resolve the .git directory (handles worktrees correctly)
|
||||
const gitDirRaw = await execGitCommand(['rev-parse', '--git-dir'], worktreePath);
|
||||
const gitDir = path.resolve(worktreePath, gitDirRaw.trim());
|
||||
const lockFilePath = path.join(gitDir, 'index.lock');
|
||||
|
||||
// Check if the lock file exists
|
||||
try {
|
||||
await fs.access(lockFilePath);
|
||||
} catch {
|
||||
// Lock file does not exist — nothing to remove
|
||||
return false;
|
||||
}
|
||||
|
||||
// Remove the stale lock file
|
||||
await fs.unlink(lockFilePath);
|
||||
logger.info('Removed stale index.lock file', { worktreePath, lockFilePath });
|
||||
return true;
|
||||
} catch (err) {
|
||||
logger.warn('Failed to remove stale index.lock file', {
|
||||
worktreePath,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a git command with automatic retry when a stale index.lock is detected.
|
||||
*
|
||||
* If the command fails with an error indicating a locked index file, this
|
||||
* helper will attempt to remove the stale `.git/index.lock` and retry the
|
||||
* command exactly once.
|
||||
*
|
||||
* This is particularly useful for `git stash push` which writes to the
|
||||
* index and commonly fails when a previous git operation was interrupted.
|
||||
*
|
||||
* @param args - Array of git command arguments
|
||||
* @param cwd - Working directory to execute the command in
|
||||
* @param env - Optional additional environment variables
|
||||
* @returns Promise resolving to stdout output
|
||||
* @throws The original error if retry also fails, or a non-lock error
|
||||
*/
|
||||
export async function execGitCommandWithLockRetry(
|
||||
args: string[],
|
||||
cwd: string,
|
||||
env?: Record<string, string>
|
||||
): Promise<string> {
|
||||
try {
|
||||
return await execGitCommand(args, cwd, env);
|
||||
} catch (error: unknown) {
|
||||
const err = error as { message?: string; stderr?: string };
|
||||
const errorMessage = err.stderr || err.message || '';
|
||||
|
||||
if (!isIndexLockError(errorMessage)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
logger.info('Git command failed due to index lock, attempting cleanup and retry', {
|
||||
cwd,
|
||||
args: args.join(' '),
|
||||
});
|
||||
|
||||
const removed = await removeStaleIndexLock(cwd);
|
||||
if (!removed) {
|
||||
// Could not remove the lock file — re-throw the original error
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Retry the command once after removing the lock file
|
||||
return await execGitCommand(args, cwd, env);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -343,6 +343,18 @@ export class ClaudeProvider extends BaseProvider {
|
||||
tier: 'premium' as const,
|
||||
default: true,
|
||||
},
|
||||
{
|
||||
id: 'claude-sonnet-4-6',
|
||||
name: 'Claude Sonnet 4.6',
|
||||
modelString: 'claude-sonnet-4-6',
|
||||
provider: 'anthropic',
|
||||
description: 'Balanced performance and cost with enhanced reasoning',
|
||||
contextWindow: 200000,
|
||||
maxOutputTokens: 128000,
|
||||
supportsVision: true,
|
||||
supportsTools: true,
|
||||
tier: 'standard' as const,
|
||||
},
|
||||
{
|
||||
id: 'claude-sonnet-4-20250514',
|
||||
name: 'Claude Sonnet 4',
|
||||
|
||||
@@ -245,15 +245,9 @@ async function resolveCodexExecutionPlan(options: ExecuteOptions): Promise<Codex
|
||||
throw new Error(ERROR_CODEX_CLI_REQUIRED);
|
||||
}
|
||||
|
||||
if (!cliAuthenticated) {
|
||||
throw new Error(ERROR_CODEX_AUTH_REQUIRED);
|
||||
}
|
||||
|
||||
return {
|
||||
mode: CODEX_EXECUTION_MODE_CLI,
|
||||
cliPath,
|
||||
openAiApiKey,
|
||||
};
|
||||
// At this point, neither hasCliNativeAuth nor hasApiKey is true,
|
||||
// so authentication is required regardless.
|
||||
throw new Error(ERROR_CODEX_AUTH_REQUIRED);
|
||||
}
|
||||
|
||||
function getEventType(event: Record<string, unknown>): string | null {
|
||||
|
||||
@@ -15,6 +15,9 @@ const SDK_HISTORY_HEADER = 'Current request:\n';
|
||||
const DEFAULT_RESPONSE_TEXT = '';
|
||||
const SDK_ERROR_DETAILS_LABEL = 'Details:';
|
||||
|
||||
type SdkReasoningEffort = 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
|
||||
const SDK_REASONING_EFFORTS = new Set<string>(['minimal', 'low', 'medium', 'high', 'xhigh']);
|
||||
|
||||
type PromptBlock = {
|
||||
type: string;
|
||||
text?: string;
|
||||
@@ -103,9 +106,6 @@ export async function* executeCodexSdkQuery(
|
||||
// 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;
|
||||
@@ -118,6 +118,7 @@ export async function* executeCodexSdkQuery(
|
||||
// Add reasoning effort to thread options if model supports it
|
||||
if (
|
||||
options.reasoningEffort &&
|
||||
options.model &&
|
||||
supportsReasoningEffort(options.model) &&
|
||||
options.reasoningEffort !== 'none' &&
|
||||
SDK_REASONING_EFFORTS.has(options.reasoningEffort)
|
||||
|
||||
@@ -42,7 +42,7 @@ import {
|
||||
const logger = createLogger('CopilotProvider');
|
||||
|
||||
// Default bare model (without copilot- prefix) for SDK calls
|
||||
const DEFAULT_BARE_MODEL = 'claude-sonnet-4.5';
|
||||
const DEFAULT_BARE_MODEL = 'claude-sonnet-4.6';
|
||||
|
||||
// =============================================================================
|
||||
// SDK Event Types (from @github/copilot-sdk)
|
||||
|
||||
@@ -549,8 +549,15 @@ export class OpencodeProvider extends CliProvider {
|
||||
|
||||
// sdkSessionId IS set — the CLI will receive `--session <id>`.
|
||||
// If that session no longer exists, intercept the error and retry fresh.
|
||||
//
|
||||
// To avoid buffering the entire stream in memory for long-lived sessions,
|
||||
// we only buffer an initial window of messages until we observe a healthy
|
||||
// (non-error) message. Once a healthy message is seen, we flush the buffer
|
||||
// and switch to direct passthrough, while still watching for session errors
|
||||
// via isSessionNotFoundError on any subsequent error messages.
|
||||
const buffered: ProviderMessage[] = [];
|
||||
let sessionError = false;
|
||||
let seenHealthyMessage = false;
|
||||
|
||||
try {
|
||||
for await (const msg of super.executeQuery(options)) {
|
||||
@@ -565,13 +572,30 @@ export class OpencodeProvider extends CliProvider {
|
||||
break; // stop consuming the failed stream
|
||||
}
|
||||
|
||||
// Non-session error — clean and buffer
|
||||
// Non-session error — clean it
|
||||
if (msg.error && typeof msg.error === 'string') {
|
||||
msg.error = OpencodeProvider.cleanErrorMessage(msg.error);
|
||||
}
|
||||
} else {
|
||||
// A non-error message is a healthy signal — stop buffering after this
|
||||
seenHealthyMessage = true;
|
||||
}
|
||||
|
||||
buffered.push(msg);
|
||||
if (seenHealthyMessage && buffered.length > 0) {
|
||||
// Flush the pre-healthy buffer first, then switch to passthrough
|
||||
for (const bufferedMsg of buffered) {
|
||||
yield bufferedMsg;
|
||||
}
|
||||
buffered.length = 0;
|
||||
}
|
||||
|
||||
if (seenHealthyMessage) {
|
||||
// Passthrough mode — yield directly without buffering
|
||||
yield msg;
|
||||
} else {
|
||||
// Still in initial window — buffer until we see a healthy message
|
||||
buffered.push(msg);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Also handle thrown exceptions (e.g. from mapError in cli-provider)
|
||||
@@ -602,12 +626,15 @@ export class OpencodeProvider extends CliProvider {
|
||||
}
|
||||
yield retryMsg;
|
||||
}
|
||||
} else {
|
||||
// No session error — flush buffered messages to the consumer
|
||||
} else if (buffered.length > 0) {
|
||||
// No session error and still have buffered messages (stream ended before
|
||||
// any healthy message was observed) — flush them to the consumer
|
||||
for (const msg of buffered) {
|
||||
yield msg;
|
||||
}
|
||||
}
|
||||
// If seenHealthyMessage is true, all messages have already been yielded
|
||||
// directly in passthrough mode — nothing left to flush.
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -673,7 +700,7 @@ export class OpencodeProvider extends CliProvider {
|
||||
return {
|
||||
type: 'error',
|
||||
session_id: finishEvent.sessionID,
|
||||
error: finishEvent.part.error,
|
||||
error: OpencodeProvider.cleanErrorMessage(finishEvent.part.error),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -682,7 +709,7 @@ export class OpencodeProvider extends CliProvider {
|
||||
return {
|
||||
type: 'error',
|
||||
session_id: finishEvent.sessionID,
|
||||
error: 'Step execution failed',
|
||||
error: OpencodeProvider.cleanErrorMessage('Step execution failed'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -705,8 +732,10 @@ export class OpencodeProvider extends CliProvider {
|
||||
case 'tool_error': {
|
||||
const toolErrorEvent = openCodeEvent as OpenCodeBaseEvent;
|
||||
|
||||
// Extract error message from part.error
|
||||
const errorMessage = toolErrorEvent.part?.error || 'Tool execution failed';
|
||||
// Extract error message from part.error and clean ANSI codes
|
||||
const errorMessage = OpencodeProvider.cleanErrorMessage(
|
||||
toolErrorEvent.part?.error || 'Tool execution failed'
|
||||
);
|
||||
|
||||
return {
|
||||
type: 'error',
|
||||
@@ -719,16 +748,8 @@ export class OpencodeProvider extends CliProvider {
|
||||
// The event format includes the tool name, call ID, and state with input/output.
|
||||
// Handle both 'tool_use' (actual CLI format) and 'tool_call' (legacy/alternative) for robustness.
|
||||
case 'tool_use': {
|
||||
const toolUseEvent = openCodeEvent as OpenCodeBaseEvent;
|
||||
const part = toolUseEvent.part as OpenCodePart & {
|
||||
callID?: string;
|
||||
tool?: string;
|
||||
state?: {
|
||||
status?: string;
|
||||
input?: unknown;
|
||||
output?: string;
|
||||
};
|
||||
};
|
||||
const toolUseEvent = openCodeEvent as OpenCodeToolUseEvent;
|
||||
const part = toolUseEvent.part;
|
||||
|
||||
// Generate a tool use ID if not provided
|
||||
const toolUseId = part?.callID || part?.call_id || generateToolUseId();
|
||||
@@ -898,9 +919,9 @@ export class OpencodeProvider extends CliProvider {
|
||||
default: true,
|
||||
},
|
||||
{
|
||||
id: 'opencode/glm-4.7-free',
|
||||
name: 'GLM 4.7 Free',
|
||||
modelString: 'opencode/glm-4.7-free',
|
||||
id: 'opencode/glm-5-free',
|
||||
name: 'GLM 5 Free',
|
||||
modelString: 'opencode/glm-5-free',
|
||||
provider: 'opencode',
|
||||
description: 'OpenCode free tier GLM model',
|
||||
supportsTools: true,
|
||||
@@ -918,19 +939,19 @@ export class OpencodeProvider extends CliProvider {
|
||||
tier: 'basic',
|
||||
},
|
||||
{
|
||||
id: 'opencode/grok-code',
|
||||
name: 'Grok Code (Free)',
|
||||
modelString: 'opencode/grok-code',
|
||||
id: 'opencode/kimi-k2.5-free',
|
||||
name: 'Kimi K2.5 Free',
|
||||
modelString: 'opencode/kimi-k2.5-free',
|
||||
provider: 'opencode',
|
||||
description: 'OpenCode free tier Grok model for coding',
|
||||
description: 'OpenCode free tier Kimi model for coding',
|
||||
supportsTools: true,
|
||||
supportsVision: false,
|
||||
tier: 'basic',
|
||||
},
|
||||
{
|
||||
id: 'opencode/minimax-m2.1-free',
|
||||
name: 'MiniMax M2.1 Free',
|
||||
modelString: 'opencode/minimax-m2.1-free',
|
||||
id: 'opencode/minimax-m2.5-free',
|
||||
name: 'MiniMax M2.5 Free',
|
||||
modelString: 'opencode/minimax-m2.5-free',
|
||||
provider: 'opencode',
|
||||
description: 'OpenCode free tier MiniMax model',
|
||||
supportsTools: true,
|
||||
@@ -1052,7 +1073,7 @@ export class OpencodeProvider extends CliProvider {
|
||||
*
|
||||
* OpenCode CLI output format (one model per line):
|
||||
* opencode/big-pickle
|
||||
* opencode/glm-4.7-free
|
||||
* opencode/glm-5-free
|
||||
* anthropic/claude-3-5-haiku-20241022
|
||||
* github-copilot/claude-3.5-sonnet
|
||||
* ...
|
||||
|
||||
@@ -94,7 +94,7 @@ export interface StreamingQueryOptions extends SimpleQueryOptions {
|
||||
/**
|
||||
* Default model to use when none specified
|
||||
*/
|
||||
const DEFAULT_MODEL = 'claude-sonnet-4-20250514';
|
||||
const DEFAULT_MODEL = 'claude-sonnet-4-6';
|
||||
|
||||
/**
|
||||
* Execute a simple query and return the text result
|
||||
|
||||
@@ -6,12 +6,14 @@ import { Router } from 'express';
|
||||
import { validatePathParams } from '../../middleware/validate-paths.js';
|
||||
import { createDiffsHandler } from './routes/diffs.js';
|
||||
import { createFileDiffHandler } from './routes/file-diff.js';
|
||||
import { createStageFilesHandler } from './routes/stage-files.js';
|
||||
|
||||
export function createGitRoutes(): Router {
|
||||
const router = Router();
|
||||
|
||||
router.post('/diffs', validatePathParams('projectPath'), createDiffsHandler());
|
||||
router.post('/file-diff', validatePathParams('projectPath', 'filePath'), createFileDiffHandler());
|
||||
router.post('/stage-files', validatePathParams('projectPath'), createStageFilesHandler());
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
60
apps/server/src/routes/git/routes/stage-files.ts
Normal file
60
apps/server/src/routes/git/routes/stage-files.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* POST /stage-files endpoint - Stage or unstage files in the main project
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
import { execGitCommand } from '../../../lib/git.js';
|
||||
|
||||
export function createStageFilesHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, files, operation } = req.body as {
|
||||
projectPath: string;
|
||||
files: string[];
|
||||
operation: 'stage' | 'unstage';
|
||||
};
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'projectPath required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!files || files.length === 0) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'files array required and must not be empty',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (operation !== 'stage' && operation !== 'unstage') {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'operation must be "stage" or "unstage"',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (operation === 'stage') {
|
||||
await execGitCommand(['add', '--', ...files], projectPath);
|
||||
} else {
|
||||
await execGitCommand(['reset', 'HEAD', '--', ...files], projectPath);
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
result: {
|
||||
operation,
|
||||
filesCount: files.length,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, `${(req.body as { operation?: string })?.operation ?? 'stage'} files failed`);
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -158,7 +158,7 @@ export function createVerifyClaudeAuthHandler() {
|
||||
const stream = query({
|
||||
prompt: "Reply with only the word 'ok'",
|
||||
options: {
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
model: 'claude-sonnet-4-6',
|
||||
maxTurns: 1,
|
||||
allowedTools: [],
|
||||
abortController,
|
||||
|
||||
@@ -63,6 +63,9 @@ import { createCherryPickHandler } from './routes/cherry-pick.js';
|
||||
import { createBranchCommitLogHandler } from './routes/branch-commit-log.js';
|
||||
import { createGeneratePRDescriptionHandler } from './routes/generate-pr-description.js';
|
||||
import { createRebaseHandler } from './routes/rebase.js';
|
||||
import { createAbortOperationHandler } from './routes/abort-operation.js';
|
||||
import { createContinueOperationHandler } from './routes/continue-operation.js';
|
||||
import { createStageFilesHandler } from './routes/stage-files.js';
|
||||
import type { SettingsService } from '../../services/settings-service.js';
|
||||
|
||||
export function createWorktreeRoutes(
|
||||
@@ -276,5 +279,29 @@ export function createWorktreeRoutes(
|
||||
createRebaseHandler(events)
|
||||
);
|
||||
|
||||
// Abort in-progress merge/rebase/cherry-pick
|
||||
router.post(
|
||||
'/abort-operation',
|
||||
validatePathParams('worktreePath'),
|
||||
requireGitRepoOnly,
|
||||
createAbortOperationHandler(events)
|
||||
);
|
||||
|
||||
// Continue in-progress merge/rebase/cherry-pick after resolving conflicts
|
||||
router.post(
|
||||
'/continue-operation',
|
||||
validatePathParams('worktreePath'),
|
||||
requireGitRepoOnly,
|
||||
createContinueOperationHandler(events)
|
||||
);
|
||||
|
||||
// Stage/unstage files route
|
||||
router.post(
|
||||
'/stage-files',
|
||||
validatePathParams('worktreePath'),
|
||||
requireGitRepoOnly,
|
||||
createStageFilesHandler()
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
117
apps/server/src/routes/worktree/routes/abort-operation.ts
Normal file
117
apps/server/src/routes/worktree/routes/abort-operation.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* POST /abort-operation endpoint - Abort an in-progress merge, rebase, or cherry-pick
|
||||
*
|
||||
* Detects which operation (merge, rebase, or cherry-pick) is in progress
|
||||
* and aborts it, returning the repository to a clean state.
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import path from 'path';
|
||||
import * as fs from 'fs/promises';
|
||||
import { getErrorMessage, logError, execAsync } from '../common.js';
|
||||
import type { EventEmitter } from '../../../lib/events.js';
|
||||
|
||||
/**
|
||||
* Detect what type of conflict operation is currently in progress
|
||||
*/
|
||||
async function detectOperation(
|
||||
worktreePath: string
|
||||
): Promise<'merge' | 'rebase' | 'cherry-pick' | null> {
|
||||
try {
|
||||
const { stdout: gitDirRaw } = await execAsync('git rev-parse --git-dir', {
|
||||
cwd: worktreePath,
|
||||
});
|
||||
const gitDir = path.resolve(worktreePath, gitDirRaw.trim());
|
||||
|
||||
const [rebaseMergeExists, rebaseApplyExists, mergeHeadExists, cherryPickHeadExists] =
|
||||
await Promise.all([
|
||||
fs
|
||||
.access(path.join(gitDir, 'rebase-merge'))
|
||||
.then(() => true)
|
||||
.catch(() => false),
|
||||
fs
|
||||
.access(path.join(gitDir, 'rebase-apply'))
|
||||
.then(() => true)
|
||||
.catch(() => false),
|
||||
fs
|
||||
.access(path.join(gitDir, 'MERGE_HEAD'))
|
||||
.then(() => true)
|
||||
.catch(() => false),
|
||||
fs
|
||||
.access(path.join(gitDir, 'CHERRY_PICK_HEAD'))
|
||||
.then(() => true)
|
||||
.catch(() => false),
|
||||
]);
|
||||
|
||||
if (rebaseMergeExists || rebaseApplyExists) return 'rebase';
|
||||
if (mergeHeadExists) return 'merge';
|
||||
if (cherryPickHeadExists) return 'cherry-pick';
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function createAbortOperationHandler(events: EventEmitter) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { worktreePath } = req.body as {
|
||||
worktreePath: string;
|
||||
};
|
||||
|
||||
if (!worktreePath) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'worktreePath is required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const resolvedWorktreePath = path.resolve(worktreePath);
|
||||
|
||||
// Detect what operation is in progress
|
||||
const operation = await detectOperation(resolvedWorktreePath);
|
||||
|
||||
if (!operation) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'No merge, rebase, or cherry-pick in progress',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Abort the operation
|
||||
let abortCommand: string;
|
||||
switch (operation) {
|
||||
case 'merge':
|
||||
abortCommand = 'git merge --abort';
|
||||
break;
|
||||
case 'rebase':
|
||||
abortCommand = 'git rebase --abort';
|
||||
break;
|
||||
case 'cherry-pick':
|
||||
abortCommand = 'git cherry-pick --abort';
|
||||
break;
|
||||
}
|
||||
|
||||
await execAsync(abortCommand, { cwd: resolvedWorktreePath });
|
||||
|
||||
// Emit event
|
||||
events.emit('conflict:aborted', {
|
||||
worktreePath: resolvedWorktreePath,
|
||||
operation,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
result: {
|
||||
operation,
|
||||
message: `${operation.charAt(0).toUpperCase() + operation.slice(1)} aborted successfully`,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Abort operation failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
151
apps/server/src/routes/worktree/routes/continue-operation.ts
Normal file
151
apps/server/src/routes/worktree/routes/continue-operation.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* POST /continue-operation endpoint - Continue an in-progress merge, rebase, or cherry-pick
|
||||
*
|
||||
* After conflicts have been resolved, this endpoint continues the operation.
|
||||
* For merge: performs git commit (merge is auto-committed after conflict resolution)
|
||||
* For rebase: runs git rebase --continue
|
||||
* For cherry-pick: runs git cherry-pick --continue
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import path from 'path';
|
||||
import * as fs from 'fs/promises';
|
||||
import { getErrorMessage, logError, execAsync } from '../common.js';
|
||||
import type { EventEmitter } from '../../../lib/events.js';
|
||||
|
||||
/**
|
||||
* Detect what type of conflict operation is currently in progress
|
||||
*/
|
||||
async function detectOperation(
|
||||
worktreePath: string
|
||||
): Promise<'merge' | 'rebase' | 'cherry-pick' | null> {
|
||||
try {
|
||||
const { stdout: gitDirRaw } = await execAsync('git rev-parse --git-dir', {
|
||||
cwd: worktreePath,
|
||||
});
|
||||
const gitDir = path.resolve(worktreePath, gitDirRaw.trim());
|
||||
|
||||
const [rebaseMergeExists, rebaseApplyExists, mergeHeadExists, cherryPickHeadExists] =
|
||||
await Promise.all([
|
||||
fs
|
||||
.access(path.join(gitDir, 'rebase-merge'))
|
||||
.then(() => true)
|
||||
.catch(() => false),
|
||||
fs
|
||||
.access(path.join(gitDir, 'rebase-apply'))
|
||||
.then(() => true)
|
||||
.catch(() => false),
|
||||
fs
|
||||
.access(path.join(gitDir, 'MERGE_HEAD'))
|
||||
.then(() => true)
|
||||
.catch(() => false),
|
||||
fs
|
||||
.access(path.join(gitDir, 'CHERRY_PICK_HEAD'))
|
||||
.then(() => true)
|
||||
.catch(() => false),
|
||||
]);
|
||||
|
||||
if (rebaseMergeExists || rebaseApplyExists) return 'rebase';
|
||||
if (mergeHeadExists) return 'merge';
|
||||
if (cherryPickHeadExists) return 'cherry-pick';
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there are still unmerged paths (unresolved conflicts)
|
||||
*/
|
||||
async function hasUnmergedPaths(worktreePath: string): Promise<boolean> {
|
||||
try {
|
||||
const { stdout: statusOutput } = await execAsync('git status --porcelain', {
|
||||
cwd: worktreePath,
|
||||
});
|
||||
return statusOutput.split('\n').some((line) => /^(UU|AA|DD|AU|UA|DU|UD)/.test(line));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function createContinueOperationHandler(events: EventEmitter) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { worktreePath } = req.body as {
|
||||
worktreePath: string;
|
||||
};
|
||||
|
||||
if (!worktreePath) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'worktreePath is required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const resolvedWorktreePath = path.resolve(worktreePath);
|
||||
|
||||
// Detect what operation is in progress
|
||||
const operation = await detectOperation(resolvedWorktreePath);
|
||||
|
||||
if (!operation) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'No merge, rebase, or cherry-pick in progress',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for unresolved conflicts
|
||||
if (await hasUnmergedPaths(resolvedWorktreePath)) {
|
||||
res.status(409).json({
|
||||
success: false,
|
||||
error:
|
||||
'There are still unresolved conflicts. Please resolve all conflicts before continuing.',
|
||||
hasUnresolvedConflicts: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Stage all resolved files first
|
||||
await execAsync('git add -A', { cwd: resolvedWorktreePath });
|
||||
|
||||
// Continue the operation
|
||||
let continueCommand: string;
|
||||
switch (operation) {
|
||||
case 'merge':
|
||||
// For merge, we need to commit after resolving conflicts
|
||||
continueCommand = 'git commit --no-edit';
|
||||
break;
|
||||
case 'rebase':
|
||||
continueCommand = 'git rebase --continue';
|
||||
break;
|
||||
case 'cherry-pick':
|
||||
continueCommand = 'git cherry-pick --continue';
|
||||
break;
|
||||
}
|
||||
|
||||
await execAsync(continueCommand, {
|
||||
cwd: resolvedWorktreePath,
|
||||
env: { ...process.env, GIT_EDITOR: 'true' }, // Prevent editor from opening
|
||||
});
|
||||
|
||||
// Emit event
|
||||
events.emit('conflict:resolved', {
|
||||
worktreePath: resolvedWorktreePath,
|
||||
operation,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
result: {
|
||||
operation,
|
||||
message: `${operation.charAt(0).toUpperCase() + operation.slice(1)} continued successfully`,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Continue operation failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -58,6 +58,88 @@ interface WorktreeInfo {
|
||||
hasChanges?: boolean;
|
||||
changedFilesCount?: number;
|
||||
pr?: WorktreePRInfo; // PR info if a PR has been created for this branch
|
||||
/** Whether a merge or rebase is in progress (has conflicts) */
|
||||
hasConflicts?: boolean;
|
||||
/** Type of conflict operation in progress */
|
||||
conflictType?: 'merge' | 'rebase' | 'cherry-pick';
|
||||
/** List of files with conflicts */
|
||||
conflictFiles?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if a merge, rebase, or cherry-pick is in progress for a worktree.
|
||||
* Checks for the presence of state files/directories that git creates
|
||||
* during these operations.
|
||||
*/
|
||||
async function detectConflictState(worktreePath: string): Promise<{
|
||||
hasConflicts: boolean;
|
||||
conflictType?: 'merge' | 'rebase' | 'cherry-pick';
|
||||
conflictFiles?: string[];
|
||||
}> {
|
||||
try {
|
||||
// Find the canonical .git directory for this worktree
|
||||
const { stdout: gitDirRaw } = await execAsync('git rev-parse --git-dir', {
|
||||
cwd: worktreePath,
|
||||
});
|
||||
const gitDir = path.resolve(worktreePath, gitDirRaw.trim());
|
||||
|
||||
// Check for merge, rebase, and cherry-pick state files/directories
|
||||
const [mergeHeadExists, rebaseMergeExists, rebaseApplyExists, cherryPickHeadExists] =
|
||||
await Promise.all([
|
||||
secureFs
|
||||
.access(path.join(gitDir, 'MERGE_HEAD'))
|
||||
.then(() => true)
|
||||
.catch(() => false),
|
||||
secureFs
|
||||
.access(path.join(gitDir, 'rebase-merge'))
|
||||
.then(() => true)
|
||||
.catch(() => false),
|
||||
secureFs
|
||||
.access(path.join(gitDir, 'rebase-apply'))
|
||||
.then(() => true)
|
||||
.catch(() => false),
|
||||
secureFs
|
||||
.access(path.join(gitDir, 'CHERRY_PICK_HEAD'))
|
||||
.then(() => true)
|
||||
.catch(() => false),
|
||||
]);
|
||||
|
||||
let conflictType: 'merge' | 'rebase' | 'cherry-pick' | undefined;
|
||||
if (rebaseMergeExists || rebaseApplyExists) {
|
||||
conflictType = 'rebase';
|
||||
} else if (mergeHeadExists) {
|
||||
conflictType = 'merge';
|
||||
} else if (cherryPickHeadExists) {
|
||||
conflictType = 'cherry-pick';
|
||||
}
|
||||
|
||||
if (!conflictType) {
|
||||
return { hasConflicts: false };
|
||||
}
|
||||
|
||||
// Get list of conflicted files using machine-readable git status
|
||||
let conflictFiles: string[] = [];
|
||||
try {
|
||||
const { stdout: statusOutput } = await execAsync('git diff --name-only --diff-filter=U', {
|
||||
cwd: worktreePath,
|
||||
});
|
||||
conflictFiles = statusOutput
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter((f) => f.trim().length > 0);
|
||||
} catch {
|
||||
// Fall back to empty list if diff fails
|
||||
}
|
||||
|
||||
return {
|
||||
hasConflicts: true,
|
||||
conflictType,
|
||||
conflictFiles,
|
||||
};
|
||||
} catch {
|
||||
// If anything fails, assume no conflicts
|
||||
return { hasConflicts: false };
|
||||
}
|
||||
}
|
||||
|
||||
async function getCurrentBranch(cwd: string): Promise<string> {
|
||||
@@ -373,7 +455,7 @@ export function createListHandler() {
|
||||
// Read all worktree metadata to get PR info
|
||||
const allMetadata = await readAllWorktreeMetadata(projectPath);
|
||||
|
||||
// If includeDetails is requested, fetch change status for each worktree
|
||||
// If includeDetails is requested, fetch change status and conflict state for each worktree
|
||||
if (includeDetails) {
|
||||
for (const worktree of worktrees) {
|
||||
try {
|
||||
@@ -390,6 +472,18 @@ export function createListHandler() {
|
||||
worktree.hasChanges = false;
|
||||
worktree.changedFilesCount = 0;
|
||||
}
|
||||
|
||||
// Detect merge/rebase/cherry-pick in progress
|
||||
try {
|
||||
const conflictState = await detectConflictState(worktree.path);
|
||||
if (conflictState.hasConflicts) {
|
||||
worktree.hasConflicts = true;
|
||||
worktree.conflictType = conflictState.conflictType;
|
||||
worktree.conflictFiles = conflictState.conflictFiles;
|
||||
}
|
||||
} catch {
|
||||
// Ignore conflict detection errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
69
apps/server/src/routes/worktree/routes/stage-files.ts
Normal file
69
apps/server/src/routes/worktree/routes/stage-files.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* POST /stage-files endpoint - Stage or unstage files in a worktree
|
||||
*
|
||||
* Supports two operations:
|
||||
* 1. Stage files: `git add <files>` (adds files to the staging area)
|
||||
* 2. Unstage files: `git reset HEAD -- <files>` (removes files from staging area)
|
||||
*
|
||||
* Note: Git repository validation (isGitRepo) is handled by
|
||||
* the requireGitRepoOnly middleware in index.ts
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
import { execGitCommand } from '../../../lib/git.js';
|
||||
|
||||
export function createStageFilesHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { worktreePath, files, operation } = req.body as {
|
||||
worktreePath: string;
|
||||
files: string[];
|
||||
operation: 'stage' | 'unstage';
|
||||
};
|
||||
|
||||
if (!worktreePath) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'worktreePath required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!files || files.length === 0) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'files array required and must not be empty',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (operation !== 'stage' && operation !== 'unstage') {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'operation must be "stage" or "unstage"',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (operation === 'stage') {
|
||||
// Stage the specified files
|
||||
await execGitCommand(['add', '--', ...files], worktreePath);
|
||||
} else {
|
||||
// Unstage the specified files
|
||||
await execGitCommand(['reset', 'HEAD', '--', ...files], worktreePath);
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
result: {
|
||||
operation,
|
||||
filesCount: files.length,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, `${(req.body as { operation?: string })?.operation ?? 'stage'} files failed`);
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -54,7 +54,7 @@ export function createStashApplyHandler(events: EventEmitter) {
|
||||
const result = await applyOrPop(worktreePath, idx, { pop }, events);
|
||||
|
||||
if (!result.success) {
|
||||
logError(new Error(result.error ?? 'Stash apply failed'), 'Stash apply failed');
|
||||
// applyOrPop already logs the error internally via logError — no need to double-log here
|
||||
res.status(500).json({ success: false, error: result.error });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -42,6 +42,27 @@ export class AgentExecutor {
|
||||
private static readonly WRITE_DEBOUNCE_MS = 500;
|
||||
private static readonly STREAM_HEARTBEAT_MS = 15_000;
|
||||
|
||||
/**
|
||||
* Sanitize a provider error value into clean text.
|
||||
* Coalesces to string, removes ANSI codes, strips leading "Error:" prefix,
|
||||
* trims, and returns 'Unknown error' when empty.
|
||||
*/
|
||||
private static sanitizeProviderError(input: string | { error?: string } | undefined): string {
|
||||
let raw: string;
|
||||
if (typeof input === 'string') {
|
||||
raw = input;
|
||||
} else if (input && typeof input === 'object' && typeof input.error === 'string') {
|
||||
raw = input.error;
|
||||
} else {
|
||||
raw = '';
|
||||
}
|
||||
const cleaned = raw
|
||||
.replace(/\x1b\[[0-9;]*m/g, '')
|
||||
.replace(/^Error:\s*/i, '')
|
||||
.trim();
|
||||
return cleaned || 'Unknown error';
|
||||
}
|
||||
|
||||
constructor(
|
||||
private eventBus: TypedEventBus,
|
||||
private featureStateManager: FeatureStateManager,
|
||||
@@ -255,15 +276,7 @@ export class AgentExecutor {
|
||||
}
|
||||
}
|
||||
} else if (msg.type === 'error') {
|
||||
// Clean the error: strip ANSI codes and the redundant "Error: " prefix
|
||||
// that CLI providers add. Without this, wrapping in new Error() produces
|
||||
// "Error: Error: Session not found" (double-prefixed).
|
||||
const cleanedError =
|
||||
(msg.error || 'Unknown error')
|
||||
.replace(/\x1b\[[0-9;]*m/g, '')
|
||||
.replace(/^Error:\s*/i, '')
|
||||
.trim() || 'Unknown error';
|
||||
throw new Error(cleanedError);
|
||||
throw new Error(AgentExecutor.sanitizeProviderError(msg.error));
|
||||
} else if (msg.type === 'result' && msg.subtype === 'success') scheduleWrite();
|
||||
}
|
||||
await writeToFile();
|
||||
|
||||
@@ -96,6 +96,20 @@ export class AgentService {
|
||||
await secureFs.mkdir(this.stateDir, { recursive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect provider-side session errors (session not found, expired, etc.).
|
||||
* Used to decide whether to clear a stale sdkSessionId.
|
||||
*/
|
||||
private isStaleSessionError(rawErrorText: string): boolean {
|
||||
const errorLower = rawErrorText.toLowerCase();
|
||||
return (
|
||||
errorLower.includes('session not found') ||
|
||||
errorLower.includes('session expired') ||
|
||||
errorLower.includes('invalid session') ||
|
||||
errorLower.includes('no such session')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start or resume a conversation
|
||||
*/
|
||||
@@ -195,7 +209,15 @@ export class AgentService {
|
||||
const resolvedWorkingDirectory = path.resolve(effectiveWorkingDirectory);
|
||||
|
||||
// Validate that the working directory is allowed using centralized validation
|
||||
validateWorkingDirectory(resolvedWorkingDirectory);
|
||||
try {
|
||||
validateWorkingDirectory(resolvedWorkingDirectory);
|
||||
} catch (validationError) {
|
||||
this.logger.warn(
|
||||
`Session "${sessionId}": working directory "${resolvedWorkingDirectory}" is not allowed — ` +
|
||||
`returning null so callers treat it as a missing session. Error: ${(validationError as Error).message}`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Load persisted queue
|
||||
const promptQueue = await this.loadQueueState(sessionId);
|
||||
@@ -411,7 +433,7 @@ export class AgentService {
|
||||
|
||||
// When using a custom provider (GLM, MiniMax), use resolved Claude model for SDK config
|
||||
// (thinking level budgets, allowedTools) but we MUST pass the provider's model ID
|
||||
// (e.g. "GLM-4.7") to the API - not "claude-sonnet-4-20250514" which causes "model not found"
|
||||
// (e.g. "GLM-4.7") to the API - not "claude-sonnet-4-6" which causes "model not found"
|
||||
const modelForSdk = providerResolvedModel || model;
|
||||
const sessionModelForSdk = providerResolvedModel ? undefined : session.model;
|
||||
|
||||
@@ -616,14 +638,7 @@ export class AgentService {
|
||||
// sdkSessionId so the next attempt starts a fresh provider session.
|
||||
// This handles providers that don't have built-in session recovery
|
||||
// (unlike OpenCode which auto-retries without the session flag).
|
||||
const errorLower = rawErrorText.toLowerCase();
|
||||
if (
|
||||
session.sdkSessionId &&
|
||||
(errorLower.includes('session not found') ||
|
||||
errorLower.includes('session expired') ||
|
||||
errorLower.includes('invalid session') ||
|
||||
errorLower.includes('no such session'))
|
||||
) {
|
||||
if (session.sdkSessionId && this.isStaleSessionError(rawErrorText)) {
|
||||
this.logger.info(
|
||||
`Clearing stale sdkSessionId for session ${sessionId} after provider session error`
|
||||
);
|
||||
@@ -699,13 +714,7 @@ export class AgentService {
|
||||
|
||||
// Check if the thrown error is a provider-side session error.
|
||||
// Clear the stale sdkSessionId so the next retry starts fresh.
|
||||
if (
|
||||
session.sdkSessionId &&
|
||||
(thrownErrorMsg.includes('session not found') ||
|
||||
thrownErrorMsg.includes('session expired') ||
|
||||
thrownErrorMsg.includes('invalid session') ||
|
||||
thrownErrorMsg.includes('no such session'))
|
||||
) {
|
||||
if (session.sdkSessionId && this.isStaleSessionError(rawThrownMsg)) {
|
||||
this.logger.info(
|
||||
`Clearing stale sdkSessionId for session ${sessionId} after thrown session error`
|
||||
);
|
||||
|
||||
@@ -208,7 +208,7 @@ export class AutoModeServiceFacade {
|
||||
model?: string,
|
||||
opts?: Record<string, unknown>
|
||||
) => {
|
||||
const resolvedModel = model || 'claude-sonnet-4-20250514';
|
||||
const resolvedModel = model || 'claude-sonnet-4-6';
|
||||
const provider = ProviderFactory.getProviderForModel(resolvedModel);
|
||||
const effectiveBareModel = stripProviderPrefix(resolvedModel);
|
||||
|
||||
@@ -258,7 +258,7 @@ export class AutoModeServiceFacade {
|
||||
featureStateManager.saveFeatureSummary(projPath, fId, summary),
|
||||
buildTaskPrompt: (task, allTasks, taskIndex, _planContent, template, feedback) => {
|
||||
let taskPrompt = template
|
||||
.replace(/\{\{taskName\}\}/g, task.description)
|
||||
.replace(/\{\{taskName\}\}/g, task.description || `Task ${task.id}`)
|
||||
.replace(/\{\{taskIndex\}\}/g, String(taskIndex + 1))
|
||||
.replace(/\{\{totalTasks\}\}/g, String(allTasks.length))
|
||||
.replace(/\{\{taskDescription\}\}/g, task.description || `Task ${task.id}`);
|
||||
@@ -336,7 +336,7 @@ export class AutoModeServiceFacade {
|
||||
branchName?: string | null;
|
||||
}
|
||||
) => {
|
||||
const resolvedModel = model || 'claude-sonnet-4-20250514';
|
||||
const resolvedModel = model || 'claude-sonnet-4-6';
|
||||
const provider = ProviderFactory.getProviderForModel(resolvedModel);
|
||||
const effectiveBareModel = stripProviderPrefix(resolvedModel);
|
||||
|
||||
@@ -385,7 +385,7 @@ export class AutoModeServiceFacade {
|
||||
featureStateManager.saveFeatureSummary(projPath, fId, summary),
|
||||
buildTaskPrompt: (task, allTasks, taskIndex, planContent, template, feedback) => {
|
||||
let taskPrompt = template
|
||||
.replace(/\{\{taskName\}\}/g, task.description)
|
||||
.replace(/\{\{taskName\}\}/g, task.description || `Task ${task.id}`)
|
||||
.replace(/\{\{taskIndex\}\}/g, String(taskIndex + 1))
|
||||
.replace(/\{\{totalTasks\}\}/g, String(allTasks.length))
|
||||
.replace(/\{\{taskDescription\}\}/g, task.description || `Task ${task.id}`);
|
||||
|
||||
@@ -35,7 +35,10 @@ export interface MergeServiceResult {
|
||||
*/
|
||||
function isValidBranchName(name: string): boolean {
|
||||
// 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;
|
||||
// Reject names containing '..' to prevent git ref traversal
|
||||
return (
|
||||
/^[a-zA-Z0-9._/][a-zA-Z0-9._\-/]*$/.test(name) && name.length < 250 && !name.includes('..')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
*/
|
||||
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { execGitCommand } from '../lib/git.js';
|
||||
import { execGitCommand, execGitCommandWithLockRetry } from '../lib/git.js';
|
||||
import { getErrorMessage } from '../routes/worktree/common.js';
|
||||
|
||||
const logger = createLogger('PullService');
|
||||
@@ -106,7 +106,10 @@ export async function getLocalChanges(
|
||||
*/
|
||||
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);
|
||||
await execGitCommandWithLockRetry(
|
||||
['stash', 'push', '--include-untracked', '-m', stashMessage],
|
||||
worktreePath
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import type { EventEmitter } from '../lib/events.js';
|
||||
import { execGitCommand } from '../lib/git.js';
|
||||
import { execGitCommand, execGitCommandWithLockRetry } from '../lib/git.js';
|
||||
import { getErrorMessage, logError } from '../routes/worktree/common.js';
|
||||
|
||||
const logger = createLogger('StashService');
|
||||
@@ -105,6 +105,46 @@ function isConflictOutput(output: string): boolean {
|
||||
return output.includes('CONFLICT') || output.includes('Merge conflict');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a conflict result from stash apply/pop, emit events, and return.
|
||||
* Extracted to avoid duplicating conflict handling in the try and catch paths.
|
||||
*/
|
||||
async function handleStashConflicts(
|
||||
worktreePath: string,
|
||||
stashIndex: number,
|
||||
operation: 'apply' | 'pop',
|
||||
events?: EventEmitter
|
||||
): Promise<StashApplyResult> {
|
||||
const conflictFiles = await getConflictedFiles(worktreePath);
|
||||
|
||||
events?.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.`,
|
||||
};
|
||||
|
||||
events?.emit('stash:success', {
|
||||
worktreePath,
|
||||
stashIndex,
|
||||
operation,
|
||||
hasConflicts: true,
|
||||
conflictFiles,
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Main Service Function
|
||||
// ============================================================================
|
||||
@@ -164,34 +204,7 @@ export async function applyOrPop(
|
||||
|
||||
// 4. Check if the error is a conflict
|
||||
if (isConflictOutput(combinedOutput)) {
|
||||
const conflictFiles = await getConflictedFiles(worktreePath);
|
||||
|
||||
events?.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.`,
|
||||
};
|
||||
|
||||
events?.emit('stash:success', {
|
||||
worktreePath,
|
||||
stashIndex,
|
||||
operation,
|
||||
hasConflicts: true,
|
||||
conflictFiles,
|
||||
});
|
||||
|
||||
return result;
|
||||
return handleStashConflicts(worktreePath, stashIndex, operation, events);
|
||||
}
|
||||
|
||||
// 5. Non-conflict git error – re-throw so the outer catch logs and handles it
|
||||
@@ -205,34 +218,7 @@ export async function applyOrPop(
|
||||
events?.emit('stash:progress', { worktreePath, stashIndex, operation, output: combinedOutput });
|
||||
|
||||
if (isConflictOutput(combinedOutput)) {
|
||||
const conflictFiles = await getConflictedFiles(worktreePath);
|
||||
|
||||
events?.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.`,
|
||||
};
|
||||
|
||||
events?.emit('stash:success', {
|
||||
worktreePath,
|
||||
stashIndex,
|
||||
operation,
|
||||
hasConflicts: true,
|
||||
conflictFiles,
|
||||
});
|
||||
|
||||
return result;
|
||||
return handleStashConflicts(worktreePath, stashIndex, operation, events);
|
||||
}
|
||||
|
||||
// 7. Clean success
|
||||
@@ -296,17 +282,20 @@ export async function applyOrPop(
|
||||
*/
|
||||
export async function pushStash(
|
||||
worktreePath: string,
|
||||
options?: { message?: string; files?: string[] }
|
||||
options?: { message?: string; files?: string[] },
|
||||
events?: EventEmitter
|
||||
): Promise<StashPushResult> {
|
||||
const message = options?.message;
|
||||
const files = options?.files;
|
||||
|
||||
logger.info(`[StashService] push stash in ${worktreePath}`);
|
||||
events?.emit('stash:start', { worktreePath, operation: 'push' });
|
||||
|
||||
// 1. Check for any changes to stash
|
||||
const status = await execGitCommand(['status', '--porcelain'], worktreePath);
|
||||
|
||||
if (!status.trim()) {
|
||||
events?.emit('stash:success', { worktreePath, operation: 'push', stashed: false });
|
||||
return {
|
||||
success: true,
|
||||
stashed: false,
|
||||
@@ -326,13 +315,20 @@ export async function pushStash(
|
||||
args.push(...files);
|
||||
}
|
||||
|
||||
// 3. Execute stash push
|
||||
await execGitCommand(args, worktreePath);
|
||||
// 3. Execute stash push (with automatic index.lock cleanup and retry)
|
||||
await execGitCommandWithLockRetry(args, worktreePath);
|
||||
|
||||
// 4. Get current branch name
|
||||
const branchOutput = await execGitCommand(['rev-parse', '--abbrev-ref', 'HEAD'], worktreePath);
|
||||
const branchName = branchOutput.trim();
|
||||
|
||||
events?.emit('stash:success', {
|
||||
worktreePath,
|
||||
operation: 'push',
|
||||
stashed: true,
|
||||
branch: branchName,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
stashed: true,
|
||||
@@ -445,14 +441,18 @@ export async function listStash(worktreePath: string): Promise<StashListResult>
|
||||
*/
|
||||
export async function dropStash(
|
||||
worktreePath: string,
|
||||
stashIndex: number
|
||||
stashIndex: number,
|
||||
events?: EventEmitter
|
||||
): Promise<StashDropResult> {
|
||||
const stashRef = `stash@{${stashIndex}}`;
|
||||
|
||||
logger.info(`[StashService] drop ${stashRef} in ${worktreePath}`);
|
||||
events?.emit('stash:start', { worktreePath, stashIndex, stashRef, operation: 'drop' });
|
||||
|
||||
await execGitCommand(['stash', 'drop', stashRef], worktreePath);
|
||||
|
||||
events?.emit('stash:success', { worktreePath, stashIndex, stashRef, operation: 'drop' });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
dropped: true,
|
||||
|
||||
@@ -16,9 +16,8 @@
|
||||
* rebase-service.ts.
|
||||
*/
|
||||
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { execGitCommand } from '../lib/git.js';
|
||||
import { getErrorMessage } from '../routes/worktree/common.js';
|
||||
import { createLogger, getErrorMessage } from '@automaker/utils';
|
||||
import { execGitCommand, execGitCommandWithLockRetry } from '../lib/git.js';
|
||||
import type { EventEmitter } from '../lib/events.js';
|
||||
|
||||
const logger = createLogger('WorktreeBranchService');
|
||||
@@ -66,7 +65,11 @@ async function hasAnyChanges(cwd: string): Promise<boolean> {
|
||||
return true;
|
||||
});
|
||||
return lines.length > 0;
|
||||
} catch {
|
||||
} catch (err) {
|
||||
logger.error('hasAnyChanges: execGitCommand failed — returning false', {
|
||||
cwd,
|
||||
error: getErrorMessage(err),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -78,24 +81,11 @@ async function hasAnyChanges(cwd: string): Promise<boolean> {
|
||||
*/
|
||||
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;
|
||||
// Stash including untracked files — a successful execGitCommand is proof
|
||||
// the stash was created. No need for a post-push listing which can throw
|
||||
// and incorrectly report a failed stash.
|
||||
await execGitCommandWithLockRetry(['stash', 'push', '--include-untracked', '-m', message], cwd);
|
||||
return true;
|
||||
} catch (error) {
|
||||
const errorMsg = getErrorMessage(error);
|
||||
|
||||
@@ -127,11 +117,8 @@ 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 };
|
||||
}
|
||||
await execGitCommand(['stash', 'pop'], cwd);
|
||||
// If execGitCommand succeeds (zero exit code), there are no conflicts
|
||||
return { success: true, hasConflicts: false };
|
||||
} catch (error) {
|
||||
const errorMsg = getErrorMessage(error);
|
||||
@@ -274,11 +261,9 @@ export async function performSwitchBranch(
|
||||
};
|
||||
}
|
||||
|
||||
// 4. Check if target branch exists (locally or as remote ref)
|
||||
// 4. Check if target branch exists as a local branch
|
||||
if (!isRemote) {
|
||||
try {
|
||||
await execGitCommand(['rev-parse', '--verify', branchName], worktreePath);
|
||||
} catch {
|
||||
if (!(await localBranchExists(worktreePath, branchName))) {
|
||||
events?.emit('switch:error', {
|
||||
worktreePath,
|
||||
branchName,
|
||||
|
||||
@@ -50,15 +50,15 @@ describe('sdk-options.ts', () => {
|
||||
describe('getModelForUseCase', () => {
|
||||
it('should return explicit model when provided', async () => {
|
||||
const { getModelForUseCase } = await import('@/lib/sdk-options.js');
|
||||
const result = getModelForUseCase('spec', 'claude-sonnet-4-20250514');
|
||||
expect(result).toBe('claude-sonnet-4-20250514');
|
||||
const result = getModelForUseCase('spec', 'claude-sonnet-4-6');
|
||||
expect(result).toBe('claude-sonnet-4-6');
|
||||
});
|
||||
|
||||
it('should use environment variable for spec model', async () => {
|
||||
process.env.AUTOMAKER_MODEL_SPEC = 'claude-sonnet-4-20250514';
|
||||
process.env.AUTOMAKER_MODEL_SPEC = 'claude-sonnet-4-6';
|
||||
const { getModelForUseCase } = await import('@/lib/sdk-options.js');
|
||||
const result = getModelForUseCase('spec');
|
||||
expect(result).toBe('claude-sonnet-4-20250514');
|
||||
expect(result).toBe('claude-sonnet-4-6');
|
||||
});
|
||||
|
||||
it('should use default model for spec when no override', async () => {
|
||||
@@ -71,10 +71,10 @@ describe('sdk-options.ts', () => {
|
||||
|
||||
it('should fall back to AUTOMAKER_MODEL_DEFAULT', async () => {
|
||||
delete process.env.AUTOMAKER_MODEL_SPEC;
|
||||
process.env.AUTOMAKER_MODEL_DEFAULT = 'claude-sonnet-4-20250514';
|
||||
process.env.AUTOMAKER_MODEL_DEFAULT = 'claude-sonnet-4-6';
|
||||
const { getModelForUseCase } = await import('@/lib/sdk-options.js');
|
||||
const result = getModelForUseCase('spec');
|
||||
expect(result).toBe('claude-sonnet-4-20250514');
|
||||
expect(result).toBe('claude-sonnet-4-6');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -203,10 +203,10 @@ describe('sdk-options.ts', () => {
|
||||
|
||||
const options = createChatOptions({
|
||||
cwd: '/test/path',
|
||||
sessionModel: 'claude-sonnet-4-20250514',
|
||||
sessionModel: 'claude-sonnet-4-6',
|
||||
});
|
||||
|
||||
expect(options.model).toBe('claude-sonnet-4-20250514');
|
||||
expect(options.model).toBe('claude-sonnet-4-6');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -360,10 +360,10 @@ describe('claude-provider.ts', () => {
|
||||
});
|
||||
|
||||
describe('getAvailableModels', () => {
|
||||
it('should return 4 Claude models', () => {
|
||||
it('should return 5 Claude models', () => {
|
||||
const models = provider.getAvailableModels();
|
||||
|
||||
expect(models).toHaveLength(4);
|
||||
expect(models).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('should include Claude Opus 4.6', () => {
|
||||
@@ -375,12 +375,12 @@ describe('claude-provider.ts', () => {
|
||||
expect(opus?.provider).toBe('anthropic');
|
||||
});
|
||||
|
||||
it('should include Claude Sonnet 4', () => {
|
||||
it('should include Claude Sonnet 4.6', () => {
|
||||
const models = provider.getAvailableModels();
|
||||
|
||||
const sonnet = models.find((m) => m.id === 'claude-sonnet-4-20250514');
|
||||
const sonnet = models.find((m) => m.id === 'claude-sonnet-4-6');
|
||||
expect(sonnet).toBeDefined();
|
||||
expect(sonnet?.name).toBe('Claude Sonnet 4');
|
||||
expect(sonnet?.name).toBe('Claude Sonnet 4.6');
|
||||
});
|
||||
|
||||
it('should include Claude 3.5 Sonnet', () => {
|
||||
|
||||
@@ -69,19 +69,19 @@ describe('opencode-provider.ts', () => {
|
||||
|
||||
it('should include free tier GLM model', () => {
|
||||
const models = provider.getAvailableModels();
|
||||
const glm = models.find((m) => m.id === 'opencode/glm-4.7-free');
|
||||
const glm = models.find((m) => m.id === 'opencode/glm-5-free');
|
||||
|
||||
expect(glm).toBeDefined();
|
||||
expect(glm?.name).toBe('GLM 4.7 Free');
|
||||
expect(glm?.name).toBe('GLM 5 Free');
|
||||
expect(glm?.tier).toBe('basic');
|
||||
});
|
||||
|
||||
it('should include free tier MiniMax model', () => {
|
||||
const models = provider.getAvailableModels();
|
||||
const minimax = models.find((m) => m.id === 'opencode/minimax-m2.1-free');
|
||||
const minimax = models.find((m) => m.id === 'opencode/minimax-m2.5-free');
|
||||
|
||||
expect(minimax).toBeDefined();
|
||||
expect(minimax?.name).toBe('MiniMax M2.1 Free');
|
||||
expect(minimax?.name).toBe('MiniMax M2.5 Free');
|
||||
expect(minimax?.tier).toBe('basic');
|
||||
});
|
||||
|
||||
|
||||
@@ -59,8 +59,8 @@ describe('provider-factory.ts', () => {
|
||||
expect(provider).toBeInstanceOf(ClaudeProvider);
|
||||
});
|
||||
|
||||
it('should return ClaudeProvider for claude-sonnet-4-20250514', () => {
|
||||
const provider = ProviderFactory.getProviderForModel('claude-sonnet-4-20250514');
|
||||
it('should return ClaudeProvider for claude-sonnet-4-6', () => {
|
||||
const provider = ProviderFactory.getProviderForModel('claude-sonnet-4-6');
|
||||
expect(provider).toBeInstanceOf(ClaudeProvider);
|
||||
});
|
||||
|
||||
|
||||
@@ -129,7 +129,7 @@ describe('AgentExecutor', () => {
|
||||
projectPath: '/project',
|
||||
abortController: new AbortController(),
|
||||
provider: {} as BaseProvider,
|
||||
effectiveBareModel: 'claude-sonnet-4-20250514',
|
||||
effectiveBareModel: 'claude-sonnet-4-6',
|
||||
};
|
||||
expect(options.featureId).toBe('test-feature');
|
||||
});
|
||||
@@ -166,7 +166,7 @@ describe('AgentExecutor', () => {
|
||||
projectPath: '/test/project',
|
||||
abortController: new AbortController(),
|
||||
provider: {} as BaseProvider,
|
||||
effectiveBareModel: 'claude-sonnet-4-20250514',
|
||||
effectiveBareModel: 'claude-sonnet-4-6',
|
||||
};
|
||||
|
||||
expect(options.workDir).toBe('/test/workdir');
|
||||
@@ -174,7 +174,7 @@ describe('AgentExecutor', () => {
|
||||
expect(options.prompt).toBe('Test prompt');
|
||||
expect(options.projectPath).toBe('/test/project');
|
||||
expect(options.abortController).toBeInstanceOf(AbortController);
|
||||
expect(options.effectiveBareModel).toBe('claude-sonnet-4-20250514');
|
||||
expect(options.effectiveBareModel).toBe('claude-sonnet-4-6');
|
||||
});
|
||||
|
||||
it('should accept optional options', () => {
|
||||
@@ -185,10 +185,10 @@ describe('AgentExecutor', () => {
|
||||
projectPath: '/test/project',
|
||||
abortController: new AbortController(),
|
||||
provider: {} as BaseProvider,
|
||||
effectiveBareModel: 'claude-sonnet-4-20250514',
|
||||
effectiveBareModel: 'claude-sonnet-4-6',
|
||||
// Optional fields
|
||||
imagePaths: ['/image1.png', '/image2.png'],
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
model: 'claude-sonnet-4-6',
|
||||
planningMode: 'spec',
|
||||
requirePlanApproval: true,
|
||||
previousContent: 'Previous content',
|
||||
@@ -419,7 +419,7 @@ describe('AgentExecutor', () => {
|
||||
projectPath: '/project',
|
||||
abortController,
|
||||
provider: mockProvider,
|
||||
effectiveBareModel: 'claude-sonnet-4-20250514',
|
||||
effectiveBareModel: 'claude-sonnet-4-6',
|
||||
planningMode: 'skip',
|
||||
};
|
||||
|
||||
@@ -461,7 +461,7 @@ describe('AgentExecutor', () => {
|
||||
projectPath: '/project',
|
||||
abortController: new AbortController(),
|
||||
provider: mockProvider,
|
||||
effectiveBareModel: 'claude-sonnet-4-20250514',
|
||||
effectiveBareModel: 'claude-sonnet-4-6',
|
||||
previousContent: 'Previous context from earlier session',
|
||||
};
|
||||
|
||||
@@ -507,7 +507,7 @@ describe('AgentExecutor', () => {
|
||||
projectPath: '/project',
|
||||
abortController: new AbortController(),
|
||||
provider: mockProvider,
|
||||
effectiveBareModel: 'claude-sonnet-4-20250514',
|
||||
effectiveBareModel: 'claude-sonnet-4-6',
|
||||
planningMode: 'skip', // No spec detection in skip mode
|
||||
};
|
||||
|
||||
@@ -558,7 +558,7 @@ describe('AgentExecutor', () => {
|
||||
projectPath: '/project',
|
||||
abortController: new AbortController(),
|
||||
provider: mockProvider,
|
||||
effectiveBareModel: 'claude-sonnet-4-20250514',
|
||||
effectiveBareModel: 'claude-sonnet-4-6',
|
||||
planningMode: 'skip',
|
||||
};
|
||||
|
||||
@@ -618,7 +618,7 @@ describe('AgentExecutor', () => {
|
||||
projectPath: '/project',
|
||||
abortController: new AbortController(),
|
||||
provider: mockProvider,
|
||||
effectiveBareModel: 'claude-sonnet-4-20250514',
|
||||
effectiveBareModel: 'claude-sonnet-4-6',
|
||||
planningMode: 'skip',
|
||||
};
|
||||
|
||||
@@ -671,7 +671,7 @@ describe('AgentExecutor', () => {
|
||||
projectPath: '/project',
|
||||
abortController: new AbortController(),
|
||||
provider: mockProvider,
|
||||
effectiveBareModel: 'claude-sonnet-4-20250514',
|
||||
effectiveBareModel: 'claude-sonnet-4-6',
|
||||
planningMode: 'skip',
|
||||
};
|
||||
|
||||
@@ -712,7 +712,7 @@ describe('AgentExecutor', () => {
|
||||
projectPath: '/project',
|
||||
abortController: new AbortController(),
|
||||
provider: mockProvider,
|
||||
effectiveBareModel: 'claude-sonnet-4-20250514',
|
||||
effectiveBareModel: 'claude-sonnet-4-6',
|
||||
planningMode: 'skip',
|
||||
};
|
||||
|
||||
@@ -763,7 +763,7 @@ describe('AgentExecutor', () => {
|
||||
projectPath: '/project',
|
||||
abortController: new AbortController(),
|
||||
provider: mockProvider,
|
||||
effectiveBareModel: 'claude-sonnet-4-20250514',
|
||||
effectiveBareModel: 'claude-sonnet-4-6',
|
||||
planningMode: 'skip',
|
||||
};
|
||||
|
||||
@@ -810,7 +810,7 @@ describe('AgentExecutor', () => {
|
||||
projectPath: '/project',
|
||||
abortController: new AbortController(),
|
||||
provider: mockProvider,
|
||||
effectiveBareModel: 'claude-sonnet-4-20250514',
|
||||
effectiveBareModel: 'claude-sonnet-4-6',
|
||||
planningMode: 'skip',
|
||||
};
|
||||
|
||||
@@ -855,7 +855,7 @@ describe('AgentExecutor', () => {
|
||||
projectPath: '/project',
|
||||
abortController: new AbortController(),
|
||||
provider: mockProvider,
|
||||
effectiveBareModel: 'claude-sonnet-4-20250514',
|
||||
effectiveBareModel: 'claude-sonnet-4-6',
|
||||
planningMode: 'skip',
|
||||
branchName: 'feature/my-feature',
|
||||
};
|
||||
@@ -906,7 +906,7 @@ describe('AgentExecutor', () => {
|
||||
projectPath: '/project',
|
||||
abortController: new AbortController(),
|
||||
provider: mockProvider,
|
||||
effectiveBareModel: 'claude-sonnet-4-20250514',
|
||||
effectiveBareModel: 'claude-sonnet-4-6',
|
||||
planningMode: 'skip',
|
||||
};
|
||||
|
||||
|
||||
@@ -272,10 +272,10 @@ describe('agent-service.ts', () => {
|
||||
await service.sendMessage({
|
||||
sessionId: 'session-1',
|
||||
message: 'Hello',
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
model: 'claude-sonnet-4-6',
|
||||
});
|
||||
|
||||
expect(ProviderFactory.getProviderForModel).toHaveBeenCalledWith('claude-sonnet-4-20250514');
|
||||
expect(ProviderFactory.getProviderForModel).toHaveBeenCalledWith('claude-sonnet-4-6');
|
||||
});
|
||||
|
||||
it('should save session messages', async () => {
|
||||
@@ -339,7 +339,10 @@ describe('agent-service.ts', () => {
|
||||
|
||||
it('should handle non-existent session', async () => {
|
||||
const history = await service.getHistory('nonexistent');
|
||||
expect(history).toBeDefined(); // Returns error object
|
||||
expect(history).toBeDefined();
|
||||
expect(history.success).toBe(false);
|
||||
expect(history.error).toBeDefined();
|
||||
expect(typeof history.error).toBe('string');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -530,13 +533,13 @@ describe('agent-service.ts', () => {
|
||||
|
||||
it('should set model for existing session', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue('{"session-1": {}}');
|
||||
const result = await service.setSessionModel('session-1', 'claude-sonnet-4-20250514');
|
||||
const result = await service.setSessionModel('session-1', 'claude-sonnet-4-6');
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-existent session', async () => {
|
||||
const result = await service.setSessionModel('nonexistent', 'claude-sonnet-4-20250514');
|
||||
const result = await service.setSessionModel('nonexistent', 'claude-sonnet-4-6');
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
@@ -719,7 +722,7 @@ describe('agent-service.ts', () => {
|
||||
const result = await service.addToQueue('session-1', {
|
||||
message: 'Test prompt',
|
||||
imagePaths: ['/test/image.png'],
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
model: 'claude-sonnet-4-6',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
@@ -25,7 +25,7 @@ const mockLogger = vi.hoisted(() => ({
|
||||
|
||||
const mockCreateChatOptions = vi.hoisted(() =>
|
||||
vi.fn(() => ({
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
model: 'claude-sonnet-4-6',
|
||||
systemPrompt: 'test prompt',
|
||||
}))
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
File,
|
||||
@@ -11,11 +11,15 @@ import {
|
||||
RefreshCw,
|
||||
GitBranch,
|
||||
AlertCircle,
|
||||
Plus,
|
||||
Minus,
|
||||
} from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { TruncatedFilePath } from '@/components/ui/truncated-file-path';
|
||||
import { Button } from './button';
|
||||
import { useWorktreeDiffs, useGitDiffs } from '@/hooks/queries';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { toast } from 'sonner';
|
||||
import type { FileStatus } from '@/types/electron';
|
||||
|
||||
interface GitDiffPanelProps {
|
||||
@@ -26,6 +30,10 @@ interface GitDiffPanelProps {
|
||||
compact?: boolean;
|
||||
/** Whether worktrees are enabled - if false, shows diffs from main project */
|
||||
useWorktrees?: boolean;
|
||||
/** Whether to show stage/unstage controls for each file */
|
||||
enableStaging?: boolean;
|
||||
/** The worktree path to use for staging operations (required when enableStaging is true) */
|
||||
worktreePath?: string;
|
||||
}
|
||||
|
||||
interface ParsedDiffHunk {
|
||||
@@ -102,6 +110,24 @@ const getStatusDisplayName = (status: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Determine the staging state of a file based on its indexStatus and workTreeStatus
|
||||
*/
|
||||
function getStagingState(file: FileStatus): 'staged' | 'unstaged' | 'partial' {
|
||||
const idx = file.indexStatus ?? ' ';
|
||||
const wt = file.workTreeStatus ?? ' ';
|
||||
|
||||
// Untracked files
|
||||
if (idx === '?' && wt === '?') return 'unstaged';
|
||||
|
||||
const hasIndexChanges = idx !== ' ' && idx !== '?';
|
||||
const hasWorkTreeChanges = wt !== ' ' && wt !== '?';
|
||||
|
||||
if (hasIndexChanges && hasWorkTreeChanges) return 'partial';
|
||||
if (hasIndexChanges) return 'staged';
|
||||
return 'unstaged';
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse unified diff format into structured data
|
||||
*/
|
||||
@@ -270,14 +296,46 @@ function DiffLine({
|
||||
);
|
||||
}
|
||||
|
||||
function StagingBadge({ state }: { state: 'staged' | 'unstaged' | 'partial' }) {
|
||||
if (state === 'staged') {
|
||||
return (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded border font-medium bg-green-500/15 text-green-400 border-green-500/30">
|
||||
Staged
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (state === 'partial') {
|
||||
return (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded border font-medium bg-amber-500/15 text-amber-400 border-amber-500/30">
|
||||
Partial
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded border font-medium bg-muted text-muted-foreground border-border">
|
||||
Unstaged
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function FileDiffSection({
|
||||
fileDiff,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
fileStatus,
|
||||
enableStaging,
|
||||
onStage,
|
||||
onUnstage,
|
||||
isStagingFile,
|
||||
}: {
|
||||
fileDiff: ParsedFileDiff;
|
||||
isExpanded: boolean;
|
||||
onToggle: () => void;
|
||||
fileStatus?: FileStatus;
|
||||
enableStaging?: boolean;
|
||||
onStage?: (filePath: string) => void;
|
||||
onUnstage?: (filePath: string) => void;
|
||||
isStagingFile?: boolean;
|
||||
}) {
|
||||
const additions = fileDiff.hunks.reduce(
|
||||
(acc, hunk) => acc + hunk.lines.filter((l) => l.type === 'addition').length,
|
||||
@@ -288,23 +346,29 @@ function FileDiffSection({
|
||||
0
|
||||
);
|
||||
|
||||
const stagingState = fileStatus ? getStagingState(fileStatus) : undefined;
|
||||
|
||||
return (
|
||||
<div className="border border-border rounded-lg overflow-hidden">
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="w-full px-3 py-2 flex items-center gap-2 text-left bg-card hover:bg-accent/50 transition-colors"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown 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" />
|
||||
<TruncatedFilePath
|
||||
path={fileDiff.filePath}
|
||||
className="flex-1 text-sm font-mono text-foreground"
|
||||
/>
|
||||
<div className="w-full px-3 py-2 flex items-center gap-2 text-left bg-card hover:bg-accent/50 transition-colors">
|
||||
<button onClick={onToggle} className="flex items-center gap-2 flex-1 min-w-0 text-left">
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
{fileStatus ? (
|
||||
getFileIcon(fileStatus.status)
|
||||
) : (
|
||||
<FileText className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
<TruncatedFilePath
|
||||
path={fileDiff.filePath}
|
||||
className="flex-1 text-sm font-mono text-foreground"
|
||||
/>
|
||||
</button>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{enableStaging && stagingState && <StagingBadge state={stagingState} />}
|
||||
{fileDiff.isNew && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-green-500/20 text-green-400">
|
||||
new
|
||||
@@ -322,8 +386,43 @@ function FileDiffSection({
|
||||
)}
|
||||
{additions > 0 && <span className="text-xs text-green-400">+{additions}</span>}
|
||||
{deletions > 0 && <span className="text-xs text-red-400">-{deletions}</span>}
|
||||
{enableStaging && onStage && onUnstage && (
|
||||
<div className="flex items-center gap-1 ml-1">
|
||||
{isStagingFile ? (
|
||||
<Spinner size="sm" />
|
||||
) : stagingState === 'staged' || stagingState === 'partial' ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onUnstage(fileDiff.filePath);
|
||||
}}
|
||||
title="Unstage file"
|
||||
>
|
||||
<Minus className="w-3 h-3 mr-1" />
|
||||
Unstage
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onStage(fileDiff.filePath);
|
||||
}}
|
||||
title="Stage file"
|
||||
>
|
||||
<Plus className="w-3 h-3 mr-1" />
|
||||
Stage
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div className="bg-background border-t border-border max-h-[400px] overflow-y-auto scrollbar-visible">
|
||||
{fileDiff.hunks.map((hunk, hunkIndex) => (
|
||||
@@ -350,9 +449,12 @@ export function GitDiffPanel({
|
||||
className,
|
||||
compact = true,
|
||||
useWorktrees = false,
|
||||
enableStaging = false,
|
||||
worktreePath,
|
||||
}: GitDiffPanelProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(!compact);
|
||||
const [expandedFiles, setExpandedFiles] = useState<Set<string>>(new Set());
|
||||
const [stagingInProgress, setStagingInProgress] = useState<Set<string>>(new Set());
|
||||
|
||||
// Use worktree diffs hook when worktrees are enabled and panel is expanded
|
||||
// Pass undefined for featureId when not using worktrees to disable the query
|
||||
@@ -393,6 +495,15 @@ export function GitDiffPanel({
|
||||
|
||||
const parsedDiffs = useMemo(() => parseDiff(diffContent), [diffContent]);
|
||||
|
||||
// Build a map from file path to FileStatus for quick lookup
|
||||
const fileStatusMap = useMemo(() => {
|
||||
const map = new Map<string, FileStatus>();
|
||||
for (const file of files) {
|
||||
map.set(file.path, file);
|
||||
}
|
||||
return map;
|
||||
}, [files]);
|
||||
|
||||
const toggleFile = (filePath: string) => {
|
||||
setExpandedFiles((prev) => {
|
||||
const next = new Set(prev);
|
||||
@@ -413,6 +524,224 @@ export function GitDiffPanel({
|
||||
setExpandedFiles(new Set());
|
||||
};
|
||||
|
||||
// Stage/unstage a single file
|
||||
const handleStageFile = useCallback(
|
||||
async (filePath: string) => {
|
||||
if (!worktreePath && !projectPath) return;
|
||||
setStagingInProgress((prev) => new Set(prev).add(filePath));
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
let result: { success: boolean; error?: string } | undefined;
|
||||
|
||||
if (useWorktrees && worktreePath) {
|
||||
if (!api.worktree?.stageFiles) {
|
||||
toast.error('Failed to stage file', {
|
||||
description: 'Worktree stage API not available',
|
||||
});
|
||||
return;
|
||||
}
|
||||
result = await api.worktree.stageFiles(worktreePath, [filePath], 'stage');
|
||||
} else if (!useWorktrees) {
|
||||
if (!api.git?.stageFiles) {
|
||||
toast.error('Failed to stage file', { description: 'Git stage API not available' });
|
||||
return;
|
||||
}
|
||||
result = await api.git.stageFiles(projectPath, [filePath], 'stage');
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
toast.error('Failed to stage file', { description: 'Stage API not available' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.success) {
|
||||
toast.error('Failed to stage file', { description: result.error });
|
||||
return;
|
||||
}
|
||||
|
||||
// Refetch diffs to reflect the new staging state
|
||||
await loadDiffs();
|
||||
toast.success('File staged', { description: filePath });
|
||||
} catch (err) {
|
||||
toast.error('Failed to stage file', {
|
||||
description: err instanceof Error ? err.message : 'Unknown error',
|
||||
});
|
||||
} finally {
|
||||
setStagingInProgress((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(filePath);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
},
|
||||
[worktreePath, projectPath, useWorktrees, loadDiffs]
|
||||
);
|
||||
|
||||
// Unstage a single file
|
||||
const handleUnstageFile = useCallback(
|
||||
async (filePath: string) => {
|
||||
if (!worktreePath && !projectPath) return;
|
||||
setStagingInProgress((prev) => new Set(prev).add(filePath));
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
let result: { success: boolean; error?: string } | undefined;
|
||||
|
||||
if (useWorktrees && worktreePath) {
|
||||
if (!api.worktree?.stageFiles) {
|
||||
toast.error('Failed to unstage file', {
|
||||
description: 'Worktree stage API not available',
|
||||
});
|
||||
return;
|
||||
}
|
||||
result = await api.worktree.stageFiles(worktreePath, [filePath], 'unstage');
|
||||
} else if (!useWorktrees) {
|
||||
if (!api.git?.stageFiles) {
|
||||
toast.error('Failed to unstage file', { description: 'Git stage API not available' });
|
||||
return;
|
||||
}
|
||||
result = await api.git.stageFiles(projectPath, [filePath], 'unstage');
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
toast.error('Failed to unstage file', { description: 'Stage API not available' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.success) {
|
||||
toast.error('Failed to unstage file', { description: result.error });
|
||||
return;
|
||||
}
|
||||
|
||||
// Refetch diffs to reflect the new staging state
|
||||
await loadDiffs();
|
||||
toast.success('File unstaged', { description: filePath });
|
||||
} catch (err) {
|
||||
toast.error('Failed to unstage file', {
|
||||
description: err instanceof Error ? err.message : 'Unknown error',
|
||||
});
|
||||
} finally {
|
||||
setStagingInProgress((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(filePath);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
},
|
||||
[worktreePath, projectPath, useWorktrees, loadDiffs]
|
||||
);
|
||||
|
||||
const handleStageAll = useCallback(async () => {
|
||||
if (!worktreePath && !projectPath) return;
|
||||
const allPaths = files.map((f) => f.path);
|
||||
if (allPaths.length === 0) return;
|
||||
setStagingInProgress(new Set(allPaths));
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
let result: { success: boolean; error?: string } | undefined;
|
||||
|
||||
if (useWorktrees && worktreePath) {
|
||||
if (!api.worktree?.stageFiles) {
|
||||
toast.error('Failed to stage all files', {
|
||||
description: 'Worktree stage API not available',
|
||||
});
|
||||
return;
|
||||
}
|
||||
result = await api.worktree.stageFiles(worktreePath, allPaths, 'stage');
|
||||
} else if (!useWorktrees) {
|
||||
if (!api.git?.stageFiles) {
|
||||
toast.error('Failed to stage all files', { description: 'Git stage API not available' });
|
||||
return;
|
||||
}
|
||||
result = await api.git.stageFiles(projectPath, allPaths, 'stage');
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
toast.error('Failed to stage all files', { description: 'Stage API not available' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.success) {
|
||||
toast.error('Failed to stage all files', { description: result.error });
|
||||
return;
|
||||
}
|
||||
|
||||
await loadDiffs();
|
||||
toast.success('All files staged');
|
||||
} catch (err) {
|
||||
toast.error('Failed to stage all files', {
|
||||
description: err instanceof Error ? err.message : 'Unknown error',
|
||||
});
|
||||
} finally {
|
||||
setStagingInProgress(new Set());
|
||||
}
|
||||
}, [worktreePath, projectPath, useWorktrees, files, loadDiffs]);
|
||||
|
||||
const handleUnstageAll = useCallback(async () => {
|
||||
if (!worktreePath && !projectPath) return;
|
||||
const allPaths = files.map((f) => f.path);
|
||||
if (allPaths.length === 0) return;
|
||||
setStagingInProgress(new Set(allPaths));
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
let result: { success: boolean; error?: string } | undefined;
|
||||
|
||||
if (useWorktrees && worktreePath) {
|
||||
if (!api.worktree?.stageFiles) {
|
||||
toast.error('Failed to unstage all files', {
|
||||
description: 'Worktree stage API not available',
|
||||
});
|
||||
return;
|
||||
}
|
||||
result = await api.worktree.stageFiles(worktreePath, allPaths, 'unstage');
|
||||
} else if (!useWorktrees) {
|
||||
if (!api.git?.stageFiles) {
|
||||
toast.error('Failed to unstage all files', {
|
||||
description: 'Git stage API not available',
|
||||
});
|
||||
return;
|
||||
}
|
||||
result = await api.git.stageFiles(projectPath, allPaths, 'unstage');
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
toast.error('Failed to unstage all files', { description: 'Stage API not available' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.success) {
|
||||
toast.error('Failed to unstage all files', { description: result.error });
|
||||
return;
|
||||
}
|
||||
|
||||
await loadDiffs();
|
||||
toast.success('All files unstaged');
|
||||
} catch (err) {
|
||||
toast.error('Failed to unstage all files', {
|
||||
description: err instanceof Error ? err.message : 'Unknown error',
|
||||
});
|
||||
} finally {
|
||||
setStagingInProgress(new Set());
|
||||
}
|
||||
}, [worktreePath, projectPath, useWorktrees, files, loadDiffs]);
|
||||
|
||||
// Compute staging summary
|
||||
const stagingSummary = useMemo(() => {
|
||||
if (!enableStaging) return null;
|
||||
let staged = 0;
|
||||
let unstaged = 0;
|
||||
for (const file of files) {
|
||||
const state = getStagingState(file);
|
||||
if (state === 'staged') staged++;
|
||||
else if (state === 'unstaged') unstaged++;
|
||||
else {
|
||||
// partial counts as both
|
||||
staged++;
|
||||
unstaged++;
|
||||
}
|
||||
}
|
||||
return { staged, unstaged, total: files.length };
|
||||
}, [enableStaging, files]);
|
||||
|
||||
// Total stats
|
||||
const totalAdditions = parsedDiffs.reduce(
|
||||
(acc, file) =>
|
||||
@@ -536,6 +865,30 @@ export function GitDiffPanel({
|
||||
})()}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{enableStaging && stagingSummary && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleStageAll}
|
||||
className="text-xs h-7"
|
||||
disabled={stagingInProgress.size > 0 || stagingSummary.unstaged === 0}
|
||||
>
|
||||
<Plus className="w-3 h-3 mr-1" />
|
||||
Stage All
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleUnstageAll}
|
||||
className="text-xs h-7"
|
||||
disabled={stagingInProgress.size > 0 || stagingSummary.staged === 0}
|
||||
>
|
||||
<Minus className="w-3 h-3 mr-1" />
|
||||
Unstage All
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -575,6 +928,11 @@ export function GitDiffPanel({
|
||||
{totalDeletions > 0 && (
|
||||
<span className="text-red-400">-{totalDeletions} deletions</span>
|
||||
)}
|
||||
{enableStaging && stagingSummary && (
|
||||
<span className="text-muted-foreground">
|
||||
({stagingSummary.staged} staged, {stagingSummary.unstaged} unstaged)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -586,6 +944,11 @@ export function GitDiffPanel({
|
||||
fileDiff={fileDiff}
|
||||
isExpanded={expandedFiles.has(fileDiff.filePath)}
|
||||
onToggle={() => toggleFile(fileDiff.filePath)}
|
||||
fileStatus={enableStaging ? fileStatusMap.get(fileDiff.filePath) : undefined}
|
||||
enableStaging={enableStaging}
|
||||
onStage={enableStaging ? handleStageFile : undefined}
|
||||
onUnstage={enableStaging ? handleUnstageFile : undefined}
|
||||
isStagingFile={stagingInProgress.has(fileDiff.filePath)}
|
||||
/>
|
||||
))}
|
||||
{/* Fallback for files that have no diff content (shouldn't happen after fix, but safety net) */}
|
||||
@@ -602,6 +965,7 @@ export function GitDiffPanel({
|
||||
path={file.path}
|
||||
className="flex-1 text-sm font-mono text-foreground"
|
||||
/>
|
||||
{enableStaging && <StagingBadge state={getStagingState(file)} />}
|
||||
<span
|
||||
className={cn(
|
||||
'text-xs px-1.5 py-0.5 rounded border font-medium',
|
||||
@@ -610,6 +974,36 @@ export function GitDiffPanel({
|
||||
>
|
||||
{getStatusDisplayName(file.status)}
|
||||
</span>
|
||||
{enableStaging && (
|
||||
<div className="flex items-center gap-1 ml-1">
|
||||
{stagingInProgress.has(file.path) ? (
|
||||
<Spinner size="sm" />
|
||||
) : getStagingState(file) === 'staged' ||
|
||||
getStagingState(file) === 'partial' ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={() => handleUnstageFile(file.path)}
|
||||
title="Unstage file"
|
||||
>
|
||||
<Minus className="w-3 h-3 mr-1" />
|
||||
Unstage
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={() => handleStageFile(file.path)}
|
||||
title="Stage file"
|
||||
>
|
||||
<Plus className="w-3 h-3 mr-1" />
|
||||
Stage
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-4 py-3 text-sm text-muted-foreground bg-background border-t border-border">
|
||||
{file.status === '?' ? (
|
||||
|
||||
@@ -56,7 +56,7 @@ import {
|
||||
PlanApprovalDialog,
|
||||
MergeRebaseDialog,
|
||||
} from './board-view/dialogs';
|
||||
import type { DependencyLinkType, PullStrategy } from './board-view/dialogs';
|
||||
import type { DependencyLinkType } from './board-view/dialogs';
|
||||
import { PipelineSettingsDialog } from './board-view/dialogs/pipeline-settings-dialog';
|
||||
import { CreateWorktreeDialog } from './board-view/dialogs/create-worktree-dialog';
|
||||
import { DeleteWorktreeDialog } from './board-view/dialogs/delete-worktree-dialog';
|
||||
@@ -87,7 +87,8 @@ import {
|
||||
useListViewState,
|
||||
} from './board-view/hooks';
|
||||
import { SelectionActionBar, ListView } from './board-view/components';
|
||||
import { MassEditDialog } from './board-view/dialogs';
|
||||
import { MassEditDialog, BranchConflictDialog } from './board-view/dialogs';
|
||||
import type { BranchConflictData } from './board-view/dialogs';
|
||||
import { InitScriptIndicator } from './board-view/init-script-indicator';
|
||||
import { useInitScriptEvents } from '@/hooks/use-init-script-events';
|
||||
import { usePipelineConfig } from '@/hooks/queries';
|
||||
@@ -189,6 +190,10 @@ export function BoardView() {
|
||||
);
|
||||
const [worktreeRefreshKey, setWorktreeRefreshKey] = useState(0);
|
||||
|
||||
// Branch conflict dialog state (for branch switch and stash pop conflicts)
|
||||
const [branchConflictData, setBranchConflictData] = useState<BranchConflictData | null>(null);
|
||||
const [showBranchConflictDialog, setShowBranchConflictDialog] = useState(false);
|
||||
|
||||
// Backlog plan dialog state
|
||||
const [showPlanDialog, setShowPlanDialog] = useState(false);
|
||||
const [pendingBacklogPlan, setPendingBacklogPlan] = useState<BacklogPlanResult | null>(null);
|
||||
@@ -935,56 +940,29 @@ export function BoardView() {
|
||||
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 isCherryPick = conflictInfo.operationType === 'cherry-pick';
|
||||
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}`;
|
||||
let description: string;
|
||||
let title: string;
|
||||
|
||||
const title = isRebase
|
||||
? `Rebase & Resolve Conflicts: ${conflictInfo.targetBranch} onto ${conflictInfo.sourceBranch}`
|
||||
: `Resolve Merge Conflicts: ${conflictInfo.sourceBranch} → ${conflictInfo.targetBranch}`;
|
||||
if (isRebase) {
|
||||
description = `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}`;
|
||||
title = `Rebase & Resolve Conflicts: ${conflictInfo.targetBranch} onto ${conflictInfo.sourceBranch}`;
|
||||
} else if (isCherryPick) {
|
||||
description = `Resolve cherry-pick conflicts when cherry-picking commits from "${conflictInfo.sourceBranch}" into "${conflictInfo.targetBranch}". The cherry-pick was attempted but encountered conflicts that need to be resolved manually. Cherry-pick the commits again using "git cherry-pick <commit-hashes>", resolve any conflicts, then use "git cherry-pick --continue" after fixing each conflict. After completing the cherry-pick, ensure the code compiles and tests pass.${conflictFilesInfo}`;
|
||||
title = `Resolve Cherry-Pick Conflicts: ${conflictInfo.sourceBranch} → ${conflictInfo.targetBranch}`;
|
||||
} else {
|
||||
description = `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}`;
|
||||
title = `Resolve Merge Conflicts: ${conflictInfo.sourceBranch} → ${conflictInfo.targetBranch}`;
|
||||
}
|
||||
|
||||
const featureData = {
|
||||
title,
|
||||
@@ -1007,60 +985,72 @@ export function BoardView() {
|
||||
[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 branch switch stash reapply causes merge conflicts.
|
||||
// Shows a dialog to let the user choose between manual or AI resolution.
|
||||
const handleBranchSwitchConflict = useCallback((conflictInfo: BranchSwitchConflictInfo) => {
|
||||
setBranchConflictData({ type: 'branch-switch', info: conflictInfo });
|
||||
setShowBranchConflictDialog(true);
|
||||
}, []);
|
||||
|
||||
// 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.`;
|
||||
// Shows a dialog to let the user choose between manual or AI resolution.
|
||||
const handleStashPopConflict = useCallback((conflictInfo: StashPopConflictInfo) => {
|
||||
setBranchConflictData({ type: 'stash-pop', info: conflictInfo });
|
||||
setShowBranchConflictDialog(true);
|
||||
}, []);
|
||||
|
||||
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,
|
||||
};
|
||||
// Handler called when the user selects "Resolve with AI" from the branch conflict dialog.
|
||||
// Creates and starts the AI-assisted conflict resolution feature task.
|
||||
const handleBranchConflictResolveWithAI = useCallback(
|
||||
async (conflictData: BranchConflictData) => {
|
||||
if (conflictData.type === 'branch-switch') {
|
||||
const conflictInfo = conflictData.info;
|
||||
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.`;
|
||||
|
||||
await handleAddAndStartFeature(featureData);
|
||||
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);
|
||||
} else {
|
||||
const conflictInfo = conflictData.info;
|
||||
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]
|
||||
);
|
||||
@@ -1925,10 +1915,17 @@ export function BoardView() {
|
||||
open={showMergeRebaseDialog}
|
||||
onOpenChange={setShowMergeRebaseDialog}
|
||||
worktree={selectedWorktreeForAction}
|
||||
onConfirm={handleConfirmResolveConflicts}
|
||||
onCreateConflictResolutionFeature={handleCreateMergeConflictResolutionFeature}
|
||||
/>
|
||||
|
||||
{/* Branch Switch / Stash Pop Conflict Dialog */}
|
||||
<BranchConflictDialog
|
||||
open={showBranchConflictDialog}
|
||||
onOpenChange={setShowBranchConflictDialog}
|
||||
conflictData={branchConflictData}
|
||||
onResolveWithAI={handleBranchConflictResolveWithAI}
|
||||
/>
|
||||
|
||||
{/* Commit Worktree Dialog */}
|
||||
<CommitWorktreeDialog
|
||||
open={showCommitWorktreeDialog}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Dialog shown when a branch switch or stash-pop operation results in merge conflicts.
|
||||
* Presents the user with two options:
|
||||
* 1. Resolve Manually - leaves conflict markers in place
|
||||
* 2. Resolve with AI - creates a feature task for AI-powered conflict resolution
|
||||
*
|
||||
* This dialog ensures the user can choose how to handle the conflict instead of
|
||||
* automatically creating and starting an AI task.
|
||||
*/
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { AlertTriangle, Wrench, Sparkles } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import type { BranchSwitchConflictInfo, StashPopConflictInfo } from '../worktree-panel/types';
|
||||
|
||||
export type BranchConflictType = 'branch-switch' | 'stash-pop';
|
||||
|
||||
export type BranchConflictData =
|
||||
| { type: 'branch-switch'; info: BranchSwitchConflictInfo }
|
||||
| { type: 'stash-pop'; info: StashPopConflictInfo };
|
||||
|
||||
interface BranchConflictDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
conflictData: BranchConflictData | null;
|
||||
onResolveWithAI?: (conflictData: BranchConflictData) => void;
|
||||
}
|
||||
|
||||
export function BranchConflictDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
conflictData,
|
||||
onResolveWithAI,
|
||||
}: BranchConflictDialogProps) {
|
||||
const handleResolveManually = useCallback(() => {
|
||||
toast.info('Conflict markers left in place', {
|
||||
description: 'Edit the conflicting files to resolve conflicts manually.',
|
||||
duration: 6000,
|
||||
});
|
||||
onOpenChange(false);
|
||||
}, [onOpenChange]);
|
||||
|
||||
const handleResolveWithAI = useCallback(() => {
|
||||
if (!conflictData || !onResolveWithAI) return;
|
||||
|
||||
onResolveWithAI(conflictData);
|
||||
onOpenChange(false);
|
||||
}, [conflictData, onResolveWithAI, onOpenChange]);
|
||||
|
||||
if (!conflictData) return null;
|
||||
|
||||
const isBranchSwitch = conflictData.type === 'branch-switch';
|
||||
const branchName = isBranchSwitch ? conflictData.info.branchName : conflictData.info.branchName;
|
||||
|
||||
const description = isBranchSwitch ? (
|
||||
<>
|
||||
Merge conflicts occurred when switching from{' '}
|
||||
<code className="font-mono bg-muted px-1 rounded">
|
||||
{(conflictData.info as BranchSwitchConflictInfo).previousBranch}
|
||||
</code>{' '}
|
||||
to <code className="font-mono bg-muted px-1 rounded">{branchName}</code>. Local changes were
|
||||
stashed before switching and reapplying them caused conflicts.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
The branch switch to <code className="font-mono bg-muted px-1 rounded">{branchName}</code>{' '}
|
||||
failed and restoring the previously stashed local changes resulted in merge conflicts.
|
||||
</>
|
||||
);
|
||||
|
||||
const title = isBranchSwitch
|
||||
? 'Branch Switch Conflicts Detected'
|
||||
: 'Stash Restore Conflicts Detected';
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[520px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<AlertTriangle className="w-5 h-5 text-orange-500" />
|
||||
{title}
|
||||
</DialogTitle>
|
||||
<DialogDescription asChild>
|
||||
<div className="space-y-3">
|
||||
<span className="block">{description}</span>
|
||||
|
||||
{!isBranchSwitch &&
|
||||
(conflictData.info as StashPopConflictInfo).stashPopConflictMessage && (
|
||||
<div className="flex items-start gap-2 p-3 rounded-md bg-orange-500/10 border border-orange-500/20">
|
||||
<AlertTriangle className="w-4 h-4 text-orange-500 mt-0.5 flex-shrink-0" />
|
||||
<span className="text-orange-500 text-sm">
|
||||
{(conflictData.info as StashPopConflictInfo).stashPopConflictMessage}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-2 p-3 rounded-md bg-muted/50 border border-border">
|
||||
<p className="text-sm text-muted-foreground font-medium mb-2">
|
||||
Choose how to resolve:
|
||||
</p>
|
||||
<ul className="text-sm text-muted-foreground list-disc list-inside space-y-1">
|
||||
<li>
|
||||
<strong>Resolve with AI</strong> — Creates a task to analyze and resolve
|
||||
conflicts automatically
|
||||
</li>
|
||||
<li>
|
||||
<strong>Resolve Manually</strong> — Leaves conflict markers in place for
|
||||
you to edit directly
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleResolveManually}>
|
||||
<Wrench className="w-4 h-4 mr-2" />
|
||||
Resolve Manually
|
||||
</Button>
|
||||
{onResolveWithAI && (
|
||||
<Button
|
||||
onClick={handleResolveWithAI}
|
||||
className="bg-purple-600 hover:bg-purple-700 text-white"
|
||||
>
|
||||
<Sparkles className="w-4 h-4 mr-2" />
|
||||
Resolve with AI
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -411,7 +411,7 @@ export function CherryPickDialog({
|
||||
sourceBranch: selectedBranch,
|
||||
targetBranch: conflictInfo.targetBranch,
|
||||
targetWorktreePath: conflictInfo.targetWorktreePath,
|
||||
operationType: 'merge',
|
||||
operationType: 'cherry-pick',
|
||||
});
|
||||
onOpenChange(false);
|
||||
}
|
||||
@@ -461,7 +461,7 @@ export function CherryPickDialog({
|
||||
Cherry-pick the selected commit(s) from{' '}
|
||||
<code className="font-mono bg-muted px-0.5 rounded">{selectedBranch}</code>
|
||||
</li>
|
||||
<li>Resolve any merge conflicts</li>
|
||||
<li>Resolve any cherry-pick conflicts</li>
|
||||
<li>Ensure the code compiles and tests pass</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -29,6 +29,7 @@ import { useAppStore } from '@/store/app-store';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { TruncatedFilePath } from '@/components/ui/truncated-file-path';
|
||||
import type { FileStatus } from '@/types/electron';
|
||||
import { parseDiff, type ParsedFileDiff } from '@/lib/diff-utils';
|
||||
|
||||
interface WorktreeInfo {
|
||||
path: string;
|
||||
@@ -45,23 +46,6 @@ interface CommitWorktreeDialogProps {
|
||||
onCommitted: () => void;
|
||||
}
|
||||
|
||||
interface ParsedDiffHunk {
|
||||
header: string;
|
||||
lines: {
|
||||
type: 'context' | 'addition' | 'deletion' | 'header';
|
||||
content: string;
|
||||
lineNumber?: { old?: number; new?: number };
|
||||
}[];
|
||||
}
|
||||
|
||||
interface ParsedFileDiff {
|
||||
filePath: string;
|
||||
hunks: ParsedDiffHunk[];
|
||||
isNew?: boolean;
|
||||
isDeleted?: boolean;
|
||||
isRenamed?: boolean;
|
||||
}
|
||||
|
||||
const getFileIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'A':
|
||||
@@ -119,102 +103,7 @@ const getStatusBadgeColor = (status: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse unified diff format into structured data
|
||||
*/
|
||||
function parseDiff(diffText: string): ParsedFileDiff[] {
|
||||
if (!diffText) return [];
|
||||
|
||||
const files: ParsedFileDiff[] = [];
|
||||
const lines = diffText.split('\n');
|
||||
let currentFile: ParsedFileDiff | null = null;
|
||||
let currentHunk: ParsedDiffHunk | null = null;
|
||||
let oldLineNum = 0;
|
||||
let newLineNum = 0;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
|
||||
if (line.startsWith('diff --git')) {
|
||||
if (currentFile) {
|
||||
if (currentHunk) currentFile.hunks.push(currentHunk);
|
||||
files.push(currentFile);
|
||||
}
|
||||
const match = line.match(/diff --git a\/(.*?) b\/(.*)/);
|
||||
currentFile = {
|
||||
filePath: match ? match[2] : 'unknown',
|
||||
hunks: [],
|
||||
};
|
||||
currentHunk = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('new file mode')) {
|
||||
if (currentFile) currentFile.isNew = true;
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith('deleted file mode')) {
|
||||
if (currentFile) currentFile.isDeleted = true;
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith('rename from') || line.startsWith('rename to')) {
|
||||
if (currentFile) currentFile.isRenamed = true;
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith('index ') || line.startsWith('--- ') || line.startsWith('+++ ')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('@@')) {
|
||||
if (currentHunk && currentFile) currentFile.hunks.push(currentHunk);
|
||||
const hunkMatch = line.match(/@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
|
||||
oldLineNum = hunkMatch ? parseInt(hunkMatch[1], 10) : 1;
|
||||
newLineNum = hunkMatch ? parseInt(hunkMatch[2], 10) : 1;
|
||||
currentHunk = {
|
||||
header: line,
|
||||
lines: [{ type: 'header', content: line }],
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentHunk) {
|
||||
// Skip trailing empty line produced by split('\n') to avoid phantom context line
|
||||
if (line === '' && i === lines.length - 1) {
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith('+')) {
|
||||
currentHunk.lines.push({
|
||||
type: 'addition',
|
||||
content: line.substring(1),
|
||||
lineNumber: { new: newLineNum },
|
||||
});
|
||||
newLineNum++;
|
||||
} else if (line.startsWith('-')) {
|
||||
currentHunk.lines.push({
|
||||
type: 'deletion',
|
||||
content: line.substring(1),
|
||||
lineNumber: { old: oldLineNum },
|
||||
});
|
||||
oldLineNum++;
|
||||
} else if (line.startsWith(' ') || line === '') {
|
||||
currentHunk.lines.push({
|
||||
type: 'context',
|
||||
content: line.substring(1) || '',
|
||||
lineNumber: { old: oldLineNum, new: newLineNum },
|
||||
});
|
||||
oldLineNum++;
|
||||
newLineNum++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (currentFile) {
|
||||
if (currentHunk) currentFile.hunks.push(currentHunk);
|
||||
files.push(currentFile);
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
// parseDiff is imported from @/lib/diff-utils
|
||||
|
||||
function DiffLine({
|
||||
type,
|
||||
@@ -323,8 +212,20 @@ export function CommitWorktreeDialog({
|
||||
const fileList = result.files ?? [];
|
||||
if (!cancelled) setFiles(fileList);
|
||||
if (!cancelled) setDiffContent(result.diff ?? '');
|
||||
// Select all files by default
|
||||
if (!cancelled) setSelectedFiles(new Set(fileList.map((f) => f.path)));
|
||||
// If any files are already staged, pre-select only staged files
|
||||
// Otherwise select all files by default
|
||||
const stagedFiles = fileList.filter((f) => {
|
||||
const idx = f.indexStatus ?? ' ';
|
||||
return idx !== ' ' && idx !== '?';
|
||||
});
|
||||
if (!cancelled) {
|
||||
if (stagedFiles.length > 0) {
|
||||
// Also include untracked files that are staged (A status)
|
||||
setSelectedFiles(new Set(stagedFiles.map((f) => f.path)));
|
||||
} else {
|
||||
setSelectedFiles(new Set(fileList.map((f) => f.path)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -532,18 +433,14 @@ export function CommitWorktreeDialog({
|
||||
const isChecked = selectedFiles.has(file.path);
|
||||
const isExpanded = expandedFile === file.path;
|
||||
const fileDiff = diffsByFile.get(file.path);
|
||||
const additions = fileDiff
|
||||
? fileDiff.hunks.reduce(
|
||||
(acc, hunk) => acc + hunk.lines.filter((l) => l.type === 'addition').length,
|
||||
0
|
||||
)
|
||||
: 0;
|
||||
const deletions = fileDiff
|
||||
? fileDiff.hunks.reduce(
|
||||
(acc, hunk) => acc + hunk.lines.filter((l) => l.type === 'deletion').length,
|
||||
0
|
||||
)
|
||||
: 0;
|
||||
const additions = fileDiff?.additions ?? 0;
|
||||
const deletions = fileDiff?.deletions ?? 0;
|
||||
// Determine staging state from index/worktree status
|
||||
const idx = file.indexStatus ?? ' ';
|
||||
const wt = file.workTreeStatus ?? ' ';
|
||||
const isStaged = idx !== ' ' && idx !== '?';
|
||||
const isUnstaged = wt !== ' ' && wt !== '?';
|
||||
const isUntracked = idx === '?' && wt === '?';
|
||||
|
||||
return (
|
||||
<div key={file.path} className="border-b border-border last:border-b-0">
|
||||
@@ -583,6 +480,16 @@ export function CommitWorktreeDialog({
|
||||
>
|
||||
{getStatusLabel(file.status)}
|
||||
</span>
|
||||
{isStaged && !isUntracked && (
|
||||
<span className="text-[10px] px-1 py-0.5 rounded border font-medium flex-shrink-0 bg-green-500/15 text-green-400 border-green-500/30">
|
||||
Staged
|
||||
</span>
|
||||
)}
|
||||
{isStaged && isUnstaged && (
|
||||
<span className="text-[10px] px-1 py-0.5 rounded border font-medium flex-shrink-0 bg-amber-500/15 text-amber-400 border-amber-500/30">
|
||||
Partial
|
||||
</span>
|
||||
)}
|
||||
{additions > 0 && (
|
||||
<span className="text-[10px] text-green-400 flex-shrink-0">
|
||||
+{additions}
|
||||
|
||||
@@ -28,6 +28,7 @@ import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { TruncatedFilePath } from '@/components/ui/truncated-file-path';
|
||||
import type { FileStatus } from '@/types/electron';
|
||||
import { parseDiff, type ParsedFileDiff } from '@/lib/diff-utils';
|
||||
|
||||
interface WorktreeInfo {
|
||||
path: string;
|
||||
@@ -44,23 +45,6 @@ interface DiscardWorktreeChangesDialogProps {
|
||||
onDiscarded: () => void;
|
||||
}
|
||||
|
||||
interface ParsedDiffHunk {
|
||||
header: string;
|
||||
lines: {
|
||||
type: 'context' | 'addition' | 'deletion' | 'header';
|
||||
content: string;
|
||||
lineNumber?: { old?: number; new?: number };
|
||||
}[];
|
||||
}
|
||||
|
||||
interface ParsedFileDiff {
|
||||
filePath: string;
|
||||
hunks: ParsedDiffHunk[];
|
||||
isNew?: boolean;
|
||||
isDeleted?: boolean;
|
||||
isRenamed?: boolean;
|
||||
}
|
||||
|
||||
const getFileIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'A':
|
||||
@@ -118,98 +102,7 @@ const getStatusBadgeColor = (status: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse unified diff format into structured data
|
||||
*/
|
||||
function parseDiff(diffText: string): ParsedFileDiff[] {
|
||||
if (!diffText) return [];
|
||||
|
||||
const files: ParsedFileDiff[] = [];
|
||||
const lines = diffText.split('\n');
|
||||
let currentFile: ParsedFileDiff | null = null;
|
||||
let currentHunk: ParsedDiffHunk | null = null;
|
||||
let oldLineNum = 0;
|
||||
let newLineNum = 0;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
|
||||
if (line.startsWith('diff --git')) {
|
||||
if (currentFile) {
|
||||
if (currentHunk) currentFile.hunks.push(currentHunk);
|
||||
files.push(currentFile);
|
||||
}
|
||||
const match = line.match(/diff --git a\/(.*?) b\/(.*)/);
|
||||
currentFile = {
|
||||
filePath: match ? match[2] : 'unknown',
|
||||
hunks: [],
|
||||
};
|
||||
currentHunk = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('new file mode')) {
|
||||
if (currentFile) currentFile.isNew = true;
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith('deleted file mode')) {
|
||||
if (currentFile) currentFile.isDeleted = true;
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith('rename from') || line.startsWith('rename to')) {
|
||||
if (currentFile) currentFile.isRenamed = true;
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith('index ') || line.startsWith('--- ') || line.startsWith('+++ ')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('@@')) {
|
||||
if (currentHunk && currentFile) currentFile.hunks.push(currentHunk);
|
||||
const hunkMatch = line.match(/@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
|
||||
oldLineNum = hunkMatch ? parseInt(hunkMatch[1], 10) : 1;
|
||||
newLineNum = hunkMatch ? parseInt(hunkMatch[2], 10) : 1;
|
||||
currentHunk = {
|
||||
header: line,
|
||||
lines: [{ type: 'header', content: line }],
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentHunk) {
|
||||
if (line.startsWith('+')) {
|
||||
currentHunk.lines.push({
|
||||
type: 'addition',
|
||||
content: line.substring(1),
|
||||
lineNumber: { new: newLineNum },
|
||||
});
|
||||
newLineNum++;
|
||||
} else if (line.startsWith('-')) {
|
||||
currentHunk.lines.push({
|
||||
type: 'deletion',
|
||||
content: line.substring(1),
|
||||
lineNumber: { old: oldLineNum },
|
||||
});
|
||||
oldLineNum++;
|
||||
} else if (line.startsWith(' ') || line === '') {
|
||||
currentHunk.lines.push({
|
||||
type: 'context',
|
||||
content: line.substring(1) || '',
|
||||
lineNumber: { old: oldLineNum, new: newLineNum },
|
||||
});
|
||||
oldLineNum++;
|
||||
newLineNum++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (currentFile) {
|
||||
if (currentHunk) currentFile.hunks.push(currentHunk);
|
||||
files.push(currentFile);
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
// parseDiff is imported from @/lib/diff-utils
|
||||
|
||||
function DiffLine({
|
||||
type,
|
||||
|
||||
@@ -76,17 +76,6 @@ export function GitPullDialog({
|
||||
const [pullResult, setPullResult] = useState<PullResult | null>(null);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
// Reset state when dialog opens
|
||||
useEffect(() => {
|
||||
if (open && worktree) {
|
||||
setPhase('checking');
|
||||
setPullResult(null);
|
||||
setErrorMessage(null);
|
||||
// Start the initial check
|
||||
checkForLocalChanges();
|
||||
}
|
||||
}, [open, worktree]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const checkForLocalChanges = useCallback(async () => {
|
||||
if (!worktree) return;
|
||||
|
||||
@@ -129,6 +118,17 @@ export function GitPullDialog({
|
||||
}
|
||||
}, [worktree, remote, onPulled]);
|
||||
|
||||
// Reset state when dialog opens
|
||||
useEffect(() => {
|
||||
if (open && worktree) {
|
||||
setPhase('checking');
|
||||
setPullResult(null);
|
||||
setErrorMessage(null);
|
||||
// Start the initial check
|
||||
checkForLocalChanges();
|
||||
}
|
||||
}, [open, worktree, checkForLocalChanges]);
|
||||
|
||||
const handlePullWithStash = useCallback(async () => {
|
||||
if (!worktree) return;
|
||||
|
||||
@@ -154,9 +154,14 @@ export function GitPullDialog({
|
||||
|
||||
if (result.result?.hasConflicts) {
|
||||
setPhase('conflict');
|
||||
} else {
|
||||
} else if (result.result?.pulled) {
|
||||
setPhase('success');
|
||||
onPulled?.();
|
||||
} else {
|
||||
// Unrecognized response: no pulled flag and no conflicts
|
||||
console.warn('handlePullWithStash: unrecognized response', result.result);
|
||||
setErrorMessage('Unexpected pull response');
|
||||
setPhase('error');
|
||||
}
|
||||
} catch (err) {
|
||||
setErrorMessage(err instanceof Error ? err.message : 'Failed to pull');
|
||||
@@ -300,14 +305,16 @@ export function GitPullDialog({
|
||||
{pullResult?.message || 'Changes pulled successfully'}
|
||||
</span>
|
||||
|
||||
{pullResult?.stashed && pullResult?.stashRestored && (
|
||||
<div className="flex items-start gap-2 p-3 rounded-md bg-green-500/10 border border-green-500/20">
|
||||
<Archive className="w-4 h-4 text-green-500 mt-0.5 flex-shrink-0" />
|
||||
<span className="text-green-600 dark:text-green-400 text-sm">
|
||||
Your stashed changes have been restored successfully.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{pullResult?.stashed &&
|
||||
pullResult?.stashRestored &&
|
||||
!pullResult?.stashRecoveryFailed && (
|
||||
<div className="flex items-start gap-2 p-3 rounded-md bg-green-500/10 border border-green-500/20">
|
||||
<Archive className="w-4 h-4 text-green-500 mt-0.5 flex-shrink-0" />
|
||||
<span className="text-green-600 dark:text-green-400 text-sm">
|
||||
Your stashed changes have been restored successfully.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pullResult?.stashed &&
|
||||
(!pullResult?.stashRestored || pullResult?.stashRecoveryFailed) && (
|
||||
|
||||
@@ -24,3 +24,8 @@ export { ViewStashesDialog } from './view-stashes-dialog';
|
||||
export { StashApplyConflictDialog } from './stash-apply-conflict-dialog';
|
||||
export { CherryPickDialog } from './cherry-pick-dialog';
|
||||
export { GitPullDialog } from './git-pull-dialog';
|
||||
export {
|
||||
BranchConflictDialog,
|
||||
type BranchConflictData,
|
||||
type BranchConflictType,
|
||||
} from './branch-conflict-dialog';
|
||||
|
||||
@@ -60,11 +60,6 @@ interface MergeRebaseDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
worktree: WorktreeInfo | null;
|
||||
onConfirm: (
|
||||
worktree: WorktreeInfo,
|
||||
remoteBranch: string,
|
||||
strategy: PullStrategy
|
||||
) => void | Promise<void>;
|
||||
onCreateConflictResolutionFeature?: (conflictInfo: MergeConflictInfo) => void;
|
||||
}
|
||||
|
||||
@@ -72,7 +67,6 @@ export function MergeRebaseDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
worktree,
|
||||
onConfirm,
|
||||
onCreateConflictResolutionFeature,
|
||||
}: MergeRebaseDialogProps) {
|
||||
const [remotes, setRemotes] = useState<RemoteInfo[]>([]);
|
||||
@@ -222,9 +216,6 @@ export function MergeRebaseDialog({
|
||||
strategy: 'rebase',
|
||||
});
|
||||
setStep('conflict');
|
||||
toast.error('Rebase conflicts detected', {
|
||||
description: 'Choose how to resolve the conflicts below.',
|
||||
});
|
||||
} else {
|
||||
toast.error('Rebase failed', {
|
||||
description: result.error || 'Unknown error',
|
||||
@@ -245,9 +236,6 @@ export function MergeRebaseDialog({
|
||||
strategy: 'merge',
|
||||
});
|
||||
setStep('conflict');
|
||||
toast.error('Merge conflicts detected', {
|
||||
description: 'Choose how to resolve the conflicts below.',
|
||||
});
|
||||
} else {
|
||||
toast.success(`Merged ${selectedBranch}`, {
|
||||
description: result.result.message || 'Merge completed successfully',
|
||||
@@ -268,53 +256,30 @@ export function MergeRebaseDialog({
|
||||
strategy: 'merge',
|
||||
});
|
||||
setStep('conflict');
|
||||
toast.error('Merge conflicts detected', {
|
||||
description: 'Choose how to resolve the conflicts below.',
|
||||
});
|
||||
} else {
|
||||
// Non-conflict failure - fall back to creating a feature task
|
||||
toast.info('Direct operation failed, creating AI task instead', {
|
||||
description: result.error || 'The operation will be handled by an AI agent.',
|
||||
// Non-conflict failure - show conflict resolution UI so user can choose
|
||||
// how to handle it (resolve manually or with AI) rather than auto-creating a task
|
||||
setConflictState({
|
||||
conflictFiles: [],
|
||||
remoteBranch: selectedBranch,
|
||||
strategy: 'merge',
|
||||
});
|
||||
try {
|
||||
await onConfirm(worktree, selectedBranch, selectedStrategy);
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
logger.error('Failed to create feature task:', err);
|
||||
setStep('select');
|
||||
}
|
||||
setStep('conflict');
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Failed to execute operation:', err);
|
||||
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
||||
const hasConflicts =
|
||||
errorMessage.toLowerCase().includes('conflict') || errorMessage.includes('CONFLICT');
|
||||
|
||||
if (hasConflicts) {
|
||||
setConflictState({
|
||||
conflictFiles: [],
|
||||
remoteBranch: selectedBranch,
|
||||
strategy: selectedStrategy,
|
||||
});
|
||||
setStep('conflict');
|
||||
} else {
|
||||
// Fall back to creating a feature task
|
||||
toast.info('Creating AI task to handle the operation', {
|
||||
description: 'The operation will be performed by an AI agent.',
|
||||
});
|
||||
try {
|
||||
await onConfirm(worktree, selectedBranch, selectedStrategy);
|
||||
onOpenChange(false);
|
||||
} catch (confirmErr) {
|
||||
logger.error('Failed to create feature task:', confirmErr);
|
||||
toast.error('Operation failed', { description: errorMessage });
|
||||
setStep('select');
|
||||
}
|
||||
}
|
||||
// Show conflict resolution UI so user can choose how to handle it
|
||||
setConflictState({
|
||||
conflictFiles: [],
|
||||
remoteBranch: selectedBranch,
|
||||
strategy: selectedStrategy,
|
||||
});
|
||||
setStep('conflict');
|
||||
}
|
||||
}, [worktree, selectedBranch, selectedStrategy, selectedRemote, onConfirm, onOpenChange]);
|
||||
}, [worktree, selectedBranch, selectedStrategy, selectedRemote, onOpenChange]);
|
||||
|
||||
const handleResolveWithAI = useCallback(() => {
|
||||
if (!worktree || !conflictState) return;
|
||||
@@ -329,13 +294,10 @@ export function MergeRebaseDialog({
|
||||
};
|
||||
|
||||
onCreateConflictResolutionFeature(conflictInfo);
|
||||
onOpenChange(false);
|
||||
} else {
|
||||
// Fallback: create via the onConfirm handler
|
||||
onConfirm(worktree, conflictState.remoteBranch, conflictState.strategy);
|
||||
onOpenChange(false);
|
||||
}
|
||||
}, [worktree, conflictState, onCreateConflictResolutionFeature, onConfirm, onOpenChange]);
|
||||
|
||||
onOpenChange(false);
|
||||
}, [worktree, conflictState, onCreateConflictResolutionFeature, onOpenChange]);
|
||||
|
||||
const handleResolveManually = useCallback(() => {
|
||||
toast.info('Conflict markers left in place', {
|
||||
|
||||
@@ -27,6 +27,7 @@ import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { TruncatedFilePath } from '@/components/ui/truncated-file-path';
|
||||
import type { FileStatus } from '@/types/electron';
|
||||
import { parseDiff, type ParsedFileDiff } from '@/lib/diff-utils';
|
||||
|
||||
interface WorktreeInfo {
|
||||
path: string;
|
||||
@@ -43,23 +44,6 @@ interface StashChangesDialogProps {
|
||||
onStashed?: () => void;
|
||||
}
|
||||
|
||||
interface ParsedDiffHunk {
|
||||
header: string;
|
||||
lines: {
|
||||
type: 'context' | 'addition' | 'deletion' | 'header';
|
||||
content: string;
|
||||
lineNumber?: { old?: number; new?: number };
|
||||
}[];
|
||||
}
|
||||
|
||||
interface ParsedFileDiff {
|
||||
filePath: string;
|
||||
hunks: ParsedDiffHunk[];
|
||||
isNew?: boolean;
|
||||
isDeleted?: boolean;
|
||||
isRenamed?: boolean;
|
||||
}
|
||||
|
||||
const getFileIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'A':
|
||||
@@ -117,101 +101,7 @@ const getStatusBadgeColor = (status: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse unified diff format into structured data
|
||||
*/
|
||||
function parseDiff(diffText: string): ParsedFileDiff[] {
|
||||
if (!diffText) return [];
|
||||
|
||||
const files: ParsedFileDiff[] = [];
|
||||
const lines = diffText.split('\n');
|
||||
let currentFile: ParsedFileDiff | null = null;
|
||||
let currentHunk: ParsedDiffHunk | null = null;
|
||||
let oldLineNum = 0;
|
||||
let newLineNum = 0;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
|
||||
// Skip trailing empty string produced by a final newline in diffText
|
||||
if (line === '' && i === lines.length - 1) continue;
|
||||
|
||||
if (line.startsWith('diff --git')) {
|
||||
if (currentFile) {
|
||||
if (currentHunk) currentFile.hunks.push(currentHunk);
|
||||
files.push(currentFile);
|
||||
}
|
||||
const match = line.match(/diff --git a\/(.*?) b\/(.*)/);
|
||||
currentFile = {
|
||||
filePath: match ? match[2] : 'unknown',
|
||||
hunks: [],
|
||||
};
|
||||
currentHunk = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('new file mode')) {
|
||||
if (currentFile) currentFile.isNew = true;
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith('deleted file mode')) {
|
||||
if (currentFile) currentFile.isDeleted = true;
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith('rename from') || line.startsWith('rename to')) {
|
||||
if (currentFile) currentFile.isRenamed = true;
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith('index ') || line.startsWith('--- ') || line.startsWith('+++ ')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('@@')) {
|
||||
if (currentHunk && currentFile) currentFile.hunks.push(currentHunk);
|
||||
const hunkMatch = line.match(/@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
|
||||
oldLineNum = hunkMatch ? parseInt(hunkMatch[1], 10) : 1;
|
||||
newLineNum = hunkMatch ? parseInt(hunkMatch[2], 10) : 1;
|
||||
currentHunk = {
|
||||
header: line,
|
||||
lines: [{ type: 'header', content: line }],
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentHunk) {
|
||||
if (line.startsWith('+')) {
|
||||
currentHunk.lines.push({
|
||||
type: 'addition',
|
||||
content: line.substring(1),
|
||||
lineNumber: { new: newLineNum },
|
||||
});
|
||||
newLineNum++;
|
||||
} else if (line.startsWith('-')) {
|
||||
currentHunk.lines.push({
|
||||
type: 'deletion',
|
||||
content: line.substring(1),
|
||||
lineNumber: { old: oldLineNum },
|
||||
});
|
||||
oldLineNum++;
|
||||
} else if (line.startsWith(' ') || line === '') {
|
||||
currentHunk.lines.push({
|
||||
type: 'context',
|
||||
content: line.substring(1) || '',
|
||||
lineNumber: { old: oldLineNum, new: newLineNum },
|
||||
});
|
||||
oldLineNum++;
|
||||
newLineNum++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (currentFile) {
|
||||
if (currentHunk) currentFile.hunks.push(currentHunk);
|
||||
files.push(currentFile);
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
// parseDiff is imported from @/lib/diff-utils
|
||||
|
||||
function DiffLine({
|
||||
type,
|
||||
@@ -316,6 +206,8 @@ export function StashChangesDialog({
|
||||
// Select all files by default
|
||||
if (!cancelled.current)
|
||||
setSelectedFiles(new Set(fileList.map((f: FileStatus) => f.path)));
|
||||
} else if (!cancelled.current) {
|
||||
setLoadDiffsError(result.error ?? 'Failed to load diffs');
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to load diffs for stash dialog:', err);
|
||||
@@ -365,7 +257,7 @@ export function StashChangesDialog({
|
||||
setExpandedFile((prev) => (prev === filePath ? null : filePath));
|
||||
}, []);
|
||||
|
||||
const handleStash = async () => {
|
||||
const handleStash = useCallback(async () => {
|
||||
if (!worktree || selectedFiles.size === 0) return;
|
||||
|
||||
setIsStashing(true);
|
||||
@@ -405,14 +297,17 @@ export function StashChangesDialog({
|
||||
} finally {
|
||||
setIsStashing(false);
|
||||
}
|
||||
};
|
||||
}, [worktree, selectedFiles, files.length, message, onOpenChange, onStashed]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && !isStashing && selectedFiles.size > 0) {
|
||||
e.preventDefault();
|
||||
handleStash();
|
||||
}
|
||||
};
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && !isStashing && selectedFiles.size > 0) {
|
||||
e.preventDefault();
|
||||
handleStash();
|
||||
}
|
||||
},
|
||||
[isStashing, selectedFiles.size, handleStash]
|
||||
);
|
||||
|
||||
if (!worktree) return null;
|
||||
|
||||
@@ -614,7 +509,13 @@ export function StashChangesDialog({
|
||||
<p className="text-xs text-muted-foreground">
|
||||
A descriptive message helps identify this stash later. Press{' '}
|
||||
<kbd className="px-1 py-0.5 text-[10px] bg-muted rounded border">
|
||||
{navigator.platform.includes('Mac') ? '⌘' : 'Ctrl'}+Enter
|
||||
{typeof navigator !== 'undefined' &&
|
||||
((navigator as any).userAgentData?.platform || navigator.platform || '').includes(
|
||||
'Mac'
|
||||
)
|
||||
? '⌘'
|
||||
: 'Ctrl'}
|
||||
+Enter
|
||||
</kbd>{' '}
|
||||
to stash.
|
||||
</p>
|
||||
|
||||
@@ -48,6 +48,9 @@ export function ViewWorktreeChangesDialog({
|
||||
{worktree.changedFilesCount > 1 ? 's' : ''} changed)
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-1 text-xs text-muted-foreground">
|
||||
— Use the Stage/Unstage buttons to prepare files for commit.
|
||||
</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -58,6 +61,8 @@ export function ViewWorktreeChangesDialog({
|
||||
featureId={worktree.branch}
|
||||
useWorktrees={true}
|
||||
compact={false}
|
||||
enableStaging={true}
|
||||
worktreePath={worktree.path}
|
||||
className="mt-4"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -37,6 +37,9 @@ import {
|
||||
History,
|
||||
Archive,
|
||||
Cherry,
|
||||
AlertTriangle,
|
||||
XCircle,
|
||||
CheckCircle,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -112,6 +115,10 @@ interface WorktreeActionsDropdownProps {
|
||||
onViewStashes?: (worktree: WorktreeInfo) => void;
|
||||
/** Cherry-pick commits from another branch */
|
||||
onCherryPick?: (worktree: WorktreeInfo) => void;
|
||||
/** Abort an in-progress merge/rebase/cherry-pick */
|
||||
onAbortOperation?: (worktree: WorktreeInfo) => void;
|
||||
/** Continue an in-progress merge/rebase/cherry-pick after resolving conflicts */
|
||||
onContinueOperation?: (worktree: WorktreeInfo) => void;
|
||||
hasInitScript: boolean;
|
||||
}
|
||||
|
||||
@@ -162,6 +169,8 @@ export function WorktreeActionsDropdown({
|
||||
onStashChanges,
|
||||
onViewStashes,
|
||||
onCherryPick,
|
||||
onAbortOperation,
|
||||
onContinueOperation,
|
||||
hasInitScript,
|
||||
}: WorktreeActionsDropdownProps) {
|
||||
// Get available editors for the "Open In" submenu
|
||||
@@ -233,6 +242,61 @@ export function WorktreeActionsDropdown({
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-56">
|
||||
{/* Conflict indicator and actions when merge/rebase/cherry-pick is in progress */}
|
||||
{worktree.hasConflicts && (
|
||||
<>
|
||||
<DropdownMenuLabel className="text-xs flex items-center gap-2 text-red-600 dark:text-red-400">
|
||||
<AlertTriangle className="w-3.5 h-3.5" />
|
||||
{worktree.conflictType === 'merge'
|
||||
? 'Merge'
|
||||
: worktree.conflictType === 'rebase'
|
||||
? 'Rebase'
|
||||
: worktree.conflictType === 'cherry-pick'
|
||||
? 'Cherry-pick'
|
||||
: 'Operation'}{' '}
|
||||
Conflicts
|
||||
{worktree.conflictFiles && worktree.conflictFiles.length > 0 && (
|
||||
<span className="ml-auto text-[10px] bg-red-500/20 text-red-600 dark:text-red-400 px-1.5 py-0.5 rounded">
|
||||
{worktree.conflictFiles.length} file
|
||||
{worktree.conflictFiles.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenuLabel>
|
||||
{onAbortOperation && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => onAbortOperation(worktree)}
|
||||
className="text-xs text-destructive focus:text-destructive"
|
||||
>
|
||||
<XCircle className="w-3.5 h-3.5 mr-2" />
|
||||
Abort{' '}
|
||||
{worktree.conflictType === 'merge'
|
||||
? 'Merge'
|
||||
: worktree.conflictType === 'rebase'
|
||||
? 'Rebase'
|
||||
: worktree.conflictType === 'cherry-pick'
|
||||
? 'Cherry-pick'
|
||||
: 'Operation'}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onContinueOperation && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => onContinueOperation(worktree)}
|
||||
className="text-xs text-green-600 focus:text-green-700"
|
||||
>
|
||||
<CheckCircle className="w-3.5 h-3.5 mr-2" />
|
||||
Continue{' '}
|
||||
{worktree.conflictType === 'merge'
|
||||
? 'Merge'
|
||||
: worktree.conflictType === 'rebase'
|
||||
? 'Rebase'
|
||||
: worktree.conflictType === 'cherry-pick'
|
||||
? 'Cherry-pick'
|
||||
: 'Operation'}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
{/* Loading indicator while git status is being determined */}
|
||||
{isLoadingGitStatus && (
|
||||
<>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { DropdownMenuItem } from '@/components/ui/dropdown-menu';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { Check, CircleDot, Globe, GitPullRequest, FlaskConical } from 'lucide-react';
|
||||
import { Check, CircleDot, Globe, GitPullRequest, FlaskConical, AlertTriangle } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { WorktreeInfo, DevServerInfo, TestSessionInfo } from '../types';
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
truncateBranchName,
|
||||
getPRBadgeStyles,
|
||||
getChangesBadgeStyles,
|
||||
getConflictBadgeStyles,
|
||||
getConflictTypeLabel,
|
||||
getTestStatusStyles,
|
||||
} from './worktree-indicator-utils';
|
||||
|
||||
@@ -182,6 +184,20 @@ export function WorktreeDropdownItem({
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Conflict indicator */}
|
||||
{worktree.hasConflicts && (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded border',
|
||||
getConflictBadgeStyles()
|
||||
)}
|
||||
title={`${getConflictTypeLabel(worktree.conflictType)} conflicts${worktree.conflictFiles?.length ? ` (${worktree.conflictFiles.length} files)` : ''}`}
|
||||
>
|
||||
<AlertTriangle className="w-2.5 h-2.5 mr-0.5" />
|
||||
{getConflictTypeLabel(worktree.conflictType)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* PR indicator */}
|
||||
{pr && (
|
||||
<span
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
Globe,
|
||||
GitPullRequest,
|
||||
FlaskConical,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -34,6 +35,8 @@ import {
|
||||
truncateBranchName,
|
||||
getPRBadgeStyles,
|
||||
getChangesBadgeStyles,
|
||||
getConflictBadgeStyles,
|
||||
getConflictTypeLabel,
|
||||
getTestStatusStyles,
|
||||
} from './worktree-indicator-utils';
|
||||
|
||||
@@ -114,6 +117,10 @@ export interface WorktreeDropdownProps {
|
||||
onViewStashes?: (worktree: WorktreeInfo) => void;
|
||||
/** Cherry-pick commits from another branch */
|
||||
onCherryPick?: (worktree: WorktreeInfo) => void;
|
||||
/** Abort an in-progress merge/rebase/cherry-pick */
|
||||
onAbortOperation?: (worktree: WorktreeInfo) => void;
|
||||
/** Continue an in-progress merge/rebase/cherry-pick after resolving conflicts */
|
||||
onContinueOperation?: (worktree: WorktreeInfo) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -195,6 +202,8 @@ export function WorktreeDropdown({
|
||||
onStashChanges,
|
||||
onViewStashes,
|
||||
onCherryPick,
|
||||
onAbortOperation,
|
||||
onContinueOperation,
|
||||
}: WorktreeDropdownProps) {
|
||||
// Find the currently selected worktree to display in the trigger
|
||||
const selectedWorktree = worktrees.find((w) => isWorktreeSelected(w));
|
||||
@@ -323,6 +332,20 @@ export function WorktreeDropdown({
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Conflict indicator */}
|
||||
{selectedWorktree?.hasConflicts && (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center h-4 min-w-4 px-1 text-[10px] font-medium rounded border shrink-0',
|
||||
getConflictBadgeStyles()
|
||||
)}
|
||||
title={`${getConflictTypeLabel(selectedWorktree.conflictType)} conflicts detected`}
|
||||
>
|
||||
<AlertTriangle className="w-2.5 h-2.5 mr-0.5" />
|
||||
{getConflictTypeLabel(selectedWorktree.conflictType)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* PR badge */}
|
||||
{selectedWorktree?.pr && (
|
||||
<span
|
||||
@@ -487,6 +510,8 @@ export function WorktreeDropdown({
|
||||
onStashChanges={onStashChanges}
|
||||
onViewStashes={onViewStashes}
|
||||
onCherryPick={onCherryPick}
|
||||
onAbortOperation={onAbortOperation}
|
||||
onContinueOperation={onContinueOperation}
|
||||
hasInitScript={hasInitScript}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -46,6 +46,30 @@ export function getChangesBadgeStyles(): string {
|
||||
return 'bg-amber-500/20 text-amber-600 dark:text-amber-400 border-amber-500/30';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the CSS classes for the conflict indicator badge.
|
||||
* Uses red/destructive colors to indicate merge/rebase/cherry-pick conflicts.
|
||||
*/
|
||||
export function getConflictBadgeStyles(): string {
|
||||
return 'bg-red-500/20 text-red-600 dark:text-red-400 border-red-500/30';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a human-readable label for the conflict type.
|
||||
*/
|
||||
export function getConflictTypeLabel(conflictType?: 'merge' | 'rebase' | 'cherry-pick'): string {
|
||||
switch (conflictType) {
|
||||
case 'merge':
|
||||
return 'Merge';
|
||||
case 'rebase':
|
||||
return 'Rebase';
|
||||
case 'cherry-pick':
|
||||
return 'Cherry-pick';
|
||||
default:
|
||||
return 'Conflict';
|
||||
}
|
||||
}
|
||||
|
||||
/** Possible test session status values */
|
||||
export type TestStatus = 'pending' | 'running' | 'passed' | 'failed' | 'cancelled';
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { JSX } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Globe, CircleDot, GitPullRequest } from 'lucide-react';
|
||||
import { Globe, CircleDot, GitPullRequest, AlertTriangle } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
} from '../types';
|
||||
import { BranchSwitchDropdown } from './branch-switch-dropdown';
|
||||
import { WorktreeActionsDropdown } from './worktree-actions-dropdown';
|
||||
import { getConflictBadgeStyles, getConflictTypeLabel } from './worktree-indicator-utils';
|
||||
|
||||
interface WorktreeTabProps {
|
||||
worktree: WorktreeInfo;
|
||||
@@ -85,6 +86,10 @@ interface WorktreeTabProps {
|
||||
onViewStashes?: (worktree: WorktreeInfo) => void;
|
||||
/** Cherry-pick commits from another branch */
|
||||
onCherryPick?: (worktree: WorktreeInfo) => void;
|
||||
/** Abort an in-progress merge/rebase/cherry-pick */
|
||||
onAbortOperation?: (worktree: WorktreeInfo) => void;
|
||||
/** Continue an in-progress merge/rebase/cherry-pick after resolving conflicts */
|
||||
onContinueOperation?: (worktree: WorktreeInfo) => void;
|
||||
hasInitScript: boolean;
|
||||
/** Whether a test command is configured in project settings */
|
||||
hasTestCommand?: boolean;
|
||||
@@ -149,6 +154,8 @@ export function WorktreeTab({
|
||||
onStashChanges,
|
||||
onViewStashes,
|
||||
onCherryPick,
|
||||
onAbortOperation,
|
||||
onContinueOperation,
|
||||
hasInitScript,
|
||||
hasTestCommand = false,
|
||||
}: WorktreeTabProps) {
|
||||
@@ -304,6 +311,29 @@ export function WorktreeTab({
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{worktree.hasConflicts && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded border',
|
||||
isSelected ? 'bg-red-500 text-white border-red-400' : getConflictBadgeStyles()
|
||||
)}
|
||||
>
|
||||
<AlertTriangle className="w-2.5 h-2.5 mr-0.5" />
|
||||
{getConflictTypeLabel(worktree.conflictType)}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
{getConflictTypeLabel(worktree.conflictType)} conflicts detected
|
||||
{worktree.conflictFiles && worktree.conflictFiles.length > 0
|
||||
? ` (${worktree.conflictFiles.length} file${worktree.conflictFiles.length !== 1 ? 's' : ''})`
|
||||
: ''}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{prBadge}
|
||||
</Button>
|
||||
<BranchSwitchDropdown
|
||||
@@ -371,6 +401,29 @@ export function WorktreeTab({
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{worktree.hasConflicts && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded border',
|
||||
isSelected ? 'bg-red-500 text-white border-red-400' : getConflictBadgeStyles()
|
||||
)}
|
||||
>
|
||||
<AlertTriangle className="w-2.5 h-2.5 mr-0.5" />
|
||||
{getConflictTypeLabel(worktree.conflictType)}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
{getConflictTypeLabel(worktree.conflictType)} conflicts detected
|
||||
{worktree.conflictFiles && worktree.conflictFiles.length > 0
|
||||
? ` (${worktree.conflictFiles.length} file${worktree.conflictFiles.length !== 1 ? 's' : ''})`
|
||||
: ''}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{prBadge}
|
||||
</Button>
|
||||
)}
|
||||
@@ -463,6 +516,8 @@ export function WorktreeTab({
|
||||
onStashChanges={onStashChanges}
|
||||
onViewStashes={onViewStashes}
|
||||
onCherryPick={onCherryPick}
|
||||
onAbortOperation={onAbortOperation}
|
||||
onContinueOperation={onContinueOperation}
|
||||
hasInitScript={hasInitScript}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,12 @@ export interface WorktreeInfo {
|
||||
hasChanges?: boolean;
|
||||
changedFilesCount?: number;
|
||||
pr?: WorktreePRInfo;
|
||||
/** Whether a merge, rebase, or cherry-pick is in progress with conflicts */
|
||||
hasConflicts?: boolean;
|
||||
/** Type of conflict operation in progress */
|
||||
conflictType?: 'merge' | 'rebase' | 'cherry-pick';
|
||||
/** List of files with conflicts */
|
||||
conflictFiles?: string[];
|
||||
}
|
||||
|
||||
export interface BranchInfo {
|
||||
@@ -81,7 +87,7 @@ export interface MergeConflictInfo {
|
||||
/** List of files with conflicts, if available */
|
||||
conflictFiles?: string[];
|
||||
/** Type of operation that caused the conflict */
|
||||
operationType?: 'merge' | 'rebase';
|
||||
operationType?: 'merge' | 'rebase' | 'cherry-pick';
|
||||
}
|
||||
|
||||
export interface BranchSwitchConflictInfo {
|
||||
|
||||
@@ -542,6 +542,48 @@ export function WorktreePanel({
|
||||
fetchWorktrees({ silent: true });
|
||||
}, [fetchWorktrees]);
|
||||
|
||||
// Handle aborting an in-progress merge/rebase/cherry-pick
|
||||
const handleAbortOperation = useCallback(
|
||||
async (worktree: WorktreeInfo) => {
|
||||
try {
|
||||
const api = getHttpApiClient();
|
||||
const result = await api.worktree.abortOperation(worktree.path);
|
||||
if (result.success && result.result) {
|
||||
toast.success(result.result.message || 'Operation aborted successfully');
|
||||
fetchWorktrees({ silent: true });
|
||||
} else {
|
||||
toast.error(result.error || 'Failed to abort operation');
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to abort operation', {
|
||||
description: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
},
|
||||
[fetchWorktrees]
|
||||
);
|
||||
|
||||
// Handle continuing an in-progress merge/rebase/cherry-pick after conflict resolution
|
||||
const handleContinueOperation = useCallback(
|
||||
async (worktree: WorktreeInfo) => {
|
||||
try {
|
||||
const api = getHttpApiClient();
|
||||
const result = await api.worktree.continueOperation(worktree.path);
|
||||
if (result.success && result.result) {
|
||||
toast.success(result.result.message || 'Operation continued successfully');
|
||||
fetchWorktrees({ silent: true });
|
||||
} else {
|
||||
toast.error(result.error || 'Failed to continue operation');
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to continue operation', {
|
||||
description: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
},
|
||||
[fetchWorktrees]
|
||||
);
|
||||
|
||||
// Handle opening the log panel for a specific worktree
|
||||
const handleViewDevServerLogs = useCallback((worktree: WorktreeInfo) => {
|
||||
setLogPanelWorktree(worktree);
|
||||
@@ -771,6 +813,8 @@ export function WorktreePanel({
|
||||
onStashChanges={handleStashChanges}
|
||||
onViewStashes={handleViewStashes}
|
||||
onCherryPick={handleCherryPick}
|
||||
onAbortOperation={handleAbortOperation}
|
||||
onContinueOperation={handleContinueOperation}
|
||||
hasInitScript={hasInitScript}
|
||||
/>
|
||||
)}
|
||||
@@ -989,6 +1033,8 @@ export function WorktreePanel({
|
||||
onStashChanges={handleStashChanges}
|
||||
onViewStashes={handleViewStashes}
|
||||
onCherryPick={handleCherryPick}
|
||||
onAbortOperation={handleAbortOperation}
|
||||
onContinueOperation={handleContinueOperation}
|
||||
/>
|
||||
|
||||
{useWorktreesEnabled && (
|
||||
@@ -1086,6 +1132,8 @@ export function WorktreePanel({
|
||||
onStashChanges={handleStashChanges}
|
||||
onViewStashes={handleViewStashes}
|
||||
onCherryPick={handleCherryPick}
|
||||
onAbortOperation={handleAbortOperation}
|
||||
onContinueOperation={handleContinueOperation}
|
||||
hasInitScript={hasInitScript}
|
||||
hasTestCommand={hasTestCommand}
|
||||
/>
|
||||
@@ -1163,6 +1211,8 @@ export function WorktreePanel({
|
||||
onStashChanges={handleStashChanges}
|
||||
onViewStashes={handleViewStashes}
|
||||
onCherryPick={handleCherryPick}
|
||||
onAbortOperation={handleAbortOperation}
|
||||
onContinueOperation={handleContinueOperation}
|
||||
hasInitScript={hasInitScript}
|
||||
hasTestCommand={hasTestCommand}
|
||||
/>
|
||||
|
||||
@@ -197,8 +197,43 @@ export function OpencodeModelConfiguration({
|
||||
onDynamicModelToggle,
|
||||
isLoadingDynamicModels = false,
|
||||
}: OpencodeModelConfigurationProps) {
|
||||
// Determine the free tier models to display.
|
||||
// When dynamic models are available from CLI, use the opencode provider models
|
||||
// from the dynamic list (they reflect the actual currently-available models).
|
||||
// Fall back to the hardcoded OPENCODE_MODELS only when CLI hasn't returned data.
|
||||
const dynamicOpencodeFreeModels = useMemo(() => {
|
||||
const opencodeModelsFromCli = dynamicModels.filter((m) => m.provider === 'opencode');
|
||||
if (opencodeModelsFromCli.length === 0) return null;
|
||||
|
||||
// Convert dynamic ModelDefinition to OpencodeModelConfig for the static section
|
||||
return opencodeModelsFromCli.map(
|
||||
(m): OpencodeModelConfig => ({
|
||||
id: m.id.replace('opencode/', 'opencode-') as OpencodeModelId,
|
||||
label: m.name.replace(/\s*\(Free\)\s*$/, '').replace(/\s*\(OpenCode\)\s*$/, ''),
|
||||
description: m.description,
|
||||
supportsVision: m.supportsVision ?? false,
|
||||
provider: 'opencode' as OpencodeProvider,
|
||||
tier: 'free',
|
||||
})
|
||||
);
|
||||
}, [dynamicModels]);
|
||||
|
||||
// Use dynamically discovered free tier models when available, otherwise hardcoded fallback
|
||||
const effectiveStaticModels = dynamicOpencodeFreeModels ?? OPENCODE_MODELS;
|
||||
|
||||
// Build an effective config map that includes dynamic models (for default model dropdown lookup)
|
||||
const effectiveModelConfigMap = useMemo(() => {
|
||||
const map = { ...OPENCODE_MODEL_CONFIG_MAP };
|
||||
if (dynamicOpencodeFreeModels) {
|
||||
for (const model of dynamicOpencodeFreeModels) {
|
||||
map[model.id] = model;
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [dynamicOpencodeFreeModels]);
|
||||
|
||||
// Group static models by provider for organized display
|
||||
const modelsByProvider = OPENCODE_MODELS.reduce(
|
||||
const modelsByProvider = effectiveStaticModels.reduce(
|
||||
(acc, model) => {
|
||||
if (!acc[model.provider]) {
|
||||
acc[model.provider] = [];
|
||||
@@ -217,7 +252,7 @@ export function OpencodeModelConfiguration({
|
||||
const [dynamicProviderSearch, setDynamicProviderSearch] = useState('');
|
||||
const normalizedDynamicSearch = dynamicProviderSearch.trim().toLowerCase();
|
||||
const hasDynamicSearch = normalizedDynamicSearch.length > 0;
|
||||
const allStaticModelIds = OPENCODE_MODELS.map((model) => model.id);
|
||||
const allStaticModelIds = effectiveStaticModels.map((model) => model.id);
|
||||
const selectableStaticModelIds = allStaticModelIds.filter(
|
||||
(modelId) => modelId !== opencodeDefaultModel
|
||||
);
|
||||
@@ -378,7 +413,7 @@ export function OpencodeModelConfiguration({
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{enabledOpencodeModels.map((modelId) => {
|
||||
const model = OPENCODE_MODEL_CONFIG_MAP[modelId];
|
||||
const model = effectiveModelConfigMap[modelId];
|
||||
if (!model) return null;
|
||||
const ModelIconComponent = getModelIcon(modelId);
|
||||
return (
|
||||
|
||||
@@ -22,6 +22,12 @@ interface WorktreeInfo {
|
||||
changedFilesCount?: number;
|
||||
featureId?: string;
|
||||
linkedToBranch?: string;
|
||||
/** Whether a merge, rebase, or cherry-pick is in progress with conflicts */
|
||||
hasConflicts?: boolean;
|
||||
/** Type of conflict operation in progress */
|
||||
conflictType?: 'merge' | 'rebase' | 'cherry-pick';
|
||||
/** List of files with conflicts */
|
||||
conflictFiles?: string[];
|
||||
}
|
||||
|
||||
interface RemovedWorktree {
|
||||
|
||||
@@ -36,6 +36,7 @@ export function formatModelName(model: string): string {
|
||||
// Claude models
|
||||
if (model.includes('opus-4-6') || model === 'claude-opus') return 'Opus 4.6';
|
||||
if (model.includes('opus')) return 'Opus 4.5';
|
||||
if (model.includes('sonnet-4-6') || model === 'claude-sonnet') return 'Sonnet 4.6';
|
||||
if (model.includes('sonnet')) return 'Sonnet 4.5';
|
||||
if (model.includes('haiku')) return 'Haiku 4.5';
|
||||
|
||||
|
||||
133
apps/ui/src/lib/diff-utils.ts
Normal file
133
apps/ui/src/lib/diff-utils.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Shared diff parsing utilities.
|
||||
*
|
||||
* Extracted from commit-worktree-dialog, discard-worktree-changes-dialog,
|
||||
* stash-changes-dialog and git-diff-panel to eliminate duplication.
|
||||
*/
|
||||
|
||||
export interface ParsedDiffHunk {
|
||||
header: string;
|
||||
lines: {
|
||||
type: 'context' | 'addition' | 'deletion' | 'header';
|
||||
content: string;
|
||||
lineNumber?: { old?: number; new?: number };
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface ParsedFileDiff {
|
||||
filePath: string;
|
||||
hunks: ParsedDiffHunk[];
|
||||
isNew?: boolean;
|
||||
isDeleted?: boolean;
|
||||
isRenamed?: boolean;
|
||||
/** Pre-computed count of added lines across all hunks */
|
||||
additions: number;
|
||||
/** Pre-computed count of deleted lines across all hunks */
|
||||
deletions: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse unified diff format into structured data.
|
||||
*
|
||||
* Note: The regex `diff --git a\/(.*?) b\/(.*)` uses a non-greedy match for
|
||||
* the `a/` path and a greedy match for `b/`. This can mis-handle paths that
|
||||
* literally contain " b/" or are quoted by git. In practice this covers the
|
||||
* vast majority of real-world paths; exotic cases will fall back to "unknown".
|
||||
*/
|
||||
export function parseDiff(diffText: string): ParsedFileDiff[] {
|
||||
if (!diffText) return [];
|
||||
|
||||
const files: ParsedFileDiff[] = [];
|
||||
const lines = diffText.split('\n');
|
||||
let currentFile: ParsedFileDiff | null = null;
|
||||
let currentHunk: ParsedDiffHunk | null = null;
|
||||
let oldLineNum = 0;
|
||||
let newLineNum = 0;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
|
||||
if (line.startsWith('diff --git')) {
|
||||
if (currentFile) {
|
||||
if (currentHunk) currentFile.hunks.push(currentHunk);
|
||||
files.push(currentFile);
|
||||
}
|
||||
const match = line.match(/diff --git a\/(.*?) b\/(.*)/);
|
||||
currentFile = {
|
||||
filePath: match ? match[2] : 'unknown',
|
||||
hunks: [],
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
};
|
||||
currentHunk = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('new file mode')) {
|
||||
if (currentFile) currentFile.isNew = true;
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith('deleted file mode')) {
|
||||
if (currentFile) currentFile.isDeleted = true;
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith('rename from') || line.startsWith('rename to')) {
|
||||
if (currentFile) currentFile.isRenamed = true;
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith('index ') || line.startsWith('--- ') || line.startsWith('+++ ')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('@@')) {
|
||||
if (currentHunk && currentFile) currentFile.hunks.push(currentHunk);
|
||||
const hunkMatch = line.match(/@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
|
||||
oldLineNum = hunkMatch ? parseInt(hunkMatch[1], 10) : 1;
|
||||
newLineNum = hunkMatch ? parseInt(hunkMatch[2], 10) : 1;
|
||||
currentHunk = {
|
||||
header: line,
|
||||
lines: [{ type: 'header', content: line }],
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentHunk) {
|
||||
// Skip trailing empty line produced by split('\n') to avoid phantom context line
|
||||
if (line === '' && i === lines.length - 1) {
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith('+')) {
|
||||
currentHunk.lines.push({
|
||||
type: 'addition',
|
||||
content: line.substring(1),
|
||||
lineNumber: { new: newLineNum },
|
||||
});
|
||||
newLineNum++;
|
||||
if (currentFile) currentFile.additions++;
|
||||
} else if (line.startsWith('-')) {
|
||||
currentHunk.lines.push({
|
||||
type: 'deletion',
|
||||
content: line.substring(1),
|
||||
lineNumber: { old: oldLineNum },
|
||||
});
|
||||
oldLineNum++;
|
||||
if (currentFile) currentFile.deletions++;
|
||||
} else if (line.startsWith(' ') || line === '') {
|
||||
currentHunk.lines.push({
|
||||
type: 'context',
|
||||
content: line.substring(1) || '',
|
||||
lineNumber: { old: oldLineNum, new: newLineNum },
|
||||
});
|
||||
oldLineNum++;
|
||||
newLineNum++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (currentFile) {
|
||||
if (currentHunk) currentFile.hunks.push(currentHunk);
|
||||
files.push(currentFile);
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
@@ -2259,6 +2259,17 @@ function createMockWorktreeAPI(): WorktreeAPI {
|
||||
};
|
||||
},
|
||||
|
||||
stageFiles: async (worktreePath: string, files: string[], operation: 'stage' | 'unstage') => {
|
||||
console.log('[Mock] Stage files:', { worktreePath, files, operation });
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
operation,
|
||||
filesCount: files.length,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
pull: async (worktreePath: string, remote?: string, stashIfNeeded?: boolean) => {
|
||||
const targetRemote = remote || 'origin';
|
||||
console.log('[Mock] Pulling latest changes for:', {
|
||||
@@ -2760,6 +2771,28 @@ function createMockWorktreeAPI(): WorktreeAPI {
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
abortOperation: async (worktreePath: string) => {
|
||||
console.log('[Mock] Abort operation:', { worktreePath });
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
operation: 'merge',
|
||||
message: 'Merge aborted successfully',
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
continueOperation: async (worktreePath: string) => {
|
||||
console.log('[Mock] Continue operation:', { worktreePath });
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
operation: 'merge',
|
||||
message: 'Merge continued successfully',
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2787,6 +2820,17 @@ function createMockGitAPI(): GitAPI {
|
||||
filePath,
|
||||
};
|
||||
},
|
||||
|
||||
stageFiles: async (projectPath: string, files: string[], operation: 'stage' | 'unstage') => {
|
||||
console.log('[Mock] Git stage files:', { projectPath, files, operation });
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
operation,
|
||||
filesCount: files.length,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -2135,6 +2135,8 @@ export class HttpApiClient implements ElectronAPI {
|
||||
featureId,
|
||||
filePath,
|
||||
}),
|
||||
stageFiles: (worktreePath: string, files: string[], operation: 'stage' | 'unstage') =>
|
||||
this.post('/api/worktree/stage-files', { worktreePath, files, operation }),
|
||||
pull: (worktreePath: string, remote?: string, stashIfNeeded?: boolean) =>
|
||||
this.post('/api/worktree/pull', { worktreePath, remote, stashIfNeeded }),
|
||||
checkoutBranch: (worktreePath: string, branchName: string, baseBranch?: string) =>
|
||||
@@ -2232,6 +2234,10 @@ export class HttpApiClient implements ElectronAPI {
|
||||
this.post('/api/worktree/cherry-pick', { worktreePath, commitHashes, options }),
|
||||
rebase: (worktreePath: string, ontoBranch: string) =>
|
||||
this.post('/api/worktree/rebase', { worktreePath, ontoBranch }),
|
||||
abortOperation: (worktreePath: string) =>
|
||||
this.post('/api/worktree/abort-operation', { worktreePath }),
|
||||
continueOperation: (worktreePath: string) =>
|
||||
this.post('/api/worktree/continue-operation', { worktreePath }),
|
||||
getBranchCommitLog: (worktreePath: string, branchName?: string, limit?: number) =>
|
||||
this.post('/api/worktree/branch-commit-log', { worktreePath, branchName, limit }),
|
||||
getTestLogs: (worktreePath?: string, sessionId?: string): Promise<TestLogsResponse> => {
|
||||
@@ -2263,6 +2269,8 @@ export class HttpApiClient implements ElectronAPI {
|
||||
getDiffs: (projectPath: string) => this.post('/api/git/diffs', { projectPath }),
|
||||
getFileDiff: (projectPath: string, filePath: string) =>
|
||||
this.post('/api/git/file-diff', { projectPath, filePath }),
|
||||
stageFiles: (projectPath: string, files: string[], operation: 'stage' | 'unstage') =>
|
||||
this.post('/api/git/stage-files', { projectPath, files, operation }),
|
||||
};
|
||||
|
||||
// Spec Regeneration API
|
||||
|
||||
32
apps/ui/src/types/electron.d.ts
vendored
32
apps/ui/src/types/electron.d.ts
vendored
@@ -755,6 +755,10 @@ export interface FileStatus {
|
||||
status: string;
|
||||
path: string;
|
||||
statusText: string;
|
||||
/** Raw staging area (index) status character from git porcelain format */
|
||||
indexStatus?: string;
|
||||
/** Raw working tree status character from git porcelain format */
|
||||
workTreeStatus?: string;
|
||||
}
|
||||
|
||||
export interface FileDiffsResult {
|
||||
@@ -985,6 +989,20 @@ export interface WorktreeAPI {
|
||||
filePath: string
|
||||
) => Promise<FileDiffResult>;
|
||||
|
||||
// Stage or unstage files in a worktree
|
||||
stageFiles: (
|
||||
worktreePath: string,
|
||||
files: string[],
|
||||
operation: 'stage' | 'unstage'
|
||||
) => Promise<{
|
||||
success: boolean;
|
||||
result?: {
|
||||
operation: 'stage' | 'unstage';
|
||||
filesCount: number;
|
||||
};
|
||||
error?: string;
|
||||
}>;
|
||||
|
||||
// Pull latest changes from remote with optional stash management
|
||||
pull: (
|
||||
worktreePath: string,
|
||||
@@ -1622,6 +1640,20 @@ export interface GitAPI {
|
||||
|
||||
// Get diff for a specific file in the main project
|
||||
getFileDiff: (projectPath: string, filePath: string) => Promise<FileDiffResult>;
|
||||
|
||||
// Stage or unstage files in the main project
|
||||
stageFiles: (
|
||||
projectPath: string,
|
||||
files: string[],
|
||||
operation: 'stage' | 'unstage'
|
||||
) => Promise<{
|
||||
success: boolean;
|
||||
result?: {
|
||||
operation: 'stage' | 'unstage';
|
||||
filesCount: number;
|
||||
};
|
||||
error?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
// Model definition type
|
||||
|
||||
@@ -99,6 +99,8 @@ export function parseGitStatus(statusOutput: string): FileStatus[] {
|
||||
status: primaryStatus,
|
||||
path: filePath,
|
||||
statusText: getStatusText(indexStatus, workTreeStatus),
|
||||
indexStatus,
|
||||
workTreeStatus,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -70,4 +70,8 @@ export interface FileStatus {
|
||||
status: string;
|
||||
path: string;
|
||||
statusText: string;
|
||||
/** Raw staging area (index) status character from git porcelain format */
|
||||
indexStatus?: string;
|
||||
/** Raw working tree status character from git porcelain format */
|
||||
workTreeStatus?: string;
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
*
|
||||
* With canonical model IDs:
|
||||
* - Cursor: cursor-auto, cursor-composer-1, cursor-gpt-5.2
|
||||
* - OpenCode: opencode-big-pickle, opencode-grok-code
|
||||
* - OpenCode: opencode-big-pickle, opencode-kimi-k2.5-free
|
||||
* - Copilot: copilot-gpt-5.1, copilot-claude-sonnet-4.5, copilot-gemini-3-pro-preview
|
||||
* - Gemini: gemini-2.5-flash, gemini-2.5-pro
|
||||
* - Claude: claude-haiku, claude-sonnet, claude-opus (also supports legacy aliases)
|
||||
@@ -110,7 +110,7 @@ export function resolveModelString(
|
||||
return resolved;
|
||||
}
|
||||
|
||||
// Full Claude model string (e.g., claude-sonnet-4-5-20250929) - pass through
|
||||
// Full Claude model string (e.g., claude-sonnet-4-6) - pass through
|
||||
if (canonicalKey.includes('claude-')) {
|
||||
console.log(`[ModelResolver] Using full Claude model string: ${canonicalKey}`);
|
||||
return canonicalKey;
|
||||
|
||||
@@ -42,7 +42,7 @@ describe('model-resolver', () => {
|
||||
|
||||
describe('with full Claude model strings', () => {
|
||||
it('should pass through full Claude model string unchanged', () => {
|
||||
const fullModel = 'claude-sonnet-4-20250514';
|
||||
const fullModel = 'claude-sonnet-4-6';
|
||||
const result = resolveModelString(fullModel);
|
||||
|
||||
expect(result).toBe(fullModel);
|
||||
@@ -243,7 +243,7 @@ describe('model-resolver', () => {
|
||||
describe('priority handling', () => {
|
||||
it('should prioritize explicit model over all others', () => {
|
||||
const explicit = 'claude-opus-4-20241113';
|
||||
const session = 'claude-sonnet-4-20250514';
|
||||
const session = 'claude-sonnet-4-6';
|
||||
const defaultModel = 'claude-3-5-haiku-20241022';
|
||||
|
||||
const result = getEffectiveModel(explicit, session, defaultModel);
|
||||
@@ -252,7 +252,7 @@ describe('model-resolver', () => {
|
||||
});
|
||||
|
||||
it('should use session model when explicit is undefined', () => {
|
||||
const session = 'claude-sonnet-4-20250514';
|
||||
const session = 'claude-sonnet-4-6';
|
||||
const defaultModel = 'claude-3-5-haiku-20241022';
|
||||
|
||||
const result = getEffectiveModel(undefined, session, defaultModel);
|
||||
@@ -297,7 +297,7 @@ describe('model-resolver', () => {
|
||||
|
||||
describe('with empty strings', () => {
|
||||
it('should treat empty explicit string as undefined', () => {
|
||||
const session = 'claude-sonnet-4-20250514';
|
||||
const session = 'claude-sonnet-4-6';
|
||||
|
||||
const result = getEffectiveModel('', session);
|
||||
|
||||
@@ -324,7 +324,7 @@ describe('model-resolver', () => {
|
||||
|
||||
describe('integration scenarios', () => {
|
||||
it('should handle user overriding session model with alias', () => {
|
||||
const sessionModel = 'claude-sonnet-4-20250514';
|
||||
const sessionModel = 'claude-sonnet-4-6';
|
||||
const userChoice = 'opus';
|
||||
|
||||
const result = getEffectiveModel(userChoice, sessionModel);
|
||||
@@ -418,7 +418,7 @@ describe('model-resolver', () => {
|
||||
});
|
||||
|
||||
it('should pass through full Claude model string', () => {
|
||||
const fullModel = 'claude-sonnet-4-20250514';
|
||||
const fullModel = 'claude-sonnet-4-6';
|
||||
const result = resolvePhaseModel(fullModel);
|
||||
|
||||
expect(result.model).toBe(fullModel);
|
||||
|
||||
@@ -30,6 +30,13 @@ export interface CopilotModelConfig {
|
||||
*/
|
||||
export const COPILOT_MODEL_MAP = {
|
||||
// Claude models (Anthropic via GitHub Copilot)
|
||||
'copilot-claude-sonnet-4.6': {
|
||||
label: 'Claude Sonnet 4.6',
|
||||
description: 'Anthropic Claude Sonnet 4.6 via GitHub Copilot.',
|
||||
supportsVision: true,
|
||||
supportsTools: true,
|
||||
contextWindow: 200000,
|
||||
},
|
||||
'copilot-claude-sonnet-4.5': {
|
||||
label: 'Claude Sonnet 4.5',
|
||||
description: 'Anthropic Claude Sonnet 4.5 via GitHub Copilot.',
|
||||
@@ -147,7 +154,7 @@ export function getAllCopilotModelIds(): CopilotModelId[] {
|
||||
/**
|
||||
* Default Copilot model
|
||||
*/
|
||||
export const DEFAULT_COPILOT_MODEL: CopilotModelId = 'copilot-claude-sonnet-4.5';
|
||||
export const DEFAULT_COPILOT_MODEL: CopilotModelId = 'copilot-claude-sonnet-4.6';
|
||||
|
||||
/**
|
||||
* GitHub Copilot authentication status
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
export type CursorModelId =
|
||||
| 'cursor-auto' // Auto-select best model
|
||||
| 'cursor-composer-1' // Cursor Composer agent model
|
||||
| 'cursor-sonnet-4.6' // Claude Sonnet 4.6
|
||||
| 'cursor-sonnet-4.6-thinking' // Claude Sonnet 4.6 with extended thinking
|
||||
| 'cursor-sonnet-4.5' // Claude Sonnet 4.5
|
||||
| 'cursor-sonnet-4.5-thinking' // Claude Sonnet 4.5 with extended thinking
|
||||
| 'cursor-opus-4.5' // Claude Opus 4.5
|
||||
@@ -35,6 +37,8 @@ export type CursorModelId =
|
||||
export type LegacyCursorModelId =
|
||||
| 'auto'
|
||||
| 'composer-1'
|
||||
| 'sonnet-4.6'
|
||||
| 'sonnet-4.6-thinking'
|
||||
| 'sonnet-4.5'
|
||||
| 'sonnet-4.5-thinking'
|
||||
| 'opus-4.5'
|
||||
@@ -75,6 +79,20 @@ export const CURSOR_MODEL_MAP: Record<CursorModelId, CursorModelConfig> = {
|
||||
hasThinking: false,
|
||||
supportsVision: false,
|
||||
},
|
||||
'cursor-sonnet-4.6': {
|
||||
id: 'cursor-sonnet-4.6',
|
||||
label: 'Claude Sonnet 4.6',
|
||||
description: 'Anthropic Claude Sonnet 4.6 via Cursor',
|
||||
hasThinking: false,
|
||||
supportsVision: false, // Model supports vision but Cursor CLI doesn't pass images
|
||||
},
|
||||
'cursor-sonnet-4.6-thinking': {
|
||||
id: 'cursor-sonnet-4.6-thinking',
|
||||
label: 'Claude Sonnet 4.6 (Thinking)',
|
||||
description: 'Claude Sonnet 4.6 with extended thinking enabled',
|
||||
hasThinking: true,
|
||||
supportsVision: false,
|
||||
},
|
||||
'cursor-sonnet-4.5': {
|
||||
id: 'cursor-sonnet-4.5',
|
||||
label: 'Claude Sonnet 4.5',
|
||||
@@ -223,6 +241,8 @@ export const CURSOR_MODEL_MAP: Record<CursorModelId, CursorModelConfig> = {
|
||||
export const LEGACY_CURSOR_MODEL_MAP: Record<LegacyCursorModelId, CursorModelId> = {
|
||||
auto: 'cursor-auto',
|
||||
'composer-1': 'cursor-composer-1',
|
||||
'sonnet-4.6': 'cursor-sonnet-4.6',
|
||||
'sonnet-4.6-thinking': 'cursor-sonnet-4.6-thinking',
|
||||
'sonnet-4.5': 'cursor-sonnet-4.5',
|
||||
'sonnet-4.5-thinking': 'cursor-sonnet-4.5-thinking',
|
||||
'opus-4.5': 'cursor-opus-4.5',
|
||||
@@ -378,6 +398,22 @@ export const CURSOR_MODEL_GROUPS: GroupedModel[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
// Sonnet 4.6 group (thinking mode)
|
||||
{
|
||||
baseId: 'cursor-sonnet-4.6-group',
|
||||
label: 'Claude Sonnet 4.6',
|
||||
description: 'Anthropic Claude Sonnet 4.6 via Cursor',
|
||||
variantType: 'thinking',
|
||||
variants: [
|
||||
{ id: 'cursor-sonnet-4.6', label: 'Standard', description: 'Fast responses' },
|
||||
{
|
||||
id: 'cursor-sonnet-4.6-thinking',
|
||||
label: 'Thinking',
|
||||
description: 'Extended reasoning',
|
||||
badge: 'Reasoning',
|
||||
},
|
||||
],
|
||||
},
|
||||
// Sonnet 4.5 group (thinking mode)
|
||||
{
|
||||
baseId: 'cursor-sonnet-4.5-group',
|
||||
|
||||
@@ -253,7 +253,7 @@ export const REASONING_EFFORT_LABELS: Record<ReasoningEffort, string> = {
|
||||
* ```typescript
|
||||
* getModelDisplayName("haiku"); // "Claude Haiku"
|
||||
* getModelDisplayName("sonnet"); // "Claude Sonnet"
|
||||
* getModelDisplayName("claude-opus-4-20250514"); // "claude-opus-4-20250514"
|
||||
* getModelDisplayName("claude-sonnet-4-6"); // "Claude Sonnet 4.6"
|
||||
* ```
|
||||
*/
|
||||
export function getModelDisplayName(model: ModelAlias | string): string {
|
||||
@@ -261,6 +261,11 @@ export function getModelDisplayName(model: ModelAlias | string): string {
|
||||
haiku: 'Claude Haiku',
|
||||
sonnet: 'Claude Sonnet',
|
||||
opus: 'Claude Opus',
|
||||
'claude-haiku': 'Claude Haiku',
|
||||
'claude-sonnet': 'Claude Sonnet',
|
||||
'claude-opus': 'Claude Opus',
|
||||
'claude-sonnet-4-6': 'Claude Sonnet 4.6',
|
||||
'claude-opus-4-6': 'Claude Opus 4.6',
|
||||
[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',
|
||||
|
||||
@@ -8,7 +8,11 @@
|
||||
import type { CursorModelId, LegacyCursorModelId } from './cursor-models.js';
|
||||
import { LEGACY_CURSOR_MODEL_MAP, CURSOR_MODEL_MAP } from './cursor-models.js';
|
||||
import type { OpencodeModelId, LegacyOpencodeModelId } from './opencode-models.js';
|
||||
import { LEGACY_OPENCODE_MODEL_MAP, OPENCODE_MODEL_CONFIG_MAP } from './opencode-models.js';
|
||||
import {
|
||||
LEGACY_OPENCODE_MODEL_MAP,
|
||||
OPENCODE_MODEL_CONFIG_MAP,
|
||||
RETIRED_OPENCODE_MODEL_MAP,
|
||||
} from './opencode-models.js';
|
||||
import type { ClaudeCanonicalId } from './model.js';
|
||||
import { LEGACY_CLAUDE_ALIAS_MAP, CLAUDE_CANONICAL_MAP, CLAUDE_MODEL_MAP } from './model.js';
|
||||
import type { PhaseModelEntry } from './settings.js';
|
||||
@@ -61,11 +65,16 @@ export function migrateModelId(legacyId: string | undefined | null): string {
|
||||
return LEGACY_CURSOR_MODEL_MAP[legacyId];
|
||||
}
|
||||
|
||||
// Already has opencode- prefix - it's canonical
|
||||
// Already has opencode- prefix - check if it's a current canonical ID
|
||||
if (legacyId.startsWith('opencode-') && legacyId in OPENCODE_MODEL_CONFIG_MAP) {
|
||||
return legacyId;
|
||||
}
|
||||
|
||||
// Retired opencode- canonical IDs (e.g., 'opencode-grok-code' → 'opencode-big-pickle')
|
||||
if (legacyId.startsWith('opencode-') && legacyId in RETIRED_OPENCODE_MODEL_MAP) {
|
||||
return RETIRED_OPENCODE_MODEL_MAP[legacyId];
|
||||
}
|
||||
|
||||
// Legacy OpenCode model ID (with slash format)
|
||||
if (isLegacyOpencodeModelId(legacyId)) {
|
||||
return LEGACY_OPENCODE_MODEL_MAP[legacyId];
|
||||
@@ -128,29 +137,36 @@ export function migrateOpencodeModelIds(ids: string[]): OpencodeModelId[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
return ids.map((id) => {
|
||||
// Already canonical (dash format)
|
||||
if (id.startsWith('opencode-') && id in OPENCODE_MODEL_CONFIG_MAP) {
|
||||
return ids
|
||||
.map((id) => {
|
||||
// Already canonical (dash format) and current
|
||||
if (id.startsWith('opencode-') && id in OPENCODE_MODEL_CONFIG_MAP) {
|
||||
return id as OpencodeModelId;
|
||||
}
|
||||
|
||||
// Retired canonical IDs (e.g., 'opencode-grok-code') → replacement
|
||||
if (id.startsWith('opencode-') && id in RETIRED_OPENCODE_MODEL_MAP) {
|
||||
return RETIRED_OPENCODE_MODEL_MAP[id];
|
||||
}
|
||||
|
||||
// Legacy ID (slash format)
|
||||
if (isLegacyOpencodeModelId(id)) {
|
||||
return LEGACY_OPENCODE_MODEL_MAP[id];
|
||||
}
|
||||
|
||||
// Convert slash to dash format for unknown models
|
||||
if (id.startsWith('opencode/')) {
|
||||
return id.replace('opencode/', 'opencode-') as OpencodeModelId;
|
||||
}
|
||||
|
||||
// Add prefix if not present
|
||||
if (!id.startsWith('opencode-')) {
|
||||
return `opencode-${id}` as OpencodeModelId;
|
||||
}
|
||||
|
||||
return id as OpencodeModelId;
|
||||
}
|
||||
|
||||
// Legacy ID (slash format)
|
||||
if (isLegacyOpencodeModelId(id)) {
|
||||
return LEGACY_OPENCODE_MODEL_MAP[id];
|
||||
}
|
||||
|
||||
// Convert slash to dash format for unknown models
|
||||
if (id.startsWith('opencode/')) {
|
||||
return id.replace('opencode/', 'opencode-') as OpencodeModelId;
|
||||
}
|
||||
|
||||
// Add prefix if not present
|
||||
if (!id.startsWith('opencode-')) {
|
||||
return `opencode-${id}` as OpencodeModelId;
|
||||
}
|
||||
|
||||
return id as OpencodeModelId;
|
||||
});
|
||||
})
|
||||
.filter((id, index, self) => self.indexOf(id) === index); // Deduplicate after migration
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -17,7 +17,7 @@ export type ClaudeCanonicalId = 'claude-haiku' | 'claude-sonnet' | 'claude-opus'
|
||||
*/
|
||||
export const CLAUDE_CANONICAL_MAP: Record<ClaudeCanonicalId, string> = {
|
||||
'claude-haiku': 'claude-haiku-4-5-20251001',
|
||||
'claude-sonnet': 'claude-sonnet-4-5-20250929',
|
||||
'claude-sonnet': 'claude-sonnet-4-6',
|
||||
'claude-opus': 'claude-opus-4-6',
|
||||
} as const;
|
||||
|
||||
@@ -28,7 +28,7 @@ export const CLAUDE_CANONICAL_MAP: Record<ClaudeCanonicalId, string> = {
|
||||
*/
|
||||
export const CLAUDE_MODEL_MAP: Record<string, string> = {
|
||||
haiku: 'claude-haiku-4-5-20251001',
|
||||
sonnet: 'claude-sonnet-4-5-20250929',
|
||||
sonnet: 'claude-sonnet-4-6',
|
||||
opus: 'claude-opus-4-6',
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -8,18 +8,23 @@
|
||||
export type OpencodeModelId =
|
||||
// OpenCode Free Tier Models
|
||||
| 'opencode-big-pickle'
|
||||
| 'opencode-glm-4.7-free'
|
||||
| 'opencode-glm-5-free'
|
||||
| 'opencode-gpt-5-nano'
|
||||
| 'opencode-grok-code'
|
||||
| 'opencode-minimax-m2.1-free';
|
||||
| 'opencode-kimi-k2.5-free'
|
||||
| 'opencode-minimax-m2.5-free';
|
||||
|
||||
/**
|
||||
* Legacy OpenCode model IDs (with slash format) for migration support
|
||||
* Includes both current and previously-available models for backward compatibility.
|
||||
*/
|
||||
export type LegacyOpencodeModelId =
|
||||
| 'opencode/big-pickle'
|
||||
| 'opencode/glm-4.7-free'
|
||||
| 'opencode/glm-5-free'
|
||||
| 'opencode/gpt-5-nano'
|
||||
| 'opencode/kimi-k2.5-free'
|
||||
| 'opencode/minimax-m2.5-free'
|
||||
// Retired models (kept for migration from older settings)
|
||||
| 'opencode/glm-4.7-free'
|
||||
| 'opencode/grok-code'
|
||||
| 'opencode/minimax-m2.1-free';
|
||||
|
||||
@@ -35,23 +40,40 @@ export const OPENCODE_MODEL_MAP: Record<string, OpencodeModelId> = {
|
||||
// OpenCode free tier aliases
|
||||
'big-pickle': 'opencode-big-pickle',
|
||||
pickle: 'opencode-big-pickle',
|
||||
'glm-free': 'opencode-glm-4.7-free',
|
||||
'glm-free': 'opencode-glm-5-free',
|
||||
'glm-5': 'opencode-glm-5-free',
|
||||
'gpt-nano': 'opencode-gpt-5-nano',
|
||||
nano: 'opencode-gpt-5-nano',
|
||||
'grok-code': 'opencode-grok-code',
|
||||
grok: 'opencode-grok-code',
|
||||
minimax: 'opencode-minimax-m2.1-free',
|
||||
'kimi-free': 'opencode-kimi-k2.5-free',
|
||||
kimi: 'opencode-kimi-k2.5-free',
|
||||
minimax: 'opencode-minimax-m2.5-free',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Map from legacy slash-format model IDs to canonical prefixed IDs
|
||||
* Map from legacy slash-format model IDs to canonical prefixed IDs.
|
||||
* Retired models are mapped to their closest replacement.
|
||||
*/
|
||||
export const LEGACY_OPENCODE_MODEL_MAP: Record<LegacyOpencodeModelId, OpencodeModelId> = {
|
||||
// Current models
|
||||
'opencode/big-pickle': 'opencode-big-pickle',
|
||||
'opencode/glm-4.7-free': 'opencode-glm-4.7-free',
|
||||
'opencode/glm-5-free': 'opencode-glm-5-free',
|
||||
'opencode/gpt-5-nano': 'opencode-gpt-5-nano',
|
||||
'opencode/grok-code': 'opencode-grok-code',
|
||||
'opencode/minimax-m2.1-free': 'opencode-minimax-m2.1-free',
|
||||
'opencode/kimi-k2.5-free': 'opencode-kimi-k2.5-free',
|
||||
'opencode/minimax-m2.5-free': 'opencode-minimax-m2.5-free',
|
||||
// Retired models → mapped to replacements
|
||||
'opencode/glm-4.7-free': 'opencode-glm-5-free',
|
||||
'opencode/grok-code': 'opencode-big-pickle', // grok-code retired, fallback to default
|
||||
'opencode/minimax-m2.1-free': 'opencode-minimax-m2.5-free',
|
||||
};
|
||||
|
||||
/**
|
||||
* Map from retired canonical (dash-format) model IDs to their replacements.
|
||||
* Used to migrate settings that reference models no longer available.
|
||||
*/
|
||||
export const RETIRED_OPENCODE_MODEL_MAP: Record<string, OpencodeModelId> = {
|
||||
'opencode-glm-4.7-free': 'opencode-glm-5-free',
|
||||
'opencode-grok-code': 'opencode-big-pickle',
|
||||
'opencode-minimax-m2.1-free': 'opencode-minimax-m2.5-free',
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -81,8 +103,8 @@ export const OPENCODE_MODELS: OpencodeModelConfig[] = [
|
||||
tier: 'free',
|
||||
},
|
||||
{
|
||||
id: 'opencode-glm-4.7-free',
|
||||
label: 'GLM 4.7 Free',
|
||||
id: 'opencode-glm-5-free',
|
||||
label: 'GLM 5 Free',
|
||||
description: 'OpenCode free tier GLM model',
|
||||
supportsVision: false,
|
||||
provider: 'opencode',
|
||||
@@ -97,16 +119,16 @@ export const OPENCODE_MODELS: OpencodeModelConfig[] = [
|
||||
tier: 'free',
|
||||
},
|
||||
{
|
||||
id: 'opencode-grok-code',
|
||||
label: 'Grok Code',
|
||||
description: 'OpenCode free tier Grok model for coding',
|
||||
id: 'opencode-kimi-k2.5-free',
|
||||
label: 'Kimi K2.5 Free',
|
||||
description: 'OpenCode free tier Kimi model for coding',
|
||||
supportsVision: false,
|
||||
provider: 'opencode',
|
||||
tier: 'free',
|
||||
},
|
||||
{
|
||||
id: 'opencode-minimax-m2.1-free',
|
||||
label: 'MiniMax M2.1 Free',
|
||||
id: 'opencode-minimax-m2.5-free',
|
||||
label: 'MiniMax M2.5 Free',
|
||||
description: 'OpenCode free tier MiniMax model',
|
||||
supportsVision: false,
|
||||
provider: 'opencode',
|
||||
@@ -160,7 +182,8 @@ export function getOpencodeModelProvider(modelId: OpencodeModelId): OpencodeProv
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Resolve an alias or partial model ID to a full model ID
|
||||
* Helper: Resolve an alias or partial model ID to a full model ID.
|
||||
* Also handles retired model IDs by mapping them to their replacements.
|
||||
*/
|
||||
export function resolveOpencodeModelId(input: string): OpencodeModelId | undefined {
|
||||
// Check if it's already a valid model ID
|
||||
@@ -168,6 +191,11 @@ export function resolveOpencodeModelId(input: string): OpencodeModelId | undefin
|
||||
return input as OpencodeModelId;
|
||||
}
|
||||
|
||||
// Check retired model map (handles old canonical IDs like 'opencode-grok-code')
|
||||
if (input in RETIRED_OPENCODE_MODEL_MAP) {
|
||||
return RETIRED_OPENCODE_MODEL_MAP[input];
|
||||
}
|
||||
|
||||
// Check alias map
|
||||
const normalized = input.toLowerCase();
|
||||
return OPENCODE_MODEL_MAP[normalized];
|
||||
|
||||
@@ -9,7 +9,11 @@
|
||||
import type { ModelProvider } from './settings.js';
|
||||
import { LEGACY_CURSOR_MODEL_MAP } from './cursor-models.js';
|
||||
import { CLAUDE_MODEL_MAP, CODEX_MODEL_MAP } from './model.js';
|
||||
import { OPENCODE_MODEL_CONFIG_MAP, LEGACY_OPENCODE_MODEL_MAP } from './opencode-models.js';
|
||||
import {
|
||||
OPENCODE_MODEL_CONFIG_MAP,
|
||||
LEGACY_OPENCODE_MODEL_MAP,
|
||||
RETIRED_OPENCODE_MODEL_MAP,
|
||||
} from './opencode-models.js';
|
||||
import { GEMINI_MODEL_MAP } from './gemini-models.js';
|
||||
import { COPILOT_MODEL_MAP } from './copilot-models.js';
|
||||
|
||||
@@ -51,7 +55,7 @@ export function isCursorModel(model: string | undefined | null): boolean {
|
||||
/**
|
||||
* Check if a model string represents a Claude model
|
||||
*
|
||||
* @param model - Model string to check (e.g., "sonnet", "opus", "claude-sonnet-4-20250514")
|
||||
* @param model - Model string to check (e.g., "sonnet", "opus", "claude-sonnet-4-6")
|
||||
* @returns true if the model is a Claude model
|
||||
*/
|
||||
export function isClaudeModel(model: string | undefined | null): boolean {
|
||||
@@ -310,7 +314,10 @@ export function getBareModelId(model: string): string {
|
||||
export function normalizeModelString(model: string | undefined | null): string {
|
||||
if (!model || typeof model !== 'string') return 'claude-sonnet'; // Default to canonical
|
||||
|
||||
// Already has a canonical prefix - return as-is
|
||||
// Already has a canonical prefix - return as-is (but check for retired opencode models first)
|
||||
if (model.startsWith(PROVIDER_PREFIXES.opencode) && model in RETIRED_OPENCODE_MODEL_MAP) {
|
||||
return RETIRED_OPENCODE_MODEL_MAP[model];
|
||||
}
|
||||
if (
|
||||
model.startsWith(PROVIDER_PREFIXES.cursor) ||
|
||||
model.startsWith(PROVIDER_PREFIXES.codex) ||
|
||||
@@ -364,7 +371,7 @@ export function normalizeModelString(model: string | undefined | null): string {
|
||||
*
|
||||
* @example
|
||||
* supportsStructuredOutput('sonnet') // true (Claude)
|
||||
* supportsStructuredOutput('claude-sonnet-4-20250514') // true (Claude)
|
||||
* supportsStructuredOutput('claude-sonnet-4-6') // true (Claude)
|
||||
* supportsStructuredOutput('codex-gpt-5.2') // true (Codex/OpenAI)
|
||||
* supportsStructuredOutput('cursor-auto') // false
|
||||
* supportsStructuredOutput('gemini-2.5-pro') // false
|
||||
|
||||
Reference in New Issue
Block a user