mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-09-10 07:36:27 -04:00
Offer to delete the branch when removing a worktree
Pressing `d` on a worktree only ever removed the worktree, leaving its branch behind even though deleting it too is often what you want. Turn the confirmation into a menu: "Remove worktree", "Remove worktree and delete branch", and "Remove worktree and delete local and remote branch". The branch-deleting items come after the plain removal (they do more harm if picked by accident); both are greyed out for a detached-HEAD worktree, and the local-and-remote one is also greyed when the branch has no upstream. The plain menu pick is the confirmation, so the standalone "remove worktree?" prompt is gone (and its now-dead translation string with it); the dirty-worktree force prompt and the unmerged-branch warning still appear when relevant. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
22914da8e5
commit
0aed44c7f3
|
|
@ -202,7 +202,7 @@ func (self *BranchesHelper) promptWorktreeBranchDelete(
|
|||
Keys: menuKey('r'),
|
||||
OnPress: func() error {
|
||||
return self.confirmForceIfUnmerged([]*models.Branch{branch}, func() error {
|
||||
return self.worktreeHelper.remove(worktree, false, deleteBranches)
|
||||
return self.worktreeHelper.Remove(worktree, deleteBranches)
|
||||
})
|
||||
},
|
||||
},
|
||||
|
|
@ -220,6 +220,36 @@ func (self *BranchesHelper) promptWorktreeBranchDelete(
|
|||
})
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
|
|
|||
|
|
@ -119,30 +119,16 @@ 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) error {
|
||||
message := utils.ResolvePlaceholderString(
|
||||
self.c.Tr.RemoveWorktreePrompt,
|
||||
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, false, 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)
|
||||
}
|
||||
|
||||
// remove deletes the worktree without confirming first; callers must have done
|
||||
// so (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, 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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
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,15 +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
|
||||
|
|
@ -2022,9 +2023,10 @@ func EnglishTranslationSet() *TranslationSet {
|
|||
Switching: "Switching",
|
||||
RemoveWorktree: "Remove worktree",
|
||||
RemoveWorktreeTitle: "Remove worktree",
|
||||
RemoveWorktreeMenuTitle: "Remove worktree '{{.worktreeName}}'?",
|
||||
RemoveWorktreeAndDeleteBranch: "Remove worktree and delete branch",
|
||||
RemoveWorktreeAndDeleteBothBranches: "Remove worktree and delete local and remote branch",
|
||||
RemoveWorktreePrompt: "Are you sure you want to remove worktree '{{.worktreeName}}'?",
|
||||
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",
|
||||
|
|
|
|||
|
|
@ -521,6 +521,7 @@ var tests = []*components.IntegrationTest{
|
|||
worktree.NewWorktreePicker,
|
||||
worktree.NewWorktreePickerRemote,
|
||||
worktree.RemoveWorktreeAndBothBranches,
|
||||
worktree.RemoveWorktreeAndBranch,
|
||||
worktree.RemoveWorktreeAndDeleteLocalAndRemoteBranch,
|
||||
worktree.RemoveWorktreeFromBranch,
|
||||
worktree.ResetWindowTabs,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
},
|
||||
})
|
||||
Loading…
Reference in a new issue