Improve deleting worktrees and their branches (#5748)

This PR improves three rough edges around deleting worktrees and the
branches checked out in them:

- **Deleting a branch that's checked out in another worktree now
actually deletes the branch.** The menu used to offer to remove or
detach the worktree but then stop there, leaving behind the very branch
you asked to delete. Both actions now delete the branch afterwards, and
the labels say so ("Remove worktree and delete branch" / "Detach
worktree and delete branch"). The old "Switch to worktree" entry is
dropped — it's not a useful option in the context of deleting a branch.

- **A branch's local branch, remote branch, and worktree can be deleted
in one step.** Picking "Delete local and remote branch" for a single
branch that's checked out in another worktree used to fail with a
confusing "select them one by one" error (which only makes sense for a
multi-selection). It now goes through the same worktree menu, with
labels that make clear the remote is deleted too.

- **Pressing `d` on a worktree can delete its branch.** The plain
confirmation becomes a menu: "Remove worktree", "Remove worktree and
delete branch", and "Remove worktree and delete local and remote
branch".

Throughout, the explicit menu choice acts as the confirmation, so the
separate "Are you sure you want to remove worktree?" prompt is gone; the
dirty-worktree force prompt and the not-fully-merged warning still show
up when relevant.

Closes #5205.
This commit is contained in:
Stefan Haller 2026-07-03 19:07:25 +02:00 committed by GitHub
commit 0ecced93ea
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 527 additions and 134 deletions

View file

@ -279,6 +279,29 @@ languages), and the map form extends cleanly when a string later needs more
than one placeholder. This holds for every user-facing string, including short
ones like disabled-action reasons and toasts.
## Only edit the English translations
`pkg/i18n/english.go` is the one translation file you edit; add, change, and
remove strings there. The other languages under `pkg/i18n/translations/` are
maintained by Crowdin and synced automatically — never edit them by hand, not
even to add a key you just introduced or to delete one you just removed. A
removed English string simply leaves an orphan key in those files, which
Crowdin cleans up on its own; an unknown key in a translation file is ignored
at load time, so it does no harm in the meantime.
## Try to keep new english.go strings within the existing column alignment
`gofumpt` aligns the `TranslationSet` struct fields and the `EnglishTranslationSet`
literal into columns, so a new field whose name is longer than the widest one in
its alignment block re-indents every line in that block. When there are several
feature branches in flight that all add strings, that reformatting churn turns
english.go into a rebase-conflict magnet. So when it's cheap to do so, make an
effort to keep a new field name within the current widest name in the block
(measure it; it's around 40 characters today), shortening the Go field name to
fit. This is a soft preference, not a rule: the usual "best name wins" still
applies, so don't mangle a name past the point of readability just to save a
column. Applies only to `pkg/i18n/english.go`.
## Code comments are for future readers, not development history
Comments in source code explain *why this code is shaped the way it is*. They

View file

@ -8,7 +8,6 @@ import (
"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/context"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/samber/lo"
@ -32,20 +31,17 @@ func (self *BranchesHelper) ConfirmLocalDelete(branches []*models.Branch) error
return errors.New(self.c.Tr.SomeBranchesCheckedOutByWorktreeError)
}
} else if self.checkedOutByOtherWorktree(branches[0]) {
return self.promptWorktreeBranchDelete(branches[0])
return self.promptWorktreeBranchDelete(
branches[0],
self.c.Tr.RemoveWorktreeAndDeleteBranch,
self.c.Tr.DetachWorktreeAndDeleteBranch,
self.deleteLocalBranchesContinuation(branches),
)
}
allBranchesMerged, err := self.allBranchesMerged(branches)
if err != nil {
return err
}
doDelete := func() error {
return self.confirmForceIfUnmerged(branches, func() error {
return self.c.WithWaitingStatus(self.c.Tr.DeletingStatus, func(_ gocui.Task) error {
self.c.LogAction(self.c.Tr.Actions.DeleteLocalBranch)
self.logBranchHashes(branches)
branchNames := lo.Map(branches, func(branch *models.Branch, _ int) string { return branch.Name })
if err := self.c.Git().Branch.LocalDelete(branchNames, true); err != nil {
if err := self.doDeleteLocalBranches(branches); err != nil {
return err
}
@ -53,34 +49,7 @@ func (self *BranchesHelper) ConfirmLocalDelete(branches []*models.Branch) error
self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.BRANCHES}})
return nil
})
}
if allBranchesMerged {
return doDelete()
}
title := self.c.Tr.ForceDeleteBranchTitle
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,
Prompt: message,
HandleConfirm: func() error {
return doDelete()
},
})
return nil
}
func (self *BranchesHelper) ConfirmDeleteRemote(remoteBranches []*models.RemoteBranch, resetRemoteBranchesSelection bool) error {
@ -128,8 +97,17 @@ func (self *BranchesHelper) ConfirmDeleteRemote(remoteBranches []*models.RemoteB
}
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)
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],
self.c.Tr.RemoveWorktreeAndDeleteBothBranches,
self.c.Tr.DetachWorktreeAndDeleteBothBranches,
self.deleteLocalAndRemoteBranchesContinuation(branches),
)
}
allBranchesMerged, err := self.allBranchesMerged(branches)
@ -169,19 +147,7 @@ func (self *BranchesHelper) ConfirmLocalAndRemoteDelete(branches []*models.Branc
Prompt: prompt,
HandleConfirm: func() error {
return self.c.WithWaitingStatus(self.c.Tr.DeletingStatus, func(task gocui.Task) error {
// Delete the remote branches first so that we keep the local ones
// in case of failure
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)
self.logBranchHashes(branches)
branchNames := lo.Map(branches, func(branch *models.Branch, _ int) string { return branch.Name })
if err := self.c.Git().Branch.LocalDelete(branchNames, true); err != nil {
if err := self.doDeleteLocalAndRemoteBranches(task, branches); err != nil {
return err
}
@ -207,8 +173,18 @@ func (self *BranchesHelper) worktreeForBranch(branch *models.Branch) (*models.Wo
return git_commands.WorktreeForBranch(branch, self.c.Model().Worktrees)
}
func (self *BranchesHelper) promptWorktreeBranchDelete(selectedBranch *models.Branch) error {
worktree, ok := self.worktreeForBranch(selectedBranch)
// promptWorktreeBranchDelete handles deleting a branch that's checked out by
// another worktree: the worktree has to be removed or detached first to free the
// branch, so we offer both as menu items. Either way the branch is deleted
// afterwards (that's what the user asked for), via deleteBranches, which knows
// whether to delete just the local branch or the remote one too.
func (self *BranchesHelper) promptWorktreeBranchDelete(
branch *models.Branch,
removeLabel string,
detachLabel string,
deleteBranches func(gocui.Task) error,
) error {
worktree, ok := self.worktreeForBranch(branch)
if !ok {
self.c.Log.Error("promptWorktreeBranchDelete out of sync with list of worktrees")
return nil
@ -216,34 +192,152 @@ func (self *BranchesHelper) promptWorktreeBranchDelete(selectedBranch *models.Br
title := utils.ResolvePlaceholderString(self.c.Tr.BranchCheckedOutByWorktree, map[string]string{
"worktreeName": worktree.Name,
"branchName": selectedBranch.Name,
"branchName": branch.Name,
})
return self.c.Menu(types.CreateMenuOptions{
Title: title,
Items: []*types.MenuItem{
{
Label: self.c.Tr.SwitchToWorktree,
Label: removeLabel,
Keys: menuKey('r'),
OnPress: func() error {
return self.worktreeHelper.Switch(worktree, context.LOCAL_BRANCHES_CONTEXT_KEY)
return self.confirmForceIfUnmerged([]*models.Branch{branch}, func() error {
return self.worktreeHelper.Remove(worktree, deleteBranches)
})
},
},
{
Label: self.c.Tr.DetachWorktree,
Label: detachLabel,
Keys: menuKey('d'),
Tooltip: self.c.Tr.DetachWorktreeTooltip,
OnPress: func() error {
return self.worktreeHelper.Detach(worktree)
},
},
{
Label: self.c.Tr.RemoveWorktree,
OnPress: func() error {
return self.worktreeHelper.Remove(worktree, false)
return self.confirmForceIfUnmerged([]*models.Branch{branch}, func() error {
return self.worktreeHelper.Detach(worktree, deleteBranches)
})
},
},
},
})
}
// RemoveWorktreeAndDeleteBranch removes the worktree and deletes the local branch
// it has checked out, force-warning first if the branch isn't fully merged. It's
// the worktrees-panel counterpart to deleting a worktree-checked-out branch from
// the branches panel.
func (self *BranchesHelper) RemoveWorktreeAndDeleteBranch(
worktree *models.Worktree, branch *models.Branch,
) error {
branches := []*models.Branch{branch}
return self.removeWorktreeAndDelete(worktree, branches,
self.deleteLocalBranchesContinuation(branches))
}
// RemoveWorktreeAndDeleteBothBranches is like RemoveWorktreeAndDeleteBranch but
// also deletes the branch's upstream.
func (self *BranchesHelper) RemoveWorktreeAndDeleteBothBranches(
worktree *models.Worktree, branch *models.Branch,
) error {
branches := []*models.Branch{branch}
return self.removeWorktreeAndDelete(worktree, branches,
self.deleteLocalAndRemoteBranchesContinuation(branches))
}
func (self *BranchesHelper) removeWorktreeAndDelete(
worktree *models.Worktree, branches []*models.Branch, deleteBranches func(gocui.Task) error,
) error {
return self.confirmForceIfUnmerged(branches, func() error {
return self.worktreeHelper.Remove(worktree, deleteBranches)
})
}
// confirmForceIfUnmerged runs onConfirm directly if all the branches are fully
// merged, and otherwise shows the force-delete warning first and runs onConfirm
// when the user confirms it.
func (self *BranchesHelper) confirmForceIfUnmerged(branches []*models.Branch, onConfirm func() error) error {
allBranchesMerged, err := self.allBranchesMerged(branches)
if err != nil {
return err
}
if allBranchesMerged {
return onConfirm()
}
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: self.c.Tr.ForceDeleteBranchTitle,
Prompt: message,
HandleConfirm: onConfirm,
})
return nil
}
func (self *BranchesHelper) doDeleteLocalBranches(branches []*models.Branch) error {
self.c.LogAction(self.c.Tr.Actions.DeleteLocalBranch)
self.logBranchHashes(branches)
branchNames := lo.Map(branches, func(branch *models.Branch, _ int) string { return branch.Name })
return self.c.Git().Branch.LocalDelete(branchNames, true)
}
func (self *BranchesHelper) doDeleteLocalAndRemoteBranches(task gocui.Task, branches []*models.Branch) error {
// Delete the remote branches first so that we keep the local ones
// in case of failure
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
}
return self.doDeleteLocalBranches(branches)
}
// deleteLocalBranchesContinuation returns a worktree-removal continuation that
// deletes the local branches and refreshes once the worktree is out of the way.
func (self *BranchesHelper) deleteLocalBranchesContinuation(branches []*models.Branch) func(gocui.Task) error {
return func(gocui.Task) error {
if err := self.doDeleteLocalBranches(branches); err != nil {
return err
}
self.c.Contexts().Branches.CollapseRangeSelectionToTop()
self.c.Refresh(types.RefreshOptions{
Mode: types.ASYNC,
Scope: []types.RefreshableView{types.WORKTREES, types.BRANCHES, types.FILES},
})
return nil
}
}
// deleteLocalAndRemoteBranchesContinuation returns a worktree-removal
// continuation that deletes the local and remote branches and refreshes once the
// worktree is out of the way.
func (self *BranchesHelper) deleteLocalAndRemoteBranchesContinuation(branches []*models.Branch) func(gocui.Task) error {
return func(task gocui.Task) error {
if err := self.doDeleteLocalAndRemoteBranches(task, branches); err != nil {
return err
}
self.c.Contexts().Branches.CollapseRangeSelectionToTop()
self.c.Refresh(types.RefreshOptions{
Mode: types.ASYNC,
Scope: []types.RefreshableView{types.WORKTREES, types.BRANCHES, types.REMOTES, types.FILES},
})
return nil
}
}
func (self *BranchesHelper) allBranchesMerged(branches []*models.Branch) (bool, error) {
allBranchesMerged := true
for _, branch := range branches {

View file

@ -119,56 +119,68 @@ func (self *WorktreeHelper) Switch(worktree *models.Worktree, contextKey types.C
return self.reposHelper.DispatchSwitchTo(worktree.Path, self.c.Tr.ErrWorktreeMovedOrRemoved, contextKey)
}
func (self *WorktreeHelper) Remove(worktree *models.Worktree, force bool) error {
title := self.c.Tr.RemoveWorktreeTitle
var templateStr string
if force {
templateStr = self.c.Tr.ForceRemoveWorktreePrompt
} else {
templateStr = self.c.Tr.RemoveWorktreePrompt
}
message := utils.ResolvePlaceholderString(
templateStr,
map[string]string{
"worktreeName": worktree.Name,
},
)
self.c.Confirm(types.ConfirmOpts{
Title: title,
Prompt: message,
HandleConfirm: func() error {
return self.c.WithWaitingStatus(self.c.Tr.RemovingWorktree, func(gocui.Task) error {
self.c.LogAction(self.c.Tr.RemoveWorktree)
if err := self.c.Git().Worktree.Delete(worktree.Path, force); err != nil {
errMessage := err.Error()
if !strings.Contains(errMessage, "--force") &&
!strings.Contains(errMessage, "fatal: working trees containing submodules cannot be moved or removed") {
return err
}
if !force {
return self.Remove(worktree, true)
}
return err
}
self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.WORKTREES, types.BRANCHES, types.FILES}})
return nil
})
},
})
return nil
// Remove deletes the worktree without confirming first; callers are expected to
// have confirmed (or shown a menu) already. If git refuses because the worktree
// is dirty or contains submodules, we ask for confirmation and retry with
// --force. When then is non-nil it runs in place of the default refresh after a
// successful removal, letting callers chain further work such as deleting the
// worktree's branch.
func (self *WorktreeHelper) Remove(worktree *models.Worktree, then func(gocui.Task) error) error {
return self.remove(worktree, false, then)
}
func (self *WorktreeHelper) Detach(worktree *models.Worktree) error {
return self.c.WithWaitingStatus(self.c.Tr.DetachingWorktree, func(gocui.Task) error {
func (self *WorktreeHelper) remove(worktree *models.Worktree, force bool, then func(gocui.Task) error) error {
return self.c.WithWaitingStatus(self.c.Tr.RemovingWorktree, func(task gocui.Task) error {
self.c.LogAction(self.c.Tr.RemoveWorktree)
if err := self.c.Git().Worktree.Delete(worktree.Path, force); err != nil {
errMessage := err.Error()
if !strings.Contains(errMessage, "--force") &&
!strings.Contains(errMessage, "fatal: working trees containing submodules cannot be moved or removed") {
return err
}
if force {
return err
}
message := utils.ResolvePlaceholderString(
self.c.Tr.ForceRemoveWorktreePrompt,
map[string]string{
"worktreeName": worktree.Name,
},
)
self.c.Confirm(types.ConfirmOpts{
Title: self.c.Tr.RemoveWorktreeTitle,
Prompt: message,
HandleConfirm: func() error {
return self.remove(worktree, true, then)
},
})
return nil
}
if then != nil {
return then(task)
}
self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.WORKTREES, types.BRANCHES, types.FILES}})
return nil
})
}
func (self *WorktreeHelper) Detach(worktree *models.Worktree, then func(gocui.Task) error) error {
return self.c.WithWaitingStatus(self.c.Tr.DetachingWorktree, func(task gocui.Task) error {
self.c.LogAction(self.c.Tr.RemovingWorktree)
err := self.c.Git().Worktree.Detach(worktree.Path)
if err != nil {
return err
}
if then != nil {
return then(task)
}
self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.WORKTREES, types.BRANCHES, types.FILES}})
return nil
})

View file

@ -11,6 +11,7 @@ import (
"github.com/jesseduffield/lazygit/pkg/gui/style"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/samber/lo"
)
type WorktreesController struct {
@ -130,7 +131,53 @@ func (self *WorktreesController) remove(worktree *models.Worktree) error {
return errors.New(self.c.Tr.CantDeleteCurrentWorktree)
}
return self.c.Helpers().Worktree.Remove(worktree, false)
removeWorktreeItem := &types.MenuItem{
Label: self.c.Tr.RemoveWorktree,
Keys: menuKey('w'),
OnPress: func() error {
return self.c.Helpers().Worktree.Remove(worktree, nil)
},
}
branch, branchFound := lo.Find(self.c.Model().Branches, func(branch *models.Branch) bool {
return branch.Name == worktree.Branch
})
// A worktree with a detached HEAD has no branch to delete
detachedReason := &types.DisabledReason{Text: self.c.Tr.WorktreeNotCheckedOutOnBranch}
removeWorktreeAndBranchItem := &types.MenuItem{
Label: self.c.Tr.RemoveWorktreeAndDeleteBranch,
Keys: menuKey('b'),
OnPress: func() error {
return self.c.Helpers().BranchesHelper.RemoveWorktreeAndDeleteBranch(worktree, branch)
},
}
if !branchFound {
removeWorktreeAndBranchItem.DisabledReason = detachedReason
}
removeWorktreeAndBothBranchesItem := &types.MenuItem{
Label: self.c.Tr.RemoveWorktreeAndDeleteBothBranches,
Keys: menuKey('r'),
OnPress: func() error {
return self.c.Helpers().BranchesHelper.RemoveWorktreeAndDeleteBothBranches(worktree, branch)
},
}
if !branchFound {
removeWorktreeAndBothBranchesItem.DisabledReason = detachedReason
} else if !branch.IsTrackingRemote() || branch.UpstreamGone {
removeWorktreeAndBothBranchesItem.DisabledReason = &types.DisabledReason{
Text: self.c.Tr.UpstreamNotSetError,
}
}
return self.c.Menu(types.CreateMenuOptions{
Title: utils.ResolvePlaceholderString(
self.c.Tr.RemoveWorktreeMenuTitle,
map[string]string{"worktreeName": worktree.Name},
),
Items: []*types.MenuItem{removeWorktreeItem, removeWorktreeAndBranchItem, removeWorktreeAndBothBranchesItem},
})
}
func (self *WorktreesController) GetOnDoubleClick() func() error {

View file

@ -874,11 +874,16 @@ type TranslationSet struct {
Switching string
RemoveWorktree string
RemoveWorktreeTitle string
RemoveWorktreeMenuTitle string
RemoveWorktreeAndDeleteBranch string
RemoveWorktreeAndDeleteBothBranches string
WorktreeNotCheckedOutOnBranch string
DetachWorktree string
DetachWorktreeAndDeleteBranch string
DetachWorktreeAndDeleteBothBranches string
DetachingWorktree string
WorktreesTitle string
WorktreeTitle string
RemoveWorktreePrompt string
ForceRemoveWorktreePrompt string
RemovingWorktree string
AddingWorktree string
@ -2018,10 +2023,15 @@ func EnglishTranslationSet() *TranslationSet {
Switching: "Switching",
RemoveWorktree: "Remove worktree",
RemoveWorktreeTitle: "Remove worktree",
RemoveWorktreePrompt: "Are you sure you want to remove worktree '{{.worktreeName}}'?",
RemoveWorktreeMenuTitle: "Remove worktree '{{.worktreeName}}'?",
RemoveWorktreeAndDeleteBranch: "Remove worktree and delete branch",
RemoveWorktreeAndDeleteBothBranches: "Remove worktree and delete local and remote branch",
WorktreeNotCheckedOutOnBranch: "This worktree is not checked out on a branch",
ForceRemoveWorktreePrompt: "'{{.worktreeName}}' contains modified or untracked files, or submodules (or all of these). Are you sure you want to remove it?",
RemovingWorktree: "Deleting worktree",
DetachWorktree: "Detach worktree",
DetachWorktreeAndDeleteBranch: "Detach worktree and delete branch",
DetachWorktreeAndDeleteBothBranches: "Detach worktree and delete local and remote branch",
DetachingWorktree: "Detaching worktree",
AddingWorktree: "Adding worktree",
CantDeleteCurrentWorktree: "You cannot remove the current worktree!",

View file

@ -520,6 +520,9 @@ var tests = []*components.IntegrationTest{
worktree.LocationCandidates,
worktree.NewWorktreePicker,
worktree.NewWorktreePickerRemote,
worktree.RemoveWorktreeAndBothBranches,
worktree.RemoveWorktreeAndBranch,
worktree.RemoveWorktreeAndDeleteLocalAndRemoteBranch,
worktree.RemoveWorktreeFromBranch,
worktree.ResetWindowTabs,
worktree.SymlinkIntoRepoSubdir,

View file

@ -106,9 +106,9 @@ var Crud = NewIntegrationTest(NewIntegrationTestArgs{
NavigateToLine(Contains("linked-worktree")).
Press(keys.Universal.Remove).
Tap(func() {
t.ExpectPopup().Confirmation().
Title(Equals("Remove worktree")).
Content(Contains("Are you sure you want to remove worktree 'linked-worktree'?")).
t.ExpectPopup().Menu().
Title(Equals("Remove worktree 'linked-worktree'?")).
Select(MatchesRegexp("Remove worktree$")).
Confirm()
}).
Lines(

View file

@ -6,7 +6,7 @@ import (
)
var DetachWorktreeFromBranch = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Detach a worktree from the branches view",
Description: "Delete a branch that's checked out in another worktree by detaching that worktree",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {},
@ -37,12 +37,12 @@ var DetachWorktreeFromBranch = NewIntegrationTest(NewIntegrationTestArgs{
Tap(func() {
t.ExpectPopup().Menu().
Title(Equals("Branch newbranch is checked out by worktree linked-worktree")).
Select(Equals("Detach worktree")).
Select(Contains("Detach worktree and delete branch")).
Confirm()
}).
// The branch is gone; the worktree stays around (now detached)
Lines(
Contains("mybranch"),
Contains("newbranch").DoesNotContain("(worktree)").IsSelected(),
Contains("mybranch").IsSelected(),
)
t.Views().Worktrees().

View file

@ -29,9 +29,9 @@ var ForceRemoveWorktree = NewIntegrationTest(NewIntegrationTestArgs{
NavigateToLine(Contains("linked-worktree")).
Press(keys.Universal.Remove).
Tap(func() {
t.ExpectPopup().Confirmation().
Title(Equals("Remove worktree")).
Content(Equals("Are you sure you want to remove worktree 'linked-worktree'?")).
t.ExpectPopup().Menu().
Title(Equals("Remove worktree 'linked-worktree'?")).
Select(MatchesRegexp("Remove worktree$")).
Confirm()
t.ExpectPopup().Confirmation().

View file

@ -29,9 +29,9 @@ var ForceRemoveWorktreeWithSubmodules = NewIntegrationTest(NewIntegrationTestArg
NavigateToLine(Contains("linked-worktree")).
Press(keys.Universal.Remove).
Tap(func() {
t.ExpectPopup().Confirmation().
Title(Equals("Remove worktree")).
Content(Equals("Are you sure you want to remove worktree 'linked-worktree'?")).
t.ExpectPopup().Menu().
Title(Equals("Remove worktree 'linked-worktree'?")).
Select(MatchesRegexp("Remove worktree$")).
Confirm()
t.ExpectPopup().Confirmation().

View file

@ -0,0 +1,64 @@
package worktree
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var RemoveWorktreeAndBothBranches = NewIntegrationTest(NewIntegrationTestArgs{
Description: "From the worktrees panel, remove a worktree and delete both its local and remote branch in one go",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
shell.CloneIntoRemote("origin")
shell.EmptyCommit("initial commit")
shell.NewBranch("mybranch")
shell.EmptyCommit("commit on mybranch")
shell.PushBranchAndSetUpstream("origin", "mybranch")
shell.EmptyCommit("commit not pushed to the remote") // so mybranch isn't fully merged
shell.Checkout("master")
shell.AddWorktreeCheckout("mybranch", "../linked-worktree")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Worktrees().
Focus().
Lines(
Contains("(main worktree)").IsSelected(),
Contains("linked-worktree"),
).
NavigateToLine(Contains("linked-worktree")).
Press(keys.Universal.Remove).
Tap(func() {
t.ExpectPopup().Menu().
Title(Equals("Remove worktree 'linked-worktree'?")).
Select(Contains("Remove worktree and delete local and remote branch")).
Confirm()
// mybranch isn't fully merged, so we get the force-delete warning
t.ExpectPopup().Confirmation().
Title(Equals("Force delete branch")).
Content(Equals("'mybranch' is not fully merged. Are you sure you want to delete it?")).
Confirm()
}).
Lines(
Contains("(main worktree)").IsSelected(),
)
// The remote branch is gone too
t.Views().Remotes().
Focus().
Lines(Contains("origin")).
PressEnter()
t.Views().RemoteBranches().
IsEmpty()
// And so is the local branch
t.Views().Branches().
Focus().
Lines(
Contains("master").IsSelected(),
)
},
})

View file

@ -0,0 +1,73 @@
package worktree
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var RemoveWorktreeAndBranch = NewIntegrationTest(NewIntegrationTestArgs{
Description: "From the worktrees panel, remove a worktree and delete its branch in one go",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
shell.NewBranch("mybranch")
shell.CreateFileAndAdd("README.md", "hello world")
shell.Commit("initial commit")
shell.NewBranch("newbranch")
shell.EmptyCommit("commit on newbranch")
shell.Checkout("mybranch")
shell.AddWorktreeCheckout("newbranch", "../linked-worktree")
shell.RunCommand([]string{"git", "worktree", "add", "--detach", "../detached-worktree"})
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Worktrees().
Focus().
Lines(
Contains("(main worktree)").IsSelected(),
Contains("detached-worktree"),
Contains("linked-worktree"),
).
// A detached worktree has no branch, so neither delete action is offered
NavigateToLine(Contains("detached-worktree")).
Press(keys.Universal.Remove).
Tap(func() {
t.ExpectPopup().Menu().
Title(Equals("Remove worktree 'detached-worktree'?")).
Select(Contains("Remove worktree and delete branch")).
Tooltip(Contains("This worktree is not checked out on a branch")).
Select(Contains("Remove worktree and delete local and remote branch")).
Tooltip(Contains("This worktree is not checked out on a branch")).
Cancel()
}).
// Remove a worktree and delete its branch at once. newbranch has no
// upstream, so deleting the remote branch too isn't offered.
NavigateToLine(Contains("linked-worktree")).
Press(keys.Universal.Remove).
Tap(func() {
t.ExpectPopup().Menu().
Title(Equals("Remove worktree 'linked-worktree'?")).
Select(Contains("Remove worktree and delete local and remote branch")).
Tooltip(Contains("The selected branch has no upstream")).
Select(Contains("Remove worktree and delete branch")).
Confirm()
// newbranch isn't fully merged, so we get the force-delete warning
t.ExpectPopup().Confirmation().
Title(Equals("Force delete branch")).
Content(Equals("'newbranch' is not fully merged. Are you sure you want to delete it?")).
Confirm()
}).
Lines(
Contains("(main worktree)"),
Contains("detached-worktree"),
)
// The branch is gone too
t.Views().Branches().
Focus().
Lines(
Contains("mybranch").IsSelected(),
)
},
})

View file

@ -0,0 +1,71 @@
package worktree
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var RemoveWorktreeAndDeleteLocalAndRemoteBranch = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Delete the local branch, the remote branch, and the worktree of a single branch checked out in another worktree, all at once",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
shell.CloneIntoRemote("origin")
shell.EmptyCommit("initial commit")
shell.NewBranch("mybranch")
shell.EmptyCommit("commit on mybranch")
shell.PushBranchAndSetUpstream("origin", "mybranch")
shell.EmptyCommit("commit not pushed to the remote") // so mybranch isn't fully merged
shell.Checkout("master")
shell.AddWorktreeCheckout("mybranch", "../linked-worktree")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Branches().
Focus().
Lines(
Contains("master").IsSelected(),
Contains("mybranch (worktree linked-worktree)"),
).
NavigateToLine(Contains("mybranch")).
Press(keys.Universal.Remove).
Tap(func() {
t.ExpectPopup().Menu().
Title(Equals("Delete branch 'mybranch'?")).
Select(Contains("Delete local and remote branch")).
Confirm()
}).
Tap(func() {
t.ExpectPopup().Menu().
Title(Equals("Branch mybranch is checked out by worktree linked-worktree")).
Select(Contains("Remove worktree and delete local and remote branch")).
Confirm()
// mybranch is not contained in master, so we get the force-delete warning
t.ExpectPopup().Confirmation().
Title(Equals("Force delete branch")).
Content(Equals("'mybranch' is not fully merged. Are you sure you want to delete it?")).
Confirm()
}).
// The local branch is gone
Lines(
Contains("master").IsSelected(),
)
// The remote branch is gone too
t.Views().Remotes().
Focus().
Lines(Contains("origin")).
PressEnter()
t.Views().RemoteBranches().
IsEmpty()
// And so is the worktree
t.Views().Worktrees().
Focus().
Lines(
Contains("(main worktree)").IsSelected(),
)
},
})

View file

@ -6,7 +6,7 @@ import (
)
var RemoveWorktreeFromBranch = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Remove a worktree from the branches view",
Description: "Delete a branch that's checked out in another worktree by removing that worktree",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {},
@ -38,22 +38,18 @@ var RemoveWorktreeFromBranch = NewIntegrationTest(NewIntegrationTestArgs{
Tap(func() {
t.ExpectPopup().Menu().
Title(Equals("Branch newbranch is checked out by worktree linked-worktree")).
Select(Equals("Remove worktree")).
Confirm()
t.ExpectPopup().Confirmation().
Title(Equals("Remove worktree")).
Content(Equals("Are you sure you want to remove worktree 'linked-worktree'?")).
Select(Contains("Remove worktree and delete branch")).
Confirm()
// The worktree is dirty, so we get asked to force-remove it
t.ExpectPopup().Confirmation().
Title(Equals("Remove worktree")).
Content(Equals("'linked-worktree' contains modified or untracked files, or submodules (or all of these). Are you sure you want to remove it?")).
Confirm()
}).
// The branch is gone, not just unlinked from its worktree
Lines(
Contains("mybranch"),
Contains("newbranch").DoesNotContain("(worktree)").IsSelected(),
Contains("mybranch").IsSelected(),
)
t.Views().Worktrees().