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.
This commit is contained in:
Stefan Haller 2024-11-23 19:32:27 +01:00
parent 0b0910573b
commit c1b4201726
9 changed files with 405 additions and 88 deletions

View file

@ -109,10 +109,10 @@ func (self *BranchCommands) CurrentBranchName() (string, error) {
} }
// LocalDelete delete branch locally // 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"). cmdArgs := NewGitCmd("branch").
ArgIfElse(force, "-D", "-d"). ArgIfElse(force, "-D", "-d").
Arg(branch). Arg(branches...).
ToArgv() ToArgv()
return self.cmd.New(cmdArgs).Run() return self.cmd.New(cmdArgs).Run()

View file

@ -62,36 +62,57 @@ func TestBranchNewBranch(t *testing.T) {
func TestBranchDeleteBranch(t *testing.T) { func TestBranchDeleteBranch(t *testing.T) {
type scenario struct { type scenario struct {
testName string testName string
force bool branchNames []string
runner *oscommands.FakeCmdObjRunner force bool
test func(error) runner *oscommands.FakeCmdObjRunner
test func(error)
} }
scenarios := []scenario{ scenarios := []scenario{
{ {
"Delete a branch", "Delete a branch",
[]string{"test"},
false, false,
oscommands.NewFakeRunner(t).ExpectGitArgs([]string{"branch", "-d", "test"}, "", nil), oscommands.NewFakeRunner(t).ExpectGitArgs([]string{"branch", "-d", "test"}, "", nil),
func(err error) { func(err error) {
assert.NoError(t, err) 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", "Force delete a branch",
[]string{"test"},
true, true,
oscommands.NewFakeRunner(t).ExpectGitArgs([]string{"branch", "-D", "test"}, "", nil), oscommands.NewFakeRunner(t).ExpectGitArgs([]string{"branch", "-D", "test"}, "", nil),
func(err error) { func(err error) {
assert.NoError(t, err) 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 { for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) { t.Run(s.testName, func(t *testing.T) {
instance := buildBranchCommands(commonDeps{runner: s.runner}) instance := buildBranchCommands(commonDeps{runner: s.runner})
s.test(instance.LocalDelete("test", s.force)) s.test(instance.LocalDelete(s.branchNames, s.force))
s.runner.CheckForMissingCalls() s.runner.CheckForMissingCalls()
}) })
} }

View file

@ -49,9 +49,10 @@ func (self *RemoteCommands) UpdateRemoteUrl(remoteName string, updatedUrl string
return self.cmd.New(cmdArgs).Run() 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"). cmdArgs := NewGitCmd("push").
Arg(remoteName, "--delete", branchName). Arg(remoteName, "--delete").
Arg(branchNames...).
ToArgv() ToArgv()
return self.cmd.New(cmdArgs).PromptOnCredentialRequest(task).Run() return self.cmd.New(cmdArgs).PromptOnCredentialRequest(task).Run()

View file

@ -91,8 +91,8 @@ func (self *BranchesController) GetKeybindings(opts types.KeybindingsOpts) []*ty
}, },
{ {
Key: opts.GetKey(opts.Config.Universal.Remove), Key: opts.GetKey(opts.Config.Universal.Remove),
Handler: self.withItem(self.delete), Handler: self.withItems(self.delete),
GetDisabledReason: self.require(self.singleItemSelected(self.branchIsReal)), GetDisabledReason: self.require(self.itemRangeSelected(self.branchesAreReal)),
Description: self.c.Tr.Delete, Description: self.c.Tr.Delete,
Tooltip: self.c.Tr.BranchDeleteTooltip, Tooltip: self.c.Tr.BranchDeleteTooltip,
OpensMenu: true, OpensMenu: true,
@ -520,29 +520,35 @@ func (self *BranchesController) createNewBranchWithName(newBranchName string) er
return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, KeepBranchSelectionIndex: true}) return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, KeepBranchSelectionIndex: true})
} }
func (self *BranchesController) localDelete(branch *models.Branch) error { func (self *BranchesController) localDelete(branches []*models.Branch) error {
return self.c.Helpers().BranchesHelper.ConfirmLocalDelete(branch) return self.c.Helpers().BranchesHelper.ConfirmLocalDelete(branches)
} }
func (self *BranchesController) remoteDelete(branch *models.Branch) error { func (self *BranchesController) remoteDelete(branches []*models.Branch) error {
remoteBranch := &models.RemoteBranch{Name: branch.UpstreamBranch, RemoteName: branch.UpstreamRemote} remoteBranches := lo.Map(branches, func(branch *models.Branch, _ int) *models.RemoteBranch {
return self.c.Helpers().BranchesHelper.ConfirmDeleteRemote(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 { func (self *BranchesController) localAndRemoteDelete(branches []*models.Branch) error {
return self.c.Helpers().BranchesHelper.ConfirmLocalAndRemoteDelete(branch) 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() checkedOutBranch := self.c.Helpers().Refs.GetCheckedOutRef()
isBranchCheckedOut := checkedOutBranch.Name == branch.Name isBranchCheckedOut := lo.SomeBy(branches, func(branch *models.Branch) bool {
hasUpstream := branch.IsTrackingRemote() && !branch.UpstreamGone return checkedOutBranch.Name == branch.Name
})
hasUpstream := lo.EveryBy(branches, func(branch *models.Branch) bool {
return branch.IsTrackingRemote() && !branch.UpstreamGone
})
localDeleteItem := &types.MenuItem{ localDeleteItem := &types.MenuItem{
Label: self.c.Tr.DeleteLocalBranch, Label: lo.Ternary(len(branches) > 1, self.c.Tr.DeleteLocalBranches, self.c.Tr.DeleteLocalBranch),
Key: 'c', Key: 'c',
OnPress: func() error { OnPress: func() error {
return self.localDelete(branch) return self.localDelete(branches)
}, },
} }
if isBranchCheckedOut { if isBranchCheckedOut {
@ -550,35 +556,44 @@ func (self *BranchesController) delete(branch *models.Branch) error {
} }
remoteDeleteItem := &types.MenuItem{ remoteDeleteItem := &types.MenuItem{
Label: self.c.Tr.DeleteRemoteBranch, Label: lo.Ternary(len(branches) > 1, self.c.Tr.DeleteRemoteBranches, self.c.Tr.DeleteRemoteBranch),
Key: 'r', Key: 'r',
OnPress: func() error { OnPress: func() error {
return self.remoteDelete(branch) return self.remoteDelete(branches)
}, },
} }
if !hasUpstream { 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{ deleteBothItem := &types.MenuItem{
Label: self.c.Tr.DeleteLocalAndRemoteBranch, Label: lo.Ternary(len(branches) > 1, self.c.Tr.DeleteLocalAndRemoteBranches, self.c.Tr.DeleteLocalAndRemoteBranch),
Key: 'b', Key: 'b',
OnPress: func() error { OnPress: func() error {
return self.localAndRemoteDelete(branch) return self.localAndRemoteDelete(branches)
}, },
} }
if isBranchCheckedOut { if isBranchCheckedOut {
deleteBothItem.DisabledReason = &types.DisabledReason{Text: self.c.Tr.CantDeleteCheckOutBranch} deleteBothItem.DisabledReason = &types.DisabledReason{Text: self.c.Tr.CantDeleteCheckOutBranch}
} else if !hasUpstream { } 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( var menuTitle string
self.c.Tr.DeleteBranchTitle, if len(branches) == 1 {
map[string]string{ menuTitle = utils.ResolvePlaceholderString(
"selectedBranchName": branch.Name, self.c.Tr.DeleteBranchTitle,
}, map[string]string{
) "selectedBranchName": branches[0].Name,
},
)
} else {
menuTitle = self.c.Tr.DeleteBranchesTitle
}
return self.c.Menu(types.CreateMenuOptions{ return self.c.Menu(types.CreateMenuOptions{
Title: menuTitle, Title: menuTitle,
@ -822,6 +837,16 @@ func (self *BranchesController) branchIsReal(branch *models.Branch) *types.Disab
return nil 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 { func (self *BranchesController) notMergingIntoYourself(branch *models.Branch) *types.DisabledReason {
selectedBranchName := branch.Name selectedBranchName := branch.Name
checkedOutBranch := self.c.Helpers().Refs.GetCheckedOutRef().Name checkedOutBranch := self.c.Helpers().Refs.GetCheckedOutRef().Name

View file

@ -1,6 +1,7 @@
package helpers package helpers
import ( import (
"errors"
"strings" "strings"
"github.com/jesseduffield/gocui" "github.com/jesseduffield/gocui"
@ -9,6 +10,7 @@ import (
"github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/context"
"github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/utils" "github.com/jesseduffield/lazygit/pkg/utils"
"github.com/samber/lo"
) )
type BranchesHelper struct { type BranchesHelper struct {
@ -23,12 +25,16 @@ func NewBranchesHelper(c *HelperCommon, worktreeHelper *WorktreeHelper) *Branche
} }
} }
func (self *BranchesHelper) ConfirmLocalDelete(branch *models.Branch) error { func (self *BranchesHelper) ConfirmLocalDelete(branches []*models.Branch) error {
if self.checkedOutByOtherWorktree(branch) { if len(branches) > 1 {
return self.promptWorktreeBranchDelete(branch) 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 { if err != nil {
return err return err
} }
@ -36,24 +42,32 @@ func (self *BranchesHelper) ConfirmLocalDelete(branch *models.Branch) error {
doDelete := func() error { doDelete := func() error {
return self.c.WithWaitingStatus(self.c.Tr.DeletingStatus, func(_ gocui.Task) error { return self.c.WithWaitingStatus(self.c.Tr.DeletingStatus, func(_ gocui.Task) error {
self.c.LogAction(self.c.Tr.Actions.DeleteLocalBranch) 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 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}}) return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.BRANCHES}})
}) })
} }
if isMerged { if allBranchesMerged {
return doDelete() return doDelete()
} }
title := self.c.Tr.ForceDeleteBranchTitle title := self.c.Tr.ForceDeleteBranchTitle
message := utils.ResolvePlaceholderString( var message string
self.c.Tr.ForceDeleteBranchMessage, if len(branches) == 1 {
map[string]string{ message = utils.ResolvePlaceholderString(
"selectedBranchName": branch.Name, self.c.Tr.ForceDeleteBranchMessage,
}, map[string]string{
) "selectedBranchName": branches[0].Name,
},
)
} else {
message = self.c.Tr.ForceDeleteBranchesMessage
}
self.c.Confirm(types.ConfirmOpts{ self.c.Confirm(types.ConfirmOpts{
Title: title, Title: title,
@ -66,27 +80,36 @@ func (self *BranchesHelper) ConfirmLocalDelete(branch *models.Branch) error {
return nil return nil
} }
func (self *BranchesHelper) ConfirmDeleteRemote(remoteBranch *models.RemoteBranch) error { func (self *BranchesHelper) ConfirmDeleteRemote(remoteBranches []*models.RemoteBranch) error {
title := utils.ResolvePlaceholderString( var title string
self.c.Tr.DeleteBranchTitle, if len(remoteBranches) == 1 {
map[string]string{ title = utils.ResolvePlaceholderString(
"selectedBranchName": remoteBranch.Name, self.c.Tr.DeleteBranchTitle,
}, map[string]string{
) "selectedBranchName": remoteBranches[0].Name,
prompt := utils.ResolvePlaceholderString( },
self.c.Tr.DeleteRemoteBranchPrompt, )
map[string]string{ } else {
"selectedBranchName": remoteBranch.Name, title = self.c.Tr.DeleteBranchesTitle
"upstream": remoteBranch.RemoteName, }
}, 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{ self.c.Confirm(types.ConfirmOpts{
Title: title, Title: title,
Prompt: prompt, Prompt: prompt,
HandleConfirm: func() error { HandleConfirm: func() error {
return self.c.WithWaitingStatus(self.c.Tr.DeletingStatus, func(task gocui.Task) 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.deleteRemoteBranches(remoteBranches, task); err != nil {
if err := self.c.Git().Remote.DeleteRemoteBranch(task, remoteBranch.RemoteName, remoteBranch.Name); err != nil {
return err return err
} }
return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.BRANCHES, types.REMOTES}}) 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 return nil
} }
func (self *BranchesHelper) ConfirmLocalAndRemoteDelete(branch *models.Branch) error { func (self *BranchesHelper) ConfirmLocalAndRemoteDelete(branches []*models.Branch) error {
if self.checkedOutByOtherWorktree(branch) { if lo.SomeBy(branches, func(branch *models.Branch) bool { return self.checkedOutByOtherWorktree(branch) }) {
return self.promptWorktreeBranchDelete(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 { if err != nil {
return err return err
} }
prompt := utils.ResolvePlaceholderString( var prompt string
self.c.Tr.DeleteLocalAndRemoteBranchPrompt, if len(branches) == 1 {
map[string]string{ prompt = utils.ResolvePlaceholderString(
"localBranchName": branch.Name, self.c.Tr.DeleteLocalAndRemoteBranchPrompt,
"remoteBranchName": branch.UpstreamBranch,
"remoteName": branch.UpstreamRemote,
},
)
if !isMerged {
prompt += "\n\n" + utils.ResolvePlaceholderString(
self.c.Tr.ForceDeleteBranchMessage,
map[string]string{ 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{ self.c.Confirm(types.ConfirmOpts{
@ -130,18 +162,24 @@ func (self *BranchesHelper) ConfirmLocalAndRemoteDelete(branch *models.Branch) e
Prompt: prompt, Prompt: prompt,
HandleConfirm: func() error { HandleConfirm: func() error {
return self.c.WithWaitingStatus(self.c.Tr.DeletingStatus, func(task gocui.Task) 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 // in case of failure
self.c.LogAction(self.c.Tr.Actions.DeleteRemoteBranch) remoteBranches := lo.Map(branches, func(branch *models.Branch, _ int) *models.RemoteBranch {
if err := self.c.Git().Remote.DeleteRemoteBranch(task, branch.UpstreamRemote, branch.Name); err != nil { return &models.RemoteBranch{Name: branch.UpstreamBranch, RemoteName: branch.UpstreamRemote}
})
if err := self.deleteRemoteBranches(remoteBranches, task); err != nil {
return err return err
} }
self.c.LogAction(self.c.Tr.Actions.DeleteLocalBranch) 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 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}}) 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
}

View file

@ -66,8 +66,8 @@ func (self *RemoteBranchesController) GetKeybindings(opts types.KeybindingsOpts)
}, },
{ {
Key: opts.GetKey(opts.Config.Universal.Remove), Key: opts.GetKey(opts.Config.Universal.Remove),
Handler: self.withItem(self.delete), Handler: self.withItems(self.delete),
GetDisabledReason: self.require(self.singleItemSelected()), GetDisabledReason: self.require(self.itemRangeSelected()),
Description: self.c.Tr.Delete, Description: self.c.Tr.Delete,
Tooltip: self.c.Tr.DeleteRemoteBranchTooltip, Tooltip: self.c.Tr.DeleteRemoteBranchTooltip,
DisplayOnScreen: true, DisplayOnScreen: true,
@ -132,8 +132,8 @@ func (self *RemoteBranchesController) context() *context.RemoteBranchesContext {
return self.c.Contexts().RemoteBranches return self.c.Contexts().RemoteBranches
} }
func (self *RemoteBranchesController) delete(selectedBranch *models.RemoteBranch) error { func (self *RemoteBranchesController) delete(selectedBranches []*models.RemoteBranch) error {
return self.c.Helpers().BranchesHelper.ConfirmDeleteRemote(selectedBranch) return self.c.Helpers().BranchesHelper.ConfirmDeleteRemote(selectedBranches)
} }
func (self *RemoteBranchesController) merge(selectedBranch *models.RemoteBranch) error { func (self *RemoteBranchesController) merge(selectedBranch *models.RemoteBranch) error {

View file

@ -105,12 +105,17 @@ type TranslationSet struct {
NewBranchNameBranchOff string NewBranchNameBranchOff string
CantDeleteCheckOutBranch string CantDeleteCheckOutBranch string
DeleteBranchTitle string DeleteBranchTitle string
DeleteBranchesTitle string
DeleteLocalBranch string DeleteLocalBranch string
DeleteLocalBranches string
DeleteRemoteBranchOption string DeleteRemoteBranchOption string
DeleteRemoteBranchPrompt string DeleteRemoteBranchPrompt string
DeleteRemoteBranchesPrompt string
DeleteLocalAndRemoteBranchPrompt string DeleteLocalAndRemoteBranchPrompt string
DeleteLocalAndRemoteBranchesPrompt string
ForceDeleteBranchTitle string ForceDeleteBranchTitle string
ForceDeleteBranchMessage string ForceDeleteBranchMessage string
ForceDeleteBranchesMessage string
RebaseBranch string RebaseBranch string
RebaseBranchTooltip string RebaseBranchTooltip string
CantRebaseOntoSelf string CantRebaseOntoSelf string
@ -472,8 +477,10 @@ type TranslationSet struct {
RemoveRemoteTooltip string RemoveRemoteTooltip string
RemoveRemotePrompt string RemoveRemotePrompt string
DeleteRemoteBranch string DeleteRemoteBranch string
DeleteRemoteBranches string
DeleteRemoteBranchTooltip string DeleteRemoteBranchTooltip string
DeleteLocalAndRemoteBranch string DeleteLocalAndRemoteBranch string
DeleteLocalAndRemoteBranches string
SetAsUpstream string SetAsUpstream string
SetAsUpstreamTooltip string SetAsUpstreamTooltip string
SetUpstream string SetUpstream string
@ -542,6 +549,7 @@ type TranslationSet struct {
ViewBranchUpstreamOptions string ViewBranchUpstreamOptions string
ViewBranchUpstreamOptionsTooltip string ViewBranchUpstreamOptionsTooltip string
UpstreamNotSetError string UpstreamNotSetError string
UpstreamsNotSetError string
NewGitFlowBranchPrompt string NewGitFlowBranchPrompt string
RenameBranchWarning string RenameBranchWarning string
OpenKeybindingsMenu string OpenKeybindingsMenu string
@ -750,6 +758,7 @@ type TranslationSet struct {
SwitchToWorktreeTooltip string SwitchToWorktreeTooltip string
AlreadyCheckedOutByWorktree string AlreadyCheckedOutByWorktree string
BranchCheckedOutByWorktree string BranchCheckedOutByWorktree string
SomeBranchesCheckedOutByWorktreeError string
DetachWorktreeTooltip string DetachWorktreeTooltip string
Switching string Switching string
RemoveWorktree string RemoveWorktree string
@ -1087,12 +1096,17 @@ func EnglishTranslationSet() *TranslationSet {
NewBranchNameBranchOff: "New branch name (branch is off of '{{.branchName}}')", NewBranchNameBranchOff: "New branch name (branch is off of '{{.branchName}}')",
CantDeleteCheckOutBranch: "You cannot delete the checked out branch!", CantDeleteCheckOutBranch: "You cannot delete the checked out branch!",
DeleteBranchTitle: "Delete branch '{{.selectedBranchName}}'?", DeleteBranchTitle: "Delete branch '{{.selectedBranchName}}'?",
DeleteBranchesTitle: "Delete selected branches?",
DeleteLocalBranch: "Delete local branch", DeleteLocalBranch: "Delete local branch",
DeleteLocalBranches: "Delete local branches",
DeleteRemoteBranchOption: "Delete remote branch", DeleteRemoteBranchOption: "Delete remote branch",
DeleteRemoteBranchPrompt: "Are you sure you want to delete the remote branch '{{.selectedBranchName}}' from '{{.upstream}}'?", 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}}'?", 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", ForceDeleteBranchTitle: "Force delete branch",
ForceDeleteBranchMessage: "'{{.selectedBranchName}}' is not fully merged. Are you sure you want to delete it?", 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", RebaseBranch: "Rebase",
RebaseBranchTooltip: "Rebase the checked-out branch onto the selected branch.", RebaseBranchTooltip: "Rebase the checked-out branch onto the selected branch.",
CantRebaseOntoSelf: "You cannot rebase a branch onto itself", 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.`, 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?", RemoveRemotePrompt: "Are you sure you want to remove remote?",
DeleteRemoteBranch: "Delete remote branch", DeleteRemoteBranch: "Delete remote branch",
DeleteRemoteBranches: "Delete remote branches",
DeleteRemoteBranchTooltip: "Delete the remote branch from the remote.", DeleteRemoteBranchTooltip: "Delete the remote branch from the remote.",
DeleteLocalAndRemoteBranch: "Delete local and remote branch", DeleteLocalAndRemoteBranch: "Delete local and remote branch",
DeleteLocalAndRemoteBranches: "Delete local and remote branches",
SetAsUpstream: "Set as upstream", SetAsUpstream: "Set as upstream",
SetAsUpstreamTooltip: "Set the selected remote branch as the upstream of the checked-out branch.", SetAsUpstreamTooltip: "Set the selected remote branch as the upstream of the checked-out branch.",
SetUpstream: "Set upstream of selected branch", SetUpstream: "Set upstream of selected branch",
@ -1530,6 +1546,7 @@ func EnglishTranslationSet() *TranslationSet {
ViewBranchUpstreamOptions: "View upstream options", ViewBranchUpstreamOptions: "View upstream options",
ViewBranchUpstreamOptionsTooltip: "View options relating to the branch's upstream e.g. setting/unsetting the upstream and resetting to the upstream.", 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)", 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", Upstream: "Upstream",
UpstreamTooltip: "View upstream options for selected branch e.g. setting/unsetting the upstream and resetting to the 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", NewBranchNamePrompt: "Enter new branch name for branch",
@ -1741,6 +1758,7 @@ func EnglishTranslationSet() *TranslationSet {
SwitchToWorktreeTooltip: "Switch to the selected worktree.", SwitchToWorktreeTooltip: "Switch to the selected worktree.",
AlreadyCheckedOutByWorktree: "This branch is checked out by worktree {{.worktreeName}}. Do you want to switch to that 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}}", 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.", 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", Switching: "Switching",
RemoveWorktree: "Remove worktree", RemoveWorktree: "Remove worktree",

View file

@ -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",
})
})
},
})

View file

@ -42,6 +42,7 @@ var tests = []*components.IntegrationTest{
branch.CheckoutByName, branch.CheckoutByName,
branch.CreateTag, branch.CreateTag,
branch.Delete, branch.Delete,
branch.DeleteMultiple,
branch.DeleteRemoteBranchWithCredentialPrompt, branch.DeleteRemoteBranchWithCredentialPrompt,
branch.DeleteRemoteBranchWithDifferentName, branch.DeleteRemoteBranchWithDifferentName,
branch.DetachedHead, branch.DetachedHead,