Show renamed files in the custom patch builder

When loading the files of a commit we passed --no-renames, so a rename
showed up as a separate delete and add rather than a single R entry.
That made it impossible to work with a rename that also modifies the
file: the modifications were spread across a full deletion and a full
addition instead of appearing as the handful of lines that actually
changed. The staging view already shows renames and lets you stage
their hunks, so there was no good reason for the patch builder to
differ; the flag was only there because the commit-file parser couldn't
cope with the rename record format.

Switch the commit-file loader and the per-file diff to --find-renames,
teach the parser about the rename record (a status followed by two
paths), and carry the previous path through the patch builder so the
diff for a rename is loaded with both paths, which is what makes git
emit the rename in the first place.

A whole-file selection keeps the rename in the header, so the rename
moves or is discarded together with the file's contents. A partial
selection instead strips the rename metadata and points the header at
the new path, so applying the patch only changes the contents and
leaves the rename in place; the blob index line is kept so that a 3-way
apply can still fall back to a blob merge.

Discarding a renamed file from a commit now discards both the new and
the old path, so the new file is removed and the old one is restored.

Changing the rename similarity threshold refreshes the commit files
panel too, not just the files panel, so that a rename can turn into a
delete and add or back. It is disabled while building a patch, however,
because the patch builder caches each file's diff by path and would
desync if a rename changed into a delete and add underneath it.

Finally, copying a file's diff from the commit files panel now passes
both paths for a rename, so the copied diff shows the rename instead of
a new-file add.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stefan Haller 2026-07-03 22:31:52 +02:00
parent c085598100
commit f84ada4941
23 changed files with 642 additions and 63 deletions

View file

@ -117,8 +117,8 @@ func NewGitCommandAux(
rebaseCommands := git_commands.NewRebaseCommands(gitCommon, commitCommands, workingTreeCommands) rebaseCommands := git_commands.NewRebaseCommands(gitCommon, commitCommands, workingTreeCommands)
stashCommands := git_commands.NewStashCommands(gitCommon, fileLoader, workingTreeCommands) stashCommands := git_commands.NewStashCommands(gitCommon, fileLoader, workingTreeCommands)
patchBuilder := patch.NewPatchBuilder(cmn.Log, patchBuilder := patch.NewPatchBuilder(cmn.Log,
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 workingTreeCommands.ShowFileDiff(from, to, reverse, filename, plain) return workingTreeCommands.ShowFileDiff(from, to, reverse, filename, previousPath, plain)
}) })
patchCommands := git_commands.NewPatchCommands(gitCommon, rebaseCommands, commitCommands, statusCommands, stashCommands, patchBuilder) patchCommands := git_commands.NewPatchCommands(gitCommon, rebaseCommands, commitCommands, statusCommands, stashCommands, patchBuilder)
bisectCommands := git_commands.NewBisectCommands(gitCommon) bisectCommands := git_commands.NewBisectCommands(gitCommon)

View file

@ -1,12 +1,12 @@
package git_commands package git_commands
import ( import (
"fmt"
"strings" "strings"
"github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands" "github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/jesseduffield/lazygit/pkg/common" "github.com/jesseduffield/lazygit/pkg/common"
"github.com/samber/lo"
) )
type CommitFileLoader struct { type CommitFileLoader struct {
@ -29,7 +29,7 @@ func (self *CommitFileLoader) GetFilesInDiff(from string, to string, reverse boo
Arg("--no-ext-diff"). Arg("--no-ext-diff").
Arg("--name-status"). Arg("--name-status").
Arg("-z"). Arg("-z").
Arg("--no-renames"). Arg(fmt.Sprintf("--find-renames=%d%%", self.UserConfig().Git.RenameSimilarityThreshold)).
ArgIf(reverse, "-R"). ArgIf(reverse, "-R").
Arg(from). Arg(from).
Arg(to). 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" // 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 { func getCommitFilesFromFilenames(filenames string) []*models.CommitFile {
lines := strings.Split(strings.TrimRight(filenames, "\x00"), "\x00") fields := strings.Split(strings.TrimRight(filenames, "\x00"), "\x00")
if len(lines) == 1 { if len(fields) == 1 {
return []*models.CommitFile{} return []*models.CommitFile{}
} }
// typical result looks like 'A my_file' meaning my_file was added commitFiles := make([]*models.CommitFile, 0, len(fields)/2)
return lo.Map(lo.Chunk(lines, 2), func(chunk []string, _ int) *models.CommitFile { for i := 0; i < len(fields)-1; {
return &models.CommitFile{ changeStatus := fields[i]
ChangeStatus: chunk[0], if changeStatus[0] == 'R' || changeStatus[0] == 'C' {
Path: chunk[1], // 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 { 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 // 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. // 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) { // For a renamed file, previousPath is the path it was renamed from (empty otherwise);
return self.ShowFileDiffCmdObj(from, to, reverse, []string{fileName}, plain).RunWithOutput() // 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 { 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"). ArgIfElse(useExtDiff || useExtDiffGitConfig, "--ext-diff", "--no-ext-diff").
Arg("--submodule"). Arg("--submodule").
Arg(fmt.Sprintf("--unified=%d", contextSize)). 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(fmt.Sprintf("--color=%s", colorArg)).
Arg(from). Arg(from).
Arg(to). Arg(to).

View file

@ -339,6 +339,8 @@ func TestWorkingTreeShowFileDiff(t *testing.T) {
from string from string
to string to string
reverse bool reverse bool
fileName string
previousPath string
plain bool plain bool
ignoreWhitespace bool ignoreWhitespace bool
contextSize uint64 contextSize uint64
@ -353,33 +355,49 @@ func TestWorkingTreeShowFileDiff(t *testing.T) {
from: "1234567890", from: "1234567890",
to: "0987654321", to: "0987654321",
reverse: false, reverse: false,
fileName: "test.txt",
plain: false, plain: false,
ignoreWhitespace: false, ignoreWhitespace: false,
contextSize: 3, contextSize: 3,
runner: oscommands.NewFakeRunner(t). 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", testName: "Show diff with custom context size",
from: "1234567890", from: "1234567890",
to: "0987654321", to: "0987654321",
reverse: false, reverse: false,
fileName: "test.txt",
plain: false, plain: false,
ignoreWhitespace: false, ignoreWhitespace: false,
contextSize: 123, contextSize: 123,
runner: oscommands.NewFakeRunner(t). 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)", testName: "Default case (ignore whitespace)",
from: "1234567890", from: "1234567890",
to: "0987654321", to: "0987654321",
reverse: false, reverse: false,
fileName: "test.txt",
plain: false, plain: false,
ignoreWhitespace: true, ignoreWhitespace: true,
contextSize: 3, contextSize: 3,
runner: oscommands.NewFakeRunner(t). 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}) 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.NoError(t, err)
assert.Equal(t, expectedResult, result) assert.Equal(t, expectedResult, result)
s.runner.CheckForMissingCalls() s.runner.CheckForMissingCalls()

View file

@ -4,6 +4,9 @@ package models
type CommitFile struct { type CommitFile struct {
Path string 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 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" 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 { func (f *CommitFile) GetPath() string {
return f.Path return f.Path
} }
func (f *CommitFile) GetPreviousPath() string {
return f.PreviousPath
}

View file

@ -25,10 +25,14 @@ type fileInfo struct {
mode PatchStatus mode PatchStatus
includedLineIndices []int includedLineIndices []int
diff string 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 ( 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 // 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{ patch.WriteString(p.RenderPatchForFile(RenderPatchForFileOpts{
Filename: filename, Filename: filename,
PreviousPath: info.previousPath,
Plain: true, Plain: true,
Reverse: reverse, Reverse: reverse,
TurnAddedFilesIntoDiffAgainstEmptyFile: turnAddedFilesIntoDiffAgainstEmptyFile, TurnAddedFilesIntoDiffAgainstEmptyFile: turnAddedFilesIntoDiffAgainstEmptyFile,
@ -102,8 +107,8 @@ func (p *PatchBuilder) removeFile(info *fileInfo) {
info.includedLineIndices = nil info.includedLineIndices = nil
} }
func (p *PatchBuilder) AddFileWhole(filename string) error { func (p *PatchBuilder) AddFileWhole(filename string, previousPath string) error {
info, err := p.getFileInfo(filename) info, err := p.getFileInfo(filename, previousPath)
if err != nil { if err != nil {
return err return err
} }
@ -113,8 +118,8 @@ func (p *PatchBuilder) AddFileWhole(filename string) error {
return nil return nil
} }
func (p *PatchBuilder) RemoveFile(filename string) error { func (p *PatchBuilder) RemoveFile(filename string, previousPath string) error {
info, err := p.getFileInfo(filename) info, err := p.getFileInfo(filename, previousPath)
if err != nil { if err != nil {
return err return err
} }
@ -124,19 +129,20 @@ func (p *PatchBuilder) RemoveFile(filename string) error {
return nil 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] info, ok := p.fileInfoMap[filename]
if ok { if ok {
return info, nil 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 { if err != nil {
return nil, err return nil, err
} }
info = &fileInfo{ info = &fileInfo{
mode: UNSELECTED, mode: UNSELECTED,
diff: diff, diff: diff,
previousPath: previousPath,
} }
p.fileInfoMap[filename] = info p.fileInfoMap[filename] = info
@ -144,8 +150,8 @@ func (p *PatchBuilder) getFileInfo(filename string) (*fileInfo, error) {
return info, nil return info, nil
} }
func (p *PatchBuilder) AddFileLineRange(filename string, lineIndices []int) error { func (p *PatchBuilder) AddFileLineRange(filename string, previousPath string, lineIndices []int) error {
info, err := p.getFileInfo(filename) info, err := p.getFileInfo(filename, previousPath)
if err != nil { if err != nil {
return err return err
} }
@ -155,8 +161,8 @@ func (p *PatchBuilder) AddFileLineRange(filename string, lineIndices []int) erro
return nil return nil
} }
func (p *PatchBuilder) RemoveFileLineRange(filename string, lineIndices []int) error { func (p *PatchBuilder) RemoveFileLineRange(filename string, previousPath string, lineIndices []int) error {
info, err := p.getFileInfo(filename) info, err := p.getFileInfo(filename, previousPath)
if err != nil { if err != nil {
return err return err
} }
@ -171,13 +177,14 @@ func (p *PatchBuilder) RemoveFileLineRange(filename string, lineIndices []int) e
type RenderPatchForFileOpts struct { type RenderPatchForFileOpts struct {
Filename string Filename string
PreviousPath string
Plain bool Plain bool
Reverse bool Reverse bool
TurnAddedFilesIntoDiffAgainstEmptyFile bool TurnAddedFilesIntoDiffAgainstEmptyFile bool
} }
func (p *PatchBuilder) RenderPatchForFile(opts RenderPatchForFileOpts) string { func (p *PatchBuilder) RenderPatchForFile(opts RenderPatchForFileOpts) string {
info, err := p.getFileInfo(opts.Filename) info, err := p.getFileInfo(opts.Filename, opts.PreviousPath)
if err != nil { if err != nil {
p.Log.Error(err) p.Log.Error(err)
return "" return ""
@ -198,7 +205,12 @@ func (p *PatchBuilder) RenderPatchForFile(opts RenderPatchForFileOpts) string {
Transform(TransformOpts{ Transform(TransformOpts{
Reverse: opts.Reverse, Reverse: opts.Reverse,
TurnAddedFilesIntoDiffAgainstEmptyFile: opts.TurnAddedFilesIntoDiffAgainstEmptyFile, 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 { if opts.Plain {
@ -215,6 +227,7 @@ func (p *PatchBuilder) renderEachFilePatch(plain bool) []string {
patches := lo.Map(filenames, func(filename string, _ int) string { patches := lo.Map(filenames, func(filename string, _ int) string {
return p.RenderPatchForFile(RenderPatchForFileOpts{ return p.RenderPatchForFile(RenderPatchForFileOpts{
Filename: filename, Filename: filename,
PreviousPath: p.fileInfoMap[filename].previousPath,
Plain: plain, Plain: plain,
Reverse: false, Reverse: false,
TurnAddedFilesIntoDiffAgainstEmptyFile: true, TurnAddedFilesIntoDiffAgainstEmptyFile: true,
@ -244,8 +257,8 @@ func (p *PatchBuilder) GetFileStatus(filename string, parent string) PatchStatus
return info.mode return info.mode
} }
func (p *PatchBuilder) GetFileIncLineIndices(filename string) ([]int, error) { func (p *PatchBuilder) GetFileIncLineIndices(filename string, previousPath string) ([]int, error) {
info, err := p.getFileInfo(filename) info, err := p.getFileInfo(filename, previousPath)
if err != nil { if err != nil {
return nil, err 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 const addNewlineToEndOfFile = `diff --git a/filename b/filename
index 80a73f1..e48a11c 100644 index 80a73f1..e48a11c 100644
--- a/filename --- a/filename
@ -152,6 +168,7 @@ func TestTransform(t *testing.T) {
firstLineIndex int firstLineIndex int
lastLineIndex int lastLineIndex int
reverse bool reverse bool
stripRename bool
expected string expected string
} }
@ -515,6 +532,43 @@ func TestTransform(t *testing.T) {
orange orange
banana banana
lemon 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{ Transform(TransformOpts{
Reverse: s.reverse, Reverse: s.reverse,
FileNameOverride: s.filename, FileNameOverride: s.filename,
StripRename: s.stripRename,
IncludedLineIndices: lineIndices, IncludedLineIndices: lineIndices,
}). }).
FormatPlain() FormatPlain()

View file

@ -33,6 +33,13 @@ type TransformOpts struct {
// treat it as a diff against an empty file. // treat it as a diff against an empty file.
TurnAddedFilesIntoDiffAgainstEmptyFile bool 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. // The indices of lines that should be included in the patch.
IncludedLineIndices []int IncludedLineIndices []int
} }
@ -72,21 +79,61 @@ func (self *patchTransformer) transformHeader() []string {
"--- a/" + self.opts.FileNameOverride, "--- a/" + self.opts.FileNameOverride,
"+++ b/" + 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") { if strings.HasPrefix(line, "new file mode") {
continue continue
} }
if line == "--- /dev/null" && strings.HasPrefix(self.patch.header[idx+1], "+++ b/") { if line == "--- /dev/null" && strings.HasPrefix(header[idx+1], "+++ b/") {
line = "--- a/" + self.patch.header[idx+1][6:] line = "--- a/" + header[idx+1][6:]
} }
result = append(result, line) result = append(result, line)
} }
return result 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 { func (self *patchTransformer) transformHunks() []*Hunk {

View file

@ -60,8 +60,11 @@ func NewContextTree(c *ContextCommon) *ContextTree {
"main", "main",
PATCH_BUILDING_MAIN_CONTEXT_KEY, PATCH_BUILDING_MAIN_CONTEXT_KEY,
func() []int { func() []int {
filename := commitFilesContext.GetSelectedPath() file := commitFilesContext.GetSelectedFile()
includedLineIndices, err := c.Git().Patch.PatchBuilder.GetFileIncLineIndices(filename) if file == nil {
return nil
}
includedLineIndices, err := c.Git().Patch.PatchBuilder.GetFileIncLineIndices(file.Path, file.PreviousPath)
if err != nil { if err != nil {
c.Log.Error(err) c.Log.Error(err)
return nil 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, to := self.context().GetFromAndToForDiff()
from, reverse := self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(from) 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() diff, err := cmdObj.RunWithOutput()
if err != nil { if err != nil {
return err return err
@ -263,7 +263,7 @@ func (self *CommitFilesController) openCopyMenu() error {
copyFileDiffItem := &types.MenuItem{ copyFileDiffItem := &types.MenuItem{
Label: self.c.Tr.CopySelectedDiff, Label: self.c.Tr.CopySelectedDiff,
OnPress: func() error { 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())(), DisabledReason: self.require(self.singleItemSelected())(),
Keys: menuKey('s'), Keys: menuKey('s'),
@ -271,7 +271,7 @@ func (self *CommitFilesController) openCopyMenu() error {
copyAllDiff := &types.MenuItem{ copyAllDiff := &types.MenuItem{
Label: self.c.Tr.CopyAllFilesDiff, Label: self.c.Tr.CopyAllFilesDiff,
OnPress: func() error { OnPress: func() error {
return self.copyDiffToClipboard(".", self.c.Tr.AllFilesDiffCopiedToast) return self.copyDiffToClipboard([]string{"."}, self.c.Tr.AllFilesDiffCopiedToast)
}, },
DisabledReason: self.require(self.itemsSelected())(), DisabledReason: self.require(self.itemsSelected())(),
Keys: menuKey('a'), Keys: menuKey('a'),
@ -348,7 +348,10 @@ func (self *CommitFilesController) discard(selectedNodes []*filetree.CommitFileN
for _, node := range selectedNodes { for _, node := range selectedNodes {
_ = node.ForEachFile(func(file *models.CommitFile) error { _ = 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 return nil
}) })
} }
@ -465,7 +468,7 @@ func (self *CommitFilesController) toggleForPatch(selectedNodes []*filetree.Comm
for _, node := range selectedNodes { for _, node := range selectedNodes {
err := node.ForEachFile(func(file *models.CommitFile) error { err := node.ForEachFile(func(file *models.CommitFile) error {
return patchOperationFunction(file.Path) return patchOperationFunction(file.Path, file.PreviousPath)
}) })
if err != nil { if err != nil {
return err return err
@ -610,11 +613,16 @@ func (self *CommitFilesController) pathsForDiff(node *filetree.CommitFileNode) [
if !node.IsFile() && self.context().IsFiltering() { if !node.IsFile() && self.context().IsFiltering() {
var paths []string var paths []string
_ = node.ForEachFile(func(file *models.CommitFile) error { _ = 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 nil
}) })
return paths return paths
} }
if file := node.GetFile(); file != nil {
return file.Names()
}
return []string{node.GetPath()} 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 // get diff from commit file that's currently selected
path := self.c.Contexts().CommitFiles.GetSelectedPath() file := self.c.Contexts().CommitFiles.GetSelectedFile()
if path == "" { if file == nil {
return return
} }
from, to := self.c.Contexts().CommitFiles.GetFromAndToForDiff() from, to := self.c.Contexts().CommitFiles.GetFromAndToForDiff()
from, reverse := self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(from) 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 { if err != nil {
return return
} }
secondaryDiff := self.c.Git().Patch.PatchBuilder.RenderPatchForFile(patch.RenderPatchForFileOpts{ secondaryDiff := self.c.Git().Patch.PatchBuilder.RenderPatchForFile(patch.RenderPatchForFileOpts{
Filename: path, Filename: file.Path,
PreviousPath: file.PreviousPath,
Plain: false, Plain: false,
Reverse: false, Reverse: false,
TurnAddedFilesIntoDiffAgainstEmptyFile: true, TurnAddedFilesIntoDiffAgainstEmptyFile: true,

View file

@ -138,8 +138,8 @@ func (self *PatchBuildingController) toggleSelection() error {
self.context().GetMutex().Lock() self.context().GetMutex().Lock()
defer self.context().GetMutex().Unlock() defer self.context().GetMutex().Unlock()
filename := self.c.Contexts().CommitFiles.GetSelectedPath() file := self.c.Contexts().CommitFiles.GetSelectedFile()
if filename == "" { if file == nil {
return nil return nil
} }
@ -152,7 +152,7 @@ func (self *PatchBuildingController) toggleSelection() error {
return nil 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 { if err != nil {
return err return err
} }
@ -164,7 +164,7 @@ func (self *PatchBuildingController) toggleSelection() error {
} }
// add range of lines to those set for the file // 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 // might actually want to return an error here
self.c.Log.Error(err) self.c.Log.Error(err)
} }

View file

@ -1,6 +1,7 @@
package controllers package controllers
import ( import (
"errors"
"fmt" "fmt"
"github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/context"
@ -49,6 +50,10 @@ func (self *RenameSimilarityThresholdController) Context() types.Context {
} }
func (self *RenameSimilarityThresholdController) Increase() error { func (self *RenameSimilarityThresholdController) Increase() error {
if err := self.checkCanChangeThreshold(); err != nil {
return err
}
old_size := self.c.UserConfig().Git.RenameSimilarityThreshold old_size := self.c.UserConfig().Git.RenameSimilarityThreshold
if old_size < 100 { if old_size < 100 {
@ -59,6 +64,10 @@ func (self *RenameSimilarityThresholdController) Increase() error {
} }
func (self *RenameSimilarityThresholdController) Decrease() error { func (self *RenameSimilarityThresholdController) Decrease() error {
if err := self.checkCanChangeThreshold(); err != nil {
return err
}
old_size := self.c.UserConfig().Git.RenameSimilarityThreshold old_size := self.c.UserConfig().Git.RenameSimilarityThreshold
if old_size > 5 { if old_size > 5 {
@ -73,11 +82,23 @@ func (self *RenameSimilarityThresholdController) applyChange() error {
currentContext := self.c.Context().CurrentSide() currentContext := self.c.Context().CurrentSide()
switch currentContext.GetKey() { 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: case context.FILES_CONTEXT_KEY:
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}}) 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: default:
currentContext.HandleRenderToMain() currentContext.HandleRenderToMain()
} }
return nil 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 { func commitFileNameAtDepth(node *filetree.Node[models.CommitFile], depth int) string {
splitName := split(node.GetInternalPath()) splitName := split(node.GetInternalPath())
if depth == 0 && splitName[0] == "." { showRootItem := splitName[0] == "."
if depth == 0 && showRootItem {
if len(splitName) == 1 { if len(splitName) == 1 {
return "/" return "/"
} }
@ -336,6 +337,20 @@ func commitFileNameAtDepth(node *filetree.Node[models.CommitFile], depth int) st
} }
name := join(splitName[depth:]) 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 return name
} }

View file

@ -151,6 +151,14 @@ func TestRenderCommitFileTree(t *testing.T) {
showRootItem: true, showRootItem: true,
expected: []string{"A test"}, 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", name: "big example",
files: []*models.CommitFile{ files: []*models.CommitFile{
@ -219,7 +227,7 @@ M file1
} }
patchBuilder := patch.NewPatchBuilder( patchBuilder := patch.NewPatchBuilder(
utils.NewDummyLog(), 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 return "", nil
}, },
) )

View file

@ -819,6 +819,7 @@ type TranslationSet struct {
SortCommits string SortCommits string
SortCommitsTooltip string SortCommitsTooltip string
CantChangeContextSizeError string CantChangeContextSizeError string
CantChangeRenameThresholdError string
OpenCommitInBrowser string OpenCommitInBrowser string
ViewBisectOptions string ViewBisectOptions string
ConfirmRevertCommit string ConfirmRevertCommit string
@ -1966,6 +1967,7 @@ func EnglishTranslationSet() *TranslationSet {
SortCommits: "Commit sort order", 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'.", 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!", 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", OpenCommitInBrowser: "Open commit in browser",
ViewBisectOptions: "View bisect options", ViewBisectOptions: "View bisect options",
ConfirmRevertCommit: "Are you sure you want to revert {{.selectedCommit}}?", ConfirmRevertCommit: "Are you sure you want to revert {{.selectedCommit}}?",

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