Show renamed files in the custom patch builder (#5759)

When showing the files of a commit, we used to display renamed files as
a pair of added and deleted files, rather than a single `R` entry. This
is inconvenient when just browsing the commit's files because you can't
see if the rename also has modifications; but it becomes a real problem
when trying to work with the renamed file in a custom patch if it also
had modifications; there was no way to discard them, for example.

The Files panel already shows renamed files as `R` and allows you to
stage/unstage/discard hunks in them, so there's no reason why the patch
building panel shouldn't allow the same; and this PR adds this.

To drop just the modifications of a rename, add the individual hunks to
the custom patch (the side panel shows a `◐` icon); reverting the patch
then only drops the modifications but not the rename. To also drop the
rename, add the entire `R` file to the custom patch from the side panel
(it gets a `●` icon).
This commit is contained in:
Stefan Haller 2026-07-04 13:07:55 +02:00 committed by GitHub
commit 24c6d38983
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 642 additions and 75 deletions

View file

@ -117,8 +117,8 @@ func NewGitCommandAux(
rebaseCommands := git_commands.NewRebaseCommands(gitCommon, commitCommands, workingTreeCommands)
stashCommands := git_commands.NewStashCommands(gitCommon, fileLoader, workingTreeCommands)
patchBuilder := patch.NewPatchBuilder(cmn.Log,
func(from string, to string, reverse bool, filename string, plain bool) (string, error) {
return workingTreeCommands.ShowFileDiff(from, to, reverse, filename, plain)
func(from string, to string, reverse bool, filename string, previousPath string, plain bool) (string, error) {
return workingTreeCommands.ShowFileDiff(from, to, reverse, filename, previousPath, plain)
})
patchCommands := git_commands.NewPatchCommands(gitCommon, rebaseCommands, commitCommands, statusCommands, stashCommands, patchBuilder)
bisectCommands := git_commands.NewBisectCommands(gitCommon)

View file

@ -1,12 +1,12 @@
package git_commands
import (
"fmt"
"strings"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/jesseduffield/lazygit/pkg/common"
"github.com/samber/lo"
)
type CommitFileLoader struct {
@ -29,7 +29,7 @@ func (self *CommitFileLoader) GetFilesInDiff(from string, to string, reverse boo
Arg("--no-ext-diff").
Arg("--name-status").
Arg("-z").
Arg("--no-renames").
Arg(fmt.Sprintf("--find-renames=%d%%", self.UserConfig().Git.RenameSimilarityThreshold)).
ArgIf(reverse, "-R").
Arg(from).
Arg(to).
@ -44,18 +44,37 @@ func (self *CommitFileLoader) GetFilesInDiff(from string, to string, reverse boo
}
// filenames string is something like "MM\x00file1\x00MU\x00file2\x00AA\x00file3\x00"
// so we need to split it by the null character and then map each status-name pair to a commit file
// so we need to split it by the null character and then map each status-name pair
// to a commit file. Renames (and copies) are special: their status is followed by
// two paths (the old one and the new one) rather than one, e.g.
// "R100\x00old\x00new\x00".
func getCommitFilesFromFilenames(filenames string) []*models.CommitFile {
lines := strings.Split(strings.TrimRight(filenames, "\x00"), "\x00")
if len(lines) == 1 {
fields := strings.Split(strings.TrimRight(filenames, "\x00"), "\x00")
if len(fields) == 1 {
return []*models.CommitFile{}
}
// typical result looks like 'A my_file' meaning my_file was added
return lo.Map(lo.Chunk(lines, 2), func(chunk []string, _ int) *models.CommitFile {
return &models.CommitFile{
ChangeStatus: chunk[0],
Path: chunk[1],
commitFiles := make([]*models.CommitFile, 0, len(fields)/2)
for i := 0; i < len(fields)-1; {
changeStatus := fields[i]
if changeStatus[0] == 'R' || changeStatus[0] == 'C' {
// The status has a similarity score appended (e.g. "R100"); drop it
// so the rest of the code only has to deal with a plain "R" or "C".
commitFiles = append(commitFiles, &models.CommitFile{
ChangeStatus: changeStatus[:1],
PreviousPath: fields[i+1],
Path: fields[i+2],
})
i += 3
} else {
// typical result looks like 'A my_file' meaning my_file was added
commitFiles = append(commitFiles, &models.CommitFile{
ChangeStatus: changeStatus,
Path: fields[i+1],
})
i += 2
}
})
}
return commitFiles
}

View file

@ -60,6 +60,25 @@ func TestGetCommitFilesFromFilenames(t *testing.T) {
},
},
},
{
testName: "a rename among regular files",
input: "M\x00Myfile\x00R100\x00before\x00after\x00A\x00Added\x00",
output: []*models.CommitFile{
{
Path: "Myfile",
ChangeStatus: "M",
},
{
Path: "after",
PreviousPath: "before",
ChangeStatus: "R",
},
{
Path: "Added",
ChangeStatus: "A",
},
},
},
}
for _, test := range tests {

View file

@ -432,8 +432,14 @@ func (self *WorkingTreeCommands) WorktreeFileDiffCmdObj(node models.IFile, plain
// ShowFileDiff get the diff of specified from and to. Typically this will be used for a single commit so it'll be 123abc^..123abc
// but when we're in diff mode it could be any 'from' to any 'to'. The reverse flag is also here thanks to diff mode.
func (self *WorkingTreeCommands) ShowFileDiff(from string, to string, reverse bool, fileName string, plain bool) (string, error) {
return self.ShowFileDiffCmdObj(from, to, reverse, []string{fileName}, plain).RunWithOutput()
// For a renamed file, previousPath is the path it was renamed from (empty otherwise);
// both paths must be passed to git for the rename to be detected.
func (self *WorkingTreeCommands) ShowFileDiff(from string, to string, reverse bool, fileName string, previousPath string, plain bool) (string, error) {
fileNames := []string{fileName}
if previousPath != "" {
fileNames = append(fileNames, previousPath)
}
return self.ShowFileDiffCmdObj(from, to, reverse, fileNames, plain).RunWithOutput()
}
func (self *WorkingTreeCommands) ShowFileDiffCmdObj(from string, to string, reverse bool, fileNames []string, plain bool) *oscommands.CmdObj {
@ -454,7 +460,7 @@ func (self *WorkingTreeCommands) ShowFileDiffCmdObj(from string, to string, reve
ArgIfElse(useExtDiff || useExtDiffGitConfig, "--ext-diff", "--no-ext-diff").
Arg("--submodule").
Arg(fmt.Sprintf("--unified=%d", contextSize)).
Arg("--no-renames").
Arg(fmt.Sprintf("--find-renames=%d%%", self.UserConfig().Git.RenameSimilarityThreshold)).
Arg(fmt.Sprintf("--color=%s", colorArg)).
Arg(from).
Arg(to).

View file

@ -339,6 +339,8 @@ func TestWorkingTreeShowFileDiff(t *testing.T) {
from string
to string
reverse bool
fileName string
previousPath string
plain bool
ignoreWhitespace bool
contextSize uint64
@ -353,33 +355,49 @@ func TestWorkingTreeShowFileDiff(t *testing.T) {
from: "1234567890",
to: "0987654321",
reverse: false,
fileName: "test.txt",
plain: false,
ignoreWhitespace: false,
contextSize: 3,
runner: oscommands.NewFakeRunner(t).
ExpectGitArgs([]string{"-C", "/path/to/worktree", "-c", "diff.noprefix=false", "diff", "--no-ext-diff", "--submodule", "--unified=3", "--no-renames", "--color=always", "1234567890", "0987654321", "--", "test.txt"}, expectedResult, nil),
ExpectGitArgs([]string{"-C", "/path/to/worktree", "-c", "diff.noprefix=false", "diff", "--no-ext-diff", "--submodule", "--unified=3", "--find-renames=50%", "--color=always", "1234567890", "0987654321", "--", "test.txt"}, expectedResult, nil),
},
{
testName: "Show diff with custom context size",
from: "1234567890",
to: "0987654321",
reverse: false,
fileName: "test.txt",
plain: false,
ignoreWhitespace: false,
contextSize: 123,
runner: oscommands.NewFakeRunner(t).
ExpectGitArgs([]string{"-C", "/path/to/worktree", "-c", "diff.noprefix=false", "diff", "--no-ext-diff", "--submodule", "--unified=123", "--no-renames", "--color=always", "1234567890", "0987654321", "--", "test.txt"}, expectedResult, nil),
ExpectGitArgs([]string{"-C", "/path/to/worktree", "-c", "diff.noprefix=false", "diff", "--no-ext-diff", "--submodule", "--unified=123", "--find-renames=50%", "--color=always", "1234567890", "0987654321", "--", "test.txt"}, expectedResult, nil),
},
{
testName: "Default case (ignore whitespace)",
from: "1234567890",
to: "0987654321",
reverse: false,
fileName: "test.txt",
plain: false,
ignoreWhitespace: true,
contextSize: 3,
runner: oscommands.NewFakeRunner(t).
ExpectGitArgs([]string{"-C", "/path/to/worktree", "-c", "diff.noprefix=false", "diff", "--no-ext-diff", "--submodule", "--unified=3", "--no-renames", "--color=always", "1234567890", "0987654321", "--ignore-all-space", "--", "test.txt"}, expectedResult, nil),
ExpectGitArgs([]string{"-C", "/path/to/worktree", "-c", "diff.noprefix=false", "diff", "--no-ext-diff", "--submodule", "--unified=3", "--find-renames=50%", "--color=always", "1234567890", "0987654321", "--ignore-all-space", "--", "test.txt"}, expectedResult, nil),
},
{
testName: "Renamed file passes both paths so the rename is detected",
from: "1234567890",
to: "0987654321",
reverse: false,
fileName: "new.txt",
previousPath: "old.txt",
plain: false,
ignoreWhitespace: false,
contextSize: 3,
runner: oscommands.NewFakeRunner(t).
ExpectGitArgs([]string{"-C", "/path/to/worktree", "-c", "diff.noprefix=false", "diff", "--no-ext-diff", "--submodule", "--unified=3", "--find-renames=50%", "--color=always", "1234567890", "0987654321", "--", "new.txt", "old.txt"}, expectedResult, nil),
},
}
@ -394,7 +412,7 @@ func TestWorkingTreeShowFileDiff(t *testing.T) {
instance := buildWorkingTreeCommands(commonDeps{runner: s.runner, userConfig: userConfig, appState: &config.AppState{}, repoPaths: &repoPaths})
result, err := instance.ShowFileDiff(s.from, s.to, s.reverse, "test.txt", s.plain)
result, err := instance.ShowFileDiff(s.from, s.to, s.reverse, s.fileName, s.previousPath, s.plain)
assert.NoError(t, err)
assert.Equal(t, expectedResult, result)
s.runner.CheckForMissingCalls()

View file

@ -4,6 +4,9 @@ package models
type CommitFile struct {
Path string
// For a renamed file, the path it was renamed from; empty otherwise.
PreviousPath string
ChangeStatus string // e.g. 'A' for added or 'M' for modified. This is based on the result from git diff --name-status
}
@ -23,6 +26,24 @@ func (f *CommitFile) Deleted() bool {
return f.ChangeStatus == "D"
}
func (f *CommitFile) IsRename() bool {
return f.PreviousPath != ""
}
// Names returns an array containing just the path, or in the case of a rename,
// the after path and the before path.
func (f *CommitFile) Names() []string {
result := []string{f.Path}
if f.PreviousPath != "" {
result = append(result, f.PreviousPath)
}
return result
}
func (f *CommitFile) GetPath() string {
return f.Path
}
func (f *CommitFile) GetPreviousPath() string {
return f.PreviousPath
}

View file

@ -25,10 +25,14 @@ type fileInfo struct {
mode PatchStatus
includedLineIndices []int
diff string
// For a renamed file, the path it was renamed from; empty otherwise. We
// need to keep hold of it so we can re-render the file's patch (which is
// keyed by the new path) without the caller having to supply it again.
previousPath string
}
type (
loadFileDiffFunc func(from string, to string, reverse bool, filename string, plain bool) (string, error)
loadFileDiffFunc func(from string, to string, reverse bool, filename string, previousPath string, plain bool) (string, error)
)
// PatchBuilder manages the building of a patch for a commit to be applied to another commit (or the working tree, or removed from the current commit). We also support building patches from things like stashes, for which there is less flexibility
@ -75,6 +79,7 @@ func (p *PatchBuilder) PatchToApply(reverse bool, turnAddedFilesIntoDiffAgainstE
patch.WriteString(p.RenderPatchForFile(RenderPatchForFileOpts{
Filename: filename,
PreviousPath: info.previousPath,
Plain: true,
Reverse: reverse,
TurnAddedFilesIntoDiffAgainstEmptyFile: turnAddedFilesIntoDiffAgainstEmptyFile,
@ -102,8 +107,8 @@ func (p *PatchBuilder) removeFile(info *fileInfo) {
info.includedLineIndices = nil
}
func (p *PatchBuilder) AddFileWhole(filename string) error {
info, err := p.getFileInfo(filename)
func (p *PatchBuilder) AddFileWhole(filename string, previousPath string) error {
info, err := p.getFileInfo(filename, previousPath)
if err != nil {
return err
}
@ -113,8 +118,8 @@ func (p *PatchBuilder) AddFileWhole(filename string) error {
return nil
}
func (p *PatchBuilder) RemoveFile(filename string) error {
info, err := p.getFileInfo(filename)
func (p *PatchBuilder) RemoveFile(filename string, previousPath string) error {
info, err := p.getFileInfo(filename, previousPath)
if err != nil {
return err
}
@ -124,19 +129,20 @@ func (p *PatchBuilder) RemoveFile(filename string) error {
return nil
}
func (p *PatchBuilder) getFileInfo(filename string) (*fileInfo, error) {
func (p *PatchBuilder) getFileInfo(filename string, previousPath string) (*fileInfo, error) {
info, ok := p.fileInfoMap[filename]
if ok {
return info, nil
}
diff, err := p.loadFileDiff(p.From, p.To, p.reverse, filename, true)
diff, err := p.loadFileDiff(p.From, p.To, p.reverse, filename, previousPath, true)
if err != nil {
return nil, err
}
info = &fileInfo{
mode: UNSELECTED,
diff: diff,
mode: UNSELECTED,
diff: diff,
previousPath: previousPath,
}
p.fileInfoMap[filename] = info
@ -144,8 +150,8 @@ func (p *PatchBuilder) getFileInfo(filename string) (*fileInfo, error) {
return info, nil
}
func (p *PatchBuilder) AddFileLineRange(filename string, lineIndices []int) error {
info, err := p.getFileInfo(filename)
func (p *PatchBuilder) AddFileLineRange(filename string, previousPath string, lineIndices []int) error {
info, err := p.getFileInfo(filename, previousPath)
if err != nil {
return err
}
@ -155,8 +161,8 @@ func (p *PatchBuilder) AddFileLineRange(filename string, lineIndices []int) erro
return nil
}
func (p *PatchBuilder) RemoveFileLineRange(filename string, lineIndices []int) error {
info, err := p.getFileInfo(filename)
func (p *PatchBuilder) RemoveFileLineRange(filename string, previousPath string, lineIndices []int) error {
info, err := p.getFileInfo(filename, previousPath)
if err != nil {
return err
}
@ -171,13 +177,14 @@ func (p *PatchBuilder) RemoveFileLineRange(filename string, lineIndices []int) e
type RenderPatchForFileOpts struct {
Filename string
PreviousPath string
Plain bool
Reverse bool
TurnAddedFilesIntoDiffAgainstEmptyFile bool
}
func (p *PatchBuilder) RenderPatchForFile(opts RenderPatchForFileOpts) string {
info, err := p.getFileInfo(opts.Filename)
info, err := p.getFileInfo(opts.Filename, opts.PreviousPath)
if err != nil {
p.Log.Error(err)
return ""
@ -198,7 +205,12 @@ func (p *PatchBuilder) RenderPatchForFile(opts RenderPatchForFileOpts) string {
Transform(TransformOpts{
Reverse: opts.Reverse,
TurnAddedFilesIntoDiffAgainstEmptyFile: opts.TurnAddedFilesIntoDiffAgainstEmptyFile,
IncludedLineIndices: info.includedLineIndices,
// For a partial selection of a renamed file we keep only the
// content change and drop the rename, so that the rename stays in
// the commit. A whole-file selection keeps the rename (and short-
// circuits before this for plain output).
StripRename: info.mode == PART && info.previousPath != "",
IncludedLineIndices: info.includedLineIndices,
})
if opts.Plain {
@ -215,6 +227,7 @@ func (p *PatchBuilder) renderEachFilePatch(plain bool) []string {
patches := lo.Map(filenames, func(filename string, _ int) string {
return p.RenderPatchForFile(RenderPatchForFileOpts{
Filename: filename,
PreviousPath: p.fileInfoMap[filename].previousPath,
Plain: plain,
Reverse: false,
TurnAddedFilesIntoDiffAgainstEmptyFile: true,
@ -244,8 +257,8 @@ func (p *PatchBuilder) GetFileStatus(filename string, parent string) PatchStatus
return info.mode
}
func (p *PatchBuilder) GetFileIncLineIndices(filename string) ([]int, error) {
info, err := p.getFileInfo(filename)
func (p *PatchBuilder) GetFileIncLineIndices(filename string, previousPath string) ([]int, error) {
info, err := p.getFileInfo(filename, previousPath)
if err != nil {
return nil, err
}

View file

@ -19,6 +19,22 @@ index dcd3485..1ba5540 100644
...
`
const renameWithModificationDiff = `diff --git a/oldname b/newname
similarity index 62%
rename from oldname
rename to newname
index dcd3485..1ba5540 100644
--- a/oldname
+++ b/newname
@@ -1,5 +1,5 @@
apple
-orange
+grape
...
...
...
`
const addNewlineToEndOfFile = `diff --git a/filename b/filename
index 80a73f1..e48a11c 100644
--- a/filename
@ -152,6 +168,7 @@ func TestTransform(t *testing.T) {
firstLineIndex int
lastLineIndex int
reverse bool
stripRename bool
expected string
}
@ -515,6 +532,43 @@ func TestTransform(t *testing.T) {
orange
banana
lemon
`,
},
{
testName: "renamed file, whole change selected, strips the rename so only the content change is applied",
firstLineIndex: 9,
lastLineIndex: 10,
stripRename: true,
diffText: renameWithModificationDiff,
expected: `diff --git a/newname b/newname
index dcd3485..1ba5540 100644
--- a/newname
+++ b/newname
@@ -1,5 +1,5 @@
apple
-orange
+grape
...
...
...
`,
},
{
testName: "renamed file, only removal selected, strips the rename",
firstLineIndex: 9,
lastLineIndex: 9,
stripRename: true,
diffText: renameWithModificationDiff,
expected: `diff --git a/newname b/newname
index dcd3485..1ba5540 100644
--- a/newname
+++ b/newname
@@ -1,5 +1,4 @@
apple
-orange
...
...
...
`,
},
}
@ -527,6 +581,7 @@ func TestTransform(t *testing.T) {
Transform(TransformOpts{
Reverse: s.reverse,
FileNameOverride: s.filename,
StripRename: s.stripRename,
IncludedLineIndices: lineIndices,
}).
FormatPlain()

View file

@ -33,6 +33,13 @@ type TransformOpts struct {
// treat it as a diff against an empty file.
TurnAddedFilesIntoDiffAgainstEmptyFile bool
// When building a partial patch for a renamed file, strip the rename
// metadata from the header and point it at the new path. Applying the
// resulting patch then only changes the file's contents and leaves the
// rename itself in place. (For a whole-file selection we keep the rename
// so that it moves or is discarded together with the contents.)
StripRename bool
// The indices of lines that should be included in the patch.
IncludedLineIndices []int
}
@ -72,21 +79,61 @@ func (self *patchTransformer) transformHeader() []string {
"--- a/" + self.opts.FileNameOverride,
"+++ b/" + self.opts.FileNameOverride,
}
} else if self.opts.TurnAddedFilesIntoDiffAgainstEmptyFile {
result := make([]string, 0, len(self.patch.header))
for idx, line := range self.patch.header {
}
header := self.patch.header
if self.opts.StripRename {
header = stripRenameFromHeader(header)
}
if self.opts.TurnAddedFilesIntoDiffAgainstEmptyFile {
result := make([]string, 0, len(header))
for idx, line := range header {
if strings.HasPrefix(line, "new file mode") {
continue
}
if line == "--- /dev/null" && strings.HasPrefix(self.patch.header[idx+1], "+++ b/") {
line = "--- a/" + self.patch.header[idx+1][6:]
if line == "--- /dev/null" && strings.HasPrefix(header[idx+1], "+++ b/") {
line = "--- a/" + header[idx+1][6:]
}
result = append(result, line)
}
return result
}
return self.patch.header
return header
}
// stripRenameFromHeader rewrites a rename diff header so that it looks like a
// plain modification of the new path: it drops the rename metadata and points
// the diff at the new path on both sides, while keeping the blob index line so
// that `git apply --3way` can still fall back to a blob merge. See the
// StripRename option for why we do this.
func stripRenameFromHeader(header []string) []string {
newPath := ""
for _, line := range header {
if path, ok := strings.CutPrefix(line, "+++ b/"); ok {
newPath = path
break
}
}
result := make([]string, 0, len(header))
for _, line := range header {
switch {
case strings.HasPrefix(line, "similarity index "),
strings.HasPrefix(line, "dissimilarity index "),
strings.HasPrefix(line, "rename from "),
strings.HasPrefix(line, "rename to "):
// drop the rename metadata
case strings.HasPrefix(line, "diff --git "):
result = append(result, "diff --git a/"+newPath+" b/"+newPath)
case strings.HasPrefix(line, "--- "):
result = append(result, "--- a/"+newPath)
default:
result = append(result, line)
}
}
return result
}
func (self *patchTransformer) transformHunks() []*Hunk {

View file

@ -60,8 +60,11 @@ func NewContextTree(c *ContextCommon) *ContextTree {
"main",
PATCH_BUILDING_MAIN_CONTEXT_KEY,
func() []int {
filename := commitFilesContext.GetSelectedPath()
includedLineIndices, err := c.Git().Patch.PatchBuilder.GetFileIncLineIndices(filename)
file := commitFilesContext.GetSelectedFile()
if file == nil {
return nil
}
includedLineIndices, err := c.Git().Patch.PatchBuilder.GetFileIncLineIndices(file.Path, file.PreviousPath)
if err != nil {
c.Log.Error(err)
return nil

View file

@ -191,11 +191,11 @@ func (self *CommitFilesController) GetOnRenderToMain() func() {
}
}
func (self *CommitFilesController) copyDiffToClipboard(path string, toastMessage string) error {
func (self *CommitFilesController) copyDiffToClipboard(paths []string, toastMessage string) error {
from, to := self.context().GetFromAndToForDiff()
from, reverse := self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(from)
cmdObj := self.c.Git().WorkingTree.ShowFileDiffCmdObj(from, to, reverse, []string{path}, true)
cmdObj := self.c.Git().WorkingTree.ShowFileDiffCmdObj(from, to, reverse, paths, true)
diff, err := cmdObj.RunWithOutput()
if err != nil {
return err
@ -263,7 +263,7 @@ func (self *CommitFilesController) openCopyMenu() error {
copyFileDiffItem := &types.MenuItem{
Label: self.c.Tr.CopySelectedDiff,
OnPress: func() error {
return self.copyDiffToClipboard(node.GetPath(), self.c.Tr.FileDiffCopiedToast)
return self.copyDiffToClipboard(self.pathsForDiff(node), self.c.Tr.FileDiffCopiedToast)
},
DisabledReason: self.require(self.singleItemSelected())(),
Keys: menuKey('s'),
@ -271,7 +271,7 @@ func (self *CommitFilesController) openCopyMenu() error {
copyAllDiff := &types.MenuItem{
Label: self.c.Tr.CopyAllFilesDiff,
OnPress: func() error {
return self.copyDiffToClipboard(".", self.c.Tr.AllFilesDiffCopiedToast)
return self.copyDiffToClipboard([]string{"."}, self.c.Tr.AllFilesDiffCopiedToast)
},
DisabledReason: self.require(self.itemsSelected())(),
Keys: menuKey('a'),
@ -348,7 +348,10 @@ func (self *CommitFilesController) discard(selectedNodes []*filetree.CommitFileN
for _, node := range selectedNodes {
_ = node.ForEachFile(func(file *models.CommitFile) error {
filePaths = append(filePaths, file.GetPath())
// For a rename we discard both the new and the old path,
// so that the new file is removed and the old one is
// restored.
filePaths = append(filePaths, file.Names()...)
return nil
})
}
@ -465,7 +468,7 @@ func (self *CommitFilesController) toggleForPatch(selectedNodes []*filetree.Comm
for _, node := range selectedNodes {
err := node.ForEachFile(func(file *models.CommitFile) error {
return patchOperationFunction(file.Path)
return patchOperationFunction(file.Path, file.PreviousPath)
})
if err != nil {
return err
@ -610,11 +613,16 @@ func (self *CommitFilesController) pathsForDiff(node *filetree.CommitFileNode) [
if !node.IsFile() && self.context().IsFiltering() {
var paths []string
_ = node.ForEachFile(func(file *models.CommitFile) error {
paths = append(paths, file.Path)
// 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, file.Names()...)
return nil
})
return paths
}
if file := node.GetFile(); file != nil {
return file.Names()
}
return []string{node.GetPath()}
}

View file

@ -66,20 +66,21 @@ func (self *PatchBuildingHelper) RefreshPatchBuildingPanel(opts types.OnFocusOpt
}
// get diff from commit file that's currently selected
path := self.c.Contexts().CommitFiles.GetSelectedPath()
if path == "" {
file := self.c.Contexts().CommitFiles.GetSelectedFile()
if file == nil {
return
}
from, to := self.c.Contexts().CommitFiles.GetFromAndToForDiff()
from, reverse := self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(from)
diff, err := self.c.Git().WorkingTree.ShowFileDiff(from, to, reverse, path, true)
diff, err := self.c.Git().WorkingTree.ShowFileDiff(from, to, reverse, file.Path, file.PreviousPath, true)
if err != nil {
return
}
secondaryDiff := self.c.Git().Patch.PatchBuilder.RenderPatchForFile(patch.RenderPatchForFileOpts{
Filename: path,
Filename: file.Path,
PreviousPath: file.PreviousPath,
Plain: false,
Reverse: false,
TurnAddedFilesIntoDiffAgainstEmptyFile: true,

View file

@ -138,8 +138,8 @@ func (self *PatchBuildingController) toggleSelection() error {
self.context().GetMutex().Lock()
defer self.context().GetMutex().Unlock()
filename := self.c.Contexts().CommitFiles.GetSelectedPath()
if filename == "" {
file := self.c.Contexts().CommitFiles.GetSelectedFile()
if file == nil {
return nil
}
@ -152,7 +152,7 @@ func (self *PatchBuildingController) toggleSelection() error {
return nil
}
includedLineIndices, err := self.c.Git().Patch.PatchBuilder.GetFileIncLineIndices(filename)
includedLineIndices, err := self.c.Git().Patch.PatchBuilder.GetFileIncLineIndices(file.Path, file.PreviousPath)
if err != nil {
return err
}
@ -164,7 +164,7 @@ func (self *PatchBuildingController) toggleSelection() error {
}
// add range of lines to those set for the file
if err := toggleFunc(filename, lineIndicesToToggle); err != nil {
if err := toggleFunc(file.Path, file.PreviousPath, lineIndicesToToggle); err != nil {
// might actually want to return an error here
self.c.Log.Error(err)
}

View file

@ -1,6 +1,7 @@
package controllers
import (
"errors"
"fmt"
"github.com/jesseduffield/lazygit/pkg/gui/context"
@ -49,6 +50,10 @@ func (self *RenameSimilarityThresholdController) Context() types.Context {
}
func (self *RenameSimilarityThresholdController) Increase() error {
if err := self.checkCanChangeThreshold(); err != nil {
return err
}
old_size := self.c.UserConfig().Git.RenameSimilarityThreshold
if old_size < 100 {
@ -59,6 +64,10 @@ func (self *RenameSimilarityThresholdController) Increase() error {
}
func (self *RenameSimilarityThresholdController) Decrease() error {
if err := self.checkCanChangeThreshold(); err != nil {
return err
}
old_size := self.c.UserConfig().Git.RenameSimilarityThreshold
if old_size > 5 {
@ -73,11 +82,23 @@ func (self *RenameSimilarityThresholdController) applyChange() error {
currentContext := self.c.Context().CurrentSide()
switch currentContext.GetKey() {
// we make an exception for our files context, because it actually need to refresh its state afterwards.
// we make an exception for the files and commit-files contexts, because
// they actually need to refresh their state afterwards: a changed threshold
// can turn a rename into a separate delete and add, or vice versa.
case context.FILES_CONTEXT_KEY:
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}})
case context.COMMIT_FILES_CONTEXT_KEY:
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.COMMIT_FILES}})
default:
currentContext.HandleRenderToMain()
}
return nil
}
func (self *RenameSimilarityThresholdController) checkCanChangeThreshold() error {
if self.c.Git().Patch.PatchBuilder.Active() {
return errors.New(self.c.Tr.CantChangeRenameThresholdError)
}
return nil
}

View file

@ -328,7 +328,8 @@ func fileNameAtDepth(node *filetree.Node[models.File], depth int, showRootItem b
func commitFileNameAtDepth(node *filetree.Node[models.CommitFile], depth int) string {
splitName := split(node.GetInternalPath())
if depth == 0 && splitName[0] == "." {
showRootItem := splitName[0] == "."
if depth == 0 && showRootItem {
if len(splitName) == 1 {
return "/"
}
@ -336,6 +337,20 @@ func commitFileNameAtDepth(node *filetree.Node[models.CommitFile], depth int) st
}
name := join(splitName[depth:])
if node.File != nil && node.File.IsRename() {
splitPrevName := filetree.SplitFileTreePath(node.File.PreviousPath, showRootItem)
prevName := node.File.PreviousPath
// if the file has just been renamed inside the same directory, we can shave off
// the prefix for the previous path too. Otherwise we'll keep it unchanged
sameParentDir := len(splitName) == len(splitPrevName) && join(splitName[0:depth]) == join(splitPrevName[0:depth])
if sameParentDir {
prevName = join(splitPrevName[depth:])
}
return prevName + " → " + name
}
return name
}

View file

@ -151,6 +151,14 @@ func TestRenderCommitFileTree(t *testing.T) {
showRootItem: true,
expected: []string{"A test"},
},
{
name: "renamed file",
files: []*models.CommitFile{
{Path: "new.txt", PreviousPath: "old.txt", ChangeStatus: "R"},
},
showRootItem: false,
expected: []string{"R old.txt → new.txt"},
},
{
name: "big example",
files: []*models.CommitFile{
@ -219,7 +227,7 @@ M file1
}
patchBuilder := patch.NewPatchBuilder(
utils.NewDummyLog(),
func(from string, to string, reverse bool, filename string, plain bool) (string, error) {
func(from string, to string, reverse bool, filename string, previousPath string, plain bool) (string, error) {
return "", nil
},
)

View file

@ -819,6 +819,7 @@ type TranslationSet struct {
SortCommits string
SortCommitsTooltip string
CantChangeContextSizeError string
CantChangeRenameThresholdError string
OpenCommitInBrowser string
ViewBisectOptions string
ConfirmRevertCommit string
@ -1966,6 +1967,7 @@ func EnglishTranslationSet() *TranslationSet {
SortCommits: "Commit sort order",
SortCommitsTooltip: "Change the sort order of the commits in the commit log.\n\nThe default can be changed in the config file with the key 'git.log.sortOrder'.",
CantChangeContextSizeError: "Cannot change context while in patch building mode because we were too lazy to support it when releasing the feature. If you really want it, please let us know!",
CantChangeRenameThresholdError: "Cannot change the rename similarity threshold while in patch building mode, because the custom patch can't cope with a rename turning into a delete and add underneath it.",
OpenCommitInBrowser: "Open commit in browser",
ViewBisectOptions: "View bisect options",
ConfirmRevertCommit: "Are you sure you want to revert {{.selectedCommit}}?",

View file

@ -4,7 +4,6 @@ import (
"fmt"
"time"
"github.com/atotto/clipboard"
"github.com/jesseduffield/lazygit/pkg/config"
integrationTypes "github.com/jesseduffield/lazygit/pkg/integration/types"
)
@ -125,17 +124,6 @@ func (self *TestDriver) ExpectToast(matcher *TextMatcher) *TestDriver {
return self
}
func (self *TestDriver) ExpectClipboard(matcher *TextMatcher) {
self.assertWithRetries(func() (bool, string) {
text, err := clipboard.ReadAll()
if err != nil {
return false, "Error occurred when reading from clipboard: " + err.Error()
}
ok, _ := matcher.test(text)
return ok, fmt.Sprintf("Expected clipboard to match %s, but got %s", matcher.name(), text)
})
}
func (self *TestDriver) ExpectSearch() *SearchDriver {
self.inSearch()

View file

@ -0,0 +1,57 @@
package commit
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var DiscardRenamedFile = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Discard a renamed file from an old commit; both the new and the old path are handled so the rename is undone",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
shell.CreateFileAndAdd("original", "line1\nline2\nline3\nline4\nline5\n")
shell.CreateFileAndAdd("other", "other content\n")
shell.Commit("first commit")
shell.RenameFileInGit("original", "renamed")
shell.UpdateFileAndAdd("renamed", "line1\nline2 changed\nline3\nline4\nline5\n")
shell.Commit("rename with modification")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Commits().
Focus().
Lines(
Contains("rename with modification").IsSelected(),
Contains("first commit"),
).
PressEnter()
t.Views().CommitFiles().
IsFocused().
Lines(
Contains("original → renamed").IsSelected(),
).
Press(keys.Universal.Remove)
t.ExpectPopup().Confirmation().
Title(Equals("Discard file changes")).
Content(Contains("Are you sure you want to discard changes to the selected file(s) from this commit?")).
Confirm()
// The rename is undone: the commit no longer touches any file. (If only
// the new path were discarded, the commit would still delete the old
// path and show "D original" here instead.)
t.Views().CommitFiles().
IsFocused().
Lines(
Contains("(none)"),
).
PressEscape()
// The working tree is clean; the original file is back at HEAD.
t.Views().Files().
IsEmpty()
},
})

View file

@ -0,0 +1,60 @@
package patch_building
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
// note: this is required to simulate the clipboard during CI
func expectClipboard(t *TestDriver, matcher *TextMatcher) {
defer t.Shell().DeleteFile("clipboard")
t.FileSystem().FileContent("clipboard", matcher)
}
var CopyRenamedFileDiff = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Copy the diff of a renamed file to the clipboard; the diff shows the rename rather than a delete and add",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {
config.GetUserConfig().OS.CopyToClipboardCmd = "printf '%s' {{text}} > clipboard"
},
SetupRepo: func(shell *Shell) {
shell.CreateFileAndAdd("original", "line1\nline2\nline3\nline4\nline5\n")
shell.Commit("first commit")
shell.RenameFileInGit("original", "renamed")
shell.UpdateFileAndAdd("renamed", "line1\nline2 changed\nline3\nline4\nline5\n")
shell.Commit("rename with modification")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Commits().
Focus().
Lines(
Contains("rename with modification").IsSelected(),
Contains("first commit"),
).
PressEnter()
t.Views().CommitFiles().
IsFocused().
Lines(
Contains("original → renamed").IsSelected(),
).
Press(keys.Files.CopyFileInfoToClipboard)
t.ExpectPopup().Menu().
Title(Equals("Copy to clipboard")).
Select(Contains("Diff of selected file")).
Confirm()
t.ExpectToast(Contains("File diff copied to clipboard"))
expectClipboard(t,
Contains("rename from original").
Contains("rename to renamed").
Contains("-line2").
Contains("+line2 changed"),
)
},
})

View file

@ -0,0 +1,66 @@
package patch_building
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var RenameSimilarityThresholdChange = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Changing the rename similarity threshold refreshes the commit files panel, but is disabled while building a patch",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
shell.CreateFileAndAdd("original", "one\ntwo\nthree\nfour\nfive\n")
shell.Commit("add original")
shell.RenameFileInGit("original", "renamed")
shell.UpdateFileAndAdd("renamed", "one\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten\n")
shell.Commit("change name and contents")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Commits().
Focus().
Lines(
Contains("change name and contents").IsSelected(),
Contains("add original"),
).
PressEnter()
// At the default threshold of 50% the 50%-similar change is not detected
// as a rename.
t.Views().CommitFiles().
IsFocused().
Lines(
Equals("▼ /"),
Equals(" D original"),
Equals(" A renamed"),
).
// Lowering the threshold turns it into a rename; the panel refreshes.
Press(keys.Universal.DecreaseRenameSimilarityThreshold).
Tap(func() {
t.ExpectToast(Equals("Changed rename similarity threshold to 45%"))
}).
Lines(
Equals("R original → renamed"),
).
// Start building a patch from the renamed file.
PressPrimaryAction().
Tap(func() {
t.Views().Information().Content(Contains("Building patch"))
// Changing the threshold is now disabled: the patch builder
// can't cope with the rename turning into a delete and add.
t.Views().CommitFiles().
Press(keys.Universal.IncreaseRenameSimilarityThreshold)
t.ExpectPopup().Alert().
Title(Equals("Error")).
Content(Contains("Cannot change the rename similarity threshold while in patch building mode")).
Confirm()
}).
// The file is unchanged: still a rename, still in the patch.
Lines(
Contains("original → renamed").IsSelected(),
)
},
})

View file

@ -0,0 +1,73 @@
package patch_building
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var RenamedFilePartial = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Select part of a renamed file's changes into a custom patch and remove it from the commit, keeping the rename in place",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
shell.CreateFileAndAdd("original", "line1\nline2\nline3\nline4\nline5\n")
shell.Commit("first commit")
shell.RenameFileInGit("original", "renamed")
shell.UpdateFileAndAdd("renamed", "line1\nline2 changed\nline3\nline4\nline5\n")
shell.Commit("rename with modification")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Commits().
Focus().
Lines(
Contains("rename with modification").IsSelected(),
Contains("first commit"),
).
PressEnter()
t.Views().CommitFiles().
IsFocused().
Lines(
Contains("original → renamed").IsSelected(),
).
PressEnter()
// The main view shows the rename together with its content change.
t.Views().PatchBuilding().
IsFocused().
Content(Contains("rename from original").Contains("rename to renamed")).
ContainsLines(
Contains(" line1"),
Contains("-line2"),
Contains("+line2 changed"),
Contains(" line3"),
).
// Add the hunk (a line selection, as opposed to adding the whole
// file), so this is a partial patch.
PressPrimaryAction()
t.Views().Information().Content(Contains("Building patch"))
t.Common().SelectPatchOption(Contains("Remove patch from original commit"))
// The rename is preserved; only the content change is gone, so the file
// is still shown as a rename but now has no content change.
t.Views().CommitFiles().
IsFocused().
Lines(
Contains("original → renamed").IsSelected(),
)
t.Views().Main().
Content(DoesNotContain("line2 changed"))
t.Views().Commits().
Focus().
Lines(
Contains("rename with modification").IsSelected(),
Contains("first commit"),
)
},
})

View file

@ -0,0 +1,62 @@
package patch_building
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var RenamedFileWhole = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Add a whole renamed file to a custom patch and remove it from the commit, taking the rename with it",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
shell.CreateFileAndAdd("original", "line1\nline2\nline3\nline4\nline5\n")
shell.Commit("first commit")
shell.RenameFileInGit("original", "renamed")
shell.UpdateFileAndAdd("renamed", "line1\nline2 changed\nline3\nline4\nline5\n")
shell.Commit("rename with modification")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Commits().
Focus().
Lines(
Contains("rename with modification").IsSelected(),
Contains("first commit"),
).
PressEnter()
t.Views().CommitFiles().
IsFocused().
Lines(
Contains("original → renamed").IsSelected(),
).
PressPrimaryAction()
t.Views().Information().Content(Contains("Building patch"))
// The whole file is added, so the patch carries the rename itself.
t.Views().Secondary().
ContainsLines(
Contains("rename from original"),
Contains("rename to renamed"),
)
t.Common().SelectPatchOption(Contains("Remove patch from original commit"))
// The rename went with the patch, so the commit no longer touches the file.
t.Views().CommitFiles().
IsFocused().
Lines(
Contains("(none)"),
)
t.Views().Commits().
Focus().
Lines(
Contains("rename with modification").IsSelected(),
Contains("first commit"),
)
},
})

View file

@ -128,6 +128,7 @@ var tests = []*components.IntegrationTest{
commit.CreateTag,
commit.DisableCopyCommitMessageBody,
commit.DiscardOldFileChanges,
commit.DiscardRenamedFile,
commit.DiscardSubmoduleChanges,
commit.DoNotShowBranchMarkerForHeadCommit,
commit.FailHooksThenCommitNoHooks,
@ -351,6 +352,7 @@ var tests = []*components.IntegrationTest{
patch_building.ApplyInReverseWithConflict,
patch_building.ApplyWithModifiedFileConflict,
patch_building.ApplyWithModifiedFileNoConflict,
patch_building.CopyRenamedFileDiff,
patch_building.DiscardLinesFromCommit,
patch_building.EditLineInPatchBuildingPanel,
patch_building.MoveRangeToIndex,
@ -373,6 +375,9 @@ var tests = []*components.IntegrationTest{
patch_building.MoveToNewCommitPartialHunk,
patch_building.RemoveFromCommit,
patch_building.RemovePartsOfAddedFile,
patch_building.RenameSimilarityThresholdChange,
patch_building.RenamedFilePartial,
patch_building.RenamedFileWhole,
patch_building.ResetWithEscape,
patch_building.SelectAllFiles,
patch_building.SpecificSelection,