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.
This commit is contained in:
Saoud Rizwan
2026-01-17 15:54:41 -08:00
parent 2df6586760
commit f2efe8767f
5 changed files with 471 additions and 4 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"cline": patch
---
Add git worktree management UI for running parallel Cline sessions
+27
View File
@@ -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.
</Warning>
### 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**
<Frame>
<img
src="https://storage.googleapis.com/cline_public_images/docs/assets/worktrees-merge.png"
alt="Merge worktree modal"
/>
</Frame>
#### 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
<Tip>
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.
</Tip>
## 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.
+21
View File
@@ -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
}
@@ -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<MergeWorktreeResult> {
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: [],
})
}
}
@@ -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<string | null>(null)
const [isLoadingDefaults, setIsLoadingDefaults] = useState(false)
// Merge worktree state
const [mergeWorktree, setMergeWorktree] = useState<WorktreeProto | null>(null)
const [isMerging, setIsMerging] = useState(false)
const [mergeError, setMergeError] = useState<string | null>(null)
const [mergeResult, setMergeResult] = useState<MergeWorktreeResult | null>(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 (
<div className="fixed inset-0 flex flex-col overflow-hidden">
{/* Sticky Header with title and Done button */}
@@ -383,6 +473,18 @@ const WorktreesView = ({ onDone }: WorktreesViewProps) => {
)}
{!worktree.isCurrent && !isMainWorktree(worktree) && (
<>
<Tooltip>
<TooltipTrigger asChild>
<VSCodeButton
appearance="icon"
onClick={() => openMergeModal(worktree)}>
<GitMerge className="w-4 h-4 text-[var(--vscode-testing-iconPassed)]" />
</VSCodeButton>
</TooltipTrigger>
<TooltipContent side="bottom">
Merge into {getMainBranch()} and close
</TooltipContent>
</Tooltip>
{deleteConfirmPath === worktree.path ? (
<div className="flex items-center gap-1">
<VSCodeButton
@@ -531,6 +633,126 @@ const WorktreesView = ({ onDone }: WorktreesViewProps) => {
</div>
</div>
)}
{/* Merge Worktree Modal */}
{mergeWorktree && (
<div
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
onClick={(e) => {
if (e.target === e.currentTarget && !isMerging) {
closeMergeModal()
}
}}>
<div className="bg-[var(--vscode-editor-background)] border border-[var(--vscode-panel-border)] rounded-lg p-5 w-[450px] max-w-[90vw] relative">
{/* Close button */}
<button
className="absolute top-3 right-3 p-1 rounded hover:bg-[var(--vscode-toolbar-hoverBackground)] text-[var(--vscode-descriptionForeground)] hover:text-[var(--vscode-foreground)] transition-colors cursor-pointer"
disabled={isMerging}
onClick={closeMergeModal}
type="button">
<X className="w-4 h-4" />
</button>
<div className="flex items-center gap-2 mb-2">
<GitMerge className="w-5 h-5 text-[var(--vscode-testing-iconPassed)]" />
<h4 className="m-0 pr-6">Merge & Close Worktree</h4>
</div>
{/* Success state */}
{mergeResult?.success ? (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2 p-3 rounded bg-[var(--vscode-testing-iconPassed)]/10 border border-[var(--vscode-testing-iconPassed)]">
<Check className="w-5 h-5 text-[var(--vscode-testing-iconPassed)]" />
<p className="text-sm m-0">{mergeResult.message}</p>
</div>
<div className="flex justify-end">
<VSCodeButton onClick={closeMergeModal}>Done</VSCodeButton>
</div>
</div>
) : mergeResult?.hasConflicts ? (
/* Conflict state */
<div className="flex flex-col gap-4">
<div className="flex items-start gap-2 p-3 rounded bg-[var(--vscode-inputValidation-warningBackground)] border border-[var(--vscode-inputValidation-warningBorder)]">
<AlertCircle className="w-5 h-5 flex-shrink-0 text-[var(--vscode-inputValidation-warningForeground)] mt-0.5" />
<div>
<p className="text-sm font-medium m-0 mb-1">Merge conflicts detected</p>
<p className="text-sm text-[var(--vscode-descriptionForeground)] m-0">
The following files have conflicts that need to be resolved:
</p>
</div>
</div>
<div className="bg-[var(--vscode-textCodeBlock-background)] rounded p-3 max-h-32 overflow-y-auto">
<ul className="m-0 pl-4 text-sm font-mono">
{mergeResult.conflictingFiles.map((file) => (
<li key={file}>{file}</li>
))}
</ul>
</div>
<div className="flex flex-col gap-2">
<VSCodeButton onClick={handleAskClineToResolve} style={{ width: "100%" }}>
<GitMerge className="w-4 h-4 mr-1" />
Ask Cline to Resolve & Merge
</VSCodeButton>
<VSCodeButton appearance="secondary" onClick={closeMergeModal} style={{ width: "100%" }}>
I'll Resolve Manually
</VSCodeButton>
</div>
</div>
) : (
/* Default state - confirm merge */
<div className="flex flex-col gap-4">
<p className="text-sm text-[var(--vscode-descriptionForeground)] m-0">
This will merge branch{" "}
<code className="bg-[var(--vscode-textCodeBlock-background)] px-1 rounded">
{mergeWorktree.branch}
</code>{" "}
into{" "}
<code className="bg-[var(--vscode-textCodeBlock-background)] px-1 rounded">
{getMainBranch()}
</code>
.
</p>
<label className="flex items-center gap-2 cursor-pointer">
<VSCodeCheckbox
checked={deleteAfterMerge}
onChange={(e) => setDeleteAfterMerge((e.target as HTMLInputElement).checked)}
/>
<span className="text-sm">Delete worktree after successful merge</span>
</label>
{mergeError && (
<div className="flex items-start gap-2 p-3 rounded bg-[var(--vscode-inputValidation-errorBackground)] border border-[var(--vscode-inputValidation-errorBorder)]">
<AlertCircle className="w-4 h-4 flex-shrink-0 text-[var(--vscode-errorForeground)] mt-0.5" />
<p className="text-sm text-[var(--vscode-errorForeground)] m-0">{mergeError}</p>
</div>
)}
<div className="flex justify-end gap-2">
<VSCodeButton appearance="secondary" disabled={isMerging} onClick={closeMergeModal}>
Cancel
</VSCodeButton>
<VSCodeButton disabled={isMerging} onClick={handleMergeWorktree}>
{isMerging ? (
<>
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
Merging...
</>
) : (
<>
<GitMerge className="w-4 h-4 mr-1" />
Merge
</>
)}
</VSCodeButton>
</div>
</div>
)}
</div>
</div>
)}
</div>
)
}