From e2a29a6d77ae047742410b8aa11e03622c052911 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 12 Oct 2026 18:30:53 +0200 Subject: [PATCH] Remove a worktree inside the repo properly when discarding it If a linked worktree lives inside the repo's working tree, it shows up in the files panel. Discarding it there deleted its directory like that of any untracked file. git still had the worktree on record, so the worktrees panel listed it as missing, and its branch couldn't be checked out anywhere else until the worktree was pruned. Show the same menu for removing the worktree as the worktrees panel does. Like a submodule, a worktree can't be discarded together with other entries, because it has a menu of its own. Co-Authored-By: Claude Opus 5.5 (1M context) --- pkg/gui/controllers/files_controller.go | 28 +++++++++++++-- .../controllers/helpers/branches_helper.go | 4 +-- .../helpers/working_tree_helper.go | 6 ++++ pkg/i18n/english.go | 2 ++ pkg/integration/tests/test_list.go | 1 + .../worktree/discard_range_with_worktree.go | 35 +++++++++++++++++++ .../tests/worktree/worktree_in_repo.go | 8 ++--- 7 files changed, 76 insertions(+), 8 deletions(-) create mode 100644 pkg/integration/tests/worktree/discard_range_with_worktree.go diff --git a/pkg/gui/controllers/files_controller.go b/pkg/gui/controllers/files_controller.go index ce53ef65b..64ebb8d15 100644 --- a/pkg/gui/controllers/files_controller.go +++ b/pkg/gui/controllers/files_controller.go @@ -1691,24 +1691,41 @@ func findSubmoduleNode(nodes []*filetree.FileNode, submodules []*models.Submodul return nil } +func findWorktreeNode(nodes []*filetree.FileNode) *models.File { + for _, node := range nodes { + if worktreeFile := node.FindFirstFileBy(func(f *models.File) bool { return f.IsWorktree }); worktreeFile != nil { + return worktreeFile + } + } + return nil +} + func (self *FilesController) canRemove(selectedNodes []*filetree.FileNode) *types.DisabledReason { - // Return disabled if the selection contains multiple changed items and includes a submodule change. + // Return disabled if the selection contains multiple changed items and + // includes a submodule change or a worktree. These have menus of their own. submodules := self.c.Model().Submodules hasFiles := false uniqueSelectedSubmodules := set.New[*models.SubmoduleConfig]() + uniqueSelectedWorktreePaths := set.New[string]() for _, node := range selectedNodes { _ = node.ForEachFile(func(f *models.File) error { if submodule := f.SubmoduleConfig(submodules); submodule != nil { uniqueSelectedSubmodules.Add(submodule) + } else if f.IsWorktree { + uniqueSelectedWorktreePaths.Add(f.Path) } else { hasFiles = true } return nil }) - if uniqueSelectedSubmodules.Len() > 0 && (hasFiles || uniqueSelectedSubmodules.Len() > 1) { + if uniqueSelectedSubmodules.Len() > 0 && + (hasFiles || uniqueSelectedWorktreePaths.Len() > 0 || uniqueSelectedSubmodules.Len() > 1) { return &types.DisabledReason{Text: self.c.Tr.MultiSelectNotSupportedForSubmodules} } + if uniqueSelectedWorktreePaths.Len() > 0 && (hasFiles || uniqueSelectedWorktreePaths.Len() > 1) { + return &types.DisabledReason{Text: self.c.Tr.MultiSelectNotSupportedForWorktrees} + } } return nil @@ -1737,6 +1754,13 @@ func (self *FilesController) remove(selectedNodes []*filetree.FileNode) error { return self.c.Menu(types.CreateMenuOptions{Title: submoduleNode.GetPath(), Items: menuItems}) } + // Likewise, if we have a worktree, it is the only thing selected + if worktreeFile := findWorktreeNode(selectedNodes); worktreeFile != nil { + if worktree, ok := self.c.Helpers().WorkingTree.WorktreeForFile(worktreeFile); ok { + return self.c.Helpers().BranchesHelper.RemoveWorktreeMenu(worktree) + } + } + discardAllChangesItem := types.MenuItem{ Label: self.c.Tr.DiscardAllChanges, OnPress: func() error { diff --git a/pkg/gui/controllers/helpers/branches_helper.go b/pkg/gui/controllers/helpers/branches_helper.go index 849226e03..8117f4bae 100644 --- a/pkg/gui/controllers/helpers/branches_helper.go +++ b/pkg/gui/controllers/helpers/branches_helper.go @@ -293,8 +293,8 @@ func (self *BranchesHelper) RemoveWorktreeMenu(worktree *models.Worktree) error // 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. +// the remove-worktree menu's counterpart to deleting a worktree-checked-out +// branch from the branches panel. func (self *BranchesHelper) RemoveWorktreeAndDeleteBranch( worktree *models.Worktree, branch *models.Branch, ) error { diff --git a/pkg/gui/controllers/helpers/working_tree_helper.go b/pkg/gui/controllers/helpers/working_tree_helper.go index 5f68cb822..1cdc99606 100644 --- a/pkg/gui/controllers/helpers/working_tree_helper.go +++ b/pkg/gui/controllers/helpers/working_tree_helper.go @@ -113,6 +113,12 @@ func (self *WorkingTreeHelper) FileForSubmodule(submodule *models.SubmoduleConfi return nil } +// WorktreeForFile returns the worktree that an entry of the files panel is, if +// it is one +func (self *WorkingTreeHelper) WorktreeForFile(file *models.File) (*models.Worktree, bool) { + return worktreeOfFile(file, self.c.Model().Worktrees, self.c.Git().RepoPaths.WorktreePath()) +} + func (self *WorkingTreeHelper) OpenMergeTool() error { self.c.LogAction(self.c.Tr.Actions.OpenMergeTool) return self.c.RunSubprocessAndRefresh( diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index e961638d3..81dd2c335 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -1003,6 +1003,7 @@ type TranslationSet struct { SelectedItemIsNotABranch string SelectedItemDoesNotHaveFiles string MultiSelectNotSupportedForSubmodules string + MultiSelectNotSupportedForWorktrees string NothingToStageForSubmodule string CommandDoesNotSupportOpeningInEditor string CustomCommands string @@ -2199,6 +2200,7 @@ func EnglishTranslationSet() *TranslationSet { SelectedItemIsNotABranch: "Selected item is not a branch", SelectedItemDoesNotHaveFiles: "Selected item does not have files to view", MultiSelectNotSupportedForSubmodules: "Multiselection not supported for submodules", + MultiSelectNotSupportedForWorktrees: "Multiselection not supported for worktrees", NothingToStageForSubmodule: "Nothing to stage: the parent repo can only stage a new submodule commit, not the uncommitted changes inside a submodule. Commit inside the submodule first.", CommandDoesNotSupportOpeningInEditor: "This command doesn't support switching to the editor", CustomCommands: "Custom commands", diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 9461fc2d3..8e8e27aec 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -696,6 +696,7 @@ var tests = []*components.IntegrationTest{ worktree.CustomCommand, worktree.DefaultPathTilde, worktree.DetachWorktreeFromBranch, + worktree.DiscardRangeWithWorktree, worktree.DotfileBareRepo, worktree.DoubleNestedLinkedSubmodule, worktree.EditFileOfBranchInOtherWorktree, diff --git a/pkg/integration/tests/worktree/discard_range_with_worktree.go b/pkg/integration/tests/worktree/discard_range_with_worktree.go new file mode 100644 index 000000000..064918479 --- /dev/null +++ b/pkg/integration/tests/worktree/discard_range_with_worktree.go @@ -0,0 +1,35 @@ +package worktree + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var DiscardRangeWithWorktree = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Discarding a range of files that includes a worktree inside the repo is not supported, because a worktree has its own menu for removing it", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Gui.ShowFileTree = false + }, + SetupRepo: func(shell *Shell) { + shell.NewBranch("mybranch") + shell.CreateFileAndAdd("README.md", "hello world") + shell.Commit("initial commit") + shell.AddWorktree("mybranch", "nested-worktree", "newbranch") + shell.CreateFile("untracked-file", "content") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Lines( + Equals("?? nested-worktree (worktree)").IsSelected(), + Equals("?? untracked-file"), + ). + Press(keys.Universal.RangeSelectDown). + Press(keys.Universal.Remove). + Tap(func() { + t.ExpectToast(Equals("Disabled: Multiselection not supported for worktrees")) + }) + }, +}) diff --git a/pkg/integration/tests/worktree/worktree_in_repo.go b/pkg/integration/tests/worktree/worktree_in_repo.go index f6b5b44a3..d05fc9136 100644 --- a/pkg/integration/tests/worktree/worktree_in_repo.go +++ b/pkg/integration/tests/worktree/worktree_in_repo.go @@ -64,18 +64,18 @@ var WorktreeInRepo = NewIntegrationTest(NewIntegrationTestArgs{ Press(keys.Universal.Remove). Tap(func() { t.ExpectPopup().Menu(). - Title(Equals("Discard changes")). - Select(Contains("Discard all changes")). + Title(Equals("Remove worktree 'linked-worktree'?")). + Select(MatchesRegexp("Remove worktree$")). Confirm() }). IsEmpty() - // confirm worktree appears as missing + t.FileSystem().PathNotPresent("linked-worktree") + t.Views().Worktrees(). Focus(). Lines( Contains("(main worktree)").IsSelected(), - Contains("linked-worktree (missing)"), ) }, })