Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
53 changes: 53 additions & 0 deletions src/main/lib/worktreeNameUtils.ts
Original file line number Diff line number Diff line change
@@ -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()}`;
}
33 changes: 27 additions & 6 deletions src/main/services/WorktreePoolService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -246,7 +247,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 +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);
Expand All @@ -287,16 +295,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
? 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)
Expand Down
32 changes: 15 additions & 17 deletions src/main/services/WorktreeService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

Expand Down Expand Up @@ -180,7 +181,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 +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}`);
Expand Down Expand Up @@ -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,
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