jesseduffield.lazygit/pkg/gui/controllers/diff_paths.go
2026-09-14 13:50:03 +02:00

145 lines
5 KiB
Go

package controllers
import (
"path"
"path/filepath"
"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
// 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
}
// 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()
}
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
for _, file := range filesInDir[T, PT](filesInTree(root), dir) {
paths = append(paths, PT(file).Names()...)
}
return paths
}
// 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.
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 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)
})
})
}
// 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 {
// "." is the root item, which contains every file
return dir == "." || strings.HasPrefix(path, dir+"/")
}
// repoRelativePath turns the absolute path a diff line carries into the one git speaks
// of the file: relative to the worktree, with forward slashes. It is "" for a path that
// is no file of this repo, which a diff renderer's own naming of a line can produce.
func repoRelativePath(worktreePath string, absolutePath string) string {
relativePath, err := filepath.Rel(worktreePath, absolutePath)
if err != nil || strings.HasPrefix(relativePath, "..") {
return ""
}
return filepath.ToSlash(relativePath)
}