git_ui: Add ability to delete git worktrees from picker (#50015)

David Alecrim and Anthony Eid created

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:
<img width="1288" height="466" alt="Screenshot 2026-02-24 at 16 01 05"
src="https://github.com/user-attachments/assets/6ca5048d-63fa-4295-a578-358904bb92eb"
/>

Release Notes:

- Added the ability to delete a git worktree from the worktree picker

---------

Co-authored-by: Anthony Eid <anthony@zed.dev>

Change summary

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(-)

Detailed changes

assets/keymaps/default-linux.json 🔗

@@ -1312,6 +1312,7 @@
     "bindings": {
       "ctrl-shift-space": "git::WorktreeFromDefaultOnWindow",
       "ctrl-space": "git::WorktreeFromDefault",
+      "ctrl-shift-backspace": "git::DeleteWorktree",
     },
   },
   {

assets/keymaps/default-macos.json 🔗

@@ -1417,6 +1417,7 @@
     "bindings": {
       "ctrl-shift-space": "git::WorktreeFromDefaultOnWindow",
       "ctrl-space": "git::WorktreeFromDefault",
+      "cmd-shift-backspace": "git::DeleteWorktree",
     },
   },
   {

assets/keymaps/default-windows.json 🔗

@@ -1333,6 +1333,7 @@
     "bindings": {
       "ctrl-shift-space": "git::WorktreeFromDefaultOnWindow",
       "ctrl-space": "git::WorktreeFromDefault",
+      "ctrl-shift-backspace": "git::DeleteWorktree",
     },
   },
   {

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<Self>,
+    ) {
+        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))

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>,
+    ) {
+        self.picker.update(cx, |picker, cx| {
+            picker
+                .delegate
+                .delete_at(picker.delegate.selected_index, window, cx)
+        })
+    }
 }
 impl ModalView for WorktreeList {}
 impl EventEmitter<DismissEvent> 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<Picker<Self>>) {
+        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(

crates/project/src/git_store.rs 🔗

@@ -5732,7 +5732,7 @@ impl Repository {
 
     pub fn remove_worktree(&mut self, path: PathBuf, force: bool) -> oneshot::Receiver<Result<()>> {
         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, .. }) => {