Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 50 additions & 6 deletions src/main/services/WorktreePoolService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,9 @@ export class WorktreePoolService {
projectId: string,
projectPath: string,
taskName: string,
requestedBaseRef?: string
requestedBaseRef?: string,
customBranchName?: string,
customWorktreeName?: string
): Promise<ClaimResult | null> {
const resolvedBaseRef = this.normalizeBaseRef(requestedBaseRef);
const reserveKey = this.getReserveKey(projectId, resolvedBaseRef);
Expand All @@ -270,7 +272,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);
Expand All @@ -287,16 +294,29 @@ export class WorktreePoolService {
/**
* Transform a reserve worktree into a task worktree
*/
private async transformReserve(reserve: ReserveWorktree, taskName: string): Promise<ClaimResult> {
private async transformReserve(
reserve: ReserveWorktree,
taskName: string,
customBranchName?: string,
customWorktreeName?: string
): Promise<ClaimResult> {
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
? this.sanitizeBranchName(customBranchName)
: `${prefix}/${sluggedName}-${hash}`;

const worktreeDirName = customWorktreeName
? this.sanitizeWorktreeName(customWorktreeName)
: `${sluggedName}-${hash}`;

const newPath = path.join(reserve.projectPath, '..', `worktrees/${worktreeDirName}`);
const newId = this.stableIdFromPath(newPath);

// Move the worktree (instant operation)
Expand Down Expand Up @@ -658,6 +678,30 @@ export class WorktreePoolService {
const bytes = crypto.randomBytes(3);
return bytes.readUIntBE(0, 3).toString(36).slice(0, 3).padStart(3, '0');
}

/** 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;
}

/** Sanitize worktree directory name to ensure it's a valid path component */
private sanitizeWorktreeName(name: string): string {
return name
.replace(/[/\\:*?"<>|]/g, '-')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, 100);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sanitizeBranchName and sanitizeWorktreeName are duplicated verbatim in both WorktreeService (lines 401–422) and WorktreePoolService (lines 682–704). Keeping two copies means any future bug-fix or rule change must be applied in both places — and divergence is one commit away from happening.

Consider extracting these helpers into a shared module (e.g. src/main/lib/worktreeNameUtils.ts) and importing them in both services to prevent drift.

}

export const worktreePoolService = new WorktreePoolService();
27 changes: 24 additions & 3 deletions src/main/services/WorktreeService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,9 @@ export class WorktreeService {
projectPath: string,
taskName: string,
projectId: string,
baseRef?: string
baseRef?: string,
customBranchName?: string,
customWorktreeName?: string
): Promise<WorktreeInfo> {
// Declare variables outside try block for access in catch block
let branchName: string | undefined;
Expand All @@ -192,8 +194,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
? this.sanitizeBranchName(customBranchName)
: this.sanitizeBranchName(`${prefix}/${sluggedName}-${hash}`);

const worktreeDirName = customWorktreeName
? this.sanitizeWorktreeName(customWorktreeName)
: `${sluggedName}-${hash}`;

worktreePath = path.join(projectPath, '..', `worktrees/${worktreeDirName}`);
const worktreeId = this.stableIdFromPath(worktreePath);

log.info(`Creating worktree: ${branchName} -> ${worktreePath}`);
Expand Down Expand Up @@ -400,6 +411,16 @@ export class WorktreeService {
return n;
}

/** Sanitize worktree directory name to ensure it's a valid path component */
private sanitizeWorktreeName(name: string): string {
return name
.replace(/[/\\:*?"<>|]/g, '-')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, 100);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sanitizeWorktreeName can return an empty string when the input consists entirely of characters that get converted to dashes. For example, an input like --- or ??? passes frontend validation (those characters aren't caught by validateWorktreeName) but produces an empty string here, because:

  1. Characters like ? are replaced with -
  2. Multiple dashes are collapsed to one via /-+/g
  3. Leading/trailing dashes are stripped via /^-|-$/g
  4. Result: empty string

This causes the worktree path to resolve to the worktrees/ directory itself rather than a subdirectory, leading to invalid git-worktree operations or filesystem errors.

A fallback is needed when sanitization results in an empty string:

private sanitizeWorktreeName(name: string): string {
  const sanitized = name
    .replace(/[/\\:*?"<>|]/g, '-')
    .replace(/\s+/g, '-')
    .replace(/-+/g, '-')
    .replace(/^-|-$/g, '')
    .slice(0, 100);
  return sanitized || `worktree-${this.generateShortHash()}`;
}

This issue also exists in WorktreePoolService.sanitizeWorktreeName (line 697).


/** Remove a worktree */
async removeWorktree(
projectPath: string,
Expand Down
18 changes: 15 additions & 3 deletions src/main/services/worktreeIpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ export function registerWorktreeIpc(): void {
taskName: string;
projectId: string;
baseRef?: string;
customBranchName?: string;
customWorktreeName?: string;
}
) => {
try {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -318,6 +322,8 @@ export function registerWorktreeIpc(): void {
projectPath: string;
taskName: string;
baseRef?: string;
customBranchName?: string;
customWorktreeName?: string;
}
) => {
try {
Expand All @@ -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 {
Expand All @@ -359,6 +367,8 @@ export function registerWorktreeIpc(): void {
projectPath: string;
taskName: string;
baseRef?: string;
customBranchName?: string;
customWorktreeName?: string;
task: {
projectId: string;
name: string;
Expand All @@ -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' };
Expand Down
75 changes: 75 additions & 0 deletions src/renderer/components/TaskAdvancedSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -80,6 +89,12 @@ export const TaskAdvancedSettings: React.FC<TaskAdvancedSettingsProps> = ({
projectPath,
useWorktree,
onUseWorktreeChange,
customBranchName,
onCustomBranchNameChange,
branchNameError,
customWorktreeName,
onCustomWorktreeNameChange,
worktreeNameError,
autoApprove,
onAutoApproveChange,
hasAutoApproveSupport,
Expand Down Expand Up @@ -113,6 +128,26 @@ export const TaskAdvancedSettings: React.FC<TaskAdvancedSettingsProps> = ({
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('');
Expand Down Expand Up @@ -385,6 +420,46 @@ export const TaskAdvancedSettings: React.FC<TaskAdvancedSettingsProps> = ({
</div>
</div>

{useWorktree && (
<>
<div className="grid grid-cols-[128px_1fr] items-start gap-4">
<Label htmlFor="custom-branch-name" className="pt-2">
Branch name
</Label>
<div className="min-w-0 flex-1">
<Input
id="custom-branch-name"
value={customBranchName}
onChange={(e) => onCustomBranchNameChange(e.target.value)}
placeholder="Leave empty to auto-generate"
className={branchNameError ? 'border-destructive' : ''}
/>
{branchNameError && (
<p className="mt-1 text-xs text-destructive">{branchNameError}</p>
)}
</div>
</div>

<div className="grid grid-cols-[128px_1fr] items-start gap-4">
<Label htmlFor="custom-worktree-name" className="pt-2">
Worktree folder
</Label>
<div className="min-w-0 flex-1">
<Input
id="custom-worktree-name"
value={customWorktreeName}
onChange={(e) => onCustomWorktreeNameChange(e.target.value)}
placeholder={getWorktreePlaceholder()}
className={worktreeNameError ? 'border-destructive' : ''}
/>
{worktreeNameError && (
<p className="mt-1 text-xs text-destructive">{worktreeNameError}</p>
)}
</div>
</div>
</>
)}

{hasAutoApproveSupport ? (
<div className="flex items-center gap-4">
<Label className="w-32 shrink-0">Auto-approve</Label>
Expand Down
Loading