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:
@@ -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 -->
|
||||
[](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.7.6-beta.2)
|
||||
[](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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+77
-159
@@ -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",
|
||||
|
||||
@@ -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>>;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user