mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-16 21:53:07 +00:00
feat: Add process abort control and improve auth detection
This commit is contained in:
@@ -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<string, string>
|
||||
env?: Record<string, string>,
|
||||
abortController?: AbortController
|
||||
): Promise<string> {
|
||||
const result = await spawnProcess({
|
||||
command: 'git',
|
||||
args,
|
||||
cwd,
|
||||
...(env !== undefined ? { env } : {}),
|
||||
...(abortController !== undefined ? { abortController } : {}),
|
||||
});
|
||||
|
||||
// spawnProcess returns { stdout, stderr, exitCode }
|
||||
|
||||
@@ -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<InstallationStatus> {
|
||||
// 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;
|
||||
|
||||
@@ -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') ||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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<void> => {
|
||||
@@ -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) });
|
||||
}
|
||||
|
||||
@@ -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<string, unknown> 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<string, unknown>
|
||||
) => {
|
||||
opts?: {
|
||||
planningMode?: PlanningMode;
|
||||
requirePlanApproval?: boolean;
|
||||
previousContent?: string;
|
||||
systemPrompt?: string;
|
||||
autoLoadClaudeMd?: boolean;
|
||||
thinkingLevel?: ThinkingLevel;
|
||||
branchName?: string | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
): Promise<void> => {
|
||||
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),
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string> {
|
||||
return await execGitCommand(['stash', 'pop'], worktreePath);
|
||||
return await execGitCommandWithLockRetry(['stash', 'pop'], worktreePath);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -129,7 +130,7 @@ export async function popStash(worktreePath: string): Promise<string> {
|
||||
*/
|
||||
async function tryPopStash(worktreePath: string): Promise<boolean> {
|
||||
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<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<boolean> {
|
||||
* @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<boolean> {
|
||||
): Promise<UpstreamStatus> {
|
||||
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<string[]> {
|
||||
try {
|
||||
const diffOutput = await execGitCommand(
|
||||
['diff', '--name-only', '--diff-filter=U'],
|
||||
worktreePath
|
||||
);
|
||||
return diffOutput
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter((f) => f.trim().length > 0);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether an error output string indicates a merge conflict.
|
||||
*/
|
||||
@@ -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<PullResult> {
|
||||
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<P
|
||||
const errorOutput = `${err.stderr || ''} ${err.stdout || ''} ${err.message || ''}`;
|
||||
|
||||
// Check if stash pop failed due to conflicts
|
||||
// The stash remains in the stash list when conflicts occur, so stashRestored is false
|
||||
if (isStashConflict(errorOutput)) {
|
||||
const stashConflictFiles = await getConflictFiles(worktreePath);
|
||||
|
||||
@@ -437,7 +428,7 @@ async function reapplyStash(worktreePath: string, branchName: string): Promise<P
|
||||
conflictSource: 'stash',
|
||||
conflictFiles: stashConflictFiles,
|
||||
stashed: true,
|
||||
stashRestored: true,
|
||||
stashRestored: false,
|
||||
message: 'Pull succeeded but reapplying your stashed changes resulted in merge conflicts.',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { getConflictFiles } from '@automaker/git-utils';
|
||||
import { execGitCommand, getCurrentBranch } from '../lib/git.js';
|
||||
|
||||
const logger = createLogger('RebaseService');
|
||||
@@ -186,24 +187,3 @@ export async function abortRebase(worktreePath: string): Promise<boolean> {
|
||||
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<string[]> {
|
||||
try {
|
||||
const diffOutput = await execGitCommand(
|
||||
['diff', '--name-only', '--diff-filter=U'],
|
||||
worktreePath
|
||||
);
|
||||
return diffOutput
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter((f) => f.trim().length > 0);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
109
apps/server/src/services/stage-files-service.ts
Normal file
109
apps/server/src/services/stage-files-service.ts
Normal file
@@ -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<StageFilesResult> {
|
||||
// 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,
|
||||
};
|
||||
}
|
||||
@@ -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<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 }) => (
|
||||
<div className="h-2 w-full bg-secondary/50 rounded-full overflow-hidden">
|
||||
// Helper component for the progress bar with optional pace indicator
|
||||
const ProgressBar = ({
|
||||
percentage,
|
||||
colorClass,
|
||||
pacePercentage,
|
||||
}: {
|
||||
percentage: number;
|
||||
colorClass: string;
|
||||
pacePercentage?: number | null;
|
||||
}) => (
|
||||
<div className="relative h-2 w-full bg-secondary/50 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn('h-full transition-all duration-500', colorClass)}
|
||||
style={{ width: `${Math.min(percentage, 100)}%` }}
|
||||
/>
|
||||
{pacePercentage != null && pacePercentage > 0 && pacePercentage < 100 && (
|
||||
<div
|
||||
className="absolute top-0 h-full w-0.5 bg-foreground/60"
|
||||
style={{ left: `${pacePercentage}%` }}
|
||||
title={`Expected: ${Math.round(pacePercentage)}%`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
@@ -165,15 +187,28 @@ export function CodexUsagePopover() {
|
||||
<ProgressBar
|
||||
percentage={safePercentage}
|
||||
colorClass={isValidPercentage ? status.bg : 'bg-muted-foreground/30'}
|
||||
pacePercentage={pacePercentage}
|
||||
/>
|
||||
{resetText && (
|
||||
<div className="mt-2 flex justify-end">
|
||||
<div className="mt-2 flex items-center justify-between">
|
||||
{paceLabel ? (
|
||||
<p
|
||||
className={cn(
|
||||
'text-[10px] font-medium',
|
||||
safePercentage > (pacePercentage ?? 0) ? 'text-orange-500' : 'text-green-500'
|
||||
)}
|
||||
>
|
||||
{paceLabel}
|
||||
</p>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
{resetText && (
|
||||
<p className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{resetText}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
>
|
||||
<Plus className="w-3 h-3 mr-1" />
|
||||
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)
|
||||
}
|
||||
>
|
||||
<Minus className="w-3 h-3 mr-1" />
|
||||
Unstage All
|
||||
@@ -942,7 +867,9 @@ export function GitDiffPanel({
|
||||
)}
|
||||
{enableStaging && stagingSummary && (
|
||||
<span className="text-muted-foreground">
|
||||
({stagingSummary.staged} staged, {stagingSummary.unstaged} unstaged)
|
||||
{stagingSummary.partial > 0
|
||||
? `(${stagingSummary.staged} staged, ${stagingSummary.partial} partial, ${stagingSummary.unstaged} unstaged)`
|
||||
: `(${stagingSummary.staged} staged, ${stagingSummary.unstaged} unstaged)`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className={cn('px-2 py-1 font-mono text-xs', bgClass[type], textClass[type])}>
|
||||
@@ -332,6 +330,7 @@ export function DiscardWorktreeChangesDialog({
|
||||
</Label>
|
||||
{files.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleToggleAll}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
@@ -368,6 +367,8 @@ export function DiscardWorktreeChangesDialog({
|
||||
)
|
||||
: 0;
|
||||
|
||||
const fileButtonId = `file-btn-${file.path.replace(/[^a-zA-Z0-9]/g, '-')}`;
|
||||
|
||||
return (
|
||||
<div key={file.path} className="border-b border-border last:border-b-0">
|
||||
<div
|
||||
@@ -381,11 +382,15 @@ export function DiscardWorktreeChangesDialog({
|
||||
checked={isChecked}
|
||||
onCheckedChange={() => handleToggleFile(file.path)}
|
||||
className="flex-shrink-0"
|
||||
aria-labelledby={fileButtonId}
|
||||
/>
|
||||
|
||||
{/* Clickable file row to show diff */}
|
||||
<button
|
||||
id={fileButtonId}
|
||||
type="button"
|
||||
onClick={() => handleFileClick(file.path)}
|
||||
aria-expanded={isExpanded}
|
||||
className="flex items-center gap-2 flex-1 min-w-0 text-left"
|
||||
>
|
||||
{isExpanded ? (
|
||||
|
||||
@@ -5,7 +5,11 @@ import { Spinner } from '@/components/ui/spinner';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { AnthropicIcon, OpenAIIcon, ZaiIcon, GeminiIcon } from '@/components/ui/provider-icon';
|
||||
import { getExpectedWeeklyPacePercentage, getPaceStatusLabel } from '@/store/utils/usage-utils';
|
||||
import {
|
||||
getExpectedWeeklyPacePercentage,
|
||||
getExpectedCodexPacePercentage,
|
||||
getPaceStatusLabel,
|
||||
} from '@/store/utils/usage-utils';
|
||||
|
||||
interface MobileUsageBarProps {
|
||||
showClaudeUsage: boolean;
|
||||
@@ -345,6 +349,10 @@ export function MobileUsageBar({
|
||||
label={getCodexWindowLabel(codexUsage.rateLimits.primary.windowDurationMins)}
|
||||
percentage={codexUsage.rateLimits.primary.usedPercent}
|
||||
isStale={isCodexStale}
|
||||
pacePercentage={getExpectedCodexPacePercentage(
|
||||
codexUsage.rateLimits.primary.resetsAt,
|
||||
codexUsage.rateLimits.primary.windowDurationMins
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{codexUsage.rateLimits.secondary && (
|
||||
@@ -352,6 +360,10 @@ export function MobileUsageBar({
|
||||
label={getCodexWindowLabel(codexUsage.rateLimits.secondary.windowDurationMins)}
|
||||
percentage={codexUsage.rateLimits.secondary.usedPercent}
|
||||
isStale={isCodexStale}
|
||||
pacePercentage={getExpectedCodexPacePercentage(
|
||||
codexUsage.rateLimits.secondary.resetsAt,
|
||||
codexUsage.rateLimits.secondary.windowDurationMins
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { useSetupStore } from '@/store/setup-store';
|
||||
import { useCodexUsage } from '@/hooks/queries';
|
||||
import type { CodexRateLimitWindow } from '@/store/app-store';
|
||||
import { getExpectedCodexPacePercentage, getPaceStatusLabel } from '@/store/utils/usage-utils';
|
||||
|
||||
const CODEX_USAGE_TITLE = 'Codex Usage';
|
||||
const CODEX_USAGE_SUBTITLE = 'Shows usage limits reported by the Codex CLI.';
|
||||
@@ -73,6 +74,12 @@ export function CodexUsageSection() {
|
||||
}) => {
|
||||
const safePercentage = Math.min(Math.max(limitWindow.usedPercent, 0), MAX_PERCENTAGE);
|
||||
const resetLabel = formatCodexResetTime(limitWindow.resetsAt);
|
||||
const pacePercentage = getExpectedCodexPacePercentage(
|
||||
limitWindow.resetsAt,
|
||||
limitWindow.windowDurationMins
|
||||
);
|
||||
const paceLabel =
|
||||
pacePercentage != null ? getPaceStatusLabel(safePercentage, pacePercentage) : null;
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border/60 bg-card/50 p-4">
|
||||
@@ -85,7 +92,7 @@ export function CodexUsageSection() {
|
||||
{Math.round(safePercentage)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 h-2 w-full rounded-full bg-secondary/60">
|
||||
<div className="relative mt-3 h-2 w-full rounded-full bg-secondary/60">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full transition-all duration-300',
|
||||
@@ -93,8 +100,29 @@ export function CodexUsageSection() {
|
||||
)}
|
||||
style={{ width: `${safePercentage}%` }}
|
||||
/>
|
||||
{pacePercentage != null && pacePercentage > 0 && pacePercentage < 100 && (
|
||||
<div
|
||||
className="absolute top-0 h-full w-0.5 bg-foreground/60"
|
||||
style={{ left: `${pacePercentage}%` }}
|
||||
title={`Expected: ${Math.round(pacePercentage)}%`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-between">
|
||||
{paceLabel ? (
|
||||
<p
|
||||
className={cn(
|
||||
'text-xs font-medium',
|
||||
safePercentage > (pacePercentage ?? 0) ? 'text-orange-500' : 'text-green-500'
|
||||
)}
|
||||
>
|
||||
{paceLabel}
|
||||
</p>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
{resetLabel && <p className="text-xs text-muted-foreground">{resetLabel}</p>}
|
||||
</div>
|
||||
{resetLabel && <p className="mt-2 text-xs text-muted-foreground">{resetLabel}</p>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Fragment, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { useIsMobile } from '@/hooks/use-media-query';
|
||||
import { useOpencodeModels } from '@/hooks/queries';
|
||||
import type {
|
||||
ModelAlias,
|
||||
CursorModelId,
|
||||
@@ -180,14 +181,16 @@ export function PhaseModelSelector({
|
||||
codexModels,
|
||||
codexModelsLoading,
|
||||
fetchCodexModels,
|
||||
dynamicOpencodeModels,
|
||||
enabledDynamicModelIds,
|
||||
opencodeModelsLoading,
|
||||
fetchOpencodeModels,
|
||||
disabledProviders,
|
||||
claudeCompatibleProviders,
|
||||
} = useAppStore();
|
||||
|
||||
// Use React Query for OpenCode models so that changes made in the settings tab
|
||||
// (which also uses React Query) are immediately reflected here via the shared cache,
|
||||
// without requiring a page refresh.
|
||||
const { data: dynamicOpencodeModels = [] } = useOpencodeModels();
|
||||
|
||||
// Detect mobile devices to use inline expansion instead of nested popovers
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
@@ -211,14 +214,9 @@ export function PhaseModelSelector({
|
||||
}
|
||||
}, [codexModels.length, codexModelsLoading, fetchCodexModels]);
|
||||
|
||||
// Fetch OpenCode models on mount
|
||||
useEffect(() => {
|
||||
if (dynamicOpencodeModels.length === 0 && !opencodeModelsLoading) {
|
||||
fetchOpencodeModels().catch(() => {
|
||||
// Silently fail - user will see only static OpenCode models
|
||||
});
|
||||
}
|
||||
}, [dynamicOpencodeModels.length, opencodeModelsLoading, fetchOpencodeModels]);
|
||||
// OpenCode dynamic models are now fetched via React Query (useOpencodeModels above),
|
||||
// which shares a cache with the settings tab. This ensures that newly enabled models
|
||||
// appear in the selector immediately after the settings tab fetches/invalidates the data.
|
||||
|
||||
// Close expanded group when trigger scrolls out of view
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { useState, useCallback, useMemo, useEffect } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
@@ -20,6 +20,7 @@ export function OpencodeSettingsTab() {
|
||||
toggleOpencodeModel,
|
||||
enabledDynamicModelIds,
|
||||
toggleDynamicModel,
|
||||
setDynamicOpencodeModels,
|
||||
} = useAppStore();
|
||||
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
@@ -37,6 +38,16 @@ export function OpencodeSettingsTab() {
|
||||
|
||||
const { data: modelsData = [], isFetching: isFetchingModels } = useOpencodeModels();
|
||||
|
||||
// Sync React Query opencode models data to Zustand store so that the model
|
||||
// selector dropdown (PhaseModelSelector) reflects newly enabled models without
|
||||
// requiring a page refresh. The selector reads from the Zustand store while
|
||||
// this settings tab fetches via React Query — keeping them in sync bridges that gap.
|
||||
useEffect(() => {
|
||||
if (modelsData.length > 0) {
|
||||
setDynamicOpencodeModels(modelsData);
|
||||
}
|
||||
}, [modelsData, setDynamicOpencodeModels]);
|
||||
|
||||
// Transform CLI status to the expected format
|
||||
const cliStatus = useMemo((): SharedCliStatus | null => {
|
||||
if (!cliStatusData) return null;
|
||||
|
||||
@@ -59,6 +59,48 @@ export function getPaceStatusLabel(
|
||||
return `${Math.abs(diff)}% ahead of pace`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the expected pace percentage for a Codex rate limit window based on how far
|
||||
* through the window we are. This is a generic version of getExpectedWeeklyPacePercentage
|
||||
* that works with any window duration.
|
||||
*
|
||||
* Only returns a value for windows >= 1 day (1440 minutes) since pace tracking isn't
|
||||
* meaningful for short windows.
|
||||
*
|
||||
* @param resetsAt - Unix timestamp in seconds for when the window resets
|
||||
* @param windowDurationMins - Window duration in minutes
|
||||
* @returns The expected usage percentage (0-100), or null if not applicable
|
||||
*/
|
||||
export function getExpectedCodexPacePercentage(
|
||||
resetsAt: number | undefined | null,
|
||||
windowDurationMins: number | undefined | null
|
||||
): number | null {
|
||||
// Only show pace for windows >= 1 day (1440 minutes)
|
||||
if (!resetsAt || !windowDurationMins || windowDurationMins < 1440) return null;
|
||||
|
||||
try {
|
||||
const resetDate = new Date(resetsAt * 1000);
|
||||
if (isNaN(resetDate.getTime())) return null;
|
||||
|
||||
const now = new Date();
|
||||
const windowMs = windowDurationMins * 60 * 1000;
|
||||
|
||||
// The window started windowDurationMins before the reset
|
||||
const windowStartDate = new Date(resetDate.getTime() - windowMs);
|
||||
|
||||
// How far through the window are we?
|
||||
const elapsed = now.getTime() - windowStartDate.getTime();
|
||||
const fractionElapsed = elapsed / windowMs;
|
||||
|
||||
// Clamp to 0-1 range
|
||||
const clamped = Math.max(0, Math.min(1, fractionElapsed));
|
||||
|
||||
return clamped * 100;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Claude usage is at its limit (any of: session >= 100%, weekly >= 100%, OR cost >= limit)
|
||||
* Returns true if any limit is reached, meaning auto mode should pause feature pickup.
|
||||
|
||||
44
libs/git-utils/src/conflict.ts
Normal file
44
libs/git-utils/src/conflict.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Git conflict detection utilities
|
||||
*/
|
||||
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
/**
|
||||
* Execute a git command with array arguments to prevent command injection.
|
||||
*
|
||||
* @param args - Array of git command arguments
|
||||
* @param cwd - Working directory to execute the command in
|
||||
* @returns Promise resolving to stdout output
|
||||
* @throws Error if the command fails
|
||||
*/
|
||||
async function execGitCommand(args: string[], cwd: string): Promise<string> {
|
||||
// Shell-escape each argument to prevent injection
|
||||
const escaped = args.map((a) => `'${a.replace(/'/g, "'\\''")}'`).join(' ');
|
||||
const { stdout } = await execAsync(`git ${escaped}`, { cwd });
|
||||
return stdout;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the list of files with unresolved merge conflicts.
|
||||
*
|
||||
* @param worktreePath - Path to the git worktree
|
||||
* @returns Array of file paths with conflicts
|
||||
*/
|
||||
export async function getConflictFiles(worktreePath: string): Promise<string[]> {
|
||||
try {
|
||||
const diffOutput = await execGitCommand(
|
||||
['diff', '--name-only', '--diff-filter=U'],
|
||||
worktreePath
|
||||
);
|
||||
return diffOutput
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter((f) => f.trim().length > 0);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -17,3 +17,6 @@ export {
|
||||
generateDiffsForNonGitDirectory,
|
||||
getGitRepositoryDiffs,
|
||||
} from './diff.js';
|
||||
|
||||
// Export conflict utilities
|
||||
export { getConflictFiles } from './conflict.js';
|
||||
|
||||
Reference in New Issue
Block a user