mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +00:00
Merge pull request #492 from AutoMaker-Org/feature/v0.11.0rc-1768413895104-31pa
feat: merge worktree to main in dropdown menu
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
8
apps/ui/src/types/electron.d.ts
vendored
8
apps/ui/src/types/electron.d.ts
vendored
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user