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:
@@ -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é)"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user