Clarify Local and Origin Branch Distinction (#1652)

* auto-claude: subtask-1-1 - Add BranchInfo type to shared types for structured branch data

- Add BranchType union type ('local' | 'remote') for branch classification
- Add BranchInfo interface with name, type, displayName, and optional isCurrent
- Add getGitBranchesWithInfo API method to ElectronAPI interface
- Keep existing getGitBranches for backward compatibility during migration
- Add mock implementation for browser testing

* auto-claude: subtask-2-1 - Update getGitBranches() to return structured BranchInfo[]

- Add new getGitBranchesWithInfo() function that returns BranchInfo[] with type indicators (local/remote)
- Keep both local and remote versions when a branch exists in both places (no deduplication)
- Add isCurrent indicator for the currently checked out branch
- Register new IPC handler GIT_GET_BRANCHES_WITH_INFO for the new function
- Keep existing getGitBranches() for backward compatibility (marked deprecated)
- Update preload API to expose the new method to renderer

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* auto-claude: subtask-2-2 - Add useLocalBranch option to worktree creation

Added useLocalBranch?: boolean option to CreateTerminalWorktreeRequest type.
When true, the worktree creation logic skips auto-switching from local branch
to origin/branch, allowing users to preserve gitignored files (.env, configs)
that may not exist on remote branches.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* auto-claude: subtask-3-1 - Add English translations for branch type labels

Added i18n keys for branch type labels and group headers:
- terminal.json: worktree.branchGroups.local/remote, worktree.branchType.local/remote
- tasks.json: wizard.gitOptions.branchGroups.local/remote, wizard.gitOptions.branchType.local/remote

These translations will be used to display visual indicators distinguishing
local branches from remote branches in branch selection dropdowns.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* auto-claude: subtask-3-2 - Add French translations for branch type labels and group headers

Added French translations for:
- terminal.json: worktree.branchGroups.local/remote, worktree.branchType.local/remote
- tasks.json: wizard.gitOptions.branchGroups.local/remote, wizard.gitOptions.branchType.local/remote

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* auto-claude: subtask-4-1 - Extend Combobox component to support option groups

- Add 'group' property to ComboboxOption for grouping options by category
- Add 'icon' property for displaying icons before the label (e.g., GitBranch)
- Add 'badge' property for displaying badges after the label
- Render group headers with visual separation when consecutive options have different groups
- Display icon and badge in trigger button when option is selected
- Maintain keyboard navigation across groups

This enables branch selection dropdowns to group by Local/Remote branches
with visual type indicators.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* auto-claude: subtask-4-2 - Update CreateWorktreeDialog to use grouped branch options

- Switch from getGitBranches to getGitBranchesWithInfo for structured branch data
- Group branches by type (Local Branches, Remote Branches) in dropdown
- Add icons (GitBranch for local, Cloud for remote) to branch options
- Add colored badges (green for local, blue for remote) as type indicators
- Pass useLocalBranch flag when creating worktree from a local branch
- This preserves gitignored files (.env, configs) when using local branches

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* auto-claude: subtask-4-3 - Update TaskCreationWizard to use grouped branch options

- Switch from getGitBranches to getGitBranchesWithInfo for structured BranchInfo[] data
- Group branches by type (Local Branches, Remote Branches) with visual headers
- Add icons (GitBranch for local, Cloud for remote) to each branch option
- Add colored badges (green for local, blue for remote) as type indicators
- Add isSelectedBranchLocal memo to track if selected branch is local
- Pass useLocalBranch: true when creating task from local branch to preserve gitignored files
- Add useLocalBranch field to TaskMetadata type

* auto-claude: subtask-5 - Consolidate branch selection with shared utility

- Create buildBranchOptions() utility in branch-utils.tsx for consistent
  branch display across all branch selectors
- Refactor TaskCreationWizard to use shared utility (~75 lines removed)
- Refactor CreateWorktreeDialog to use shared utility (~75 lines removed)
- Update GitHubIntegration to use Combobox with shared utility instead of
  custom BranchSelector component (~140 lines removed)
- Add translations for GitHub settings branch selector (en/fr)
- Fix: Local default branch now appears in dropdown (was filtered out)
- Fix: "Use project default" option now shows Local/Remote badge

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* docs: update README to v2.7.6-beta.1 [skip ci]

* fix: address all PR review findings for branch distinction feature

- Remove unused exports (createBranchTypeBadge, getBranchIcon) from branch-utils
- Extract badge styling constants (BADGE_BASE_CLASSES, LOCAL/REMOTE_BADGE_CLASSES)
- Thread useLocalBranch flag from frontend through backend worktree creation
- Consolidate branch group/type i18n keys into common.json namespace
- Rename BranchType → GitBranchType, BranchInfo → GitBranchDetail for consistency
- Fix incorrect GitBranchInfo → GitBranchDetail rename in changelog API type

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: trailing commas in JSON and unsorted Python imports

- Remove trailing commas in settings.json and tasks.json (en/fr) that
  were left after removing branchGroups/branchType sections
- Fix ruff I001 import sorting in build_commands.py

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* style: apply ruff format to setup.py and worktree.py

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Test User <test@example.com>
This commit is contained in:
Andy
2026-02-04 11:21:35 +01:00
committed by GitHub
parent 4730206214
commit 9317148b6b
24 changed files with 615 additions and 264 deletions
+7 -7
View File
@@ -35,18 +35,18 @@
> ⚠️ Beta releases may contain bugs and breaking changes. [View all releases](https://github.com/AndyMik90/Auto-Claude/releases)
<!-- BETA_VERSION_BADGE -->
[![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)
<!-- BETA_VERSION_BADGE_END -->
<!-- BETA_DOWNLOADS -->
| 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) |
<!-- BETA_DOWNLOADS_END -->
> All releases include SHA256 checksums and VirusTotal scan results for security verification.
+8 -1
View File
@@ -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:
+5 -1
View File
@@ -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
+21 -10
View File
@@ -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(
+25
View File
@@ -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
+2
View File
@@ -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 {
@@ -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<IPCResult<string[]>> => {
@@ -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<IPCResult<GitBranchDetail[]>> => {
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,
@@ -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
);
@@ -345,9 +345,9 @@ function loadWorktreeConfig(projectPath: string, name: string): TerminalWorktree
async function createTerminalWorktree(
request: CreateTerminalWorktreeRequest
): Promise<TerminalWorktreeResult> {
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,
+8 -1
View File
@@ -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<IPCResult<string[]>>;
/** Get branches with structured type information (local vs remote) */
getGitBranchesWithInfo: (projectPath: string) => Promise<IPCResult<GitBranchDetail[]>>;
getCurrentGitBranch: (projectPath: string) => Promise<IPCResult<string | null>>;
detectMainBranch: (projectPath: string) => Promise<IPCResult<string | null>>;
checkGitStatus: (projectPath: string) => Promise<IPCResult<GitStatus>>;
@@ -277,6 +281,9 @@ export const createProjectAPI = (): ProjectAPI => ({
getGitBranches: (projectPath: string): Promise<IPCResult<string[]>> =>
ipcRenderer.invoke(IPC_CHANNELS.GIT_GET_BRANCHES, projectPath),
getGitBranchesWithInfo: (projectPath: string): Promise<IPCResult<GitBranchDetail[]>> =>
ipcRenderer.invoke(IPC_CHANNELS.GIT_GET_BRANCHES_WITH_INFO, projectPath),
getCurrentGitBranch: (projectPath: string): Promise<IPCResult<string | null>> =>
ipcRenderer.invoke(IPC_CHANNELS.GIT_GET_CURRENT_BRANCH, projectPath),
@@ -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<string[]>([]);
// Git options state - using structured GitBranchDetail for type indicators
const [branches, setBranches] = useState<GitBranchDetail[]>([]);
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
const [baseBranch, setBaseBranch] = useState<string>(PROJECT_DEFAULT_BRANCH);
const [projectDefaultBranch, setProjectDefaultBranch] = useState<string>('');
@@ -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<TaskCategory | ''>('');
const [priority, setPriority] = useState<TaskPriority | ''>('');
@@ -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) {
@@ -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<string | null>(null);
const [repos, setRepos] = useState<GitHubRepo[]>([]);
const [isLoadingRepos, setIsLoadingRepos] = useState(false);
const [reposError, setReposError] = useState<string | null>(null);
// Branch selection state
const [branches, setBranches] = useState<string[]>([]);
// Branch selection state - now uses GitBranchDetail for local/remote distinction
const [branches, setBranches] = useState<GitBranchDetail[]>([]);
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
const [branchesError, setBranchesError] = useState<string | null>(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 (
<div className="space-y-4">
<div className="flex items-center justify-between">
@@ -336,14 +355,56 @@ export function GitHubIntegration({
{/* Default Branch Selector */}
{projectPath && (
<BranchSelector
branches={branches}
selectedBranch={settings?.mainBranch || envConfig.defaultBranch || ''}
isLoading={isLoadingBranches}
error={branchesError}
onSelect={handleBranchChange}
onRefresh={fetchBranches}
/>
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<div className="flex items-center gap-2">
<GitBranch className="h-4 w-4 text-info" />
<Label className="text-sm font-medium text-foreground">
{t('settings:integrations.github.defaultBranch.label')}
</Label>
</div>
<p className="text-xs text-muted-foreground pl-6">
{t('settings:integrations.github.defaultBranch.description')}
</p>
</div>
<Button
variant="ghost"
size="sm"
onClick={fetchBranches}
disabled={isLoadingBranches}
className="h-7 px-2"
>
<RefreshCw className={`h-3 w-3 ${isLoadingBranches ? 'animate-spin' : ''}`} />
</Button>
</div>
{branchesError && (
<div className="flex items-center gap-2 text-xs text-destructive pl-6">
<AlertCircle className="h-3 w-3" />
{branchesError}
</div>
)}
<div className="pl-6">
<Combobox
options={branchOptions}
value={selectedBranch}
onValueChange={handleBranchChange}
placeholder={t('settings:integrations.github.defaultBranch.autoDetect')}
searchPlaceholder={t('settings:integrations.github.defaultBranch.searchPlaceholder')}
emptyMessage={t('settings:integrations.github.defaultBranch.noBranchesFound')}
disabled={isLoadingBranches}
className="w-full"
/>
</div>
{selectedBranch && (
<p className="text-xs text-muted-foreground pl-6">
{t('settings:integrations.github.defaultBranch.selectedBranchHelp', { branch: selectedBranch })}
</p>
)}
</div>
)}
<Separator />
@@ -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 (
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<div className="flex items-center gap-2">
<GitBranch className="h-4 w-4 text-info" />
<Label className="text-sm font-medium text-foreground">Default Branch</Label>
</div>
<p className="text-xs text-muted-foreground pl-6">
Base branch for creating task worktrees
</p>
</div>
<Button
variant="ghost"
size="sm"
onClick={onRefresh}
disabled={isLoading}
className="h-7 px-2"
>
<RefreshCw className={`h-3 w-3 ${isLoading ? 'animate-spin' : ''}`} />
</Button>
</div>
{error && (
<div className="flex items-center gap-2 text-xs text-destructive pl-6">
<AlertCircle className="h-3 w-3" />
{error}
</div>
)}
<div className="relative pl-6">
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
disabled={isLoading}
className="w-full flex items-center justify-between px-3 py-2 text-sm border border-input rounded-md bg-background hover:bg-accent hover:text-accent-foreground disabled:opacity-50"
>
{isLoading ? (
<span className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Loading branches...
</span>
) : selectedBranch ? (
<span className="flex items-center gap-2">
<GitBranch className="h-3 w-3 text-muted-foreground" />
{selectedBranch}
</span>
) : (
<span className="text-muted-foreground">Auto-detect (main/master)</span>
)}
<ChevronDown className={`h-4 w-4 text-muted-foreground transition-transform ${isOpen ? 'rotate-180' : ''}`} />
</button>
{isOpen && !isLoading && (
<div className="absolute z-50 w-full mt-1 bg-popover border border-border rounded-md shadow-lg max-h-64 overflow-hidden">
{/* Search filter */}
<div className="p-2 border-b border-border">
<Input
placeholder="Search branches..."
value={filter}
onChange={(e) => setFilter(e.target.value)}
className="h-8 text-sm"
autoFocus
/>
</div>
{/* Auto-detect option */}
<button
type="button"
onClick={() => {
onSelect('');
setIsOpen(false);
setFilter('');
}}
className={`w-full px-3 py-2 text-left hover:bg-accent flex items-center gap-2 ${
!selectedBranch ? 'bg-accent' : ''
}`}
>
<span className="text-sm text-muted-foreground italic">Auto-detect (main/master)</span>
</button>
{/* Branch list */}
<div className="max-h-40 overflow-y-auto border-t border-border">
{filteredBranches.length === 0 ? (
<div className="px-3 py-4 text-sm text-muted-foreground text-center">
{filter ? 'No matching branches' : 'No branches found'}
</div>
) : (
filteredBranches.map((branch) => (
<button
key={branch}
type="button"
onClick={() => {
onSelect(branch);
setIsOpen(false);
setFilter('');
}}
className={`w-full px-3 py-2 text-left hover:bg-accent flex items-center gap-2 ${
branch === selectedBranch ? 'bg-accent' : ''
}`}
>
<GitBranch className="h-3 w-3 text-muted-foreground" />
<span className="text-sm">{branch}</span>
</button>
))
)}
</div>
</div>
)}
</div>
{selectedBranch && (
<p className="text-xs text-muted-foreground pl-6">
All new tasks will branch from <code className="px-1 bg-muted rounded">{selectedBranch}</code>
</p>
)}
</div>
);
}
@@ -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<string[]>([]);
// Branch selection state - using structured GitBranchDetail for type indicators
const [branches, setBranches] = useState<GitBranchDetail[]>([]);
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
const [baseBranch, setBaseBranch] = useState<string>(PROJECT_DEFAULT_BRANCH);
const [projectDefaultBranch, setProjectDefaultBranch] = useState<string>('');
@@ -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 (
@@ -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<HTMLButtonElement, ComboboxProps>(
className
)}
>
<span className={cn('truncate', !selectedOption && 'text-muted-foreground')}>
{displayValue}
<span className={cn('flex items-center gap-2 truncate', !selectedOption && 'text-muted-foreground')}>
{selectedOption?.icon && (
<span className="shrink-0 text-muted-foreground">{selectedOption.icon}</span>
)}
<span className="truncate">{displayValue}</span>
{selectedOption?.badge && (
<span className="shrink-0">{selectedOption.badge}</span>
)}
</span>
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" />
</button>
@@ -217,36 +229,64 @@ const Combobox = React.forwardRef<HTMLButtonElement, ComboboxProps>(
{emptyMessage}
</div>
) : (
filteredOptions.map((option, index) => (
<button
key={option.value}
ref={(el) => {
if (el) {
optionRefs.current.set(index, el);
} else {
optionRefs.current.delete(index);
}
}}
id={getOptionId(index)}
type="button"
role="option"
aria-selected={value === option.value}
onClick={() => handleSelect(option.value)}
onMouseEnter={() => setFocusedIndex(index)}
className={cn(
'relative flex w-full cursor-default select-none items-center',
'rounded-md py-2 pl-8 pr-2 text-sm outline-none',
'hover:bg-accent hover:text-accent-foreground',
'transition-colors duration-150',
focusedIndex === index && 'bg-accent text-accent-foreground'
)}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
{value === option.value && <Check className="h-4 w-4 text-primary" />}
</span>
<span className="truncate">{option.label}</span>
</button>
))
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 (
<React.Fragment key={option.value}>
{/* Group header */}
{showGroupHeader && (
<div
role="presentation"
className={cn(
'px-2 py-1.5 text-xs font-semibold text-muted-foreground',
index > 0 && 'mt-1 border-t border-border pt-2'
)}
>
{option.group}
</div>
)}
{/* Option item */}
<button
ref={(el) => {
if (el) {
optionRefs.current.set(index, el);
} else {
optionRefs.current.delete(index);
}
}}
id={getOptionId(index)}
type="button"
role="option"
aria-selected={value === option.value}
onClick={() => handleSelect(option.value)}
onMouseEnter={() => setFocusedIndex(index)}
className={cn(
'relative flex w-full cursor-default select-none items-center',
'rounded-md py-2 pl-8 pr-2 text-sm outline-none',
'hover:bg-accent hover:text-accent-foreground',
'transition-colors duration-150',
focusedIndex === index && 'bg-accent text-accent-foreground'
)}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
{value === option.value && <Check className="h-4 w-4 text-primary" />}
</span>
<span className="flex flex-1 items-center gap-2 truncate">
{option.icon && (
<span className="shrink-0 text-muted-foreground">{option.icon}</span>
)}
<span className="truncate">{option.label}</span>
{option.badge && (
<span className="shrink-0">{option.badge}</span>
)}
</span>
</button>
</React.Fragment>
);
})
)}
</div>
</ScrollArea>
@@ -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, string>) => 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: <GitBranch className="h-3.5 w-3.5" />,
badge: (
<span className={cn(BADGE_BASE_CLASSES, LOCAL_BADGE_CLASSES)}>
{t('common:git.branchType.local')}
</span>
),
}));
// Build remote branch options
const remoteOptions: ComboboxOption[] = remoteBranches.map((branch) => ({
value: branch.name,
label: branch.displayName,
group: t('common:git.branchGroups.remote'),
icon: <Cloud className="h-3.5 w-3.5" />,
badge: (
<span className={cn(BADGE_BASE_CLASSES, REMOTE_BADGE_CLASSES)}>
{t('common:git.branchType.remote')}
</span>
),
}));
// 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 ? <GitBranch className="h-3.5 w-3.5" /> : <Cloud className="h-3.5 w-3.5" />,
badge: defaultBranchInfo ? (
<span className={cn(
BADGE_BASE_CLASSES,
isDefaultLocal ? LOCAL_BADGE_CLASSES : REMOTE_BADGE_CLASSES
)}>
{isDefaultLocal
? t('common:git.branchType.local')
: t('common:git.branchType.remote')}
</span>
) : undefined,
});
}
// Add local branches, then remote branches
options.push(...localOptions, ...remoteOptions);
return options;
}
@@ -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'
@@ -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',
@@ -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",
@@ -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",
@@ -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é",
@@ -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",
+31
View File
@@ -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<IPCResult<string>>;
// Git operations
/** @deprecated Will return GitBranchDetail[] in future - see getGitBranchesWithInfo */
getGitBranches: (projectPath: string) => Promise<IPCResult<string[]>>;
/** Get branches with structured type information (local vs remote) */
getGitBranchesWithInfo: (projectPath: string) => Promise<IPCResult<GitBranchDetail[]>>;
getCurrentGitBranch: (projectPath: string) => Promise<IPCResult<string | null>>;
detectMainBranch: (projectPath: string) => Promise<IPCResult<string | null>>;
checkGitStatus: (projectPath: string) => Promise<IPCResult<GitStatus>>;
+1
View File
@@ -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
@@ -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;
}
/**