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.
This commit is contained in:
Stefan Haller
2026-08-17 09:32:30 +02:00
parent 40cb4bb24d
commit bee03d3b98
6 changed files with 128 additions and 57 deletions
@@ -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())(),
+46 -10
View File
@@ -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+"/")
}
+79
View File
@@ -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))
})
}
}
+1 -1
View File
@@ -369,7 +369,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 {
@@ -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().
@@ -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().