diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 66f527ef0244d63bf2227473b5e8fdbede90563b..9c49646b5a786ca16e94906148fb11140fedb513 100644 --- a/assets/keymaps/default-linux.json +++ b/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", }, }, diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index d73c6d7a8b65de370e3ed32f3d294cb029843ef1..d0ac2c22e03a01443eda868f155154c364167f69 100644 --- a/assets/keymaps/default-macos.json +++ b/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", }, }, diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index fc1d78b39f29c3125ed5d684ca99055a34b5c59e..66195c604fef0f6805b46184b0748d8319e91185 100644 --- a/assets/keymaps/default-windows.json +++ b/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", }, }, diff --git a/crates/fs/src/fake_git_repo.rs b/crates/fs/src/fake_git_repo.rs index 5f2cb0515ce757cf06d0324fa515b637e542f2d8..4ce38b59565413d267c3eeea617956f317f2f08d 100644 --- a/crates/fs/src/fake_git_repo.rs +++ b/crates/fs/src/fake_git_repo.rs @@ -72,6 +72,7 @@ pub struct FakeGitRepositoryState { pub simulated_index_write_error_message: Option, pub simulated_create_worktree_error: Option, pub simulated_graph_error: Option, + pub branches_requiring_force_delete: HashSet, pub refs: HashMap, pub graph_commits: Vec>, pub commit_data: HashMap, @@ -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(()) }) } diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index 90ac06d959a1fafe5d94d4d8fb8220cb52117c37..a0fa3c8a95b93cc6c289563bc70ca7e20793619f 100644 --- a/crates/git/src/repository.rs +++ b/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>>; @@ -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() diff --git a/crates/git_ui/src/branch_picker.rs b/crates/git_ui/src/branch_picker.rs index 64f1032ce5916426b3618e6ab24988964e2c930c..839997cc588ec3107fb59ce655e7d271d8f6530a 100644 --- a/crates/git_ui/src/branch_picker.rs +++ b/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.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.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, show_footer: bool, + hovered_delete_index: Option, } #[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>, + focus_handle: FocusHandle, + delete_index: usize, + _subscription: Subscription, +} + +impl DeleteBranchTooltip { + fn new( + picker: Entity>, + focus_handle: FocusHandle, + delete_index: usize, + cx: &mut Context, + ) -> 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) -> 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 { 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, @@ -509,7 +604,13 @@ impl BranchListDelegate { cx.emit(DismissEvent); } - fn delete_at(&self, idx: usize, window: &mut Window, cx: &mut Context>) { + fn delete_at( + &self, + idx: usize, + force: bool, + window: &mut Window, + cx: &mut Context>, + ) { 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, Entity) { + ) -> (Arc, Entity, Entity) { 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, Entity) { + 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::>(); + 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::>(); + 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::>(); + 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 }) }); diff --git a/crates/git_ui/src/git_picker.rs b/crates/git_ui/src/git_picker.rs index a1f55ce9fad106ec2b7445ad7c8e5b8ccdf0c751..02299e5f5e68db093352c7ddf06b6dae3d0d213c 100644 --- a/crates/git_ui/src/git_picker.rs +++ b/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, + ) { + 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| { diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs index 509d694885b1adb828c831dd2a0b7738761c13f1..61cca22ff77e878fab6c43012a99b43220bdfc58 100644 --- a/crates/project/src/git_store.rs +++ b/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> { 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?; diff --git a/crates/proto/proto/git.proto b/crates/proto/proto/git.proto index bf7bbeb4359f2710a01d4763cf6594a2a281c674..8c7d09eb4b0d33bef85806a6cb0882e691a94e03 100644 --- a/crates/proto/proto/git.proto +++ b/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 {