diff --git a/pkg/gui/controllers/branches_controller.go b/pkg/gui/controllers/branches_controller.go index 93b3b93e146..a97168fc15f 100644 --- a/pkg/gui/controllers/branches_controller.go +++ b/pkg/gui/controllers/branches_controller.go @@ -528,6 +528,10 @@ func (self *BranchesController) remoteDelete(branch *models.Branch) error { return self.c.Helpers().BranchesHelper.ConfirmDeleteRemote(branch.UpstreamRemote, branch.UpstreamBranch) } +func (self *BranchesController) localAndRemoteDelete(branch *models.Branch) error { + return self.c.Helpers().BranchesHelper.ConfirmLocalAndRemoteDelete(branch) +} + func (self *BranchesController) delete(branch *models.Branch) error { checkedOutBranch := self.c.Helpers().Refs.GetCheckedOutRef() @@ -553,6 +557,19 @@ func (self *BranchesController) delete(branch *models.Branch) error { remoteDeleteItem.DisabledReason = &types.DisabledReason{Text: self.c.Tr.UpstreamNotSetError} } + deleteBothItem := &types.MenuItem{ + Label: self.c.Tr.DeleteLocalAndRemoteBranch, + Key: 'b', + OnPress: func() error { + return self.localAndRemoteDelete(branch) + }, + } + if checkedOutBranch.Name == branch.Name { + deleteBothItem.DisabledReason = &types.DisabledReason{Text: self.c.Tr.CantDeleteCheckOutBranch} + } else if !branch.IsTrackingRemote() || branch.UpstreamGone { + deleteBothItem.DisabledReason = &types.DisabledReason{Text: self.c.Tr.UpstreamNotSetError} + } + menuTitle := utils.ResolvePlaceholderString( self.c.Tr.DeleteBranchTitle, map[string]string{ @@ -562,7 +579,7 @@ func (self *BranchesController) delete(branch *models.Branch) error { return self.c.Menu(types.CreateMenuOptions{ Title: menuTitle, - Items: []*types.MenuItem{localDeleteItem, remoteDeleteItem}, + Items: []*types.MenuItem{localDeleteItem, remoteDeleteItem, deleteBothItem}, }) } diff --git a/pkg/gui/controllers/helpers/branches_helper.go b/pkg/gui/controllers/helpers/branches_helper.go index 68976f7a37f..fdd72e18870 100644 --- a/pkg/gui/controllers/helpers/branches_helper.go +++ b/pkg/gui/controllers/helpers/branches_helper.go @@ -97,6 +97,59 @@ func (self *BranchesHelper) ConfirmDeleteRemote(remoteName string, branchName st return nil } +func (self *BranchesHelper) ConfirmLocalAndRemoteDelete(branch *models.Branch) error { + if self.checkedOutByOtherWorktree(branch) { + return self.promptWorktreeBranchDelete(branch) + } + + isMerged, err := self.c.Git().Branch.IsBranchMerged(branch, self.c.Model().MainBranches) + if err != nil { + return err + } + + prompt := utils.ResolvePlaceholderString( + self.c.Tr.DeleteLocalAndRemoteBranchPrompt, + map[string]string{ + "localBranchName": branch.Name, + "remoteBranchName": branch.UpstreamBranch, + "remoteName": branch.UpstreamRemote, + }, + ) + + if !isMerged { + prompt += "\n\n" + utils.ResolvePlaceholderString( + self.c.Tr.ForceDeleteBranchMessage, + map[string]string{ + "selectedBranchName": branch.Name, + }, + ) + } + + self.c.Confirm(types.ConfirmOpts{ + Title: self.c.Tr.DeleteLocalAndRemoteBranch, + Prompt: prompt, + HandleConfirm: func() error { + return self.c.WithWaitingStatus(self.c.Tr.DeletingStatus, func(task gocui.Task) error { + // Delete the remote branch first so that we keep the local one + // in case of failure + self.c.LogAction(self.c.Tr.Actions.DeleteRemoteBranch) + if err := self.c.Git().Remote.DeleteRemoteBranch(task, branch.UpstreamRemote, branch.Name); err != nil { + return err + } + + self.c.LogAction(self.c.Tr.Actions.DeleteLocalBranch) + if err := self.c.Git().Branch.LocalDelete(branch.Name, true); err != nil { + return err + } + + return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.BRANCHES, types.REMOTES}}) + }) + }, + }) + + return nil +} + func ShortBranchName(fullBranchName string) string { return strings.TrimPrefix(strings.TrimPrefix(fullBranchName, "refs/heads/"), "refs/remotes/") } diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 09b4eda1cdf..e141a614ccb 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -107,6 +107,7 @@ type TranslationSet struct { DeleteLocalBranch string DeleteRemoteBranchOption string DeleteRemoteBranchPrompt string + DeleteLocalAndRemoteBranchPrompt string ForceDeleteBranchTitle string ForceDeleteBranchMessage string RebaseBranch string @@ -473,6 +474,7 @@ type TranslationSet struct { RemoveRemotePrompt string DeleteRemoteBranch string DeleteRemoteBranchTooltip string + DeleteLocalAndRemoteBranch string SetAsUpstream string SetAsUpstreamTooltip string SetUpstream string @@ -1086,6 +1088,7 @@ func EnglishTranslationSet() *TranslationSet { DeleteLocalBranch: "Delete local branch", DeleteRemoteBranchOption: "Delete remote branch", DeleteRemoteBranchPrompt: "Are you sure you want to delete the remote branch '{{.selectedBranchName}}' from '{{.upstream}}'?", + DeleteLocalAndRemoteBranchPrompt: "Are you sure you want to delete both '{{.localBranchName}}' from your machine, and '{{.remoteBranchName}}' from '{{.remoteName}}'?", ForceDeleteBranchTitle: "Force delete branch", ForceDeleteBranchMessage: "'{{.selectedBranchName}}' is not fully merged. Are you sure you want to delete it?", RebaseBranch: "Rebase", @@ -1462,6 +1465,7 @@ func EnglishTranslationSet() *TranslationSet { RemoveRemotePrompt: "Are you sure you want to remove remote?", DeleteRemoteBranch: "Delete remote branch", DeleteRemoteBranchTooltip: "Delete the remote branch from the remote.", + DeleteLocalAndRemoteBranch: "Delete local and remote branch", SetAsUpstream: "Set as upstream", SetAsUpstreamTooltip: "Set the selected remote branch as the upstream of the checked-out branch.", SetUpstream: "Set upstream of selected branch", diff --git a/pkg/integration/tests/branch/delete.go b/pkg/integration/tests/branch/delete.go index aab872957e4..d277f31b4d8 100644 --- a/pkg/integration/tests/branch/delete.go +++ b/pkg/integration/tests/branch/delete.go @@ -31,6 +31,13 @@ var Delete = NewIntegrationTest(NewIntegrationTestArgs{ EmptyCommit("on branch-four 01"). PushBranchAndSetUpstream("origin", "branch-four"). EmptyCommit("on branch-four 02"). // branch-four is not contained in any of these, so we get a delete confirmation + NewBranchFrom("branch-five", "master"). + EmptyCommit("on branch-five 01"). + PushBranchAndSetUpstream("origin", "branch-five"). // branch-five is contained in its own upstream + NewBranchFrom("branch-six", "master"). + EmptyCommit("on branch-six 01"). + PushBranchAndSetUpstream("origin", "branch-six"). + EmptyCommit("on branch-six 02"). // branch-six is not contained in any of these, so we get a delete confirmation Checkout("current-head") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { @@ -38,6 +45,8 @@ var Delete = NewIntegrationTest(NewIntegrationTestArgs{ Focus(). Lines( Contains("current-head").IsSelected(), + Contains("branch-six ↑1"), + Contains("branch-five ✓"), Contains("branch-four ↑1"), Contains("branch-three"), Contains("branch-two ✓"), @@ -62,7 +71,7 @@ var Delete = NewIntegrationTest(NewIntegrationTestArgs{ // Delete branch-four. This is the only branch that is not fully merged, so we get // a confirmation popup. - SelectNextItem(). + NavigateToLine(Contains("branch-four")). Press(keys.Universal.Remove). Tap(func() { t.ExpectPopup(). @@ -78,6 +87,8 @@ var Delete = NewIntegrationTest(NewIntegrationTestArgs{ }). Lines( Contains("current-head"), + Contains("branch-six ↑1"), + Contains("branch-five ✓"), Contains("branch-three").IsSelected(), Contains("branch-two ✓"), Contains("master"), @@ -96,6 +107,8 @@ var Delete = NewIntegrationTest(NewIntegrationTestArgs{ }). Lines( Contains("current-head"), + Contains("branch-six ↑1"), + Contains("branch-five ✓"), Contains("branch-two ✓").IsSelected(), Contains("master"), Contains("branch-one ↑1"), @@ -113,6 +126,8 @@ var Delete = NewIntegrationTest(NewIntegrationTestArgs{ }). Lines( Contains("current-head"), + Contains("branch-six ↑1"), + Contains("branch-five ✓"), Contains("master").IsSelected(), Contains("branch-one ↑1"), ). @@ -143,7 +158,9 @@ var Delete = NewIntegrationTest(NewIntegrationTestArgs{ t.Views(). RemoteBranches(). Lines( + Equals("branch-five"), Equals("branch-four"), + Equals("branch-six"), Equals("branch-two"), ). Press(keys.Universal.Return) @@ -154,6 +171,8 @@ var Delete = NewIntegrationTest(NewIntegrationTestArgs{ }). Lines( Contains("current-head"), + Contains("branch-six ↑1"), + Contains("branch-five ✓"), Contains("master"), Contains("branch-one (upstream gone)").IsSelected(), ). @@ -168,6 +187,51 @@ var Delete = NewIntegrationTest(NewIntegrationTestArgs{ Select(Contains("Delete local branch")). Confirm() }). + Lines( + Contains("current-head"), + Contains("branch-six ↑1"), + Contains("branch-five ✓"), + Contains("master").IsSelected(), + ). + + // Delete both local and remote branch of branch-six. We get the force-delete warning because it is not fully merged. + NavigateToLine(Contains("branch-six")). + Press(keys.Universal.Remove). + Tap(func() { + t.ExpectPopup(). + Menu(). + Title(Equals("Delete branch 'branch-six'?")). + Select(Contains("Delete local and remote branch")). + Confirm() + t.ExpectPopup(). + Confirmation(). + Title(Equals("Delete local and remote branch")). + Content(Contains("Are you sure you want to delete both 'branch-six' from your machine, and 'branch-six' from 'origin'?"). + Contains("'branch-six' is not fully merged. Are you sure you want to delete it?")). + Confirm() + }). + Lines( + Contains("current-head"), + Contains("branch-five ✓").IsSelected(), + Contains("master"), + ). + + // Delete both local and remote branch of branch-five. We get the same popups, but the confirmation + // doesn't contain the force-delete warning. + Press(keys.Universal.Remove). + Tap(func() { + t.ExpectPopup(). + Menu(). + Title(Equals("Delete branch 'branch-five'?")). + Select(Contains("Delete local and remote branch")). + Confirm() + t.ExpectPopup(). + Confirmation(). + Title(Equals("Delete local and remote branch")). + Content(Equals("Are you sure you want to delete both 'branch-five' from your machine, and 'branch-five' from 'origin'?"). + DoesNotContain("not fully merged")). + Confirm() + }). Lines( Contains("current-head"), Contains("master").IsSelected(),