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) <noreply@anthropic.com>
This commit is contained in:
Stefan Haller
2026-10-15 20:51:34 +02:00
co-authored by Claude Opus 5.5
parent 3ffcc0c7b0
commit e2a29a6d77
7 changed files with 76 additions and 8 deletions
+26 -2
View File
@@ -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 {
@@ -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 {
@@ -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(
+2
View File
@@ -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",
+1
View File
@@ -696,6 +696,7 @@ var tests = []*components.IntegrationTest{
worktree.CustomCommand,
worktree.DefaultPathTilde,
worktree.DetachWorktreeFromBranch,
worktree.DiscardRangeWithWorktree,
worktree.DotfileBareRepo,
worktree.DoubleNestedLinkedSubmodule,
worktree.EditFileOfBranchInOtherWorktree,
@@ -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"))
})
},
})
@@ -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)"),
)
},
})