From 6dca12131f4f5d8fc0fd36ce62bd396b04eeceb1 Mon Sep 17 00:00:00 2001 From: phanium <91544758+phanen@users.noreply.github.com> Date: Sat, 25 Jul 2026 21:43:13 +0800 Subject: [PATCH 1/3] Refactor Ignore/Exclude to accept multiple filenames The gitignore-applier layer now takes a slice, looping over paths. The files_controller plumbing still feeds a single-element slice so behavior is unchanged for the single-file case; this prepares for wiring the binding up to multi-select. --- pkg/commands/git_commands/working_tree.go | 22 ++++++++++++++++------ pkg/gui/controllers/files_controller.go | 8 ++++++-- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/pkg/commands/git_commands/working_tree.go b/pkg/commands/git_commands/working_tree.go index d296858f6b6..dbe0346acd6 100644 --- a/pkg/commands/git_commands/working_tree.go +++ b/pkg/commands/git_commands/working_tree.go @@ -367,19 +367,29 @@ func escapeFilename(filename string) string { return "/" + re.ReplaceAllString(filename, `\${0}`) } -// Ignore adds a file to the gitignore for the repo -func (self *WorkingTreeCommands) Ignore(filename string) error { - return self.os.AppendLineToFile(".gitignore", escapeFilename(filename)) +// Ignore adds the given files to the gitignore for the repo +func (self *WorkingTreeCommands) Ignore(filenames []string) error { + for _, filename := range filenames { + if err := self.os.AppendLineToFile(".gitignore", escapeFilename(filename)); err != nil { + return err + } + } + return nil } -// Exclude adds a file to the .git/info/exclude for the repo -func (self *WorkingTreeCommands) Exclude(filename string) error { +// Exclude adds the given files to the .git/info/exclude for the repo +func (self *WorkingTreeCommands) Exclude(filenames []string) error { infoDir := filepath.Join(self.repoPaths.repoGitDirPath, "info") if err := os.MkdirAll(infoDir, 0o755); err != nil { return err } excludeFile := filepath.Join(infoDir, "exclude") - return self.os.AppendLineToFile(excludeFile, escapeFilename(filename)) + for _, filename := range filenames { + if err := self.os.AppendLineToFile(excludeFile, escapeFilename(filename)); err != nil { + return err + } + } + return nil } // WorktreeFileDiff returns the diff of a file diff --git a/pkg/gui/controllers/files_controller.go b/pkg/gui/controllers/files_controller.go index 656da1bf5e8..a133dac72de 100644 --- a/pkg/gui/controllers/files_controller.go +++ b/pkg/gui/controllers/files_controller.go @@ -1020,7 +1020,9 @@ func (self *FilesController) ignore(node *filetree.FileNode) error { if node.GetPath() == ".gitignore" { return errors.New(self.c.Tr.Actions.IgnoreFileErr) } - return self.ignoreOrExcludeFile(node, self.c.Tr.IgnoreTracked, self.c.Tr.IgnoreTrackedPrompt, self.c.Tr.Actions.IgnoreExcludeFile, self.c.Git().WorkingTree.Ignore) + return self.ignoreOrExcludeFile(node, self.c.Tr.IgnoreTracked, self.c.Tr.IgnoreTrackedPrompt, self.c.Tr.Actions.IgnoreExcludeFile, func(name string) error { + return self.c.Git().WorkingTree.Ignore([]string{name}) + }) } func (self *FilesController) exclude(node *filetree.FileNode) error { @@ -1028,7 +1030,9 @@ func (self *FilesController) exclude(node *filetree.FileNode) error { return errors.New(self.c.Tr.Actions.ExcludeGitIgnoreErr) } - return self.ignoreOrExcludeFile(node, self.c.Tr.ExcludeTracked, self.c.Tr.ExcludeTrackedPrompt, self.c.Tr.Actions.ExcludeFile, self.c.Git().WorkingTree.Exclude) + return self.ignoreOrExcludeFile(node, self.c.Tr.ExcludeTracked, self.c.Tr.ExcludeTrackedPrompt, self.c.Tr.Actions.ExcludeFile, func(name string) error { + return self.c.Git().WorkingTree.Exclude([]string{name}) + }) } func (self *FilesController) ignoreOrExcludeMenu(node *filetree.FileNode) error { From ffab5b6ac94e565303fd1176bce0b7fa10862fd7 Mon Sep 17 00:00:00 2001 From: phanium <91544758+phanen@users.noreply.github.com> Date: Sat, 25 Jul 2026 21:44:07 +0800 Subject: [PATCH 2/3] Support multi-select for ignore/exclude file binding The `i` keybinding in the files panel now accepts a range selection of nodes: every selected file is unstaged, removed from the index if it was tracked, and appended to `.gitignore` (or `.git/info/exclude` on the other menu entry) on its own line. Plumbing switched from a single node to a slice of nodes so the tracked /untracked split unstage-and-rm each path before writing the list. The single-file path keeps the existing `.gitignore`-self-ignore guard. --- pkg/gui/controllers/files_controller.go | 81 ++++++++++++------- .../tests/file/gitignore_range_select.go | 65 +++++++++++++++ pkg/integration/tests/test_list.go | 1 + 3 files changed, 116 insertions(+), 31 deletions(-) create mode 100644 pkg/integration/tests/file/gitignore_range_select.go diff --git a/pkg/gui/controllers/files_controller.go b/pkg/gui/controllers/files_controller.go index a133dac72de..94c0a8132c7 100644 --- a/pkg/gui/controllers/files_controller.go +++ b/pkg/gui/controllers/files_controller.go @@ -105,8 +105,8 @@ func (self *FilesController) GetKeybindings(opts types.KeybindingsOpts) []*types }, { Keys: opts.GetKeys(opts.Config.Files.IgnoreFile), - Handler: self.withItem(self.ignoreOrExcludeMenu), - GetDisabledReason: self.require(self.singleItemSelected()), + Handler: self.withItems(self.ignoreOrExcludeMenu), + GetDisabledReason: self.require(self.itemsSelected()), Description: self.c.Tr.Actions.IgnoreExcludeFile, OpensMenu: true, }, @@ -971,18 +971,24 @@ func (self *FilesController) unstageFiles(node *filetree.FileNode) error { }) } -func (self *FilesController) ignoreOrExcludeTracked(node *filetree.FileNode, trAction string, f func(string) error) error { +func (self *FilesController) ignoreOrExcludeTracked(nodes []*filetree.FileNode, trAction string, f func([]string) error) error { self.c.LogAction(trAction) - // not 100% sure if this is necessary but I'll assume it is - if err := self.unstageFiles(node); err != nil { - return err - } - if err := self.c.Git().WorkingTree.RemoveTrackedFiles(node.GetPath()); err != nil { - return err + paths := make([]string, 0, len(nodes)) + for _, node := range nodes { + // not 100% sure if this is necessary but I'll assume it is + if err := self.unstageFiles(node); err != nil { + return err + } + + if err := self.c.Git().WorkingTree.RemoveTrackedFiles(node.GetPath()); err != nil { + return err + } + + paths = append(paths, node.GetPath()) } - if err := f(node.GetPath()); err != nil { + if err := f(paths); err != nil { return err } @@ -990,10 +996,15 @@ func (self *FilesController) ignoreOrExcludeTracked(node *filetree.FileNode, trA return nil } -func (self *FilesController) ignoreOrExcludeUntracked(node *filetree.FileNode, trAction string, f func(string) error) error { +func (self *FilesController) ignoreOrExcludeUntracked(nodes []*filetree.FileNode, trAction string, f func([]string) error) error { self.c.LogAction(trAction) - if err := f(node.GetPath()); err != nil { + paths := make([]string, 0, len(nodes)) + for _, node := range nodes { + paths = append(paths, node.GetPath()) + } + + if err := f(paths); err != nil { return err } @@ -1001,48 +1012,56 @@ func (self *FilesController) ignoreOrExcludeUntracked(node *filetree.FileNode, t return nil } -func (self *FilesController) ignoreOrExcludeFile(node *filetree.FileNode, trText string, trPrompt string, trAction string, f func(string) error) error { - if node.GetIsTracked() { +func (self *FilesController) ignoreOrExcludeFiles(nodes []*filetree.FileNode, trText string, trPrompt string, trAction string, f func([]string) error) error { + hasTracked := false + for _, node := range nodes { + if node.GetIsTracked() { + hasTracked = true + break + } + } + + if hasTracked { self.c.Confirm(types.ConfirmOpts{ Title: trText, Prompt: trPrompt, HandleConfirm: func() error { - return self.ignoreOrExcludeTracked(node, trAction, f) + return self.ignoreOrExcludeTracked(nodes, trAction, f) }, }) return nil } - return self.ignoreOrExcludeUntracked(node, trAction, f) + return self.ignoreOrExcludeUntracked(nodes, trAction, f) } -func (self *FilesController) ignore(node *filetree.FileNode) error { - if node.GetPath() == ".gitignore" { - return errors.New(self.c.Tr.Actions.IgnoreFileErr) +func (self *FilesController) ignore(nodes []*filetree.FileNode) error { + for _, node := range nodes { + if node.GetPath() == ".gitignore" { + return errors.New(self.c.Tr.Actions.IgnoreFileErr) + } } - return self.ignoreOrExcludeFile(node, self.c.Tr.IgnoreTracked, self.c.Tr.IgnoreTrackedPrompt, self.c.Tr.Actions.IgnoreExcludeFile, func(name string) error { - return self.c.Git().WorkingTree.Ignore([]string{name}) - }) + return self.ignoreOrExcludeFiles(nodes, self.c.Tr.IgnoreTracked, self.c.Tr.IgnoreTrackedPrompt, self.c.Tr.Actions.IgnoreExcludeFile, self.c.Git().WorkingTree.Ignore) } -func (self *FilesController) exclude(node *filetree.FileNode) error { - if node.GetPath() == ".gitignore" { - return errors.New(self.c.Tr.Actions.ExcludeGitIgnoreErr) +func (self *FilesController) exclude(nodes []*filetree.FileNode) error { + for _, node := range nodes { + if node.GetPath() == ".gitignore" { + return errors.New(self.c.Tr.Actions.ExcludeGitIgnoreErr) + } } - return self.ignoreOrExcludeFile(node, self.c.Tr.ExcludeTracked, self.c.Tr.ExcludeTrackedPrompt, self.c.Tr.Actions.ExcludeFile, func(name string) error { - return self.c.Git().WorkingTree.Exclude([]string{name}) - }) + return self.ignoreOrExcludeFiles(nodes, self.c.Tr.ExcludeTracked, self.c.Tr.ExcludeTrackedPrompt, self.c.Tr.Actions.ExcludeFile, self.c.Git().WorkingTree.Exclude) } -func (self *FilesController) ignoreOrExcludeMenu(node *filetree.FileNode) error { +func (self *FilesController) ignoreOrExcludeMenu(nodes []*filetree.FileNode) error { return self.c.Menu(types.CreateMenuOptions{ Title: self.c.Tr.Actions.IgnoreExcludeFile, Items: []*types.MenuItem{ { LabelColumns: []string{self.c.Tr.IgnoreFile}, OnPress: func() error { - if err := self.ignore(node); err != nil { + if err := self.ignore(nodes); err != nil { return err } return nil @@ -1052,7 +1071,7 @@ func (self *FilesController) ignoreOrExcludeMenu(node *filetree.FileNode) error { LabelColumns: []string{self.c.Tr.ExcludeFile}, OnPress: func() error { - if err := self.exclude(node); err != nil { + if err := self.exclude(nodes); err != nil { return err } return nil diff --git a/pkg/integration/tests/file/gitignore_range_select.go b/pkg/integration/tests/file/gitignore_range_select.go new file mode 100644 index 00000000000..4d41a6760d4 --- /dev/null +++ b/pkg/integration/tests/file/gitignore_range_select.go @@ -0,0 +1,65 @@ +package file + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var GitignoreRangeSelect = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Ignore and exclude multiple files at once via range select", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + }, + SetupRepo: func(shell *Shell) { + shell.CreateFile(".gitignore", "") + shell.CreateFile("toIgnore1", "") + shell.CreateFile("toIgnore2", "") + shell.CreateFile("toIgnore3", "") + shell.CreateFile("toExclude1", "") + shell.CreateFile("toExclude2", "") + shell.CreateFile("toExclude3", "") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Lines( + Equals("▼ /").IsSelected(), + Equals(" ?? .gitignore"), + Equals(" ?? toExclude1"), + Equals(" ?? toExclude2"), + Equals(" ?? toExclude3"), + Equals(" ?? toIgnore1"), + Equals(" ?? toIgnore2"), + Equals(" ?? toIgnore3"), + ). + // Select range from toIgnore1 to toIgnore3 + NavigateToLine(Contains("toIgnore1")). + Press(keys.Universal.ToggleRangeSelect). + NavigateToLine(Contains("toIgnore3")). + Press(keys.Files.IgnoreFile). + // Ignore all selected files + Tap(func() { + t.ExpectPopup().Menu().Title(Equals("Ignore or exclude file")). + Select(Contains("Add to .gitignore")). + Confirm() + + t.FileSystem().FileContent(".gitignore", Equals("/toIgnore1\n/toIgnore2\n/toIgnore3\n")) + }). + // Dismiss the range select mode for the next set of steps + Press(keys.Universal.ToggleRangeSelect). + // Select range from toExclude1 to toExclude3 + NavigateToLine(Contains("toExclude1")). + Press(keys.Universal.ToggleRangeSelect). + NavigateToLine(Contains("toExclude3")). + Press(keys.Files.IgnoreFile). + // Exclude all selected files + Tap(func() { + t.ExpectPopup().Menu().Title(Equals("Ignore or exclude file")). + Select(Contains("Add to .git/info/exclude")). + Confirm() + + t.FileSystem().FileContent(".git/info/exclude", Contains("/toExclude1\n/toExclude2\n/toExclude3\n")) + }) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 1bb06741fa2..a6e9771e04b 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -240,6 +240,7 @@ var tests = []*components.IntegrationTest{ file.DiscardVariousChangesRangeSelect, file.ExcludeWithoutInfoDir, file.Gitignore, + file.GitignoreRangeSelect, file.GitignoreSpecialCharacters, file.RememberCommitMessageAfterFail, file.RenameSimilarityThresholdChange, From 6905d62af6c3edd8a48bb4c68476cb4acc3469c3 Mon Sep 17 00:00:00 2001 From: phanium <91544758+phanen@users.noreply.github.com> Date: Sun, 26 Jul 2026 00:23:43 +0800 Subject: [PATCH 3/3] Fix multi-select ignore/exclude for tracked ranges A range select that crosses a tracked directory boundary breaks because `git rm -r --cached -- dir` removes its descendants from the index, so a subsequent child-targeted call fails with pathspec errors. Untracked nodes in a mixed range hit the same problem since they aren't in the index at all. Filter the selection through `normalisedSelectedNodes` and only unstage/rm the nodes that are actually tracked. --- pkg/gui/controllers/files_controller.go | 18 ++++--- .../file/gitignore_tracked_range_select.go | 50 +++++++++++++++++++ pkg/integration/tests/test_list.go | 1 + 3 files changed, 63 insertions(+), 6 deletions(-) create mode 100644 pkg/integration/tests/file/gitignore_tracked_range_select.go diff --git a/pkg/gui/controllers/files_controller.go b/pkg/gui/controllers/files_controller.go index 94c0a8132c7..3c88701c945 100644 --- a/pkg/gui/controllers/files_controller.go +++ b/pkg/gui/controllers/files_controller.go @@ -974,15 +974,19 @@ func (self *FilesController) unstageFiles(node *filetree.FileNode) error { func (self *FilesController) ignoreOrExcludeTracked(nodes []*filetree.FileNode, trAction string, f func([]string) error) error { self.c.LogAction(trAction) + nodes = normalisedSelectedNodes(nodes) + paths := make([]string, 0, len(nodes)) for _, node := range nodes { - // not 100% sure if this is necessary but I'll assume it is - if err := self.unstageFiles(node); err != nil { - return err - } + if node.GetIsTracked() { + // not 100% sure if this is necessary but I'll assume it is + if err := self.unstageFiles(node); err != nil { + return err + } - if err := self.c.Git().WorkingTree.RemoveTrackedFiles(node.GetPath()); err != nil { - return err + if err := self.c.Git().WorkingTree.RemoveTrackedFiles(node.GetPath()); err != nil { + return err + } } paths = append(paths, node.GetPath()) @@ -999,6 +1003,8 @@ func (self *FilesController) ignoreOrExcludeTracked(nodes []*filetree.FileNode, func (self *FilesController) ignoreOrExcludeUntracked(nodes []*filetree.FileNode, trAction string, f func([]string) error) error { self.c.LogAction(trAction) + nodes = normalisedSelectedNodes(nodes) + paths := make([]string, 0, len(nodes)) for _, node := range nodes { paths = append(paths, node.GetPath()) diff --git a/pkg/integration/tests/file/gitignore_tracked_range_select.go b/pkg/integration/tests/file/gitignore_tracked_range_select.go new file mode 100644 index 00000000000..1e754cb8645 --- /dev/null +++ b/pkg/integration/tests/file/gitignore_tracked_range_select.go @@ -0,0 +1,50 @@ +package file + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var GitignoreTrackedRangeSelect = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Range-select across a tracked directory and its children (parent+child case)", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + }, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("dir-tracked/file-a", "x") + shell.CreateFileAndAdd("dir-tracked/file-b", "x") + shell.CreateFileAndAdd("tracked1", "x") + shell.Commit("initial") + shell.UpdateFile("dir-tracked/file-a", "y") + shell.UpdateFile("dir-tracked/file-b", "y") + shell.UpdateFile("tracked1", "y") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Lines( + Equals("▼ /").IsSelected(), + Equals(" ▼ dir-tracked"), + Equals(" M file-a"), + Equals(" M file-b"), + Equals(" M tracked1"), + ). + NavigateToLine(Contains("dir-tracked")). + Press(keys.Universal.ToggleRangeSelect). + NavigateToLine(Contains("tracked1")). + Press(keys.Files.IgnoreFile). + Tap(func() { + t.ExpectPopup().Menu().Title(Equals("Ignore or exclude file")). + Select(Contains("Add to .gitignore")). + Confirm() + + t.ExpectPopup().Confirmation(). + Title(Equals("Ignore tracked file")). + Content(Contains("tracked file")). + Confirm() + + t.FileSystem().FileContent(".gitignore", Equals("/dir-tracked\n/tracked1\n")) + }) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index a6e9771e04b..78438c48b8d 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -242,6 +242,7 @@ var tests = []*components.IntegrationTest{ file.Gitignore, file.GitignoreRangeSelect, file.GitignoreSpecialCharacters, + file.GitignoreTrackedRangeSelect, file.RememberCommitMessageAfterFail, file.RenameSimilarityThresholdChange, file.RenamedFiles,