From 53fca25ba55db83824c04da4ea4fdfebce9b8d2b Mon Sep 17 00:00:00 2001
From: David Alecrim <35930364+davidalecrim1@users.noreply.github.com>
Date: Wed, 4 Mar 2026 18:49:06 -0300
Subject: [PATCH] git_ui: Add ability to delete git worktrees from picker
(#50015)
Adds the ability to delete a git worktree directly from the worktree
picker, inspired by the existing branch delete functionality.
(`cmd-shift-backspace` on macOS, `ctrl-shift-backspace` on
Linux/Windows).
Screenshot:
Release Notes:
- Added the ability to delete a git worktree from the worktree picker
---------
Co-authored-by: Anthony Eid
---
assets/keymaps/default-linux.json | 1 +
assets/keymaps/default-macos.json | 1 +
assets/keymaps/default-windows.json | 1 +
crates/git_ui/src/git_picker.rs | 16 ++++-
crates/git_ui/src/worktree_picker.rs | 88 +++++++++++++++++++++++++++-
crates/project/src/git_store.rs | 2 +-
6 files changed, 106 insertions(+), 3 deletions(-)
diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json
index 87e76829966b501df4139d4942de604c4fc42d65..7e01245ec62b2590a1c88fef5946b7d06463968d 100644
--- a/assets/keymaps/default-linux.json
+++ b/assets/keymaps/default-linux.json
@@ -1312,6 +1312,7 @@
"bindings": {
"ctrl-shift-space": "git::WorktreeFromDefaultOnWindow",
"ctrl-space": "git::WorktreeFromDefault",
+ "ctrl-shift-backspace": "git::DeleteWorktree",
},
},
{
diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json
index ccb3a7fa9116b0771dda94e75e467c4572cdaf2c..43d6419575fc698110cd5a033c01127ac6543f9a 100644
--- a/assets/keymaps/default-macos.json
+++ b/assets/keymaps/default-macos.json
@@ -1417,6 +1417,7 @@
"bindings": {
"ctrl-shift-space": "git::WorktreeFromDefaultOnWindow",
"ctrl-space": "git::WorktreeFromDefault",
+ "cmd-shift-backspace": "git::DeleteWorktree",
},
},
{
diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json
index 251c3d6541a611737027900e659a94271ed36526..22541368cecfc6a645e2b8b7ce55a6711491a012 100644
--- a/assets/keymaps/default-windows.json
+++ b/assets/keymaps/default-windows.json
@@ -1333,6 +1333,7 @@
"bindings": {
"ctrl-shift-space": "git::WorktreeFromDefaultOnWindow",
"ctrl-space": "git::WorktreeFromDefault",
+ "ctrl-shift-backspace": "git::DeleteWorktree",
},
},
{
diff --git a/crates/git_ui/src/git_picker.rs b/crates/git_ui/src/git_picker.rs
index 82ef9c9516b7c145edbf26d6c5b8927189525cab..6cf82327b43abe6c3784e4ec8ca3d16161edfda7 100644
--- a/crates/git_ui/src/git_picker.rs
+++ b/crates/git_ui/src/git_picker.rs
@@ -15,7 +15,7 @@ use workspace::{ModalView, Workspace, pane};
use crate::branch_picker::{self, BranchList, DeleteBranch, FilterRemotes};
use crate::stash_picker::{self, DropStashItem, ShowStashItem, StashList};
use crate::worktree_picker::{
- self, WorktreeFromDefault, WorktreeFromDefaultOnWindow, WorktreeList,
+ self, DeleteWorktree, WorktreeFromDefault, WorktreeFromDefaultOnWindow, WorktreeList,
};
actions!(
@@ -408,6 +408,19 @@ impl GitPicker {
}
}
+ fn handle_worktree_delete(
+ &mut self,
+ _: &DeleteWorktree,
+ window: &mut Window,
+ cx: &mut Context,
+ ) {
+ if let Some(worktree_list) = &self.worktree_list {
+ worktree_list.update(cx, |list, cx| {
+ list.handle_delete(&DeleteWorktree, window, cx);
+ });
+ }
+ }
+
fn handle_drop_stash(
&mut self,
_: &DropStashItem,
@@ -524,6 +537,7 @@ impl Render for GitPicker {
.when(self.tab == GitPickerTab::Worktrees, |el| {
el.on_action(cx.listener(Self::handle_worktree_from_default))
.on_action(cx.listener(Self::handle_worktree_from_default_on_window))
+ .on_action(cx.listener(Self::handle_worktree_delete))
})
.when(self.tab == GitPickerTab::Stash, |el| {
el.on_action(cx.listener(Self::handle_drop_stash))
diff --git a/crates/git_ui/src/worktree_picker.rs b/crates/git_ui/src/worktree_picker.rs
index 9f70c29da86ee52668984f92b247331524fc5936..6c35e7c99ffb8f6efa1a2bd7a07c2ded8d158668 100644
--- a/crates/git_ui/src/worktree_picker.rs
+++ b/crates/git_ui/src/worktree_picker.rs
@@ -22,7 +22,16 @@ use ui::{HighlightedLabel, KeyBinding, ListItem, ListItemSpacing, prelude::*};
use util::ResultExt;
use workspace::{ModalView, MultiWorkspace, Workspace, notifications::DetachAndPromptErr};
-actions!(git, [WorktreeFromDefault, WorktreeFromDefaultOnWindow]);
+use crate::git_panel::show_error_toast;
+
+actions!(
+ git,
+ [
+ WorktreeFromDefault,
+ WorktreeFromDefaultOnWindow,
+ DeleteWorktree
+ ]
+);
pub fn open(
workspace: &mut Workspace,
@@ -181,6 +190,19 @@ impl WorktreeList {
);
})
}
+
+ pub fn handle_delete(
+ &mut self,
+ _: &DeleteWorktree,
+ window: &mut Window,
+ cx: &mut Context,
+ ) {
+ self.picker.update(cx, |picker, cx| {
+ picker
+ .delegate
+ .delete_at(picker.delegate.selected_index, window, cx)
+ })
+ }
}
impl ModalView for WorktreeList {}
impl EventEmitter for WorktreeList {}
@@ -203,6 +225,9 @@ impl Render for WorktreeList {
.on_action(cx.listener(|this, _: &WorktreeFromDefaultOnWindow, w, cx| {
this.handle_new_worktree(true, w, cx)
}))
+ .on_action(cx.listener(|this, _: &DeleteWorktree, window, cx| {
+ this.handle_delete(&DeleteWorktree, window, cx)
+ }))
.child(self.picker.clone())
.when(!self.embedded, |el| {
el.on_mouse_down_out({
@@ -420,6 +445,57 @@ impl WorktreeListDelegate {
.as_ref()
.and_then(|repo| repo.read(cx).branch.as_ref().map(|b| b.name()))
}
+
+ fn delete_at(&self, idx: usize, window: &mut Window, cx: &mut Context>) {
+ let Some(entry) = self.matches.get(idx).cloned() else {
+ return;
+ };
+ if entry.is_new {
+ return;
+ }
+ let Some(repo) = self.repo.clone() else {
+ return;
+ };
+ let workspace = self.workspace.clone();
+ let path = entry.worktree.path;
+
+ cx.spawn_in(window, async move |picker, cx| {
+ let result = repo
+ .update(cx, |repo, _| repo.remove_worktree(path.clone(), false))
+ .await?;
+
+ if let Err(e) = result {
+ log::error!("Failed to remove worktree: {}", e);
+ if let Some(workspace) = workspace.upgrade() {
+ cx.update(|_window, cx| {
+ show_error_toast(
+ workspace,
+ format!("worktree remove {}", path.display()),
+ e,
+ cx,
+ )
+ })?;
+ }
+ return Ok(());
+ }
+
+ picker.update_in(cx, |picker, _, cx| {
+ picker.delegate.matches.retain(|e| e.worktree.path != path);
+ if let Some(all_worktrees) = &mut picker.delegate.all_worktrees {
+ all_worktrees.retain(|w| w.path != path);
+ }
+ if picker.delegate.matches.is_empty() {
+ picker.delegate.selected_index = 0;
+ } else if picker.delegate.selected_index >= picker.delegate.matches.len() {
+ picker.delegate.selected_index = picker.delegate.matches.len() - 1;
+ }
+ cx.notify();
+ })?;
+
+ anyhow::Ok(())
+ })
+ .detach();
+ }
}
async fn open_remote_worktree(
@@ -778,6 +854,16 @@ impl PickerDelegate for WorktreeListDelegate {
} else {
Some(
footer_container
+ .child(
+ Button::new("delete-worktree", "Delete")
+ .key_binding(
+ KeyBinding::for_action_in(&DeleteWorktree, &focus_handle, cx)
+ .map(|kb| kb.size(rems_from_px(12.))),
+ )
+ .on_click(|_, window, cx| {
+ window.dispatch_action(DeleteWorktree.boxed_clone(), cx)
+ }),
+ )
.child(
Button::new("open-in-new-window", "Open in New Window")
.key_binding(
diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs
index fdafea73fd0ca797616cc58fc9e4b6a3c2101224..eed16761974876247df2e5936f9db9fbdd8fafcc 100644
--- a/crates/project/src/git_store.rs
+++ b/crates/project/src/git_store.rs
@@ -5732,7 +5732,7 @@ impl Repository {
pub fn remove_worktree(&mut self, path: PathBuf, force: bool) -> oneshot::Receiver> {
self.send_job(
- Some("git worktree remove".into()),
+ Some(format!("git worktree remove: {}", path.display()).into()),
move |repo, _cx| async move {
match repo {
RepositoryState::Local(LocalRepositoryState { backend, .. }) => {