diff --git a/apps/frontend/src/main/__tests__/worktree-detection.test.ts b/apps/frontend/src/main/__tests__/worktree-detection.test.ts new file mode 100644 index 0000000000..ccd5b6d4b2 --- /dev/null +++ b/apps/frontend/src/main/__tests__/worktree-detection.test.ts @@ -0,0 +1,345 @@ +/** + * Worktree Detection Tests + * ======================== + * Tests for worktree detection logic and backend path resolution priority order. + * + * Test Coverage: + * 1. Regex pattern validation for detecting worktree directory structure + * 2. Backend path resolution priority order (ENV > worktree > settings > standard) + * 3. Cross-platform path handling (Unix / and Windows \\) + * 4. Edge cases and error handling + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { mkdirSync, mkdtempSync, writeFileSync, rmSync, existsSync, realpathSync } from "fs"; +import { tmpdir } from "os"; +import path from "path"; +import { WORKTREE_PATTERN, WORKTREE_ROOT_PATTERN, WORKTREE_SPEC_PATTERN } from "../../shared/constants/worktree-patterns"; +import { detectAutoBuildSourcePath } from "../ipc-handlers/settings-handlers"; + +// Test data directory +const TEST_DIR = mkdtempSync(path.join(tmpdir(), "worktree-test-")); + +/** + * Safely remove directory with retry logic for Windows + * Windows can have file locking issues that require retries + */ +function safeRemoveDir(dirPath: string, retries = 3): void { + for (let i = 0; i < retries; i++) { + try { + if (existsSync(dirPath)) { + rmSync(dirPath, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); + } + return; // Success + } catch (error) { + if (i === retries - 1) { + // Last attempt failed - log but don't fail test + console.warn(`Warning: Failed to clean up test directory ${dirPath}:`, error); + return; + } + // Wait before retry (helps with Windows file locking) + const delay = 200 * (i + 1); + const start = Date.now(); + while (Date.now() - start < delay) { + // Busy wait + } + } + } +} + +// Mock @electron-toolkit/utils +vi.mock("@electron-toolkit/utils", () => ({ + is: { + dev: true, + windows: process.platform === "win32", + macos: process.platform === "darwin", + linux: process.platform === "linux", + }, +})); + +// Mock electron +vi.mock("electron", () => ({ + app: { + getAppPath: vi.fn(() => tmpdir()), + getVersion: vi.fn(() => "2.7.5"), + getPath: vi.fn((name: string) => { + if (name === "userData") return path.join(tmpdir(), "userData"); + return tmpdir(); + }), + }, + ipcMain: { + handle: vi.fn(), + on: vi.fn(), + }, + shell: { + openPath: vi.fn(), + }, + dialog: { + showOpenDialog: vi.fn(), + showMessageBox: vi.fn(), + }, +})); + +// Mock electron-updater +vi.mock("electron-updater", () => ({ + autoUpdater: { + autoDownload: true, + autoInstallOnAppQuit: true, + checkForUpdates: vi.fn(), + on: vi.fn(), + }, +})); + +describe("Worktree Detection", () => { + let originalCwd: string; + let originalEnv: NodeJS.ProcessEnv; + + beforeEach(() => { + // Save original values + originalCwd = process.cwd(); + originalEnv = { ...process.env }; + + // Clear mocks + vi.clearAllMocks(); + }); + + afterEach(() => { + // Restore original values + process.chdir(originalCwd); + process.env = originalEnv; + }); + + describe("Regex Pattern", () => { + it("should detect valid 3-digit spec numbers", () => { + const validPaths = [ + "/project/.auto-claude/worktrees/tasks/001-feature", + "/project/.auto-claude/worktrees/tasks/009-bug-fix", + "/project/.auto-claude/worktrees/tasks/123-enhancement", + "/project/.auto-claude/worktrees/tasks/999-test", + ]; + + for (const testPath of validPaths) { + expect(WORKTREE_PATTERN.test(testPath)).toBe(true); + } + }); + + it("should reject 1, 2, or 4+ digit spec numbers", () => { + const invalidPaths = [ + "/project/.auto-claude/worktrees/tasks/1-feature", // 1 digit + "/project/.auto-claude/worktrees/tasks/12-feature", // 2 digits + "/project/.auto-claude/worktrees/tasks/1234-feature", // 4 digits + "/project/.auto-claude/worktrees/tasks/12345-feature", // 5 digits + ]; + + for (const testPath of invalidPaths) { + expect(WORKTREE_PATTERN.test(testPath)).toBe(false); + } + }); + + it("should handle cross-platform path separators", () => { + const unixPath = "/project/.auto-claude/worktrees/tasks/009-feature"; + const windowsPath = "C:\\project\\.auto-claude\\worktrees\\tasks\\009-feature"; + + expect(WORKTREE_PATTERN.test(unixPath)).toBe(true); + expect(WORKTREE_PATTERN.test(windowsPath)).toBe(true); + }); + + it("should extract correct spec number", () => { + // Custom pattern that captures both root path AND spec number (for dual extraction test) + const extractPattern = /(.*\.auto-claude[/\\]worktrees[/\\]tasks[/\\]([0-9]{3})-[^/\\]+)/; + const testPath = "/project/.auto-claude/worktrees/tasks/009-add-feature/apps/backend"; + + const match = testPath.match(extractPattern); + expect(match).not.toBeNull(); + expect(match![2]).toBe("009"); + }); + + it("should extract worktree root path", () => { + const testPath = "/project/.auto-claude/worktrees/tasks/009-add-feature/apps/backend"; + + const match = testPath.match(WORKTREE_ROOT_PATTERN); + expect(match).not.toBeNull(); + expect(match![1]).toBe("/project/.auto-claude/worktrees/tasks/009-add-feature"); + }); + + it("should reject malformed paths", () => { + const malformedPaths = [ + "/project/worktrees/tasks/009-feature", // Missing .auto-claude + "/project/.auto-claude/tasks/009-feature", // Missing worktrees + "/project/.auto-claude/worktrees/009-feature", // Missing tasks + "/project/.auto-claude/worktrees/tasks/", // No spec directory + "/project/.auto-claude/worktrees/tasks/abc-feature", // Non-numeric spec + ]; + + for (const testPath of malformedPaths) { + expect(WORKTREE_PATTERN.test(testPath)).toBe(false); + } + }); + }); + + describe("Backend Path Resolution", () => { + let testWorktreeDir: string; + let testBackendDir: string; + let testMarkerFile: string; + + beforeEach(() => { + // Create test worktree structure + testWorktreeDir = path.join(TEST_DIR, ".auto-claude", "worktrees", "tasks", "009-test-feature"); + testBackendDir = path.join(testWorktreeDir, "apps", "backend"); + const runnersDir = path.join(testBackendDir, "runners"); + testMarkerFile = path.join(runnersDir, "spec_runner.py"); + + // Create directories and marker file + mkdirSync(runnersDir, { recursive: true }); + writeFileSync(testMarkerFile, "# spec_runner.py"); + }); + + afterEach(() => { + // Clean up test files (with retry logic for Windows) + safeRemoveDir(TEST_DIR); + }); + + it("should prioritize environment variable", async () => { + // Set up custom backend path via ENV var + const customBackendPath = path.join(TEST_DIR, "custom-backend"); + const customRunnersDir = path.join(customBackendPath, "runners"); + mkdirSync(customRunnersDir, { recursive: true }); + writeFileSync(path.join(customRunnersDir, "spec_runner.py"), "# custom"); + + process.env.AUTO_CLAUDE_BACKEND_PATH = customBackendPath; + + // Change to worktree directory (should be ignored due to ENV var priority) + process.chdir(testWorktreeDir); + + // Call the actual resolver function + const detectedPath = detectAutoBuildSourcePath(); + + // ENV var should take precedence over worktree + expect(detectedPath).toBe(customBackendPath); + expect(WORKTREE_PATTERN.test(process.cwd())).toBe(true); // Verify we're in worktree + expect(process.env.AUTO_CLAUDE_BACKEND_PATH).toBe(customBackendPath); // Verify ENV var is set + }); + + it("should prioritize worktree local backend", () => { + // Ensure no ENV override + delete process.env.AUTO_CLAUDE_BACKEND_PATH; + + // Change to worktree directory + process.chdir(testWorktreeDir); + + // Call the actual resolver function + const detectedPath = detectAutoBuildSourcePath(); + + // Should return the worktree local backend path + // Use realpathSync to resolve symlinks (e.g., /var -> /private/var on macOS) + const expectedPath = realpathSync(path.join(testWorktreeDir, "apps", "backend")); + const normalizedDetectedPath = detectedPath ? realpathSync(detectedPath) : null; + expect(normalizedDetectedPath).toBe(expectedPath); + expect(existsSync(path.join(expectedPath, "runners", "spec_runner.py"))).toBe(true); + }); + + it("should use saved settings as priority 3", () => { + // Simulate settings check (without ENV var or worktree) + const settingsPath = "/custom/settings/backend"; + + // Mock scenario: not in worktree, no ENV var + process.chdir(TEST_DIR); // Non-worktree directory + delete process.env.AUTO_CLAUDE_BACKEND_PATH; + + const cwd = process.cwd(); + const inWorktree = WORKTREE_PATTERN.test(cwd); + const hasEnvVar = !!process.env.AUTO_CLAUDE_BACKEND_PATH; + + expect(inWorktree).toBe(false); + expect(hasEnvVar).toBe(false); + + // In this scenario, settings path should be checked + // (actual settings reading logic is in settings-handlers.ts) + expect(settingsPath).toBeTruthy(); + }); + + it("should fall back to standard auto-detection", () => { + // No ENV var, not in worktree, no saved settings + process.chdir(TEST_DIR); + delete process.env.AUTO_CLAUDE_BACKEND_PATH; + + const cwd = process.cwd(); + const inWorktree = WORKTREE_PATTERN.test(cwd); + const hasEnvVar = !!process.env.AUTO_CLAUDE_BACKEND_PATH; + + expect(inWorktree).toBe(false); + expect(hasEnvVar).toBe(false); + + // Standard auto-detection would check possiblePaths array + // This is the final fallback when all else fails + }); + + it("should validate marker file exists", () => { + const validBackendPath = testBackendDir; + const invalidBackendPath = path.join(TEST_DIR, "no-backend"); + + // Valid path has marker file + const validMarkerPath = path.join(validBackendPath, "runners", "spec_runner.py"); + expect(existsSync(validBackendPath)).toBe(true); + expect(existsSync(validMarkerPath)).toBe(true); + + // Invalid path does not have marker file + const invalidMarkerPath = path.join(invalidBackendPath, "runners", "spec_runner.py"); + expect(existsSync(invalidBackendPath)).toBe(false); + expect(existsSync(invalidMarkerPath)).toBe(false); + }); + + it("should handle invalid paths gracefully", () => { + // Set ENV var to invalid path + process.env.AUTO_CLAUDE_BACKEND_PATH = "/does/not/exist"; + + const envPath = process.env.AUTO_CLAUDE_BACKEND_PATH; + const markerPath = path.join(envPath, "runners", "spec_runner.py"); + const exists = existsSync(envPath) && existsSync(markerPath); + + // Should detect as invalid + expect(exists).toBe(false); + + // Logic should fall back to next priority + // (actual fallback is handled in settings-handlers.ts) + }); + }); + + describe("Worktree Info Extraction", () => { + it("should extract spec number from directory name", () => { + const testCases = [ + { path: ".auto-claude/worktrees/tasks/001-feature", expected: "001" }, + { path: ".auto-claude/worktrees/tasks/009-bug-fix", expected: "009" }, + { path: ".auto-claude/worktrees/tasks/123-enhancement", expected: "123" }, + { path: ".auto-claude/worktrees/tasks/999-test", expected: "999" }, + ]; + + for (const testCase of testCases) { + const match = testCase.path.match(WORKTREE_SPEC_PATTERN); + expect(match).not.toBeNull(); + expect(match![1]).toBe(testCase.expected); + } + }); + + it("should handle paths with subdirectories", () => { + const fullPath = "/project/.auto-claude/worktrees/tasks/009-feature/apps/backend/runners"; + + const match = fullPath.match(WORKTREE_SPEC_PATTERN); + expect(match).not.toBeNull(); + expect(match![1]).toBe("009"); + }); + + it("should return null for non-worktree paths", () => { + const nonWorktreePaths = [ + "/project/apps/backend", + "/project/src/main", + "/Users/user/projects/my-app", + ]; + + + for (const testPath of nonWorktreePaths) { + expect(WORKTREE_PATTERN.test(testPath)).toBe(false); + } + }); + }); +}); diff --git a/apps/frontend/src/main/index.ts b/apps/frontend/src/main/index.ts index eebbcc7c7d..12358db8cf 100644 --- a/apps/frontend/src/main/index.ts +++ b/apps/frontend/src/main/index.ts @@ -46,6 +46,7 @@ import { pythonEnvManager } from './python-env-manager'; import { getUsageMonitor } from './claude-profile/usage-monitor'; import { initializeUsageMonitorForwarding } from './ipc-handlers/terminal-handlers'; import { initializeAppUpdater, stopPeriodicUpdates } from './app-updater'; +import { getEffectiveSourcePath } from './updater/path-resolver'; import { DEFAULT_APP_SETTINGS } from '../shared/constants'; import { readSettingsFile } from './settings-utils'; import { setupErrorLogging } from './app-logger'; @@ -356,11 +357,14 @@ app.whenReady().then(() => { } if (settings.pythonPath || validAutoBuildPath) { - console.warn('[main] Configuring AgentManager with settings:', { + // Get the effective path that will actually be used (includes worktree detection) + const effectivePath = validAutoBuildPath ? getEffectiveSourcePath() : undefined; + + console.debug('[main] Configuring AgentManager with settings:', { pythonPath: settings.pythonPath, - autoBuildPath: validAutoBuildPath + autoBuildPath: effectivePath }); - agentManager.configure(settings.pythonPath, validAutoBuildPath); + agentManager.configure(settings.pythonPath, effectivePath); } } catch (error: unknown) { // ENOENT means no settings file yet - that's fine, use defaults diff --git a/apps/frontend/src/main/ipc-handlers/settings-handlers.ts b/apps/frontend/src/main/ipc-handlers/settings-handlers.ts index 532e1db4e2..998739f91b 100644 --- a/apps/frontend/src/main/ipc-handlers/settings-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/settings-handlers.ts @@ -20,15 +20,102 @@ import type { BrowserWindow } from 'electron'; import { setUpdateChannel, setUpdateChannelWithDowngradeCheck } from '../app-updater'; import { getSettingsPath, readSettingsFile } from '../settings-utils'; import { configureTools, getToolPath, getToolInfo, isPathFromWrongPlatform, preWarmToolCache } from '../cli-tool-manager'; +import { getPlatformDescription, isMacOS, isWindows } from '../platform'; import { parseEnvFile } from './utils'; +import { WORKTREE_PATTERN, WORKTREE_ROOT_PATTERN, WORKTREE_SPEC_PATTERN } from '../../shared/constants/worktree-patterns'; const settingsPath = getSettingsPath(); /** * Auto-detect the auto-claude source path relative to the app location. * Works across platforms (macOS, Windows, Linux) in both dev and production modes. + * + * Priority order (4-priority system): + * 1. AUTO_CLAUDE_BACKEND_PATH environment variable (manual override) + * 2. Local apps/backend in worktree (if running from worktree) + * 3. User settings (saved autoBuildPath) + * 4. Standard auto-detection logic (production/dev paths) */ -const detectAutoBuildSourcePath = (): string | null => { +export const detectAutoBuildSourcePath = (): string | null => { + // Enable debug logging with DEBUG=1 + const debug = process.env.DEBUG === '1' || process.env.DEBUG === 'true'; + + if (debug) { + console.debug('[detectAutoBuildSourcePath] Platform:', getPlatformDescription()); + console.debug('[detectAutoBuildSourcePath] Is dev:', is.dev); + console.debug('[detectAutoBuildSourcePath] __dirname:', __dirname); + console.debug('[detectAutoBuildSourcePath] app.getAppPath():', app.getAppPath()); + console.debug('[detectAutoBuildSourcePath] process.cwd():', process.cwd()); + } + + // PRIORITY 1: Check environment variable override + const envBackendPath = process.env.AUTO_CLAUDE_BACKEND_PATH; + if (envBackendPath) { + const markerPath = path.join(envBackendPath, 'runners', 'spec_runner.py'); + const exists = existsSync(envBackendPath) && existsSync(markerPath); + + if (debug) { + console.debug(`[detectAutoBuildSourcePath] ENV override path: ${envBackendPath}: ${exists ? '✓ FOUND' : '✗ not found'}`); + } + + if (exists) { + console.warn(`[detectAutoBuildSourcePath] Using AUTO_CLAUDE_BACKEND_PATH: ${envBackendPath}`); + return envBackendPath; + } else { + console.warn(`[detectAutoBuildSourcePath] AUTO_CLAUDE_BACKEND_PATH set but invalid (marker file not found): ${envBackendPath}`); + } + } + + // PRIORITY 2: Check if running in worktree and use local backend + const cwd = process.cwd(); + + if (WORKTREE_PATTERN.test(cwd)) { + // We're in a worktree - extract worktree root and check for local backend + // Pattern: .../worktrees/tasks/XXX-name/... + // We want to find the worktree root (XXX-name directory) + const match = cwd.match(WORKTREE_ROOT_PATTERN); + + if (match) { + const worktreeRoot = match[1]; + const localBackendPath = path.join(worktreeRoot, 'apps', 'backend'); + const markerPath = path.join(localBackendPath, 'runners', 'spec_runner.py'); + const exists = existsSync(localBackendPath) && existsSync(markerPath); + + if (debug) { + console.debug(`[detectAutoBuildSourcePath] Detected worktree: ${worktreeRoot}`); + console.debug(`[detectAutoBuildSourcePath] Local backend path: ${localBackendPath}: ${exists ? '✓ FOUND' : '✗ not found'}`); + } + + if (exists) { + console.warn(`[detectAutoBuildSourcePath] Using worktree local backend: ${localBackendPath}`); + return localBackendPath; + } else { + console.warn(`[detectAutoBuildSourcePath] Worktree detected but no local backend found at ${localBackendPath}, falling back to standard detection`); + } + } + } + + // PRIORITY 3: Check user's saved settings + const savedSettings = readSettingsFile(); + const settings = { ...DEFAULT_APP_SETTINGS, ...savedSettings }; + + if (settings.autoBuildPath) { + const markerPath = path.join(settings.autoBuildPath, 'runners', 'spec_runner.py'); + const exists = existsSync(settings.autoBuildPath) && existsSync(markerPath); + + if (debug) { + console.debug(`[detectAutoBuildSourcePath] Saved settings path: ${settings.autoBuildPath}: ${exists ? '✓ FOUND' : '✗ not found'}`); + } + + if (exists) { + console.warn(`[detectAutoBuildSourcePath] Using saved settings backend path: ${settings.autoBuildPath}`); + return settings.autoBuildPath; + } else { + console.warn(`[detectAutoBuildSourcePath] Saved settings path '${settings.autoBuildPath}' is invalid, falling back to standard detection`); + } + } + + // PRIORITY 4: Standard auto-detection logic (fallback) const possiblePaths: string[] = []; // Development mode paths @@ -57,16 +144,8 @@ const detectAutoBuildSourcePath = (): string | null => { // Add process.cwd() as last resort on all platforms possiblePaths.push(path.resolve(process.cwd(), 'apps', 'backend')); - // Enable debug logging with DEBUG=1 - const debug = process.env.DEBUG === '1' || process.env.DEBUG === 'true'; - if (debug) { - console.warn('[detectAutoBuildSourcePath] Platform:', process.platform); - console.warn('[detectAutoBuildSourcePath] Is dev:', is.dev); - console.warn('[detectAutoBuildSourcePath] __dirname:', __dirname); - console.warn('[detectAutoBuildSourcePath] app.getAppPath():', app.getAppPath()); - console.warn('[detectAutoBuildSourcePath] process.cwd():', process.cwd()); - console.warn('[detectAutoBuildSourcePath] Checking paths:', possiblePaths); + console.debug('[detectAutoBuildSourcePath] Checking standard paths:', possiblePaths); } for (const p of possiblePaths) { @@ -470,12 +549,10 @@ export function registerSettingsHandlers( }; } - const platform = process.platform; - - if (platform === 'darwin') { + if (isMacOS()) { // macOS: Use execFileSync with argument array to prevent injection execFileSync('open', ['-a', 'Terminal', resolvedPath], { stdio: 'ignore' }); - } else if (platform === 'win32') { + } else if (isWindows()) { // Windows: Use cmd.exe directly with argument array // /C tells cmd to execute the command and terminate // /K keeps the window open after executing cd @@ -536,20 +613,20 @@ export function registerSettingsHandlers( * In production mode, the .env file is NOT bundled (excluded in electron-builder config). * We store the source .env in app userData directory instead, which is writable. * The sourcePath points to the bundled backend for reference, but envPath is in userData. + * + * Uses enhanced backend detection with 3-priority system: + * 1. AUTO_CLAUDE_BACKEND_PATH environment variable (manual override) + * 2. Local apps/backend in worktree (if running from worktree) + * 3. Standard auto-detection logic (production/dev paths) */ const getSourceEnvPath = (): { sourcePath: string | null; envPath: string | null; isProduction: boolean; } => { - const savedSettings = readSettingsFile(); - const settings = { ...DEFAULT_APP_SETTINGS, ...savedSettings }; - - // Get autoBuildPath from settings or try to auto-detect - let sourcePath: string | null = settings.autoBuildPath || null; - if (!sourcePath) { - sourcePath = detectAutoBuildSourcePath(); - } + // Always use enhanced detection to ensure priority order is respected + // (environment variable override, worktree detection, standard paths) + const sourcePath = detectAutoBuildSourcePath(); if (!sourcePath) { return { sourcePath: null, envPath: null, isProduction: !is.dev }; @@ -759,4 +836,40 @@ export function registerSettingsHandlers( } } ); + + // Worktree detection IPC handler + ipcMain.handle( + IPC_CHANNELS.APP_GET_WORKTREE_INFO, + async (): Promise<{ isWorktree: boolean; specNumber: string | null; worktreePath: string | null }> => { + try { + const cwd = process.cwd(); + const match = cwd.match(WORKTREE_SPEC_PATTERN); + + if (match) { + // Extract worktree root path + const worktreeRootMatch = cwd.match(WORKTREE_ROOT_PATTERN); + const worktreeRoot = worktreeRootMatch ? worktreeRootMatch[1] : null; + + return { + isWorktree: true, + specNumber: match[1], // The captured spec number (e.g., "009") + worktreePath: worktreeRoot + }; + } + + return { + isWorktree: false, + specNumber: null, + worktreePath: null + }; + } catch (error) { + console.error('[APP_GET_WORKTREE_INFO] Error detecting worktree:', error); + return { + isWorktree: false, + specNumber: null, + worktreePath: null + }; + } + } + ); } diff --git a/apps/frontend/src/main/project-store.ts b/apps/frontend/src/main/project-store.ts index cdb6bfcf78..161ce62404 100644 --- a/apps/frontend/src/main/project-store.ts +++ b/apps/frontend/src/main/project-store.ts @@ -8,6 +8,7 @@ import { getAutoBuildPath, isInitialized } from './project-initializer'; import { getTaskWorktreeDir } from './worktree-paths'; import { debugLog } from '../shared/utils/debug-logger'; import { isValidTaskId, findAllSpecPaths } from './utils/spec-path-helpers'; +import { WORKTREE_SPEC_DIR_PATTERN, WORKTREE_ROOT_PATTERN } from '../shared/constants/worktree-patterns'; interface TabState { openProjectIds: string[]; @@ -267,17 +268,31 @@ export class ProjectStore { debugLog('[ProjectStore] Project not found for id:', projectId); return []; } - debugLog('[ProjectStore] Found project:', project.name, 'autoBuildPath:', project.autoBuildPath, 'path:', project.path); + + // WORKTREE ISOLATION: If running from a worktree, use worktree root instead of project path + const cwd = process.cwd(); + const worktreeMatch = cwd.match(WORKTREE_ROOT_PATTERN); + let effectiveProjectPath = project.path; + if (worktreeMatch) { + const worktreeRoot = worktreeMatch[1]; + const worktreeFullName = worktreeRoot.match(/(\d{3}-[^/\\]+)$/)?.[1]; + const worktreeSpecNumber = worktreeFullName?.match(/^(\d{3})/)?.[1]; + + debugLog(`[ProjectStore] Worktree mode detected (${worktreeSpecNumber}), using worktree path "${worktreeRoot}" instead of project path "${project.path}"`); + effectiveProjectPath = worktreeRoot; + } + + debugLog('[ProjectStore] Found project:', project.name, 'autoBuildPath:', project.autoBuildPath, 'effectivePath:', effectiveProjectPath); const allTasks: Task[] = []; const specsBaseDir = getSpecsDir(project.autoBuildPath); // 1. Scan main project specs directory (source of truth for task existence) - const mainSpecsDir = path.join(project.path, specsBaseDir); + const mainSpecsDir = path.join(effectiveProjectPath, specsBaseDir); const mainSpecIds = new Set(); console.warn('[ProjectStore] Main specsDir:', mainSpecsDir, 'exists:', existsSync(mainSpecsDir)); if (existsSync(mainSpecsDir)) { - const mainTasks = this.loadTasksFromSpecsDir(mainSpecsDir, project.path, 'main', projectId, specsBaseDir); + const mainTasks = this.loadTasksFromSpecsDir(mainSpecsDir, effectiveProjectPath, 'main', projectId, specsBaseDir); allTasks.push(...mainTasks); // Track which specs exist in main project mainTasks.forEach(t => mainSpecIds.add(t.specId)); @@ -287,7 +302,7 @@ export class ProjectStore { // 2. Scan worktree specs directories // NOTE FOR MAINTAINERS: Worktree tasks are only included if the spec also exists in main. // This prevents deleted tasks from "coming back" when the worktree isn't cleaned up. - const worktreesDir = getTaskWorktreeDir(project.path); + const worktreesDir = getTaskWorktreeDir(effectiveProjectPath); if (existsSync(worktreesDir)) { try { const worktrees = readdirSync(worktreesDir, { withFileTypes: true }); diff --git a/apps/frontend/src/main/updater/path-resolver.ts b/apps/frontend/src/main/updater/path-resolver.ts index 6c149a5b5a..252480bde2 100644 --- a/apps/frontend/src/main/updater/path-resolver.ts +++ b/apps/frontend/src/main/updater/path-resolver.ts @@ -5,6 +5,7 @@ import { existsSync, readFileSync } from 'fs'; import path from 'path'; import { app } from 'electron'; +import { WORKTREE_SPEC_PATTERN, WORKTREE_ROOT_PATTERN } from '../../shared/constants/worktree-patterns'; /** * Get the path to the bundled backend source @@ -55,7 +56,25 @@ export function getUpdateCachePath(): string { * Get the effective source path (considers override from updates and settings) */ export function getEffectiveSourcePath(): string { - // First, check user settings for configured autoBuildPath + // PRIORITY 1: Check if running from a worktree + const cwd = process.cwd(); + const worktreeMatch = cwd.match(WORKTREE_ROOT_PATTERN); + + if (worktreeMatch) { + const worktreeRoot = worktreeMatch[1]; + const specNumberMatch = worktreeRoot.match(WORKTREE_SPEC_PATTERN); + const specNumber = specNumberMatch ? specNumberMatch[1] : 'unknown'; + const worktreeBackendPath = path.join(worktreeRoot, 'apps', 'backend'); + const worktreeMarkerPath = path.join(worktreeBackendPath, 'runners', 'spec_runner.py'); + + if (existsSync(worktreeMarkerPath)) { + console.debug(`[path-resolver] Worktree mode detected (${specNumber}), using: ${worktreeBackendPath}`); + return worktreeBackendPath; + } + console.debug(`[path-resolver] Worktree mode detected (${specNumber}), but backend not found at: ${worktreeBackendPath}`); + } + + // PRIORITY 2: Check user settings for configured autoBuildPath try { const settingsPath = path.join(app.getPath('userData'), 'settings.json'); if (existsSync(settingsPath)) { diff --git a/apps/frontend/src/preload/api/settings-api.ts b/apps/frontend/src/preload/api/settings-api.ts index 1c1f8752f9..e9b7ff981d 100644 --- a/apps/frontend/src/preload/api/settings-api.ts +++ b/apps/frontend/src/preload/api/settings-api.ts @@ -5,7 +5,8 @@ import type { IPCResult, SourceEnvConfig, SourceEnvCheckResult, - ToolDetectionResult + ToolDetectionResult, + WorktreeInfo } from '../../shared/types'; export interface SettingsAPI { @@ -23,6 +24,7 @@ export interface SettingsAPI { // App Info getAppVersion: () => Promise; + getWorktreeInfo: () => Promise; // Auto-Build Source Environment getSourceEnv: () => Promise>; @@ -56,6 +58,9 @@ export const createSettingsAPI = (): SettingsAPI => ({ getAppVersion: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.APP_VERSION), + getWorktreeInfo: (): Promise => + ipcRenderer.invoke(IPC_CHANNELS.APP_GET_WORKTREE_INFO), + // Auto-Build Source Environment getSourceEnv: (): Promise> => ipcRenderer.invoke(IPC_CHANNELS.AUTOBUILD_SOURCE_ENV_GET), diff --git a/apps/frontend/src/renderer/components/Sidebar.tsx b/apps/frontend/src/renderer/components/Sidebar.tsx index 585e3ccf60..6a260c2a34 100644 --- a/apps/frontend/src/renderer/components/Sidebar.tsx +++ b/apps/frontend/src/renderer/components/Sidebar.tsx @@ -50,7 +50,7 @@ import { GitSetupModal } from './GitSetupModal'; import { RateLimitIndicator } from './RateLimitIndicator'; import { ClaudeCodeStatusBadge } from './ClaudeCodeStatusBadge'; import { UpdateBanner } from './UpdateBanner'; -import type { Project, AutoBuildVersionInfo, GitStatus, ProjectEnvConfig } from '../../shared/types'; +import type { Project, AutoBuildVersionInfo, GitStatus, ProjectEnvConfig, WorktreeInfo } from '../../shared/types'; export type SidebarView = 'kanban' | 'terminals' | 'roadmap' | 'context' | 'ideation' | 'github-issues' | 'gitlab-issues' | 'github-prs' | 'gitlab-merge-requests' | 'changelog' | 'insights' | 'worktrees' | 'agent-tools'; @@ -111,6 +111,7 @@ export function Sidebar({ const [pendingProject, setPendingProject] = useState(null); const [isInitializing, setIsInitializing] = useState(false); const [envConfig, setEnvConfig] = useState(null); + const [worktreeInfo, setWorktreeInfo] = useState(null); const selectedProject = projects.find((p) => p.id === selectedProjectId); @@ -207,6 +208,20 @@ export function Sidebar({ checkGit(); }, [selectedProject]); + // Load worktree info on mount + useEffect(() => { + const loadWorktreeInfo = async () => { + try { + const info = await window.electronAPI.getWorktreeInfo(); + setWorktreeInfo(info); + } catch (error) { + console.error('Failed to load worktree info:', error); + setWorktreeInfo(null); + } + }; + loadWorktreeInfo(); + }, []); + const handleProjectAdded = (project: Project, needsInit: boolean) => { if (needsInit) { setPendingProject(project); @@ -294,8 +309,20 @@ export function Sidebar({
{/* Header with drag area - extra top padding for macOS traffic lights */} -
+
Auto Claude + {worktreeInfo?.isWorktree && worktreeInfo.specNumber && ( + + + + {t('navigation:worktree.badge')}/{worktreeInfo.specNumber} + + + + {t('navigation:worktree.tooltip')} + + + )}
diff --git a/apps/frontend/src/renderer/lib/mocks/settings-mock.ts b/apps/frontend/src/renderer/lib/mocks/settings-mock.ts index 88c78c357a..62567c8463 100644 --- a/apps/frontend/src/renderer/lib/mocks/settings-mock.ts +++ b/apps/frontend/src/renderer/lib/mocks/settings-mock.ts @@ -32,6 +32,11 @@ export const settingsMock = { // App Info getAppVersion: async () => '0.1.0-browser', + getWorktreeInfo: async () => ({ + isWorktree: false, + specNumber: null, + worktreePath: null + }), // App Update Operations (mock - no updates in browser mode) checkAppUpdate: async () => ({ success: true, data: null }), diff --git a/apps/frontend/src/shared/constants/ipc.ts b/apps/frontend/src/shared/constants/ipc.ts index 2e6a6b9c2e..180e312490 100644 --- a/apps/frontend/src/shared/constants/ipc.ts +++ b/apps/frontend/src/shared/constants/ipc.ts @@ -154,6 +154,7 @@ export const IPC_CHANNELS = { // App info APP_VERSION: 'app:version', + APP_GET_WORKTREE_INFO: 'app:getWorktreeInfo', // Shell operations SHELL_OPEN_EXTERNAL: 'shell:openExternal', diff --git a/apps/frontend/src/shared/constants/worktree-patterns.ts b/apps/frontend/src/shared/constants/worktree-patterns.ts new file mode 100644 index 0000000000..43b85366b7 --- /dev/null +++ b/apps/frontend/src/shared/constants/worktree-patterns.ts @@ -0,0 +1,21 @@ +/** + * Cross-platform regex patterns for worktree detection + * + * These patterns use [/\\] to match both Unix (/) and Windows (\) path separators, + * ensuring the worktree detection feature works correctly on all platforms. + * + * Pattern structure: .auto-claude/worktrees/tasks/XXX-name/ + * Where XXX is a 3-digit spec number (001-999) + */ + +/** Detects if path is within a worktree (basic test) */ +export const WORKTREE_PATTERN = /\.auto-claude[/\\]worktrees[/\\]tasks[/\\][0-9]{3}-/; + +/** Extracts worktree root path (full path to spec directory) */ +export const WORKTREE_ROOT_PATTERN = /(.*\.auto-claude[/\\]worktrees[/\\]tasks[/\\][0-9]{3}-[^/\\]+)/; + +/** Extracts spec number only (e.g., "009") */ +export const WORKTREE_SPEC_PATTERN = /\.auto-claude[/\\]worktrees[/\\]tasks[/\\]([0-9]{3})-[^/\\]+/; + +/** Extracts spec directory name (e.g., "009-feature-name") */ +export const WORKTREE_SPEC_DIR_PATTERN = /\.auto-claude[/\\]worktrees[/\\]tasks[/\\](\d{3}-[^/\\]+)/; diff --git a/apps/frontend/src/shared/i18n/locales/en/navigation.json b/apps/frontend/src/shared/i18n/locales/en/navigation.json index 204f9a9b21..51d9f03403 100644 --- a/apps/frontend/src/shared/i18n/locales/en/navigation.json +++ b/apps/frontend/src/shared/i18n/locales/en/navigation.json @@ -79,5 +79,11 @@ "pathChangeWarningDescription": "Switching CLI installations will use a different Claude Code binary. Any running sessions will continue using the previous installation until restarted.", "switchInstallationConfirm": "Switch", "versionUnknown": "version unknown" + }, + "worktree": { + "badge": "Worktree", + "tooltip": "You are working in an isolated worktree for this task", + "inWorktree": "Working in worktree: {{name}}", + "inMain": "Main workspace" } } diff --git a/apps/frontend/src/shared/i18n/locales/fr/navigation.json b/apps/frontend/src/shared/i18n/locales/fr/navigation.json index c40ee36ca7..5ade094c0e 100644 --- a/apps/frontend/src/shared/i18n/locales/fr/navigation.json +++ b/apps/frontend/src/shared/i18n/locales/fr/navigation.json @@ -79,5 +79,11 @@ "pathChangeWarningDescription": "Le changement d'installation CLI utilisera un binaire Claude Code différent. Les sessions en cours continueront à utiliser l'installation précédente jusqu'à leur redémarrage.", "switchInstallationConfirm": "Changer", "versionUnknown": "version inconnue" + }, + "worktree": { + "badge": "Worktree", + "tooltip": "Vous travaillez dans un worktree isolé pour cette tâche", + "inWorktree": "Travail dans le worktree : {{name}}", + "inMain": "Espace de travail principal" } } diff --git a/apps/frontend/src/shared/types/ipc.ts b/apps/frontend/src/shared/types/ipc.ts index 7de16a764d..8a40125743 100644 --- a/apps/frontend/src/shared/types/ipc.ts +++ b/apps/frontend/src/shared/types/ipc.ts @@ -349,6 +349,7 @@ export interface ElectronAPI { // App info getAppVersion: () => Promise; + getWorktreeInfo: () => Promise; // Roadmap operations getRoadmap: (projectId: string) => Promise>; diff --git a/apps/frontend/src/shared/types/settings.ts b/apps/frontend/src/shared/types/settings.ts index 6acf1aaa4c..8acae7760c 100644 --- a/apps/frontend/src/shared/types/settings.ts +++ b/apps/frontend/src/shared/types/settings.ts @@ -305,3 +305,10 @@ export interface SourceEnvCheckResult { sourcePath?: string; error?: string; } + +// Worktree detection information +export interface WorktreeInfo { + isWorktree: boolean; + specNumber: string | null; + worktreePath: string | null; +}