fix: merge worktree handler now uses correct branch name and path

The merge handler previously hardcoded branch names as `feature/${featureId}`
and worktree paths as `.worktrees/${featureId}`, which failed for auto-generated
branches (e.g., `feature/v0.11.0rc-1768413895104-31pa`) and custom worktrees.

Changes:
- Server handler now accepts branchName and worktreePath directly from the UI
- Added branch existence validation before attempting merge
- Updated merge dialog with 2-step confirmation (type "merge" to confirm)
- Removed feature branch naming restriction - any branch can now be merged
- Updated API types and client to pass correct parameters

Closes #408

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Shirone
2026-01-14 20:49:17 +01:00
parent ca3286a374
commit ee0d0c6c59
10 changed files with 335 additions and 22 deletions

View File

@@ -8,7 +8,6 @@
import type { Request, Response } from 'express';
import { exec } from 'child_process';
import { promisify } from 'util';
import path from 'path';
import { getErrorMessage, logError } from '../common.js';
const execAsync = promisify(exec);
@@ -16,28 +15,31 @@ const execAsync = promisify(exec);
export function createMergeHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, featureId, options } = req.body as {
const { projectPath, branchName, worktreePath, options } = req.body as {
projectPath: string;
featureId: string;
branchName: string;
worktreePath: string;
options?: { squash?: boolean; message?: string };
};
if (!projectPath || !featureId) {
if (!projectPath || !branchName || !worktreePath) {
res.status(400).json({
success: false,
error: 'projectPath and featureId required',
error: 'projectPath, branchName, and worktreePath are required',
});
return;
}
const branchName = `feature/${featureId}`;
// Git worktrees are stored in project directory
const worktreePath = path.join(projectPath, '.worktrees', featureId);
// Get current branch
const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', {
cwd: projectPath,
});
// Validate branch exists
try {
await execAsync(`git rev-parse --verify ${branchName}`, { cwd: projectPath });
} catch {
res.status(400).json({
success: false,
error: `Branch "${branchName}" does not exist`,
});
return;
}
// Merge the feature branch
const mergeCmd = options?.squash

View File

@@ -58,6 +58,7 @@ import { DeleteWorktreeDialog } from './board-view/dialogs/delete-worktree-dialo
import { CommitWorktreeDialog } from './board-view/dialogs/commit-worktree-dialog';
import { CreatePRDialog } from './board-view/dialogs/create-pr-dialog';
import { CreateBranchDialog } from './board-view/dialogs/create-branch-dialog';
import { MergeWorktreeDialog } from './board-view/dialogs/merge-worktree-dialog';
import { WorktreePanel } from './board-view/worktree-panel';
import type { PRInfo, WorktreeInfo } from './board-view/worktree-panel/types';
import { COLUMNS, getColumnsWithPipeline } from './board-view/constants';
@@ -148,6 +149,7 @@ export function BoardView() {
const [showCommitWorktreeDialog, setShowCommitWorktreeDialog] = useState(false);
const [showCreatePRDialog, setShowCreatePRDialog] = useState(false);
const [showCreateBranchDialog, setShowCreateBranchDialog] = useState(false);
const [showMergeWorktreeDialog, setShowMergeWorktreeDialog] = useState(false);
const [selectedWorktreeForAction, setSelectedWorktreeForAction] = useState<{
path: string;
branch: string;
@@ -1354,6 +1356,10 @@ export function BoardView() {
}}
onAddressPRComments={handleAddressPRComments}
onResolveConflicts={handleResolveConflicts}
onMerge={(worktree) => {
setSelectedWorktreeForAction(worktree);
setShowMergeWorktreeDialog(true);
}}
onRemovedWorktrees={handleRemovedWorktrees}
runningFeatureIds={runningAutoTasks}
branchCardCounts={branchCardCounts}
@@ -1698,6 +1704,35 @@ export function BoardView() {
}}
/>
{/* Merge Worktree Dialog */}
<MergeWorktreeDialog
open={showMergeWorktreeDialog}
onOpenChange={setShowMergeWorktreeDialog}
projectPath={currentProject.path}
worktree={selectedWorktreeForAction}
affectedFeatureCount={
selectedWorktreeForAction
? hookFeatures.filter((f) => f.branchName === selectedWorktreeForAction.branch).length
: 0
}
onMerged={(mergedWorktree) => {
// Reset features that were assigned to the merged worktree (by branch)
hookFeatures.forEach((feature) => {
if (feature.branchName === mergedWorktree.branch) {
// Reset the feature's branch assignment - update both local state and persist
const updates = {
branchName: null as unknown as string | undefined,
};
updateFeature(feature.id, updates);
persistFeatureUpdate(feature.id, updates);
}
});
setWorktreeRefreshKey((k) => k + 1);
setSelectedWorktreeForAction(null);
}}
/>
{/* Commit Worktree Dialog */}
<CommitWorktreeDialog
open={showCommitWorktreeDialog}

View File

@@ -0,0 +1,234 @@
import { useState, useEffect } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Loader2, GitMerge, AlertTriangle, CheckCircle2 } from 'lucide-react';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
interface WorktreeInfo {
path: string;
branch: string;
isMain: boolean;
hasChanges?: boolean;
changedFilesCount?: number;
}
interface MergeWorktreeDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
projectPath: string;
worktree: WorktreeInfo | null;
onMerged: (mergedWorktree: WorktreeInfo) => void;
/** Number of features assigned to this worktree's branch */
affectedFeatureCount?: number;
}
type DialogStep = 'confirm' | 'verify';
export function MergeWorktreeDialog({
open,
onOpenChange,
projectPath,
worktree,
onMerged,
affectedFeatureCount = 0,
}: MergeWorktreeDialogProps) {
const [isLoading, setIsLoading] = useState(false);
const [step, setStep] = useState<DialogStep>('confirm');
const [confirmText, setConfirmText] = useState('');
// Reset state when dialog opens
useEffect(() => {
if (open) {
setIsLoading(false);
setStep('confirm');
setConfirmText('');
}
}, [open]);
const handleProceedToVerify = () => {
setStep('verify');
};
const handleMerge = async () => {
if (!worktree) return;
setIsLoading(true);
try {
const api = getElectronAPI();
if (!api?.worktree?.mergeFeature) {
toast.error('Worktree API not available');
return;
}
// Pass branchName and worktreePath directly to the API
const result = await api.worktree.mergeFeature(projectPath, worktree.branch, worktree.path);
if (result.success) {
toast.success('Branch merged to main', {
description: `Branch "${worktree.branch}" has been merged and cleaned up`,
});
onMerged(worktree);
onOpenChange(false);
} else {
toast.error('Failed to merge branch', {
description: result.error,
});
}
} catch (err) {
toast.error('Failed to merge branch', {
description: err instanceof Error ? err.message : 'Unknown error',
});
} finally {
setIsLoading(false);
}
};
if (!worktree) return null;
const confirmationWord = 'merge';
const isConfirmValid = confirmText.toLowerCase() === confirmationWord;
// First step: Show what will happen and ask for confirmation
if (step === 'confirm') {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<GitMerge className="w-5 h-5 text-green-600" />
Merge to Main
</DialogTitle>
<DialogDescription asChild>
<div className="space-y-3">
<span className="block">
Merge branch{' '}
<code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code> into
main?
</span>
<div className="text-sm text-muted-foreground mt-2">
This will:
<ul className="list-disc list-inside mt-1 space-y-1">
<li>Merge the branch into the main branch</li>
<li>Remove the worktree directory</li>
<li>Delete the branch</li>
</ul>
</div>
{worktree.hasChanges && (
<div className="flex items-start gap-2 p-3 rounded-md bg-yellow-500/10 border border-yellow-500/20 mt-2">
<AlertTriangle className="w-4 h-4 text-yellow-500 mt-0.5 flex-shrink-0" />
<span className="text-yellow-500 text-sm">
This worktree has {worktree.changedFilesCount} uncommitted change(s). Please
commit or discard them before merging.
</span>
</div>
)}
{affectedFeatureCount > 0 && (
<div className="flex items-start gap-2 p-3 rounded-md bg-blue-500/10 border border-blue-500/20 mt-2">
<AlertTriangle className="w-4 h-4 text-blue-500 mt-0.5 flex-shrink-0" />
<span className="text-blue-500 text-sm">
{affectedFeatureCount} feature{affectedFeatureCount !== 1 ? 's' : ''}{' '}
{affectedFeatureCount !== 1 ? 'are' : 'is'} assigned to this branch and will
be unassigned after merge.
</span>
</div>
)}
</div>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
onClick={handleProceedToVerify}
disabled={worktree.hasChanges}
className="bg-green-600 hover:bg-green-700 text-white"
>
<GitMerge className="w-4 h-4 mr-2" />
Continue
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// Second step: Type confirmation
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-orange-500" />
Confirm Merge
</DialogTitle>
<DialogDescription asChild>
<div className="space-y-4">
<div className="flex items-start gap-2 p-3 rounded-md bg-orange-500/10 border border-orange-500/20">
<AlertTriangle className="w-4 h-4 text-orange-500 mt-0.5 flex-shrink-0" />
<span className="text-orange-600 dark:text-orange-400 text-sm">
This action cannot be undone. The branch{' '}
<code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code> will be
permanently deleted after merging.
</span>
</div>
<div className="space-y-2">
<Label htmlFor="confirm-merge" className="text-sm text-foreground">
Type <span className="font-bold text-foreground">{confirmationWord}</span> to
confirm:
</Label>
<Input
id="confirm-merge"
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
placeholder={confirmationWord}
disabled={isLoading}
className="font-mono"
autoComplete="off"
/>
</div>
</div>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="ghost" onClick={() => setStep('confirm')} disabled={isLoading}>
Back
</Button>
<Button
onClick={handleMerge}
disabled={isLoading || !isConfirmValid}
className="bg-green-600 hover:bg-green-700 text-white"
>
{isLoading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Merging...
</>
) : (
<>
<CheckCircle2 className="w-4 h-4 mr-2" />
Merge to Main
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -55,6 +55,7 @@ interface WorktreeActionsDropdownProps {
onCreatePR: (worktree: WorktreeInfo) => void;
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
onResolveConflicts: (worktree: WorktreeInfo) => void;
onMerge: (worktree: WorktreeInfo) => void;
onDeleteWorktree: (worktree: WorktreeInfo) => void;
onStartDevServer: (worktree: WorktreeInfo) => void;
onStopDevServer: (worktree: WorktreeInfo) => void;
@@ -84,6 +85,7 @@ export function WorktreeActionsDropdown({
onCreatePR,
onAddressPRComments,
onResolveConflicts,
onMerge,
onDeleteWorktree,
onStartDevServer,
onStopDevServer,
@@ -231,6 +233,27 @@ export function WorktreeActionsDropdown({
{!canPerformGitOps && <AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />}
</DropdownMenuItem>
</TooltipWrapper>
{!worktree.isMain && (
<TooltipWrapper
showTooltip={!!gitOpsDisabledReason}
tooltipContent={gitOpsDisabledReason}
>
<DropdownMenuItem
onClick={() => canPerformGitOps && onMerge(worktree)}
disabled={!canPerformGitOps}
className={cn(
'text-xs text-green-600 focus:text-green-700',
!canPerformGitOps && 'opacity-50 cursor-not-allowed'
)}
>
<GitMerge className="w-3.5 h-3.5 mr-2" />
Merge to Main
{!canPerformGitOps && (
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
)}
</DropdownMenuItem>
</TooltipWrapper>
)}
<DropdownMenuSeparator />
{/* Open in editor - split button: click main area for default, chevron for other options */}
{effectiveDefaultEditor && (

View File

@@ -41,6 +41,7 @@ interface WorktreeTabProps {
onCreatePR: (worktree: WorktreeInfo) => void;
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
onResolveConflicts: (worktree: WorktreeInfo) => void;
onMerge: (worktree: WorktreeInfo) => void;
onDeleteWorktree: (worktree: WorktreeInfo) => void;
onStartDevServer: (worktree: WorktreeInfo) => void;
onStopDevServer: (worktree: WorktreeInfo) => void;
@@ -84,6 +85,7 @@ export function WorktreeTab({
onCreatePR,
onAddressPRComments,
onResolveConflicts,
onMerge,
onDeleteWorktree,
onStartDevServer,
onStopDevServer,
@@ -344,6 +346,7 @@ export function WorktreeTab({
onCreatePR={onCreatePR}
onAddressPRComments={onAddressPRComments}
onResolveConflicts={onResolveConflicts}
onMerge={onMerge}
onDeleteWorktree={onDeleteWorktree}
onStartDevServer={onStartDevServer}
onStopDevServer={onStopDevServer}

View File

@@ -73,6 +73,7 @@ export interface WorktreePanelProps {
onCreateBranch: (worktree: WorktreeInfo) => void;
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
onResolveConflicts: (worktree: WorktreeInfo) => void;
onMerge: (worktree: WorktreeInfo) => void;
onRemovedWorktrees?: (removedWorktrees: Array<{ path: string; branch: string }>) => void;
runningFeatureIds?: string[];
features?: FeatureInfo[];

View File

@@ -30,6 +30,7 @@ export function WorktreePanel({
onCreateBranch,
onAddressPRComments,
onResolveConflicts,
onMerge,
onRemovedWorktrees,
runningFeatureIds = [],
features = [],
@@ -248,10 +249,12 @@ export function WorktreePanel({
onCreatePR={onCreatePR}
onAddressPRComments={onAddressPRComments}
onResolveConflicts={onResolveConflicts}
onMerge={onMerge}
onDeleteWorktree={onDeleteWorktree}
onStartDevServer={handleStartDevServer}
onStopDevServer={handleStopDevServer}
onOpenDevServerUrl={handleOpenDevServerUrl}
onViewDevServerLogs={handleViewDevServerLogs}
onRunInitScript={handleRunInitScript}
hasInitScript={hasInitScript}
/>
@@ -333,6 +336,7 @@ export function WorktreePanel({
onCreatePR={onCreatePR}
onAddressPRComments={onAddressPRComments}
onResolveConflicts={onResolveConflicts}
onMerge={onMerge}
onDeleteWorktree={onDeleteWorktree}
onStartDevServer={handleStartDevServer}
onStopDevServer={handleStopDevServer}
@@ -390,6 +394,7 @@ export function WorktreePanel({
onCreatePR={onCreatePR}
onAddressPRComments={onAddressPRComments}
onResolveConflicts={onResolveConflicts}
onMerge={onMerge}
onDeleteWorktree={onDeleteWorktree}
onStartDevServer={handleStartDevServer}
onStopDevServer={handleStopDevServer}

View File

@@ -1440,13 +1440,19 @@ function createMockSetupAPI(): SetupAPI {
// Mock Worktree API implementation
function createMockWorktreeAPI(): WorktreeAPI {
return {
mergeFeature: async (projectPath: string, featureId: string, options?: object) => {
mergeFeature: async (
projectPath: string,
branchName: string,
worktreePath: string,
options?: object
) => {
console.log('[Mock] Merging feature:', {
projectPath,
featureId,
branchName,
worktreePath,
options,
});
return { success: true, mergedBranch: `feature/${featureId}` };
return { success: true, mergedBranch: branchName };
},
getInfo: async (projectPath: string, featureId: string) => {

View File

@@ -1706,8 +1706,12 @@ export class HttpApiClient implements ElectronAPI {
// Worktree API
worktree: WorktreeAPI = {
mergeFeature: (projectPath: string, featureId: string, options?: object) =>
this.post('/api/worktree/merge', { projectPath, featureId, options }),
mergeFeature: (
projectPath: string,
branchName: string,
worktreePath: string,
options?: object
) => this.post('/api/worktree/merge', { projectPath, branchName, worktreePath, options }),
getInfo: (projectPath: string, featureId: string) =>
this.post('/api/worktree/info', { projectPath, featureId }),
getStatus: (projectPath: string, featureId: string) =>

View File

@@ -660,14 +660,14 @@ export interface FileDiffResult {
}
export interface WorktreeAPI {
// Merge feature worktree changes back to main branch
// Merge worktree branch into main and clean up
mergeFeature: (
projectPath: string,
featureId: string,
branchName: string,
worktreePath: string,
options?: {
squash?: boolean;
commitMessage?: string;
squashMessage?: string;
message?: string;
}
) => Promise<{
success: boolean;