1
0
mirror of https://github.com/jesseduffield/lazygit.git synced 2025-04-25 12:24:47 +02:00

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
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()

View File

@ -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()
})
}

View File

@ -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()

View File

@ -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

View File

@ -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
}

View File

@ -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 {

View File

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

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.CreateTag,
branch.Delete,
branch.DeleteMultiple,
branch.DeleteRemoteBranchWithCredentialPrompt,
branch.DeleteRemoteBranchWithDifferentName,
branch.DetachedHead,