From 15ca1eb6d34cce3f6a3e4f58561fb1551268c093 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 18 Feb 2026 20:48:37 -0800 Subject: [PATCH] feat: Add process abort control and improve auth detection --- apps/server/src/lib/git.ts | 16 +- apps/server/src/providers/claude-provider.ts | 47 +++- apps/server/src/providers/codex-provider.ts | 5 +- .../src/routes/git/routes/stage-files.ts | 38 ++- .../src/routes/worktree/routes/stage-files.ts | 84 ++---- apps/server/src/services/auto-mode/facade.ts | 147 +++------- apps/server/src/services/merge-service.ts | 14 +- apps/server/src/services/pull-service.ts | 105 ++++--- apps/server/src/services/rebase-service.ts | 22 +- .../src/services/stage-files-service.ts | 109 ++++++++ .../src/services/worktree-branch-service.ts | 50 +++- .../unit/providers/claude-provider.test.ts | 11 + .../ui/src/components/codex-usage-popover.tsx | 57 +++- apps/ui/src/components/ui/git-diff-panel.tsx | 259 +++++++----------- apps/ui/src/components/usage-popover.tsx | 14 +- .../dialogs/commit-worktree-dialog.tsx | 9 +- .../discard-worktree-changes-dialog.tsx | 49 ++-- .../views/board-view/mobile-usage-bar.tsx | 14 +- .../codex/codex-usage-section.tsx | 32 ++- .../model-defaults/phase-model-selector.tsx | 20 +- .../providers/opencode-settings-tab.tsx | 13 +- apps/ui/src/store/utils/usage-utils.ts | 42 +++ libs/git-utils/src/conflict.ts | 44 +++ libs/git-utils/src/index.ts | 3 + 24 files changed, 706 insertions(+), 498 deletions(-) create mode 100644 apps/server/src/services/stage-files-service.ts create mode 100644 libs/git-utils/src/conflict.ts diff --git a/apps/server/src/lib/git.ts b/apps/server/src/lib/git.ts index cda5f71b..697d532d 100644 --- a/apps/server/src/lib/git.ts +++ b/apps/server/src/lib/git.ts @@ -27,6 +27,9 @@ const logger = createLogger('GitLib'); * These are merged on top of the current process environment. Pass * `{ LC_ALL: 'C' }` to force git to emit English output regardless of the * system locale so that text-based output parsing remains reliable. + * @param abortController - Optional AbortController to cancel the git process. + * When the controller is aborted the underlying process is sent SIGTERM and + * the returned promise rejects with an Error whose message is 'Process aborted'. * @returns Promise resolving to stdout output * @throws Error with stderr/stdout message if command fails. The thrown error * also has `stdout` and `stderr` string properties for structured access. @@ -39,6 +42,15 @@ const logger = createLogger('GitLib'); * // Force English output for reliable text parsing: * await execGitCommand(['rebase', '--', 'main'], worktreePath, { LC_ALL: 'C' }); * + * // With a process-level timeout: + * const controller = new AbortController(); + * const timerId = setTimeout(() => controller.abort(), 30_000); + * try { + * await execGitCommand(['fetch', '--all', '--quiet'], cwd, undefined, controller); + * } finally { + * clearTimeout(timerId); + * } + * * // Instead of unsafe: * // await execAsync(`git branch -D ${branchName}`, { cwd }); * ``` @@ -46,13 +58,15 @@ const logger = createLogger('GitLib'); export async function execGitCommand( args: string[], cwd: string, - env?: Record + env?: Record, + abortController?: AbortController ): Promise { const result = await spawnProcess({ command: 'git', args, cwd, ...(env !== undefined ? { env } : {}), + ...(abortController !== undefined ? { abortController } : {}), }); // spawnProcess returns { stdout, stderr, exitCode } diff --git a/apps/server/src/providers/claude-provider.ts b/apps/server/src/providers/claude-provider.ts index 5f45900f..dd32050c 100644 --- a/apps/server/src/providers/claude-provider.ts +++ b/apps/server/src/providers/claude-provider.ts @@ -8,8 +8,7 @@ import { query, type Options, type SDKUserMessage } from '@anthropic-ai/claude-agent-sdk'; import { BaseProvider } from './base-provider.js'; import { classifyError, getUserFriendlyErrorMessage, createLogger } from '@automaker/utils'; - -const logger = createLogger('ClaudeProvider'); +import { getClaudeAuthIndicators } from '@automaker/platform'; import { getThinkingTokenBudget, validateBareModelId, @@ -17,6 +16,14 @@ import { type ClaudeCompatibleProvider, type Credentials, } from '@automaker/types'; +import type { + ExecuteOptions, + ProviderMessage, + InstallationStatus, + ModelDefinition, +} from './types.js'; + +const logger = createLogger('ClaudeProvider'); /** * ProviderConfig - Union type for provider configuration @@ -25,12 +32,6 @@ import { * Both share the same connection settings structure. */ type ProviderConfig = ClaudeApiProfile | ClaudeCompatibleProvider; -import type { - ExecuteOptions, - ProviderMessage, - InstallationStatus, - ModelDefinition, -} from './types.js'; // System vars are always passed from process.env regardless of profile const SYSTEM_ENV_VARS = ['PATH', 'HOME', 'SHELL', 'TERM', 'USER', 'LANG', 'LC_ALL']; @@ -240,7 +241,7 @@ export class ClaudeProvider extends BaseProvider { promptPayload = (async function* () { const multiPartPrompt = { type: 'user' as const, - session_id: '', + session_id: sdkSessionId || undefined, message: { role: 'user' as const, content: prompt, @@ -313,13 +314,37 @@ export class ClaudeProvider extends BaseProvider { */ async detectInstallation(): Promise { // Claude SDK is always available since it's a dependency - const hasApiKey = !!process.env.ANTHROPIC_API_KEY; + // Check all four supported auth methods, mirroring the logic in buildEnv(): + // 1. ANTHROPIC_API_KEY environment variable + // 2. ANTHROPIC_AUTH_TOKEN environment variable + // 3. credentials?.apiKeys?.anthropic (credentials file, checked via platform indicators) + // 4. Claude Max CLI OAuth (SDK handles this automatically; detected via getClaudeAuthIndicators) + const hasEnvApiKey = !!process.env.ANTHROPIC_API_KEY; + const hasEnvAuthToken = !!process.env.ANTHROPIC_AUTH_TOKEN; + + // Check credentials file and CLI OAuth indicators (same sources used by buildEnv) + let hasCredentialsApiKey = false; + let hasCliOAuth = false; + try { + const indicators = await getClaudeAuthIndicators(); + hasCredentialsApiKey = !!indicators.credentials?.hasApiKey; + hasCliOAuth = !!( + indicators.credentials?.hasOAuthToken || + indicators.hasStatsCacheWithActivity || + (indicators.hasSettingsFile && indicators.hasProjectsSessions) + ); + } catch { + // If we can't check indicators, fall back to env vars only + } + + const hasApiKey = hasEnvApiKey || hasCredentialsApiKey; + const authenticated = hasEnvApiKey || hasEnvAuthToken || hasCredentialsApiKey || hasCliOAuth; const status: InstallationStatus = { installed: true, method: 'sdk', hasApiKey, - authenticated: hasApiKey, + authenticated, }; return status; diff --git a/apps/server/src/providers/codex-provider.ts b/apps/server/src/providers/codex-provider.ts index 0ff543f3..28fd7410 100644 --- a/apps/server/src/providers/codex-provider.ts +++ b/apps/server/src/providers/codex-provider.ts @@ -884,8 +884,9 @@ export class CodexProvider extends BaseProvider { ) { enhancedError = `${errorText}\n\nTip: The model '${options.model}' may not be available on your OpenAI plan. ` + - `Some models (like gpt-5.3-codex) require a ChatGPT Pro/Plus subscription and OAuth login via 'codex login'. ` + - `Try using a different model (e.g., gpt-5.1 or gpt-5.2), or authenticate with 'codex login' instead of an API key.`; + `Available models include: ${CODEX_MODELS.map((m) => m.id).join(', ')}. ` + + `Some models require a ChatGPT Pro/Plus subscription—authenticate with 'codex login' instead of an API key. ` + + `For the current list of compatible models, visit https://platform.openai.com/docs/models.`; } else if ( errorLower.includes('stream disconnected') || errorLower.includes('stream ended') || diff --git a/apps/server/src/routes/git/routes/stage-files.ts b/apps/server/src/routes/git/routes/stage-files.ts index c688f8b5..9aba0be3 100644 --- a/apps/server/src/routes/git/routes/stage-files.ts +++ b/apps/server/src/routes/git/routes/stage-files.ts @@ -2,6 +2,7 @@ * POST /stage-files endpoint - Stage or unstage files in the main project */ +import fs from 'fs'; import path from 'path'; import type { Request, Response } from 'express'; import { getErrorMessage, logError } from '../common.js'; @@ -24,7 +25,7 @@ export function createStageFilesHandler() { return; } - if (!files || files.length === 0) { + if (!Array.isArray(files) || files.length === 0) { res.status(400).json({ success: false, error: 'files array required and must not be empty', @@ -32,6 +33,16 @@ export function createStageFilesHandler() { return; } + for (const file of files) { + if (typeof file !== 'string' || file.trim() === '') { + res.status(400).json({ + success: false, + error: 'Each element of files must be a non-empty string', + }); + return; + } + } + if (operation !== 'stage' && operation !== 'unstage') { res.status(400).json({ success: false, @@ -40,8 +51,23 @@ export function createStageFilesHandler() { return; } + // Resolve the canonical (symlink-dereferenced) project path so that + // startsWith(base) reliably prevents symlink traversal attacks. + // If projectPath does not exist or is unreadable, realpath rejects and + // we return a 400 instead of letting the error propagate as a 500. + let canonicalRoot: string; + try { + canonicalRoot = await fs.promises.realpath(projectPath); + } catch { + res.status(400).json({ + success: false, + error: `Invalid projectPath (non-existent or unreadable): ${projectPath}`, + }); + return; + } + // Validate and sanitize each file path to prevent path traversal attacks - const base = path.resolve(projectPath) + path.sep; + const base = path.resolve(canonicalRoot) + path.sep; const sanitizedFiles: string[] = []; for (const file of files) { // Reject absolute paths @@ -61,8 +87,8 @@ export function createStageFilesHandler() { return; } // Ensure the resolved path stays within the project directory - const resolved = path.resolve(path.join(projectPath, file)); - if (resolved !== path.resolve(projectPath) && !resolved.startsWith(base)) { + const resolved = path.resolve(path.join(canonicalRoot, file)); + if (resolved !== path.resolve(canonicalRoot) && !resolved.startsWith(base)) { res.status(400).json({ success: false, error: `Invalid file path (outside project directory): ${file}`, @@ -73,9 +99,9 @@ export function createStageFilesHandler() { } if (operation === 'stage') { - await execGitCommand(['add', '--', ...sanitizedFiles], projectPath); + await execGitCommand(['add', '--', ...sanitizedFiles], canonicalRoot); } else { - await execGitCommand(['reset', 'HEAD', '--', ...sanitizedFiles], projectPath); + await execGitCommand(['reset', 'HEAD', '--', ...sanitizedFiles], canonicalRoot); } res.json({ diff --git a/apps/server/src/routes/worktree/routes/stage-files.ts b/apps/server/src/routes/worktree/routes/stage-files.ts index 618e8b67..d04813e7 100644 --- a/apps/server/src/routes/worktree/routes/stage-files.ts +++ b/apps/server/src/routes/worktree/routes/stage-files.ts @@ -9,11 +9,9 @@ * the requireGitRepoOnly middleware in index.ts */ -import path from 'path'; -import fs from 'fs/promises'; import type { Request, Response } from 'express'; import { getErrorMessage, logError } from '../common.js'; -import { execGitCommand } from '../../../lib/git.js'; +import { stageFiles, StageFilesValidationError } from '../../../services/stage-files-service.js'; export function createStageFilesHandler() { return async (req: Request, res: Response): Promise => { @@ -32,7 +30,7 @@ export function createStageFilesHandler() { return; } - if (!files || files.length === 0) { + if (!Array.isArray(files) || files.length === 0) { res.status(400).json({ success: false, error: 'files array required and must not be empty', @@ -40,6 +38,16 @@ export function createStageFilesHandler() { return; } + for (const file of files) { + if (typeof file !== 'string' || file.trim() === '') { + res.status(400).json({ + success: false, + error: 'Each element of files must be a non-empty string', + }); + return; + } + } + if (operation !== 'stage' && operation !== 'unstage') { res.status(400).json({ success: false, @@ -48,73 +56,17 @@ export function createStageFilesHandler() { return; } - // Canonicalize the worktree root by resolving symlinks so that - // path-traversal checks are reliable even when symlinks are involved. - let canonicalRoot: string; - try { - canonicalRoot = await fs.realpath(worktreePath); - } catch { - res.status(400).json({ - success: false, - error: 'worktreePath does not exist or is not accessible', - }); - return; - } - - // Validate and sanitize each file path to prevent path traversal attacks. - // Each file entry is resolved against the canonicalized worktree root and - // must remain within that root directory. - const base = canonicalRoot + path.sep; - const sanitizedFiles: string[] = []; - for (const file of files) { - // Reject absolute paths - if (path.isAbsolute(file)) { - res.status(400).json({ - success: false, - error: `Invalid file path (absolute paths not allowed): ${file}`, - }); - return; - } - // Reject entries containing '..' - if (file.includes('..')) { - res.status(400).json({ - success: false, - error: `Invalid file path (path traversal not allowed): ${file}`, - }); - return; - } - // Resolve the file path against the canonicalized worktree root and - // ensure the result stays within the worktree directory. - const resolved = path.resolve(canonicalRoot, file); - if (resolved !== canonicalRoot && !resolved.startsWith(base)) { - res.status(400).json({ - success: false, - error: `Invalid file path (outside worktree directory): ${file}`, - }); - return; - } - // Forward only the original relative path to git — git interprets - // paths relative to its working directory (canonicalRoot / worktreePath), - // so we do not need to pass the resolved absolute path. - sanitizedFiles.push(file); - } - - if (operation === 'stage') { - // Stage the specified files - await execGitCommand(['add', '--', ...sanitizedFiles], worktreePath); - } else { - // Unstage the specified files - await execGitCommand(['reset', 'HEAD', '--', ...sanitizedFiles], worktreePath); - } + const result = await stageFiles(worktreePath, files, operation); res.json({ success: true, - result: { - operation, - filesCount: sanitizedFiles.length, - }, + result, }); } catch (error) { + if (error instanceof StageFilesValidationError) { + res.status(400).json({ success: false, error: error.message }); + return; + } logError(error, `${(req.body as { operation?: string })?.operation ?? 'stage'} files failed`); res.status(500).json({ success: false, error: getErrorMessage(error) }); } diff --git a/apps/server/src/services/auto-mode/facade.ts b/apps/server/src/services/auto-mode/facade.ts index 32957cf2..38a1c878 100644 --- a/apps/server/src/services/auto-mode/facade.ts +++ b/apps/server/src/services/auto-mode/facade.ts @@ -182,23 +182,18 @@ export class AutoModeServiceFacade { return facadeInstance; }; - // PipelineOrchestrator - runAgentFn is a stub; routes use AutoModeService directly - const pipelineOrchestrator = new PipelineOrchestrator( - eventBus, - featureStateManager, - agentExecutor, - testRunnerService, - worktreeResolver, - concurrencyManager, - settingsService, - // Callbacks - (pPath, featureId, status) => - featureStateManager.updateFeatureStatus(pPath, featureId, status), - loadContextFiles, - buildFeaturePrompt, - (pPath, featureId, useWorktrees, _isAutoMode, _model, opts) => - getFacade().executeFeature(featureId, useWorktrees, false, undefined, opts), - // runAgentFn - delegates to AgentExecutor + /** + * Shared agent-run helper used by both PipelineOrchestrator and ExecutionService. + * + * Resolves the model string, looks up the custom provider/credentials via + * getProviderByModelId, then delegates to agentExecutor.execute with the + * full payload. The opts parameter uses an index-signature union so it + * accepts both the typed ExecutionService opts object and the looser + * Record used by PipelineOrchestrator without requiring + * type casts at the call sites. + */ + const createRunAgentFn = + () => async ( workDir: string, featureId: string, @@ -207,8 +202,17 @@ export class AutoModeServiceFacade { pPath: string, imagePaths?: string[], model?: string, - opts?: Record - ) => { + opts?: { + planningMode?: PlanningMode; + requirePlanApproval?: boolean; + previousContent?: string; + systemPrompt?: string; + autoLoadClaudeMd?: boolean; + thinkingLevel?: ThinkingLevel; + branchName?: string | null; + [key: string]: unknown; + } + ): Promise => { const resolvedModel = resolveModelString(model, 'claude-sonnet-4-6'); const provider = ProviderFactory.getProviderForModel(resolvedModel); const effectiveBareModel = stripProviderPrefix(resolvedModel); @@ -218,7 +222,7 @@ export class AutoModeServiceFacade { | import('@automaker/types').ClaudeCompatibleProvider | undefined; let credentials: import('@automaker/types').Credentials | undefined; - if (resolvedModel && settingsService) { + if (settingsService) { const providerResult = await getProviderByModelId( resolvedModel, settingsService, @@ -270,7 +274,25 @@ export class AutoModeServiceFacade { }, } ); - } + }; + + // PipelineOrchestrator - runAgentFn delegates to AgentExecutor via shared helper + const pipelineOrchestrator = new PipelineOrchestrator( + eventBus, + featureStateManager, + agentExecutor, + testRunnerService, + worktreeResolver, + concurrencyManager, + settingsService, + // Callbacks + (pPath, featureId, status) => + featureStateManager.updateFeatureStatus(pPath, featureId, status), + loadContextFiles, + buildFeaturePrompt, + (pPath, featureId, useWorktrees, _isAutoMode, _model, opts) => + getFacade().executeFeature(featureId, useWorktrees, false, undefined, opts), + createRunAgentFn() ); // AutoLoopCoordinator - ALWAYS create new with proper execution callbacks @@ -312,92 +334,13 @@ export class AutoModeServiceFacade { async (pPath) => featureLoader.getAll(pPath) ); - // ExecutionService - runAgentFn calls AgentExecutor.execute + // ExecutionService - runAgentFn delegates to AgentExecutor via shared helper const executionService = new ExecutionService( eventBus, concurrencyManager, worktreeResolver, settingsService, - // runAgentFn - delegates to AgentExecutor - async ( - workDir: string, - featureId: string, - prompt: string, - abortController: AbortController, - pPath: string, - imagePaths?: string[], - model?: string, - opts?: { - projectPath?: string; - planningMode?: PlanningMode; - requirePlanApproval?: boolean; - systemPrompt?: string; - autoLoadClaudeMd?: boolean; - thinkingLevel?: ThinkingLevel; - branchName?: string | null; - } - ) => { - const resolvedModel = resolveModelString(model, 'claude-sonnet-4-6'); - const provider = ProviderFactory.getProviderForModel(resolvedModel); - const effectiveBareModel = stripProviderPrefix(resolvedModel); - - // Resolve custom provider (GLM, MiniMax, etc.) for baseUrl and credentials - let claudeCompatibleProvider: - | import('@automaker/types').ClaudeCompatibleProvider - | undefined; - let credentials: import('@automaker/types').Credentials | undefined; - if (resolvedModel && settingsService) { - const providerResult = await getProviderByModelId( - resolvedModel, - settingsService, - '[AutoModeFacade]' - ); - if (providerResult.provider) { - claudeCompatibleProvider = providerResult.provider; - credentials = providerResult.credentials; - } - } - - await agentExecutor.execute( - { - workDir, - featureId, - prompt, - projectPath: pPath, - abortController, - imagePaths, - model: resolvedModel, - planningMode: opts?.planningMode, - requirePlanApproval: opts?.requirePlanApproval, - systemPrompt: opts?.systemPrompt, - autoLoadClaudeMd: opts?.autoLoadClaudeMd, - thinkingLevel: opts?.thinkingLevel, - branchName: opts?.branchName, - provider, - effectiveBareModel, - credentials, - claudeCompatibleProvider, - }, - { - waitForApproval: (fId, projPath) => planApprovalService.waitForApproval(fId, projPath), - saveFeatureSummary: (projPath, fId, summary) => - featureStateManager.saveFeatureSummary(projPath, fId, summary), - updateFeatureSummary: (projPath, fId, summary) => - featureStateManager.saveFeatureSummary(projPath, fId, summary), - buildTaskPrompt: (task, allTasks, taskIndex, planContent, template, feedback) => { - let taskPrompt = template - .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}`); - if (feedback) { - taskPrompt = taskPrompt.replace(/\{\{userFeedback\}\}/g, feedback); - } - return taskPrompt; - }, - } - ); - }, + createRunAgentFn(), (context) => pipelineOrchestrator.executePipeline(context), (pPath, featureId, status) => featureStateManager.updateFeatureStatus(pPath, featureId, status), diff --git a/apps/server/src/services/merge-service.ts b/apps/server/src/services/merge-service.ts index 49cd6aed..52bb34d6 100644 --- a/apps/server/src/services/merge-service.ts +++ b/apps/server/src/services/merge-service.ts @@ -195,15 +195,11 @@ export async function performMerge( // Delete the branch (but not main/master) if (branchName !== 'main' && branchName !== 'master') { - if (!isValidBranchName(branchName)) { - logger.warn(`Invalid branch name detected, skipping deletion: ${branchName}`); - } else { - try { - await execGitCommand(['branch', '-D', branchName], projectPath); - branchDeleted = true; - } catch { - logger.warn(`Failed to delete branch: ${branchName}`); - } + try { + await execGitCommand(['branch', '-D', branchName], projectPath); + branchDeleted = true; + } catch { + logger.warn(`Failed to delete branch: ${branchName}`); } } } diff --git a/apps/server/src/services/pull-service.ts b/apps/server/src/services/pull-service.ts index d21e2c7d..3db5af85 100644 --- a/apps/server/src/services/pull-service.ts +++ b/apps/server/src/services/pull-service.ts @@ -16,6 +16,7 @@ */ import { createLogger, getErrorMessage } from '@automaker/utils'; +import { getConflictFiles } from '@automaker/git-utils'; import { execGitCommand, execGitCommandWithLockRetry } from '../lib/git.js'; const logger = createLogger('PullService'); @@ -118,7 +119,7 @@ export async function stashChanges(worktreePath: string, branchName: string): Pr * @returns The stdout from stash pop */ export async function popStash(worktreePath: string): Promise { - return await execGitCommand(['stash', 'pop'], worktreePath); + return await execGitCommandWithLockRetry(['stash', 'pop'], worktreePath); } /** @@ -129,7 +130,7 @@ export async function popStash(worktreePath: string): Promise { */ async function tryPopStash(worktreePath: string): Promise { try { - await execGitCommand(['stash', 'pop'], worktreePath); + await execGitCommandWithLockRetry(['stash', 'pop'], worktreePath); return true; } catch (stashPopError) { // Stash pop failed - leave it in stash list for manual recovery @@ -141,6 +142,14 @@ async function tryPopStash(worktreePath: string): Promise { } } +/** + * Result of the upstream/remote branch check. + * - 'tracking': the branch has a configured upstream tracking ref + * - 'remote': no tracking ref, but the remote branch exists + * - 'none': neither a tracking ref nor a remote branch was found + */ +export type UpstreamStatus = 'tracking' | 'remote' | 'none'; + /** * Check whether the branch has an upstream tracking ref, or whether * the remote branch exists. @@ -148,48 +157,27 @@ async function tryPopStash(worktreePath: string): Promise { * @param worktreePath - Path to the git worktree * @param branchName - Current branch name * @param remote - Remote name - * @returns true if upstream or remote branch exists + * @returns UpstreamStatus indicating tracking ref, remote branch, or neither */ export async function hasUpstreamOrRemoteBranch( worktreePath: string, branchName: string, remote: string -): Promise { +): Promise { try { await execGitCommand(['rev-parse', '--abbrev-ref', `${branchName}@{upstream}`], worktreePath); - return true; + return 'tracking'; } catch { // No upstream tracking - check if the remote branch exists try { await execGitCommand(['rev-parse', '--verify', `${remote}/${branchName}`], worktreePath); - return true; + return 'remote'; } catch { - return false; + return 'none'; } } } -/** - * Get the list of files with unresolved merge conflicts. - * - * @param worktreePath - Path to the git worktree - * @returns Array of file paths with conflicts - */ -export async function getConflictFiles(worktreePath: string): Promise { - try { - const diffOutput = await execGitCommand( - ['diff', '--name-only', '--diff-filter=U'], - worktreePath - ); - return diffOutput - .trim() - .split('\n') - .filter((f) => f.trim().length > 0); - } catch { - return []; - } -} - /** * Check whether an error output string indicates a merge conflict. */ @@ -233,7 +221,15 @@ export async function performPull( const stashIfNeeded = options?.stashIfNeeded ?? false; // 1. Get current branch name - const branchName = await getCurrentBranch(worktreePath); + let branchName: string; + try { + branchName = await getCurrentBranch(worktreePath); + } catch (err) { + return { + success: false, + error: `Failed to get current branch: ${getErrorMessage(err)}`, + }; + } // 2. Check for detached HEAD state if (branchName === 'HEAD') { @@ -254,7 +250,16 @@ export async function performPull( } // 4. Check for local changes - const { hasLocalChanges, localChangedFiles } = await getLocalChanges(worktreePath); + let hasLocalChanges: boolean; + let localChangedFiles: string[]; + try { + ({ hasLocalChanges, localChangedFiles } = await getLocalChanges(worktreePath)); + } catch (err) { + return { + success: false, + error: `Failed to get local changes: ${getErrorMessage(err)}`, + }; + } // 5. If there are local changes and stashIfNeeded is not requested, return info if (hasLocalChanges && !stashIfNeeded) { @@ -284,8 +289,8 @@ export async function performPull( } // 7. Verify upstream tracking or remote branch exists - const hasUpstream = await hasUpstreamOrRemoteBranch(worktreePath, branchName, targetRemote); - if (!hasUpstream) { + const upstreamStatus = await hasUpstreamOrRemoteBranch(worktreePath, branchName, targetRemote); + if (upstreamStatus === 'none') { let stashRecoveryFailed = false; if (didStash) { const stashPopped = await tryPopStash(worktreePath); @@ -294,15 +299,18 @@ export async function performPull( return { success: false, error: `Branch '${branchName}' has no upstream branch on remote '${targetRemote}'. Push it first or set upstream with: git branch --set-upstream-to=${targetRemote}/${branchName}${stashRecoveryFailed ? ' Local changes remain stashed and need manual recovery (run: git stash pop).' : ''}`, - stashRecoveryFailed: stashRecoveryFailed || undefined, + stashRecoveryFailed: stashRecoveryFailed ? stashRecoveryFailed : undefined, }; } // 8. Pull latest changes + // When the branch has a configured upstream tracking ref, let Git use it automatically. + // When only the remote branch exists (no tracking ref), explicitly specify remote and branch. + const pullArgs = upstreamStatus === 'tracking' ? ['pull'] : ['pull', targetRemote, branchName]; let pullConflict = false; let pullConflictFiles: string[] = []; try { - const pullOutput = await execGitCommand(['pull', targetRemote, branchName], worktreePath); + const pullOutput = await execGitCommand(pullArgs, worktreePath); const alreadyUpToDate = pullOutput.includes('Already up to date'); @@ -339,14 +347,14 @@ export async function performPull( return { success: false, error: `Branch '${branchName}' has no upstream branch. Push it first or set upstream with: git branch --set-upstream-to=${targetRemote}/${branchName}${stashRecoveryFailed ? ' Local changes remain stashed and need manual recovery (run: git stash pop).' : ''}`, - stashRecoveryFailed: stashRecoveryFailed || undefined, + stashRecoveryFailed: stashRecoveryFailed ? stashRecoveryFailed : undefined, }; } return { success: false, error: `${errorMsg}${stashRecoveryFailed ? ' Local changes remain stashed and need manual recovery (run: git stash pop).' : ''}`, - stashRecoveryFailed: stashRecoveryFailed || undefined, + stashRecoveryFailed: stashRecoveryFailed ? stashRecoveryFailed : undefined, }; } } @@ -391,27 +399,9 @@ export async function performPull( */ async function reapplyStash(worktreePath: string, branchName: string): Promise { try { - const stashPopOutput = await popStash(worktreePath); - const stashPopCombined = stashPopOutput || ''; + await popStash(worktreePath); - // Check if stash pop had conflicts - if (isStashConflict(stashPopCombined)) { - const stashConflictFiles = await getConflictFiles(worktreePath); - - return { - success: true, - branch: branchName, - pulled: true, - hasConflicts: true, - conflictSource: 'stash', - conflictFiles: stashConflictFiles, - stashed: true, - stashRestored: true, // Stash was applied but with conflicts - message: 'Pull succeeded but reapplying your stashed changes resulted in merge conflicts.', - }; - } - - // Stash pop succeeded cleanly + // Stash pop succeeded cleanly (popStash throws on non-zero exit) return { success: true, branch: branchName, @@ -426,6 +416,7 @@ async function reapplyStash(worktreePath: string, branchName: string): Promise

{ return false; } } - -/** - * Get the list of files with unresolved conflicts. - * - * @param worktreePath - Path to the git worktree - * @returns Array of file paths with conflicts - */ -export async function getConflictFiles(worktreePath: string): Promise { - try { - const diffOutput = await execGitCommand( - ['diff', '--name-only', '--diff-filter=U'], - worktreePath - ); - return diffOutput - .trim() - .split('\n') - .filter((f) => f.trim().length > 0); - } catch { - return []; - } -} diff --git a/apps/server/src/services/stage-files-service.ts b/apps/server/src/services/stage-files-service.ts new file mode 100644 index 00000000..0e3299be --- /dev/null +++ b/apps/server/src/services/stage-files-service.ts @@ -0,0 +1,109 @@ +/** + * stageFilesService - Path validation and git staging/unstaging operations + * + * Extracted from createStageFilesHandler to centralise path canonicalization, + * path-traversal validation, and git invocation so they can be tested and + * reused independently of the HTTP layer. + */ + +import path from 'path'; +import fs from 'fs/promises'; +import { execGitCommand } from '../lib/git.js'; + +/** + * Result returned by `stageFiles` on success. + */ +export interface StageFilesResult { + operation: string; + filesCount: number; +} + +/** + * Error thrown when one or more file paths fail validation (e.g. absolute + * paths, path-traversal attempts, or paths that resolve outside the worktree + * root, or when the worktree path itself does not exist). + * + * Handlers can catch this to return an HTTP 400 response instead of 500. + */ +export class StageFilesValidationError extends Error { + constructor(message: string) { + super(message); + this.name = 'StageFilesValidationError'; + } +} + +/** + * Resolve the canonical path of the worktree root, validate every file path + * against it to prevent path-traversal attacks, and then invoke the + * appropriate git command (`add` or `reset`) to stage or unstage the files. + * + * @param worktreePath - Absolute path to the git worktree root directory. + * @param files - Relative file paths to stage or unstage. + * @param operation - `'stage'` runs `git add`, `'unstage'` runs `git reset HEAD`. + * + * @returns An object containing the operation name and the number of files + * that were staged/unstaged. + * + * @throws {StageFilesValidationError} When `worktreePath` is inaccessible or + * any entry in `files` fails the path-traversal checks. + * @throws {Error} When the underlying git command fails. + */ +export async function stageFiles( + worktreePath: string, + files: string[], + operation: 'stage' | 'unstage' +): Promise { + // Canonicalize the worktree root by resolving symlinks so that + // path-traversal checks are reliable even when symlinks are involved. + let canonicalRoot: string; + try { + canonicalRoot = await fs.realpath(worktreePath); + } catch { + throw new StageFilesValidationError('worktreePath does not exist or is not accessible'); + } + + // Validate and sanitize each file path to prevent path traversal attacks. + // Each file entry is resolved against the canonicalized worktree root and + // must remain within that root directory. + const base = canonicalRoot + path.sep; + const sanitizedFiles: string[] = []; + for (const file of files) { + // Reject absolute paths + if (path.isAbsolute(file)) { + throw new StageFilesValidationError( + `Invalid file path (absolute paths not allowed): ${file}` + ); + } + // Reject entries containing '..' + if (file.includes('..')) { + throw new StageFilesValidationError( + `Invalid file path (path traversal not allowed): ${file}` + ); + } + // Resolve the file path against the canonicalized worktree root and + // ensure the result stays within the worktree directory. + const resolved = path.resolve(canonicalRoot, file); + if (resolved !== canonicalRoot && !resolved.startsWith(base)) { + throw new StageFilesValidationError( + `Invalid file path (outside worktree directory): ${file}` + ); + } + // Forward only the original relative path to git — git interprets + // paths relative to its working directory (canonicalRoot / worktreePath), + // so we do not need to pass the resolved absolute path. + sanitizedFiles.push(file); + } + + if (operation === 'stage') { + // Stage the specified files + await execGitCommand(['add', '--', ...sanitizedFiles], worktreePath); + } else { + // Unstage the specified files + await execGitCommand(['reset', 'HEAD', '--', ...sanitizedFiles], worktreePath); + } + + return { + operation, + filesCount: sanitizedFiles.length, + }; +} diff --git a/apps/server/src/services/worktree-branch-service.ts b/apps/server/src/services/worktree-branch-service.ts index ca8aeeaf..5d3fc9bc 100644 --- a/apps/server/src/services/worktree-branch-service.ts +++ b/apps/server/src/services/worktree-branch-service.ts @@ -129,26 +129,52 @@ async function popStash( } } +/** Timeout for git fetch operations (30 seconds) */ +const FETCH_TIMEOUT_MS = 30_000; + /** - * Fetch latest from all remotes (silently, with timeout) + * Fetch latest from all remotes (silently, with timeout). + * + * A process-level timeout is enforced via an AbortController so that a + * slow or unresponsive remote does not block the branch-switch flow + * indefinitely. Timeout errors are logged and treated as non-fatal + * (the same as network-unavailable errors) so the rest of the workflow + * continues normally. */ async function fetchRemotes(cwd: string): Promise { + const controller = new AbortController(); + const timerId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + try { - await execGitCommand(['fetch', '--all', '--quiet'], cwd); - } catch { - // Ignore fetch errors - we may be offline + await execGitCommand(['fetch', '--all', '--quiet'], cwd, undefined, controller); + } catch (error) { + if (error instanceof Error && error.message === 'Process aborted') { + // Fetch timed out - log and continue; callers should not be blocked by a slow remote + logger.warn( + `fetchRemotes timed out after ${FETCH_TIMEOUT_MS}ms - continuing without latest remote refs` + ); + } + // Ignore all fetch errors (timeout or otherwise) - we may be offline or the + // remote may be temporarily unavailable. The branch switch itself has + // already succeeded at this point. + } finally { + clearTimeout(timerId); } } /** - * Parse a remote branch name like "origin/feature-branch" into its parts + * Parse a remote branch name like "origin/feature-branch" into its parts. + * Splits on the first slash so the remote is the segment before the first '/' + * and the branch is everything after it (preserving any subsequent slashes). + * For example, "origin/feature/my-branch" → { remote: "origin", branch: "feature/my-branch" }. + * Returns null if the input contains no slash. */ function parseRemoteBranch(branchName: string): { remote: string; branch: string } | null { - const lastSlash = branchName.lastIndexOf('/'); - if (lastSlash === -1) return null; + const firstSlash = branchName.indexOf('/'); + if (firstSlash === -1) return null; return { - remote: branchName.substring(0, lastSlash), - branch: branchName.substring(lastSlash + 1), + remote: branchName.substring(0, firstSlash), + branch: branchName.substring(firstSlash + 1), }; } @@ -453,6 +479,12 @@ export async function performSwitchBranch( } // popResult.success === true: stash was cleanly restored, re-throw the checkout error } + const checkoutErrorMsg = getErrorMessage(checkoutError); + events?.emit('switch:error', { + worktreePath, + branchName, + error: checkoutErrorMsg, + }); throw checkoutError; } } diff --git a/apps/server/tests/unit/providers/claude-provider.test.ts b/apps/server/tests/unit/providers/claude-provider.test.ts index 833ab9ca..69e0c260 100644 --- a/apps/server/tests/unit/providers/claude-provider.test.ts +++ b/apps/server/tests/unit/providers/claude-provider.test.ts @@ -5,6 +5,17 @@ import { collectAsyncGenerator } from '../../utils/helpers.js'; vi.mock('@anthropic-ai/claude-agent-sdk'); +vi.mock('@automaker/platform', () => ({ + getClaudeAuthIndicators: vi.fn().mockResolvedValue({ + hasCredentialsFile: false, + hasSettingsFile: false, + hasStatsCacheWithActivity: false, + hasProjectsSessions: false, + credentials: null, + checks: {}, + }), +})); + describe('claude-provider.ts', () => { let provider: ClaudeProvider; diff --git a/apps/ui/src/components/codex-usage-popover.tsx b/apps/ui/src/components/codex-usage-popover.tsx index 04482548..d4b55506 100644 --- a/apps/ui/src/components/codex-usage-popover.tsx +++ b/apps/ui/src/components/codex-usage-popover.tsx @@ -6,6 +6,7 @@ import { Spinner } from '@/components/ui/spinner'; import { cn } from '@/lib/utils'; import { useSetupStore } from '@/store/setup-store'; import { useCodexUsage } from '@/hooks/queries'; +import { getExpectedCodexPacePercentage, getPaceStatusLabel } from '@/store/utils/usage-utils'; // Error codes for distinguishing failure modes const ERROR_CODES = { @@ -100,13 +101,28 @@ export function CodexUsagePopover() { return { color: 'text-green-500', icon: CheckCircle, bg: 'bg-green-500' }; }; - // Helper component for the progress bar - const ProgressBar = ({ percentage, colorClass }: { percentage: number; colorClass: string }) => ( -

+ // Helper component for the progress bar with optional pace indicator + const ProgressBar = ({ + percentage, + colorClass, + pacePercentage, + }: { + percentage: number; + colorClass: string; + pacePercentage?: number | null; + }) => ( +
+ {pacePercentage != null && pacePercentage > 0 && pacePercentage < 100 && ( +
+ )}
); @@ -117,6 +133,7 @@ export function CodexUsagePopover() { resetText, isPrimary = false, stale = false, + pacePercentage, }: { title: string; subtitle: string; @@ -124,6 +141,7 @@ export function CodexUsagePopover() { resetText?: string; isPrimary?: boolean; stale?: boolean; + pacePercentage?: number | null; }) => { const isValidPercentage = typeof percentage === 'number' && !isNaN(percentage) && isFinite(percentage); @@ -131,6 +149,10 @@ export function CodexUsagePopover() { const status = getStatusInfo(safePercentage); const StatusIcon = status.icon; + const paceLabel = + isValidPercentage && pacePercentage != null + ? getPaceStatusLabel(safePercentage, pacePercentage) + : null; return (
- {resetText && ( -
+
+ {paceLabel ? ( +

(pacePercentage ?? 0) ? 'text-orange-500' : 'text-green-500' + )} + > + {paceLabel} +

+ ) : ( +
+ )} + {resetText && (

{resetText}

-
- )} + )} +
); }; @@ -289,6 +324,10 @@ export function CodexUsagePopover() { resetText={formatResetTime(codexUsage.rateLimits.primary.resetsAt)} isPrimary={true} stale={isStale} + pacePercentage={getExpectedCodexPacePercentage( + codexUsage.rateLimits.primary.resetsAt, + codexUsage.rateLimits.primary.windowDurationMins + )} /> )} @@ -302,6 +341,10 @@ export function CodexUsagePopover() { percentage={codexUsage.rateLimits.secondary.usedPercent} resetText={formatResetTime(codexUsage.rateLimits.secondary.resetsAt)} stale={isStale} + pacePercentage={getExpectedCodexPacePercentage( + codexUsage.rateLimits.secondary.resetsAt, + codexUsage.rateLimits.secondary.windowDurationMins + )} /> )} diff --git a/apps/ui/src/components/ui/git-diff-panel.tsx b/apps/ui/src/components/ui/git-diff-panel.tsx index 4fe39cf8..a3afe715 100644 --- a/apps/ui/src/components/ui/git-diff-panel.tsx +++ b/apps/ui/src/components/ui/git-diff-panel.tsx @@ -524,234 +524,153 @@ export function GitDiffPanel({ setExpandedFiles(new Set()); }; - // Stage/unstage a single file - const handleStageFile = useCallback( - async (filePath: string) => { + // Shared helper that encapsulates all staging/unstaging logic + const executeStagingAction = useCallback( + async ( + action: 'stage' | 'unstage', + paths: string[], + successMessage: string, + failurePrefix: string, + onStart: () => void, + onFinally: () => void + ) => { if (!worktreePath && !projectPath) return; - if (enableStaging && useWorktrees && !worktreePath) { - toast.error('Failed to stage file', { - description: 'worktreePath required when useWorktrees is enabled', - }); - return; - } - setStagingInProgress((prev) => new Set(prev).add(filePath)); + onStart(); try { const api = getElectronAPI(); let result: { success: boolean; error?: string } | undefined; if (useWorktrees && worktreePath) { if (!api.worktree?.stageFiles) { - toast.error('Failed to stage file', { + toast.error(failurePrefix, { description: 'Worktree stage API not available', }); return; } - result = await api.worktree.stageFiles(worktreePath, [filePath], 'stage'); + result = await api.worktree.stageFiles(worktreePath, paths, action); } else if (!useWorktrees) { if (!api.git?.stageFiles) { - toast.error('Failed to stage file', { description: 'Git stage API not available' }); + toast.error(failurePrefix, { description: 'Git stage API not available' }); return; } - result = await api.git.stageFiles(projectPath, [filePath], 'stage'); + result = await api.git.stageFiles(projectPath, paths, action); } if (!result) { - toast.error('Failed to stage file', { description: 'Stage API not available' }); + toast.error(failurePrefix, { description: 'Stage API not available' }); return; } if (!result.success) { - toast.error('Failed to stage file', { description: result.error }); + toast.error(failurePrefix, { description: result.error }); return; } // Refetch diffs to reflect the new staging state await loadDiffs(); - toast.success('File staged', { description: filePath }); + toast.success(successMessage, paths.length === 1 ? { description: paths[0] } : undefined); } catch (err) { - toast.error('Failed to stage file', { + toast.error(failurePrefix, { description: err instanceof Error ? err.message : 'Unknown error', }); } finally { - setStagingInProgress((prev) => { - const next = new Set(prev); - next.delete(filePath); - return next; - }); + onFinally(); } }, - [worktreePath, projectPath, useWorktrees, enableStaging, loadDiffs] + [worktreePath, projectPath, useWorktrees, loadDiffs] + ); + + // Stage/unstage a single file + const handleStageFile = useCallback( + async (filePath: string) => { + if (enableStaging && useWorktrees && !worktreePath) { + toast.error('Failed to stage file', { + description: 'worktreePath required when useWorktrees is enabled', + }); + return; + } + await executeStagingAction( + 'stage', + [filePath], + 'File staged', + 'Failed to stage file', + () => setStagingInProgress((prev) => new Set(prev).add(filePath)), + () => + setStagingInProgress((prev) => { + const next = new Set(prev); + next.delete(filePath); + return next; + }) + ); + }, + [worktreePath, useWorktrees, enableStaging, executeStagingAction] ); // Unstage a single file const handleUnstageFile = useCallback( async (filePath: string) => { - if (!worktreePath && !projectPath) return; if (enableStaging && useWorktrees && !worktreePath) { toast.error('Failed to unstage file', { description: 'worktreePath required when useWorktrees is enabled', }); 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; - }); - } + await executeStagingAction( + 'unstage', + [filePath], + 'File unstaged', + 'Failed to unstage file', + () => setStagingInProgress((prev) => new Set(prev).add(filePath)), + () => + setStagingInProgress((prev) => { + const next = new Set(prev); + next.delete(filePath); + return next; + }) + ); }, - [worktreePath, projectPath, useWorktrees, enableStaging, loadDiffs] + [worktreePath, useWorktrees, enableStaging, executeStagingAction] ); 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]); + await executeStagingAction( + 'stage', + allPaths, + 'All files staged', + 'Failed to stage all files', + () => setStagingInProgress(new Set(allPaths)), + () => setStagingInProgress(new Set()) + ); + }, [worktreePath, projectPath, useWorktrees, files, executeStagingAction]); 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]); + await executeStagingAction( + 'unstage', + allPaths, + 'All files unstaged', + 'Failed to unstage all files', + () => setStagingInProgress(new Set(allPaths)), + () => setStagingInProgress(new Set()) + ); + }, [worktreePath, projectPath, useWorktrees, files, executeStagingAction]); // Compute staging summary const stagingSummary = useMemo(() => { if (!enableStaging) return null; let staged = 0; + let partial = 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++; - } + else partial++; } - return { staged, unstaged, total: files.length }; + return { staged, partial, unstaged, total: files.length }; }, [enableStaging, files]); // Total stats @@ -884,7 +803,10 @@ export function GitDiffPanel({ size="sm" onClick={handleStageAll} className="text-xs h-7" - disabled={stagingInProgress.size > 0 || stagingSummary.unstaged === 0} + disabled={ + stagingInProgress.size > 0 || + (stagingSummary.unstaged === 0 && stagingSummary.partial === 0) + } > Stage All @@ -894,7 +816,10 @@ export function GitDiffPanel({ size="sm" onClick={handleUnstageAll} className="text-xs h-7" - disabled={stagingInProgress.size > 0 || stagingSummary.staged === 0} + disabled={ + stagingInProgress.size > 0 || + (stagingSummary.staged === 0 && stagingSummary.partial === 0) + } > Unstage All @@ -942,7 +867,9 @@ export function GitDiffPanel({ )} {enableStaging && stagingSummary && ( - ({stagingSummary.staged} staged, {stagingSummary.unstaged} unstaged) + {stagingSummary.partial > 0 + ? `(${stagingSummary.staged} staged, ${stagingSummary.partial} partial, ${stagingSummary.unstaged} unstaged)` + : `(${stagingSummary.staged} staged, ${stagingSummary.unstaged} unstaged)`} )}
diff --git a/apps/ui/src/components/usage-popover.tsx b/apps/ui/src/components/usage-popover.tsx index 5292ba1e..aad1b670 100644 --- a/apps/ui/src/components/usage-popover.tsx +++ b/apps/ui/src/components/usage-popover.tsx @@ -8,7 +8,11 @@ import { cn } from '@/lib/utils'; import { useSetupStore } from '@/store/setup-store'; import { AnthropicIcon, OpenAIIcon, ZaiIcon, GeminiIcon } from '@/components/ui/provider-icon'; import { useClaudeUsage, useCodexUsage, useZaiUsage, useGeminiUsage } from '@/hooks/queries'; -import { getExpectedWeeklyPacePercentage, getPaceStatusLabel } from '@/store/utils/usage-utils'; +import { + getExpectedWeeklyPacePercentage, + getExpectedCodexPacePercentage, + getPaceStatusLabel, +} from '@/store/utils/usage-utils'; // Error codes for distinguishing failure modes const ERROR_CODES = { @@ -683,6 +687,10 @@ export function UsagePopover() { resetText={formatCodexResetTime(codexUsage.rateLimits.primary.resetsAt)} isPrimary={true} stale={isCodexStale} + pacePercentage={getExpectedCodexPacePercentage( + codexUsage.rateLimits.primary.resetsAt, + codexUsage.rateLimits.primary.windowDurationMins + )} /> )} @@ -699,6 +707,10 @@ export function UsagePopover() { percentage={codexUsage.rateLimits.secondary.usedPercent} resetText={formatCodexResetTime(codexUsage.rateLimits.secondary.resetsAt)} stale={isCodexStale} + pacePercentage={getExpectedCodexPacePercentage( + codexUsage.rateLimits.secondary.resetsAt, + codexUsage.rateLimits.secondary.windowDurationMins + )} /> )} diff --git a/apps/ui/src/components/views/board-view/dialogs/commit-worktree-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/commit-worktree-dialog.tsx index 5381627b..4c7e0c0d 100644 --- a/apps/ui/src/components/views/board-view/dialogs/commit-worktree-dialog.tsx +++ b/apps/ui/src/components/views/board-view/dialogs/commit-worktree-dialog.tsx @@ -103,8 +103,6 @@ const getStatusBadgeColor = (status: string) => { } }; -// parseDiff is imported from @/lib/diff-utils - function DiffLine({ type, content, @@ -236,7 +234,12 @@ export function CommitWorktreeDialog({ } } } catch (err) { - console.warn('Failed to load diffs for commit dialog:', err); + console.error('Failed to load diffs for commit dialog:', err); + if (!cancelled) { + const errorMsg = err instanceof Error ? err.message : 'Failed to load diffs'; + setError(errorMsg); + toast.error(errorMsg); + } } finally { if (!cancelled) setIsLoadingDiffs(false); } diff --git a/apps/ui/src/components/views/board-view/dialogs/discard-worktree-changes-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/discard-worktree-changes-dialog.tsx index a01c613e..72b77204 100644 --- a/apps/ui/src/components/views/board-view/dialogs/discard-worktree-changes-dialog.tsx +++ b/apps/ui/src/components/views/board-view/dialogs/discard-worktree-changes-dialog.tsx @@ -102,7 +102,26 @@ const getStatusBadgeColor = (status: string) => { } }; -// parseDiff is imported from @/lib/diff-utils +const bgClass = { + context: 'bg-transparent', + addition: 'bg-green-500/10', + deletion: 'bg-red-500/10', + header: 'bg-blue-500/10', +}; + +const textClass = { + context: 'text-foreground-secondary', + addition: 'text-green-400', + deletion: 'text-red-400', + header: 'text-blue-400', +}; + +const prefix = { + context: ' ', + addition: '+', + deletion: '-', + header: '', +}; function DiffLine({ type, @@ -113,27 +132,6 @@ function DiffLine({ content: string; lineNumber?: { old?: number; new?: number }; }) { - const bgClass = { - context: 'bg-transparent', - addition: 'bg-green-500/10', - deletion: 'bg-red-500/10', - header: 'bg-blue-500/10', - }; - - const textClass = { - context: 'text-foreground-secondary', - addition: 'text-green-400', - deletion: 'text-red-400', - header: 'text-blue-400', - }; - - const prefix = { - context: ' ', - addition: '+', - deletion: '-', - header: '', - }; - if (type === 'header') { return (
@@ -332,6 +330,7 @@ export function DiscardWorktreeChangesDialog({ {files.length > 0 && (