diff --git a/.github/workflows/test-azure-auth.yml b/.github/workflows/test-azure-auth.yml new file mode 100644 index 0000000000..f3fb73c276 --- /dev/null +++ b/.github/workflows/test-azure-auth.yml @@ -0,0 +1,21 @@ +name: Test Azure Auth + +on: + workflow_dispatch: + +jobs: + test-auth: + runs-on: windows-latest + permissions: + id-token: write + contents: read + steps: + - name: Azure Login (OIDC) + uses: azure/login@v2 + with: + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + + - name: Success + run: echo "Azure authentication successful!" diff --git a/.gitignore b/.gitignore index 6d2e458532..e0acc0dc5b 100644 --- a/.gitignore +++ b/.gitignore @@ -168,3 +168,4 @@ OPUS_ANALYSIS_AND_IDEAS.md # Auto Claude generated files .security-key +/shared_docs \ No newline at end of file diff --git a/README.md b/README.md index 595f8768db..6592bac309 100644 --- a/README.md +++ b/README.md @@ -22,11 +22,12 @@ | Platform | Download | |----------|----------| -| **Windows** | [Auto-Claude-2.7.2-win32-x64.exe](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.2/Auto-Claude-2.7.2-win32-x64.exe) | -| **macOS (Apple Silicon)** | [Auto-Claude-2.7.2-darwin-arm64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.2/Auto-Claude-2.7.2-darwin-arm64.dmg) | -| **macOS (Intel)** | [Auto-Claude-2.7.2-darwin-x64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.2/Auto-Claude-2.7.2-darwin-x64.dmg) | -| **Linux** | [Auto-Claude-2.7.2-linux-x86_64.AppImage](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.2/Auto-Claude-2.7.2-linux-x86_64.AppImage) | -| **Linux (Debian)** | [Auto-Claude-2.7.2-linux-amd64.deb](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.2/Auto-Claude-2.7.2-linux-amd64.deb) | +| **Windows** | [Auto-Claude-2.7.3-win32-x64.exe](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.3/Auto-Claude-2.7.3-win32-x64.exe) | +| **macOS (Apple Silicon)** | [Auto-Claude-2.7.3-darwin-arm64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.3/Auto-Claude-2.7.3-darwin-arm64.dmg) | +| **macOS (Intel)** | [Auto-Claude-2.7.3-darwin-x64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.3/Auto-Claude-2.7.3-darwin-x64.dmg) | +| **Linux** | [Auto-Claude-2.7.3-linux-x86_64.AppImage](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.3/Auto-Claude-2.7.3-linux-x86_64.AppImage) | +| **Linux (Debian)** | [Auto-Claude-2.7.3-linux-amd64.deb](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.3/Auto-Claude-2.7.3-linux-amd64.deb) | +| **Linux (Flatpak)** | [Auto-Claude-2.7.3-linux-x86_64.flatpak](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.3/Auto-Claude-2.7.3-linux-x86_64.flatpak) | ### Beta Release diff --git a/apps/backend/core/worktree.py b/apps/backend/core/worktree.py index a5fdc6d57e..a94964ef7f 100644 --- a/apps/backend/core/worktree.py +++ b/apps/backend/core/worktree.py @@ -647,7 +647,29 @@ def merge_worktree( result = self._run_git(merge_args) if result.returncode != 0: - print("Merge conflict! Aborting merge...") + # Check if it's "already up to date" - not an error + output = (result.stdout + result.stderr).lower() + if "already up to date" in output or "already up-to-date" in output: + print(f"Branch {info.branch} is already up to date.") + if no_commit: + print("No changes to stage.") + if delete_after: + self.remove_worktree(spec_name, delete_branch=True) + return True + # Check for actual conflicts + if "conflict" in output: + print("Merge conflict! Aborting merge...") + self._run_git(["merge", "--abort"]) + return False + # Other error - show details + stderr_msg = ( + result.stderr[:200] + if result.stderr + else result.stdout[:200] + if result.stdout + else "" + ) + print(f"Merge failed: {stderr_msg}") self._run_git(["merge", "--abort"]) return False diff --git a/apps/frontend/src/main/claude-profile/profile-scorer.ts b/apps/frontend/src/main/claude-profile/profile-scorer.ts index fc0f8ecc0d..25b58816c8 100644 --- a/apps/frontend/src/main/claude-profile/profile-scorer.ts +++ b/apps/frontend/src/main/claude-profile/profile-scorer.ts @@ -35,12 +35,19 @@ export function getBestAvailableProfile( // 2. Lower weekly usage (more important than session) // 3. Lower session usage // 4. More recently authenticated + const isDebug = process.env.DEBUG === 'true'; + + if (isDebug) { + console.warn('[ProfileScorer] Evaluating', candidates.length, 'candidate profiles (excluding:', excludeProfileId, ')'); + } const scoredProfiles: ScoredProfile[] = candidates.map(profile => { let score = 100; // Base score + if (isDebug) console.warn('[ProfileScorer] Scoring profile:', profile.name, '(', profile.id, ')'); // Check rate limit status const rateLimitStatus = isProfileRateLimited(profile); + if (isDebug) console.warn('[ProfileScorer] Rate limit status:', rateLimitStatus); if (rateLimitStatus.limited) { // Severely penalize rate-limited profiles if (rateLimitStatus.type === 'weekly') { @@ -73,10 +80,14 @@ export function getBestAvailableProfile( } // Check if authenticated - if (!isProfileAuthenticated(profile)) { + const isAuth = isProfileAuthenticated(profile); + if (isDebug) console.warn('[ProfileScorer] isProfileAuthenticated:', isAuth, 'hasOAuthToken:', !!profile.oauthToken, 'hasConfigDir:', !!profile.configDir); + if (!isAuth) { score -= 500; // Severely penalize unauthenticated profiles + if (isDebug) console.warn('[ProfileScorer] Applied -500 penalty for no auth'); } + if (isDebug) console.warn('[ProfileScorer] Final score:', score); return { profile, score }; }); diff --git a/apps/frontend/src/main/claude-profile/profile-utils.ts b/apps/frontend/src/main/claude-profile/profile-utils.ts index 80a3c048cb..e6d8ceea83 100644 --- a/apps/frontend/src/main/claude-profile/profile-utils.ts +++ b/apps/frontend/src/main/claude-profile/profile-utils.ts @@ -56,9 +56,16 @@ export async function createProfileDirectory(profileName: string): Promise = new Map(); // profileId -> timestamp + private static AUTH_FAILURE_COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes cooldown + + // Debug flag for verbose logging + private readonly isDebug = process.env.DEBUG === 'true'; private constructor() { super(); @@ -40,7 +47,7 @@ export class UsageMonitor extends EventEmitter { const settings = profileManager.getAutoSwitchSettings(); if (!settings.enabled || !settings.proactiveSwapEnabled) { - console.warn('[UsageMonitor] Proactive monitoring disabled'); + console.warn('[UsageMonitor] Proactive monitoring disabled. Settings:', JSON.stringify(settings, null, 2)); return; } @@ -118,6 +125,15 @@ export class UsageMonitor extends EventEmitter { const weeklyExceeded = usage.weeklyPercent >= settings.weeklyThreshold; if (sessionExceeded || weeklyExceeded) { + if (this.isDebug) { + console.warn('[UsageMonitor:TRACE] Threshold exceeded', { + sessionPercent: usage.sessionPercent, + weekPercent: usage.weeklyPercent, + activeProfile: activeProfile.id, + hasToken: !!decryptedToken + }); + } + console.warn('[UsageMonitor] Threshold exceeded:', { sessionPercent: usage.sessionPercent, sessionThreshold: settings.sessionThreshold, @@ -130,8 +146,48 @@ export class UsageMonitor extends EventEmitter { activeProfile.id, sessionExceeded ? 'session' : 'weekly' ); + } else { + if (this.isDebug) { + console.warn('[UsageMonitor:TRACE] Usage OK', { + sessionPercent: usage.sessionPercent, + weekPercent: usage.weeklyPercent + }); + } } } catch (error) { + // Check for auth failure (401/403) from fetchUsageViaAPI + if ((error as any).statusCode === 401 || (error as any).statusCode === 403) { + const profileManager = getClaudeProfileManager(); + const activeProfile = profileManager.getActiveProfile(); + + if (activeProfile) { + // Mark this profile as auth-failed to prevent swap loops + this.authFailedProfiles.set(activeProfile.id, Date.now()); + console.warn('[UsageMonitor] Auth failure detected, marked profile as failed:', activeProfile.id); + + // Clean up expired entries from the failed profiles map + const now = Date.now(); + this.authFailedProfiles.forEach((timestamp, profileId) => { + if (now - timestamp > UsageMonitor.AUTH_FAILURE_COOLDOWN_MS) { + this.authFailedProfiles.delete(profileId); + } + }); + + try { + const excludeProfiles = Array.from(this.authFailedProfiles.keys()); + console.warn('[UsageMonitor] Attempting proactive swap (excluding failed profiles):', excludeProfiles); + await this.performProactiveSwap( + activeProfile.id, + 'session', // Treat auth failure as session limit for immediate swap + excludeProfiles + ); + return; + } catch (swapError) { + console.error('[UsageMonitor] Failed to perform auth-failure swap:', swapError); + } + } + } + console.error('[UsageMonitor] Check failed:', error); } finally { this.isChecking = false; @@ -190,6 +246,12 @@ export class UsageMonitor extends EventEmitter { if (!response.ok) { console.error('[UsageMonitor] API error:', response.status, response.statusText); + // Throw specific error for auth failures so we can trigger a swap + if (response.status === 401 || response.status === 403) { + const error = new Error(`API Auth Failure: ${response.status}`); + (error as any).statusCode = response.status; + throw error; + } return null; } @@ -220,7 +282,12 @@ export class UsageMonitor extends EventEmitter { ? 'weekly' : 'session' }; - } catch (error) { + } catch (error: any) { + // Re-throw auth failures to be handled by checkUsageAndSwap + if (error?.statusCode === 401 || error?.statusCode === 403) { + throw error; + } + console.error('[UsageMonitor] API fetch failed:', error); return null; } @@ -270,22 +337,34 @@ export class UsageMonitor extends EventEmitter { /** * Perform proactive profile swap + * @param currentProfileId - The profile to switch from + * @param limitType - The type of limit that triggered the swap + * @param additionalExclusions - Additional profile IDs to exclude (e.g., auth-failed profiles) */ private async performProactiveSwap( currentProfileId: string, - limitType: 'session' | 'weekly' + limitType: 'session' | 'weekly', + additionalExclusions: string[] = [] ): Promise { const profileManager = getClaudeProfileManager(); - const bestProfile = profileManager.getBestAvailableProfile(currentProfileId); - - if (!bestProfile) { - console.warn('[UsageMonitor] No alternative profile for proactive swap'); + + // Get all profiles to swap to, excluding current and any additional exclusions + const allProfiles = profileManager.getProfilesSortedByAvailability(); + const excludeIds = new Set([currentProfileId, ...additionalExclusions]); + const eligibleProfiles = allProfiles.filter(p => !excludeIds.has(p.id)); + + if (eligibleProfiles.length === 0) { + console.warn('[UsageMonitor] No alternative profile for proactive swap (excluded:', Array.from(excludeIds), ')'); this.emit('proactive-swap-failed', { - reason: 'no_alternative', - currentProfile: currentProfileId + reason: additionalExclusions.length > 0 ? 'all_alternatives_failed_auth' : 'no_alternative', + currentProfile: currentProfileId, + excludedProfiles: Array.from(excludeIds) }); return; } + + // Use the best available from eligible profiles + const bestProfile = eligibleProfiles[0]; console.warn('[UsageMonitor] Proactive swap:', { from: currentProfileId, diff --git a/apps/frontend/src/main/index.ts b/apps/frontend/src/main/index.ts index 8ee2eaf76c..612e26a209 100644 --- a/apps/frontend/src/main/index.ts +++ b/apps/frontend/src/main/index.ts @@ -359,24 +359,34 @@ app.whenReady().then(() => { }); }); - // Pre-initialize Claude profile manager in background (non-blocking) - // This ensures profile data is loaded before user clicks "Start Claude Code" - setImmediate(() => { - initializeClaudeProfileManager().catch((error) => { - console.warn('[main] Failed to pre-initialize profile manager:', error); + // Initialize Claude profile manager, then start usage monitor + // We do this sequentially to ensure profile data (including auto-switch settings) + // is loaded BEFORE the usage monitor attempts to read settings. + // This prevents the "UsageMonitor disabled" error due to race condition. + initializeClaudeProfileManager() + .then(() => { + // Only start monitoring if window is still available (app not quitting) + if (mainWindow) { + // Setup event forwarding from usage monitor to renderer + initializeUsageMonitorForwarding(mainWindow); + + // Start the usage monitor + const usageMonitor = getUsageMonitor(); + usageMonitor.start(); + console.warn('[main] Usage monitor initialized and started (after profile load)'); + } + }) + .catch((error) => { + console.warn('[main] Failed to initialize profile manager:', error); + // Fallback: try starting usage monitor anyway (might use defaults) + if (mainWindow) { + initializeUsageMonitorForwarding(mainWindow); + const usageMonitor = getUsageMonitor(); + usageMonitor.start(); + } }); - }); - // Initialize usage monitoring after window is created if (mainWindow) { - // Setup event forwarding from usage monitor to renderer - initializeUsageMonitorForwarding(mainWindow); - - // Start the usage monitor - const usageMonitor = getUsageMonitor(); - usageMonitor.start(); - console.warn('[main] Usage monitor initialized and started'); - // Log debug mode status const isDebugMode = process.env.DEBUG === 'true'; if (isDebugMode) { diff --git a/apps/frontend/src/main/ipc-handlers/cleanup-handlers.ts b/apps/frontend/src/main/ipc-handlers/cleanup-handlers.ts new file mode 100644 index 0000000000..72ef9fc68f --- /dev/null +++ b/apps/frontend/src/main/ipc-handlers/cleanup-handlers.ts @@ -0,0 +1,430 @@ +import { ipcMain } from 'electron'; +import { spawn } from 'child_process'; +import { existsSync } from 'fs'; +import path from 'path'; +import { IPC_CHANNELS } from '../../shared/constants'; +import type { IPCResult } from '../../shared/types'; +import { PythonEnvManager } from '../python-env-manager'; + +interface CleanupItem { + name: string; + size: number; + type: 'file' | 'directory'; + specCount?: number; + worktreeCount?: number; +} + +interface CleanupPreview { + items: CleanupItem[]; + totalSize: number; + archiveLocation: string; +} + +interface CleanupPreviewResult extends IPCResult { + preview?: CleanupPreview; +} + +interface CleanupExecuteResult extends IPCResult { + count?: number; + size?: number; + duration?: number; +} + +/** + * Parse Python script output for cleanup preview + * + * Extracts cleanup items, sizes, and archive location from the script's stdout. + * + * @param output - Raw stdout from the Python cleanup script + * @returns Parsed cleanup preview with items and totals, or null if parsing fails + */ +function parseCleanupPreview(output: string): CleanupPreview | null { + try { + const lines = output.split('\n'); + const items: CleanupItem[] = []; + let totalSize = 0; + let archiveLocation = ''; + let inItemsList = false; + + for (const line of lines) { + // Check if we're in the items list section + if (line.includes('The following will be cleaned:')) { + inItemsList = true; + continue; + } + + // Parse total size + if (line.includes('Total space to be freed:')) { + const sizeMatch = line.match(/(\d+\.?\d*)\s+(B|KB|MB|GB|TB)/); + if (sizeMatch) { + totalSize = parseSizeString(`${sizeMatch[1]} ${sizeMatch[2]}`); + } + inItemsList = false; + continue; + } + + // Parse archive location + if (line.includes('Archive location:')) { + archiveLocation = line.split('Archive location:')[1].trim(); + continue; + } + + // Parse individual items + if (inItemsList && line.trim().startsWith('✓')) { + const itemMatch = line.match(/✓\s+([^\(]+)\(([^\)]+)\)/); + if (itemMatch) { + const name = itemMatch[1].trim(); + const details = itemMatch[2]; + + // Extract size + const sizeMatch = details.match(/(\d+\.?\d*)\s+(B|KB|MB|GB|TB)/); + const size = sizeMatch ? parseSizeString(`${sizeMatch[1]} ${sizeMatch[2]}`) : 0; + + // Extract spec count + const specMatch = details.match(/(\d+)\s+specs/); + const specCount = specMatch ? parseInt(specMatch[1], 10) : undefined; + + // Extract worktree count + const worktreeMatch = details.match(/(\d+)\s+worktrees/); + const worktreeCount = worktreeMatch ? parseInt(worktreeMatch[1], 10) : undefined; + + items.push({ + name, + size, + type: name.endsWith('/') ? 'directory' : 'file', + specCount, + worktreeCount, + }); + } + } + } + + if (items.length === 0) { + return null; + } + + return { items, totalSize, archiveLocation }; + } catch (error) { + console.error('Error parsing cleanup preview:', error); + return null; + } +} + +/** + * Parse size string to bytes + * + * Converts human-readable size strings (e.g., "1.5 MB") to bytes. + * + * @param sizeStr - Size string in format "N.N UNIT" (e.g., "1.5 MB") + * @returns Size in bytes, or 0 if parsing fails + */ +function parseSizeString(sizeStr: string): number { + const match = sizeStr.match(/(\d+\.?\d*)\s+(B|KB|MB|GB|TB)/); + if (!match) return 0; + + const value = parseFloat(match[1]); + const unit = match[2]; + + const multipliers: Record = { + B: 1, + KB: 1024, + MB: 1024 * 1024, + GB: 1024 * 1024 * 1024, + TB: 1024 * 1024 * 1024 * 1024, + }; + + return value * (multipliers[unit] || 1); +} + +/** + * Parse Python script output for cleanup execution + * + * Extracts the count of cleaned items, total size freed, and execution duration. + * + * @param output - Raw stdout from the Python cleanup script execution + * @returns Object with count, size (bytes), and duration (seconds), or null if parsing fails + */ +function parseCleanupResult(output: string): { count: number; size: number; duration: number } | null { + try { + const lines = output.split('\n'); + let count = 0; + let size = 0; + let duration = 0; + let foundAnyData = false; + + for (const line of lines) { + // Parse archived/deleted count and size + const countMatch = line.match(/(Archived|Deleted)\s+(\d+)\s+items?\s+\(([^\)]+)\)/); + if (countMatch) { + foundAnyData = true; + count = parseInt(countMatch[2], 10); + const sizeStr = countMatch[3]; + const sizeMatch = sizeStr.match(/(\d+\.?\d*)\s+(B|KB|MB|GB|TB)/); + if (sizeMatch) { + size = parseSizeString(`${sizeMatch[1]} ${sizeMatch[2]}`); + } + } + + // Parse duration + const durationMatch = line.match(/Cleanup completed in\s+(\d+\.?\d*)\s+seconds/); + if (durationMatch) { + foundAnyData = true; + duration = parseFloat(durationMatch[1]); + } + } + + // Only return data if we found at least one match + if (!foundAnyData) { + return null; + } + + return { count, size, duration }; + } catch (error) { + console.error('Error parsing cleanup result:', error); + return null; + } +} + +// Timeout constants for cleanup operations +const CLEANUP_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes for actual cleanup +const PREVIEW_TIMEOUT_MS = 30 * 1000; // 30 seconds for preview + +/** + * Run Python cleanup command + * + * @param pythonEnvManager - Python environment manager instance + * @param projectPath - Path to the project directory to clean + * @param dryRun - If true, only preview changes without executing + * @param preserveArchive - If true, archive data instead of deleting + * @returns Promise resolving to command output, error, and exit code + */ +async function runCleanupCommand( + pythonEnvManager: PythonEnvManager, + projectPath: string, + dryRun: boolean, + preserveArchive: boolean +): Promise<{ output: string; error: string; exitCode: number }> { + return new Promise((resolve) => { + const pythonPath = pythonEnvManager.getPythonPath(); + if (!pythonPath) { + resolve({ + output: '', + error: 'Python environment not ready', + exitCode: 1, + }); + return; + } + + // Find backend directory more robustly by searching for run.py + // pythonPath is typically: /.venv/Scripts/python.exe (Windows) + // or /.venv/bin/python (Unix) + let backendPath = path.dirname(pythonPath); + + // Navigate up until we find run.py or reach root + let runPyPath: string | null = null; + for (let i = 0; i < 5; i++) { + backendPath = path.dirname(backendPath); + const testPath = path.join(backendPath, 'run.py'); + try { + if (existsSync(testPath)) { + runPyPath = testPath; + break; + } + } catch { + // Continue searching + } + } + + if (!runPyPath) { + resolve({ + output: '', + error: 'Could not locate run.py in backend directory', + exitCode: 1, + }); + return; + } + + // Build command arguments + const args = [runPyPath, '--clean']; + if (!dryRun) { + args.push('--execute'); + args.push('--yes'); // Auto-confirm instead of using stdin timeout + } + if (!preserveArchive) { + args.push('--no-archive'); + } + + args.push('--project-dir', projectPath); + + let output = ''; + let errorOutput = ''; + let resolved = false; + + const cleanupProcess = spawn(pythonPath, args, { + cwd: backendPath, + env: { + ...process.env, + PYTHONUNBUFFERED: '1', + }, + }); + + // Set timeout to prevent indefinite hangs + const timeoutMs = dryRun ? PREVIEW_TIMEOUT_MS : CLEANUP_TIMEOUT_MS; + const timeoutId = setTimeout(() => { + if (!resolved) { + resolved = true; + cleanupProcess.kill('SIGTERM'); + resolve({ + output: '', + error: `Cleanup process timed out after ${timeoutMs / 1000} seconds`, + exitCode: 1, + }); + } + }, timeoutMs); + + cleanupProcess.stdout.on('data', (data) => { + output += data.toString(); + }); + + cleanupProcess.stderr.on('data', (data) => { + errorOutput += data.toString(); + }); + + cleanupProcess.on('close', (code) => { + clearTimeout(timeoutId); + if (!resolved) { + resolved = true; + resolve({ + output, + error: errorOutput, + exitCode: code || 0, + }); + } + }); + + cleanupProcess.on('error', (err) => { + clearTimeout(timeoutId); + if (!resolved) { + resolved = true; + resolve({ + output: '', + error: err.message, + exitCode: 1, + }); + } + }); + + // Note: We use --yes flag in args instead of stdin confirmation to avoid race conditions + }); +} + +/** + * Register cleanup IPC handlers + */ +export function registerCleanupHandlers(pythonEnvManager: PythonEnvManager): void { + console.log('[IPC] Registering cleanup handlers'); + + /** + * Get cleanup preview (dry-run) + */ + ipcMain.handle( + IPC_CHANNELS.CLEANUP_PREVIEW, + async (_, projectPath: string): Promise => { + try { + console.log(`[IPC] CLEANUP_PREVIEW called for: ${projectPath}`); + + const { output, error, exitCode } = await runCleanupCommand( + pythonEnvManager, + projectPath, + true, // dry-run + true // preserve archive (doesn't matter for dry-run) + ); + + if (exitCode !== 0) { + console.error(`[IPC] Cleanup preview failed:`, error); + return { + success: false, + error: error || 'Failed to get cleanup preview', + }; + } + + const preview = parseCleanupPreview(output); + if (!preview) { + return { + success: true, + preview: { + items: [], + totalSize: 0, + archiveLocation: '', + }, + }; + } + + console.log(`[IPC] CLEANUP_PREVIEW returning ${preview.items.length} items`); + return { + success: true, + preview, + }; + } catch (error) { + console.error('[IPC] CLEANUP_PREVIEW error:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + } + ); + + /** + * Execute cleanup + */ + ipcMain.handle( + IPC_CHANNELS.CLEANUP_EXECUTE, + async (_, projectPath: string, preserveArchive: boolean): Promise => { + try { + console.log( + `[IPC] CLEANUP_EXECUTE called for: ${projectPath}, preserveArchive: ${preserveArchive}` + ); + + const { output, error, exitCode } = await runCleanupCommand( + pythonEnvManager, + projectPath, + false, // not dry-run + preserveArchive + ); + + if (exitCode !== 0) { + console.error(`[IPC] Cleanup execution failed:`, error); + return { + success: false, + error: error || 'Failed to execute cleanup', + }; + } + + const result = parseCleanupResult(output); + if (!result) { + return { + success: false, + error: 'Failed to parse cleanup result', + }; + } + + console.log(`[IPC] CLEANUP_EXECUTE completed: ${result.count} items, ${result.size} bytes`); + return { + success: true, + count: result.count ?? 0, + size: result.size ?? 0, + duration: result.duration ?? 0, + }; + } catch (error) { + console.error('[IPC] CLEANUP_EXECUTE error:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + } + ); + + console.log('[IPC] Cleanup handlers registered'); +} diff --git a/apps/frontend/src/main/ipc-handlers/index.ts b/apps/frontend/src/main/ipc-handlers/index.ts index b3ee57212b..dffd295254 100644 --- a/apps/frontend/src/main/ipc-handlers/index.ts +++ b/apps/frontend/src/main/ipc-handlers/index.ts @@ -32,6 +32,7 @@ import { registerDebugHandlers } from './debug-handlers'; import { registerClaudeCodeHandlers } from './claude-code-handlers'; import { registerMcpHandlers } from './mcp-handlers'; import { registerProfileHandlers } from './profile-handlers'; +import { registerCleanupHandlers } from './cleanup-handlers'; import { registerTerminalWorktreeIpcHandlers } from './terminal'; import { notificationService } from '../notification-service'; @@ -55,6 +56,9 @@ export function setupIpcHandlers( // Project handlers (including Python environment setup) registerProjectHandlers(pythonEnvManager, agentManager, getMainWindow); + // Project cleanup handlers + registerCleanupHandlers(pythonEnvManager); + // Task handlers registerTaskHandlers(agentManager, pythonEnvManager, getMainWindow); @@ -144,5 +148,6 @@ export { registerDebugHandlers, registerClaudeCodeHandlers, registerMcpHandlers, - registerProfileHandlers + registerProfileHandlers, + registerCleanupHandlers }; diff --git a/apps/frontend/src/preload/api/project-api.ts b/apps/frontend/src/preload/api/project-api.ts index 3852c9e440..4df1787696 100644 --- a/apps/frontend/src/preload/api/project-api.ts +++ b/apps/frontend/src/preload/api/project-api.ts @@ -91,7 +91,25 @@ export interface ProjectAPI { percentage: number; }) => void) => () => void; - // Git Operations + // Cleanup Operations + cleanupPreview: (projectPath: string) => Promise; + totalSize: number; + archiveLocation: string; + }>>; + cleanupExecute: (projectPath: string, preserveArchive: boolean) => Promise>; + + // Git Operations getGitBranches: (projectPath: string) => Promise>; getCurrentGitBranch: (projectPath: string) => Promise>; detectMainBranch: (projectPath: string) => Promise>; @@ -261,6 +279,13 @@ export const createProjectAPI = (): ProjectAPI => ({ return () => ipcRenderer.off(IPC_CHANNELS.OLLAMA_PULL_PROGRESS, listener); }, + // Cleanup Operations + cleanupPreview: (projectPath: string) => + ipcRenderer.invoke(IPC_CHANNELS.CLEANUP_PREVIEW, projectPath), + + cleanupExecute: (projectPath: string, preserveArchive: boolean) => + ipcRenderer.invoke(IPC_CHANNELS.CLEANUP_EXECUTE, projectPath, preserveArchive), + // Git Operations getGitBranches: (projectPath: string): Promise> => ipcRenderer.invoke(IPC_CHANNELS.GIT_GET_BRANCHES, projectPath), diff --git a/apps/frontend/src/renderer/App.tsx b/apps/frontend/src/renderer/App.tsx index cbccbc5c61..d4f07d368c 100644 --- a/apps/frontend/src/renderer/App.tsx +++ b/apps/frontend/src/renderer/App.tsx @@ -31,6 +31,7 @@ import { KanbanBoard } from './components/KanbanBoard'; import { TaskDetailModal } from './components/task-detail/TaskDetailModal'; import { TaskCreationWizard } from './components/TaskCreationWizard'; import { AppSettingsDialog, type AppSection } from './components/settings/AppSettings'; +import { CleanProjectDialog } from './components/CleanProjectDialog'; import type { ProjectSettingsSection } from './components/settings/ProjectSettingsContent'; import { TerminalGrid } from './components/TerminalGrid'; import { Roadmap } from './components/Roadmap'; @@ -74,6 +75,8 @@ interface ProjectTabBarWithContextProps { onProjectClose: (projectId: string) => void; onAddProject: () => void; onSettingsClick: () => void; + onCleanProjectClick: () => void; + onRemoveProjectClick: () => void; } function ProjectTabBarWithContext({ @@ -82,7 +85,9 @@ function ProjectTabBarWithContext({ onProjectSelect, onProjectClose, onAddProject, - onSettingsClick + onSettingsClick, + onCleanProjectClick, + onRemoveProjectClick }: ProjectTabBarWithContextProps) { return ( ); } @@ -126,6 +133,7 @@ export function App() { const [isSettingsDialogOpen, setIsSettingsDialogOpen] = useState(false); const [settingsInitialSection, setSettingsInitialSection] = useState(undefined); const [settingsInitialProjectSection, setSettingsInitialProjectSection] = useState(undefined); + const [isCleanProjectDialogOpen, setIsCleanProjectDialogOpen] = useState(false); const [activeView, setActiveView] = useState('kanban'); const [isOnboardingWizardOpen, setIsOnboardingWizardOpen] = useState(false); const [isRefreshingTasks, setIsRefreshingTasks] = useState(false); @@ -591,6 +599,13 @@ export function App() { } }; + const handleRemoveProject = () => { + // Remove the currently active project + if (activeProjectId) { + handleProjectTabClose(activeProjectId); + } + }; + const handleConfirmRemoveProject = () => { if (projectToRemove) { try { @@ -784,6 +799,8 @@ export function App() { onProjectClose={handleProjectTabClose} onAddProject={handleAddProject} onSettingsClick={() => setIsSettingsDialogOpen(true)} + onCleanProjectClick={() => setIsCleanProjectDialogOpen(true)} + onRemoveProjectClick={handleRemoveProject} /> @@ -934,6 +951,13 @@ export function App() { }} /> + {/* Clean Project Dialog */} + + {/* Add Project Modal */} void; +} + +type DialogStep = 'preview' | 'confirm' | 'cleaning' | 'result'; + +/** + * Dialog for cleaning project Auto-Claude data with preview and confirmation + */ +export function CleanProjectDialog({ + open, + projectPath, + onOpenChange, +}: CleanProjectDialogProps) { + const { t } = useTranslation(['common']); + const [step, setStep] = useState('preview'); + const [preview, setPreview] = useState(null); + const [selectedMode, setSelectedMode] = useState<'archive' | 'delete'>('archive'); + const [confirmText, setConfirmText] = useState(''); + const [result, setResult] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const loadPreview = useCallback(async () => { + if (!projectPath) return; + + setLoading(true); + setError(null); + + try { + const response = await window.electronAPI.cleanupPreview(projectPath); + + if (response.success && response.preview) { + setPreview(response.preview); + } else { + setError(response.error || t('common:cleanProject.errors.loadPreviewFailed')); + } + } catch (err) { + console.error('Error loading cleanup preview:', err); + setError(err instanceof Error ? err.message : t('common:cleanProject.errors.unknown')); + } finally { + setLoading(false); + } + }, [projectPath, t]); + + // Load preview when dialog opens + useEffect(() => { + if (open && projectPath) { + loadPreview(); + } + }, [open, projectPath, loadPreview]); + + // Reset state when dialog closes + useEffect(() => { + if (!open) { + const timeoutId = setTimeout(() => { + setStep('preview'); + setPreview(null); + setSelectedMode('archive'); + setConfirmText(''); + setResult(null); + setError(null); + }, 200); + + return () => clearTimeout(timeoutId); + } + }, [open]); + + const executeCleanup = useCallback(async () => { + if (!projectPath || !preview) return; + + setLoading(true); + setError(null); + setStep('cleaning'); + + try { + const response = await window.electronAPI.cleanupExecute( + projectPath, + selectedMode === 'archive' + ); + + if (response.success) { + setResult({ + success: true, + count: response.count, + size: response.size, + duration: response.duration, + mode: selectedMode, + }); + setStep('result'); + } else { + setError(response.error || t('common:cleanProject.errors.cleanupFailed')); + setStep('preview'); + } + } catch (err) { + console.error('Error executing cleanup:', err); + setError(err instanceof Error ? err.message : t('common:cleanProject.errors.unknown')); + setStep('preview'); + } finally { + setLoading(false); + } + }, [projectPath, preview, selectedMode, t]); + + const formatBytes = (bytes: number): string => { + // Handle edge cases + if (!Number.isFinite(bytes) || bytes < 0) { + return '0 B'; + } + if (bytes === 0) { + return '0 B'; + } + + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + + // Clamp index to valid range + const index = Math.max(0, Math.min(i, units.length - 1)); + + return `${(bytes / Math.pow(1024, index)).toFixed(1)} ${units[index]}`; + }; + + const getItemDisplayName = (item: CleanupItem): string => { + const baseNames: Record = { + '.auto-claude/': t('common:cleanProject.items.autoClaudeDir'), + '.worktrees/': t('common:cleanProject.items.worktreesDir'), + 'logs/security/': t('common:cleanProject.items.securityLogs'), + '.auto-claude-security.json': t('common:cleanProject.items.securityJson'), + '.auto-claude-status': t('common:cleanProject.items.statusFile'), + '.claude_settings.json': t('common:cleanProject.items.settingsFile'), + '.security-key': t('common:cleanProject.items.securityKey'), + }; + + return baseNames[item.name] || item.name; + }; + + const handleConfirmClick = () => { + const confirmWord = t('common:cleanProject.confirmWord').toLowerCase(); + if (confirmText.toLowerCase() === confirmWord) { + executeCleanup(); + } + }; + + const handleClose = () => { + if (!loading) { + onOpenChange(false); + } + }; + + const renderPreview = () => { + if (loading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( +
+ +

{error}

+
+ ); + } + + if (!preview || preview.items.length === 0) { + return ( +
+

{t('common:cleanProject.noDataToClean')}

+
+ ); + } + + return ( +
+

+ {t('common:cleanProject.description')} +

+ +
+

+ {t('common:cleanProject.previewTitle')} +

+ +
+ {preview.items.map((item, index) => ( +
+
+ {item.type === 'directory' ? ( + + ) : ( + + )} + {getItemDisplayName(item)} + {item.specCount !== undefined && item.specCount > 0 && ( + + ({item.specCount} {t('common:cleanProject.items.specs')}) + + )} + {item.worktreeCount !== undefined && item.worktreeCount > 0 && ( + + ({item.worktreeCount} {t('common:cleanProject.items.worktrees')}) + + )} +
+ + {formatBytes(item.size)} + +
+ ))} +
+
+
+ +
+
+ {t('common:cleanProject.totalSize')} + {formatBytes(preview.totalSize)} +
+ {selectedMode === 'archive' && ( +
+ + {t('common:cleanProject.archiveLocation')} + + + {preview.archiveLocation} + +
+ )} +
+ +
+ + + + + +
+
+ ); + }; + + const renderConfirm = () => ( +
+
+ +

+ {selectedMode === 'archive' + ? t('common:cleanProject.confirmArchive') + : t('common:cleanProject.confirmDelete')} +

+
+ +
+ + setConfirmText(e.target.value)} + placeholder={t('common:cleanProject.confirmWord')} + autoComplete="off" + autoFocus + /> +
+
+ ); + + const renderCleaning = () => ( +
+ +

{t('common:cleanProject.cleaning')}

+
+ ); + + const renderResult = () => { + if (!result) return null; + + return ( +
+ {result.success ? ( +
+

+ {result.mode === 'archive' + ? t('common:cleanProject.results.archived', { + count: result.count, + size: formatBytes(result.size), + }) + : t('common:cleanProject.results.deleted', { + count: result.count, + size: formatBytes(result.size), + })} +

+
+ ) : ( +
+ +

{result.error || t('common:cleanProject.error')}

+
+ )} + +

+ {t('common:cleanProject.results.duration', { + seconds: (result.duration ?? 0).toFixed(1), + })} +

+
+ ); + }; + + const canProceed = () => { + if (step === 'preview') { + return preview && preview.items.length > 0; + } + if (step === 'confirm') { + const confirmWord = t('common:cleanProject.confirmWord').toLowerCase(); + return confirmText.toLowerCase() === confirmWord; + } + return false; + }; + + const handlePrimaryAction = () => { + if (step === 'preview') { + setStep('confirm'); + } else if (step === 'confirm') { + handleConfirmClick(); + } else if (step === 'result') { + handleClose(); + } + }; + + return ( + + + + + + {t('common:cleanProject.title')} + + {step !== 'result' && ( + + {step === 'preview' && t('common:cleanProject.description')} + {step === 'confirm' && t('common:cleanProject.confirmTitle')} + + )} + + +
+ {step === 'preview' && renderPreview()} + {step === 'confirm' && renderConfirm()} + {step === 'cleaning' && renderCleaning()} + {step === 'result' && renderResult()} +
+ + + {step !== 'cleaning' && step !== 'result' && ( + <> + + {step === 'confirm' && ( + + )} + + + )} + {step === 'result' && ( + + )} + +
+
+ ); +} diff --git a/apps/frontend/src/renderer/components/ProjectTabBar.tsx b/apps/frontend/src/renderer/components/ProjectTabBar.tsx index 7836b8c77a..cac9f08b15 100644 --- a/apps/frontend/src/renderer/components/ProjectTabBar.tsx +++ b/apps/frontend/src/renderer/components/ProjectTabBar.tsx @@ -16,6 +16,8 @@ interface ProjectTabBarProps { className?: string; // Control props for active tab onSettingsClick?: () => void; + onCleanProjectClick?: () => void; + onRemoveProjectClick?: () => void; } export function ProjectTabBar({ @@ -25,7 +27,9 @@ export function ProjectTabBar({ onProjectClose, onAddProject, className, - onSettingsClick + onSettingsClick, + onCleanProjectClick, + onRemoveProjectClick }: ProjectTabBarProps) { const { t } = useTranslation('common'); @@ -106,6 +110,8 @@ export function ProjectTabBar({ }} // Pass control props only for active tab onSettingsClick={isActiveTab ? onSettingsClick : undefined} + onCleanProjectClick={isActiveTab ? onCleanProjectClick : undefined} + onRemoveProjectClick={isActiveTab ? onRemoveProjectClick : undefined} /> ); })} diff --git a/apps/frontend/src/renderer/components/SortableProjectTab.tsx b/apps/frontend/src/renderer/components/SortableProjectTab.tsx index d57cf1292c..32615cabaa 100644 --- a/apps/frontend/src/renderer/components/SortableProjectTab.tsx +++ b/apps/frontend/src/renderer/components/SortableProjectTab.tsx @@ -1,7 +1,7 @@ import { useSortable } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { useTranslation } from 'react-i18next'; -import { Settings2 } from 'lucide-react'; +import { Settings2, Trash2, XCircle } from 'lucide-react'; import { cn } from '../lib/utils'; import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip'; import type { Project } from '../../shared/types'; @@ -15,6 +15,8 @@ interface SortableProjectTabProps { onClose: (e: React.MouseEvent) => void; // Optional control props for active tab onSettingsClick?: () => void; + onCleanProjectClick?: () => void; + onRemoveProjectClick?: () => void; } // Detect if running on macOS for keyboard shortcut display @@ -28,7 +30,9 @@ export function SortableProjectTab({ tabIndex, onSelect, onClose, - onSettingsClick + onSettingsClick, + onCleanProjectClick, + onRemoveProjectClick }: SortableProjectTabProps) { const { t } = useTranslation('common'); // Build tooltip with keyboard shortcut hint (only for tabs 1-9) @@ -112,7 +116,7 @@ export function SortableProjectTab({ - {/* Active tab controls - settings and archive, always accessible */} + {/* Active tab controls - settings, clean, and archive, always accessible */} {isActive && (
{/* Settings icon - responsive sizing */} @@ -142,6 +146,45 @@ export function SortableProjectTab({ )} + + {/* Smart Cleanup/Remove button - changes based on project status */} + {(onCleanProjectClick || onRemoveProjectClick) && (() => { + // Project is "broken" if autoBuildPath is not set (not initialized) + const isBroken = !project.autoBuildPath; + const icon = isBroken ? XCircle : Trash2; + const label = isBroken ? t('projectTab.removeProject') : t('projectTab.cleanProject'); + const onClick = isBroken ? onRemoveProjectClick : onCleanProjectClick; + const IconComponent = icon; + + if (!onClick) return null; + + return ( + + + + + + {label} + + + ); + })()}
)} diff --git a/apps/frontend/src/renderer/components/TerminalGrid.tsx b/apps/frontend/src/renderer/components/TerminalGrid.tsx index 2c2d43903d..5c8ce4c2f1 100644 --- a/apps/frontend/src/renderer/components/TerminalGrid.tsx +++ b/apps/frontend/src/renderer/components/TerminalGrid.tsx @@ -12,7 +12,8 @@ import { PointerSensor, KeyboardSensor, useSensor, - useSensors + useSensors, + closestCenter, } from '@dnd-kit/core'; import { SortableContext, @@ -292,17 +293,22 @@ export function TerminalGrid({ projectPath, onNewTaskClick, isActive = false }: // Handle file drop on terminal const overId = over.id.toString(); + let terminalId: string | null = null; + if (overId.startsWith('terminal-')) { - const terminalId = overId.replace('terminal-', ''); + terminalId = overId.replace('terminal-', ''); + } else if (terminals.some(t => t.id === overId)) { + // closestCenter might return the sortable ID instead of droppable ID + terminalId = overId; + } - if (activeData?.path) { - // Quote the path if it contains spaces - const quotedPath = activeData.path.includes(' ') ? `"${activeData.path}"` : activeData.path; - // Insert the file path into the terminal with a trailing space - window.electronAPI.sendTerminalInput(terminalId, quotedPath + ' '); - } + if (terminalId && activeData?.path) { + // Quote the path if it contains spaces + const quotedPath = activeData.path.includes(' ') ? `"${activeData.path}"` : activeData.path; + // Insert the file path into the terminal with a trailing space + window.electronAPI.sendTerminalInput(terminalId, quotedPath + ' '); } - }, [reorderTerminals]); + }, [reorderTerminals, terminals]); // Calculate grid layout based on number of terminals const gridLayout = useMemo(() => { @@ -358,6 +364,7 @@ export function TerminalGrid({ projectPath, onNewTaskClick, isActive = false }: return ( diff --git a/apps/frontend/src/shared/constants/ipc.ts b/apps/frontend/src/shared/constants/ipc.ts index ddbb1c6042..8890c75f85 100644 --- a/apps/frontend/src/shared/constants/ipc.ts +++ b/apps/frontend/src/shared/constants/ipc.ts @@ -12,6 +12,10 @@ export const IPC_CHANNELS = { PROJECT_INITIALIZE: 'project:initialize', PROJECT_CHECK_VERSION: 'project:checkVersion', + // Project cleanup operations + CLEANUP_PREVIEW: 'cleanup:preview', + CLEANUP_EXECUTE: 'cleanup:execute', + // Tab state operations (persisted in main process) TAB_STATE_GET: 'tabState:get', TAB_STATE_SAVE: 'tabState:save', @@ -42,7 +46,6 @@ export const IPC_CHANNELS = { TASK_LIST_WORKTREES: 'task:listWorktrees', TASK_ARCHIVE: 'task:archive', TASK_UNARCHIVE: 'task:unarchive', - TASK_CLEAR_STAGED_STATE: 'task:clearStagedState', // Task events (main -> renderer) TASK_PROGRESS: 'task:progress', diff --git a/apps/frontend/src/shared/i18n/locales/en/common.json b/apps/frontend/src/shared/i18n/locales/en/common.json index a66c099940..b1d4c39aa4 100644 --- a/apps/frontend/src/shared/i18n/locales/en/common.json +++ b/apps/frontend/src/shared/i18n/locales/en/common.json @@ -1,6 +1,8 @@ { "projectTab": { "settings": "Project settings", + "cleanProject": "Clean project", + "removeProject": "Remove from Auto-Claude", "showArchived": "Show archived", "hideArchived": "Hide archived", "showArchivedTasks": "Show archived tasks", @@ -9,6 +11,49 @@ "closeTabAriaLabel": "Close tab (removes project from app)", "addProjectAriaLabel": "Add project" }, + "cleanProject": { + "title": "Clean Project Data", + "description": "Remove all Auto-Claude data from this project. The Auto-Claude installation will remain intact.", + "previewTitle": "Preview: What Will Be Cleaned", + "noDataToClean": "No Auto-Claude data found in this project.", + "confirmTitle": "Confirm Cleanup", + "confirmArchive": "Archive data before cleaning? Data will be preserved in .auto-claude/archive/", + "confirmDelete": "Permanently delete? Data CANNOT be recovered!", + "confirmWord": "clean", + "typeToConfirm": "Type 'clean' to confirm:", + "cleaning": "Cleaning...", + "success": "Project cleaned successfully", + "error": "Failed to clean project", + "totalSize": "Total space to be freed:", + "archiveLocation": "Archive location:", + "items": { + "autoClaudeDir": ".auto-claude/ directory", + "worktreesDir": ".worktrees/ directory", + "securityLogs": "Security logs", + "securityJson": "Security profile", + "statusFile": "Status file", + "settingsFile": "Settings file", + "securityKey": "Security key", + "specs": "specs", + "worktrees": "worktrees" + }, + "mode": { + "archive": "Archive & Clean", + "archiveDesc": "Moves data to archive (recoverable)", + "delete": "Permanent Delete", + "deleteDesc": "Deletes data permanently (cannot be recovered)" + }, + "results": { + "archived": "Archived {{count}} items ({{size}})", + "deleted": "Deleted {{count}} items ({{size}})", + "duration": "Completed in {{seconds}} seconds" + }, + "errors": { + "loadPreviewFailed": "Failed to load cleanup preview", + "cleanupFailed": "Failed to clean project", + "unknown": "Unknown error" + } + }, "accessibility": { "deleteFeatureAriaLabel": "Delete feature", "closeFeatureDetailsAriaLabel": "Close feature details", diff --git a/apps/frontend/src/shared/i18n/locales/fr/common.json b/apps/frontend/src/shared/i18n/locales/fr/common.json index b28437872f..6aca81b75b 100644 --- a/apps/frontend/src/shared/i18n/locales/fr/common.json +++ b/apps/frontend/src/shared/i18n/locales/fr/common.json @@ -1,6 +1,8 @@ { "projectTab": { "settings": "Paramètres du projet", + "cleanProject": "Nettoyer le projet", + "removeProject": "Retirer d'Auto-Claude", "showArchived": "Afficher archivés", "hideArchived": "Masquer archivés", "showArchivedTasks": "Afficher les tâches archivées", @@ -9,6 +11,49 @@ "closeTabAriaLabel": "Fermer l'onglet (retire le projet de l'application)", "addProjectAriaLabel": "Ajouter un projet" }, + "cleanProject": { + "title": "Nettoyer les données du projet", + "description": "Supprimer toutes les données Auto-Claude de ce projet. L'installation Auto-Claude restera intacte.", + "previewTitle": "Aperçu : Ce qui sera nettoyé", + "noDataToClean": "Aucune donnée Auto-Claude trouvée dans ce projet.", + "confirmTitle": "Confirmer le nettoyage", + "confirmArchive": "Archiver les données avant le nettoyage ? Les données seront préservées dans .auto-claude/archive/", + "confirmDelete": "Supprimer définitivement ? Les données NE PEUVENT PAS être récupérées !", + "confirmWord": "nettoyer", + "typeToConfirm": "Tapez 'nettoyer' pour confirmer :", + "cleaning": "Nettoyage en cours...", + "success": "Projet nettoyé avec succès", + "error": "Échec du nettoyage du projet", + "totalSize": "Espace total à libérer :", + "archiveLocation": "Emplacement de l'archive :", + "items": { + "autoClaudeDir": "Répertoire .auto-claude/", + "worktreesDir": "Répertoire .worktrees/", + "securityLogs": "Journaux de sécurité", + "securityJson": "Profil de sécurité", + "statusFile": "Fichier d'état", + "settingsFile": "Fichier de paramètres", + "securityKey": "Clé de sécurité", + "specs": "spécifications", + "worktrees": "arbres de travail" + }, + "mode": { + "archive": "Archiver et nettoyer", + "archiveDesc": "Déplace les données vers l'archive (récupérable)", + "delete": "Suppression définitive", + "deleteDesc": "Supprime les données définitivement (ne peuvent pas être récupérées)" + }, + "results": { + "archived": "{{count}} éléments archivés ({{size}})", + "deleted": "{{count}} éléments supprimés ({{size}})", + "duration": "Terminé en {{seconds}} secondes" + }, + "errors": { + "loadPreviewFailed": "Échec du chargement de l'aperçu de nettoyage", + "cleanupFailed": "Échec du nettoyage du projet", + "unknown": "Erreur inconnue" + } + }, "accessibility": { "deleteFeatureAriaLabel": "Supprimer la fonctionnalité", "closeFeatureDetailsAriaLabel": "Fermer les détails de la fonctionnalité", diff --git a/tests/test_worktree.py b/tests/test_worktree.py index a8f5ddbe74..a6b725d20e 100644 --- a/tests/test_worktree.py +++ b/tests/test_worktree.py @@ -198,6 +198,209 @@ def test_merge_worktree_already_on_target_branch(self, temp_git_repo: Path): # Verify file is in main branch assert (temp_git_repo / "worker-file.txt").exists() + def test_merge_worktree_already_up_to_date(self, temp_git_repo: Path): + """merge_worktree succeeds when branch is already up to date (ACS-226).""" + manager = WorktreeManager(temp_git_repo) + manager.setup() + + # Create a worktree with changes + worker_info = manager.create_worktree("worker-spec") + (worker_info.path / "worker-file.txt").write_text("worker content") + add_result = subprocess.run(["git", "add", "."], cwd=worker_info.path, capture_output=True) + assert add_result.returncode == 0, f"git add failed: {add_result.stderr}" + commit_result = subprocess.run( + ["git", "commit", "-m", "Worker commit"], + cwd=worker_info.path, capture_output=True + ) + assert commit_result.returncode == 0, f"git commit failed: {commit_result.stderr}" + + # First merge succeeds + result = manager.merge_worktree("worker-spec", delete_after=False) + assert result is True + + # Second merge should also succeed (already up to date) + result = manager.merge_worktree("worker-spec", delete_after=False) + assert result is True + + def test_merge_worktree_already_up_to_date_with_no_commit(self, temp_git_repo: Path): + """merge_worktree with no_commit=True succeeds when already up to date (ACS-226).""" + manager = WorktreeManager(temp_git_repo) + manager.setup() + + # Create a worktree with changes + worker_info = manager.create_worktree("worker-spec") + (worker_info.path / "worker-file.txt").write_text("worker content") + add_result = subprocess.run(["git", "add", "."], cwd=worker_info.path, capture_output=True) + assert add_result.returncode == 0, f"git add failed: {add_result.stderr}" + commit_result = subprocess.run( + ["git", "commit", "-m", "Worker commit"], + cwd=worker_info.path, capture_output=True + ) + assert commit_result.returncode == 0, f"git commit failed: {commit_result.stderr}" + + # First merge with no_commit succeeds + result = manager.merge_worktree("worker-spec", no_commit=True, delete_after=False) + assert result is True + + # Commit the staged changes + merge_commit_result = subprocess.run( + ["git", "commit", "-m", "Merge commit"], + cwd=temp_git_repo, capture_output=True + ) + assert merge_commit_result.returncode == 0, f"git commit failed: {merge_commit_result.stderr}" + + # Second merge should also succeed (already up to date) + result = manager.merge_worktree("worker-spec", no_commit=True, delete_after=False) + assert result is True + + def test_merge_worktree_already_up_to_date_with_delete_after(self, temp_git_repo: Path): + """merge_worktree with delete_after=True succeeds when already up to date (ACS-226).""" + manager = WorktreeManager(temp_git_repo) + manager.setup() + + # Create a worktree with changes + worker_info = manager.create_worktree("worker-spec") + branch_name = worker_info.branch + (worker_info.path / "worker-file.txt").write_text("worker content") + add_result = subprocess.run(["git", "add", "."], cwd=worker_info.path, capture_output=True) + assert add_result.returncode == 0, f"git add failed: {add_result.stderr}" + commit_result = subprocess.run( + ["git", "commit", "-m", "Worker commit"], + cwd=worker_info.path, capture_output=True + ) + assert commit_result.returncode == 0, f"git commit failed: {commit_result.stderr}" + + # First merge succeeds + result = manager.merge_worktree("worker-spec", delete_after=False) + assert result is True + + # Second merge with delete_after=True should also succeed and clean up + result = manager.merge_worktree("worker-spec", delete_after=True) + assert result is True + + # Verify worktree was deleted + assert not worker_info.path.exists() + + # Verify branch was deleted + branch_list_result = subprocess.run( + ["git", "branch", "--list", branch_name], + cwd=temp_git_repo, capture_output=True, text=True + ) + assert branch_name not in branch_list_result.stdout, f"Branch {branch_name} should be deleted" + + def test_merge_worktree_conflict_detection(self, temp_git_repo: Path): + """merge_worktree correctly detects and handles merge conflicts.""" + manager = WorktreeManager(temp_git_repo) + manager.setup() + + # Create initial file on base branch + (temp_git_repo / "shared.txt").write_text("base content") + add_result = subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True) + assert add_result.returncode == 0, f"git add failed: {add_result.stderr}" + commit_result = subprocess.run( + ["git", "commit", "-m", "Add shared file"], + cwd=temp_git_repo, capture_output=True + ) + assert commit_result.returncode == 0, f"git commit failed: {commit_result.stderr}" + + # Create worktree with conflicting change + worker_info = manager.create_worktree("worker-spec") + (worker_info.path / "shared.txt").write_text("worker content") + add_result = subprocess.run(["git", "add", "."], cwd=worker_info.path, capture_output=True) + assert add_result.returncode == 0, f"git add failed: {add_result.stderr}" + commit_result = subprocess.run( + ["git", "commit", "-m", "Worker change"], + cwd=worker_info.path, capture_output=True + ) + assert commit_result.returncode == 0, f"git commit failed: {commit_result.stderr}" + + # Make conflicting change on base branch + (temp_git_repo / "shared.txt").write_text("base change") + add_result = subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True) + assert add_result.returncode == 0, f"git add failed: {add_result.stderr}" + commit_result = subprocess.run( + ["git", "commit", "-m", "Base change"], + cwd=temp_git_repo, capture_output=True + ) + assert commit_result.returncode == 0, f"git commit failed: {commit_result.stderr}" + + # Merge should detect conflict and fail + result = manager.merge_worktree("worker-spec", delete_after=False) + assert result is False + + # Verify merge was aborted (no merge state exists) + # Check that MERGE_HEAD does not exist + merge_head_result = subprocess.run( + ["git", "rev-parse", "--verify", "MERGE_HEAD"], + cwd=temp_git_repo, capture_output=True + ) + assert merge_head_result.returncode != 0, "MERGE_HEAD should not exist after abort" + + # Verify git status shows no unmerged/conflict status codes + git_status = subprocess.run( + ["git", "status", "--porcelain"], + cwd=temp_git_repo, capture_output=True, text=True + ) + # Should have no output (clean working directory) + assert git_status.returncode == 0 + assert not git_status.stdout.strip(), f"Expected clean status, got: {git_status.stdout}" + + def test_merge_worktree_conflict_with_no_commit(self, temp_git_repo: Path): + """merge_worktree with no_commit=True handles conflicts correctly.""" + manager = WorktreeManager(temp_git_repo) + manager.setup() + + # Create initial file on base branch + (temp_git_repo / "shared.txt").write_text("base content") + add_result = subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True) + assert add_result.returncode == 0, f"git add failed: {add_result.stderr}" + commit_result = subprocess.run( + ["git", "commit", "-m", "Add shared file"], + cwd=temp_git_repo, capture_output=True + ) + assert commit_result.returncode == 0, f"git commit failed: {commit_result.stderr}" + + # Create worktree with conflicting change + worker_info = manager.create_worktree("worker-spec") + (worker_info.path / "shared.txt").write_text("worker content") + add_result = subprocess.run(["git", "add", "."], cwd=worker_info.path, capture_output=True) + assert add_result.returncode == 0, f"git add failed: {add_result.stderr}" + commit_result = subprocess.run( + ["git", "commit", "-m", "Worker change"], + cwd=worker_info.path, capture_output=True + ) + assert commit_result.returncode == 0, f"git commit failed: {commit_result.stderr}" + + # Make conflicting change on base branch + (temp_git_repo / "shared.txt").write_text("base change") + add_result = subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True) + assert add_result.returncode == 0, f"git add failed: {add_result.stderr}" + commit_result = subprocess.run( + ["git", "commit", "-m", "Base change"], + cwd=temp_git_repo, capture_output=True + ) + assert commit_result.returncode == 0, f"git commit failed: {commit_result.stderr}" + + # Merge with no_commit should detect conflict and fail + result = manager.merge_worktree("worker-spec", no_commit=True, delete_after=False) + assert result is False + + # Verify merge was aborted (no merge state exists) + # Check that MERGE_HEAD does not exist + merge_head_result = subprocess.run( + ["git", "rev-parse", "--verify", "MERGE_HEAD"], + cwd=temp_git_repo, capture_output=True + ) + assert merge_head_result.returncode != 0, "MERGE_HEAD should not exist after abort" + + # Verify git status shows no staged/unstaged changes + git_status = subprocess.run( + ["git", "status", "--porcelain"], + cwd=temp_git_repo, capture_output=True, text=True + ) + assert git_status.returncode == 0 + assert not git_status.stdout.strip(), f"Expected clean status, got: {git_status.stdout}" + class TestChangeTracking: """Tests for tracking changes in worktrees."""