From dd68fbd159454696e6d2da7627790d3417f8c851 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 12 Aug 2026 10:37:38 +0200 Subject: [PATCH 1/5] Add tests for diffing a directory that files were renamed into or out of Pathspec limiting happens before rename detection in git's tree diff, so filtering the diff to a directory hides the delete-side entry of a rename whose other end is outside that directory. Git then has nothing to pair up, and reports a file moved into the directory as an addition and one moved out of it as a deletion. Selecting a directory is supposed to filter the commit's diff down, never to change it, so both are wrong. --- .../directory_diff_with_renamed_files.go | 112 +++++++++++++++++ .../file/directory_diff_with_renamed_files.go | 119 ++++++++++++++++++ pkg/integration/tests/test_list.go | 2 + 3 files changed, 233 insertions(+) create mode 100644 pkg/integration/tests/commit/directory_diff_with_renamed_files.go create mode 100644 pkg/integration/tests/file/directory_diff_with_renamed_files.go diff --git a/pkg/integration/tests/commit/directory_diff_with_renamed_files.go b/pkg/integration/tests/commit/directory_diff_with_renamed_files.go new file mode 100644 index 00000000000..ef3c71e5e58 --- /dev/null +++ b/pkg/integration/tests/commit/directory_diff_with_renamed_files.go @@ -0,0 +1,112 @@ +package commit + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var DirectoryDiffWithRenamedFiles = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Selecting a directory in the commit files panel shows the renames of files that were moved into or out of it", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.CreateDir("dir") + shell.CreateDir("dir/nested") + shell.CreateFileAndAdd("file1", "file1 content\n") + shell.CreateFileAndAdd("dir/file2", "file2 content\n") + shell.CreateFileAndAdd("dir/nested/file3", "file3 content\n") + shell.Commit("initial commit") + shell.RenameFileInGit("file1", "dir/file1") + shell.RenameFileInGit("dir/file2", "dir/file2-renamed") + shell.RenameFileInGit("dir/nested/file3", "file3") + shell.Commit("move files") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Commits(). + Focus(). + Lines( + Contains("move files").IsSelected(), + Contains("initial commit"), + ). + PressEnter() + + t.Views().CommitFiles(). + IsFocused(). + Lines( + Equals("▼ /").IsSelected(), + Equals(" ▼ dir"), + Equals(" R file1 → file1"), + Equals(" R file2 → file2-renamed"), + Equals(" R dir/nested/file3 → file3"), + ) + + t.Views().Main().ContainsLines( + Equals("diff --git a/file1 b/dir/file1"), + Equals("similarity index 100%"), + Equals("rename from file1"), + Equals("rename to dir/file1"), + Equals("diff --git a/dir/file2 b/dir/file2-renamed"), + Equals("similarity index 100%"), + Equals("rename from dir/file2"), + Equals("rename to dir/file2-renamed"), + Equals("diff --git a/dir/nested/file3 b/file3"), + Equals("similarity index 100%"), + Equals("rename from dir/nested/file3"), + Equals("rename to file3"), + ) + + t.Views().CommitFiles(). + SelectNextItem(). + SelectedLine(Equals(" ▼ dir")) + + t.Views().Main(). + /* EXPECTED: + ContainsLines( + Equals("diff --git a/file1 b/dir/file1"), + Equals("similarity index 100%"), + Equals("rename from file1"), + Equals("rename to dir/file1"), + Equals("diff --git a/dir/file2 b/dir/file2-renamed"), + Equals("similarity index 100%"), + Equals("rename from dir/file2"), + Equals("rename to dir/file2-renamed"), + Equals("diff --git a/dir/nested/file3 b/file3"), + Equals("similarity index 100%"), + Equals("rename from dir/nested/file3"), + Equals("rename to file3"), + ) + ACTUAL: */ + ContainsLines( + Equals("diff --git a/dir/file1 b/dir/file1"), + Equals("new file mode 100644"), + Contains("index"), + Equals("--- /dev/null"), + Equals("+++ b/dir/file1"), + Equals("@@ -0,0 +1 @@"), + Equals("+file1 content"), + Equals("diff --git a/dir/file2 b/dir/file2-renamed"), + Equals("similarity index 100%"), + Equals("rename from dir/file2"), + Equals("rename to dir/file2-renamed"), + Equals("diff --git a/dir/nested/file3 b/dir/nested/file3"), + Equals("deleted file mode 100644"), + Contains("index"), + Equals("--- a/dir/nested/file3"), + Equals("+++ /dev/null"), + Equals("@@ -1 +0,0 @@"), + Equals("-file3 content"), + ) + + t.Views().CommitFiles(). + SelectNextItem(). + SelectedLine(Equals(" R file1 → file1")) + + t.Views().Main().ContainsLines( + Equals("diff --git a/file1 b/dir/file1"), + Equals("similarity index 100%"), + Equals("rename from file1"), + Equals("rename to dir/file1"), + ) + }, +}) diff --git a/pkg/integration/tests/file/directory_diff_with_renamed_files.go b/pkg/integration/tests/file/directory_diff_with_renamed_files.go new file mode 100644 index 00000000000..bd4e667c283 --- /dev/null +++ b/pkg/integration/tests/file/directory_diff_with_renamed_files.go @@ -0,0 +1,119 @@ +package file + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var DirectoryDiffWithRenamedFiles = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Selecting a directory in the files panel shows the renames of files that were moved into or out of it", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.CreateDir("dir") + shell.CreateDir("dir/nested") + shell.CreateFileAndAdd("file1", "file1 content\n") + shell.CreateFileAndAdd("dir/file2", "file2 content\n") + shell.CreateFileAndAdd("dir/nested/file3", "file3 content\n") + shell.Commit("initial commit") + shell.RenameFileInGit("file1", "dir/file1") + shell.RenameFileInGit("dir/file2", "dir/file2-renamed") + shell.RenameFileInGit("dir/nested/file3", "file3") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Lines( + Equals("▼ /").IsSelected(), + Equals(" ▼ dir"), + Equals(" R file1 → file1"), + Equals(" R file2 → file2-renamed"), + Equals(" R dir/nested/file3 → file3"), + ) + + t.Views().Main().ContainsLines( + Equals("diff --git a/file1 b/dir/file1"), + Equals("similarity index 100%"), + Equals("rename from file1"), + Equals("rename to dir/file1"), + Equals("diff --git a/dir/file2 b/dir/file2-renamed"), + Equals("similarity index 100%"), + Equals("rename from dir/file2"), + Equals("rename to dir/file2-renamed"), + Equals("diff --git a/dir/nested/file3 b/file3"), + Equals("similarity index 100%"), + Equals("rename from dir/nested/file3"), + Equals("rename to file3"), + ) + + t.Views().Files(). + SelectNextItem(). + SelectedLine(Equals(" ▼ dir")) + + t.Views().Main(). + /* EXPECTED: + ContainsLines( + Equals("diff --git a/file1 b/dir/file1"), + Equals("similarity index 100%"), + Equals("rename from file1"), + Equals("rename to dir/file1"), + Equals("diff --git a/dir/file2 b/dir/file2-renamed"), + Equals("similarity index 100%"), + Equals("rename from dir/file2"), + Equals("rename to dir/file2-renamed"), + Equals("diff --git a/dir/nested/file3 b/file3"), + Equals("similarity index 100%"), + Equals("rename from dir/nested/file3"), + Equals("rename to file3"), + ) + ACTUAL: */ + ContainsLines( + Equals("diff --git a/dir/file1 b/dir/file1"), + Equals("new file mode 100644"), + Contains("index"), + Equals("--- /dev/null"), + Equals("+++ b/dir/file1"), + Equals("@@ -0,0 +1 @@"), + Equals("+file1 content"), + Equals("diff --git a/dir/file2 b/dir/file2-renamed"), + Equals("similarity index 100%"), + Equals("rename from dir/file2"), + Equals("rename to dir/file2-renamed"), + Equals("diff --git a/dir/nested/file3 b/dir/nested/file3"), + Equals("deleted file mode 100644"), + Contains("index"), + Equals("--- a/dir/nested/file3"), + Equals("+++ /dev/null"), + Equals("@@ -1 +0,0 @@"), + Equals("-file3 content"), + ) + + // The same applies when a filter reduces the directory to a single file + t.Views().Files(). + FilterOrSearch("file1"). + Lines( + Equals("▼ dir").IsSelected(), + Equals(" R file1 → file1"), + ) + + t.Views().Main(). + /* EXPECTED: + ContainsLines( + Equals("diff --git a/file1 b/dir/file1"), + Equals("similarity index 100%"), + Equals("rename from file1"), + Equals("rename to dir/file1"), + ) + ACTUAL: */ + ContainsLines( + Equals("diff --git a/dir/file1 b/dir/file1"), + Equals("new file mode 100644"), + Contains("index"), + Equals("--- /dev/null"), + Equals("+++ b/dir/file1"), + Equals("@@ -0,0 +1 @@"), + Equals("+file1 content"), + ) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 15ef6f8c75d..645e9710f9b 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -126,6 +126,7 @@ var tests = []*components.IntegrationTest{ commit.CreateAmendCommit, commit.CreateFixupCommitInBranchStack, commit.CreateTag, + commit.DirectoryDiffWithRenamedFiles, commit.DisableCopyCommitMessageBody, commit.DiscardOldFileChanges, commit.DiscardRenamedFile, @@ -231,6 +232,7 @@ var tests = []*components.IntegrationTest{ file.CollapseExpand, file.CopyMenu, file.DirWithUntrackedFile, + file.DirectoryDiffWithRenamedFiles, file.DiscardAllDirChanges, file.DiscardAllDirChangesWhenFiltering, file.DiscardRangeSelect, From 51c1c6261893ef7e5370b438776310bfd04f6e83 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 12 Aug 2026 10:38:12 +0200 Subject: [PATCH 2/5] Pass the previous path when diffing a filtered directory in the files panel Restricting the diff to the files that a filter leaves visible drops the delete-side entry of a staged rename, so git shows the file as an addition instead. Its commit files counterpart already passes both paths; this brings the files panel in line. --- pkg/gui/controllers/files_controller.go | 4 +++- .../tests/file/directory_diff_with_renamed_files.go | 11 ----------- 2 files changed, 3 insertions(+), 12 deletions(-) diff --git a/pkg/gui/controllers/files_controller.go b/pkg/gui/controllers/files_controller.go index 41f9197fe75..eda86b997e8 100644 --- a/pkg/gui/controllers/files_controller.go +++ b/pkg/gui/controllers/files_controller.go @@ -649,7 +649,9 @@ func (self *FilesController) pathOverridesForDiff(node *filetree.FileNode) []str if !node.IsFile() && self.context().IsFiltering() { var paths []string _ = node.ForEachFile(func(file *models.File) error { - paths = append(paths, file.Path) + // For a rename we need to pass both paths so that git detects it as + // a rename rather than an unrelated delete and add. + paths = append(paths, file.Names()...) return nil }) return paths diff --git a/pkg/integration/tests/file/directory_diff_with_renamed_files.go b/pkg/integration/tests/file/directory_diff_with_renamed_files.go index bd4e667c283..101f569505d 100644 --- a/pkg/integration/tests/file/directory_diff_with_renamed_files.go +++ b/pkg/integration/tests/file/directory_diff_with_renamed_files.go @@ -98,22 +98,11 @@ var DirectoryDiffWithRenamedFiles = NewIntegrationTest(NewIntegrationTestArgs{ ) t.Views().Main(). - /* EXPECTED: ContainsLines( Equals("diff --git a/file1 b/dir/file1"), Equals("similarity index 100%"), Equals("rename from file1"), Equals("rename to dir/file1"), ) - ACTUAL: */ - ContainsLines( - Equals("diff --git a/dir/file1 b/dir/file1"), - Equals("new file mode 100644"), - Contains("index"), - Equals("--- /dev/null"), - Equals("+++ b/dir/file1"), - Equals("@@ -0,0 +1 @@"), - Equals("+file1 content"), - ) }, }) From 8bcf111b0756ea477af0880d040618fe8f1d2d1d Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 12 Aug 2026 10:41:50 +0200 Subject: [PATCH 3/5] Extract a single helper for the paths a node's diff is limited to The files and commit files panels each had their own copy of this, one of which used to be missing the previous path of a rename. Growing them apart again is the last thing we want, since the next commit needs to teach both of them about renames that cross a directory boundary. The files panel version only returned paths for the filtered case, and left it to WorktreeFileDiffCmdObj to derive the rest from the node; now that all callers pass the paths in, that command doesn't need to know about renames at all. --- pkg/commands/git_commands/working_tree.go | 18 ++++------ .../controllers/commits_files_controller.go | 25 ++----------- pkg/gui/controllers/diff_paths.go | 35 +++++++++++++++++++ pkg/gui/controllers/files_controller.go | 23 ++---------- pkg/gui/controllers/submodules_controller.go | 2 +- 5 files changed, 48 insertions(+), 55 deletions(-) create mode 100644 pkg/gui/controllers/diff_paths.go diff --git a/pkg/commands/git_commands/working_tree.go b/pkg/commands/git_commands/working_tree.go index 1f9d9653bd0..846b359b38c 100644 --- a/pkg/commands/git_commands/working_tree.go +++ b/pkg/commands/git_commands/working_tree.go @@ -385,27 +385,22 @@ func (self *WorkingTreeCommands) Exclude(filename string) error { // WorktreeFileDiff returns the diff of a file func (self *WorkingTreeCommands) WorktreeFileDiff(file *models.File, plain bool, cached bool) string { // for now we assume an error means the file was deleted - s, _ := self.WorktreeFileDiffCmdObj(file, plain, cached, nil).RunWithOutput() + s, _ := self.WorktreeFileDiffCmdObj(file, plain, cached, file.Names()).RunWithOutput() return s } -// WorktreeFileDiffCmdObj returns a command object for diffing a file or directory -// in the working tree. When pathOverrides is non-empty, those paths are used instead of -// the node's path (used to diff only filtered/visible files within a directory). -func (self *WorkingTreeCommands) WorktreeFileDiffCmdObj(node models.IFile, plain bool, cached bool, pathOverrides []string) *oscommands.CmdObj { +// WorktreeFileDiffCmdObj returns a command object for diffing the given paths +// in the working tree. node is the item they belong to; all it decides is +// whether git has to compare against /dev/null, which is the case for a file +// that isn't in the index yet. +func (self *WorkingTreeCommands) WorktreeFileDiffCmdObj(node models.IFile, plain bool, cached bool, paths []string) *oscommands.CmdObj { colorArg := self.diffRendererConfigManager.GetColorArg() if plain { colorArg = "never" } - prevPath := node.GetPreviousPath() noIndex := !node.GetIsTracked() && !node.GetHasStagedChanges() && !cached && node.GetIsFile() - paths := pathOverrides - if len(paths) == 0 { - paths = []string{node.GetPath()} - } - cmdArgs := NewGitCmd("diff"). AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), !plain). Arg("--submodule"). @@ -415,7 +410,6 @@ func (self *WorkingTreeCommands) WorktreeFileDiffCmdObj(node models.IFile, plain Arg("--"). ArgIf(noIndex, "/dev/null"). Arg(paths...). - ArgIf(prevPath != "", prevPath). Dir(self.repoPaths.worktreePath). ToArgv() diff --git a/pkg/gui/controllers/commits_files_controller.go b/pkg/gui/controllers/commits_files_controller.go index 748355f6729..c01658b0339 100644 --- a/pkg/gui/controllers/commits_files_controller.go +++ b/pkg/gui/controllers/commits_files_controller.go @@ -175,7 +175,7 @@ func (self *CommitFilesController) GetOnRenderToMain() func() { from, to := self.context().GetFromAndToForDiff() from, reverse := self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(from) - paths := self.pathsForDiff(node) + paths := pathsForDiff(node.Raw(), self.context().IsFiltering()) cmdObj := self.c.Git().WorkingTree.ShowFileDiffCmdObj(from, to, reverse, paths, false) task := types.NewRunPtyTask(cmdObj.GetCmd()) @@ -263,7 +263,8 @@ func (self *CommitFilesController) openCopyMenu() error { copyFileDiffItem := &types.MenuItem{ Label: self.c.Tr.CopySelectedDiff, OnPress: func() error { - return self.copyDiffToClipboard(self.pathsForDiff(node), self.c.Tr.FileDiffCopiedToast) + paths := pathsForDiff(node.Raw(), self.context().IsFiltering()) + return self.copyDiffToClipboard(paths, self.c.Tr.FileDiffCopiedToast) }, DisabledReason: self.require(self.singleItemSelected())(), Keys: menuKey('s'), @@ -616,26 +617,6 @@ func (self *CommitFilesController) GetOnClickFocusedMainView() func(mainViewName } } -// pathsForDiff returns the file paths to use for a diff command. When a text -// filter is active and the node is a directory, only the visible (filtered) -// file paths are returned so the diff reflects what the user sees. -func (self *CommitFilesController) pathsForDiff(node *filetree.CommitFileNode) []string { - if !node.IsFile() && self.context().IsFiltering() { - var paths []string - _ = node.ForEachFile(func(file *models.CommitFile) error { - // For a rename we need to pass both paths so that git detects it as - // a rename rather than an unrelated delete and add. - paths = append(paths, file.Names()...) - return nil - }) - return paths - } - if file := node.GetFile(); file != nil { - return file.Names() - } - return []string{node.GetPath()} -} - // NOTE: these functions are identical to those in files_controller.go (except for types) and // could also be cleaned up with some generics func normalisedSelectedCommitFileNodes(selectedNodes []*filetree.CommitFileNode) []*filetree.CommitFileNode { diff --git a/pkg/gui/controllers/diff_paths.go b/pkg/gui/controllers/diff_paths.go new file mode 100644 index 00000000000..31fc8175721 --- /dev/null +++ b/pkg/gui/controllers/diff_paths.go @@ -0,0 +1,35 @@ +package controllers + +import ( + "github.com/jesseduffield/lazygit/pkg/gui/filetree" +) + +// Both models.File and models.CommitFile satisfy this. Names returns the file's +// path, plus the path it was renamed from if it is a rename. +type fileWithNames[T any] interface { + *T + Names() []string +} + +// pathsForDiff returns the paths to limit a diff command to for showing the +// changes of the given node. For a directory this is the directory itself, +// unless a filter is active, in which case we list the files that are visible +// under it. +func pathsForDiff[T any, PT fileWithNames[T]](node *filetree.Node[T], isFiltering bool) []string { + if file := node.GetFile(); file != nil { + return PT(file).Names() + } + + if isFiltering { + var paths []string + _ = node.ForEachFile(func(file *T) error { + // For a rename we need to pass both paths so that git detects it as + // a rename rather than an unrelated delete and add. + paths = append(paths, PT(file).Names()...) + return nil + }) + return paths + } + + return []string{node.GetPath()} +} diff --git a/pkg/gui/controllers/files_controller.go b/pkg/gui/controllers/files_controller.go index eda86b997e8..985a37613e7 100644 --- a/pkg/gui/controllers/files_controller.go +++ b/pkg/gui/controllers/files_controller.go @@ -368,8 +368,8 @@ func (self *FilesController) renderWorkingTreeDiff(node *filetree.FileNode) { split := self.c.UserConfig().Gui.SplitDiff == "always" || (node.GetHasUnstagedChanges() && node.GetHasStagedChanges()) mainShowsStaged := !split && node.GetHasStagedChanges() - pathOverrides := self.pathOverridesForDiff(node) - cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, false, mainShowsStaged, pathOverrides) + paths := pathsForDiff(node.Raw(), self.context().IsFiltering()) + cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, false, mainShowsStaged, paths) title := self.c.Tr.UnstagedChanges if mainShowsStaged { title = self.c.Tr.StagedChanges @@ -384,7 +384,7 @@ func (self *FilesController) renderWorkingTreeDiff(node *filetree.FileNode) { } if split { - cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, false, true, pathOverrides) + cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, false, true, paths) title := self.c.Tr.StagedChanges if mainShowsStaged { @@ -642,23 +642,6 @@ func (self *FilesController) press(nodes []*filetree.FileNode) error { return nil } -// pathOverridesForDiff returns file paths to override the node's path in diff -// commands when a text filter is active and the node is a directory. This -// ensures the diff only shows filtered/visible files. -func (self *FilesController) pathOverridesForDiff(node *filetree.FileNode) []string { - if !node.IsFile() && self.context().IsFiltering() { - var paths []string - _ = node.ForEachFile(func(file *models.File) error { - // For a rename we need to pass both paths so that git detects it as - // a rename rather than an unrelated delete and add. - paths = append(paths, file.Names()...) - return nil - }) - return paths - } - return nil -} - // unstageFilteredFiles unstages only the visible (filtered) files from the // given nodes, correctly partitioning by tracked/untracked. func (self *FilesController) unstageFilteredFiles(nodes []*filetree.FileNode) error { diff --git a/pkg/gui/controllers/submodules_controller.go b/pkg/gui/controllers/submodules_controller.go index a2dd22ed3ff..82ca509cafb 100644 --- a/pkg/gui/controllers/submodules_controller.go +++ b/pkg/gui/controllers/submodules_controller.go @@ -123,7 +123,7 @@ func (self *SubmodulesController) GetOnRenderToMain() func() { if file == nil { task = types.NewRenderStringTask(prefix) } else { - cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(file, false, !file.HasUnstagedChanges && file.HasStagedChanges, nil) + cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(file, false, !file.HasUnstagedChanges && file.HasStagedChanges, file.Names()) task = types.NewRunCommandTaskWithPrefix(cmdObj.GetCmd(), prefix) } } From 62bb395f31f8549632c424bdc842d81a21e99ac1 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 12 Aug 2026 10:47:47 +0200 Subject: [PATCH 4/5] Show renames when diffing a directory that a file was moved into or out of Git limits its tree diff by the pathspec before it looks for renames, so a directory only ever gets one end of a rename whose other end is outside it. Nothing is left to pair up, and the file turns into an addition or a deletion that the commit doesn't contain. Pass the other end along with the directory. This is bounded by the number of renames that cross the directory's boundary, so it costs nothing at all for the vast majority of commits. --- .../controllers/commits_files_controller.go | 4 +- pkg/gui/controllers/diff_paths.go | 56 ++++++++++--- pkg/gui/controllers/diff_paths_test.go | 79 +++++++++++++++++++ pkg/gui/controllers/files_controller.go | 2 +- .../directory_diff_with_renamed_files.go | 22 ------ .../file/directory_diff_with_renamed_files.go | 22 ------ 6 files changed, 128 insertions(+), 57 deletions(-) create mode 100644 pkg/gui/controllers/diff_paths_test.go diff --git a/pkg/gui/controllers/commits_files_controller.go b/pkg/gui/controllers/commits_files_controller.go index c01658b0339..690114dbbe2 100644 --- a/pkg/gui/controllers/commits_files_controller.go +++ b/pkg/gui/controllers/commits_files_controller.go @@ -175,7 +175,7 @@ func (self *CommitFilesController) GetOnRenderToMain() func() { from, to := self.context().GetFromAndToForDiff() from, reverse := self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(from) - paths := pathsForDiff(node.Raw(), self.context().IsFiltering()) + paths := pathsForDiff(node.Raw(), self.context().GetRoot().Raw(), self.context().IsFiltering()) cmdObj := self.c.Git().WorkingTree.ShowFileDiffCmdObj(from, to, reverse, paths, false) task := types.NewRunPtyTask(cmdObj.GetCmd()) @@ -263,7 +263,7 @@ func (self *CommitFilesController) openCopyMenu() error { copyFileDiffItem := &types.MenuItem{ Label: self.c.Tr.CopySelectedDiff, OnPress: func() error { - paths := pathsForDiff(node.Raw(), self.context().IsFiltering()) + paths := pathsForDiff(node.Raw(), self.context().GetRoot().Raw(), self.context().IsFiltering()) return self.copyDiffToClipboard(paths, self.c.Tr.FileDiffCopiedToast) }, DisabledReason: self.require(self.singleItemSelected())(), diff --git a/pkg/gui/controllers/diff_paths.go b/pkg/gui/controllers/diff_paths.go index 31fc8175721..4cb34f3cc0f 100644 --- a/pkg/gui/controllers/diff_paths.go +++ b/pkg/gui/controllers/diff_paths.go @@ -1,6 +1,8 @@ package controllers import ( + "strings" + "github.com/jesseduffield/lazygit/pkg/gui/filetree" ) @@ -8,28 +10,62 @@ import ( // path, plus the path it was renamed from if it is a rename. type fileWithNames[T any] interface { *T + GetPath() string + GetPreviousPath() string Names() []string } // pathsForDiff returns the paths to limit a diff command to for showing the -// changes of the given node. For a directory this is the directory itself, -// unless a filter is active, in which case we list the files that are visible -// under it. -func pathsForDiff[T any, PT fileWithNames[T]](node *filetree.Node[T], isFiltering bool) []string { +// changes of the given node. root is the root of the tree that the node belongs +// to, and isFiltering says whether that tree is reduced to the files matching a +// text filter. +func pathsForDiff[T any, PT fileWithNames[T]](node *filetree.Node[T], root *filetree.Node[T], isFiltering bool) []string { if file := node.GetFile(); file != nil { return PT(file).Names() } + dir := node.GetPath() + if isFiltering { + // Passing the directory would bring back the files that the filter hides, + // so we spell out the ones it leaves. var paths []string - _ = node.ForEachFile(func(file *T) error { - // For a rename we need to pass both paths so that git detects it as - // a rename rather than an unrelated delete and add. - paths = append(paths, PT(file).Names()...) - return nil + forEachFileInDir[T, PT](root, dir, func(file PT) { + paths = append(paths, file.Names()...) }) return paths } - return []string{node.GetPath()} + // The directory covers everything below it, but git only pairs up the two + // ends of a rename if both are in the pathspec, and one end can well be + // outside the directory. Without that end we would get an addition or a + // deletion where the diff has a rename. + paths := []string{dir} + forEachFileInDir[T, PT](root, dir, func(file PT) { + if path := file.GetPath(); !isInDir(path, dir) { + paths = append(paths, path) + } + if previousPath := file.GetPreviousPath(); previousPath != "" && !isInDir(previousPath, dir) { + paths = append(paths, previousPath) + } + }) + return paths +} + +// forEachFileInDir calls cb for each file in the tree that the given directory +// contains, either at its current or at its previous path. +func forEachFileInDir[T any, PT fileWithNames[T]](root *filetree.Node[T], dir string, cb func(PT)) { + _ = root.ForEachFile(func(f *T) error { + file := PT(f) + previousPath := file.GetPreviousPath() + if isInDir(file.GetPath(), dir) || (previousPath != "" && isInDir(previousPath, dir)) { + cb(file) + } + return nil + }) +} + +func isInDir(path string, dir string) bool { + // "." is the root item, which contains every file + return dir == "." || strings.HasPrefix(path, dir+"/") } diff --git a/pkg/gui/controllers/diff_paths_test.go b/pkg/gui/controllers/diff_paths_test.go new file mode 100644 index 00000000000..ef095a29a32 --- /dev/null +++ b/pkg/gui/controllers/diff_paths_test.go @@ -0,0 +1,79 @@ +package controllers + +import ( + "testing" + + "github.com/jesseduffield/lazygit/pkg/commands/models" + "github.com/jesseduffield/lazygit/pkg/gui/filetree" + "github.com/samber/lo" + "github.com/stretchr/testify/assert" +) + +func TestPathsForDiff(t *testing.T) { + files := []*models.CommitFile{ + {Path: "dir/file1", PreviousPath: "file1", ChangeStatus: "R"}, + {Path: "dir/file2-renamed", PreviousPath: "dir/file2", ChangeStatus: "R"}, + {Path: "dir/sub/file3", ChangeStatus: "M"}, + {Path: "file4", PreviousPath: "dir/sub/file4", ChangeStatus: "R"}, + {Path: "file5", ChangeStatus: "M"}, + } + + scenarios := []struct { + testName string + selectedPath string + isFiltering bool + expectedPaths []string + }{ + { + testName: "file", + selectedPath: "dir/sub/file3", + expectedPaths: []string{"dir/sub/file3"}, + }, + { + testName: "renamed file", + selectedPath: "dir/file1", + expectedPaths: []string{"dir/file1", "file1"}, + }, + { + testName: "directory: pass the other end of each rename that crosses its boundary", + selectedPath: "dir", + // dir/file2-renamed was renamed within the directory, so both of its + // paths are covered by it already + expectedPaths: []string{"dir", "file1", "file4"}, + }, + { + testName: "directory without renames crossing its boundary", + selectedPath: "dir/sub", + expectedPaths: []string{"dir/sub", "file4"}, + }, + { + testName: "root", + selectedPath: ".", + expectedPaths: []string{"."}, + }, + { + testName: "directory while filtering", + selectedPath: "dir", + isFiltering: true, + expectedPaths: []string{ + "dir/file1", "file1", + "dir/file2-renamed", "dir/file2", + "dir/sub/file3", + "file4", "dir/sub/file4", + }, + }, + } + + for _, s := range scenarios { + t.Run(s.testName, func(t *testing.T) { + cmp := filetree.NodeSortComparator[models.CommitFile]("mixed", true) + root := filetree.BuildTreeFromCommitFiles(files, true, cmp) + node, found := lo.Find(root.Flatten(filetree.NewCollapsedPaths()), func(node *filetree.Node[models.CommitFile]) bool { + return node.GetPath() == s.selectedPath + }) + assert.True(t, found, "no node for path %s", s.selectedPath) + + assert.Equal(t, s.expectedPaths, pathsForDiff(node, root, s.isFiltering)) + }) + } +} diff --git a/pkg/gui/controllers/files_controller.go b/pkg/gui/controllers/files_controller.go index 985a37613e7..a70358d3ffb 100644 --- a/pkg/gui/controllers/files_controller.go +++ b/pkg/gui/controllers/files_controller.go @@ -368,7 +368,7 @@ func (self *FilesController) renderWorkingTreeDiff(node *filetree.FileNode) { split := self.c.UserConfig().Gui.SplitDiff == "always" || (node.GetHasUnstagedChanges() && node.GetHasStagedChanges()) mainShowsStaged := !split && node.GetHasStagedChanges() - paths := pathsForDiff(node.Raw(), self.context().IsFiltering()) + paths := pathsForDiff(node.Raw(), self.context().GetRoot().Raw(), self.context().IsFiltering()) cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, false, mainShowsStaged, paths) title := self.c.Tr.UnstagedChanges if mainShowsStaged { diff --git a/pkg/integration/tests/commit/directory_diff_with_renamed_files.go b/pkg/integration/tests/commit/directory_diff_with_renamed_files.go index ef3c71e5e58..f849056f78c 100644 --- a/pkg/integration/tests/commit/directory_diff_with_renamed_files.go +++ b/pkg/integration/tests/commit/directory_diff_with_renamed_files.go @@ -61,7 +61,6 @@ var DirectoryDiffWithRenamedFiles = NewIntegrationTest(NewIntegrationTestArgs{ SelectedLine(Equals(" ▼ dir")) t.Views().Main(). - /* EXPECTED: ContainsLines( Equals("diff --git a/file1 b/dir/file1"), Equals("similarity index 100%"), @@ -76,27 +75,6 @@ var DirectoryDiffWithRenamedFiles = NewIntegrationTest(NewIntegrationTestArgs{ Equals("rename from dir/nested/file3"), Equals("rename to file3"), ) - ACTUAL: */ - ContainsLines( - Equals("diff --git a/dir/file1 b/dir/file1"), - Equals("new file mode 100644"), - Contains("index"), - Equals("--- /dev/null"), - Equals("+++ b/dir/file1"), - Equals("@@ -0,0 +1 @@"), - Equals("+file1 content"), - Equals("diff --git a/dir/file2 b/dir/file2-renamed"), - Equals("similarity index 100%"), - Equals("rename from dir/file2"), - Equals("rename to dir/file2-renamed"), - Equals("diff --git a/dir/nested/file3 b/dir/nested/file3"), - Equals("deleted file mode 100644"), - Contains("index"), - Equals("--- a/dir/nested/file3"), - Equals("+++ /dev/null"), - Equals("@@ -1 +0,0 @@"), - Equals("-file3 content"), - ) t.Views().CommitFiles(). SelectNextItem(). diff --git a/pkg/integration/tests/file/directory_diff_with_renamed_files.go b/pkg/integration/tests/file/directory_diff_with_renamed_files.go index 101f569505d..18906bf034e 100644 --- a/pkg/integration/tests/file/directory_diff_with_renamed_files.go +++ b/pkg/integration/tests/file/directory_diff_with_renamed_files.go @@ -52,7 +52,6 @@ var DirectoryDiffWithRenamedFiles = NewIntegrationTest(NewIntegrationTestArgs{ SelectedLine(Equals(" ▼ dir")) t.Views().Main(). - /* EXPECTED: ContainsLines( Equals("diff --git a/file1 b/dir/file1"), Equals("similarity index 100%"), @@ -67,27 +66,6 @@ var DirectoryDiffWithRenamedFiles = NewIntegrationTest(NewIntegrationTestArgs{ Equals("rename from dir/nested/file3"), Equals("rename to file3"), ) - ACTUAL: */ - ContainsLines( - Equals("diff --git a/dir/file1 b/dir/file1"), - Equals("new file mode 100644"), - Contains("index"), - Equals("--- /dev/null"), - Equals("+++ b/dir/file1"), - Equals("@@ -0,0 +1 @@"), - Equals("+file1 content"), - Equals("diff --git a/dir/file2 b/dir/file2-renamed"), - Equals("similarity index 100%"), - Equals("rename from dir/file2"), - Equals("rename to dir/file2-renamed"), - Equals("diff --git a/dir/nested/file3 b/dir/nested/file3"), - Equals("deleted file mode 100644"), - Contains("index"), - Equals("--- a/dir/nested/file3"), - Equals("+++ /dev/null"), - Equals("@@ -1 +0,0 @@"), - Equals("-file3 content"), - ) // The same applies when a filter reduces the directory to a single file t.Views().Files(). From e12e79394881cfd6ded6e81ebf2d67c8ddff3fb2 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 12 Aug 2026 11:02:55 +0200 Subject: [PATCH 5/5] Collapse the paths of a moved directory into the directory itself A commit that moves an entire package elsewhere renames hundreds of files, and passing every one of their old paths can push the command past the length limit the OS imposes (~32k characters on Windows). Their common parent directory does just as well whenever everything it holds ends up in the diff anyway. Deciding that needs to consider every file of the diff, not only those on display, so the paths are now derived from the model rather than from the tree; a status filter must not make a directory look emptier than it is. --- .../controllers/commits_files_controller.go | 10 +- pkg/gui/controllers/diff_paths.go | 107 ++++++++++++++---- pkg/gui/controllers/diff_paths_test.go | 38 ++++++- pkg/gui/controllers/files_controller.go | 7 +- 4 files changed, 133 insertions(+), 29 deletions(-) diff --git a/pkg/gui/controllers/commits_files_controller.go b/pkg/gui/controllers/commits_files_controller.go index 690114dbbe2..4aa46a28c09 100644 --- a/pkg/gui/controllers/commits_files_controller.go +++ b/pkg/gui/controllers/commits_files_controller.go @@ -175,7 +175,7 @@ func (self *CommitFilesController) GetOnRenderToMain() func() { from, to := self.context().GetFromAndToForDiff() from, reverse := self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(from) - paths := pathsForDiff(node.Raw(), self.context().GetRoot().Raw(), self.context().IsFiltering()) + paths := self.pathsForDiff(node) cmdObj := self.c.Git().WorkingTree.ShowFileDiffCmdObj(from, to, reverse, paths, false) task := types.NewRunPtyTask(cmdObj.GetCmd()) @@ -263,8 +263,7 @@ func (self *CommitFilesController) openCopyMenu() error { copyFileDiffItem := &types.MenuItem{ Label: self.c.Tr.CopySelectedDiff, OnPress: func() error { - paths := pathsForDiff(node.Raw(), self.context().GetRoot().Raw(), self.context().IsFiltering()) - return self.copyDiffToClipboard(paths, self.c.Tr.FileDiffCopiedToast) + return self.copyDiffToClipboard(self.pathsForDiff(node), self.c.Tr.FileDiffCopiedToast) }, DisabledReason: self.require(self.singleItemSelected())(), Keys: menuKey('s'), @@ -617,6 +616,11 @@ func (self *CommitFilesController) GetOnClickFocusedMainView() func(mainViewName } } +func (self *CommitFilesController) pathsForDiff(node *filetree.CommitFileNode) []string { + return diffPathsForNode( + node.Raw(), self.context().GetRoot().Raw(), self.c.Model().CommitFiles, self.context().IsFiltering()) +} + // NOTE: these functions are identical to those in files_controller.go (except for types) and // could also be cleaned up with some generics func normalisedSelectedCommitFileNodes(selectedNodes []*filetree.CommitFileNode) []*filetree.CommitFileNode { diff --git a/pkg/gui/controllers/diff_paths.go b/pkg/gui/controllers/diff_paths.go index 4cb34f3cc0f..e9c12606ff2 100644 --- a/pkg/gui/controllers/diff_paths.go +++ b/pkg/gui/controllers/diff_paths.go @@ -1,9 +1,11 @@ package controllers import ( + "path" "strings" "github.com/jesseduffield/lazygit/pkg/gui/filetree" + "github.com/samber/lo" ) // Both models.File and models.CommitFile satisfy this. Names returns the file's @@ -15,11 +17,11 @@ type fileWithNames[T any] interface { Names() []string } -// pathsForDiff returns the paths to limit a diff command to for showing the -// changes of the given node. root is the root of the tree that the node belongs -// to, and isFiltering says whether that tree is reduced to the files matching a -// text filter. -func pathsForDiff[T any, PT fileWithNames[T]](node *filetree.Node[T], root *filetree.Node[T], isFiltering bool) []string { +// diffPathsForNode returns the paths to limit a diff command to for showing the +// changes of the given node. files are all the files that the diff contains, +// while root is the root of the tree the node belongs to, which holds only the +// files matching the text filter when there is one. +func diffPathsForNode[T any, PT fileWithNames[T]](node *filetree.Node[T], root *filetree.Node[T], files []*T, isFiltering bool) []string { if file := node.GetFile(); file != nil { return PT(file).Names() } @@ -30,9 +32,9 @@ func pathsForDiff[T any, PT fileWithNames[T]](node *filetree.Node[T], root *file // Passing the directory would bring back the files that the filter hides, // so we spell out the ones it leaves. var paths []string - forEachFileInDir[T, PT](root, dir, func(file PT) { - paths = append(paths, file.Names()...) - }) + for _, file := range filesInDir[T, PT](filesInTree(root), dir) { + paths = append(paths, PT(file).Names()...) + } return paths } @@ -40,29 +42,88 @@ func pathsForDiff[T any, PT fileWithNames[T]](node *filetree.Node[T], root *file // ends of a rename if both are in the pathspec, and one end can well be // outside the directory. Without that end we would get an addition or a // deletion where the diff has a rename. - paths := []string{dir} - forEachFileInDir[T, PT](root, dir, func(file PT) { - if path := file.GetPath(); !isInDir(path, dir) { - paths = append(paths, path) + var outsidePaths []string + for _, f := range filesInDir[T, PT](files, dir) { + file := PT(f) + if p := file.GetPath(); !isInDir(p, dir) { + outsidePaths = append(outsidePaths, p) } - if previousPath := file.GetPreviousPath(); previousPath != "" && !isInDir(previousPath, dir) { - paths = append(paths, previousPath) + if p := file.GetPreviousPath(); p != "" && !isInDir(p, dir) { + outsidePaths = append(outsidePaths, p) } + } + + return dropContainedPaths(append([]string{dir}, collapseToDirs[T, PT](outsidePaths, files, dir)...)) +} + +// dropContainedPaths removes the paths that another one of them contains, since +// a pathspec that matches a directory matches everything below it anyway. +func dropContainedPaths(paths []string) []string { + return lo.Filter(paths, func(p string, _ int) bool { + return !lo.SomeBy(paths, func(other string) bool { + return other != p && isInDir(p, other) + }) }) - return paths } -// forEachFileInDir calls cb for each file in the tree that the given directory -// contains, either at its current or at its previous path. -func forEachFileInDir[T any, PT fileWithNames[T]](root *filetree.Node[T], dir string, cb func(PT)) { - _ = root.ForEachFile(func(f *T) error { - file := PT(f) - previousPath := file.GetPreviousPath() - if isInDir(file.GetPath(), dir) || (previousPath != "" && isInDir(previousPath, dir)) { - cb(file) +// collapseToDirs replaces each of the given paths with the highest directory +// that can stand in for it, so that moving a whole directory elsewhere costs a +// single pathspec rather than one per file. There is a limit to how long a +// command line may get, and a commit can move a great many files at once. +func collapseToDirs[T any, PT fileWithNames[T]](paths []string, files []*T, dir string) []string { + if len(paths) == 0 { + return nil + } + + // A directory can stand in for the paths under it as long as everything it + // contains ends up in the diff anyway, which is to say as long as all of it + // is in the directory we are diffing too. + canStandIn := make(map[string]bool) + standsIn := func(candidate string) bool { + if result, ok := canStandIn[candidate]; ok { + return result } + + result := lo.EveryBy(files, func(file *T) bool { + return !fileIsInDir[T, PT](file, candidate) || fileIsInDir[T, PT](file, dir) + }) + canStandIn[candidate] = result + return result + } + + return lo.Uniq(lo.Map(paths, func(p string, _ int) string { + // A directory that can't stand in for the path rules out its parents + // too, since they contain everything it contains. We stop short of the + // repository root: it would leave the command with nothing to say about + // the directory whose diff we are showing. + for candidate := path.Dir(p); candidate != "." && standsIn(candidate); candidate = path.Dir(candidate) { + p = candidate + } + return p + })) +} + +func filesInTree[T any](root *filetree.Node[T]) []*T { + files := []*T{} + _ = root.ForEachFile(func(file *T) error { + files = append(files, file) return nil }) + return files +} + +// filesInDir returns the files that the given directory contains, either at +// their current or at their previous path. +func filesInDir[T any, PT fileWithNames[T]](files []*T, dir string) []*T { + return lo.Filter(files, func(file *T, _ int) bool { + return fileIsInDir[T, PT](file, dir) + }) +} + +func fileIsInDir[T any, PT fileWithNames[T]](f *T, dir string) bool { + file := PT(f) + previousPath := file.GetPreviousPath() + return isInDir(file.GetPath(), dir) || (previousPath != "" && isInDir(previousPath, dir)) } func isInDir(path string, dir string) bool { diff --git a/pkg/gui/controllers/diff_paths_test.go b/pkg/gui/controllers/diff_paths_test.go index ef095a29a32..549ed4f3186 100644 --- a/pkg/gui/controllers/diff_paths_test.go +++ b/pkg/gui/controllers/diff_paths_test.go @@ -9,7 +9,7 @@ import ( "github.com/stretchr/testify/assert" ) -func TestPathsForDiff(t *testing.T) { +func TestDiffPathsForNode(t *testing.T) { files := []*models.CommitFile{ {Path: "dir/file1", PreviousPath: "file1", ChangeStatus: "R"}, {Path: "dir/file2-renamed", PreviousPath: "dir/file2", ChangeStatus: "R"}, @@ -20,6 +20,7 @@ func TestPathsForDiff(t *testing.T) { scenarios := []struct { testName string + files []*models.CommitFile // defaults to the files above selectedPath string isFiltering bool expectedPaths []string @@ -51,6 +52,38 @@ func TestPathsForDiff(t *testing.T) { selectedPath: ".", expectedPaths: []string{"."}, }, + { + testName: "a whole directory moved into the selected one collapses to that directory", + files: []*models.CommitFile{ + {Path: "dir/a", PreviousPath: "src/a", ChangeStatus: "R"}, + {Path: "dir/b", PreviousPath: "src/nested/b", ChangeStatus: "R"}, + {Path: "dir/c", PreviousPath: "src/nested/c", ChangeStatus: "R"}, + {Path: "unrelated", ChangeStatus: "M"}, + }, + selectedPath: "dir", + expectedPaths: []string{"dir", "src"}, + }, + { + testName: "a directory that stands in for the selected one as well", + files: []*models.CommitFile{ + {Path: "a/b/c", PreviousPath: "a/c", ChangeStatus: "R"}, + {Path: "a/b/d", ChangeStatus: "M"}, + {Path: "unrelated", ChangeStatus: "M"}, + }, + selectedPath: "a/b", + expectedPaths: []string{"a"}, + }, + { + testName: "a directory with changes of its own doesn't collapse", + files: []*models.CommitFile{ + {Path: "dir/a", PreviousPath: "src/a", ChangeStatus: "R"}, + {Path: "dir/b", PreviousPath: "src/nested/b", ChangeStatus: "R"}, + {Path: "src/nested/c", ChangeStatus: "M"}, + }, + selectedPath: "dir", + // src/nested is left out of it, so that only src/a stays behind + expectedPaths: []string{"dir", "src/a", "src/nested/b"}, + }, { testName: "directory while filtering", selectedPath: "dir", @@ -66,6 +99,7 @@ func TestPathsForDiff(t *testing.T) { for _, s := range scenarios { t.Run(s.testName, func(t *testing.T) { + files := lo.Ternary(s.files != nil, s.files, files) cmp := filetree.NodeSortComparator[models.CommitFile]("mixed", true) root := filetree.BuildTreeFromCommitFiles(files, true, cmp) node, found := lo.Find(root.Flatten(filetree.NewCollapsedPaths()), func(node *filetree.Node[models.CommitFile]) bool { @@ -73,7 +107,7 @@ func TestPathsForDiff(t *testing.T) { }) assert.True(t, found, "no node for path %s", s.selectedPath) - assert.Equal(t, s.expectedPaths, pathsForDiff(node, root, s.isFiltering)) + assert.Equal(t, s.expectedPaths, diffPathsForNode(node, root, files, s.isFiltering)) }) } } diff --git a/pkg/gui/controllers/files_controller.go b/pkg/gui/controllers/files_controller.go index a70358d3ffb..1ecbd37371d 100644 --- a/pkg/gui/controllers/files_controller.go +++ b/pkg/gui/controllers/files_controller.go @@ -368,7 +368,7 @@ func (self *FilesController) renderWorkingTreeDiff(node *filetree.FileNode) { split := self.c.UserConfig().Gui.SplitDiff == "always" || (node.GetHasUnstagedChanges() && node.GetHasStagedChanges()) mainShowsStaged := !split && node.GetHasStagedChanges() - paths := pathsForDiff(node.Raw(), self.context().GetRoot().Raw(), self.context().IsFiltering()) + paths := self.pathsForDiff(node) cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, false, mainShowsStaged, paths) title := self.c.Tr.UnstagedChanges if mainShowsStaged { @@ -642,6 +642,11 @@ func (self *FilesController) press(nodes []*filetree.FileNode) error { return nil } +func (self *FilesController) pathsForDiff(node *filetree.FileNode) []string { + return diffPathsForNode( + node.Raw(), self.context().GetRoot().Raw(), self.c.Model().Files, self.context().IsFiltering()) +} + // unstageFilteredFiles unstages only the visible (filtered) files from the // given nodes, correctly partitioning by tracked/untracked. func (self *FilesController) unstageFilteredFiles(nodes []*filetree.FileNode) error {