diff --git a/src/main/lib/worktreeNameUtils.ts b/src/main/lib/worktreeNameUtils.ts new file mode 100644 index 000000000..a08ed6ebe --- /dev/null +++ b/src/main/lib/worktreeNameUtils.ts @@ -0,0 +1,53 @@ +import crypto from 'crypto'; + +/** + * Shared utilities for worktree and branch name sanitization. + * Used by both WorktreeService and WorktreePoolService. + */ + +/** Generate a short random hash for fallback names */ +function generateShortHash(): string { + const bytes = crypto.randomBytes(3); + return bytes.readUIntBE(0, 3).toString(36).slice(0, 3).padStart(3, '0'); +} + +/** Slugify a string for use in branch/worktree names */ +function slugify(str: string): string { + return str + .toLowerCase() + .replace(/[^a-z0-9-]/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, ''); +} + +/** + * Sanitize branch name to ensure it's a valid Git ref. + * Returns a fallback name if sanitization results in an empty/invalid string. + */ +export function sanitizeBranchName(name: string, prefix = 'emdash'): string { + let n = name + .replace(/\s+/g, '-') + .replace(/[^A-Za-z0-9._\/-]+/g, '-') + .replace(/-+/g, '-') + .replace(/\/+/g, '/'); + n = n.replace(/^[./-]+/, '').replace(/[./-]+$/, ''); + if (!n || n === 'HEAD') { + n = `${prefix}/${slugify('task')}-${generateShortHash()}`; + } + return n; +} + +/** + * Sanitize worktree directory name to ensure it's a valid path component. + * Returns a fallback name if sanitization results in an empty string. + */ +export function sanitizeWorktreeName(name: string): string { + const sanitized = name + .replace(/[/\\:*?"<>|]/g, '-') + .replace(/\s+/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') + .slice(0, 100); + // Fallback if sanitization results in empty string + return sanitized || `worktree-${generateShortHash()}`; +} diff --git a/src/main/services/WorktreePoolService.ts b/src/main/services/WorktreePoolService.ts index 267bb1a31..eda639472 100644 --- a/src/main/services/WorktreePoolService.ts +++ b/src/main/services/WorktreePoolService.ts @@ -5,6 +5,7 @@ import fs from 'fs'; import crypto from 'crypto'; import { log } from '../lib/logger'; import { worktreeService, type WorktreeInfo } from './WorktreeService'; +import { sanitizeBranchName, sanitizeWorktreeName } from '../lib/worktreeNameUtils'; const execFileAsync = promisify(execFile); @@ -246,7 +247,9 @@ export class WorktreePoolService { projectId: string, projectPath: string, taskName: string, - requestedBaseRef?: string + requestedBaseRef?: string, + customBranchName?: string, + customWorktreeName?: string ): Promise { const resolvedBaseRef = this.normalizeBaseRef(requestedBaseRef); const reserveKey = this.getReserveKey(projectId, resolvedBaseRef); @@ -270,7 +273,12 @@ export class WorktreePoolService { this.reserves.delete(reserveKey); try { - const result = await this.transformReserve(reserve, taskName); + const result = await this.transformReserve( + reserve, + taskName, + customBranchName, + customWorktreeName + ); // Start background replenishment this.replenishReserve(projectId, projectPath, resolvedBaseRef); @@ -287,16 +295,29 @@ export class WorktreePoolService { /** * Transform a reserve worktree into a task worktree */ - private async transformReserve(reserve: ReserveWorktree, taskName: string): Promise { + private async transformReserve( + reserve: ReserveWorktree, + taskName: string, + customBranchName?: string, + customWorktreeName?: string + ): Promise { const { getAppSettings } = await import('../settings'); const settings = getAppSettings(); const prefix = settings?.repository?.branchPrefix || 'emdash'; - // Generate new names + // Generate new names, using custom names if provided const sluggedName = this.slugify(taskName); const hash = this.generateShortHash(); - const newBranch = `${prefix}/${sluggedName}-${hash}`; - const newPath = path.join(reserve.projectPath, '..', `worktrees/${sluggedName}-${hash}`); + + const newBranch = customBranchName + ? sanitizeBranchName(customBranchName, prefix) + : `${prefix}/${sluggedName}-${hash}`; + + const worktreeDirName = customWorktreeName + ? sanitizeWorktreeName(customWorktreeName) + : `${sluggedName}-${hash}`; + + const newPath = path.join(reserve.projectPath, '..', `worktrees/${worktreeDirName}`); const newId = this.stableIdFromPath(newPath); // Move the worktree (instant operation) diff --git a/src/main/services/WorktreeService.ts b/src/main/services/WorktreeService.ts index 2ae09511d..6adf9b0a6 100644 --- a/src/main/services/WorktreeService.ts +++ b/src/main/services/WorktreeService.ts @@ -7,6 +7,7 @@ import crypto from 'crypto'; import { projectSettingsService } from './ProjectSettingsService'; import { minimatch } from 'minimatch'; import { errorTracking } from '../errorTracking'; +import { sanitizeBranchName, sanitizeWorktreeName } from '../lib/worktreeNameUtils'; type BaseRefInfo = { remote: string; branch: string; fullRef: string }; @@ -180,7 +181,9 @@ export class WorktreeService { projectPath: string, taskName: string, projectId: string, - baseRef?: string + baseRef?: string, + customBranchName?: string, + customWorktreeName?: string ): Promise { // Declare variables outside try block for access in catch block let branchName: string | undefined; @@ -192,8 +195,17 @@ export class WorktreeService { const { getAppSettings } = await import('../settings'); const settings = getAppSettings(); const prefix = settings?.repository?.branchPrefix || 'emdash'; - branchName = this.sanitizeBranchName(`${prefix}/${sluggedName}-${hash}`); - worktreePath = path.join(projectPath, '..', `worktrees/${sluggedName}-${hash}`); + + // Use custom names if provided, otherwise auto-generate + branchName = customBranchName + ? sanitizeBranchName(customBranchName, prefix) + : sanitizeBranchName(`${prefix}/${sluggedName}-${hash}`, prefix); + + const worktreeDirName = customWorktreeName + ? sanitizeWorktreeName(customWorktreeName) + : `${sluggedName}-${hash}`; + + worktreePath = path.join(projectPath, '..', `worktrees/${worktreeDirName}`); const worktreeId = this.stableIdFromPath(worktreePath); log.info(`Creating worktree: ${branchName} -> ${worktreePath}`); @@ -386,20 +398,6 @@ export class WorktreeService { } } - /** Sanitize branch name to ensure it's a valid Git ref */ - private sanitizeBranchName(name: string): string { - let n = name - .replace(/\s+/g, '-') - .replace(/[^A-Za-z0-9._\/-]+/g, '-') - .replace(/-+/g, '-') - .replace(/\/+/g, '/'); - n = n.replace(/^[./-]+/, '').replace(/[./-]+$/, ''); - if (!n || n === 'HEAD') { - n = `emdash/${this.slugify('task')}-${this.generateShortHash()}`; - } - return n; - } - /** Remove a worktree */ async removeWorktree( projectPath: string, diff --git a/src/main/services/worktreeIpc.ts b/src/main/services/worktreeIpc.ts index 92fa09edd..71afe002a 100644 --- a/src/main/services/worktreeIpc.ts +++ b/src/main/services/worktreeIpc.ts @@ -56,6 +56,8 @@ export function registerWorktreeIpc(): void { taskName: string; projectId: string; baseRef?: string; + customBranchName?: string; + customWorktreeName?: string; } ) => { try { @@ -92,7 +94,9 @@ export function registerWorktreeIpc(): void { args.projectPath, args.taskName, args.projectId, - args.baseRef + args.baseRef, + args.customBranchName, + args.customWorktreeName ); return { success: true, worktree }; } catch (error) { @@ -318,6 +322,8 @@ export function registerWorktreeIpc(): void { projectPath: string; taskName: string; baseRef?: string; + customBranchName?: string; + customWorktreeName?: string; } ) => { try { @@ -332,7 +338,9 @@ export function registerWorktreeIpc(): void { args.projectId, args.projectPath, args.taskName, - args.baseRef + args.baseRef, + args.customBranchName, + args.customWorktreeName ); if (result) { return { @@ -359,6 +367,8 @@ export function registerWorktreeIpc(): void { projectPath: string; taskName: string; baseRef?: string; + customBranchName?: string; + customWorktreeName?: string; task: { projectId: string; name: string; @@ -382,7 +392,9 @@ export function registerWorktreeIpc(): void { args.projectId, args.projectPath, args.taskName, - args.baseRef + args.baseRef, + args.customBranchName, + args.customWorktreeName ); if (!claim) { return { success: false, error: 'No reserve available' }; diff --git a/src/renderer/components/TaskAdvancedSettings.tsx b/src/renderer/components/TaskAdvancedSettings.tsx index 597d17e5b..d49105d6d 100644 --- a/src/renderer/components/TaskAdvancedSettings.tsx +++ b/src/renderer/components/TaskAdvancedSettings.tsx @@ -3,6 +3,7 @@ import { AnimatePresence, motion, useReducedMotion } from 'motion/react'; import { ExternalLink, Settings } from 'lucide-react'; import { Button } from './ui/button'; import { Checkbox } from './ui/checkbox'; +import { Input } from './ui/input'; import { Label } from './ui/label'; import { Spinner } from './ui/spinner'; import { Textarea } from './ui/textarea'; @@ -31,6 +32,14 @@ interface TaskAdvancedSettingsProps { useWorktree: boolean; onUseWorktreeChange: (value: boolean) => void; + // Custom branch and worktree names + customBranchName: string; + onCustomBranchNameChange: (value: string) => void; + branchNameError: string | null; + customWorktreeName: string; + onCustomWorktreeNameChange: (value: string) => void; + worktreeNameError: string | null; + // Auto-approve autoApprove: boolean; onAutoApproveChange: (value: boolean) => void; @@ -80,6 +89,12 @@ export const TaskAdvancedSettings: React.FC = ({ projectPath, useWorktree, onUseWorktreeChange, + customBranchName, + onCustomBranchNameChange, + branchNameError, + customWorktreeName, + onCustomWorktreeNameChange, + worktreeNameError, autoApprove, onAutoApproveChange, hasAutoApproveSupport, @@ -113,6 +128,26 @@ export const TaskAdvancedSettings: React.FC = ({ const shouldReduceMotion = useReducedMotion(); const [showAdvanced, setShowAdvanced] = useState(false); + // Derive a suggested worktree folder name from the branch name for the placeholder + const getWorktreePlaceholder = (): string => { + const trimmed = customBranchName.trim(); + if (!trimmed) return 'Leave empty to auto-generate'; + + // Take the part after the last slash, or the whole string if no slash + const lastSlashIndex = trimmed.lastIndexOf('/'); + const baseName = lastSlashIndex >= 0 ? trimmed.slice(lastSlashIndex + 1) : trimmed; + + // Sanitize for display + const sanitized = baseName + .replace(/[\\:*?"<>|]/g, '-') + .replace(/\s+/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') + .slice(0, 100); + + return sanitized ? `e.g. ${sanitized}` : 'Leave empty to auto-generate'; + }; + // Linear setup state const [linearSetupOpen, setLinearSetupOpen] = useState(false); const [linearApiKey, setLinearApiKey] = useState(''); @@ -385,6 +420,46 @@ export const TaskAdvancedSettings: React.FC = ({ + {useWorktree && ( + <> +
+ +
+ onCustomBranchNameChange(e.target.value)} + placeholder="Leave empty to auto-generate" + className={branchNameError ? 'border-destructive' : ''} + /> + {branchNameError && ( +

{branchNameError}

+ )} +
+
+ +
+ +
+ onCustomWorktreeNameChange(e.target.value)} + placeholder={getWorktreePlaceholder()} + className={worktreeNameError ? 'border-destructive' : ''} + /> + {worktreeNameError && ( +

{worktreeNameError}

+ )} +
+
+ + )} + {hasAutoApproveSupport ? (
diff --git a/src/renderer/components/TaskModal.tsx b/src/renderer/components/TaskModal.tsx index 36d5a794d..3c985199a 100644 --- a/src/renderer/components/TaskModal.tsx +++ b/src/renderer/components/TaskModal.tsx @@ -28,6 +28,7 @@ import { normalizeTaskName, MAX_TASK_NAME_LENGTH, } from '../lib/taskNames'; +import { validateBranchName, validateWorktreeName } from '../lib/nameValidation'; import BranchSelect from './BranchSelect'; import { generateTaskNameFromContext } from '../lib/branchNameGenerator'; import { useProjectManagementContext } from '../contexts/ProjectManagementProvider'; @@ -63,7 +64,9 @@ interface TaskModalProps { autoApprove?: boolean, useWorktree?: boolean, baseRef?: string, - nameGenerated?: boolean + nameGenerated?: boolean, + customBranchName?: string, + customWorktreeName?: string ) => Promise; } @@ -86,7 +89,9 @@ export function TaskModalOverlay({ onClose }: TaskModalOverlayProps) { autoApprove, useWorktree, baseRef, - nameGenerated + nameGenerated, + customBranchName, + customWorktreeName ) => { await handleCreateTask( name, @@ -99,7 +104,9 @@ export function TaskModalOverlay({ onClose }: TaskModalOverlayProps) { autoApprove, useWorktree, baseRef, - nameGenerated + nameGenerated, + customBranchName, + customWorktreeName ); }} /> @@ -137,6 +144,12 @@ const TaskModal: React.FC = ({ onClose, onCreateTask }) => { const [autoApprove, setAutoApprove] = useState(false); const [useWorktree, setUseWorktree] = useState(true); + // Custom branch and worktree name state + const [customBranchName, setCustomBranchName] = useState(''); + const [customWorktreeName, setCustomWorktreeName] = useState(''); + const [branchNameError, setBranchNameError] = useState(null); + const [worktreeNameError, setWorktreeNameError] = useState(null); + // Branch selection state - sync with defaultBranch unless user manually changed it const [selectedBranch, setSelectedBranch] = useState(defaultBranch); const userChangedBranchRef = useRef(false); @@ -224,6 +237,10 @@ const TaskModal: React.FC = ({ onClose, onCreateTask }) => { setSelectedPlainThread(null); setAutoApprove(false); setUseWorktree(true); + setCustomBranchName(''); + setCustomWorktreeName(''); + setBranchNameError(null); + setWorktreeNameError(null); userHasTypedRef.current = false; autoNameInitializedRef.current = false; customNameTrackedRef.current = false; @@ -331,6 +348,16 @@ const TaskModal: React.FC = ({ onClose, onCreateTask }) => { } }; + const handleCustomBranchNameChange = (val: string) => { + setCustomBranchName(val); + setBranchNameError(validateBranchName(val)); + }; + + const handleCustomWorktreeNameChange = (val: string) => { + setCustomWorktreeName(val); + setWorktreeNameError(validateWorktreeName(val)); + }; + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setTouched(true); @@ -341,6 +368,17 @@ const TaskModal: React.FC = ({ onClose, onCreateTask }) => { return; } + // Validate custom names if worktree is enabled + if (useWorktree) { + const branchErr = validateBranchName(customBranchName); + const worktreeErr = validateWorktreeName(customWorktreeName); + if (branchErr || worktreeErr) { + setBranchNameError(branchErr); + setWorktreeNameError(worktreeErr); + return; + } + } + // Determine the final task name and whether it should be eligible for // post-creation auto-rename (nameGenerated flag). let finalName = normalizeTaskName(taskName); @@ -372,7 +410,9 @@ const TaskModal: React.FC = ({ onClose, onCreateTask }) => { hasAutoApproveSupport ? autoApprove : false, useWorktree, selectedBranch, - isNameGenerated + isNameGenerated, + useWorktree && customBranchName.trim() ? customBranchName.trim() : undefined, + useWorktree && customWorktreeName.trim() ? customWorktreeName.trim() : undefined ); onClose(); } catch (error) { @@ -456,6 +496,12 @@ const TaskModal: React.FC = ({ onClose, onCreateTask }) => { projectPath={projectPath} useWorktree={useWorktree} onUseWorktreeChange={setUseWorktree} + customBranchName={customBranchName} + onCustomBranchNameChange={handleCustomBranchNameChange} + branchNameError={branchNameError} + customWorktreeName={customWorktreeName} + onCustomWorktreeNameChange={handleCustomWorktreeNameChange} + worktreeNameError={worktreeNameError} autoApprove={autoApprove} onAutoApproveChange={setAutoApprove} hasAutoApproveSupport={hasAutoApproveSupport} @@ -489,7 +535,11 @@ const TaskModal: React.FC = ({ onClose, onCreateTask }) => {
-