mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-09-10 07:36:27 -04:00
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:
commit
0ecced93ea
23
AGENTS.md
23
AGENTS.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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!",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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().
|
||||
|
|
|
|||
|
|
@ -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().
|
||||
|
|
|
|||
|
|
@ -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().
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
},
|
||||
})
|
||||
73
pkg/integration/tests/worktree/remove_worktree_and_branch.go
Normal file
73
pkg/integration/tests/worktree/remove_worktree_and_branch.go
Normal 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(),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
|
@ -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(),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
|
@ -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().
|
||||
|
|
|
|||
Loading…
Reference in a new issue