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:
Stefan Haller 2026-07-01 10:44:11 +02:00
parent 22914da8e5
commit 0aed44c7f3
10 changed files with 238 additions and 35 deletions

View file

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

View file

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

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

View file

@ -521,6 +521,7 @@ var tests = []*components.IntegrationTest{
worktree.NewWorktreePicker,
worktree.NewWorktreePickerRemote,
worktree.RemoveWorktreeAndBothBranches,
worktree.RemoveWorktreeAndBranch,
worktree.RemoveWorktreeAndDeleteLocalAndRemoteBranch,
worktree.RemoveWorktreeFromBranch,
worktree.ResetWindowTabs,

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

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