|
| 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 | +} |
0 commit comments