From f2efe8767fc60578d78a7c7e47eddd6ea54ee852 Mon Sep 17 00:00:00 2001 From: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com> Date: Sat, 27 Dec 2025 08:14:37 -0800 Subject: [PATCH] feat: add worktree merging functionality and UI enhancements Introduces a new feature for merging worktrees, allowing users to merge changes from a worktree's branch into the main branch with options to delete the worktree post-merge. Updates the WorktreesView component to include a merge modal, handling merge conflicts, and integrating with the WorktreeService for seamless operations. Additionally, enhances documentation to reflect these changes. --- .changeset/worktree-management-ui.md | 5 + docs/features/worktrees.mdx | 27 ++ proto/cline/worktree.proto | 21 ++ src/core/controller/worktree/mergeWorktree.ts | 192 +++++++++++++++ .../components/worktrees/WorktreesView.tsx | 230 +++++++++++++++++- 5 files changed, 471 insertions(+), 4 deletions(-) create mode 100644 .changeset/worktree-management-ui.md create mode 100644 src/core/controller/worktree/mergeWorktree.ts diff --git a/.changeset/worktree-management-ui.md b/.changeset/worktree-management-ui.md new file mode 100644 index 0000000000..beb292893e --- /dev/null +++ b/.changeset/worktree-management-ui.md @@ -0,0 +1,5 @@ +--- +"cline": patch +--- + +Add git worktree management UI for running parallel Cline sessions diff --git a/docs/features/worktrees.mdx b/docs/features/worktrees.mdx index 7a54e8b82a..52e9dc2b72 100644 --- a/docs/features/worktrees.mdx +++ b/docs/features/worktrees.mdx @@ -78,6 +78,33 @@ Click the trash icon on any linked worktree to delete it. This removes the workt You cannot delete the main worktree. It's the primary repository where your `.git` directory lives. +### Merging Worktrees + +When you're done working in a worktree and ready to merge your changes back to the main branch: + +1. Click the **merge icon** (git merge symbol) on any linked worktree +2. Review the merge details in the confirmation modal +3. Choose whether to delete the worktree after merging +4. Click **Merge** + + + Merge worktree modal + + +#### Handling Merge Conflicts + +If your branch has conflicts with the main branch, Cline will detect them and show you the conflicting files. You have two options: + +1. **Ask Cline to Resolve & Merge** - Creates a new Cline task with a prompt asking Cline to resolve the conflicts, complete the merge, and clean up the worktree +2. **Resolve Manually** - Close the modal and resolve conflicts yourself using your preferred Git tools + + + The "Ask Cline to Resolve" option is particularly useful for complex conflicts. Cline will analyze the conflicting files and attempt to merge them intelligently based on the intent of both branches. + + ## Worktreeinclude: Automatic File Copying {#worktreeinclude} When you create a new worktree, it starts with a fresh checkout—no `node_modules`, no build artifacts, no IDE settings. This means you'd normally need to run `npm install` or similar setup commands. diff --git a/proto/cline/worktree.proto b/proto/cline/worktree.proto index 7abaefc23c..d914dac918 100644 --- a/proto/cline/worktree.proto +++ b/proto/cline/worktree.proto @@ -36,6 +36,9 @@ service WorktreeService { // Switches to a different branch in the current worktree (git checkout) rpc checkoutBranch(CheckoutBranchRequest) returns (WorktreeResult); + + // Merges a worktree's branch into the target branch and optionally deletes the worktree + rpc mergeWorktree(MergeWorktreeRequest) returns (MergeWorktreeResult); } // Represents a single git worktree @@ -118,3 +121,21 @@ message CheckoutBranchRequest { string branch = 2; // Branch name to checkout } +// Request to merge a worktree's branch into target branch +message MergeWorktreeRequest { + Metadata metadata = 1; + string worktree_path = 2; // Path of the worktree to merge + string target_branch = 3; // Branch to merge into (e.g., "main") + bool delete_after_merge = 4; // Whether to delete the worktree after successful merge +} + +// Result of merge operation +message MergeWorktreeResult { + bool success = 1; + string message = 2; // Success or error message + bool has_conflicts = 3; // Whether merge resulted in conflicts + repeated string conflicting_files = 4; // List of files with conflicts + string source_branch = 5; // The branch that was merged + string target_branch = 6; // The branch merged into +} + diff --git a/src/core/controller/worktree/mergeWorktree.ts b/src/core/controller/worktree/mergeWorktree.ts new file mode 100644 index 0000000000..910cf4efe2 --- /dev/null +++ b/src/core/controller/worktree/mergeWorktree.ts @@ -0,0 +1,192 @@ +import { MergeWorktreeRequest, MergeWorktreeResult } from "@shared/proto/cline/worktree" +import { getWorkspacePath } from "@utils/path" +import { exec } from "child_process" +import { promisify } from "util" +import { Controller } from ".." + +const execAsync = promisify(exec) + +/** + * Merges a worktree's branch into the target branch and optionally deletes the worktree + * @param controller The controller instance + * @param request The merge worktree request + * @returns MergeWorktreeResult indicating success, failure, or conflicts + */ +export async function mergeWorktree(_controller: Controller, request: MergeWorktreeRequest): Promise { + const cwd = await getWorkspacePath() + if (!cwd) { + return MergeWorktreeResult.create({ + success: false, + message: "No workspace folder found", + hasConflicts: false, + conflictingFiles: [], + }) + } + + const { worktreePath, targetBranch, deleteAfterMerge } = request + + if (!worktreePath) { + return MergeWorktreeResult.create({ + success: false, + message: "Worktree path is required", + hasConflicts: false, + conflictingFiles: [], + }) + } + + if (!targetBranch) { + return MergeWorktreeResult.create({ + success: false, + message: "Target branch is required", + hasConflicts: false, + conflictingFiles: [], + }) + } + + try { + // Get the branch name of the worktree + let sourceBranch: string + try { + const { stdout } = await execAsync(`git -C "${worktreePath}" rev-parse --abbrev-ref HEAD`) + sourceBranch = stdout.trim() + } catch { + return MergeWorktreeResult.create({ + success: false, + message: "Failed to get branch name from worktree", + hasConflicts: false, + conflictingFiles: [], + }) + } + + if (sourceBranch === "HEAD") { + return MergeWorktreeResult.create({ + success: false, + message: "Cannot merge a detached HEAD worktree", + hasConflicts: false, + conflictingFiles: [], + sourceBranch, + targetBranch, + }) + } + + // Check for uncommitted changes in the worktree + try { + const { stdout: statusOutput } = await execAsync(`git -C "${worktreePath}" status --porcelain`) + if (statusOutput.trim()) { + return MergeWorktreeResult.create({ + success: false, + message: `Worktree has uncommitted changes. Please commit or stash them first.`, + hasConflicts: false, + conflictingFiles: [], + sourceBranch, + targetBranch, + }) + } + } catch { + // If status check fails, continue anyway + } + + // Checkout target branch in the main worktree + try { + await execAsync(`git checkout "${targetBranch}"`, { cwd }) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + return MergeWorktreeResult.create({ + success: false, + message: `Failed to checkout target branch '${targetBranch}': ${errorMessage}`, + hasConflicts: false, + conflictingFiles: [], + sourceBranch, + targetBranch, + }) + } + + // Attempt the merge + try { + await execAsync(`git merge "${sourceBranch}" --no-edit`, { cwd }) + } catch (error) { + // Check if it's a merge conflict + try { + const { stdout: conflictOutput } = await execAsync(`git diff --name-only --diff-filter=U`, { cwd }) + const conflictingFiles = conflictOutput + .trim() + .split("\n") + .filter((f) => f) + + if (conflictingFiles.length > 0) { + // Abort the merge so we don't leave the repo in a conflicted state + try { + await execAsync(`git merge --abort`, { cwd }) + } catch { + // Ignore abort errors + } + + return MergeWorktreeResult.create({ + success: false, + message: `Merge conflict detected. ${conflictingFiles.length} file(s) have conflicts.`, + hasConflicts: true, + conflictingFiles, + sourceBranch, + targetBranch, + }) + } + } catch { + // If conflict check fails, return the original error + } + + const errorMessage = error instanceof Error ? error.message : String(error) + return MergeWorktreeResult.create({ + success: false, + message: `Merge failed: ${errorMessage}`, + hasConflicts: false, + conflictingFiles: [], + sourceBranch, + targetBranch, + }) + } + + // Delete worktree if requested + if (deleteAfterMerge) { + try { + await execAsync(`git worktree remove "${worktreePath}" --force`, { cwd }) + } catch (error) { + // Merge succeeded but deletion failed - still return success + const errorMessage = error instanceof Error ? error.message : String(error) + return MergeWorktreeResult.create({ + success: true, + message: `Merged '${sourceBranch}' into '${targetBranch}' successfully, but failed to delete worktree: ${errorMessage}`, + hasConflicts: false, + conflictingFiles: [], + sourceBranch, + targetBranch, + }) + } + + // Optionally delete the branch too + try { + await execAsync(`git branch -d "${sourceBranch}"`, { cwd }) + } catch { + // Branch deletion is optional, don't fail if it doesn't work + } + } + + return MergeWorktreeResult.create({ + success: true, + message: deleteAfterMerge + ? `Successfully merged '${sourceBranch}' into '${targetBranch}' and removed worktree` + : `Successfully merged '${sourceBranch}' into '${targetBranch}'`, + hasConflicts: false, + conflictingFiles: [], + sourceBranch, + targetBranch, + }) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + return MergeWorktreeResult.create({ + success: false, + message: `Unexpected error: ${errorMessage}`, + hasConflicts: false, + conflictingFiles: [], + }) + } +} diff --git a/webview-ui/src/components/worktrees/WorktreesView.tsx b/webview-ui/src/components/worktrees/WorktreesView.tsx index c3c2485c2d..8a88941a8c 100644 --- a/webview-ui/src/components/worktrees/WorktreesView.tsx +++ b/webview-ui/src/components/worktrees/WorktreesView.tsx @@ -1,17 +1,19 @@ import { EmptyRequest } from "@shared/proto/cline/common" -import type { Worktree as WorktreeProto } from "@shared/proto/cline/worktree" +import { NewTaskRequest } from "@shared/proto/cline/task" +import type { MergeWorktreeResult, Worktree as WorktreeProto } from "@shared/proto/cline/worktree" import { CreateWorktreeIncludeRequest, CreateWorktreeRequest, DeleteWorktreeRequest, + MergeWorktreeRequest, SwitchWorktreeRequest, } from "@shared/proto/cline/worktree" -import { VSCodeButton, VSCodeTextField } from "@vscode/webview-ui-toolkit/react" -import { AlertCircle, Check, ExternalLink, FolderOpen, GitBranch, Loader2, Plus, Trash2, X } from "lucide-react" +import { VSCodeButton, VSCodeCheckbox, VSCodeTextField } from "@vscode/webview-ui-toolkit/react" +import { AlertCircle, Check, ExternalLink, FolderOpen, GitBranch, GitMerge, Loader2, Plus, Trash2, X } from "lucide-react" import { memo, useCallback, useEffect, useState } from "react" import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" import { useExtensionState } from "@/context/ExtensionStateContext" -import { FileServiceClient, WorktreeServiceClient } from "@/services/grpc-client" +import { FileServiceClient, TaskServiceClient, WorktreeServiceClient } from "@/services/grpc-client" import { getEnvironmentColor } from "@/utils/environmentColors" type WorktreesViewProps = { @@ -32,6 +34,13 @@ const WorktreesView = ({ onDone }: WorktreesViewProps) => { const [deleteConfirmPath, setDeleteConfirmPath] = useState(null) const [isLoadingDefaults, setIsLoadingDefaults] = useState(false) + // Merge worktree state + const [mergeWorktree, setMergeWorktree] = useState(null) + const [isMerging, setIsMerging] = useState(false) + const [mergeError, setMergeError] = useState(null) + const [mergeResult, setMergeResult] = useState(null) + const [deleteAfterMerge, setDeleteAfterMerge] = useState(true) + // .worktreeinclude status const [hasWorktreeInclude, setHasWorktreeInclude] = useState(false) const [hasGitignore, setHasGitignore] = useState(false) @@ -102,11 +111,21 @@ const WorktreesView = ({ onDone }: WorktreesViewProps) => { } }, [gitignoreContent]) + // Initial load useEffect(() => { loadWorktrees() loadWorktreeIncludeStatus() }, [loadWorktrees, loadWorktreeIncludeStatus]) + // Poll for updates every 3 seconds while the view is open + useEffect(() => { + const interval = setInterval(() => { + loadWorktrees() + }, 3000) + + return () => clearInterval(interval) + }, [loadWorktrees]) + // Fetch and apply suggested defaults for branch name and path const loadDefaults = useCallback(async () => { setIsLoadingDefaults(true) @@ -195,6 +214,77 @@ const WorktreesView = ({ onDone }: WorktreesViewProps) => { } }, []) + // Get the main branch name (first worktree's branch, usually main/master) + const getMainBranch = useCallback(() => { + if (worktrees.length === 0) return "main" + return worktrees[0]?.branch || "main" + }, [worktrees]) + + // Open merge modal for a worktree + const openMergeModal = useCallback((worktree: WorktreeProto) => { + setMergeWorktree(worktree) + setMergeError(null) + setMergeResult(null) + setDeleteAfterMerge(true) + }, []) + + // Close merge modal + const closeMergeModal = useCallback(() => { + setMergeWorktree(null) + setMergeError(null) + setMergeResult(null) + }, []) + + // Handle merge + const handleMergeWorktree = useCallback(async () => { + if (!mergeWorktree) return + + setIsMerging(true) + setMergeError(null) + setMergeResult(null) + + try { + const result = await WorktreeServiceClient.mergeWorktree( + MergeWorktreeRequest.create({ + worktreePath: mergeWorktree.path, + targetBranch: getMainBranch(), + deleteAfterMerge, + }), + ) + + setMergeResult(result) + + if (result.success) { + // Reload worktrees to reflect changes + await loadWorktrees() + } else if (!result.hasConflicts) { + setMergeError(result.message) + } + } catch (err) { + setMergeError(err instanceof Error ? err.message : "Failed to merge worktree") + } finally { + setIsMerging(false) + } + }, [mergeWorktree, getMainBranch, deleteAfterMerge, loadWorktrees, closeMergeModal]) + + // Ask Cline to resolve conflicts + const handleAskClineToResolve = useCallback(async () => { + if (!mergeResult || !mergeResult.hasConflicts) return + + const conflictList = mergeResult.conflictingFiles.join(", ") + const prompt = `I tried to merge branch '${mergeResult.sourceBranch}' into '${mergeResult.targetBranch}' but there are merge conflicts in the following files: ${conflictList} + +Please help me resolve these merge conflicts, then complete the merge, and delete the worktree at: ${mergeWorktree?.path}` + + try { + // Create a new task with this prompt + await TaskServiceClient.newTask(NewTaskRequest.create({ text: prompt })) + closeMergeModal() + } catch (err) { + setMergeError(err instanceof Error ? err.message : "Failed to create task for Cline") + } + }, [mergeResult, mergeWorktree, closeMergeModal]) + return (
{/* Sticky Header with title and Done button */} @@ -383,6 +473,18 @@ const WorktreesView = ({ onDone }: WorktreesViewProps) => { )} {!worktree.isCurrent && !isMainWorktree(worktree) && ( <> + + + openMergeModal(worktree)}> + + + + + Merge into {getMainBranch()} and close + + {deleteConfirmPath === worktree.path ? (
{
)} + + {/* Merge Worktree Modal */} + {mergeWorktree && ( +
{ + if (e.target === e.currentTarget && !isMerging) { + closeMergeModal() + } + }}> +
+ {/* Close button */} + + +
+ +

Merge & Close Worktree

+
+ + {/* Success state */} + {mergeResult?.success ? ( +
+
+ +

{mergeResult.message}

+
+
+ Done +
+
+ ) : mergeResult?.hasConflicts ? ( + /* Conflict state */ +
+
+ +
+

Merge conflicts detected

+

+ The following files have conflicts that need to be resolved: +

+
+
+ +
+
    + {mergeResult.conflictingFiles.map((file) => ( +
  • {file}
  • + ))} +
+
+ +
+ + + Ask Cline to Resolve & Merge + + + I'll Resolve Manually + +
+
+ ) : ( + /* Default state - confirm merge */ +
+

+ This will merge branch{" "} + + {mergeWorktree.branch} + {" "} + into{" "} + + {getMainBranch()} + + . +

+ + + + {mergeError && ( +
+ +

{mergeError}

+
+ )} + +
+ + Cancel + + + {isMerging ? ( + <> + + Merging... + + ) : ( + <> + + Merge + + )} + +
+
+ )} +
+
+ )} ) }