Skip to content

Commit ea91639

Browse files
authored
Show renames when selecting a directory that a file was moved into or out of (#5924)
In a commit that moves a bunch of files from one directory to another, showing the commit's files and selecting the target directory of those moves would show these files as newly added rather than moved in the main view's diff. Selecting the source directory would show them as removed. Fix this to keep showing them as moved in both cases. The same applies to the files panel when staging the move of a file, and when filtering the file list down to just the source or target directory using the `/` filter in either panel. The decision to show them as renames when selecting the "moved-from" directory was not an easy one; it's slightly weird because the list of files in the side panel doesn't show them there (they appear in the target directory), but the main view does. An alternative would have been not to show them in that case, to match the side panel. However, the point of selecting a directory is to see all the changes that affect it, and the moved-out files are relevant changes you want to see there. See #4899 (reply in thread).
2 parents c199ac6 + d740155 commit ea91639

9 files changed

Lines changed: 438 additions & 46 deletions

File tree

pkg/commands/git_commands/working_tree.go

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -385,27 +385,22 @@ func (self *WorkingTreeCommands) Exclude(filename string) error {
385385
// WorktreeFileDiff returns the diff of a file
386386
func (self *WorkingTreeCommands) WorktreeFileDiff(file *models.File, plain bool, cached bool) string {
387387
// for now we assume an error means the file was deleted
388-
s, _ := self.WorktreeFileDiffCmdObj(file, plain, cached, nil).RunWithOutput()
388+
s, _ := self.WorktreeFileDiffCmdObj(file, plain, cached, file.Names()).RunWithOutput()
389389
return s
390390
}
391391

392-
// WorktreeFileDiffCmdObj returns a command object for diffing a file or directory
393-
// in the working tree. When pathOverrides is non-empty, those paths are used instead of
394-
// the node's path (used to diff only filtered/visible files within a directory).
395-
func (self *WorkingTreeCommands) WorktreeFileDiffCmdObj(node models.IFile, plain bool, cached bool, pathOverrides []string) *oscommands.CmdObj {
392+
// WorktreeFileDiffCmdObj returns a command object for diffing the given paths
393+
// in the working tree. node is the item they belong to; all it decides is
394+
// whether git has to compare against /dev/null, which is the case for a file
395+
// that isn't in the index yet.
396+
func (self *WorkingTreeCommands) WorktreeFileDiffCmdObj(node models.IFile, plain bool, cached bool, paths []string) *oscommands.CmdObj {
396397
colorArg := self.diffRendererConfigManager.GetColorArg()
397398
if plain {
398399
colorArg = "never"
399400
}
400401

401-
prevPath := node.GetPreviousPath()
402402
noIndex := !node.GetIsTracked() && !node.GetHasStagedChanges() && !cached && node.GetIsFile()
403403

404-
paths := pathOverrides
405-
if len(paths) == 0 {
406-
paths = []string{node.GetPath()}
407-
}
408-
409404
cmdArgs := NewGitCmd("diff").
410405
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), !plain).
411406
Arg("--submodule").
@@ -415,7 +410,6 @@ func (self *WorkingTreeCommands) WorktreeFileDiffCmdObj(node models.IFile, plain
415410
Arg("--").
416411
ArgIf(noIndex, "/dev/null").
417412
Arg(paths...).
418-
ArgIf(prevPath != "", prevPath).
419413
Dir(self.repoPaths.worktreePath).
420414
ToArgv()
421415

pkg/gui/controllers/commits_files_controller.go

Lines changed: 2 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -616,24 +616,9 @@ func (self *CommitFilesController) GetOnClickFocusedMainView() func(mainViewName
616616
}
617617
}
618618

619-
// pathsForDiff returns the file paths to use for a diff command. When a text
620-
// filter is active and the node is a directory, only the visible (filtered)
621-
// file paths are returned so the diff reflects what the user sees.
622619
func (self *CommitFilesController) pathsForDiff(node *filetree.CommitFileNode) []string {
623-
if !node.IsFile() && self.context().IsFiltering() {
624-
var paths []string
625-
_ = node.ForEachFile(func(file *models.CommitFile) error {
626-
// For a rename we need to pass both paths so that git detects it as
627-
// a rename rather than an unrelated delete and add.
628-
paths = append(paths, file.Names()...)
629-
return nil
630-
})
631-
return paths
632-
}
633-
if file := node.GetFile(); file != nil {
634-
return file.Names()
635-
}
636-
return []string{node.GetPath()}
620+
return diffPathsForNode(
621+
node.Raw(), self.context().GetRoot().Raw(), self.c.Model().CommitFiles, self.context().IsFiltering())
637622
}
638623

639624
// NOTE: these functions are identical to those in files_controller.go (except for types) and

pkg/gui/controllers/diff_paths.go

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
package controllers
2+
3+
import (
4+
"path"
5+
"strings"
6+
7+
"github.com/jesseduffield/lazygit/pkg/gui/filetree"
8+
"github.com/samber/lo"
9+
)
10+
11+
// Both models.File and models.CommitFile satisfy this. Names returns the file's
12+
// path, plus the path it was renamed from if it is a rename.
13+
type fileWithNames[T any] interface {
14+
*T
15+
GetPath() string
16+
GetPreviousPath() string
17+
Names() []string
18+
}
19+
20+
// diffPathsForNode returns the paths to limit a diff command to for showing the
21+
// changes of the given node. files are all the files that the diff contains,
22+
// while root is the root of the tree the node belongs to, which holds only the
23+
// files matching the text filter when there is one.
24+
func diffPathsForNode[T any, PT fileWithNames[T]](node *filetree.Node[T], root *filetree.Node[T], files []*T, isFiltering bool) []string {
25+
if file := node.GetFile(); file != nil {
26+
return PT(file).Names()
27+
}
28+
29+
dir := node.GetPath()
30+
31+
if isFiltering {
32+
// Passing the directory would bring back the files that the filter hides,
33+
// so we spell out the ones it leaves.
34+
var paths []string
35+
for _, file := range filesInDir[T, PT](filesInTree(root), dir) {
36+
paths = append(paths, PT(file).Names()...)
37+
}
38+
return paths
39+
}
40+
41+
// The directory covers everything below it, but git only pairs up the two
42+
// ends of a rename if both are in the pathspec, and one end can well be
43+
// outside the directory. Without that end we would get an addition or a
44+
// deletion where the diff has a rename.
45+
var outsidePaths []string
46+
for _, f := range filesInDir[T, PT](files, dir) {
47+
file := PT(f)
48+
if p := file.GetPath(); !isInDir(p, dir) {
49+
outsidePaths = append(outsidePaths, p)
50+
}
51+
if p := file.GetPreviousPath(); p != "" && !isInDir(p, dir) {
52+
outsidePaths = append(outsidePaths, p)
53+
}
54+
}
55+
56+
return dropContainedPaths(append([]string{dir}, collapseToDirs[T, PT](outsidePaths, files, dir)...))
57+
}
58+
59+
// dropContainedPaths removes the paths that another one of them contains, since
60+
// a pathspec that matches a directory matches everything below it anyway.
61+
func dropContainedPaths(paths []string) []string {
62+
return lo.Filter(paths, func(p string, _ int) bool {
63+
return !lo.SomeBy(paths, func(other string) bool {
64+
return other != p && isInDir(p, other)
65+
})
66+
})
67+
}
68+
69+
// collapseToDirs replaces each of the given paths with the highest directory
70+
// that can stand in for it, so that moving a whole directory elsewhere costs a
71+
// single pathspec rather than one per file. There is a limit to how long a
72+
// command line may get, and a commit can move a great many files at once.
73+
func collapseToDirs[T any, PT fileWithNames[T]](paths []string, files []*T, dir string) []string {
74+
if len(paths) == 0 {
75+
return nil
76+
}
77+
78+
// A directory can stand in for the paths under it as long as everything it
79+
// contains ends up in the diff anyway, which is to say as long as all of it
80+
// is in the directory we are diffing too.
81+
canStandIn := make(map[string]bool)
82+
standsIn := func(candidate string) bool {
83+
if result, ok := canStandIn[candidate]; ok {
84+
return result
85+
}
86+
87+
result := lo.EveryBy(files, func(file *T) bool {
88+
return !fileIsInDir[T, PT](file, candidate) || fileIsInDir[T, PT](file, dir)
89+
})
90+
canStandIn[candidate] = result
91+
return result
92+
}
93+
94+
return lo.Uniq(lo.Map(paths, func(p string, _ int) string {
95+
// A directory that can't stand in for the path rules out its parents
96+
// too, since they contain everything it contains. We stop short of the
97+
// repository root: it would leave the command with nothing to say about
98+
// the directory whose diff we are showing.
99+
for candidate := path.Dir(p); candidate != "." && standsIn(candidate); candidate = path.Dir(candidate) {
100+
p = candidate
101+
}
102+
return p
103+
}))
104+
}
105+
106+
func filesInTree[T any](root *filetree.Node[T]) []*T {
107+
files := []*T{}
108+
_ = root.ForEachFile(func(file *T) error {
109+
files = append(files, file)
110+
return nil
111+
})
112+
return files
113+
}
114+
115+
// filesInDir returns the files that the given directory contains, either at
116+
// their current or at their previous path.
117+
func filesInDir[T any, PT fileWithNames[T]](files []*T, dir string) []*T {
118+
return lo.Filter(files, func(file *T, _ int) bool {
119+
return fileIsInDir[T, PT](file, dir)
120+
})
121+
}
122+
123+
func fileIsInDir[T any, PT fileWithNames[T]](f *T, dir string) bool {
124+
file := PT(f)
125+
previousPath := file.GetPreviousPath()
126+
return isInDir(file.GetPath(), dir) || (previousPath != "" && isInDir(previousPath, dir))
127+
}
128+
129+
func isInDir(path string, dir string) bool {
130+
// "." is the root item, which contains every file
131+
return dir == "." || strings.HasPrefix(path, dir+"/")
132+
}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
package controllers
2+
3+
import (
4+
"testing"
5+
6+
"github.com/jesseduffield/lazygit/pkg/commands/models"
7+
"github.com/jesseduffield/lazygit/pkg/gui/filetree"
8+
"github.com/samber/lo"
9+
"github.com/stretchr/testify/assert"
10+
)
11+
12+
func TestDiffPathsForNode(t *testing.T) {
13+
files := []*models.CommitFile{
14+
{Path: "dir/file1", PreviousPath: "file1", ChangeStatus: "R"},
15+
{Path: "dir/file2-renamed", PreviousPath: "dir/file2", ChangeStatus: "R"},
16+
{Path: "dir/sub/file3", ChangeStatus: "M"},
17+
{Path: "file4", PreviousPath: "dir/sub/file4", ChangeStatus: "R"},
18+
{Path: "file5", ChangeStatus: "M"},
19+
}
20+
21+
scenarios := []struct {
22+
testName string
23+
files []*models.CommitFile // defaults to the files above
24+
selectedPath string
25+
isFiltering bool
26+
expectedPaths []string
27+
}{
28+
{
29+
testName: "file",
30+
selectedPath: "dir/sub/file3",
31+
expectedPaths: []string{"dir/sub/file3"},
32+
},
33+
{
34+
testName: "renamed file",
35+
selectedPath: "dir/file1",
36+
expectedPaths: []string{"dir/file1", "file1"},
37+
},
38+
{
39+
testName: "directory: pass the other end of each rename that crosses its boundary",
40+
selectedPath: "dir",
41+
// dir/file2-renamed was renamed within the directory, so both of its
42+
// paths are covered by it already
43+
expectedPaths: []string{"dir", "file1", "file4"},
44+
},
45+
{
46+
testName: "directory without renames crossing its boundary",
47+
selectedPath: "dir/sub",
48+
expectedPaths: []string{"dir/sub", "file4"},
49+
},
50+
{
51+
testName: "root",
52+
selectedPath: ".",
53+
expectedPaths: []string{"."},
54+
},
55+
{
56+
testName: "a whole directory moved into the selected one collapses to that directory",
57+
files: []*models.CommitFile{
58+
{Path: "dir/a", PreviousPath: "src/a", ChangeStatus: "R"},
59+
{Path: "dir/b", PreviousPath: "src/nested/b", ChangeStatus: "R"},
60+
{Path: "dir/c", PreviousPath: "src/nested/c", ChangeStatus: "R"},
61+
{Path: "unrelated", ChangeStatus: "M"},
62+
},
63+
selectedPath: "dir",
64+
expectedPaths: []string{"dir", "src"},
65+
},
66+
{
67+
testName: "a directory that stands in for the selected one as well",
68+
files: []*models.CommitFile{
69+
{Path: "a/b/c", PreviousPath: "a/c", ChangeStatus: "R"},
70+
{Path: "a/b/d", ChangeStatus: "M"},
71+
{Path: "unrelated", ChangeStatus: "M"},
72+
},
73+
selectedPath: "a/b",
74+
expectedPaths: []string{"a"},
75+
},
76+
{
77+
testName: "a directory with changes of its own doesn't collapse",
78+
files: []*models.CommitFile{
79+
{Path: "dir/a", PreviousPath: "src/a", ChangeStatus: "R"},
80+
{Path: "dir/b", PreviousPath: "src/nested/b", ChangeStatus: "R"},
81+
{Path: "src/nested/c", ChangeStatus: "M"},
82+
},
83+
selectedPath: "dir",
84+
// src/nested is left out of it, so that only src/a stays behind
85+
expectedPaths: []string{"dir", "src/a", "src/nested/b"},
86+
},
87+
{
88+
testName: "directory while filtering",
89+
selectedPath: "dir",
90+
isFiltering: true,
91+
expectedPaths: []string{
92+
"dir/file1", "file1",
93+
"dir/file2-renamed", "dir/file2",
94+
"dir/sub/file3",
95+
"file4", "dir/sub/file4",
96+
},
97+
},
98+
}
99+
100+
for _, s := range scenarios {
101+
t.Run(s.testName, func(t *testing.T) {
102+
files := lo.Ternary(s.files != nil, s.files, files)
103+
cmp := filetree.NodeSortComparator[models.CommitFile]("mixed", true)
104+
root := filetree.BuildTreeFromCommitFiles(files, true, cmp)
105+
node, found := lo.Find(root.Flatten(filetree.NewCollapsedPaths()), func(node *filetree.Node[models.CommitFile]) bool {
106+
return node.GetPath() == s.selectedPath
107+
})
108+
assert.True(t, found, "no node for path %s", s.selectedPath)
109+
110+
assert.Equal(t, s.expectedPaths, diffPathsForNode(node, root, files, s.isFiltering))
111+
})
112+
}
113+
}

pkg/gui/controllers/files_controller.go

Lines changed: 6 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -369,8 +369,8 @@ func (self *FilesController) renderWorkingTreeDiff(node *filetree.FileNode) {
369369
split := self.c.UserConfig().Gui.SplitDiff == "always" || (node.GetHasUnstagedChanges() && node.GetHasStagedChanges())
370370
mainShowsStaged := !split && node.GetHasStagedChanges()
371371

372-
pathOverrides := self.pathOverridesForDiff(node)
373-
cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, false, mainShowsStaged, pathOverrides)
372+
paths := self.pathsForDiff(node)
373+
cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, false, mainShowsStaged, paths)
374374
title := self.c.Tr.UnstagedChanges
375375
if mainShowsStaged {
376376
title = self.c.Tr.StagedChanges
@@ -385,7 +385,7 @@ func (self *FilesController) renderWorkingTreeDiff(node *filetree.FileNode) {
385385
}
386386

387387
if split {
388-
cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, false, true, pathOverrides)
388+
cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, false, true, paths)
389389

390390
title := self.c.Tr.StagedChanges
391391
if mainShowsStaged {
@@ -643,19 +643,9 @@ func (self *FilesController) press(nodes []*filetree.FileNode) error {
643643
return nil
644644
}
645645

646-
// pathOverridesForDiff returns file paths to override the node's path in diff
647-
// commands when a text filter is active and the node is a directory. This
648-
// ensures the diff only shows filtered/visible files.
649-
func (self *FilesController) pathOverridesForDiff(node *filetree.FileNode) []string {
650-
if !node.IsFile() && self.context().IsFiltering() {
651-
var paths []string
652-
_ = node.ForEachFile(func(file *models.File) error {
653-
paths = append(paths, file.Path)
654-
return nil
655-
})
656-
return paths
657-
}
658-
return nil
646+
func (self *FilesController) pathsForDiff(node *filetree.FileNode) []string {
647+
return diffPathsForNode(
648+
node.Raw(), self.context().GetRoot().Raw(), self.c.Model().Files, self.context().IsFiltering())
659649
}
660650

661651
// unstageFilteredFiles unstages only the visible (filtered) files from the

pkg/gui/controllers/submodules_controller.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ func (self *SubmodulesController) GetOnRenderToMain() func() {
123123
if file == nil {
124124
task = types.NewRenderStringTask(prefix)
125125
} else {
126-
cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(file, false, !file.HasUnstagedChanges && file.HasStagedChanges, nil)
126+
cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(file, false, !file.HasUnstagedChanges && file.HasStagedChanges, file.Names())
127127
task = types.NewRunCommandTaskWithPrefix(cmdObj.GetCmd(), prefix)
128128
}
129129
}

0 commit comments

Comments
 (0)