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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Andy
2026-01-16 23:18:45 +01:00
committed by GitHub
parent 48bd4a9c07
commit 219cc068b8
9 changed files with 226 additions and 7 deletions
@@ -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<TerminalWorkt
return configs;
}
/**
* List "other" worktrees - worktrees not managed by Auto Claude
* These are discovered via `git worktree list` excluding:
* - Main worktree (project root)
* - .auto-claude/worktrees/terminal/*
* - .auto-claude/worktrees/tasks/*
* - .auto-claude/worktrees/pr/*
*/
async function listOtherWorktrees(projectPath: string): Promise<OtherWorktreeInfo[]> {
// 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<IPCResult<OtherWorktreeInfo[]>> => {
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',
};
}
}
);
}
@@ -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<TerminalWorktreeResult>;
listTerminalWorktrees: (projectPath: string) => Promise<IPCResult<TerminalWorktreeConfig[]>>;
removeTerminalWorktree: (projectPath: string, name: string, deleteBranch?: boolean) => Promise<IPCResult>;
listOtherWorktrees: (projectPath: string) => Promise<IPCResult<OtherWorktreeInfo[]>>;
// 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<IPCResult> =>
ipcRenderer.invoke(IPC_CHANNELS.TERMINAL_WORKTREE_REMOVE, projectPath, name, deleteBranch),
listOtherWorktrees: (projectPath: string): Promise<IPCResult<OtherWorktreeInfo[]>> =>
ipcRenderer.invoke(IPC_CHANNELS.TERMINAL_WORKTREE_LIST_OTHER, projectPath),
// Terminal Event Listeners
onTerminalOutput: (
callback: (id: string, data: string) => void
@@ -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<TerminalWorktreeConfig[]>([]);
const [taskWorktrees, setTaskWorktrees] = useState<WorktreeListItem[]>([]);
const [otherWorktrees, setOtherWorktrees] = useState<OtherWorktreeInfo[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const [deleteWorktree, setDeleteWorktree] = useState<TerminalWorktreeConfig | null>(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 && (
<>
<DropdownMenuSeparator />
<div className="px-2 py-1.5 text-xs text-muted-foreground">
{t('terminal:worktree.otherWorktrees')}
</div>
{otherWorktrees.map((wt) => (
<DropdownMenuItem
key={wt.path}
onClick={(e) => {
e.stopPropagation();
setIsOpen(false);
selectOtherWorktree(wt);
}}
className="text-xs group"
>
<GitFork className="h-3 w-3 mr-2 text-purple-500/70 shrink-0" />
<div className="flex flex-col min-w-0 flex-1">
<span className="truncate font-medium">{wt.displayName}</span>
<span className="text-[10px] text-muted-foreground truncate">
{wt.branch !== null ? wt.branch : `${wt.commitSha} ${t('terminal:worktree.detached')}`}
</span>
</div>
</DropdownMenuItem>
))}
</>
)}
</>
)}
</div>
@@ -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) => ({
@@ -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',
@@ -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)"
}
}
@@ -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é)"
}
}
+2
View File
@@ -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<TerminalWorktreeResult>;
listTerminalWorktrees: (projectPath: string) => Promise<IPCResult<TerminalWorktreeConfig[]>>;
removeTerminalWorktree: (projectPath: string, name: string, deleteBranch?: boolean) => Promise<IPCResult>;
listOtherWorktrees: (projectPath: string) => Promise<IPCResult<OtherWorktreeInfo[]>>;
// Terminal event listeners
onTerminalOutput: (callback: (id: string, data: string) => void) => () => void;
@@ -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;
}