diff --git a/README.md b/README.md index 48af61a3..4f8c4d1c 100644 --- a/README.md +++ b/README.md @@ -35,18 +35,18 @@ > ⚠️ Beta releases may contain bugs and breaking changes. [View all releases](https://github.com/AndyMik90/Auto-Claude/releases) -[![Beta](https://img.shields.io/badge/beta-2.7.6--beta.2-orange?style=flat-square)](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.7.6-beta.2) +[![Beta](https://img.shields.io/badge/beta-2.7.6--beta.1-orange?style=flat-square)](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.7.6-beta.1) | Platform | Download | |----------|----------| -| **Windows** | [Auto-Claude-2.7.6-beta.2-win32-x64.exe](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.2/Auto-Claude-2.7.6-beta.2-win32-x64.exe) | -| **macOS (Apple Silicon)** | [Auto-Claude-2.7.6-beta.2-darwin-arm64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.2/Auto-Claude-2.7.6-beta.2-darwin-arm64.dmg) | -| **macOS (Intel)** | [Auto-Claude-2.7.6-beta.2-darwin-x64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.2/Auto-Claude-2.7.6-beta.2-darwin-x64.dmg) | -| **Linux** | [Auto-Claude-2.7.6-beta.2-linux-x86_64.AppImage](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.2/Auto-Claude-2.7.6-beta.2-linux-x86_64.AppImage) | -| **Linux (Debian)** | [Auto-Claude-2.7.6-beta.2-linux-amd64.deb](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.2/Auto-Claude-2.7.6-beta.2-linux-amd64.deb) | -| **Linux (Flatpak)** | [Auto-Claude-2.7.6-beta.2-linux-x86_64.flatpak](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.2/Auto-Claude-2.7.6-beta.2-linux-x86_64.flatpak) | +| **Windows** | [Auto-Claude-2.7.6-beta.1-win32-x64.exe](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.1/Auto-Claude-2.7.6-beta.1-win32-x64.exe) | +| **macOS (Apple Silicon)** | [Auto-Claude-2.7.6-beta.1-darwin-arm64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.1/Auto-Claude-2.7.6-beta.1-darwin-arm64.dmg) | +| **macOS (Intel)** | [Auto-Claude-2.7.6-beta.1-darwin-x64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.1/Auto-Claude-2.7.6-beta.1-darwin-x64.dmg) | +| **Linux** | [Auto-Claude-2.7.6-beta.1-linux-x86_64.AppImage](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.1/Auto-Claude-2.7.6-beta.1-linux-x86_64.AppImage) | +| **Linux (Debian)** | [Auto-Claude-2.7.6-beta.1-linux-amd64.deb](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.1/Auto-Claude-2.7.6-beta.1-linux-amd64.deb) | +| **Linux (Flatpak)** | [Auto-Claude-2.7.6-beta.1-linux-x86_64.flatpak](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.1/Auto-Claude-2.7.6-beta.1-linux-x86_64.flatpak) | > All releases include SHA256 checksums and VirusTotal scan results for security verification. diff --git a/apps/backend/cli/build_commands.py b/apps/backend/cli/build_commands.py index 39e2275e..6a0d9c37 100644 --- a/apps/backend/cli/build_commands.py +++ b/apps/backend/cli/build_commands.py @@ -87,7 +87,10 @@ def handle_build_command( debug_success, ) from phase_config import get_phase_model - from prompts_pkg.prompts import get_base_branch_from_metadata + from prompts_pkg.prompts import ( + get_base_branch_from_metadata, + get_use_local_branch_from_metadata, + ) from qa_loop import run_qa_validation_loop, should_run_qa from .utils import print_banner, validate_environment @@ -203,6 +206,9 @@ def handle_build_command( base_branch = metadata_branch debug("run.py", f"Using base branch from task metadata: {base_branch}") + # Check if user requested local branch (preserves gitignored files like .env) + use_local_branch = get_use_local_branch_from_metadata(spec_dir) + if workspace_mode == WorkspaceMode.ISOLATED: # Keep reference to original spec directory for syncing progress back source_spec_dir = spec_dir @@ -213,6 +219,7 @@ def handle_build_command( workspace_mode, source_spec_dir=spec_dir, base_branch=base_branch, + use_local_branch=use_local_branch, ) # Use the localized spec directory (inside worktree) for AI access if localized_spec_dir: diff --git a/apps/backend/core/workspace/setup.py b/apps/backend/core/workspace/setup.py index b43907d9..accfc9e0 100644 --- a/apps/backend/core/workspace/setup.py +++ b/apps/backend/core/workspace/setup.py @@ -325,6 +325,7 @@ def setup_workspace( mode: WorkspaceMode, source_spec_dir: Path | None = None, base_branch: str | None = None, + use_local_branch: bool = False, ) -> tuple[Path, WorktreeManager | None, Path | None]: """ Set up the workspace based on user's choice. @@ -337,6 +338,7 @@ def setup_workspace( mode: The workspace mode to use source_spec_dir: Optional source spec directory to copy to worktree base_branch: Base branch for worktree creation (default: current branch) + use_local_branch: If True, use local branch directly instead of preferring origin/branch Returns: Tuple of (working_directory, worktree_manager or None, localized_spec_dir or None) @@ -357,7 +359,9 @@ def setup_workspace( # Ensure timeline tracking hook is installed (once per session) ensure_timeline_hook_installed(project_dir) - manager = WorktreeManager(project_dir, base_branch=base_branch) + manager = WorktreeManager( + project_dir, base_branch=base_branch, use_local_branch=use_local_branch + ) manager.setup() # Get or create worktree for THIS SPECIFIC SPEC diff --git a/apps/backend/core/worktree.py b/apps/backend/core/worktree.py index 8b782a6e..d2aeedf4 100644 --- a/apps/backend/core/worktree.py +++ b/apps/backend/core/worktree.py @@ -186,9 +186,15 @@ class WorktreeManager: CLI_TIMEOUT = 60 # 1 minute for CLI commands (gh/glab) CLI_QUERY_TIMEOUT = 30 # 30 seconds for CLI queries (gh/glab) - def __init__(self, project_dir: Path, base_branch: str | None = None): + def __init__( + self, + project_dir: Path, + base_branch: str | None = None, + use_local_branch: bool = False, + ): self.project_dir = project_dir self.base_branch = base_branch or self._detect_base_branch() + self.use_local_branch = use_local_branch self.worktrees_dir = project_dir / ".auto-claude" / "worktrees" / "tasks" self._merge_lock = asyncio.Lock() @@ -695,18 +701,23 @@ class WorktreeManager: else: # Branch doesn't exist - create new branch from remote or local base # Determine the start point for the worktree - remote_ref = f"origin/{self.base_branch}" start_point = self.base_branch # Default to local branch - # Check if remote ref exists and use it as the source of truth - check_remote = self._run_git(["rev-parse", "--verify", remote_ref]) - if check_remote.returncode == 0: - start_point = remote_ref - print(f"Creating worktree from remote: {remote_ref}") + if self.use_local_branch: + # User explicitly requested local branch - skip auto-switch to remote + # This preserves gitignored files (.env, configs) that may not exist on remote + print(f"Creating worktree from local branch: {self.base_branch}") else: - print( - f"Remote ref {remote_ref} not found, using local branch: {self.base_branch}" - ) + # Check if remote ref exists and use it as the source of truth + remote_ref = f"origin/{self.base_branch}" + check_remote = self._run_git(["rev-parse", "--verify", remote_ref]) + if check_remote.returncode == 0: + start_point = remote_ref + print(f"Creating worktree from remote: {remote_ref}") + else: + print( + f"Remote ref {remote_ref} not found, using local branch: {self.base_branch}" + ) # Create worktree with new branch from the start point result = self._run_git( diff --git a/apps/backend/prompts_pkg/prompts.py b/apps/backend/prompts_pkg/prompts.py index 80ce5e43..c50d8286 100644 --- a/apps/backend/prompts_pkg/prompts.py +++ b/apps/backend/prompts_pkg/prompts.py @@ -81,6 +81,31 @@ def get_base_branch_from_metadata(spec_dir: Path) -> str | None: return None +def get_use_local_branch_from_metadata(spec_dir: Path) -> bool: + """ + Read useLocalBranch from task_metadata.json if it exists. + + When True, the worktree should be created from the local branch directly + instead of preferring origin/branch. This preserves gitignored files + (.env, configs) that may not exist on the remote. + + Args: + spec_dir: Directory containing the spec files + + Returns: + True if useLocalBranch is set in metadata, False otherwise + """ + metadata_path = spec_dir / "task_metadata.json" + if metadata_path.exists(): + try: + with open(metadata_path, encoding="utf-8") as f: + metadata = json.load(f) + return bool(metadata.get("useLocalBranch", False)) + except (json.JSONDecodeError, OSError): + pass + return False + + # Alias for backwards compatibility (internal use) _get_base_branch_from_metadata = get_base_branch_from_metadata diff --git a/apps/frontend/src/main/agent/types.ts b/apps/frontend/src/main/agent/types.ts index 47e3afeb..17cded63 100644 --- a/apps/frontend/src/main/agent/types.ts +++ b/apps/frontend/src/main/agent/types.ts @@ -50,6 +50,7 @@ export interface TaskExecutionOptions { workers?: number; baseBranch?: string; useWorktree?: boolean; // If false, use --direct mode (no worktree isolation) + useLocalBranch?: boolean; // If true, use local branch directly instead of preferring origin/branch } export interface SpecCreationMetadata { @@ -73,6 +74,7 @@ export interface SpecCreationMetadata { thinkingLevel?: 'none' | 'low' | 'medium' | 'high' | 'ultrathink'; // Workspace mode - whether to use worktree isolation useWorktree?: boolean; // If false, use --direct mode (no worktree isolation) + useLocalBranch?: boolean; // If true, use local branch directly instead of preferring origin/branch } export interface IdeationProgressData { diff --git a/apps/frontend/src/main/ipc-handlers/project-handlers.ts b/apps/frontend/src/main/ipc-handlers/project-handlers.ts index 1d021bf7..2dac34ad 100644 --- a/apps/frontend/src/main/ipc-handlers/project-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/project-handlers.ts @@ -10,7 +10,8 @@ import type { IPCResult, InitializationResult, AutoBuildVersionInfo, - GitStatus + GitStatus, + GitBranchDetail } from '../../shared/types'; import { projectStore } from '../project-store'; import { @@ -89,6 +90,96 @@ function getGitBranches(projectPath: string): string[] { } } +/** + * Get structured branch information for a directory (both local and remote) + * Returns GitBranchDetail[] with type indicators, keeping both local and remote versions + * when a branch exists in both places (no deduplication) + */ +function getGitBranchesWithInfo(projectPath: string): GitBranchDetail[] { + try { + // First fetch to ensure we have latest remote refs + try { + execFileSync(getToolPath('git'), ['fetch', '--prune'], { + cwd: projectPath, + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + timeout: 10000 // 10 second timeout for fetch + }); + } catch { + // Fetch may fail if offline or no remote, continue with local refs + } + + // Get current branch for isCurrent indicator + let currentBranch: string | null = null; + try { + const currentResult = execFileSync(getToolPath('git'), ['rev-parse', '--abbrev-ref', 'HEAD'], { + cwd: projectPath, + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'] + }); + currentBranch = currentResult.trim() || null; + } catch { + // Ignore - current branch detection may fail in some edge cases + } + + // Get local branches + const localResult = execFileSync(getToolPath('git'), ['branch', '--format=%(refname:short)'], { + cwd: projectPath, + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'] + }); + + const localBranches: GitBranchDetail[] = localResult.trim().split('\n') + .filter(b => b.trim()) + .map(b => { + const name = b.trim(); + return { + name, + type: 'local' as const, + displayName: name, + isCurrent: name === currentBranch + }; + }); + + // Get remote branches + let remoteBranches: GitBranchDetail[] = []; + try { + const remoteResult = execFileSync(getToolPath('git'), ['branch', '-r', '--format=%(refname:short)'], { + cwd: projectPath, + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'] + }); + + remoteBranches = remoteResult.trim().split('\n') + .filter(b => b.trim()) + .map(b => b.trim()) + // Remove HEAD pointer entries like "origin/HEAD" + .filter(b => !b.endsWith('/HEAD')) + .map(name => ({ + name, + type: 'remote' as const, + displayName: name, + isCurrent: false + })); + } catch { + // Remote branches may not exist, continue with local only + } + + // Combine and sort: local branches first, then remote branches, alphabetically within each group + const allBranches = [...localBranches, ...remoteBranches]; + + return allBranches.sort((a, b) => { + // Local branches come first + if (a.type === 'local' && b.type === 'remote') return -1; + if (a.type === 'remote' && b.type === 'local') return 1; + // Within same type, sort alphabetically + return a.name.localeCompare(b.name); + }); + } catch { + return []; + } +} + /** * Get the current git branch for a directory */ @@ -449,7 +540,7 @@ export function registerProjectHandlers( // Git Operations // ============================================ - // Get all branches for a project + // Get all branches for a project (legacy - returns string[]) ipcMain.handle( IPC_CHANNELS.GIT_GET_BRANCHES, async (_, projectPath: string): Promise> => { @@ -468,6 +559,25 @@ export function registerProjectHandlers( } ); + // Get all branches with structured type information (local vs remote) + ipcMain.handle( + IPC_CHANNELS.GIT_GET_BRANCHES_WITH_INFO, + async (_, projectPath: string): Promise> => { + try { + if (!existsSync(projectPath)) { + return { success: false, error: 'Directory does not exist' }; + } + const branches = getGitBranchesWithInfo(projectPath); + return { success: true, data: branches }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error' + }; + } + } + ); + // Get current branch for a project ipcMain.handle( IPC_CHANNELS.GIT_GET_CURRENT_BRANCH, diff --git a/apps/frontend/src/main/ipc-handlers/task/execution-handlers.ts b/apps/frontend/src/main/ipc-handlers/task/execution-handlers.ts index b04a0a5f..d81c27f0 100644 --- a/apps/frontend/src/main/ipc-handlers/task/execution-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/task/execution-handlers.ts @@ -268,7 +268,8 @@ export function registerTaskExecutionHandlers( parallel: false, // Sequential for planning phase workers: 1, baseBranch, - useWorktree: task.metadata?.useWorktree + useWorktree: task.metadata?.useWorktree, + useLocalBranch: task.metadata?.useLocalBranch }, project.id ); @@ -285,7 +286,8 @@ export function registerTaskExecutionHandlers( parallel: false, workers: 1, baseBranch, - useWorktree: task.metadata?.useWorktree + useWorktree: task.metadata?.useWorktree, + useLocalBranch: task.metadata?.useLocalBranch }, project.id ); @@ -741,7 +743,8 @@ export function registerTaskExecutionHandlers( parallel: false, workers: 1, baseBranch: baseBranchForUpdate, - useWorktree: task.metadata?.useWorktree + useWorktree: task.metadata?.useWorktree, + useLocalBranch: task.metadata?.useLocalBranch }, project.id ); @@ -757,7 +760,8 @@ export function registerTaskExecutionHandlers( parallel: false, workers: 1, baseBranch: baseBranchForUpdate, - useWorktree: task.metadata?.useWorktree + useWorktree: task.metadata?.useWorktree, + useLocalBranch: task.metadata?.useLocalBranch }, project.id ); @@ -1124,7 +1128,8 @@ export function registerTaskExecutionHandlers( parallel: false, workers: 1, baseBranch: baseBranchForRecovery, - useWorktree: task.metadata?.useWorktree + useWorktree: task.metadata?.useWorktree, + useLocalBranch: task.metadata?.useLocalBranch }, project.id ); diff --git a/apps/frontend/src/main/ipc-handlers/terminal/worktree-handlers.ts b/apps/frontend/src/main/ipc-handlers/terminal/worktree-handlers.ts index 05639c1c..6989e014 100644 --- a/apps/frontend/src/main/ipc-handlers/terminal/worktree-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/terminal/worktree-handlers.ts @@ -345,9 +345,9 @@ function loadWorktreeConfig(projectPath: string, name: string): TerminalWorktree async function createTerminalWorktree( request: CreateTerminalWorktreeRequest ): Promise { - const { terminalId, name, taskId, createGitBranch, projectPath, baseBranch: customBaseBranch } = request; + const { terminalId, name, taskId, createGitBranch, projectPath, baseBranch: customBaseBranch, useLocalBranch } = request; - debugLog('[TerminalWorktree] Creating worktree:', { name, taskId, createGitBranch, projectPath, customBaseBranch }); + debugLog('[TerminalWorktree] Creating worktree:', { name, taskId, createGitBranch, projectPath, customBaseBranch, useLocalBranch }); // Validate projectPath against registered projects if (!isValidProjectPath(projectPath)) { @@ -418,8 +418,13 @@ async function createTerminalWorktree( // Already a remote ref, use as-is baseRef = baseBranch; debugLog('[TerminalWorktree] Using remote ref directly:', baseRef); + } else if (useLocalBranch) { + // User explicitly requested local branch - skip auto-switch to remote + // This preserves gitignored files (.env, configs) that may not exist on remote + baseRef = baseBranch; + debugLog('[TerminalWorktree] Using local branch (explicit):', baseRef); } else { - // Check if remote version exists and use it for latest code + // Default behavior: check if remote version exists and use it for latest code try { execFileSync(getToolPath('git'), ['rev-parse', '--verify', `origin/${baseBranch}`], { cwd: projectPath, diff --git a/apps/frontend/src/preload/api/project-api.ts b/apps/frontend/src/preload/api/project-api.ts index a1725c22..b37face3 100644 --- a/apps/frontend/src/preload/api/project-api.ts +++ b/apps/frontend/src/preload/api/project-api.ts @@ -12,7 +12,8 @@ import type { GraphitiValidationResult, GraphitiConnectionTestResult, GitStatus, - KanbanPreferences + KanbanPreferences, + GitBranchDetail } from '../../shared/types'; // Tab state interface (persisted in main process) @@ -97,7 +98,10 @@ export interface ProjectAPI { }) => void) => () => void; // Git Operations + /** @deprecated Use getGitBranchesWithInfo for structured branch data with type indicators */ getGitBranches: (projectPath: string) => Promise>; + /** Get branches with structured type information (local vs remote) */ + getGitBranchesWithInfo: (projectPath: string) => Promise>; getCurrentGitBranch: (projectPath: string) => Promise>; detectMainBranch: (projectPath: string) => Promise>; checkGitStatus: (projectPath: string) => Promise>; @@ -277,6 +281,9 @@ export const createProjectAPI = (): ProjectAPI => ({ getGitBranches: (projectPath: string): Promise> => ipcRenderer.invoke(IPC_CHANNELS.GIT_GET_BRANCHES, projectPath), + getGitBranchesWithInfo: (projectPath: string): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.GIT_GET_BRANCHES_WITH_INFO, projectPath), + getCurrentGitBranch: (projectPath: string): Promise> => ipcRenderer.invoke(IPC_CHANNELS.GIT_GET_CURRENT_BRANCH, projectPath), diff --git a/apps/frontend/src/renderer/components/TaskCreationWizard.tsx b/apps/frontend/src/renderer/components/TaskCreationWizard.tsx index 30a184e3..b597eab1 100644 --- a/apps/frontend/src/renderer/components/TaskCreationWizard.tsx +++ b/apps/frontend/src/renderer/components/TaskCreationWizard.tsx @@ -15,7 +15,7 @@ import { useTranslation } from 'react-i18next'; import { Loader2, ChevronDown, ChevronUp, RotateCcw, FolderTree, GitBranch, Info } from 'lucide-react'; import { Button } from './ui/button'; import { Label } from './ui/label'; -import { Combobox, type ComboboxOption } from './ui/combobox'; +import { Combobox } from './ui/combobox'; import { TaskModalLayout } from './task-form/TaskModalLayout'; import { TaskFormFields } from './task-form/TaskFormFields'; import { type FileReferenceData } from './task-form/useImageUpload'; @@ -23,8 +23,9 @@ import { TaskFileExplorerDrawer } from './TaskFileExplorerDrawer'; import { FileAutocomplete } from './FileAutocomplete'; import { createTask, saveDraft, loadDraft, clearDraft, isDraftEmpty } from '../stores/task-store'; import { useProjectStore } from '../stores/project-store'; +import { buildBranchOptions } from '../lib/branch-utils'; import { cn } from '../lib/utils'; -import type { TaskCategory, TaskPriority, TaskComplexity, TaskImpact, TaskMetadata, ImageAttachment, TaskDraft, ModelType, ThinkingLevel, ReferencedFile } from '../../shared/types'; +import type { TaskCategory, TaskPriority, TaskComplexity, TaskImpact, TaskMetadata, ImageAttachment, TaskDraft, ModelType, ThinkingLevel, ReferencedFile, GitBranchDetail } from '../../shared/types'; import type { PhaseModelConfig, PhaseThinkingConfig } from '../../shared/types/settings'; import { DEFAULT_AGENT_PROFILES, @@ -62,8 +63,8 @@ export function TaskCreationWizard({ const [showFileExplorer, setShowFileExplorer] = useState(false); const [showGitOptions, setShowGitOptions] = useState(false); - // Git options state - const [branches, setBranches] = useState([]); + // Git options state - using structured GitBranchDetail for type indicators + const [branches, setBranches] = useState([]); const [isLoadingBranches, setIsLoadingBranches] = useState(false); const [baseBranch, setBaseBranch] = useState(PROJECT_DEFAULT_BRANCH); const [projectDefaultBranch, setProjectDefaultBranch] = useState(''); @@ -77,22 +78,27 @@ export function TaskCreationWizard({ return project?.path ?? null; }, [projects, projectId]); - // Convert branches to ComboboxOption[] format for searchable dropdown - const branchOptions: ComboboxOption[] = useMemo(() => { - const options: ComboboxOption[] = [ - { + // Build branch options using shared utility - groups by local/remote with type indicators + const branchOptions = useMemo(() => { + return buildBranchOptions(branches, { + t, + includeProjectDefault: { value: PROJECT_DEFAULT_BRANCH, - label: projectDefaultBranch - ? t('tasks:wizard.gitOptions.useProjectDefaultWithBranch', { branch: projectDefaultBranch }) - : t('tasks:wizard.gitOptions.useProjectDefault') - } - ]; - branches.forEach((branch) => { - options.push({ value: branch, label: branch }); + branchName: projectDefaultBranch, + labelKey: projectDefaultBranch + ? 'tasks:wizard.gitOptions.useProjectDefaultWithBranch' + : 'tasks:wizard.gitOptions.useProjectDefault', + }, }); - return options; }, [branches, projectDefaultBranch, t]); + // Determine if the selected branch is local (for useLocalBranch flag) + const isSelectedBranchLocal = useMemo(() => { + if (baseBranch === PROJECT_DEFAULT_BRANCH) return false; + const selectedGitBranchDetail = branches.find((b) => b.name === baseBranch); + return selectedGitBranchDetail?.type === 'local'; + }, [baseBranch, branches]); + // Classification fields const [category, setCategory] = useState(''); const [priority, setPriority] = useState(''); @@ -187,7 +193,7 @@ export function TaskCreationWizard({ } }, [open, projectId, settings.selectedAgentProfile, settings.customPhaseModels, settings.customPhaseThinking, selectedProfile.model, selectedProfile.thinkingLevel, selectedProfile.phaseModels, selectedProfile.phaseThinking]); - // Fetch branches when dialog opens + // Fetch branches when dialog opens - using structured branch data with type indicators useEffect(() => { let isMounted = true; @@ -195,7 +201,8 @@ export function TaskCreationWizard({ if (!projectPath) return; if (isMounted) setIsLoadingBranches(true); try { - const result = await window.electronAPI.getGitBranches(projectPath); + // Use structured branch data with type indicators + const result = await window.electronAPI.getGitBranchesWithInfo(projectPath); if (isMounted && result.success && result.data) { setBranches(result.data); } @@ -432,6 +439,9 @@ export function TaskCreationWizard({ } // Pass worktree preference - false means use --direct mode if (!useWorktree) metadata.useWorktree = false; + // Set useLocalBranch when user explicitly selects a local branch + // This preserves gitignored files (.env, configs) by not switching to origin + if (isSelectedBranchLocal) metadata.useLocalBranch = true; const task = await createTask(projectId, title.trim(), description.trim(), metadata); if (task) { diff --git a/apps/frontend/src/renderer/components/settings/integrations/GitHubIntegration.tsx b/apps/frontend/src/renderer/components/settings/integrations/GitHubIntegration.tsx index 9f4405fc..611bf97d 100644 --- a/apps/frontend/src/renderer/components/settings/integrations/GitHubIntegration.tsx +++ b/apps/frontend/src/renderer/components/settings/integrations/GitHubIntegration.tsx @@ -1,13 +1,16 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; import { Github, RefreshCw, KeyRound, Loader2, CheckCircle2, AlertCircle, User, Lock, Globe, ChevronDown, GitBranch } from 'lucide-react'; import { Input } from '../../ui/input'; import { Label } from '../../ui/label'; import { Switch } from '../../ui/switch'; import { Separator } from '../../ui/separator'; import { Button } from '../../ui/button'; +import { Combobox } from '../../ui/combobox'; import { GitHubOAuthFlow } from '../../project-settings/GitHubOAuthFlow'; import { PasswordInput } from '../../project-settings/PasswordInput'; -import type { ProjectEnvConfig, GitHubSyncStatus, ProjectSettings } from '../../../../shared/types'; +import { buildBranchOptions } from '../../../lib/branch-utils'; +import type { ProjectEnvConfig, GitHubSyncStatus, ProjectSettings, GitBranchDetail } from '../../../../shared/types'; // Debug logging const DEBUG = process.env.NODE_ENV === 'development' || process.env.DEBUG === 'true'; @@ -55,14 +58,15 @@ export function GitHubIntegration({ settings, setSettings }: GitHubIntegrationProps) { + const { t } = useTranslation(['settings', 'common']); const [authMode, setAuthMode] = useState<'manual' | 'oauth' | 'oauth-success'>('manual'); const [oauthUsername, setOauthUsername] = useState(null); const [repos, setRepos] = useState([]); const [isLoadingRepos, setIsLoadingRepos] = useState(false); const [reposError, setReposError] = useState(null); - // Branch selection state - const [branches, setBranches] = useState([]); + // Branch selection state - now uses GitBranchDetail for local/remote distinction + const [branches, setBranches] = useState([]); const [isLoadingBranches, setIsLoadingBranches] = useState(false); const [branchesError, setBranchesError] = useState(null); @@ -118,11 +122,11 @@ export function GitHubIntegration({ setBranchesError(null); try { - debugLog('fetchBranches: Calling getGitBranches...'); - const result = await window.electronAPI.getGitBranches(projectPath); - debugLog('fetchBranches: getGitBranches result:', { success: result.success, dataType: typeof result.data, dataLength: Array.isArray(result.data) ? result.data.length : 'N/A', error: result.error }); + debugLog('fetchBranches: Calling getGitBranchesWithInfo...'); + const result = await window.electronAPI.getGitBranchesWithInfo(projectPath); + debugLog('fetchBranches: getGitBranchesWithInfo result:', { success: result.success, dataType: typeof result.data, dataLength: Array.isArray(result.data) ? result.data.length : 'N/A', error: result.error }); - // result.data is the array directly (not { branches: [] }) + // result.data is the GitBranchDetail[] array if (result.success && result.data) { setBranches(result.data); debugLog('fetchBranches: Loaded branches:', result.data.length); @@ -173,6 +177,18 @@ export function GitHubIntegration({ } }; + // Build branch options for Combobox using shared utility + // Must be called before early return to satisfy React hooks rules + const branchOptions = useMemo(() => { + return buildBranchOptions(branches, { + t, + includeAutoDetect: { + value: '', + label: t('settings:integrations.github.defaultBranch.autoDetect'), + }, + }); + }, [branches, t]); + if (!envConfig) { debugLog('No envConfig, returning null'); return null; @@ -204,6 +220,9 @@ export function GitHubIntegration({ updateEnvConfig({ githubRepo: repoFullName }); }; + // Selected branch for Combobox value + const selectedBranch = settings?.mainBranch || envConfig?.defaultBranch || ''; + return (
@@ -336,14 +355,56 @@ export function GitHubIntegration({ {/* Default Branch Selector */} {projectPath && ( - +
+
+
+
+ + +
+

+ {t('settings:integrations.github.defaultBranch.description')} +

+
+ +
+ + {branchesError && ( +
+ + {branchesError} +
+ )} + +
+ +
+ + {selectedBranch && ( +

+ {t('settings:integrations.github.defaultBranch.selectedBranchHelp', { branch: selectedBranch })} +

+ )} +
)} @@ -601,146 +662,3 @@ function AutoSyncToggle({ enabled, onToggle }: AutoSyncToggleProps) { ); } -interface BranchSelectorProps { - branches: string[]; - selectedBranch: string; - isLoading: boolean; - error: string | null; - onSelect: (branch: string) => void; - onRefresh: () => void; -} - -function BranchSelector({ - branches, - selectedBranch, - isLoading, - error, - onSelect, - onRefresh -}: BranchSelectorProps) { - const [isOpen, setIsOpen] = useState(false); - const [filter, setFilter] = useState(''); - - const filteredBranches = branches.filter(branch => - branch.toLowerCase().includes(filter.toLowerCase()) - ); - - return ( -
-
-
-
- - -
-

- Base branch for creating task worktrees -

-
- -
- - {error && ( -
- - {error} -
- )} - -
- - - {isOpen && !isLoading && ( -
- {/* Search filter */} -
- setFilter(e.target.value)} - className="h-8 text-sm" - autoFocus - /> -
- - {/* Auto-detect option */} - - - {/* Branch list */} -
- {filteredBranches.length === 0 ? ( -
- {filter ? 'No matching branches' : 'No branches found'} -
- ) : ( - filteredBranches.map((branch) => ( - - )) - )} -
-
- )} -
- - {selectedBranch && ( -

- All new tasks will branch from {selectedBranch} -

- )} -
- ); -} diff --git a/apps/frontend/src/renderer/components/terminal/CreateWorktreeDialog.tsx b/apps/frontend/src/renderer/components/terminal/CreateWorktreeDialog.tsx index 32ce51b9..6ef218cf 100644 --- a/apps/frontend/src/renderer/components/terminal/CreateWorktreeDialog.tsx +++ b/apps/frontend/src/renderer/components/terminal/CreateWorktreeDialog.tsx @@ -20,8 +20,9 @@ import { SelectTrigger, SelectValue, } from '../ui/select'; -import { Combobox, type ComboboxOption } from '../ui/combobox'; -import type { Task, TerminalWorktreeConfig } from '../../../shared/types'; +import { Combobox } from '../ui/combobox'; +import { buildBranchOptions } from '../../lib/branch-utils'; +import type { Task, TerminalWorktreeConfig, GitBranchDetail } from '../../../shared/types'; import { useProjectStore } from '../../stores/project-store'; // Special value to represent "use project default" since Radix UI Select doesn't allow empty string values @@ -94,8 +95,8 @@ export function CreateWorktreeDialog({ state.projects.find((p) => p.path === projectPath) ); - // Branch selection state - const [branches, setBranches] = useState([]); + // Branch selection state - using structured GitBranchDetail for type indicators + const [branches, setBranches] = useState([]); const [isLoadingBranches, setIsLoadingBranches] = useState(false); const [baseBranch, setBaseBranch] = useState(PROJECT_DEFAULT_BRANCH); const [projectDefaultBranch, setProjectDefaultBranch] = useState(''); @@ -115,7 +116,8 @@ export function CreateWorktreeDialog({ const fetchBranches = async () => { setIsLoadingBranches(true); try { - const result = await window.electronAPI.getGitBranches(projectPath); + // Use structured branch data with type indicators + const result = await window.electronAPI.getGitBranchesWithInfo(projectPath); if (!isMounted) return; if (result.success && result.data) { @@ -176,6 +178,13 @@ export function CreateWorktreeDialog({ } }, [backlogTasks, name]); + // Determine if the selected branch is local (for useLocalBranch flag) + const isSelectedBranchLocal = useMemo(() => { + if (baseBranch === PROJECT_DEFAULT_BRANCH) return false; + const selectedGitBranchDetail = branches.find((b) => b.name === baseBranch); + return selectedGitBranchDetail?.type === 'local'; + }, [baseBranch, branches]); + const handleCreate = async () => { // Final sanitization: trim trailing hyphens/underscores for submission const finalName = sanitizeWorktreeName(name, undefined, true); @@ -204,6 +213,9 @@ export function CreateWorktreeDialog({ projectPath, // Only include baseBranch if not using project default baseBranch: baseBranch !== PROJECT_DEFAULT_BRANCH ? baseBranch : undefined, + // Set useLocalBranch when user explicitly selects a local branch + // This preserves gitignored files (.env, configs) by not switching to origin + useLocalBranch: isSelectedBranchLocal, }); if (result.success && result.config) { @@ -235,26 +247,16 @@ export function CreateWorktreeDialog({ onOpenChange(newOpen); }; - // Memoized branch options for the Combobox - const branchOptions: ComboboxOption[] = useMemo(() => { - const regularBranchOptions = branches - .filter((b) => b !== projectDefaultBranch) - .map((branch) => ({ value: branch, label: branch })); - - const options: ComboboxOption[] = [ - { + // Build branch options using shared utility - groups by local/remote with type indicators + const branchOptions = useMemo(() => { + return buildBranchOptions(branches, { + t, + includeProjectDefault: { value: PROJECT_DEFAULT_BRANCH, - label: t('terminal:worktree.useProjectDefault', { branch: projectDefaultBranch || 'main' }), + branchName: projectDefaultBranch || 'main', + labelKey: 'terminal:worktree.useProjectDefault', }, - ...regularBranchOptions, - ]; - - // If the project default branch is not in the list of existing branches, add it as a selectable option - if (projectDefaultBranch && !branches.includes(projectDefaultBranch)) { - options.push({ value: projectDefaultBranch, label: projectDefaultBranch }); - } - - return options; + }); }, [branches, projectDefaultBranch, t]); return ( diff --git a/apps/frontend/src/renderer/components/ui/combobox.tsx b/apps/frontend/src/renderer/components/ui/combobox.tsx index 41dea671..43299efe 100644 --- a/apps/frontend/src/renderer/components/ui/combobox.tsx +++ b/apps/frontend/src/renderer/components/ui/combobox.tsx @@ -8,6 +8,12 @@ export interface ComboboxOption { value: string; label: string; description?: string; + /** Optional group name for grouping options (e.g., "Local Branches", "Remote Branches") */ + group?: string; + /** Optional icon to display before the label */ + icon?: React.ReactNode; + /** Optional badge to display after the label */ + badge?: React.ReactNode; } interface ComboboxProps { @@ -176,8 +182,14 @@ const Combobox = React.forwardRef( className )} > - - {displayValue} + + {selectedOption?.icon && ( + {selectedOption.icon} + )} + {displayValue} + {selectedOption?.badge && ( + {selectedOption.badge} + )} @@ -217,36 +229,64 @@ const Combobox = React.forwardRef( {emptyMessage}
) : ( - filteredOptions.map((option, index) => ( - - )) + filteredOptions.map((option, index) => { + // Check if we need to render a group header + const prevOption = index > 0 ? filteredOptions[index - 1] : null; + const showGroupHeader = option.group && option.group !== prevOption?.group; + + return ( + + {/* Group header */} + {showGroupHeader && ( +
0 && 'mt-1 border-t border-border pt-2' + )} + > + {option.group} +
+ )} + {/* Option item */} + +
+ ); + }) )}
diff --git a/apps/frontend/src/renderer/lib/branch-utils.tsx b/apps/frontend/src/renderer/lib/branch-utils.tsx new file mode 100644 index 00000000..c90589b8 --- /dev/null +++ b/apps/frontend/src/renderer/lib/branch-utils.tsx @@ -0,0 +1,119 @@ +/** + * Shared utilities for branch selection across the application. + * Used by TaskCreationWizard, CreateWorktreeDialog, and GitHubIntegration. + */ +import { GitBranch, Cloud } from 'lucide-react'; +import type { ComboboxOption } from '../components/ui/combobox'; +import type { GitBranchDetail } from '../../shared/types'; +import { cn } from './utils'; + +// Badge styling constants for branch type indicators +const BADGE_BASE_CLASSES = 'text-xs px-1.5 py-0.5 rounded'; +const LOCAL_BADGE_CLASSES = 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'; +const REMOTE_BADGE_CLASSES = 'bg-blue-500/10 text-blue-600 dark:text-blue-400'; + +/** + * Configuration for building branch options + */ +export interface BranchOptionsConfig { + /** Translation function (must have 'common' namespace loaded for git.branchGroups/branchType) */ + t: (key: string, options?: Record) => string; + /** Optional: Include a "use project default" option at the top */ + includeProjectDefault?: { + /** The special value to use for the project default option */ + value: string; + /** The name of the project's default branch (e.g., 'develop') */ + branchName: string; + /** Translation key for the label (will receive { branch } interpolation) */ + labelKey: string; + }; + /** Optional: Include an "auto-detect" option (used in GitHub settings) */ + includeAutoDetect?: { + /** The value to use for auto-detect (usually empty string) */ + value: string; + /** The label to display */ + label: string; + }; +} + +/** + * Builds ComboboxOption[] from GitBranchDetail[] with proper grouping, icons, and badges. + * This shared function ensures consistent branch display across all branch selectors. + */ +export function buildBranchOptions( + branches: GitBranchDetail[], + config: BranchOptionsConfig +): ComboboxOption[] { + const { t, includeProjectDefault, includeAutoDetect } = config; + + // Separate local and remote branches + const localBranches = branches.filter((b) => b.type === 'local'); + const remoteBranches = branches.filter((b) => b.type === 'remote'); + + // Build local branch options + const localOptions: ComboboxOption[] = localBranches.map((branch) => ({ + value: branch.name, + label: branch.displayName, + group: t('common:git.branchGroups.local'), + icon: , + badge: ( + + {t('common:git.branchType.local')} + + ), + })); + + // Build remote branch options + const remoteOptions: ComboboxOption[] = remoteBranches.map((branch) => ({ + value: branch.name, + label: branch.displayName, + group: t('common:git.branchGroups.remote'), + icon: , + badge: ( + + {t('common:git.branchType.remote')} + + ), + })); + + // Build final options array + const options: ComboboxOption[] = []; + + // Add auto-detect option if configured (for GitHub settings) + if (includeAutoDetect) { + options.push({ + value: includeAutoDetect.value, + label: includeAutoDetect.label, + }); + } + + // Add project default option if configured (for task creation and worktree dialogs) + if (includeProjectDefault) { + const { value, branchName, labelKey } = includeProjectDefault; + + // Determine if project default branch is local or remote + const defaultBranchInfo = branches.find((b) => b.name === branchName); + const isDefaultLocal = defaultBranchInfo?.type === 'local'; + + options.push({ + value, + label: t(labelKey, { branch: branchName }), + icon: isDefaultLocal ? : , + badge: defaultBranchInfo ? ( + + {isDefaultLocal + ? t('common:git.branchType.local') + : t('common:git.branchType.remote')} + + ) : undefined, + }); + } + + // Add local branches, then remote branches + options.push(...localOptions, ...remoteOptions); + + return options; +} diff --git a/apps/frontend/src/renderer/lib/mocks/project-mock.ts b/apps/frontend/src/renderer/lib/mocks/project-mock.ts index 820b8f30..153600e0 100644 --- a/apps/frontend/src/renderer/lib/mocks/project-mock.ts +++ b/apps/frontend/src/renderer/lib/mocks/project-mock.ts @@ -92,6 +92,17 @@ export const projectMock = { data: ['main', 'develop', 'feature/test'] }), + getGitBranchesWithInfo: async () => ({ + success: true, + data: [ + { name: 'main', type: 'local' as const, displayName: 'main', isCurrent: true }, + { name: 'develop', type: 'local' as const, displayName: 'develop', isCurrent: false }, + { name: 'feature/test', type: 'local' as const, displayName: 'feature/test', isCurrent: false }, + { name: 'origin/main', type: 'remote' as const, displayName: 'origin/main', isCurrent: false }, + { name: 'origin/develop', type: 'remote' as const, displayName: 'origin/develop', isCurrent: false } + ] + }), + getCurrentGitBranch: async () => ({ success: true, data: 'main' diff --git a/apps/frontend/src/shared/constants/ipc.ts b/apps/frontend/src/shared/constants/ipc.ts index f0ad45dc..03ff9098 100644 --- a/apps/frontend/src/shared/constants/ipc.ts +++ b/apps/frontend/src/shared/constants/ipc.ts @@ -502,6 +502,7 @@ export const IPC_CHANNELS = { // Git operations GIT_GET_BRANCHES: 'git:getBranches', + GIT_GET_BRANCHES_WITH_INFO: 'git:getBranchesWithInfo', GIT_GET_CURRENT_BRANCH: 'git:getCurrentBranch', GIT_DETECT_MAIN_BRANCH: 'git:detectMainBranch', GIT_CHECK_STATUS: 'git:checkStatus', diff --git a/apps/frontend/src/shared/i18n/locales/en/common.json b/apps/frontend/src/shared/i18n/locales/en/common.json index eb4c801f..3187d29b 100644 --- a/apps/frontend/src/shared/i18n/locales/en/common.json +++ b/apps/frontend/src/shared/i18n/locales/en/common.json @@ -631,6 +631,16 @@ "goToSettings": "Go to Settings" } }, + "git": { + "branchGroups": { + "local": "Local Branches", + "remote": "Remote Branches" + }, + "branchType": { + "local": "Local", + "remote": "Remote" + } + }, "roadmapProgress": { "elapsedTime": "Elapsed", "lastActivity": "Last activity", diff --git a/apps/frontend/src/shared/i18n/locales/en/settings.json b/apps/frontend/src/shared/i18n/locales/en/settings.json index e2e74a47..52965895 100644 --- a/apps/frontend/src/shared/i18n/locales/en/settings.json +++ b/apps/frontend/src/shared/i18n/locales/en/settings.json @@ -344,7 +344,15 @@ "description": "GitHub issues sync", "integrationTitle": "GitHub Integration", "integrationDescription": "Connect to GitHub for issue tracking", - "syncDescription": "Sync with GitHub Issues" + "syncDescription": "Sync with GitHub Issues", + "defaultBranch": { + "label": "Default Branch", + "description": "Base branch for creating task worktrees", + "autoDetect": "Auto-detect (main/master)", + "searchPlaceholder": "Search branches...", + "noBranchesFound": "No branches found", + "selectedBranchHelp": "All new tasks will branch from {{branch}}" + } }, "gitlab": { "title": "GitLab", diff --git a/apps/frontend/src/shared/i18n/locales/fr/common.json b/apps/frontend/src/shared/i18n/locales/fr/common.json index aa1f8789..f5cc8f61 100644 --- a/apps/frontend/src/shared/i18n/locales/fr/common.json +++ b/apps/frontend/src/shared/i18n/locales/fr/common.json @@ -631,6 +631,16 @@ "goToSettings": "Aller aux paramètres" } }, + "git": { + "branchGroups": { + "local": "Branches Locales", + "remote": "Branches Distantes" + }, + "branchType": { + "local": "Locale", + "remote": "Distante" + } + }, "roadmapProgress": { "elapsedTime": "Écoulé", "lastActivity": "Dernière activité", diff --git a/apps/frontend/src/shared/i18n/locales/fr/settings.json b/apps/frontend/src/shared/i18n/locales/fr/settings.json index 9d9748b7..f224937b 100644 --- a/apps/frontend/src/shared/i18n/locales/fr/settings.json +++ b/apps/frontend/src/shared/i18n/locales/fr/settings.json @@ -344,7 +344,15 @@ "description": "Synchronisation issues GitHub", "integrationTitle": "Intégration GitHub", "integrationDescription": "Se connecter à GitHub pour le suivi des issues", - "syncDescription": "Synchroniser avec GitHub Issues" + "syncDescription": "Synchroniser avec GitHub Issues", + "defaultBranch": { + "label": "Branche par défaut", + "description": "Branche de base pour créer les worktrees de tâches", + "autoDetect": "Détection automatique (main/master)", + "searchPlaceholder": "Rechercher des branches...", + "noBranchesFound": "Aucune branche trouvée", + "selectedBranchHelp": "Toutes les nouvelles tâches partiront de {{branch}}" + } }, "gitlab": { "title": "GitLab", diff --git a/apps/frontend/src/shared/types/ipc.ts b/apps/frontend/src/shared/types/ipc.ts index fe1b1ec5..ce48120b 100644 --- a/apps/frontend/src/shared/types/ipc.ts +++ b/apps/frontend/src/shared/types/ipc.ts @@ -140,6 +140,34 @@ import type { } from './integrations'; import type { APIProfile, ProfilesFile, TestConnectionResult, DiscoverModelsResult } from './profile'; +// ============================================ +// Branch Types +// ============================================ + +/** + * Branch type indicator for distinguishing local from remote branches + */ +export type GitBranchType = 'local' | 'remote'; + +/** + * Structured branch information for UI display with type indicators + * Used in branch selection dropdowns to distinguish local vs remote branches + */ +export interface GitBranchDetail { + /** The branch name (e.g., 'main', 'origin/main') */ + name: string; + /** Whether this is a local or remote branch */ + type: GitBranchType; + /** Display name for UI (e.g., 'main' for local, 'origin/main' for remote) */ + displayName: string; + /** Whether this is the currently checked out branch */ + isCurrent?: boolean; +} + +// ============================================ +// Electron API +// ============================================ + // Electron API exposed via contextBridge // Tab state interface (persisted in main process) export interface TabState { @@ -787,7 +815,10 @@ export interface ElectronAPI { readFile: (filePath: string) => Promise>; // Git operations + /** @deprecated Will return GitBranchDetail[] in future - see getGitBranchesWithInfo */ getGitBranches: (projectPath: string) => Promise>; + /** Get branches with structured type information (local vs remote) */ + getGitBranchesWithInfo: (projectPath: string) => Promise>; getCurrentGitBranch: (projectPath: string) => Promise>; detectMainBranch: (projectPath: string) => Promise>; checkGitStatus: (projectPath: string) => Promise>; diff --git a/apps/frontend/src/shared/types/task.ts b/apps/frontend/src/shared/types/task.ts index bb5e5aa2..742c3227 100644 --- a/apps/frontend/src/shared/types/task.ts +++ b/apps/frontend/src/shared/types/task.ts @@ -237,6 +237,7 @@ export interface TaskMetadata { baseBranch?: string; // Override base branch for this task's worktree prUrl?: string; // GitHub PR URL if task has been submitted as a PR useWorktree?: boolean; // If false, use direct mode (no worktree isolation) - default is true for safety + useLocalBranch?: boolean; // If true, use the local branch directly instead of preferring origin/branch (preserves gitignored files) // Archive status archivedAt?: string; // ISO date when task was archived diff --git a/apps/frontend/src/shared/types/terminal.ts b/apps/frontend/src/shared/types/terminal.ts index 0e921353..ff30f451 100644 --- a/apps/frontend/src/shared/types/terminal.ts +++ b/apps/frontend/src/shared/types/terminal.ts @@ -237,6 +237,12 @@ export interface CreateTerminalWorktreeRequest { projectPath: string; /** Optional base branch to create worktree from (defaults to project default) */ baseBranch?: string; + /** + * When true, use the local branch directly without auto-switching to remote. + * This preserves gitignored files (.env, configs) that may not exist on remote. + * When false or undefined, the default behavior prefers origin/branch if it exists. + */ + useLocalBranch?: boolean; } /**