From 4f078f5463d78cb5ffd6825a22602f45a35263ca Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 30 Jun 2026 19:02:32 +0200 Subject: [PATCH] Delete the branch when deleting it via its worktree When you delete a local branch that's checked out in another worktree, the menu offered to remove or detach the worktree but then stopped there, leaving the branch you asked to delete still around. Now both actions delete the branch afterwards, and the labels say so ("Remove worktree and delete branch" / "Detach worktree and delete branch") to avoid surprises. Also drop the "Switch to worktree" item: switching abandons the delete the user asked for, and it's already reachable by checking out the branch or via the worktrees panel. And drop the now-redundant "remove worktree?" confirmation: the explicit menu pick is the confirmation (the dirty-worktree force prompt and the unmerged-branch warning still appear when relevant). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../controllers/helpers/branches_helper.go | 61 ++++++++++++++----- pkg/i18n/english.go | 4 ++ .../worktree/detach_worktree_from_branch.go | 8 +-- .../worktree/remove_worktree_from_branch.go | 14 ++--- 4 files changed, 59 insertions(+), 28 deletions(-) diff --git a/pkg/gui/controllers/helpers/branches_helper.go b/pkg/gui/controllers/helpers/branches_helper.go index 18f19aa97..73db775fe 100644 --- a/pkg/gui/controllers/helpers/branches_helper.go +++ b/pkg/gui/controllers/helpers/branches_helper.go @@ -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,7 +31,12 @@ 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), + ) } return self.confirmForceIfUnmerged(branches, func() error { @@ -160,8 +164,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 @@ -169,28 +183,28 @@ 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, false, deleteBranches) + }) }, }, { - Label: self.c.Tr.DetachWorktree, + Label: detachLabel, + Keys: menuKey('d'), Tooltip: self.c.Tr.DetachWorktreeTooltip, OnPress: func() error { - return self.worktreeHelper.Detach(worktree, nil) - }, - }, - { - Label: self.c.Tr.RemoveWorktree, - OnPress: func() error { - return self.worktreeHelper.Remove(worktree) + return self.confirmForceIfUnmerged([]*models.Branch{branch}, func() error { + return self.worktreeHelper.Detach(worktree, deleteBranches) + }) }, }, }, @@ -250,6 +264,23 @@ func (self *BranchesHelper) doDeleteLocalAndRemoteBranches(task gocui.Task, bran 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 + } +} + func (self *BranchesHelper) allBranchesMerged(branches []*models.Branch) (bool, error) { allBranchesMerged := true for _, branch := range branches { diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 822e47c10..213210fcd 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -874,7 +874,9 @@ type TranslationSet struct { Switching string RemoveWorktree string RemoveWorktreeTitle string + RemoveWorktreeAndDeleteBranch string DetachWorktree string + DetachWorktreeAndDeleteBranch string DetachingWorktree string WorktreesTitle string WorktreeTitle string @@ -2018,10 +2020,12 @@ func EnglishTranslationSet() *TranslationSet { Switching: "Switching", RemoveWorktree: "Remove worktree", RemoveWorktreeTitle: "Remove worktree", + RemoveWorktreeAndDeleteBranch: "Remove worktree and delete branch", RemoveWorktreePrompt: "Are you sure you want to remove worktree '{{.worktreeName}}'?", 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", DetachingWorktree: "Detaching worktree", AddingWorktree: "Adding worktree", CantDeleteCurrentWorktree: "You cannot remove the current worktree!", diff --git a/pkg/integration/tests/worktree/detach_worktree_from_branch.go b/pkg/integration/tests/worktree/detach_worktree_from_branch.go index acd40e6ad..b36b89349 100644 --- a/pkg/integration/tests/worktree/detach_worktree_from_branch.go +++ b/pkg/integration/tests/worktree/detach_worktree_from_branch.go @@ -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(). diff --git a/pkg/integration/tests/worktree/remove_worktree_from_branch.go b/pkg/integration/tests/worktree/remove_worktree_from_branch.go index 1aa9645f3..7823af54c 100644 --- a/pkg/integration/tests/worktree/remove_worktree_from_branch.go +++ b/pkg/integration/tests/worktree/remove_worktree_from_branch.go @@ -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().