git_ui: Add force delete for unmerged branches (#55927)

Smit Barmase created

Git's `-d` flag deletes a branch only if it's fully merged into its
upstream or HEAD - this is what we were using before, which caused the
"not fully merged" error. The `-D` flag force deletes a branch even with
unmerged changes (equivalent to `--delete --force`).

### Before

Deleting an unmerged branch failed with a "not fully merged" error
toast.

### After

- Deleting an unmerged branch prompts for confirmation to force delete
- Delete button tooltip shows "Hold alt to force delete" hint
- Holding **alt** turns the delete icon red and tooltip changes to
"Force Delete Branch"
- Force delete keybinding: `cmd-alt-shift-backspace`

Release Notes:

- Added confirmation prompt when deleting unmerged git branches, with
option to force delete.
- Added alt+click on delete button to force delete a branch immediately.

Change summary

assets/keymaps/default-linux.json   |   1 
assets/keymaps/default-macos.json   |   1 
assets/keymaps/default-windows.json |   1 
crates/fs/src/fake_git_repo.rs      |  15 
crates/git/src/repository.rs        |  28 +
crates/git_ui/src/branch_picker.rs  | 514 +++++++++++++++++++++++++++---
crates/git_ui/src/git_picker.rs     |  16 
crates/project/src/git_store.rs     |  22 
crates/proto/proto/git.proto        |   1 
9 files changed, 521 insertions(+), 78 deletions(-)

Detailed changes

assets/keymaps/default-linux.json 🔗

@@ -1499,6 +1499,7 @@
     "use_key_equivalents": true,
     "bindings": {
       "ctrl-shift-backspace": "branch_picker::DeleteBranch",
+      "ctrl-alt-shift-backspace": "branch_picker::ForceDeleteBranch",
       "ctrl-shift-i": "branch_picker::FilterRemotes",
     },
   },

assets/keymaps/default-macos.json 🔗

@@ -1552,6 +1552,7 @@
     "use_key_equivalents": true,
     "bindings": {
       "cmd-shift-backspace": "branch_picker::DeleteBranch",
+      "cmd-alt-shift-backspace": "branch_picker::ForceDeleteBranch",
       "cmd-shift-i": "branch_picker::FilterRemotes",
     },
   },

assets/keymaps/default-windows.json 🔗

@@ -1479,6 +1479,7 @@
     "use_key_equivalents": true,
     "bindings": {
       "ctrl-shift-backspace": "branch_picker::DeleteBranch",
+      "ctrl-alt-shift-backspace": "branch_picker::ForceDeleteBranch",
       "ctrl-shift-i": "branch_picker::FilterRemotes",
     },
   },

crates/fs/src/fake_git_repo.rs 🔗

@@ -72,6 +72,7 @@ pub struct FakeGitRepositoryState {
     pub simulated_index_write_error_message: Option<String>,
     pub simulated_create_worktree_error: Option<String>,
     pub simulated_graph_error: Option<String>,
+    pub branches_requiring_force_delete: HashSet<String>,
     pub refs: HashMap<String, String>,
     pub graph_commits: Vec<Arc<InitialGraphCommitData>>,
     pub commit_data: HashMap<Oid, FakeCommitDataEntry>,
@@ -91,6 +92,7 @@ impl FakeGitRepositoryState {
             simulated_index_write_error_message: Default::default(),
             simulated_create_worktree_error: Default::default(),
             simulated_graph_error: None,
+            branches_requiring_force_delete: Default::default(),
             refs: HashMap::from_iter([("HEAD".into(), "abc".into())]),
             merge_base_contents: Default::default(),
             oids: Default::default(),
@@ -888,11 +890,22 @@ impl GitRepository for FakeGitRepository {
         })
     }
 
-    fn delete_branch(&self, _is_remote: bool, name: String) -> BoxFuture<'_, Result<()>> {
+    fn delete_branch(
+        &self,
+        _is_remote: bool,
+        name: String,
+        force: bool,
+    ) -> BoxFuture<'_, Result<()>> {
         self.with_state_async(true, move |state| {
+            if !force && state.branches_requiring_force_delete.contains(&name) {
+                bail!(
+                    "error: The branch '{name}' is not fully merged.\nIf you are sure you want to delete it, run 'git branch -D {name}'."
+                );
+            }
             if !state.branches.remove(&name) {
                 bail!("no such branch: {name}");
             }
+            state.branches_requiring_force_delete.remove(&name);
             Ok(())
         })
     }

crates/git/src/repository.rs 🔗

@@ -721,6 +721,15 @@ pub struct SearchCommitArgs {
     pub case_sensitive: bool,
 }
 
+pub fn delete_branch_flag(is_remote_tracking_ref: bool, force: bool) -> &'static str {
+    match (is_remote_tracking_ref, force) {
+        (true, true) => "-Dr",
+        (true, false) => "-dr",
+        (false, true) => "-D",
+        (false, false) => "-d",
+    }
+}
+
 pub trait GitRepository: Send + Sync {
     fn reload_index(&self);
 
@@ -775,7 +784,12 @@ pub trait GitRepository: Send + Sync {
     -> BoxFuture<'_, Result<()>>;
     fn rename_branch(&self, branch: String, new_name: String) -> BoxFuture<'_, Result<()>>;
 
-    fn delete_branch(&self, is_remote: bool, name: String) -> BoxFuture<'_, Result<()>>;
+    fn delete_branch(
+        &self,
+        is_remote: bool,
+        name: String,
+        force: bool,
+    ) -> BoxFuture<'_, Result<()>>;
 
     fn worktrees(&self) -> BoxFuture<'_, Result<Vec<Worktree>>>;
 
@@ -2033,14 +2047,18 @@ impl GitRepository for RealGitRepository {
             .boxed()
     }
 
-    fn delete_branch(&self, is_remote: bool, name: String) -> BoxFuture<'_, Result<()>> {
+    fn delete_branch(
+        &self,
+        is_remote: bool,
+        name: String,
+        force: bool,
+    ) -> BoxFuture<'_, Result<()>> {
         let git_binary = self.git_binary_in_worktree();
 
         self.executor
             .spawn(async move {
-                git_binary?
-                    .run(&["branch", if is_remote { "-dr" } else { "-d" }, &name])
-                    .await?;
+                let flag = delete_branch_flag(is_remote, force);
+                git_binary?.run(&["branch", flag, &name]).await?;
                 anyhow::Ok(())
             })
             .boxed()

crates/git_ui/src/branch_picker.rs 🔗

@@ -3,12 +3,12 @@ use editor::Editor;
 use fuzzy_nucleo::StringMatchCandidate;
 
 use collections::HashSet;
-use git::repository::Branch;
+use git::repository::{Branch, delete_branch_flag};
 use gpui::http_client::Url;
 use gpui::{
     Action, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
-    InteractiveElement, IntoElement, Modifiers, ModifiersChangedEvent, ParentElement, Render,
-    SharedString, Styled, Subscription, Task, TaskExt, WeakEntity, Window, actions, rems,
+    InteractiveElement, IntoElement, Modifiers, ModifiersChangedEvent, ParentElement, PromptLevel,
+    Render, SharedString, Styled, Subscription, Task, TaskExt, WeakEntity, Window, actions, rems,
 };
 use picker::{Picker, PickerDelegate, PickerEditorPosition};
 use project::git_store::{Repository, RepositoryEvent};
@@ -29,6 +29,8 @@ actions!(
     [
         /// Deletes the selected git branch or remote.
         DeleteBranch,
+        /// Force deletes the selected git branch or remote.
+        ForceDeleteBranch,
         /// Filter the list of remotes
         FilterRemotes
     ]
@@ -254,8 +256,10 @@ impl BranchList {
         _: &mut Window,
         cx: &mut Context<Self>,
     ) {
-        self.picker
-            .update(cx, |picker, _| picker.delegate.modifiers = ev.modifiers)
+        self.picker.update(cx, |picker, cx| {
+            picker.delegate.modifiers = ev.modifiers;
+            cx.notify();
+        })
     }
 
     pub fn handle_delete(
@@ -267,7 +271,20 @@ impl BranchList {
         self.picker.update(cx, |picker, cx| {
             picker
                 .delegate
-                .delete_at(picker.delegate.selected_index, window, cx)
+                .delete_at(picker.delegate.selected_index, false, window, cx)
+        })
+    }
+
+    pub fn handle_force_delete(
+        &mut self,
+        _: &branch_picker::ForceDeleteBranch,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        self.picker.update(cx, |picker, cx| {
+            picker
+                .delegate
+                .delete_at(picker.delegate.selected_index, true, window, cx)
         })
     }
 
@@ -301,6 +318,7 @@ impl Render for BranchList {
             .w(self.width)
             .on_modifiers_changed(cx.listener(Self::handle_modifiers_changed))
             .on_action(cx.listener(Self::handle_delete))
+            .on_action(cx.listener(Self::handle_force_delete))
             .on_action(cx.listener(Self::handle_filter))
             .child(self.picker.clone())
             .when(!self.embedded, |this| {
@@ -393,6 +411,7 @@ pub struct BranchListDelegate {
     focus_handle: FocusHandle,
     restore_selected_branch: Option<SharedString>,
     show_footer: bool,
+    hovered_delete_index: Option<usize>,
 }
 
 #[derive(Debug)]
@@ -407,6 +426,77 @@ enum PickerState {
     NewBranch,
 }
 
+fn delete_branch_command(is_remote: bool, branch_name: &str, force: bool) -> String {
+    format!(
+        "branch {} {branch_name}",
+        delete_branch_flag(is_remote, force)
+    )
+}
+
+// Git only reports "not fully merged" via localized stderr, so this
+// best-effort check may miss some locales and fall back to the raw error toast.
+fn is_unmerged_branch_delete_error(error: &anyhow::Error) -> bool {
+    error
+        .to_string()
+        .to_lowercase()
+        .contains("not fully merged")
+}
+
+struct DeleteBranchTooltip {
+    picker: WeakEntity<Picker<BranchListDelegate>>,
+    focus_handle: FocusHandle,
+    delete_index: usize,
+    _subscription: Subscription,
+}
+
+impl DeleteBranchTooltip {
+    fn new(
+        picker: Entity<Picker<BranchListDelegate>>,
+        focus_handle: FocusHandle,
+        delete_index: usize,
+        cx: &mut Context<Self>,
+    ) -> Self {
+        let subscription = cx.observe(&picker, |_, _, cx| cx.notify());
+        Self {
+            picker: picker.downgrade(),
+            focus_handle,
+            delete_index,
+            _subscription: subscription,
+        }
+    }
+}
+
+impl Render for DeleteBranchTooltip {
+    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        let force_delete = self
+            .picker
+            .read_with(cx, |picker, _| {
+                picker
+                    .delegate
+                    .is_force_delete_hovering_index(self.delete_index)
+            })
+            .unwrap_or(false);
+        if force_delete {
+            Tooltip::for_action_in(
+                "Force Delete Branch",
+                &branch_picker::ForceDeleteBranch,
+                &self.focus_handle,
+                cx,
+            )
+            .into_any_element()
+        } else {
+            Tooltip::with_meta_in(
+                "Delete Branch",
+                Some(&branch_picker::DeleteBranch),
+                "Hold alt to force delete",
+                &self.focus_handle,
+                cx,
+            )
+            .into_any_element()
+        }
+    }
+}
+
 fn process_branches(branches: &Arc<[Branch]>) -> Vec<Branch> {
     let remote_upstreams: HashSet<_> = branches
         .iter()
@@ -460,9 +550,14 @@ impl BranchListDelegate {
             focus_handle: cx.focus_handle(),
             restore_selected_branch: None,
             show_footer: false,
+            hovered_delete_index: None,
         }
     }
 
+    fn is_force_delete_hovering_index(&self, index: usize) -> bool {
+        self.modifiers.alt && self.hovered_delete_index == Some(index)
+    }
+
     fn create_branch(
         &self,
         from_branch: Option<SharedString>,
@@ -509,7 +604,13 @@ impl BranchListDelegate {
         cx.emit(DismissEvent);
     }
 
-    fn delete_at(&self, idx: usize, window: &mut Window, cx: &mut Context<Picker<Self>>) {
+    fn delete_at(
+        &self,
+        idx: usize,
+        force: bool,
+        window: &mut Window,
+        cx: &mut Context<Picker<Self>>,
+    ) {
         let Some(entry) = self.matches.get(idx).cloned() else {
             return;
         };
@@ -520,49 +621,75 @@ impl BranchListDelegate {
         let workspace = self.workspace.clone();
 
         cx.spawn_in(window, async move |picker, cx| {
-            let is_remote;
-            let result = match &entry {
-                Entry::Branch { branch, .. } => {
-                    if branch.is_head {
-                        return Ok(());
+            let Entry::Branch { branch, .. } = &entry else {
+                log::error!("Failed to delete entry: wrong entry to delete");
+                return Ok(());
+            };
+
+            if branch.is_head {
+                return Ok(());
+            }
+
+            let is_remote = branch.is_remote();
+            let branch_name = branch.name().to_string();
+            let initial_result = repo
+                .update(cx, |repo, _| {
+                    repo.delete_branch(is_remote, branch_name.clone(), force)
+                })
+                .await?;
+
+            let (result, attempted_force) = match initial_result {
+                Ok(()) => (Ok(()), force),
+                Err(error) => {
+                    if is_remote {
+                        log::error!("Failed to delete remote branch: {error}");
+                    } else {
+                        log::error!("Failed to delete branch: {error}");
                     }
 
-                    is_remote = branch.is_remote();
-                    repo.update(cx, |repo, _| {
-                        repo.delete_branch(is_remote, branch.name().to_string())
-                    })
-                    .await?
-                }
-                _ => {
-                    log::error!("Failed to delete entry: wrong entry to delete");
-                    return Ok(());
-                }
-            };
+                    if force || !is_unmerged_branch_delete_error(&error) {
+                        (Err(error), force)
+                    } else {
+                        let answer = cx.update(|window, cx| {
+                            window.prompt(
+                                PromptLevel::Warning,
+                                &format!(
+                                    "Branch \"{}\" is not fully merged. Force delete it?",
+                                    entry.name()
+                                ),
+                                None,
+                                &["Force Delete", "Cancel"],
+                                cx,
+                            )
+                        })?;
 
-            if let Err(e) = result {
-                if is_remote {
-                    log::error!("Failed to delete remote branch: {}", e);
-                } else {
-                    log::error!("Failed to delete branch: {}", e);
+                        if answer.await != Ok(0) {
+                            return Ok(());
+                        }
+
+                        let retry = repo
+                            .update(cx, |repo, _| {
+                                repo.delete_branch(is_remote, branch_name, true)
+                            })
+                            .await?;
+
+                        if let Err(error) = &retry {
+                            log::error!("Failed to force delete branch: {error}");
+                        }
+                        (retry, true)
+                    }
                 }
+            };
 
+            if let Err(error) = result {
                 if let Some(workspace) = workspace.upgrade() {
                     cx.update(|_window, cx| {
-                        if is_remote {
-                            show_error_toast(
-                                workspace,
-                                format!("branch -dr {}", entry.name()),
-                                e,
-                                cx,
-                            )
-                        } else {
-                            show_error_toast(
-                                workspace,
-                                format!("branch -d {}", entry.name()),
-                                e,
-                                cx,
-                            )
-                        }
+                        show_error_toast(
+                            workspace,
+                            delete_branch_command(is_remote, entry.name(), attempted_force),
+                            error,
+                            cx,
+                        )
                     })?;
                 }
 
@@ -585,6 +712,8 @@ impl BranchListDelegate {
                     picker.delegate.selected_index = picker.delegate.matches.len() - 1;
                 }
 
+                picker.delegate.hovered_delete_index = None;
+
                 cx.notify();
             })?;
 
@@ -980,6 +1109,7 @@ impl PickerDelegate for BranchListDelegate {
         };
 
         let focus_handle = self.focus_handle.clone();
+        let picker = cx.entity();
         let is_new_items = matches!(
             entry,
             Entry::NewUrl { .. } | Entry::NewBranch { .. } | Entry::NewRemoteName { .. }
@@ -988,19 +1118,44 @@ impl PickerDelegate for BranchListDelegate {
         let is_head_branch = entry.as_branch().is_some_and(|branch| branch.is_head);
 
         let deleted_branch_icon = |entry_ix: usize| {
-            IconButton::new(("delete", entry_ix), IconName::Trash)
-                .icon_size(IconSize::Small)
-                .tooltip(move |_, cx| {
-                    Tooltip::for_action_in(
-                        "Delete Branch",
-                        &branch_picker::DeleteBranch,
-                        &focus_handle,
-                        cx,
-                    )
-                })
-                .on_click(cx.listener(move |this, _, window, cx| {
-                    this.delegate.delete_at(entry_ix, window, cx);
+            let picker = picker.clone();
+            let focus_handle = focus_handle.clone();
+            let force_delete = self.is_force_delete_hovering_index(entry_ix);
+
+            div()
+                .id(("delete-hover", entry_ix))
+                .on_hover(cx.listener(move |this, hovered: &bool, _, cx| {
+                    if *hovered {
+                        this.delegate.hovered_delete_index = Some(entry_ix);
+                    } else if this.delegate.hovered_delete_index == Some(entry_ix) {
+                        this.delegate.hovered_delete_index = None;
+                    }
+                    cx.notify();
                 }))
+                .child(
+                    IconButton::new(("delete", entry_ix), IconName::Trash)
+                        .icon_size(IconSize::Small)
+                        .when(force_delete, |this| this.icon_color(Color::Error))
+                        .tooltip(move |_, cx| {
+                            cx.new(|cx| {
+                                DeleteBranchTooltip::new(
+                                    picker.clone(),
+                                    focus_handle.clone(),
+                                    entry_ix,
+                                    cx,
+                                )
+                            })
+                            .into()
+                        })
+                        .on_click(cx.listener(move |this, _, window, cx| {
+                            this.delegate.delete_at(
+                                entry_ix,
+                                this.delegate.modifiers.alt,
+                                window,
+                                cx,
+                            );
+                        })),
+                )
         };
 
         let create_from_default_button = self.default_branch.as_ref().map(|default_branch| {
@@ -1480,9 +1635,9 @@ mod tests {
         (branch_list, cx)
     }
 
-    async fn init_fake_repository(
+    async fn init_fake_repository_with_fs(
         cx: &mut TestAppContext,
-    ) -> (Entity<Project>, Entity<Repository>) {
+    ) -> (Arc<FakeFs>, Entity<Project>, Entity<Repository>) {
         let fs = FakeFs::new(cx.executor());
         fs.insert_tree(
             path!("/dir"),
@@ -1505,7 +1660,14 @@ mod tests {
         let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
         let repository = cx.read(|cx| project.read(cx).active_repository(cx));
 
-        (project, repository.unwrap())
+        (fs, project, repository.unwrap())
+    }
+
+    async fn init_fake_repository(
+        cx: &mut TestAppContext,
+    ) -> (Entity<Project>, Entity<Repository>) {
+        let (_, project, repository) = init_fake_repository_with_fs(cx).await;
+        (project, repository)
     }
 
     #[gpui::test]
@@ -1597,7 +1759,7 @@ mod tests {
             branch_list.picker.update(cx, |picker, cx| {
                 assert_eq!(picker.delegate.matches.len(), 4);
                 let branch_to_delete = picker.delegate.matches.get(1).unwrap().name().to_string();
-                picker.delegate.delete_at(1, window, cx);
+                picker.delegate.delete_at(1, false, window, cx);
                 branch_to_delete
             })
         });
@@ -1641,6 +1803,238 @@ mod tests {
         });
     }
 
+    #[gpui::test]
+    async fn test_delete_unmerged_branch_prompts_for_force_delete(cx: &mut TestAppContext) {
+        init_test(cx);
+        let (fs, _project, repository) = init_fake_repository_with_fs(cx).await;
+
+        let branches = create_test_branches();
+        let branch_names = branches
+            .iter()
+            .map(|branch| branch.name().to_string())
+            .collect::<Vec<String>>();
+        let repo = repository.clone();
+        cx.spawn(async move |mut cx| {
+            for branch in branch_names {
+                repo.update(&mut cx, |repo, _| repo.create_branch(branch, None))
+                    .await
+                    .unwrap()
+                    .unwrap();
+            }
+        })
+        .await;
+        cx.run_until_parked();
+
+        let branch_to_delete = "feature-auth";
+        fs.with_git_state(path!("/dir/.git").as_ref(), true, |state| {
+            state
+                .branches_requiring_force_delete
+                .insert(branch_to_delete.to_string());
+        })
+        .expect("failed to mark test branch as requiring force delete");
+
+        let (branch_list, mut ctx) = init_branch_list_test(repository.into(), branches, cx).await;
+        let cx = &mut ctx;
+        update_branch_list_matches_with_empty_query(&branch_list, cx).await;
+
+        branch_list.update_in(cx, |branch_list, window, cx| {
+            branch_list.picker.update(cx, |picker, cx| {
+                let branch_index = picker
+                    .delegate
+                    .matches
+                    .iter()
+                    .position(|entry| entry.name() == branch_to_delete)
+                    .unwrap();
+                picker.delegate.delete_at(branch_index, false, window, cx);
+            })
+        });
+        cx.run_until_parked();
+        assert!(cx.has_pending_prompt());
+
+        cx.simulate_prompt_answer("Force Delete");
+        cx.run_until_parked();
+
+        let repo_branches = branch_list
+            .update(cx, |branch_list, cx| {
+                branch_list.picker.update(cx, |picker, cx| {
+                    picker
+                        .delegate
+                        .repo
+                        .as_ref()
+                        .unwrap()
+                        .update(cx, |repo, _cx| repo.branches())
+                })
+            })
+            .await
+            .unwrap()
+            .unwrap();
+        assert!(
+            repo_branches
+                .iter()
+                .all(|branch| branch.name() != branch_to_delete)
+        );
+    }
+
+    #[gpui::test]
+    async fn test_delete_unmerged_branch_cancel_keeps_branch(cx: &mut TestAppContext) {
+        init_test(cx);
+        let (fs, _project, repository) = init_fake_repository_with_fs(cx).await;
+
+        let branches = create_test_branches();
+        let branch_names = branches
+            .iter()
+            .map(|branch| branch.name().to_string())
+            .collect::<Vec<String>>();
+        let repo = repository.clone();
+        cx.spawn(async move |mut cx| {
+            for branch in branch_names {
+                repo.update(&mut cx, |repo, _| repo.create_branch(branch, None))
+                    .await
+                    .unwrap()
+                    .unwrap();
+            }
+        })
+        .await;
+        cx.run_until_parked();
+
+        let branch_to_delete = "feature-auth";
+        fs.with_git_state(path!("/dir/.git").as_ref(), true, |state| {
+            state
+                .branches_requiring_force_delete
+                .insert(branch_to_delete.to_string());
+        })
+        .expect("failed to mark test branch as requiring force delete");
+
+        let (branch_list, mut ctx) = init_branch_list_test(repository.into(), branches, cx).await;
+        let cx = &mut ctx;
+        update_branch_list_matches_with_empty_query(&branch_list, cx).await;
+
+        let initial_match_count = branch_list.update(cx, |branch_list, cx| {
+            branch_list
+                .picker
+                .update(cx, |picker, _| picker.delegate.matches.len())
+        });
+
+        branch_list.update_in(cx, |branch_list, window, cx| {
+            branch_list.picker.update(cx, |picker, cx| {
+                let branch_index = picker
+                    .delegate
+                    .matches
+                    .iter()
+                    .position(|entry| entry.name() == branch_to_delete)
+                    .unwrap();
+                picker.delegate.delete_at(branch_index, false, window, cx);
+            })
+        });
+        cx.run_until_parked();
+        assert!(cx.has_pending_prompt());
+
+        cx.simulate_prompt_answer("Cancel");
+        cx.run_until_parked();
+        assert!(!cx.has_pending_prompt());
+
+        let repo_branches = branch_list
+            .update(cx, |branch_list, cx| {
+                branch_list.picker.update(cx, |picker, cx| {
+                    picker
+                        .delegate
+                        .repo
+                        .as_ref()
+                        .unwrap()
+                        .update(cx, |repo, _cx| repo.branches())
+                })
+            })
+            .await
+            .unwrap()
+            .unwrap();
+        assert!(
+            repo_branches
+                .iter()
+                .any(|branch| branch.name() == branch_to_delete),
+            "branch should still exist after cancelling the force-delete prompt"
+        );
+
+        let final_match_count = branch_list.update(cx, |branch_list, cx| {
+            branch_list
+                .picker
+                .update(cx, |picker, _| picker.delegate.matches.len())
+        });
+        assert_eq!(
+            initial_match_count, final_match_count,
+            "picker matches should be unchanged after cancel"
+        );
+    }
+
+    #[gpui::test]
+    async fn test_force_delete_click_deletes_branch_without_prompt(cx: &mut TestAppContext) {
+        init_test(cx);
+        let (fs, _project, repository) = init_fake_repository_with_fs(cx).await;
+
+        let branches = create_test_branches();
+        let branch_names = branches
+            .iter()
+            .map(|branch| branch.name().to_string())
+            .collect::<Vec<String>>();
+        let repo = repository.clone();
+        cx.spawn(async move |mut cx| {
+            for branch in branch_names {
+                repo.update(&mut cx, |repo, _| repo.create_branch(branch, None))
+                    .await
+                    .unwrap()
+                    .unwrap();
+            }
+        })
+        .await;
+        cx.run_until_parked();
+
+        let branch_to_delete = "feature-auth";
+        fs.with_git_state(path!("/dir/.git").as_ref(), true, |state| {
+            state
+                .branches_requiring_force_delete
+                .insert(branch_to_delete.to_string());
+        })
+        .expect("failed to mark test branch as requiring force delete");
+
+        let (branch_list, mut ctx) = init_branch_list_test(repository.into(), branches, cx).await;
+        let cx = &mut ctx;
+        update_branch_list_matches_with_empty_query(&branch_list, cx).await;
+
+        branch_list.update_in(cx, |branch_list, window, cx| {
+            branch_list.picker.update(cx, |picker, cx| {
+                picker.delegate.modifiers = Modifiers::alt();
+                let branch_index = picker
+                    .delegate
+                    .matches
+                    .iter()
+                    .position(|entry| entry.name() == branch_to_delete)
+                    .unwrap();
+                picker.delegate.delete_at(branch_index, true, window, cx);
+            })
+        });
+        cx.run_until_parked();
+        assert!(!cx.has_pending_prompt());
+
+        let repo_branches = branch_list
+            .update(cx, |branch_list, cx| {
+                branch_list.picker.update(cx, |picker, cx| {
+                    picker
+                        .delegate
+                        .repo
+                        .as_ref()
+                        .unwrap()
+                        .update(cx, |repo, _cx| repo.branches())
+                })
+            })
+            .await
+            .unwrap()
+            .unwrap();
+        assert!(
+            repo_branches
+                .iter()
+                .all(|branch| branch.name() != branch_to_delete)
+        );
+    }
+
     #[gpui::test]
     async fn test_delete_remote_branch(cx: &mut TestAppContext) {
         init_test(cx);
@@ -1683,7 +2077,7 @@ mod tests {
             branch_list.picker.update(cx, |picker, cx| {
                 assert_eq!(picker.delegate.matches.len(), 4);
                 let branch_to_delete = picker.delegate.matches.get(1).unwrap().name().to_string();
-                picker.delegate.delete_at(1, window, cx);
+                picker.delegate.delete_at(1, false, window, cx);
                 branch_to_delete
             })
         });

crates/git_ui/src/git_picker.rs 🔗

@@ -12,7 +12,7 @@ use ui::{
 };
 use workspace::{ModalView, Workspace, pane};
 
-use crate::branch_picker::{self, BranchList, DeleteBranch, FilterRemotes};
+use crate::branch_picker::{self, BranchList, DeleteBranch, FilterRemotes, ForceDeleteBranch};
 use crate::stash_picker::{self, DropStashItem, ShowStashItem, StashList};
 
 actions!(git_picker, [ActivateBranchesTab, ActivateStashTab,]);
@@ -295,6 +295,19 @@ impl GitPicker {
         }
     }
 
+    fn handle_force_delete_branch(
+        &mut self,
+        _: &ForceDeleteBranch,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        if let Some(branch_list) = &self.branch_list {
+            branch_list.update(cx, |list, cx| {
+                list.handle_force_delete(&ForceDeleteBranch, window, cx);
+            });
+        }
+    }
+
     fn handle_filter_remotes(
         &mut self,
         _: &FilterRemotes,
@@ -407,6 +420,7 @@ impl Render for GitPicker {
             .on_modifiers_changed(cx.listener(Self::handle_modifiers_changed))
             .when(self.tab == GitPickerTab::Branches, |el| {
                 el.on_action(cx.listener(Self::handle_delete_branch))
+                    .on_action(cx.listener(Self::handle_force_delete_branch))
                     .on_action(cx.listener(Self::handle_filter_remotes))
             })
             .when(self.tab == GitPickerTab::Stash, |el| {

crates/project/src/git_store.rs 🔗

@@ -37,7 +37,7 @@ use git::{
         CreateWorktreeTarget, DiffType, FetchOptions, GitCommitTemplate, GitRepository,
         GitRepositoryCheckpoint, InitialGraphCommitData, LogOrder, LogSource, PushOptions, Remote,
         RemoteCommandOutput, RepoPath, ResetMode, SearchCommitArgs, UpstreamTrackingStatus,
-        Worktree as GitWorktree,
+        Worktree as GitWorktree, delete_branch_flag,
     },
     stash::{GitStash, StashEntry},
     status::{
@@ -2981,10 +2981,11 @@ impl GitStore {
         let repository_handle = Self::repository_for_request(&this, repository_id, &mut cx)?;
         let is_remote = envelope.payload.is_remote;
         let branch_name = envelope.payload.branch_name;
+        let force = envelope.payload.force;
 
         repository_handle
             .update(&mut cx, |repository_handle, _| {
-                repository_handle.delete_branch(is_remote, branch_name)
+                repository_handle.delete_branch(is_remote, branch_name, force)
             })
             .await??;
 
@@ -7367,21 +7368,19 @@ impl Repository {
         &mut self,
         is_remote: bool,
         branch_name: String,
+        force: bool,
     ) -> oneshot::Receiver<Result<()>> {
         let id = self.id;
+        let flag = delete_branch_flag(is_remote, force);
         self.send_job(
-            Some(
-                format!(
-                    "git branch {} {}",
-                    if is_remote { "-dr" } else { "-d" },
-                    branch_name
-                )
-                .into(),
-            ),
+            Some(format!("git branch {flag} {branch_name}").into()),
             move |repo, _cx| async move {
                 match repo {
                     RepositoryState::Local(state) => {
-                        state.backend.delete_branch(is_remote, branch_name).await
+                        state
+                            .backend
+                            .delete_branch(is_remote, branch_name, force)
+                            .await
                     }
                     RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => {
                         client
@@ -7390,6 +7389,7 @@ impl Repository {
                                 repository_id: id.to_proto(),
                                 is_remote,
                                 branch_name,
+                                force,
                             })
                             .await?;
 

crates/proto/proto/git.proto 🔗

@@ -215,6 +215,7 @@ message GitDeleteBranch {
   uint64 repository_id = 2;
   string branch_name = 3;
   bool is_remote = 4;
+  bool force = 5;
 }
 
 message GitDiff {