From c593e9e756c469dfd9853a09abcc093ecb067830 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 19 Aug 2026 18:05:48 +0200 Subject: [PATCH] Materialize the custom patch so the diff renderer can show it The pane beside a commit's diff showed the patch being built from it by assembling the text itself. That text could not be handed to a diff renderer the way a diff can: a stdin filter might have coped, but a tool that diffs two files could not. Its idea of how much context to show around a hunk was also its own rather than git's. Materialize the patch instead: write each of its files as it is before the patch into one tree and as it is after into another, and let git diff the two trees. The patch becomes a diff of real files, rendered by whatever renders the rest of them, with git's own context around it. Its lines can then be pointed at; taking them back out of the patch will need that. The trees are named a and b, so that with git's own prefixes suppressed the paths read like an ordinary diff's over the repo's own paths. They are written when the patch changes rather than when it is shown, the patch builder counting its own versions for that, and they go away with the patch. A renamed file is materialized under the name the patch expects to find it under. Where the patch carries the rename, that is the name the file had before, so the rename comes out as a rename. git names the trees themselves in the two rename lines, having only the two paths to go by. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/commands/git.go | 5 + pkg/commands/git_commands/diff.go | 22 ++++ .../git_commands/git_command_builder.go | 21 ++++ pkg/commands/git_commands/patch.go | 89 ++++++++++++++ pkg/commands/patch/patch_builder.go | 111 +++++++++++++++++- pkg/commands/patch/patch_builder_test.go | 34 +++++- .../controllers/local_commits_controller.go | 29 +++-- pkg/gui/presentation/files_test.go | 1 + pkg/gui/types/rendering.go | 15 --- ...om_patch_goes_through_the_diff_renderer.go | 50 ++++++++ ...om_patch_ignores_line_ending_conversion.go | 52 ++++++++ .../patch_building/renamed_file_whole.go | 11 +- .../patch_building/specific_selection.go | 6 +- pkg/integration/tests/test_list.go | 2 + 14 files changed, 417 insertions(+), 31 deletions(-) create mode 100644 pkg/integration/tests/main_view/custom_patch_goes_through_the_diff_renderer.go create mode 100644 pkg/integration/tests/main_view/custom_patch_ignores_line_ending_conversion.go diff --git a/pkg/commands/git.go b/pkg/commands/git.go index bfe19b85f..efb4bb22d 100644 --- a/pkg/commands/git.go +++ b/pkg/commands/git.go @@ -139,6 +139,11 @@ func NewGitCommandAux( // A patch is built from git's own diff: what a diff renderer would make of it // is a picture of it, not something that can be applied. return workingTreeCommands.ShowFileDiff(from, to, reverse, filename, previousPath, git_commands.DiffModePlain) + }, + func() (string, error) { + // Under lazygit's own temp dir, so that it honours the configured location + // and is cleaned up with everything else when we exit. + return os.MkdirTemp(osCommand.GetTempDir(), "custom-patch-") }) patchCommands := git_commands.NewPatchCommands(gitCommon, rebaseCommands, commitCommands, statusCommands, stashCommands, patchBuilder) bisectCommands := git_commands.NewBisectCommands(gitCommon) diff --git a/pkg/commands/git_commands/diff.go b/pkg/commands/git_commands/diff.go index 3fece6235..01bb670c2 100644 --- a/pkg/commands/git_commands/diff.go +++ b/pkg/commands/git_commands/diff.go @@ -156,6 +156,28 @@ func (self *DiffCommands) DiffCmdObj(diffArgs []string, mode DiffMode) *oscomman ) } +// CustomPatchDiffCmdObj is the command that renders the custom patch being built: a diff +// of the two trees the patch was materialized into (PatchCommands.WriteCustomPatchDiffTrees), +// under the directory holding them. It goes through the same wiring as any other diff we +// show, so the patch is rendered by whatever renders the rest of them, and git works out +// how much context to give it. +// +// git's own path prefixes are suppressed because the trees are named a and b themselves, +// which leaves the paths reading like an ordinary diff's over the repo's own paths. +func (self *DiffCommands) CustomPatchDiffCmdObj(dir string, mode DiffMode) *oscommands.CmdObj { + return self.cmd.New( + NewGitCmd("diff"). + AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), mode). + NoLineEndingConversion(). + Arg("--no-index"). + Arg("--no-prefix"). + Arg(fmt.Sprintf("--color=%s", mode.colorArg(self.diffRendererConfigManager))). + Arg("a", "b"). + Dir(dir). + ToArgv(), + ) +} + // This is a basic generic diff command that can be used for any diff operation // (e.g. copying a diff to the clipboard). It will not use a custom diff renderer, // and does not use user configs such as ignore whitespace. diff --git a/pkg/commands/git_commands/git_command_builder.go b/pkg/commands/git_commands/git_command_builder.go index fe7a6d075..a4bd7340e 100644 --- a/pkg/commands/git_commands/git_command_builder.go +++ b/pkg/commands/git_commands/git_command_builder.go @@ -123,6 +123,27 @@ func (self *GitCommandBuilder) GitDirIf(condition bool, path string) *GitCommand return self } +// NoLineEndingConversion keeps git's line-ending machinery away from files that are not +// a working tree's. The trees the custom patch is materialized into hold the bytes git +// states the patch in, so a command over them has to read and write those bytes as they +// are. On a machine that checks files out with CRLF, `git apply` writes the after tree +// in that form while the before tree keeps the LF it was written with. git's own diff +// converts both back, but it warns about a round trip through a working tree these +// files never belong to, and an external diff renderer is handed the two files as they +// stand, one line ending apart in every line. +func (self *GitCommandBuilder) NoLineEndingConversion() *GitCommandBuilder { + return self. + // The setting that converts on most machines, and the one Git for Windows + // installs itself with. + Config("core.autocrlf=false"). + // An attributes file outside the repo can still mark the files as text. The + // form to keep them in is then the form they are written in. + Config("core.eol=lf"). + // An attribute naming CRLF outright overrides that, and git converts after all. + // The warning it gives is about a checkout these files never have. + Config("core.safecrlf=false") +} + func (self *GitCommandBuilder) AddCommonDiffArgs(diffRendererConfigManager *config.DiffRendererConfigManager, userConfig *config.UserConfig, mode DiffMode) *GitCommandBuilder { contextSize := userConfig.Git.DiffContextSize useExtDiff := mode == DiffModeRendered && diffRendererConfigManager.GetDiffRendererType() == config.DiffRendererType_ExtDiff diff --git a/pkg/commands/git_commands/patch.go b/pkg/commands/git_commands/patch.go index f40f7fa6f..823206c90 100644 --- a/pkg/commands/git_commands/patch.go +++ b/pkg/commands/git_commands/patch.go @@ -2,13 +2,16 @@ package git_commands import ( "fmt" + "os" "path/filepath" + "strings" "time" "github.com/go-errors/errors" "github.com/jesseduffield/lazygit/pkg/app/daemon" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/commands/patch" + "github.com/samber/lo" "github.com/stefanhaller/git-todo-parser/todo" ) @@ -20,6 +23,10 @@ type PatchCommands struct { stash *StashCommands PatchBuilder *patch.PatchBuilder + + // The version of the patch the diff trees were last written for, so that they are + // written again when, and only when, the patch has changed since. + treesWrittenForGeneration int } func NewPatchCommands( @@ -40,6 +47,88 @@ func NewPatchCommands( } } +// EnsureCustomPatchDiffTrees writes the custom patch's diff trees if what is there no +// longer describes the patch. Call it before rendering the patch, which is often — every +// time the panel showing it re-renders — while the patch itself changes rarely. +func (self *PatchCommands) EnsureCustomPatchDiffTrees() error { + if self.PatchBuilder.Generation() == self.treesWrittenForGeneration { + return nil + } + if err := self.WriteCustomPatchDiffTrees(); err != nil { + return err + } + self.treesWrittenForGeneration = self.PatchBuilder.Generation() + return nil +} + +// WriteCustomPatchDiffTrees materializes the custom patch as two file trees under the +// directory the patch builder keeps for it: `a` holds each of the patch's files as it is +// before the patch, `b` as it is after. Diffing those two trees against each other +// (DiffCommands.CustomPatchDiffCmdObj) turns the patch into a diff of real files, which +// can then be rendered exactly as any other diff is — through a diff renderer of any +// kind, and with git's own idea of how much context to show. +// +// The trees are named a and b so that the diff's paths, with git's own prefixes +// suppressed, come out reading like the a/ and b/ of an ordinary diff, over the real +// repo-relative paths. +func (self *PatchCommands) WriteCustomPatchDiffTrees() error { + dir := self.PatchBuilder.TempDir() + if dir == "" { + return nil + } + + before := filepath.Join(dir, "a") + after := filepath.Join(dir, "b") + for _, tree := range []string{before, after} { + if err := os.RemoveAll(tree); err != nil { + return err + } + if err := os.MkdirAll(tree, 0o700); err != nil { + return err + } + } + + for _, file := range self.PatchBuilder.FilesInPatch() { + content, err := self.commit.ShowFileContentCmdObj(self.PatchBuilder.From, file.ContentPath).RunWithOutput() + // A file the patch adds has no content on the before side, so git has nothing to + // show for it. + added := err != nil + + // The before side holds an added file as an empty file rather than not at all, so + // that the diff pairs the two sides up and states the file's real path, instead of + // reporting a file that only one of the trees has. + if err := self.os.CreateFileWithContent(filepath.Join(before, file.Path), + lo.Ternary(added, "", content)); err != nil { + return err + } + // The after side is seeded with the same content, for the patch to change; a file + // the patch adds is left absent, for the patch to create. + if !added { + if err := self.os.CreateFileWithContent(filepath.Join(after, file.Path), content); err != nil { + return err + } + } + } + + // Write added files as creations rather than as diffs against an empty file: the + // patch is applied in one go, so a file it expects to be there already would make + // the whole of it fail. + patchText := self.PatchBuilder.PatchToApply(false, false) + if strings.TrimSpace(patchText) == "" { + // Nothing in the patch, so the two trees are alike and the diff is empty. + return nil + } + patchFilePath, err := self.SaveTemporaryPatch(patchText) + if err != nil { + return err + } + return self.cmd.New(NewGitCmd("apply"). + NoLineEndingConversion(). + Arg(patchFilePath). + Dir(after). + ToArgv()).Run() +} + type ApplyPatchOpts struct { ThreeWay bool Cached bool diff --git a/pkg/commands/patch/patch_builder.go b/pkg/commands/patch/patch_builder.go index 55256a99c..8d94bdc9b 100644 --- a/pkg/commands/patch/patch_builder.go +++ b/pkg/commands/patch/patch_builder.go @@ -1,6 +1,7 @@ package patch import ( + "os" "sort" "strings" @@ -61,12 +62,27 @@ type PatchBuilder struct { // loadFileDiff loads the diff of a file, for a given to (typically a commit hash) loadFileDiff loadFileDiffFunc + + // newTempDir makes a directory for the current patch to be materialized into, as + // two file trees that can be diffed against each other and so rendered like any + // other diff (see PatchCommands.WriteCustomPatchDiffTrees). Its lifetime is the + // patch's: made when one is started, removed when it is given up. + newTempDir func() (string, error) + tempDir string + + // generation counts the changes made to the patch, so that whoever materializes it + // can tell whether what they last built still describes it — and rebuild only then, + // rather than on every render of it. + generation int } -func NewPatchBuilder(log *logrus.Entry, loadFileDiff loadFileDiffFunc) *PatchBuilder { +func NewPatchBuilder( + log *logrus.Entry, loadFileDiff loadFileDiffFunc, newTempDir func() (string, error), +) *PatchBuilder { return &PatchBuilder{ Log: log, loadFileDiff: loadFileDiff, + newTempDir: newTempDir, } } @@ -74,6 +90,9 @@ func (p *PatchBuilder) Start(from, to string, reverse bool, canRebase bool) { p.mutex.Lock() defer p.mutex.Unlock() + p.generation++ + p.makeTempDir() + p.To = to p.From = from p.reverse = reverse @@ -92,6 +111,89 @@ func (p *PatchBuilder) snapshotFileInfoMap() map[string]*fileInfo { return p.fileInfoMap } +// TempDir is the directory the patch is materialized into for rendering, and "" when +// there is none — no patch, or a directory we failed to make. +func (p *PatchBuilder) TempDir() string { + p.mutex.Lock() + defer p.mutex.Unlock() + + return p.tempDir +} + +// Generation says which version of the patch this is; see the field. +func (p *PatchBuilder) Generation() int { + p.mutex.Lock() + defer p.mutex.Unlock() + + return p.generation +} + +// makeTempDir replaces the directory the patch is materialized into with a fresh one. +// Only call this with the lock held. +func (p *PatchBuilder) makeTempDir() { + p.removeTempDir() + if p.newTempDir == nil { + return + } + dir, err := p.newTempDir() + if err != nil { + p.Log.Error(err) + return + } + p.tempDir = dir +} + +// removeTempDir takes the patch's materialized form away with the patch. Only call this +// with the lock held. +func (p *PatchBuilder) removeTempDir() { + if p.tempDir == "" { + return + } + if err := os.RemoveAll(p.tempDir); err != nil { + p.Log.Error(err) + } + p.tempDir = "" +} + +// PatchFile records what materializing the patch needs to know about one of its files: +// where the patch expects to find it, and where its content before the patch comes from. +type PatchFile struct { + // Path is the name the patch knows the file by: for a renamed file, the name it had + // before where the patch carries the rename, and the name it was renamed to where the + // patch keeps only a content change and leaves the rename behind. + Path string + // ContentPath is where the file's content before the patch is to be found in the + // commit the patch is built from — for a renamed file always the name it had there, + // whatever the patch calls it. + ContentPath string +} + +// FilesInPatch says which files the patch touches, in a stable order, and where each of +// them comes from. +func (p *PatchBuilder) FilesInPatch() []PatchFile { + fileInfoMap := p.snapshotFileInfoMap() + + filenames := maps.Keys(fileInfoMap) + sort.Strings(filenames) + + files := make([]PatchFile, 0, len(filenames)) + for _, filename := range filenames { + info := fileInfoMap[filename] + if info.mode == UNSELECTED { + continue + } + file := PatchFile{Path: filename, ContentPath: filename} + if info.previousPath != "" { + file.ContentPath = info.previousPath + if info.mode == WHOLE { + file.Path = info.previousPath + } + } + files = append(files, file) + } + return files +} + func (p *PatchBuilder) PatchToApply(reverse bool, turnAddedFilesIntoDiffAgainstEmptyFile bool) string { var patch strings.Builder @@ -136,6 +238,7 @@ func (p *PatchBuilder) AddFileWhole(filename string, previousPath string) error return err } + p.generation++ p.addFileWhole(info) return nil @@ -147,6 +250,7 @@ func (p *PatchBuilder) RemoveFile(filename string, previousPath string) error { return err } + p.generation++ p.removeFile(info) return nil @@ -183,6 +287,7 @@ func (p *PatchBuilder) AddFileLineRange(filename string, previousPath string, li if err != nil { return err } + p.generation++ info.mode = PART info.includedLineIndices = lo.Union(info.includedLineIndices, lineIndices) @@ -194,6 +299,7 @@ func (p *PatchBuilder) RemoveFileLineRange(filename string, previousPath string, if err != nil { return err } + p.generation++ info.mode = PART info.includedLineIndices, _ = lo.Difference(info.includedLineIndices, lineIndices) if len(info.includedLineIndices) == 0 { @@ -380,6 +486,9 @@ func (p *PatchBuilder) Reset() { p.mutex.Lock() defer p.mutex.Unlock() + p.generation++ + p.removeTempDir() + p.To = "" p.fileInfoMap = map[string]*fileInfo{} } diff --git a/pkg/commands/patch/patch_builder_test.go b/pkg/commands/patch/patch_builder_test.go index 9fd8caf80..cb6bb3684 100644 --- a/pkg/commands/patch/patch_builder_test.go +++ b/pkg/commands/patch/patch_builder_test.go @@ -13,7 +13,10 @@ func newTestPatchBuilder(diff string) *PatchBuilder { patchBuilder := NewPatchBuilder(logrus.New().WithField("test", "test"), func(from string, to string, reverse bool, filename string, previousPath string) (string, error) { return diff, nil - }) + }, + // Nothing here renders the patch, so it needs no directory to be + // materialized into. + nil) patchBuilder.Start("from", "to", false, true) return patchBuilder } @@ -64,6 +67,35 @@ func TestIncludedLineIdentities(t *testing.T) { patchBuilder.IncludedLineIdentities("filename")) } +func TestFilesInPatch(t *testing.T) { + patchBuilder := newTestPatchBuilder(simpleDiff) + + // A file no part of the patch is no part of what the patch is materialized from. + assert.Empty(t, patchBuilder.FilesInPatch()) + + assert.NoError(t, patchBuilder.AddFileLineRange("filename", "", []int{6})) + assert.Equal(t, + []PatchFile{{Path: "filename", ContentPath: "filename"}}, + patchBuilder.FilesInPatch()) +} + +// A renamed file's content is under the name it had before whatever the patch calls the +// file, and the patch calls it by the name it had before only where it carries the +// rename — a partial selection has the rename stripped and names the file by the new one. +func TestFilesInPatchOfARenamedFile(t *testing.T) { + patchBuilder := newTestPatchBuilder(renameWithModificationDiff) + + assert.NoError(t, patchBuilder.AddFileLineRange("newname", "oldname", []int{9})) + assert.Equal(t, + []PatchFile{{Path: "newname", ContentPath: "oldname"}}, + patchBuilder.FilesInPatch()) + + assert.NoError(t, patchBuilder.AddFileWhole("newname", "oldname")) + assert.Equal(t, + []PatchFile{{Path: "oldname", ContentPath: "oldname"}}, + patchBuilder.FilesInPatch()) +} + // A file taken into the patch whole has every one of its change lines in it. func TestIncludedLineIdentitiesOfAWholeFile(t *testing.T) { patchBuilder := newTestPatchBuilder(simpleDiff) diff --git a/pkg/gui/controllers/local_commits_controller.go b/pkg/gui/controllers/local_commits_controller.go index 72c99d2ad..983ca789f 100644 --- a/pkg/gui/controllers/local_commits_controller.go +++ b/pkg/gui/controllers/local_commits_controller.go @@ -722,17 +722,30 @@ func (self *LocalCommitsController) GetOnRenderToMain() func() { } } +// secondaryPatchPanelUpdateOpts renders the custom patch being built into the pane +// beside the diff it is being built from, as a diff of the two trees the patch is +// materialized into. This way it is shown by whatever renders the rest of the diffs, +// and its lines can be pointed at and taken back out of the patch. func secondaryPatchPanelUpdateOpts(c *ControllerCommon) *types.ViewUpdateOpts { - if c.Git().Patch.PatchBuilder.Active() { - patch := c.Git().Patch.PatchBuilder.RenderAggregatedPatch(false) - - return &types.ViewUpdateOpts{ - Task: types.NewMainViewDiffStringTask(patch), - Title: c.Tr.CustomPatch, - } + if !c.Git().Patch.PatchBuilder.Active() { + return nil } - return nil + // A render of the same patch reuses the trees; only a change to the patch writes them + // again. + if err := c.Git().Patch.EnsureCustomPatchDiffTrees(); err != nil { + c.Log.Error(err) + } + + // The same mode as the diff beside it: both panes of the pair have to agree about + // whether what they show can be acted on. + mode := c.Helpers().DiffLine.MainViewDiffMode() + cmdObj := c.Git().Diff.CustomPatchDiffCmdObj(c.Git().Patch.PatchBuilder.TempDir(), mode) + + return &types.ViewUpdateOpts{ + Task: types.NewMainViewDiffTask(cmdObj.GetCmd(), mode), + Title: c.Tr.CustomPatch, + } } func (self *LocalCommitsController) squashDown(selectedCommits []*models.Commit, startIdx int, endIdx int) error { diff --git a/pkg/gui/presentation/files_test.go b/pkg/gui/presentation/files_test.go index 74d8f1369..9a92731a0 100644 --- a/pkg/gui/presentation/files_test.go +++ b/pkg/gui/presentation/files_test.go @@ -230,6 +230,7 @@ M file1 func(from string, to string, reverse bool, filename string, previousPath string) (string, error) { return "", nil }, + nil, ) patchBuilder.Start("from", "to", false, false) result := RenderCommitFileTree(viewModel, patchBuilder, false, &config.CustomIconsConfig{}) diff --git a/pkg/gui/types/rendering.go b/pkg/gui/types/rendering.go index ac8932a9b..9710ca948 100644 --- a/pkg/gui/types/rendering.go +++ b/pkg/gui/types/rendering.go @@ -69,9 +69,6 @@ func NewRenderStringTask(str string) *RenderStringTask { type RenderStringWithoutScrollTask struct { Str string - - // contentIsDiff marks a string that is a panel's own diff; see ContentIsDiff. - contentIsDiff bool } func (t *RenderStringWithoutScrollTask) IsUpdateTask() {} @@ -80,16 +77,6 @@ func NewRenderStringWithoutScrollTask(str string) *RenderStringWithoutScrollTask return &RenderStringWithoutScrollTask{Str: str} } -// NewMainViewDiffStringTask returns the task for rendering a diff we hold as text -// rather than as a command to run — the custom patch being built, which we assemble -// ourselves. The view stays where it is, the patch being rendered again on every -// change to it. -func NewMainViewDiffStringTask(str string) UpdateTask { - task := NewRenderStringWithoutScrollTask(str) - task.contentIsDiff = true - return task -} - type RenderStringWithScrollTask struct { Str string OriginX int @@ -166,8 +153,6 @@ func NewMainViewDiffTaskWithPrefix(cmd *exec.Cmd, prefix string, mode git_comman // such a render that there is anything to point at. func ContentIsDiff(task UpdateTask) bool { switch task := task.(type) { - case *RenderStringWithoutScrollTask: - return task.contentIsDiff case *RunCommandTask: return task.contentIsDiff case *RunDiffRendererTask: diff --git a/pkg/integration/tests/main_view/custom_patch_goes_through_the_diff_renderer.go b/pkg/integration/tests/main_view/custom_patch_goes_through_the_diff_renderer.go new file mode 100644 index 000000000..96bbdda8c --- /dev/null +++ b/pkg/integration/tests/main_view/custom_patch_goes_through_the_diff_renderer.go @@ -0,0 +1,50 @@ +package main_view + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var CustomPatchGoesThroughTheDiffRenderer = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "The custom patch is shown by the configured diff renderer, being rendered as a diff of real files rather than assembled by us", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(cfg *config.AppConfig) { + cfg.GetUserConfig().Gui.UseHunkModeInStagingView = false + // A renderer that announces the metadata protocol — so that focusing the main + // view keeps its output — and says who it is above the diff it passes through. + cfg.GetUserConfig().Git.DiffRenderers = []config.DiffRendererConfig{ + {Command: `printf '\033]1717;1\007RENDERED BY ME\n'; cat`}, + } + }, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("file1", "one\ntwo\nthree\n") + shell.Commit("first commit") + + shell.UpdateFileAndAdd("file1", "one\nTWO\nthree\n") + shell.Commit("second commit") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Commits(). + Focus(). + Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + Content(Contains("RENDERED BY ME")). + SelectedLines( + Contains("-two"), + ). + PressPrimaryAction() + + // The patch is a diff like any other, so the renderer has had it too. git worked + // out its context, and that is where the unchanged line either side comes from. + t.Views().Secondary(). + Content(Contains("RENDERED BY ME")). + ContainsLines( + Contains(" one"), + Contains("-two"), + Contains(" three"), + ) + }, +}) diff --git a/pkg/integration/tests/main_view/custom_patch_ignores_line_ending_conversion.go b/pkg/integration/tests/main_view/custom_patch_ignores_line_ending_conversion.go new file mode 100644 index 000000000..5d5e131a2 --- /dev/null +++ b/pkg/integration/tests/main_view/custom_patch_ignores_line_ending_conversion.go @@ -0,0 +1,52 @@ +package main_view + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var CustomPatchIgnoresLineEndingConversion = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "The custom patch is shown as git states it on a machine that checks files out with CRLF", + ExtraCmdArgs: []string{}, + Skip: false, + // The patch is materialized into trees of its own outside the repo, so what the repo + // says about line endings never reaches the commands over them. These variables are + // how a setting reaches a git command wherever it runs. + ExtraEnvVars: map[string]string{ + "GIT_CONFIG_COUNT": "1", + "GIT_CONFIG_KEY_0": "core.autocrlf", + "GIT_CONFIG_VALUE_0": "true", + }, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("file1", "one\ntwo\nthree\n") + shell.Commit("first commit") + + shell.UpdateFileAndAdd("file1", "one\nthree\n") + shell.Commit("second commit") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Commits(). + Focus(). + Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + SelectedLines( + Contains("-two"), + ). + PressPrimaryAction() + + // The patch is one line and its context, so that is all the two trees differ in. + // A tree written in the form the platform checks files out in would differ from + // the other in every line instead. + t.Views().Secondary(). + ContainsLines( + Contains(" one"), + Contains("-two"), + Contains(" three"), + ). + // git says nothing about a round trip these files never take. + Content(DoesNotContain("warning")) + }, +}) diff --git a/pkg/integration/tests/patch_building/renamed_file_whole.go b/pkg/integration/tests/patch_building/renamed_file_whole.go index f4151a766..71b0cbedc 100644 --- a/pkg/integration/tests/patch_building/renamed_file_whole.go +++ b/pkg/integration/tests/patch_building/renamed_file_whole.go @@ -36,11 +36,16 @@ var RenamedFileWhole = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Information().Content(Contains("Building patch")) - // The whole file is added, so the patch carries the rename itself. + // The whole file is added, so the patch carries the rename itself, and the diff + // the patch is shown as carries it too. The trees the patch is materialized into + // are what git names in the rename lines, the paths of the two sides being all it + // has to go by. t.Views().Secondary(). ContainsLines( - Contains("rename from original"), - Contains("rename to renamed"), + Contains("diff --git a/original b/renamed"), + Contains("similarity index"), + Contains("rename from a/original"), + Contains("rename to b/renamed"), ) t.Common().SelectPatchOption(Contains("Remove patch from original commit")) diff --git a/pkg/integration/tests/patch_building/specific_selection.go b/pkg/integration/tests/patch_building/specific_selection.go index 2e140e41c..723bde864 100644 --- a/pkg/integration/tests/patch_building/specific_selection.go +++ b/pkg/integration/tests/patch_building/specific_selection.go @@ -134,14 +134,14 @@ var SpecificSelection = NewIntegrationTest(NewIntegrationTestArgs{ Contains(`index`), Contains(`--- a/hunk-file`), Contains(`+++ b/hunk-file`), - Contains(`@@ -1,6 +1,6 @@`), + // git works out the context of the patch's own hunks, which is why + // this is not the six lines the file's diff had + Contains(`@@ -1,4 +1,4 @@`), Contains(`-1a`), Contains(`+aa`), Contains(` 1b`), Contains(` 1c`), Contains(` 1d`), - Contains(` 1e`), - Contains(` 1f`), // line-file patch Contains(`diff --git a/line-file b/line-file`), Contains(`index`), diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 8143e97bf..9d7bfa4a0 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -375,6 +375,8 @@ var tests = []*components.IntegrationTest{ main_view.CommitFromMainView, main_view.CopyRowsThatAreNoDiffLine, main_view.CopySelectedDiffLines, + main_view.CustomPatchGoesThroughTheDiffRenderer, + main_view.CustomPatchIgnoresLineEndingConversion, main_view.DiscardDiffLines, main_view.DiscardFromACommitOnlyWhereItCanBeRewritten, main_view.DiscardLinesFromACommit,