mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-11-15 09:12:05 -04:00
When we stage, discard or edit lines of the working tree's diff, we read the file's diff again and look for the selected lines in it. If none of them are there, nothing happens. The same goes for adding lines of a commit's diff to a custom patch, or taking lines out of it. This can happen if the file changed since its diff was shown, or if we read the line numbers out of the diff on screen wrongly. Show an error that names the file instead. As with a file that can't be found, don't act on any file if the lines of one are missing. Unlike the file check, this one happens after a new custom patch has been started. If the error leaves that patch without any lines, reset it, as we do when the last line is taken out of a patch. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
166 lines
5.8 KiB
Go
166 lines
5.8 KiB
Go
package controllers
|
|
|
|
import (
|
|
"errors"
|
|
"path"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/jesseduffield/lazygit/pkg/gui/filetree"
|
|
"github.com/jesseduffield/lazygit/pkg/utils"
|
|
"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+"/")
|
|
}
|
|
|
|
// diffLinesFileNotFoundError reports that the file selected diff lines belong to can't
|
|
// be found. It names the file by its path in the repo, or by the path the diff line
|
|
// carries for one outside of it.
|
|
func diffLinesFileNotFoundError(c *ControllerCommon, absolutePath string) error {
|
|
path := repoRelativePath(c.Git().RepoPaths.WorktreePath(), absolutePath)
|
|
if path == "" {
|
|
path = absolutePath
|
|
}
|
|
return errors.New(utils.ResolvePlaceholderString(c.Tr.DiffLinesFileNotFound,
|
|
map[string]string{"path": path}))
|
|
}
|
|
|
|
// diffLinesNotFoundError reports that selected diff lines can't be found in the diff of
|
|
// the file at the given path in the repo.
|
|
func diffLinesNotFoundError(c *ControllerCommon, path string) error {
|
|
return errors.New(utils.ResolvePlaceholderString(c.Tr.DiffLinesNotFound,
|
|
map[string]string{"path": path}))
|
|
}
|
|
|
|
// 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)
|
|
}
|