From 3f6a21f7f8c5b1cd65660ac7fbb897724ff23c07 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 1 Jul 2026 10:44:29 +0200 Subject: [PATCH 1/6] AGENTS.md additions --- AGENTS.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 58e0a38f3..46ad3e506 100644 --- a/AGENTS.md +++ b/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 From d6016d628651f13ec1bee215c6017a057a645b66 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 30 Jun 2026 18:52:56 +0200 Subject: [PATCH 2/6] Extract reusable branch-deletion helpers Pull the merged-check-and-force-warning step and the actual git deletion out of ConfirmLocalDelete and ConfirmLocalAndRemoteDelete into helpers, so that the upcoming worktree-aware delete flows can reuse them instead of duplicating the logic. No behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../controllers/helpers/branches_helper.go | 106 +++++++++--------- 1 file changed, 56 insertions(+), 50 deletions(-) diff --git a/pkg/gui/controllers/helpers/branches_helper.go b/pkg/gui/controllers/helpers/branches_helper.go index ccc9d33ac..3e6c50b58 100644 --- a/pkg/gui/controllers/helpers/branches_helper.go +++ b/pkg/gui/controllers/helpers/branches_helper.go @@ -35,17 +35,9 @@ func (self *BranchesHelper) ConfirmLocalDelete(branches []*models.Branch) error return self.promptWorktreeBranchDelete(branches[0]) } - 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 +45,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 { @@ -169,19 +134,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 } @@ -244,6 +197,59 @@ func (self *BranchesHelper) promptWorktreeBranchDelete(selectedBranch *models.Br }) } +// 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) +} + func (self *BranchesHelper) allBranchesMerged(branches []*models.Branch) (bool, error) { allBranchesMerged := true for _, branch := range branches { From 9a8244110ffaa538878e92a354df171912dd590a Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 30 Jun 2026 18:57:21 +0200 Subject: [PATCH 3/6] Let worktree removal/detach chain follow-up work Split the actual worktree removal out of the confirmation in Remove into a non-confirming helper, and give both Remove and Detach an optional `then` continuation that runs after a successful removal in place of the default refresh. Upcoming flows need to delete the worktree's branch once the worktree is out of the way; threading a continuation through (rather than the caller firing branch deletion independently) keeps it ordered after the git command that actually frees the branch. No behavior change yet. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../controllers/helpers/branches_helper.go | 4 +- .../controllers/helpers/worktree_helper.go | 84 ++++++++++++------- pkg/gui/controllers/worktrees_controller.go | 2 +- 3 files changed, 58 insertions(+), 32 deletions(-) diff --git a/pkg/gui/controllers/helpers/branches_helper.go b/pkg/gui/controllers/helpers/branches_helper.go index 3e6c50b58..18f19aa97 100644 --- a/pkg/gui/controllers/helpers/branches_helper.go +++ b/pkg/gui/controllers/helpers/branches_helper.go @@ -184,13 +184,13 @@ func (self *BranchesHelper) promptWorktreeBranchDelete(selectedBranch *models.Br Label: self.c.Tr.DetachWorktree, Tooltip: self.c.Tr.DetachWorktreeTooltip, OnPress: func() error { - return self.worktreeHelper.Detach(worktree) + return self.worktreeHelper.Detach(worktree, nil) }, }, { Label: self.c.Tr.RemoveWorktree, OnPress: func() error { - return self.worktreeHelper.Remove(worktree, false) + return self.worktreeHelper.Remove(worktree) }, }, }, diff --git a/pkg/gui/controllers/helpers/worktree_helper.go b/pkg/gui/controllers/helpers/worktree_helper.go index 7a32d898c..84973d705 100644 --- a/pkg/gui/controllers/helpers/worktree_helper.go +++ b/pkg/gui/controllers/helpers/worktree_helper.go @@ -119,56 +119,82 @@ 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 - } +func (self *WorktreeHelper) Remove(worktree *models.Worktree) error { message := utils.ResolvePlaceholderString( - templateStr, + self.c.Tr.RemoveWorktreePrompt, map[string]string{ "worktreeName": worktree.Name, }, ) self.c.Confirm(types.ConfirmOpts{ - Title: title, + Title: self.c.Tr.RemoveWorktreeTitle, 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 self.remove(worktree, false, nil) }, }) return nil } -func (self *WorktreeHelper) Detach(worktree *models.Worktree) error { - return self.c.WithWaitingStatus(self.c.Tr.DetachingWorktree, func(gocui.Task) error { +// 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) + 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 }) diff --git a/pkg/gui/controllers/worktrees_controller.go b/pkg/gui/controllers/worktrees_controller.go index 5128ad716..4f87362d6 100644 --- a/pkg/gui/controllers/worktrees_controller.go +++ b/pkg/gui/controllers/worktrees_controller.go @@ -130,7 +130,7 @@ func (self *WorktreesController) remove(worktree *models.Worktree) error { return errors.New(self.c.Tr.CantDeleteCurrentWorktree) } - return self.c.Helpers().Worktree.Remove(worktree, false) + return self.c.Helpers().Worktree.Remove(worktree) } func (self *WorktreesController) GetOnDoubleClick() func() error { From 4f078f5463d78cb5ffd6825a22602f45a35263ca Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 30 Jun 2026 19:02:32 +0200 Subject: [PATCH 4/6] 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(). From 22914da8e5c1c2dedb82d343d8c9915cda249e48 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 1 Jul 2026 10:42:13 +0200 Subject: [PATCH 5/6] Allow deleting local+remote of a worktree-checked-out branch at once Picking "Delete local and remote branch" for a single branch that's checked out in another worktree used to fail with "Some of the selected branches are checked out by other worktrees. Select them one by one to delete them." That message only makes sense for a multi-selection; for a single branch there's no reason we can't remove the worktree and delete both the local and remote branch in one go. Route that case through the same worktree menu as the local-only delete, with labels that spell out that the remote goes too. The multi-select error stays for actual multi-selections. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../controllers/helpers/branches_helper.go | 31 +++++++- pkg/i18n/english.go | 4 ++ pkg/integration/tests/test_list.go | 2 + ...tree_and_delete_local_and_remote_branch.go | 71 +++++++++++++++++++ 4 files changed, 106 insertions(+), 2 deletions(-) create mode 100644 pkg/integration/tests/worktree/remove_worktree_and_delete_local_and_remote_branch.go diff --git a/pkg/gui/controllers/helpers/branches_helper.go b/pkg/gui/controllers/helpers/branches_helper.go index 73db775fe..7c873f8f3 100644 --- a/pkg/gui/controllers/helpers/branches_helper.go +++ b/pkg/gui/controllers/helpers/branches_helper.go @@ -97,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) @@ -281,6 +290,24 @@ func (self *BranchesHelper) deleteLocalBranchesContinuation(branches []*models.B } } +// 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 { diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 213210fcd..91f514796 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -875,8 +875,10 @@ type TranslationSet struct { RemoveWorktree string RemoveWorktreeTitle string RemoveWorktreeAndDeleteBranch string + RemoveWorktreeAndDeleteBothBranches string DetachWorktree string DetachWorktreeAndDeleteBranch string + DetachWorktreeAndDeleteBothBranches string DetachingWorktree string WorktreesTitle string WorktreeTitle string @@ -2021,11 +2023,13 @@ func EnglishTranslationSet() *TranslationSet { RemoveWorktree: "Remove worktree", RemoveWorktreeTitle: "Remove worktree", 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}}'?", 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!", diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 136cd579b..ed32775d4 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -520,6 +520,8 @@ var tests = []*components.IntegrationTest{ worktree.LocationCandidates, worktree.NewWorktreePicker, worktree.NewWorktreePickerRemote, + worktree.RemoveWorktreeAndBothBranches, + worktree.RemoveWorktreeAndDeleteLocalAndRemoteBranch, worktree.RemoveWorktreeFromBranch, worktree.ResetWindowTabs, worktree.SymlinkIntoRepoSubdir, diff --git a/pkg/integration/tests/worktree/remove_worktree_and_delete_local_and_remote_branch.go b/pkg/integration/tests/worktree/remove_worktree_and_delete_local_and_remote_branch.go new file mode 100644 index 000000000..09fae45eb --- /dev/null +++ b/pkg/integration/tests/worktree/remove_worktree_and_delete_local_and_remote_branch.go @@ -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(), + ) + }, +}) From 0aed44c7f33355a53fbb6cd1769c413e9e4b1490 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 1 Jul 2026 10:44:11 +0200 Subject: [PATCH 6/6] 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) --- .../controllers/helpers/branches_helper.go | 32 +++++++- .../controllers/helpers/worktree_helper.go | 30 ++------ pkg/gui/controllers/worktrees_controller.go | 49 ++++++++++++- pkg/i18n/english.go | 6 +- pkg/integration/tests/test_list.go | 1 + pkg/integration/tests/worktree/crud.go | 6 +- .../tests/worktree/force_remove_worktree.go | 6 +- .../force_remove_worktree_with_submodules.go | 6 +- .../remove_worktree_and_both_branches.go | 64 ++++++++++++++++ .../worktree/remove_worktree_and_branch.go | 73 +++++++++++++++++++ 10 files changed, 238 insertions(+), 35 deletions(-) create mode 100644 pkg/integration/tests/worktree/remove_worktree_and_both_branches.go create mode 100644 pkg/integration/tests/worktree/remove_worktree_and_branch.go diff --git a/pkg/gui/controllers/helpers/branches_helper.go b/pkg/gui/controllers/helpers/branches_helper.go index 7c873f8f3..4283bd29a 100644 --- a/pkg/gui/controllers/helpers/branches_helper.go +++ b/pkg/gui/controllers/helpers/branches_helper.go @@ -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. diff --git a/pkg/gui/controllers/helpers/worktree_helper.go b/pkg/gui/controllers/helpers/worktree_helper.go index 84973d705..7cec9f873 100644 --- a/pkg/gui/controllers/helpers/worktree_helper.go +++ b/pkg/gui/controllers/helpers/worktree_helper.go @@ -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) diff --git a/pkg/gui/controllers/worktrees_controller.go b/pkg/gui/controllers/worktrees_controller.go index 4f87362d6..02d20b3f2 100644 --- a/pkg/gui/controllers/worktrees_controller.go +++ b/pkg/gui/controllers/worktrees_controller.go @@ -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 { diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 91f514796..be3886fbe 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -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", diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index ed32775d4..380aca2b6 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -521,6 +521,7 @@ var tests = []*components.IntegrationTest{ worktree.NewWorktreePicker, worktree.NewWorktreePickerRemote, worktree.RemoveWorktreeAndBothBranches, + worktree.RemoveWorktreeAndBranch, worktree.RemoveWorktreeAndDeleteLocalAndRemoteBranch, worktree.RemoveWorktreeFromBranch, worktree.ResetWindowTabs, diff --git a/pkg/integration/tests/worktree/crud.go b/pkg/integration/tests/worktree/crud.go index 6cda94141..9a35d6cf7 100644 --- a/pkg/integration/tests/worktree/crud.go +++ b/pkg/integration/tests/worktree/crud.go @@ -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( diff --git a/pkg/integration/tests/worktree/force_remove_worktree.go b/pkg/integration/tests/worktree/force_remove_worktree.go index cde9e9da3..3fafa9755 100644 --- a/pkg/integration/tests/worktree/force_remove_worktree.go +++ b/pkg/integration/tests/worktree/force_remove_worktree.go @@ -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(). diff --git a/pkg/integration/tests/worktree/force_remove_worktree_with_submodules.go b/pkg/integration/tests/worktree/force_remove_worktree_with_submodules.go index 4af533e02..82e5a9303 100644 --- a/pkg/integration/tests/worktree/force_remove_worktree_with_submodules.go +++ b/pkg/integration/tests/worktree/force_remove_worktree_with_submodules.go @@ -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(). diff --git a/pkg/integration/tests/worktree/remove_worktree_and_both_branches.go b/pkg/integration/tests/worktree/remove_worktree_and_both_branches.go new file mode 100644 index 000000000..8ba8e9112 --- /dev/null +++ b/pkg/integration/tests/worktree/remove_worktree_and_both_branches.go @@ -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(), + ) + }, +}) diff --git a/pkg/integration/tests/worktree/remove_worktree_and_branch.go b/pkg/integration/tests/worktree/remove_worktree_and_branch.go new file mode 100644 index 000000000..5910b6b7f --- /dev/null +++ b/pkg/integration/tests/worktree/remove_worktree_and_branch.go @@ -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(), + ) + }, +})