From 219cc068b865f6630d8f938764f001e6bcd95e25 Mon Sep 17 00:00:00 2001 From: Andy <119136210+AndyMik90@users.noreply.github.com> Date: Fri, 16 Jan 2026 23:18:45 +0100 Subject: [PATCH] feat(terminal): add "Others" section to worktree dropdown (#1209) * feat(terminal): add "Others" section to worktree dropdown Add a third section to the terminal worktree dropdown that shows worktrees not managed by Auto Claude. This includes user-created worktrees via `git worktree add`, legacy worktrees, or worktrees from other tools. Changes: - Add OtherWorktreeInfo type for external worktrees - Add IPC handler using `git worktree list --porcelain` - Filter out Auto Claude managed paths (.auto-claude/worktrees/*) - Add "Others" section with purple GitFork icon - Add translations for en/fr locales Co-Authored-By: Claude Opus 4.5 * fix(terminal): address PR review feedback for "Others" worktree section - Use null instead of "detached" sentinel value to avoid collision with actual branch named "detached" - Add i18n translation key for "(detached)" text in en/fr locales - Convert execFileSync to async execFileAsync using promisify - Extract magic numbers (9, 5, 7, 8) to named GIT_PORCELAIN constants Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: Claude Opus 4.5 --- .../terminal/worktree-handlers.ts | 134 +++++++++++++++++- apps/frontend/src/preload/api/terminal-api.ts | 5 + .../components/terminal/WorktreeSelector.tsx | 64 ++++++++- .../frontend/src/renderer/lib/browser-mock.ts | 4 + apps/frontend/src/shared/constants/ipc.ts | 1 + .../src/shared/i18n/locales/en/terminal.json | 4 +- .../src/shared/i18n/locales/fr/terminal.json | 4 +- apps/frontend/src/shared/types/ipc.ts | 2 + apps/frontend/src/shared/types/terminal.ts | 15 ++ 9 files changed, 226 insertions(+), 7 deletions(-) diff --git a/apps/frontend/src/main/ipc-handlers/terminal/worktree-handlers.ts b/apps/frontend/src/main/ipc-handlers/terminal/worktree-handlers.ts index 2bf73d1a..508978de 100644 --- a/apps/frontend/src/main/ipc-handlers/terminal/worktree-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/terminal/worktree-handlers.ts @@ -5,10 +5,12 @@ import type { CreateTerminalWorktreeRequest, TerminalWorktreeConfig, TerminalWorktreeResult, + OtherWorktreeInfo, } from '../../../shared/types'; import path from 'path'; import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, rmSync, symlinkSync, lstatSync } from 'fs'; -import { execFileSync } from 'child_process'; +import { execFileSync, execFile } from 'child_process'; +import { promisify } from 'util'; import { minimatch } from 'minimatch'; import { debugLog, debugError } from '../../../shared/utils/debug-logger'; import { projectStore } from '../../project-store'; @@ -20,6 +22,9 @@ import { getTerminalWorktreeMetadataPath, } from '../../worktree-paths'; +// Promisify execFile for async operations +const execFileAsync = promisify(execFile); + // Shared validation regex for worktree names - lowercase alphanumeric with dashes/underscores // Must start and end with alphanumeric character const WORKTREE_NAME_REGEX = /^[a-z0-9][a-z0-9_-]*[a-z0-9]$|^[a-z0-9]$/; @@ -27,6 +32,15 @@ const WORKTREE_NAME_REGEX = /^[a-z0-9][a-z0-9_-]*[a-z0-9]$|^[a-z0-9]$/; // Validation regex for git branch names - allows alphanumeric, dots, slashes, dashes, underscores const GIT_BRANCH_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9._/-]*[a-zA-Z0-9]$|^[a-zA-Z0-9]$/; +// Git worktree list porcelain output parsing constants +const GIT_PORCELAIN = { + WORKTREE_PREFIX: 'worktree ', + HEAD_PREFIX: 'HEAD ', + BRANCH_PREFIX: 'branch ', + DETACHED_LINE: 'detached', + COMMIT_SHA_LENGTH: 8, +} as const; + /** * Fix repositories that are incorrectly marked with core.bare=true. * This can happen when git worktree operations incorrectly set bare=true @@ -551,6 +565,109 @@ async function listTerminalWorktrees(projectPath: string): Promise { + // Validate projectPath against registered projects + if (!isValidProjectPath(projectPath)) { + debugError('[TerminalWorktree] Invalid project path for listing other worktrees:', projectPath); + return []; + } + + const results: OtherWorktreeInfo[] = []; + + // Paths to exclude (normalize for comparison) + const normalizedProjectPath = path.resolve(projectPath); + const excludePrefixes = [ + path.join(normalizedProjectPath, '.auto-claude', 'worktrees', 'terminal'), + path.join(normalizedProjectPath, '.auto-claude', 'worktrees', 'tasks'), + path.join(normalizedProjectPath, '.auto-claude', 'worktrees', 'pr'), + ]; + + try { + const { stdout: output } = await execFileAsync('git', ['worktree', 'list', '--porcelain'], { + cwd: projectPath, + encoding: 'utf-8', + timeout: 30000, + }); + + // Parse porcelain output + // Format: + // worktree /path/to/worktree + // HEAD abc123... + // branch refs/heads/branch-name (or "detached" line) + // (blank line) + + let currentWorktree: { path?: string; head?: string; branch?: string | null } = {}; + + for (const line of output.split('\n')) { + if (line.startsWith(GIT_PORCELAIN.WORKTREE_PREFIX)) { + // Save previous worktree if complete + if (currentWorktree.path && currentWorktree.head) { + processOtherWorktree(currentWorktree, normalizedProjectPath, excludePrefixes, results); + } + currentWorktree = { path: line.substring(GIT_PORCELAIN.WORKTREE_PREFIX.length) }; + } else if (line.startsWith(GIT_PORCELAIN.HEAD_PREFIX)) { + currentWorktree.head = line.substring(GIT_PORCELAIN.HEAD_PREFIX.length); + } else if (line.startsWith(GIT_PORCELAIN.BRANCH_PREFIX)) { + // Extract branch name from "refs/heads/branch-name" + const fullRef = line.substring(GIT_PORCELAIN.BRANCH_PREFIX.length); + currentWorktree.branch = fullRef.replace('refs/heads/', ''); + } else if (line === GIT_PORCELAIN.DETACHED_LINE) { + currentWorktree.branch = null; // Use null for detached HEAD state + } + } + + // Process final worktree + if (currentWorktree.path && currentWorktree.head) { + processOtherWorktree(currentWorktree, normalizedProjectPath, excludePrefixes, results); + } + } catch (error) { + debugError('[TerminalWorktree] Error listing other worktrees:', error); + } + + return results; +} + +function processOtherWorktree( + wt: { path?: string; head?: string; branch?: string | null }, + mainWorktreePath: string, + excludePrefixes: string[], + results: OtherWorktreeInfo[] +): void { + if (!wt.path || !wt.head) return; + + const normalizedPath = path.resolve(wt.path); + + // Exclude main worktree + if (normalizedPath === mainWorktreePath) { + return; + } + + // Check if this path starts with any excluded prefix + for (const excludePrefix of excludePrefixes) { + if (normalizedPath.startsWith(excludePrefix + path.sep) || normalizedPath === excludePrefix) { + return; // Skip this worktree + } + } + + // Extract display name from path (last directory component) + const displayName = path.basename(normalizedPath); + + results.push({ + path: normalizedPath, + branch: wt.branch ?? null, // null indicates detached HEAD state + commitSha: wt.head.substring(0, GIT_PORCELAIN.COMMIT_SHA_LENGTH), + displayName, + }); +} + async function removeTerminalWorktree( projectPath: string, name: string, @@ -663,4 +780,19 @@ export function registerTerminalWorktreeHandlers(): void { return removeTerminalWorktree(projectPath, name, deleteBranch); } ); + + ipcMain.handle( + IPC_CHANNELS.TERMINAL_WORKTREE_LIST_OTHER, + async (_, projectPath: string): Promise> => { + try { + const worktrees = await listOtherWorktrees(projectPath); + return { success: true, data: worktrees }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to list other worktrees', + }; + } + } + ); } diff --git a/apps/frontend/src/preload/api/terminal-api.ts b/apps/frontend/src/preload/api/terminal-api.ts index 54c2452e..efd6b0c0 100644 --- a/apps/frontend/src/preload/api/terminal-api.ts +++ b/apps/frontend/src/preload/api/terminal-api.ts @@ -16,6 +16,7 @@ import type { CreateTerminalWorktreeRequest, TerminalWorktreeConfig, TerminalWorktreeResult, + OtherWorktreeInfo, } from '../../shared/types'; /** Type for proactive swap notification events */ @@ -64,6 +65,7 @@ export interface TerminalAPI { createTerminalWorktree: (request: CreateTerminalWorktreeRequest) => Promise; listTerminalWorktrees: (projectPath: string) => Promise>; removeTerminalWorktree: (projectPath: string, name: string, deleteBranch?: boolean) => Promise; + listOtherWorktrees: (projectPath: string) => Promise>; // Terminal Event Listeners onTerminalOutput: (callback: (id: string, data: string) => void) => () => void; @@ -180,6 +182,9 @@ export const createTerminalAPI = (): TerminalAPI => ({ removeTerminalWorktree: (projectPath: string, name: string, deleteBranch: boolean = false): Promise => ipcRenderer.invoke(IPC_CHANNELS.TERMINAL_WORKTREE_REMOVE, projectPath, name, deleteBranch), + listOtherWorktrees: (projectPath: string): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.TERMINAL_WORKTREE_LIST_OTHER, projectPath), + // Terminal Event Listeners onTerminalOutput: ( callback: (id: string, data: string) => void diff --git a/apps/frontend/src/renderer/components/terminal/WorktreeSelector.tsx b/apps/frontend/src/renderer/components/terminal/WorktreeSelector.tsx index 797a97ab..be35fa92 100644 --- a/apps/frontend/src/renderer/components/terminal/WorktreeSelector.tsx +++ b/apps/frontend/src/renderer/components/terminal/WorktreeSelector.tsx @@ -1,7 +1,7 @@ import { useState, useEffect } from 'react'; -import { FolderGit, Plus, ChevronDown, Loader2, Trash2, ListTodo } from 'lucide-react'; +import { FolderGit, Plus, ChevronDown, Loader2, Trash2, ListTodo, GitFork } from 'lucide-react'; import { useTranslation } from 'react-i18next'; -import type { TerminalWorktreeConfig, WorktreeListItem } from '../../../shared/types'; +import type { TerminalWorktreeConfig, WorktreeListItem, OtherWorktreeInfo } from '../../../shared/types'; import { DropdownMenu, DropdownMenuContent, @@ -43,6 +43,7 @@ export function WorktreeSelector({ const { t } = useTranslation(['terminal', 'common']); const [worktrees, setWorktrees] = useState([]); const [taskWorktrees, setTaskWorktrees] = useState([]); + const [otherWorktrees, setOtherWorktrees] = useState([]); const [isLoading, setIsLoading] = useState(false); const [isOpen, setIsOpen] = useState(false); const [deleteWorktree, setDeleteWorktree] = useState(null); @@ -58,10 +59,11 @@ export function WorktreeSelector({ if (!projectPath) return; setIsLoading(true); try { - // Fetch terminal worktrees and task worktrees in parallel - const [terminalResult, taskResult] = await Promise.all([ + // Fetch terminal worktrees, task worktrees, and other worktrees in parallel + const [terminalResult, taskResult, otherResult] = await Promise.all([ window.electronAPI.listTerminalWorktrees(projectPath), project?.id ? window.electronAPI.listWorktrees(project.id) : Promise.resolve(null), + window.electronAPI.listOtherWorktrees(projectPath), ]); // Process terminal worktrees @@ -84,6 +86,17 @@ export function WorktreeSelector({ // Clear task worktrees when project is null or fetch failed setTaskWorktrees([]); } + + // Process other worktrees + if (otherResult?.success && otherResult.data) { + // Filter out current worktree if it matches + const availableOtherWorktrees = currentWorktree + ? otherResult.data.filter((wt) => wt.path !== currentWorktree.worktreePath) + : otherResult.data; + setOtherWorktrees(availableOtherWorktrees); + } else { + setOtherWorktrees([]); + } } catch (err) { console.error('Failed to fetch worktrees:', err); } finally { @@ -106,6 +119,20 @@ export function WorktreeSelector({ onSelectWorktree(config); }; + // Convert other worktree to terminal worktree config for selection + const selectOtherWorktree = (otherWt: OtherWorktreeInfo) => { + const config: TerminalWorktreeConfig = { + name: otherWt.displayName, + worktreePath: otherWt.path, + branchName: otherWt.branch ?? '', + baseBranch: '', // Unknown for external worktrees + hasGitBranch: otherWt.branch !== null, + createdAt: new Date().toISOString(), + terminalId, + }; + onSelectWorktree(config); + }; + useEffect(() => { if (isOpen && projectPath) { fetchWorktrees(); @@ -251,6 +278,35 @@ export function WorktreeSelector({ ))} )} + + {/* Other Worktrees Section */} + {otherWorktrees.length > 0 && ( + <> + +
+ {t('terminal:worktree.otherWorktrees')} +
+ {otherWorktrees.map((wt) => ( + { + e.stopPropagation(); + setIsOpen(false); + selectOtherWorktree(wt); + }} + className="text-xs group" + > + +
+ {wt.displayName} + + {wt.branch !== null ? wt.branch : `${wt.commitSha} ${t('terminal:worktree.detached')}`} + +
+
+ ))} + + )} )} diff --git a/apps/frontend/src/renderer/lib/browser-mock.ts b/apps/frontend/src/renderer/lib/browser-mock.ts index fb622bfa..bd9d8d4d 100644 --- a/apps/frontend/src/renderer/lib/browser-mock.ts +++ b/apps/frontend/src/renderer/lib/browser-mock.ts @@ -292,6 +292,10 @@ const browserMockAPI: ElectronAPI = { success: false, error: 'Not available in browser mode' }), + listOtherWorktrees: async () => ({ + success: true, + data: [] + }), // MCP Server Health Check Operations checkMcpHealth: async (server) => ({ diff --git a/apps/frontend/src/shared/constants/ipc.ts b/apps/frontend/src/shared/constants/ipc.ts index eebd6c4e..9e4f5b36 100644 --- a/apps/frontend/src/shared/constants/ipc.ts +++ b/apps/frontend/src/shared/constants/ipc.ts @@ -83,6 +83,7 @@ export const IPC_CHANNELS = { TERMINAL_WORKTREE_CREATE: 'terminal:worktreeCreate', TERMINAL_WORKTREE_REMOVE: 'terminal:worktreeRemove', TERMINAL_WORKTREE_LIST: 'terminal:worktreeList', + TERMINAL_WORKTREE_LIST_OTHER: 'terminal:worktreeListOther', // Terminal events (main -> renderer) TERMINAL_OUTPUT: 'terminal:output', diff --git a/apps/frontend/src/shared/i18n/locales/en/terminal.json b/apps/frontend/src/shared/i18n/locales/en/terminal.json index 370d4032..c08ca11d 100644 --- a/apps/frontend/src/shared/i18n/locales/en/terminal.json +++ b/apps/frontend/src/shared/i18n/locales/en/terminal.json @@ -12,6 +12,7 @@ "createNew": "New Worktree", "existing": "Terminal Worktrees", "taskWorktrees": "Task Worktrees", + "otherWorktrees": "Others", "createTitle": "Create Terminal Worktree", "createDescription": "Create an isolated workspace for this terminal. All work will happen in the worktree directory.", "name": "Worktree Name", @@ -34,6 +35,7 @@ "maxReached": "Maximum of 12 terminal worktrees reached", "alreadyExists": "A worktree with this name already exists", "deleteTitle": "Delete Worktree?", - "deleteDescription": "This will permanently delete the worktree and its branch. Any uncommitted changes will be lost." + "deleteDescription": "This will permanently delete the worktree and its branch. Any uncommitted changes will be lost.", + "detached": "(detached)" } } diff --git a/apps/frontend/src/shared/i18n/locales/fr/terminal.json b/apps/frontend/src/shared/i18n/locales/fr/terminal.json index 20abb2ea..b142e074 100644 --- a/apps/frontend/src/shared/i18n/locales/fr/terminal.json +++ b/apps/frontend/src/shared/i18n/locales/fr/terminal.json @@ -12,6 +12,7 @@ "createNew": "Nouveau Worktree", "existing": "Worktrees Terminal", "taskWorktrees": "Worktrees de Taches", + "otherWorktrees": "Autres", "createTitle": "Creer un Worktree Terminal", "createDescription": "Creer un espace de travail isole pour ce terminal. Tout le travail se fera dans le repertoire du worktree.", "name": "Nom du Worktree", @@ -34,6 +35,7 @@ "maxReached": "Maximum de 12 worktrees terminal atteint", "alreadyExists": "Un worktree avec ce nom existe deja", "deleteTitle": "Supprimer le Worktree?", - "deleteDescription": "Ceci supprimera definitivement le worktree et sa branche. Les modifications non committées seront perdues." + "deleteDescription": "Ceci supprimera definitivement le worktree et sa branche. Les modifications non committées seront perdues.", + "detached": "(détaché)" } } diff --git a/apps/frontend/src/shared/types/ipc.ts b/apps/frontend/src/shared/types/ipc.ts index b1886f73..3ed5c339 100644 --- a/apps/frontend/src/shared/types/ipc.ts +++ b/apps/frontend/src/shared/types/ipc.ts @@ -57,6 +57,7 @@ import type { CreateTerminalWorktreeRequest, TerminalWorktreeConfig, TerminalWorktreeResult, + OtherWorktreeInfo, } from './terminal'; import type { ClaudeProfileSettings, @@ -215,6 +216,7 @@ export interface ElectronAPI { createTerminalWorktree: (request: CreateTerminalWorktreeRequest) => Promise; listTerminalWorktrees: (projectPath: string) => Promise>; removeTerminalWorktree: (projectPath: string, name: string, deleteBranch?: boolean) => Promise; + listOtherWorktrees: (projectPath: string) => Promise>; // Terminal event listeners onTerminalOutput: (callback: (id: string, data: string) => void) => () => void; diff --git a/apps/frontend/src/shared/types/terminal.ts b/apps/frontend/src/shared/types/terminal.ts index fc7eb930..6bb85adb 100644 --- a/apps/frontend/src/shared/types/terminal.ts +++ b/apps/frontend/src/shared/types/terminal.ts @@ -188,3 +188,18 @@ export interface TerminalWorktreeResult { config?: TerminalWorktreeConfig; error?: string; } + +/** + * Information about a worktree not managed by Auto Claude + * Discovered via `git worktree list` excluding Auto Claude paths + */ +export interface OtherWorktreeInfo { + /** Full path to the worktree */ + path: string; + /** Git branch name, or null if in detached HEAD state */ + branch: string | null; + /** Short commit SHA (first 8 chars) */ + commitSha: string; + /** Display name (last directory component of path) */ + displayName: string; +}