[SQUASHED] show-commit-msg-diff-for-amend-commits

This commit is contained in:
Stefan Haller 2026-09-03 10:13:07 +02:00
parent 68d6f69d37
commit 822b3be988
17 changed files with 795 additions and 35 deletions

View file

@ -6,6 +6,7 @@ import (
"github.com/go-errors/errors"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/samber/lo"
)
var ErrInvalidCommitIndex = errors.New("invalid commit index")
@ -155,13 +156,39 @@ func (self *CommitCommands) signoffFlag() string {
}
func (self *CommitCommands) GetCommitMessage(commitHash string) (string, error) {
messages, err := self.GetCommitMessages([]string{commitHash})
if err != nil {
return "", err
}
return messages[0], nil
}
// GetCommitMessages returns the messages of the given commits, in the order in
// which the hashes were passed in.
func (self *CommitCommands) GetCommitMessages(commitHashes []string) ([]string, error) {
cmdArgs := NewGitCmd("log").
Arg("--format=%B", "--max-count=1", commitHash).
Arg("--no-walk=unsorted", "--format=%B%x00").
Arg(commitHashes...).
Config("log.showsignature=false").
ToArgv()
message, err := self.cmd.New(cmdArgs).DontLog().RunWithOutput()
return strings.ReplaceAll(strings.TrimSpace(message), "\r\n", "\n"), err
output, err := self.cmd.New(cmdArgs).DontLog().RunWithOutput()
if err != nil {
return nil, err
}
// The messages are NUL-terminated, so the split gives us one more element
// than we asked for (holding the newline that git prints after the last
// message).
messages := strings.Split(output, "\x00")
if len(messages) <= len(commitHashes) {
return nil, errors.New("unexpected output from git log")
}
return lo.Map(messages[:len(commitHashes)], func(message string, _ int) string {
return strings.ReplaceAll(strings.TrimSpace(message), "\r\n", "\n")
}), nil
}
func (self *CommitCommands) GetCommitSubject(commitHash string) (string, error) {

View file

@ -378,7 +378,7 @@ func TestGetCommitMsg(t *testing.T) {
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
instance := buildCommitCommands(commonDeps{
runner: oscommands.NewFakeRunner(t).ExpectGitArgs([]string{"-c", "log.showsignature=false", "log", "--format=%B", "--max-count=1", "deadbeef"}, s.input, nil),
runner: oscommands.NewFakeRunner(t).ExpectGitArgs([]string{"-c", "log.showsignature=false", "log", "--no-walk=unsorted", "--format=%B%x00", "deadbeef"}, s.input+"\x00\n", nil),
})
output, err := instance.GetCommitMessage("deadbeef")
@ -390,6 +390,21 @@ func TestGetCommitMsg(t *testing.T) {
}
}
func TestGetCommitMessages(t *testing.T) {
instance := buildCommitCommands(commonDeps{
runner: oscommands.NewFakeRunner(t).ExpectGitArgs(
[]string{"-c", "log.showsignature=false", "log", "--no-walk=unsorted", "--format=%B%x00", "deadbeef", "1234567"},
"first subject\n\nfirst body\n\x00\nsecond subject\n\x00\n",
nil,
),
})
output, err := instance.GetCommitMessages([]string{"deadbeef", "1234567"})
assert.NoError(t, err)
assert.Equal(t, []string{"first subject\n\nfirst body", "second subject"}, output)
}
func TestGetCommitMessageFromHistory(t *testing.T) {
type scenario struct {
testName string
@ -406,7 +421,7 @@ func TestGetCommitMessageFromHistory(t *testing.T) {
},
{
"Default case to retrieve a commit in history",
oscommands.NewFakeRunner(t).ExpectGitArgs([]string{"log", "-1", "--skip=2", "--pretty=%H"}, "hash3 \n", nil).ExpectGitArgs([]string{"-c", "log.showsignature=false", "log", "--format=%B", "--max-count=1", "hash3"}, `use generics to DRY up context code`, nil),
oscommands.NewFakeRunner(t).ExpectGitArgs([]string{"log", "-1", "--skip=2", "--pretty=%H"}, "hash3 \n", nil).ExpectGitArgs([]string{"-c", "log.showsignature=false", "log", "--no-walk=unsorted", "--format=%B%x00", "hash3"}, "use generics to DRY up context code\x00\n", nil),
func(output string, err error) {
assert.NoError(t, err)
assert.Equal(t, "use generics to DRY up context code", output)

View file

@ -121,6 +121,11 @@ func buildSubmoduleCommands(deps commonDeps) *SubmoduleCommands {
return NewSubmoduleCommands(gitCommon)
}
func buildDiffCommands(deps commonDeps) *DiffCommands {
gitCommon := buildGitCommon(deps)
return NewDiffCommands(gitCommon)
}
func buildCommitCommands(deps commonDeps) *CommitCommands {
gitCommon := buildGitCommon(deps)
return NewCommitCommands(gitCommon)

View file

@ -3,10 +3,12 @@ package git_commands
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/jesseduffield/lazygit/pkg/config"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/mgutz/str"
)
@ -189,6 +191,92 @@ func (self *DiffCommands) GetDiff(staged bool, additionalArgs ...string) (string
).DontLog().RunWithOutput()
}
// NamedText is a text to be diffed by RenderedTextDiff, along with the name to
// show for it in the diff. The name doubles as the name of the temporary file
// holding the text, so it has to be usable as a file name.
type NamedText struct {
Name string
Content string
}
// RenderedTextDiff returns a diff of the two given texts, produced by the
// configured diff renderer in the same way as every other diff we show. width
// and height are the size of the view the diff is going to be shown in; a diff
// renderer lays its output out for them.
//
// git can only diff files, so the texts are written to temporary files named
// after them. Under a diff renderer those names are what the diff calls the two
// sides; under git's own diff they go away with the rest of the header.
func (self *DiffCommands) RenderedTextDiff(before NamedText, after NamedText, width int, height int) (string, error) {
dir, err := os.MkdirTemp(self.os.GetTempDir(), "textdiff-")
if err != nil {
return "", err
}
defer os.RemoveAll(dir)
for _, text := range []NamedText{before, after} {
content := text.Content
// End the file with a newline, or git says that it doesn't, which tells
// a reader of the diff nothing about the two texts.
if content != "" && !strings.HasSuffix(content, "\n") {
content += "\n"
}
if err := self.os.CreateFileWithContent(filepath.Join(dir, text.Name), content); err != nil {
return "", err
}
}
cmdObj := self.cmd.New(
NewGitCmd("diff").
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), DiffModeRendered).
Arg("--no-index", "--no-prefix").
Arg(fmt.Sprintf("--color=%s", self.diffRendererConfigManager.GetColorArg())).
Arg("--", before.Name, after.Name).
Dir(dir).
ToArgv(),
).DontLog()
// --no-index implies --exit-code, so git exits with a non-zero status
// whenever the two texts differ; that is only an error if it left us with
// nothing to show.
if self.diffRendererConfigManager.GetDiffRendererType() == config.DiffRendererType_RawGit {
// git renders the diff itself here, so no terminal is needed to get it.
output, err := cmdObj.RunWithOutput()
if output == "" && err != nil {
return "", err
}
return stripDiffHeaders(output), nil
}
oscommands.SetDumbTerminalEnv(cmdObj.GetCmd())
cmdObj.AddEnvVars(
// For diff renderer scripts that can't query the terminal width directly.
fmt.Sprintf("LAZYGIT_COLUMNS=%d", width),
"GIT_PAGER="+self.diffRendererConfigManager.GetStdinFilterCommand(width),
)
output, err := oscommands.RunInPtyWithOutput(cmdObj.GetCmd(), uint16(width), uint16(height))
if output == "" && err != nil {
return "", err
}
return output, nil
}
// Strips the file header and the first hunk header from a diff. For a diff of
// two temporary files these say nothing that a reader could make sense of.
func stripDiffHeaders(diff string) string {
lines := strings.SplitAfter(diff, "\n")
for i, line := range lines {
if strings.HasPrefix(utils.Decolorise(line), "@@ ") {
return strings.Join(lines[i+1:], "")
}
}
return diff
}
type DiffToolCmdOptions struct {
// The path to show a diff for. Pass "." for the entire repo.
Filepath string

View file

@ -0,0 +1,126 @@
package git_commands
import (
"os"
"path/filepath"
"testing"
"github.com/go-errors/errors"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/jesseduffield/lazygit/pkg/config"
"github.com/stretchr/testify/assert"
)
func TestRenderedTextDiff(t *testing.T) {
var args []string
var dir string
var beforeContent, afterContent string
// The two texts are diffed as files in a temporary directory that is removed
// again afterwards, so read them here, while they are still there.
runner := oscommands.NewFakeRunner(t).ExpectFunc("text diff",
func(cmdObj *oscommands.CmdObj) bool {
args = cmdObj.GetCmd().Args
dir = args[2] // git -C <dir>
before, _ := os.ReadFile(filepath.Join(dir, "old message"))
after, _ := os.ReadFile(filepath.Join(dir, "new message"))
beforeContent, afterContent = string(before), string(after)
return true
},
"\x1b[1mdiff --git old message new message\x1b[m\n"+
"\x1b[1mindex 9ebe4a6..ec3885a 100644\x1b[m\n"+
"\x1b[1m--- old message\x1b[m\n"+
"\x1b[1m+++ new message\x1b[m\n"+
"\x1b[36m@@ -1,3 +1,3 @@\x1b[m\n"+
"-Fix the widget\n"+
"+Fix the widget on startup\n",
// --no-index implies --exit-code
errors.New("exit status 1"))
instance := buildDiffCommands(commonDeps{runner: runner})
output, err := instance.RenderedTextDiff(
NamedText{Name: "old message", Content: "Fix the widget\n"},
NamedText{Name: "new message", Content: "Fix the widget on startup\n"},
80, 24)
assert.NoError(t, err)
assert.Equal(t, "-Fix the widget\n+Fix the widget on startup\n", output)
assert.Equal(t, []string{
"git", "-C", dir, "diff", "--no-ext-diff", "--unified=3", "--find-renames=50%",
"--no-index", "--no-prefix", "--color=always", "--", "old message", "new message",
}, args)
assert.Equal(t, "Fix the widget\n", beforeContent)
assert.Equal(t, "Fix the widget on startup\n", afterContent)
assert.NoDirExists(t, dir)
}
func TestRenderedTextDiffPassesOnTheRenderersGitArgs(t *testing.T) {
userConfig := config.GetDefaultConfig()
userConfig.Git.DiffRenderers = []config.DiffRendererConfig{
{Type: "rawGit", Args: []string{"--color-words"}},
}
var args []string
runner := oscommands.NewFakeRunner(t).ExpectFunc("text diff",
func(cmdObj *oscommands.CmdObj) bool {
args = cmdObj.GetCmd().Args
return true
}, "", nil)
instance := buildDiffCommands(commonDeps{runner: runner, userConfig: userConfig})
_, err := instance.RenderedTextDiff(
NamedText{Name: "old message", Content: "one"},
NamedText{Name: "new message", Content: "two"},
80, 24)
assert.NoError(t, err)
assert.Contains(t, args, "--color-words")
}
func TestStripDiffHeaders(t *testing.T) {
scenarios := []struct {
name string
diff string
expectedOutput string
}{
{
name: "colored diff",
diff: "\x1b[1mdiff --git old message new message\x1b[m\n" +
"\x1b[1mindex 9ebe4a6..ec3885a 100644\x1b[m\n" +
"\x1b[1m--- old message\x1b[m\n" +
"\x1b[1m+++ new message\x1b[m\n" +
"\x1b[36m@@ -1,3 +1,3 @@\x1b[m\n" +
"one \x1b[32mtwo\x1b[m\n",
expectedOutput: "one \x1b[32mtwo\x1b[m\n",
},
{
name: "uncolored diff with several hunks",
diff: "diff --git old message new message\n" +
"index 9ebe4a6..ec3885a 100644\n" +
"--- old message\n" +
"+++ new message\n" +
"@@ -1,3 +1,3 @@\n" +
"one two\n" +
"@@ -20,3 +20,3 @@\n" +
"three four\n",
expectedOutput: "one two\n" +
"@@ -20,3 +20,3 @@\n" +
"three four\n",
},
{
name: "empty diff",
diff: "",
expectedOutput: "",
},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
assert.Equal(t, s.expectedOutput, stripDiffHeaders(s.diff))
})
}
}

View file

@ -3,6 +3,10 @@ package oscommands
import (
"io"
"os"
"os/exec"
"strings"
"github.com/samber/lo"
)
// Pty is the master side of a pseudo-terminal running a subprocess. The
@ -32,3 +36,51 @@ type StartedPty struct {
// Implemented per-platform in pty_unix.go / pty_windows.go.
//
// func StartPty(cmd *exec.Cmd, cols, rows uint16) (StartedPty, error)
// RunInPtyWithOutput runs cmd in a pseudo-terminal of the given size and returns
// everything it wrote. Commands that render for a terminal need one to render at
// all: git only pipes its output through a pager when it thinks it is talking to
// a terminal, and the pager itself commonly decides whether to use color the same
// way.
func RunInPtyWithOutput(cmd *exec.Cmd, cols uint16, rows uint16) (string, error) {
startedPty, err := StartPty(cmd, cols, rows)
if err != nil {
return "", err
}
defer startedPty.Pty.Close()
// Reading from the master side of a pty fails as soon as the child has closed
// the other side, so an error here only tells us that the command is done.
// What it wrote before that is what we came for.
output, _ := io.ReadAll(startedPty.Pty)
// A terminal ends each line with a carriage return, which is of no use to a
// caller that treats the output as text.
return strings.ReplaceAll(string(output), "\r\n", "\n"), startedPty.Wait()
}
// SetDumbTerminalEnv tells a command that runs in a pty that we're in a very
// simple terminal that they should not expect to have much capabilities.
// Moving the cursor, clearing the screen, or querying for colors are among such
// "advanced" capabilities.
// Context: https://github.com/jesseduffield/lazygit/issues/3419
func SetDumbTerminalEnv(cmd *exec.Cmd) {
cmd.Env = append(removeExistingTermEnvVars(cmd.Env), "TERM=dumb")
}
func removeExistingTermEnvVars(env []string) []string {
return lo.Filter(env, func(envVar string, _ int) bool {
return !isTermEnvVar(envVar)
})
}
// Terminals set a variety of different environment variables
// to identify themselves to processes. This list should catch the most common among them.
func isTermEnvVar(envVar string) bool {
return strings.HasPrefix(envVar, "TERM=") ||
strings.HasPrefix(envVar, "TERM_PROGRAM=") ||
strings.HasPrefix(envVar, "TERM_PROGRAM_VERSION=") ||
strings.HasPrefix(envVar, "TERMINAL_EMULATOR=") ||
strings.HasPrefix(envVar, "TERMINAL_NAME=") ||
strings.HasPrefix(envVar, "TERMINAL_VERSION_")
}

View file

@ -1,6 +1,7 @@
package config
import (
"fmt"
"strconv"
"strings"
@ -108,6 +109,18 @@ func (self *DiffRendererConfigManager) GetRawGitArgs() []string {
return currentDiffRendererConfig.Args
}
// Signature is what identifies the current diff renderer, so that something we
// remembered about what it produced can be dropped once it no longer describes
// the renderer we have. The width a command is asked for is no part of its
// identity, so a fixed one is used.
func (self *DiffRendererConfigManager) Signature() string {
return fmt.Sprintf("%d\x00%s\x00%s\x00%s",
self.diffRendererIndex,
self.GetExternalDiffCommand(3),
self.GetStdinFilterCommand(0),
strings.Join(self.GetRawGitArgs(), "\x00"))
}
func (self *DiffRendererConfigManager) CycleDiffRenderers() {
self.diffRendererIndex = (self.diffRendererIndex + 1) % len(self.getUserConfig().Git.DiffRenderers)
}

View file

@ -1,6 +1,7 @@
package helpers
import (
"fmt"
"strings"
"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
@ -10,6 +11,7 @@ import (
"github.com/jesseduffield/lazygit/pkg/gui/modes/diffing"
"github.com/jesseduffield/lazygit/pkg/gui/style"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/samber/lo"
)
@ -18,12 +20,19 @@ type DiffHelper struct {
// diffLineHelper says how a diff for the main view is to be produced, which depends
// on whether the focused main view could act on what a diff renderer would make of it.
diffLineHelper *DiffLineHelper
// Diffs of the messages of "amend!" commits, keyed by everything that
// shapes them: the two commits, and the diff renderer and width they were
// rendered by. An empty diff means that the commit doesn't change the
// message. Only accessed on the UI thread, while rendering the main view.
commitMessageDiffs map[string]string
}
func NewDiffHelper(c *HelperCommon, diffLineHelper *DiffLineHelper) *DiffHelper {
return &DiffHelper{
c: c,
diffLineHelper: diffLineHelper,
c: c,
diffLineHelper: diffLineHelper,
commitMessageDiffs: make(map[string]string),
}
}
@ -56,7 +65,13 @@ func (self *DiffHelper) DiffArgs() []string {
// and the refRange for a range selection. If the refRange is nil (meaning that
// either there's no range, or it can't be diffed for some reason), then we want
// to fall back to rendering the diff for the single commit.
func (self *DiffHelper) GetUpdateTaskForRenderingCommitsDiff(commit *models.Commit, refRange *types.RefRange) types.UpdateTask {
// In addition, we need to pass the list of all commits; this is needed for
// showing the commit message diff for "amend!" commits.
func (self *DiffHelper) GetUpdateTaskForRenderingCommitsDiff(
commits []*models.Commit,
commit *models.Commit,
refRange *types.RefRange,
) types.UpdateTask {
mode := self.diffLineHelper.MainViewDiffMode()
if refRange != nil {
@ -84,7 +99,81 @@ func (self *DiffHelper) GetUpdateTaskForRenderingCommitsDiff(commit *models.Comm
}
cmdObj := self.c.Git().Commit.ShowCmdObj(commit.Hash(), self.FilterPathsForCommit(commit), mode)
return types.NewMainViewDiffTask(cmdObj.GetCmd(), mode)
return types.NewMainViewDiffTaskWithPrefix(cmdObj.GetCmd(), self.commitMessageDiffPrefix(commits, commit), mode)
}
// For an "amend!" commit, returns a diff of the commit message it sets against
// the message it replaces, to be shown above the commit's own diff. Returns an
// empty string for any other commit, and for an "amend!" commit that only
// changes the contents of the commit it applies to.
func (self *DiffHelper) commitMessageDiffPrefix(commits []*models.Commit, commit *models.Commit) string {
previousCommit, ok := findCommitWithPreviousMessage(commits, commit)
if !ok {
return ""
}
width, height := self.c.Contexts().Normal.GetView().InnerSize()
diff := self.commitMessageDiff(commit, previousCommit, width, height)
if diff == "" {
return ""
}
header := style.FgYellow.Sprintf("%s\n", utils.ResolvePlaceholderString(
self.c.Tr.CommitMessageChanges,
map[string]string{"hash": previousCommit.ShortHash()},
))
rule := strings.Repeat("─", width) + "\n"
return header + diff + rule
}
// The names the two messages are diffed under. A diff renderer shows them as the
// names of the files being diffed, so they are what tells the reader which side
// is which. They are not translated because they end up as file names, and git
// mangles paths outside of ASCII when it states them in a diff.
const (
oldMessageName = "old message"
newMessageName = "new message"
)
func (self *DiffHelper) commitMessageDiff(
commit *models.Commit, previousCommit *models.Commit, width int, height int,
) string {
// The diff renderer lays the diff out, and lays it out for the width it is
// shown at, so both belong in the key along with the two messages.
key := fmt.Sprintf("%s\x00%s\x00%d\x00%s",
commit.Hash(),
previousCommit.Hash(),
width,
self.c.State().GetDiffRendererConfigManager().Signature())
if diff, ok := self.commitMessageDiffs[key]; ok {
return diff
}
messages, err := self.c.Git().Commit.GetCommitMessages([]string{previousCommit.Hash(), commit.Hash()})
if err != nil {
self.c.Log.Error(err)
return ""
}
before := messageAfterAmending(messages[0])
after := messageAfterAmending(messages[1])
diff := ""
if before != after {
diff, err = self.c.Git().Diff.RenderedTextDiff(
git_commands.NamedText{Name: oldMessageName, Content: before},
git_commands.NamedText{Name: newMessageName, Content: after},
width, height)
if err != nil {
self.c.Log.Error(err)
return ""
}
}
self.commitMessageDiffs[key] = diff
return diff
}
// PlainDiffBetweenRefs returns the diff of the given files between two refs as git

View file

@ -409,3 +409,62 @@ func IsFixupCommit(subject string) (string, bool) {
return subject, false
}
// Check whether the given subject line is the subject of an "amend!" commit,
// i.e. of a commit that replaces the message of the commit it applies to, and
// return the subject of that commit if so. Note that a commit with a subject
// like "fixup! amend! Bla" is not an "amend!" commit; only the outermost
// prefix decides what happens to the message.
func isAmendCommit(subject string) (string, bool) {
if !strings.HasPrefix(subject, "amend! ") {
return subject, false
}
return IsFixupCommit(subject)
}
// For an "amend!" commit, find the commit that holds the message it replaces.
// This is the nearest "amend!" commit below it that applies to the same commit,
// or, if there is none, the commit it applies to itself. Returns false if the
// given commit isn't an "amend!" commit, or if the commit it applies to isn't
// in the given list.
func findCommitWithPreviousMessage(commits []*models.Commit, commit *models.Commit) (*models.Commit, bool) {
baseSubject, isAmend := isAmendCommit(commit.Name)
if !isAmend {
return nil, false
}
_, index, ok := lo.FindIndexOf(commits, func(c *models.Commit) bool {
return c.Hash() == commit.Hash()
})
if !ok {
return nil, false
}
for _, previousCommit := range commits[index+1:] {
if previousCommit.Name == baseSubject {
return previousCommit, true
}
if subject, isAmend := isAmendCommit(previousCommit.Name); isAmend && subject == baseSubject {
return previousCommit, true
}
}
return nil, false
}
// Return the message that the given commit leaves on the commit it applies to:
// for an "amend!" commit this is its message without the "amend! <subject>"
// line at the top, and for any other commit it is simply its own message.
func messageAfterAmending(message string) string {
subject, body, found := strings.Cut(message, "\n")
if !found {
return message
}
if _, isAmend := isAmendCommit(subject); !isAmend {
return message
}
return strings.TrimLeft(body, "\n")
}

View file

@ -205,6 +205,187 @@ func TestFixupHelper_IsFixupCommit(t *testing.T) {
}
}
func TestFixupHelper_findCommitWithPreviousMessage(t *testing.T) {
hashPool := &utils.StringPool{}
type commitDesc struct {
Hash string
Name string
}
scenarios := []struct {
name string
commits []commitDesc
index int
expectedHash string
}{
{
name: "not an amend commit",
commits: []commitDesc{
{"abc123", "Some feature"},
},
index: 0,
expectedHash: "",
},
{
name: "fixup commits don't change the message",
commits: []commitDesc{
{"abc123", "fixup! Some feature"},
{"def456", "Some feature"},
},
index: 0,
expectedHash: "",
},
{
name: "a fixup of an amend commit doesn't change the message either",
commits: []commitDesc{
{"abc123", "fixup! amend! Some feature"},
{"def456", "amend! Some feature"},
{"ghi789", "Some feature"},
},
index: 0,
expectedHash: "",
},
{
name: "base commit right below the amend commit",
commits: []commitDesc{
{"abc123", "amend! Some feature"},
{"def456", "Some feature"},
},
index: 0,
expectedHash: "def456",
},
{
name: "base commit further down the list",
commits: []commitDesc{
{"abc123", "amend! Some feature"},
{"def456", "Unrelated commit"},
{"ghi789", "Some feature"},
},
index: 0,
expectedHash: "ghi789",
},
{
name: "amend commit in the middle of the list",
commits: []commitDesc{
{"abc123", "Unrelated commit"},
{"def456", "amend! Some feature"},
{"ghi789", "Some feature"},
},
index: 1,
expectedHash: "ghi789",
},
{
name: "the nearest earlier amend commit holds the message",
commits: []commitDesc{
{"abc123", "amend! Some feature"},
{"def456", "amend! Some feature"},
{"ghi789", "Some feature"},
},
index: 0,
expectedHash: "def456",
},
{
name: "fixup and squash commits in between are skipped",
commits: []commitDesc{
{"abc123", "amend! Some feature"},
{"def456", "fixup! Some feature"},
{"ghi789", "squash! Some feature"},
{"jkl012", "amend! Some feature"},
{"mno345", "Some feature"},
},
index: 0,
expectedHash: "jkl012",
},
{
name: "amend commit with several prefixes applies to the innermost subject",
commits: []commitDesc{
{"abc123", "amend! amend! Some feature"},
{"def456", "amend! Some feature"},
{"ghi789", "Some feature"},
},
index: 0,
expectedHash: "def456",
},
{
name: "base commit is not in the list",
commits: []commitDesc{
{"abc123", "amend! Some feature"},
{"def456", "Unrelated commit"},
},
index: 0,
expectedHash: "",
},
{
name: "base commit is above the amend commit",
commits: []commitDesc{
{"abc123", "Some feature"},
{"def456", "amend! Some feature"},
},
index: 1,
expectedHash: "",
},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
commits := lo.Map(s.commits, func(desc commitDesc, _ int) *models.Commit {
return models.NewCommit(hashPool, models.NewCommitOpts{Hash: desc.Hash, Name: desc.Name})
})
result, ok := findCommitWithPreviousMessage(commits, commits[s.index])
if s.expectedHash == "" {
assert.False(t, ok)
assert.Nil(t, result)
} else {
assert.True(t, ok)
assert.Equal(t, s.expectedHash, result.Hash())
}
})
}
}
func TestFixupHelper_messageAfterAmending(t *testing.T) {
scenarios := []struct {
name string
message string
expectedMessage string
}{
{
name: "subject only",
message: "Some feature",
expectedMessage: "Some feature",
},
{
name: "subject and body",
message: "Some feature\n\nSome description",
expectedMessage: "Some feature\n\nSome description",
},
{
name: "amend commit",
message: "amend! Some feature\n\nA better subject\n\nSome description",
expectedMessage: "A better subject\n\nSome description",
},
{
name: "amend commit without a replacement message",
message: "amend! Some feature",
expectedMessage: "amend! Some feature",
},
{
name: "fixup commit",
message: "fixup! amend! Some feature\n\nSome description",
expectedMessage: "fixup! amend! Some feature\n\nSome description",
},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
assert.Equal(t, s.expectedMessage, messageAfterAmending(s.message))
})
}
}
func TestFixupHelper_removeFixupCommits(t *testing.T) {
hashPool := &utils.StringPool{}

View file

@ -706,7 +706,8 @@ func (self *LocalCommitsController) GetOnRenderToMain() func() {
self.c.Tr.ExecCommandHere + "\n\n" + commit.Name)
} else {
refRange := self.context().GetSelectedRefRangeForDiffFiles()
task = self.c.Helpers().Diff.GetUpdateTaskForRenderingCommitsDiff(commit, refRange)
task = self.c.Helpers().Diff.GetUpdateTaskForRenderingCommitsDiff(
self.c.Model().Commits, commit, refRange)
}
self.c.RenderToMainViews(types.RefreshMainOpts{

View file

@ -46,7 +46,8 @@ func (self *SubCommitsController) GetOnRenderToMain() func() {
task = types.NewRenderStringTask("No commits")
} else {
refRange := self.context().GetSelectedRefRangeForDiffFiles()
task = self.c.Helpers().Diff.GetUpdateTaskForRenderingCommitsDiff(commit, refRange)
task = self.c.Helpers().Diff.GetUpdateTaskForRenderingCommitsDiff(
self.c.Model().SubCommits, commit, refRange)
}
self.c.RenderToMainViews(types.RefreshMainOpts{

View file

@ -14,7 +14,6 @@ import (
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/tasks"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/samber/lo"
)
func (gui *Gui) desiredPtySize(view *gocui.View) (cols, rows uint16) {
@ -104,12 +103,7 @@ func (gui *Gui) newPtyTask(view *gocui.View, cmd *exec.Cmd, prefix string) error
width = view.InnerWidth()
pager := gui.stateAccessor.GetDiffRendererConfigManager().GetStdinFilterCommand(width)
// This communicates to diff renderers that we're in a very simple
// terminal that they should not expect to have much capabilities.
// Moving the cursor, clearing the screen, or querying for colors are among such "advanced" capabilities.
// Context: https://github.com/jesseduffield/lazygit/issues/3419
cmd.Env = removeExistingTermEnvVars(cmd.Env)
cmd.Env = append(cmd.Env, "TERM=dumb")
oscommands.SetDumbTerminalEnv(cmd)
cmd.Env = append(cmd.Env, "GIT_PAGER="+pager)
@ -202,20 +196,3 @@ func withPtyGitConfig(args []string, goos string) []string {
result = append(result, "-c", "diff.autoRefreshIndex=false")
return append(result, args[1:]...)
}
func removeExistingTermEnvVars(env []string) []string {
return lo.Filter(env, func(envVar string, _ int) bool {
return !isTermEnvVar(envVar)
})
}
// Terminals set a variety of different environment variables
// to identify themselves to processes. This list should catch the most common among them.
func isTermEnvVar(envVar string) bool {
return strings.HasPrefix(envVar, "TERM=") ||
strings.HasPrefix(envVar, "TERM_PROGRAM=") ||
strings.HasPrefix(envVar, "TERM_PROGRAM_VERSION=") ||
strings.HasPrefix(envVar, "TERMINAL_EMULATOR=") ||
strings.HasPrefix(envVar, "TERMINAL_NAME=") ||
strings.HasPrefix(envVar, "TERMINAL_VERSION_")
}

View file

@ -693,6 +693,7 @@ type TranslationSet struct {
OpenCommandLogMenuTooltip string
ShowingGitDiff string
ShowingDiffForRange string
CommitMessageChanges string
CommitDiff string
CopyCommitHashToClipboard string
CommitHash string
@ -1846,6 +1847,7 @@ func EnglishTranslationSet() *TranslationSet {
OpenCommandLogMenuTooltip: "View options for the command log e.g. show/hide the command log and focus the command log.",
ShowingGitDiff: "Showing output for:",
ShowingDiffForRange: "Showing diff for range",
CommitMessageChanges: "Commit message changes compared to {{.hash}}:",
CommitDiff: "Commit diff",
CopyCommitHashToClipboard: "Copy abbreviated commit hash to clipboard",
CommitHash: "Commit hash",

View file

@ -0,0 +1,74 @@
package commit
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var ShowAmendCommitMessageDiff = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Show a diff of the commit message when selecting an amend! commit",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
shell.
EmptyCommitWithBody("Fix the widget",
"The frobnicator was not initialised properly at all.").
EmptyCommitWithBody("amend! Fix the widget",
"Fix the widget on startup\n\nThe frobnicator was not initialised at all.").
EmptyCommitWithBody("amend! Fix the widget",
"Fix the widget on startup\n\nThe frobnicator was not properly initialised at all.")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
// git's default colors for added and removed lines
green := "#008000"
red := "#800000"
t.Views().Commits().
Focus().
Lines(
Contains("amend! Fix the widget").IsSelected(),
Contains("amend! Fix the widget"),
Contains("Fix the widget"),
)
// The message of the topmost amend! commit is compared with the message
// of the amend! commit below it, not with the one of the commit they
// both apply to.
t.Views().Main().
TopLines(
Contains("Commit message changes compared to"),
Equals(" Fix the widget on startup"),
Equals(" "),
Equals("-The frobnicator was not initialised at all."),
Equals("+The frobnicator was not properly initialised at all."),
Contains("───"),
).
ContainsColoredText(green, "+The frobnicator was not properly initialised at all.").
ContainsColoredText(red, "-The frobnicator was not initialised at all.")
t.Views().Commits().
SelectNextItem()
// The other amend! commit is compared with the commit it applies to.
t.Views().Main().
TopLines(
Contains("Commit message changes compared to"),
Equals("-Fix the widget"),
Equals("+Fix the widget on startup"),
Equals(" "),
Equals("-The frobnicator was not initialised properly at all."),
Equals("+The frobnicator was not initialised at all."),
Contains("───"),
)
t.Views().Commits().
SelectNextItem()
// The commit that they both apply to gets no such header.
t.Views().Main().
TopLines(
Contains("commit "),
)
},
})

View file

@ -0,0 +1,48 @@
package commit
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var ShowAmendCommitMessageDiffWithRenderer = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Show the commit message diff of an amend! commit through a diff renderer",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(cfg *config.AppConfig) {
// cat does nothing to the diff, but it is a diff renderer as far as
// lazygit is concerned, so the diff takes the same route through it as
// it would for a real one.
cfg.GetUserConfig().Git.DiffRenderers = []config.DiffRendererConfig{{Command: "cat"}}
},
SetupRepo: func(shell *Shell) {
shell.
EmptyCommitWithBody("Fix the widget",
"The frobnicator was not initialised.").
EmptyCommitWithBody("amend! Fix the widget",
"Fix the widget on startup\n\nThe frobnicator was not initialised.")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Commits().
Focus().
Lines(
Contains("amend! Fix the widget").IsSelected(),
Contains("Fix the widget"),
)
// A diff renderer states the two sides of the diff in its own way, so
// unlike for git's own diff, the header naming them is kept.
t.Views().Main().
TopLines(
Contains("Commit message changes compared to"),
Equals("diff --git old message new message"),
).
ContainsLines(
Contains("--- old message"),
Contains("+++ new message"),
Contains("@@"),
Equals("-Fix the widget"),
Equals("+Fix the widget on startup"),
)
},
})

View file

@ -158,6 +158,8 @@ var tests = []*components.IntegrationTest{
commit.Search,
commit.SetAuthor,
commit.SetAuthorRange,
commit.ShowAmendCommitMessageDiff,
commit.ShowAmendCommitMessageDiffWithRenderer,
commit.StageRangeOfLines,
commit.Staged,
commit.StagedWithoutHooks,