From c1b4201726c6dc5279cf611a376f977e4ca78b57 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 23 Nov 2024 19:32:27 +0100 Subject: [PATCH] Allow deleting a range selection of branches We allow deleting remote branches (or local and remote branches) only if *all* selected branches have one. We show the a warning about force-deleting as soon as at least one of the selected branches is not fully merged. The added test only tests a few of the most interesting cases; I didn't try to cover the whole space of possible combinations, that would have been too much. --- pkg/commands/git_commands/branch.go | 4 +- pkg/commands/git_commands/branch_test.go | 31 ++- pkg/commands/git_commands/remote.go | 5 +- pkg/gui/controllers/branches_controller.go | 77 +++++--- .../controllers/helpers/branches_helper.go | 163 ++++++++++----- .../controllers/remote_branches_controller.go | 8 +- pkg/i18n/english.go | 18 ++ .../tests/branch/delete_multiple.go | 186 ++++++++++++++++++ pkg/integration/tests/test_list.go | 1 + 9 files changed, 405 insertions(+), 88 deletions(-) create mode 100644 pkg/integration/tests/branch/delete_multiple.go diff --git a/pkg/commands/git_commands/branch.go b/pkg/commands/git_commands/branch.go index 99229b12b..155471e1e 100644 --- a/pkg/commands/git_commands/branch.go +++ b/pkg/commands/git_commands/branch.go @@ -109,10 +109,10 @@ func (self *BranchCommands) CurrentBranchName() (string, error) { } // LocalDelete delete branch locally -func (self *BranchCommands) LocalDelete(branch string, force bool) error { +func (self *BranchCommands) LocalDelete(branches []string, force bool) error { cmdArgs := NewGitCmd("branch"). ArgIfElse(force, "-D", "-d"). - Arg(branch). + Arg(branches...). ToArgv() return self.cmd.New(cmdArgs).Run() diff --git a/pkg/commands/git_commands/branch_test.go b/pkg/commands/git_commands/branch_test.go index 5c58513d0..37ad79613 100644 --- a/pkg/commands/git_commands/branch_test.go +++ b/pkg/commands/git_commands/branch_test.go @@ -62,36 +62,57 @@ func TestBranchNewBranch(t *testing.T) { func TestBranchDeleteBranch(t *testing.T) { type scenario struct { - testName string - force bool - runner *oscommands.FakeCmdObjRunner - test func(error) + testName string + branchNames []string + force bool + runner *oscommands.FakeCmdObjRunner + test func(error) } scenarios := []scenario{ { "Delete a branch", + []string{"test"}, false, oscommands.NewFakeRunner(t).ExpectGitArgs([]string{"branch", "-d", "test"}, "", nil), func(err error) { assert.NoError(t, err) }, }, + { + "Delete multiple branches", + []string{"test1", "test2", "test3"}, + false, + oscommands.NewFakeRunner(t).ExpectGitArgs([]string{"branch", "-d", "test1", "test2", "test3"}, "", nil), + func(err error) { + assert.NoError(t, err) + }, + }, { "Force delete a branch", + []string{"test"}, true, oscommands.NewFakeRunner(t).ExpectGitArgs([]string{"branch", "-D", "test"}, "", nil), func(err error) { assert.NoError(t, err) }, }, + { + "Force delete multiple branches", + []string{"test1", "test2", "test3"}, + true, + oscommands.NewFakeRunner(t).ExpectGitArgs([]string{"branch", "-D", "test1", "test2", "test3"}, "", nil), + func(err error) { + assert.NoError(t, err) + }, + }, } for _, s := range scenarios { t.Run(s.testName, func(t *testing.T) { instance := buildBranchCommands(commonDeps{runner: s.runner}) - s.test(instance.LocalDelete("test", s.force)) + s.test(instance.LocalDelete(s.branchNames, s.force)) s.runner.CheckForMissingCalls() }) } diff --git a/pkg/commands/git_commands/remote.go b/pkg/commands/git_commands/remote.go index e2b3c6086..ca3610679 100644 --- a/pkg/commands/git_commands/remote.go +++ b/pkg/commands/git_commands/remote.go @@ -49,9 +49,10 @@ func (self *RemoteCommands) UpdateRemoteUrl(remoteName string, updatedUrl string return self.cmd.New(cmdArgs).Run() } -func (self *RemoteCommands) DeleteRemoteBranch(task gocui.Task, remoteName string, branchName string) error { +func (self *RemoteCommands) DeleteRemoteBranch(task gocui.Task, remoteName string, branchNames []string) error { cmdArgs := NewGitCmd("push"). - Arg(remoteName, "--delete", branchName). + Arg(remoteName, "--delete"). + Arg(branchNames...). ToArgv() return self.cmd.New(cmdArgs).PromptOnCredentialRequest(task).Run() diff --git a/pkg/gui/controllers/branches_controller.go b/pkg/gui/controllers/branches_controller.go index 131c7f677..a364811d6 100644 --- a/pkg/gui/controllers/branches_controller.go +++ b/pkg/gui/controllers/branches_controller.go @@ -91,8 +91,8 @@ func (self *BranchesController) GetKeybindings(opts types.KeybindingsOpts) []*ty }, { Key: opts.GetKey(opts.Config.Universal.Remove), - Handler: self.withItem(self.delete), - GetDisabledReason: self.require(self.singleItemSelected(self.branchIsReal)), + Handler: self.withItems(self.delete), + GetDisabledReason: self.require(self.itemRangeSelected(self.branchesAreReal)), Description: self.c.Tr.Delete, Tooltip: self.c.Tr.BranchDeleteTooltip, OpensMenu: true, @@ -520,29 +520,35 @@ func (self *BranchesController) createNewBranchWithName(newBranchName string) er return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, KeepBranchSelectionIndex: true}) } -func (self *BranchesController) localDelete(branch *models.Branch) error { - return self.c.Helpers().BranchesHelper.ConfirmLocalDelete(branch) +func (self *BranchesController) localDelete(branches []*models.Branch) error { + return self.c.Helpers().BranchesHelper.ConfirmLocalDelete(branches) } -func (self *BranchesController) remoteDelete(branch *models.Branch) error { - remoteBranch := &models.RemoteBranch{Name: branch.UpstreamBranch, RemoteName: branch.UpstreamRemote} - return self.c.Helpers().BranchesHelper.ConfirmDeleteRemote(remoteBranch) +func (self *BranchesController) remoteDelete(branches []*models.Branch) error { + remoteBranches := lo.Map(branches, func(branch *models.Branch, _ int) *models.RemoteBranch { + return &models.RemoteBranch{Name: branch.UpstreamBranch, RemoteName: branch.UpstreamRemote} + }) + return self.c.Helpers().BranchesHelper.ConfirmDeleteRemote(remoteBranches) } -func (self *BranchesController) localAndRemoteDelete(branch *models.Branch) error { - return self.c.Helpers().BranchesHelper.ConfirmLocalAndRemoteDelete(branch) +func (self *BranchesController) localAndRemoteDelete(branches []*models.Branch) error { + return self.c.Helpers().BranchesHelper.ConfirmLocalAndRemoteDelete(branches) } -func (self *BranchesController) delete(branch *models.Branch) error { +func (self *BranchesController) delete(branches []*models.Branch) error { checkedOutBranch := self.c.Helpers().Refs.GetCheckedOutRef() - isBranchCheckedOut := checkedOutBranch.Name == branch.Name - hasUpstream := branch.IsTrackingRemote() && !branch.UpstreamGone + isBranchCheckedOut := lo.SomeBy(branches, func(branch *models.Branch) bool { + return checkedOutBranch.Name == branch.Name + }) + hasUpstream := lo.EveryBy(branches, func(branch *models.Branch) bool { + return branch.IsTrackingRemote() && !branch.UpstreamGone + }) localDeleteItem := &types.MenuItem{ - Label: self.c.Tr.DeleteLocalBranch, + Label: lo.Ternary(len(branches) > 1, self.c.Tr.DeleteLocalBranches, self.c.Tr.DeleteLocalBranch), Key: 'c', OnPress: func() error { - return self.localDelete(branch) + return self.localDelete(branches) }, } if isBranchCheckedOut { @@ -550,35 +556,44 @@ func (self *BranchesController) delete(branch *models.Branch) error { } remoteDeleteItem := &types.MenuItem{ - Label: self.c.Tr.DeleteRemoteBranch, + Label: lo.Ternary(len(branches) > 1, self.c.Tr.DeleteRemoteBranches, self.c.Tr.DeleteRemoteBranch), Key: 'r', OnPress: func() error { - return self.remoteDelete(branch) + return self.remoteDelete(branches) }, } if !hasUpstream { - remoteDeleteItem.DisabledReason = &types.DisabledReason{Text: self.c.Tr.UpstreamNotSetError} + remoteDeleteItem.DisabledReason = &types.DisabledReason{ + Text: lo.Ternary(len(branches) > 1, self.c.Tr.UpstreamsNotSetError, self.c.Tr.UpstreamNotSetError), + } } deleteBothItem := &types.MenuItem{ - Label: self.c.Tr.DeleteLocalAndRemoteBranch, + Label: lo.Ternary(len(branches) > 1, self.c.Tr.DeleteLocalAndRemoteBranches, self.c.Tr.DeleteLocalAndRemoteBranch), Key: 'b', OnPress: func() error { - return self.localAndRemoteDelete(branch) + return self.localAndRemoteDelete(branches) }, } if isBranchCheckedOut { deleteBothItem.DisabledReason = &types.DisabledReason{Text: self.c.Tr.CantDeleteCheckOutBranch} } else if !hasUpstream { - deleteBothItem.DisabledReason = &types.DisabledReason{Text: self.c.Tr.UpstreamNotSetError} + deleteBothItem.DisabledReason = &types.DisabledReason{ + Text: lo.Ternary(len(branches) > 1, self.c.Tr.UpstreamsNotSetError, self.c.Tr.UpstreamNotSetError), + } } - menuTitle := utils.ResolvePlaceholderString( - self.c.Tr.DeleteBranchTitle, - map[string]string{ - "selectedBranchName": branch.Name, - }, - ) + var menuTitle string + if len(branches) == 1 { + menuTitle = utils.ResolvePlaceholderString( + self.c.Tr.DeleteBranchTitle, + map[string]string{ + "selectedBranchName": branches[0].Name, + }, + ) + } else { + menuTitle = self.c.Tr.DeleteBranchesTitle + } return self.c.Menu(types.CreateMenuOptions{ Title: menuTitle, @@ -822,6 +837,16 @@ func (self *BranchesController) branchIsReal(branch *models.Branch) *types.Disab return nil } +func (self *BranchesController) branchesAreReal(selectedBranches []*models.Branch, startIdx int, endIdx int) *types.DisabledReason { + if !lo.EveryBy(selectedBranches, func(branch *models.Branch) bool { + return branch.IsRealBranch() + }) { + return &types.DisabledReason{Text: self.c.Tr.SelectedItemIsNotABranch} + } + + return nil +} + func (self *BranchesController) notMergingIntoYourself(branch *models.Branch) *types.DisabledReason { selectedBranchName := branch.Name checkedOutBranch := self.c.Helpers().Refs.GetCheckedOutRef().Name diff --git a/pkg/gui/controllers/helpers/branches_helper.go b/pkg/gui/controllers/helpers/branches_helper.go index e2c83960c..30c5dbee7 100644 --- a/pkg/gui/controllers/helpers/branches_helper.go +++ b/pkg/gui/controllers/helpers/branches_helper.go @@ -1,6 +1,7 @@ package helpers import ( + "errors" "strings" "github.com/jesseduffield/gocui" @@ -9,6 +10,7 @@ import ( "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/utils" + "github.com/samber/lo" ) type BranchesHelper struct { @@ -23,12 +25,16 @@ func NewBranchesHelper(c *HelperCommon, worktreeHelper *WorktreeHelper) *Branche } } -func (self *BranchesHelper) ConfirmLocalDelete(branch *models.Branch) error { - if self.checkedOutByOtherWorktree(branch) { - return self.promptWorktreeBranchDelete(branch) +func (self *BranchesHelper) ConfirmLocalDelete(branches []*models.Branch) error { + if len(branches) > 1 { + if lo.SomeBy(branches, func(branch *models.Branch) bool { return self.checkedOutByOtherWorktree(branch) }) { + return errors.New(self.c.Tr.SomeBranchesCheckedOutByWorktreeError) + } + } else if self.checkedOutByOtherWorktree(branches[0]) { + return self.promptWorktreeBranchDelete(branches[0]) } - isMerged, err := self.c.Git().Branch.IsBranchMerged(branch, self.c.Model().MainBranches) + allBranchesMerged, err := self.allBranchesMerged(branches) if err != nil { return err } @@ -36,24 +42,32 @@ func (self *BranchesHelper) ConfirmLocalDelete(branch *models.Branch) error { doDelete := func() error { return self.c.WithWaitingStatus(self.c.Tr.DeletingStatus, func(_ gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.DeleteLocalBranch) - if err := self.c.Git().Branch.LocalDelete(branch.Name, true); err != nil { + branchNames := lo.Map(branches, func(branch *models.Branch, _ int) string { return branch.Name }) + if err := self.c.Git().Branch.LocalDelete(branchNames, true); err != nil { return err } + selectionStart, _ := self.c.Contexts().Branches.GetSelectionRange() + self.c.Contexts().Branches.SetSelectedLineIdx(selectionStart) return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.BRANCHES}}) }) } - if isMerged { + if allBranchesMerged { return doDelete() } title := self.c.Tr.ForceDeleteBranchTitle - message := utils.ResolvePlaceholderString( - self.c.Tr.ForceDeleteBranchMessage, - map[string]string{ - "selectedBranchName": branch.Name, - }, - ) + var message string + if len(branches) == 1 { + message = utils.ResolvePlaceholderString( + self.c.Tr.ForceDeleteBranchMessage, + map[string]string{ + "selectedBranchName": branches[0].Name, + }, + ) + } else { + message = self.c.Tr.ForceDeleteBranchesMessage + } self.c.Confirm(types.ConfirmOpts{ Title: title, @@ -66,27 +80,36 @@ func (self *BranchesHelper) ConfirmLocalDelete(branch *models.Branch) error { return nil } -func (self *BranchesHelper) ConfirmDeleteRemote(remoteBranch *models.RemoteBranch) error { - title := utils.ResolvePlaceholderString( - self.c.Tr.DeleteBranchTitle, - map[string]string{ - "selectedBranchName": remoteBranch.Name, - }, - ) - prompt := utils.ResolvePlaceholderString( - self.c.Tr.DeleteRemoteBranchPrompt, - map[string]string{ - "selectedBranchName": remoteBranch.Name, - "upstream": remoteBranch.RemoteName, - }, - ) +func (self *BranchesHelper) ConfirmDeleteRemote(remoteBranches []*models.RemoteBranch) error { + var title string + if len(remoteBranches) == 1 { + title = utils.ResolvePlaceholderString( + self.c.Tr.DeleteBranchTitle, + map[string]string{ + "selectedBranchName": remoteBranches[0].Name, + }, + ) + } else { + title = self.c.Tr.DeleteBranchesTitle + } + var prompt string + if len(remoteBranches) == 1 { + prompt = utils.ResolvePlaceholderString( + self.c.Tr.DeleteRemoteBranchPrompt, + map[string]string{ + "selectedBranchName": remoteBranches[0].Name, + "upstream": remoteBranches[0].RemoteName, + }, + ) + } else { + prompt = self.c.Tr.DeleteRemoteBranchesPrompt + } self.c.Confirm(types.ConfirmOpts{ Title: title, Prompt: prompt, HandleConfirm: func() error { return self.c.WithWaitingStatus(self.c.Tr.DeletingStatus, func(task gocui.Task) error { - self.c.LogAction(self.c.Tr.Actions.DeleteRemoteBranch) - if err := self.c.Git().Remote.DeleteRemoteBranch(task, remoteBranch.RemoteName, remoteBranch.Name); err != nil { + if err := self.deleteRemoteBranches(remoteBranches, task); err != nil { return err } return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.BRANCHES, types.REMOTES}}) @@ -97,32 +120,41 @@ func (self *BranchesHelper) ConfirmDeleteRemote(remoteBranch *models.RemoteBranc return nil } -func (self *BranchesHelper) ConfirmLocalAndRemoteDelete(branch *models.Branch) error { - if self.checkedOutByOtherWorktree(branch) { - return self.promptWorktreeBranchDelete(branch) +func (self *BranchesHelper) ConfirmLocalAndRemoteDelete(branches []*models.Branch) error { + if lo.SomeBy(branches, func(branch *models.Branch) bool { return self.checkedOutByOtherWorktree(branch) }) { + return errors.New(self.c.Tr.SomeBranchesCheckedOutByWorktreeError) } - isMerged, err := self.c.Git().Branch.IsBranchMerged(branch, self.c.Model().MainBranches) + allBranchesMerged, err := self.allBranchesMerged(branches) 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, + var prompt string + if len(branches) == 1 { + prompt = utils.ResolvePlaceholderString( + self.c.Tr.DeleteLocalAndRemoteBranchPrompt, map[string]string{ - "selectedBranchName": branch.Name, + "localBranchName": branches[0].Name, + "remoteBranchName": branches[0].UpstreamBranch, + "remoteName": branches[0].UpstreamRemote, }, ) + } else { + prompt = self.c.Tr.DeleteLocalAndRemoteBranchesPrompt + } + + if !allBranchesMerged { + if len(branches) == 1 { + prompt += "\n\n" + utils.ResolvePlaceholderString( + self.c.Tr.ForceDeleteBranchMessage, + map[string]string{ + "selectedBranchName": branches[0].Name, + }, + ) + } else { + prompt += "\n\n" + self.c.Tr.ForceDeleteBranchesMessage + } } self.c.Confirm(types.ConfirmOpts{ @@ -130,18 +162,24 @@ func (self *BranchesHelper) ConfirmLocalAndRemoteDelete(branch *models.Branch) e 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 + // Delete the remote branches first so that we keep the local ones // 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 { + remoteBranches := lo.Map(branches, func(branch *models.Branch, _ int) *models.RemoteBranch { + return &models.RemoteBranch{Name: branch.UpstreamBranch, RemoteName: branch.UpstreamRemote} + }) + if err := self.deleteRemoteBranches(remoteBranches, task); err != nil { return err } self.c.LogAction(self.c.Tr.Actions.DeleteLocalBranch) - if err := self.c.Git().Branch.LocalDelete(branch.Name, true); err != nil { + branchNames := lo.Map(branches, func(branch *models.Branch, _ int) string { return branch.Name }) + if err := self.c.Git().Branch.LocalDelete(branchNames, true); err != nil { return err } + selectionStart, _ := self.c.Contexts().Branches.GetSelectionRange() + self.c.Contexts().Branches.SetSelectedLineIdx(selectionStart) + return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.BRANCHES, types.REMOTES}}) }) }, @@ -198,3 +236,30 @@ func (self *BranchesHelper) promptWorktreeBranchDelete(selectedBranch *models.Br }, }) } + +func (self *BranchesHelper) allBranchesMerged(branches []*models.Branch) (bool, error) { + allBranchesMerged := true + for _, branch := range branches { + isMerged, err := self.c.Git().Branch.IsBranchMerged(branch, self.c.Model().MainBranches) + if err != nil { + return false, err + } + if !isMerged { + allBranchesMerged = false + break + } + } + return allBranchesMerged, nil +} + +func (self *BranchesHelper) deleteRemoteBranches(remoteBranches []*models.RemoteBranch, task gocui.Task) error { + remotes := lo.GroupBy(remoteBranches, func(branch *models.RemoteBranch) string { return branch.RemoteName }) + for remote, branches := range remotes { + self.c.LogAction(self.c.Tr.Actions.DeleteRemoteBranch) + branchNames := lo.Map(branches, func(branch *models.RemoteBranch, _ int) string { return branch.Name }) + if err := self.c.Git().Remote.DeleteRemoteBranch(task, remote, branchNames); err != nil { + return err + } + } + return nil +} diff --git a/pkg/gui/controllers/remote_branches_controller.go b/pkg/gui/controllers/remote_branches_controller.go index 1b590a97b..74e31cd01 100644 --- a/pkg/gui/controllers/remote_branches_controller.go +++ b/pkg/gui/controllers/remote_branches_controller.go @@ -66,8 +66,8 @@ func (self *RemoteBranchesController) GetKeybindings(opts types.KeybindingsOpts) }, { Key: opts.GetKey(opts.Config.Universal.Remove), - Handler: self.withItem(self.delete), - GetDisabledReason: self.require(self.singleItemSelected()), + Handler: self.withItems(self.delete), + GetDisabledReason: self.require(self.itemRangeSelected()), Description: self.c.Tr.Delete, Tooltip: self.c.Tr.DeleteRemoteBranchTooltip, DisplayOnScreen: true, @@ -132,8 +132,8 @@ func (self *RemoteBranchesController) context() *context.RemoteBranchesContext { return self.c.Contexts().RemoteBranches } -func (self *RemoteBranchesController) delete(selectedBranch *models.RemoteBranch) error { - return self.c.Helpers().BranchesHelper.ConfirmDeleteRemote(selectedBranch) +func (self *RemoteBranchesController) delete(selectedBranches []*models.RemoteBranch) error { + return self.c.Helpers().BranchesHelper.ConfirmDeleteRemote(selectedBranches) } func (self *RemoteBranchesController) merge(selectedBranch *models.RemoteBranch) error { diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 2b233cd70..675d6fcab 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -105,12 +105,17 @@ type TranslationSet struct { NewBranchNameBranchOff string CantDeleteCheckOutBranch string DeleteBranchTitle string + DeleteBranchesTitle string DeleteLocalBranch string + DeleteLocalBranches string DeleteRemoteBranchOption string DeleteRemoteBranchPrompt string + DeleteRemoteBranchesPrompt string DeleteLocalAndRemoteBranchPrompt string + DeleteLocalAndRemoteBranchesPrompt string ForceDeleteBranchTitle string ForceDeleteBranchMessage string + ForceDeleteBranchesMessage string RebaseBranch string RebaseBranchTooltip string CantRebaseOntoSelf string @@ -472,8 +477,10 @@ type TranslationSet struct { RemoveRemoteTooltip string RemoveRemotePrompt string DeleteRemoteBranch string + DeleteRemoteBranches string DeleteRemoteBranchTooltip string DeleteLocalAndRemoteBranch string + DeleteLocalAndRemoteBranches string SetAsUpstream string SetAsUpstreamTooltip string SetUpstream string @@ -542,6 +549,7 @@ type TranslationSet struct { ViewBranchUpstreamOptions string ViewBranchUpstreamOptionsTooltip string UpstreamNotSetError string + UpstreamsNotSetError string NewGitFlowBranchPrompt string RenameBranchWarning string OpenKeybindingsMenu string @@ -750,6 +758,7 @@ type TranslationSet struct { SwitchToWorktreeTooltip string AlreadyCheckedOutByWorktree string BranchCheckedOutByWorktree string + SomeBranchesCheckedOutByWorktreeError string DetachWorktreeTooltip string Switching string RemoveWorktree string @@ -1087,12 +1096,17 @@ func EnglishTranslationSet() *TranslationSet { NewBranchNameBranchOff: "New branch name (branch is off of '{{.branchName}}')", CantDeleteCheckOutBranch: "You cannot delete the checked out branch!", DeleteBranchTitle: "Delete branch '{{.selectedBranchName}}'?", + DeleteBranchesTitle: "Delete selected branches?", DeleteLocalBranch: "Delete local branch", + DeleteLocalBranches: "Delete local branches", DeleteRemoteBranchOption: "Delete remote branch", DeleteRemoteBranchPrompt: "Are you sure you want to delete the remote branch '{{.selectedBranchName}}' from '{{.upstream}}'?", + DeleteRemoteBranchesPrompt: "Are you sure you want to delete the remote branches of the selected branches from their respective remotes?", DeleteLocalAndRemoteBranchPrompt: "Are you sure you want to delete both '{{.localBranchName}}' from your machine, and '{{.remoteBranchName}}' from '{{.remoteName}}'?", + DeleteLocalAndRemoteBranchesPrompt: "Are you sure you want to delete both the selected branches from your machine, and their remote branches from their respective remotes?", ForceDeleteBranchTitle: "Force delete branch", ForceDeleteBranchMessage: "'{{.selectedBranchName}}' is not fully merged. Are you sure you want to delete it?", + ForceDeleteBranchesMessage: "Some of the selected branches are not fully merged. Are you sure you want to delete them?", RebaseBranch: "Rebase", RebaseBranchTooltip: "Rebase the checked-out branch onto the selected branch.", CantRebaseOntoSelf: "You cannot rebase a branch onto itself", @@ -1464,8 +1478,10 @@ func EnglishTranslationSet() *TranslationSet { RemoveRemoteTooltip: `Remove the selected remote. Any local branches tracking a remote branch from the remote will be unaffected.`, RemoveRemotePrompt: "Are you sure you want to remove remote?", DeleteRemoteBranch: "Delete remote branch", + DeleteRemoteBranches: "Delete remote branches", DeleteRemoteBranchTooltip: "Delete the remote branch from the remote.", DeleteLocalAndRemoteBranch: "Delete local and remote branch", + DeleteLocalAndRemoteBranches: "Delete local and remote branches", SetAsUpstream: "Set as upstream", SetAsUpstreamTooltip: "Set the selected remote branch as the upstream of the checked-out branch.", SetUpstream: "Set upstream of selected branch", @@ -1530,6 +1546,7 @@ func EnglishTranslationSet() *TranslationSet { ViewBranchUpstreamOptions: "View upstream options", ViewBranchUpstreamOptionsTooltip: "View options relating to the branch's upstream e.g. setting/unsetting the upstream and resetting to the upstream.", UpstreamNotSetError: "The selected branch has no upstream (or the upstream is not stored locally)", + UpstreamsNotSetError: "Some of the selected branches have no upstream (or the upstream is not stored locally)", Upstream: "Upstream", UpstreamTooltip: "View upstream options for selected branch e.g. setting/unsetting the upstream and resetting to the upstream.", NewBranchNamePrompt: "Enter new branch name for branch", @@ -1741,6 +1758,7 @@ func EnglishTranslationSet() *TranslationSet { SwitchToWorktreeTooltip: "Switch to the selected worktree.", AlreadyCheckedOutByWorktree: "This branch is checked out by worktree {{.worktreeName}}. Do you want to switch to that worktree?", BranchCheckedOutByWorktree: "Branch {{.branchName}} is checked out by worktree {{.worktreeName}}", + SomeBranchesCheckedOutByWorktreeError: "Some of the selected branches are checked out by other worktrees. Select them one by one to delete them.", DetachWorktreeTooltip: "This will run `git checkout --detach` on the worktree so that it stops hogging the branch, but the worktree's working tree will be left alone.", Switching: "Switching", RemoveWorktree: "Remove worktree", diff --git a/pkg/integration/tests/branch/delete_multiple.go b/pkg/integration/tests/branch/delete_multiple.go new file mode 100644 index 000000000..a03a822f6 --- /dev/null +++ b/pkg/integration/tests/branch/delete_multiple.go @@ -0,0 +1,186 @@ +package branch + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var DeleteMultiple = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Try some combinations of local and remote branch deletions with a range selection of branches", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + config.GetAppState().LocalBranchSortOrder = "alphabetic" + }, + SetupRepo: func(shell *Shell) { + shell. + CloneIntoRemote("origin"). + CloneIntoRemote("other-remote"). + EmptyCommit("blah"). + NewBranch("branch-01"). + EmptyCommit("on branch-01 01"). + PushBranchAndSetUpstream("origin", "branch-01"). + EmptyCommit("on branch-01 02"). + NewBranch("branch-02"). + EmptyCommit("on branch-02 01"). + PushBranchAndSetUpstream("origin", "branch-02"). + NewBranchFrom("branch-03", "master"). + EmptyCommit("on branch-03 01"). + NewBranch("current-head"). + EmptyCommit("on current-head"). + NewBranchFrom("branch-04", "master"). + EmptyCommit("on branch-04 01"). + PushBranchAndSetUpstream("other-remote", "branch-04"). + EmptyCommit("on branch-04 02"). + NewBranchFrom("branch-05", "master"). + EmptyCommit("on branch-05 01"). + PushBranchAndSetUpstream("origin", "branch-05"). + NewBranchFrom("branch-06", "master"). + EmptyCommit("on branch-06 01"). + PushBranch("origin", "branch-06"). + PushBranchAndSetUpstream("other-remote", "branch-06"). + EmptyCommit("on branch-06 02"). + Checkout("current-head") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Branches(). + Focus(). + Lines( + Contains("current-head").IsSelected(), + Contains("branch-01 ↑1"), + Contains("branch-02 ✓"), + Contains("branch-03"), + Contains("branch-04 ↑1"), + Contains("branch-05 ✓"), + Contains("branch-06 ↑1"), + Contains("master"), + ). + Press(keys.Universal.RangeSelectDown). + + // Deleting a range that includes the current branch is not possible + Press(keys.Universal.Remove). + Tap(func() { + t.ExpectPopup(). + Menu(). + Tooltip(Contains("You cannot delete the checked out branch!")). + Title(Equals("Delete selected branches?")). + Select(Contains("Delete local branches")). + Confirm(). + Tap(func() { + t.ExpectToast(Contains("You cannot delete the checked out branch!")) + }). + Cancel() + }). + + // Delete branch-03 and branch-04. 04 is not fully merged, so we get + // a confirmation popup. + NavigateToLine(Contains("branch-03")). + Press(keys.Universal.RangeSelectDown). + Press(keys.Universal.Remove). + Tap(func() { + t.ExpectPopup(). + Menu(). + Title(Equals("Delete selected branches?")). + Select(Contains("Delete local branches")). + Confirm() + t.ExpectPopup(). + Confirmation(). + Title(Equals("Force delete branch")). + Content(Equals("Some of the selected branches are not fully merged. Are you sure you want to delete them?")). + Confirm() + }). + Lines( + Contains("current-head"), + Contains("branch-01 ↑1"), + Contains("branch-02 ✓"), + Contains("branch-05 ✓").IsSelected(), + Contains("branch-06 ↑1"), + Contains("master"), + ). + + // Delete remote branches of branch-05 and branch-06. They are on different remotes. + NavigateToLine(Contains("branch-05")). + Press(keys.Universal.RangeSelectDown). + Press(keys.Universal.Remove). + Tap(func() { + t.ExpectPopup(). + Menu(). + Title(Equals("Delete selected branches?")). + Select(Contains("Delete remote branches")). + Confirm() + }). + Tap(func() { + t.ExpectPopup(). + Confirmation(). + Title(Equals("Delete selected branches?")). + Content(Equals("Are you sure you want to delete the remote branches of the selected branches from their respective remotes?")). + Confirm() + }). + Tap(func() { + checkRemoteBranches(t, keys, "origin", []string{ + "branch-01", + "branch-02", + "branch-06", + }) + checkRemoteBranches(t, keys, "other-remote", []string{ + "branch-04", + }) + }). + Lines( + Contains("current-head"), + Contains("branch-01 ↑1"), + Contains("branch-02 ✓"), + Contains("branch-05 (upstream gone)").IsSelected(), + Contains("branch-06 (upstream gone)").IsSelected(), + Contains("master"), + ). + + // Try to delete both local and remote branches of branch-02 and + // branch-05; not possible because branch-05's upstream is gone + Press(keys.Universal.Remove). + Tap(func() { + t.ExpectPopup(). + Menu(). + Title(Equals("Delete selected branches?")). + Select(Contains("Delete local and remote branches")). + Confirm(). + Tap(func() { + t.ExpectToast(Contains("Some of the selected branches have no upstream (or the upstream is not stored locally)")) + }). + Cancel() + }). + + // Delete both local and remote branches of branch-01 and branch-02. We get + // the force-delete warning because branch-01 it is not fully merged. + NavigateToLine(Contains("branch-01")). + Press(keys.Universal.RangeSelectDown). + Press(keys.Universal.Remove). + Tap(func() { + t.ExpectPopup(). + Menu(). + Title(Equals("Delete selected branches?")). + Select(Contains("Delete local and remote branches")). + Confirm() + t.ExpectPopup(). + Confirmation(). + Title(Equals("Delete local and remote branch")). + Content(Contains("Are you sure you want to delete both the selected branches from your machine, and their remote branches from their respective remotes?"). + Contains("Some of the selected branches are not fully merged. Are you sure you want to delete them?")). + Confirm() + }). + Lines( + Contains("current-head"), + Contains("branch-05 (upstream gone)").IsSelected(), + Contains("branch-06 (upstream gone)"), + Contains("master"), + ). + Tap(func() { + checkRemoteBranches(t, keys, "origin", []string{ + "branch-06", + }) + checkRemoteBranches(t, keys, "other-remote", []string{ + "branch-04", + }) + }) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 2f60f3a47..40435b916 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -42,6 +42,7 @@ var tests = []*components.IntegrationTest{ branch.CheckoutByName, branch.CreateTag, branch.Delete, + branch.DeleteMultiple, branch.DeleteRemoteBranchWithCredentialPrompt, branch.DeleteRemoteBranchWithDifferentName, branch.DetachedHead,