From 58309b02a900aa3513060c1331cfc8e63ebfd25a Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 21 Apr 2026 18:49:17 +0200 Subject: [PATCH 1/4] Fix the check_for_fixups script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Git's --grep option uses Basic Regular Expression syntax by default, which means that the `[^\n]*` didn't do what was intended; it means "any character except `\` or the literal letter `n`" — not "any character except newline." Besides, `^` matched any line start, not only the start of the entire message, so "any character except newline" would have been wrong anyway. Given this, the script matched commits that have WIP or DROPME in the body, which is not what we want. (The last commit of this branch is an example for that.) Fix this by listing only the subject lines and grepping them outside of git; this also lets us use a slightly simpler regex (we want to match WIP anywhere in the subject). --- scripts/check_for_fixups.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/check_for_fixups.sh b/scripts/check_for_fixups.sh index 3d5518360..d2e8cb08e 100755 --- a/scripts/check_for_fixups.sh +++ b/scripts/check_for_fixups.sh @@ -2,7 +2,7 @@ # We will have only done a shallow clone, so the git log will consist only of # commits on the current PR -commits=$(git log --grep='^fixup!' --grep='^squash!' --grep='^amend!' --grep='^[^\n]*WIP' --grep='^[^\n]*DROPME' --format="%h %s") +commits=$(git log --format="%h %s" | egrep '(^fixup!|^squash!|^amend!|WIP|DROPME)') if [ -z "$commits" ]; then echo "No fixup commits found." From 4ab36461cb975632a4f836f67447e65a92ddbea9 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 19 Apr 2026 12:03:41 +0200 Subject: [PATCH 2/4] Tolerate trailing newlines in AddCoAuthorToDescription Callers currently hand this function a trimmed description, so the output is always clean. An upcoming change to the commit-panel getters will stop trimming at the callsite (so that whitespace typed by the user round-trips through the preservation file exactly), at which point the description can end with one or more newlines. Without this change, a user who presses Enter after their description body and then invokes "Add co-author" would end up with two blank lines between the body and the trailer instead of the expected one. --- pkg/commands/git_commands/commit.go | 1 + pkg/commands/git_commands/commit_test.go | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/pkg/commands/git_commands/commit.go b/pkg/commands/git_commands/commit.go index 40d2b7319..6abf272b3 100644 --- a/pkg/commands/git_commands/commit.go +++ b/pkg/commands/git_commands/commit.go @@ -61,6 +61,7 @@ func AddCoAuthorToMessage(message string, author string) string { } func AddCoAuthorToDescription(description string, author string) string { + description = strings.TrimRight(description, "\n") if description != "" { lines := strings.Split(description, "\n") if strings.HasPrefix(lines[len(lines)-1], "Co-authored-by:") { diff --git a/pkg/commands/git_commands/commit_test.go b/pkg/commands/git_commands/commit_test.go index 6ea914c64..25966c06f 100644 --- a/pkg/commands/git_commands/commit_test.go +++ b/pkg/commands/git_commands/commit_test.go @@ -483,6 +483,11 @@ func TestAddCoAuthorToDescription(t *testing.T) { description: "Body\n\nCo-authored-by: Jane Smith ", expectedResult: "Body\n\nCo-authored-by: Jane Smith \nCo-authored-by: John Doe ", }, + { + name: "Description with trailing newlines", + description: "Body\n\n", + expectedResult: "Body\n\nCo-authored-by: John Doe ", + }, } for _, s := range scenarios { t.Run(s.name, func(t *testing.T) { From 07a5bb58673b672eb325ba2b25b0b25d13fa6aef Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 19 Apr 2026 12:08:24 +0200 Subject: [PATCH 3/4] Add integration test demonstrating whitespace loss in preserved commit messages Typing a description with leading blank lines, canceling the commit panel, and reopening it currently drops leading blank lines. --- .../preserve_commit_message_whitespace.go | 42 +++++++++++++++++++ pkg/integration/tests/test_list.go | 1 + 2 files changed, 43 insertions(+) create mode 100644 pkg/integration/tests/commit/preserve_commit_message_whitespace.go diff --git a/pkg/integration/tests/commit/preserve_commit_message_whitespace.go b/pkg/integration/tests/commit/preserve_commit_message_whitespace.go new file mode 100644 index 000000000..78ddda196 --- /dev/null +++ b/pkg/integration/tests/commit/preserve_commit_message_whitespace.go @@ -0,0 +1,42 @@ +package commit + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var PreserveCommitMessageWhitespace = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Whitespace in the description (e.g. leading blank lines, indented first line) should be preserved when canceling and reopening the commit message panel", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("myfile", "myfile content") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Press(keys.Files.CommitChanges) + + t.ExpectPopup().CommitMessagePanel(). + Type("my commit message"). + SwitchToDescription(). + AddNewline(). + AddNewline(). + Type("body "). + Cancel() + + t.Views().Files(). + IsFocused(). + Press(keys.Files.CommitChanges) + + t.ExpectPopup().CommitMessagePanel(). + Content(Equals("my commit message")). + SwitchToDescription(). + /* EXPECTED: + Content(Equals("\n\nbody ")). + ACTUAL: */ + Content(Equals("body")). + Cancel() + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 04c12e600..09d487852 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -142,6 +142,7 @@ var tests = []*components.IntegrationTest{ commit.PasteCommitMessage, commit.PasteCommitMessageOverExisting, commit.PreserveCommitMessage, + commit.PreserveCommitMessageWhitespace, commit.ResetAuthor, commit.ResetAuthorRange, commit.Revert, From 227081f1d1b758adc1fc5af6cf5857110eaaa529 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 19 Apr 2026 12:20:44 +0200 Subject: [PATCH 4/4] Preserve whitespace when remembering a commit message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is useful when cancelling out of the commit panel mid-sentence (after having typed the space for the next word); when entering the commit message panel again, the space was gone and you had to type it again. Small thing, but it just seems better to resume the panel in exactly the state that you left it in. (Which we actually don't do; we don't remember the cursor position, or which of the subject/description panels was active. That would be a separate improvement.) The save path and the load path used to be asymmetric. On save, the textarea getters applied strings.TrimSpace, which stripped any leading blank lines, a trailing newline after the cursor, or indentation on the very first line of the description — all of which are legitimate user content. On load, SplitCommitMessageAndDescription did TrimSpace on the description as well, and the preserved message was routed through that same git-format split because HandleCommitPress passed it as OpenCommitMessagePanel's InitialMessage. The result: every round-trip through "escape and reopen" silently mutated the message. The fix is to treat our own preservation file as its own format, distinct from git's canonical "summary\n\nbody" format: - The textarea getters return raw content. strings.TrimSpace moves to the one place that still needs it: the empty-summary check in HandleCommitConfirm (git itself strips trailing whitespace and blank lines, so no pre-trim is needed before -m). - SplitPreservedCommitMessage / SetPreservedMessageInView split on the single "\n" our Join uses, without any trimming — truly lossless. - SplitCommitMessageAndDescription keeps its git-format behavior but replaces TrimSpace with TrimPrefix("\n"), so it strips only the blank-line separator and leaves body indentation intact. - HandleCommitPress now mirrors HandleWIPCommitPress: it no longer passes the preserved message as InitialMessage. OpenCommitMessagePanel resolves the preserved content itself, uses it for display via the preservation-format setter, and stores it as the initial message so the close-time "did the user change anything?" check still correctly detects a cleared panel. - GetInitialMessage no longer trims. With raw getters on both sides of the comparison, trimming here caused spurious non-matches (e.g. for preserved content with trailing whitespace). The original motivation — matching a "WIP: " prefix with trailing space — works unchanged. - UpdateCommitPanelView becomes dead code and is removed; its one remaining caller (history cycling, always git-format) goes directly through SetMessageAndDescriptionInView. Co-Authored-By: Claude Opus 4.7 (1M context) --- pkg/gui/context/commit_message_context.go | 2 +- pkg/gui/controllers.go | 8 +-- .../controllers/commit_message_controller.go | 4 +- pkg/gui/controllers/helpers/commits_helper.go | 61 +++++++++++++------ .../helpers/working_tree_helper.go | 11 ++-- .../preserve_commit_message_whitespace.go | 3 - 6 files changed, 52 insertions(+), 37 deletions(-) diff --git a/pkg/gui/context/commit_message_context.go b/pkg/gui/context/commit_message_context.go index d533e1dea..58351615e 100644 --- a/pkg/gui/context/commit_message_context.go +++ b/pkg/gui/context/commit_message_context.go @@ -129,7 +129,7 @@ func (self *CommitMessageContext) SetPreservedMessageAndLogError(message string) } func (self *CommitMessageContext) GetInitialMessage() string { - return strings.TrimSpace(self.viewModel.initialMessage) + return self.viewModel.initialMessage } func (self *CommitMessageContext) GetHistoryMessage() string { diff --git a/pkg/gui/controllers.go b/pkg/gui/controllers.go index 702ed826d..51c4c3bab 100644 --- a/pkg/gui/controllers.go +++ b/pkg/gui/controllers.go @@ -1,8 +1,6 @@ package gui import ( - "strings" - "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/gui/controllers" @@ -35,14 +33,14 @@ func (gui *Gui) resetHelpersAndControllers() { setCommitSummary := gui.getCommitMessageSetTextareaTextFn(func() *gocui.View { return gui.Views.CommitMessage }) setCommitDescription := gui.getCommitMessageSetTextareaTextFn(func() *gocui.View { return gui.Views.CommitDescription }) getCommitSummary := func() string { - return strings.TrimSpace(gui.Views.CommitMessage.TextArea.GetContent()) + return gui.Views.CommitMessage.TextArea.GetContent() } getCommitDescription := func() string { - return strings.TrimSpace(gui.Views.CommitDescription.TextArea.GetContent()) + return gui.Views.CommitDescription.TextArea.GetContent() } getUnwrappedCommitDescription := func() string { - return strings.TrimSpace(gui.Views.CommitDescription.TextArea.GetUnwrappedContent()) + return gui.Views.CommitDescription.TextArea.GetUnwrappedContent() } commitsHelper := helpers.NewCommitsHelper(helperCommon, getCommitSummary, diff --git a/pkg/gui/controllers/commit_message_controller.go b/pkg/gui/controllers/commit_message_controller.go index 993513987..b92e8dd4c 100644 --- a/pkg/gui/controllers/commit_message_controller.go +++ b/pkg/gui/controllers/commit_message_controller.go @@ -143,7 +143,7 @@ func (self *CommitMessageController) handleCommitIndexChange(value int) error { newIndex := currentIndex + value if newIndex == context.NoCommitIndex { self.context().SetSelectedIndex(newIndex) - self.c.Helpers().Commits.SetMessageAndDescriptionInView(self.context().GetHistoryMessage()) + self.c.Helpers().Commits.SetPreservedMessageInView(self.context().GetHistoryMessage()) return nil } else if currentIndex == context.NoCommitIndex { self.context().SetHistoryMessage(self.c.Helpers().Commits.JoinCommitMessageAndUnwrappedDescription()) @@ -168,7 +168,7 @@ func (self *CommitMessageController) setCommitMessageAtIndex(index int) (bool, e if self.c.UserConfig().Git.Commit.AutoWrapCommitMessage { commitMessage = helpers.TryRemoveHardLineBreaks(commitMessage, self.c.UserConfig().Git.Commit.AutoWrapWidth) } - self.c.Helpers().Commits.UpdateCommitPanelView(commitMessage) + self.c.Helpers().Commits.SetMessageAndDescriptionInView(commitMessage) return true, nil } diff --git a/pkg/gui/controllers/helpers/commits_helper.go b/pkg/gui/controllers/helpers/commits_helper.go index ed97ff41b..73f1d9baf 100644 --- a/pkg/gui/controllers/helpers/commits_helper.go +++ b/pkg/gui/controllers/helpers/commits_helper.go @@ -40,14 +40,34 @@ func NewCommitsHelper( } } +// SplitCommitMessageAndDescription splits a message in git's canonical format +// (summary and body separated by a blank line) into summary and description. func (self *CommitsHelper) SplitCommitMessageAndDescription(message string) (string, string) { - msg, description, _ := strings.Cut(message, "\n") - return msg, strings.TrimSpace(description) + summary, description, _ := strings.Cut(message, "\n") + description = strings.TrimPrefix(description, "\n") + return summary, description +} + +// SplitPreservedCommitMessage splits a message in our preservation format +// (summary and description joined by a single "\n") into summary and description. +// It is lossless: round-tripping through JoinCommitMessageAndUnwrappedDescription +// preserves the exact content. +func (self *CommitsHelper) SplitPreservedCommitMessage(message string) (string, string) { + summary, description, _ := strings.Cut(message, "\n") + return summary, description } func (self *CommitsHelper) SetMessageAndDescriptionInView(message string) { summary, description := self.SplitCommitMessageAndDescription(message) + self.setSummaryAndDescriptionInView(summary, description) +} +func (self *CommitsHelper) SetPreservedMessageInView(message string) { + summary, description := self.SplitPreservedCommitMessage(message) + self.setSummaryAndDescriptionInView(summary, description) +} + +func (self *CommitsHelper) setSummaryAndDescriptionInView(summary, description string) { self.setCommitSummary(summary) self.setCommitDescription(description) self.c.Contexts().CommitMessage.RenderSubtitle() @@ -97,21 +117,6 @@ func (self *CommitsHelper) SwitchToEditor() error { return self.c.Contexts().CommitMessage.SwitchToEditor(filepath) } -func (self *CommitsHelper) UpdateCommitPanelView(message string) { - if message != "" { - self.SetMessageAndDescriptionInView(message) - return - } - - if self.c.Contexts().CommitMessage.GetPreserveMessage() { - preservedMessage := self.c.Contexts().CommitMessage.GetPreservedMessageAndLogError() - self.SetMessageAndDescriptionInView(preservedMessage) - return - } - - self.SetMessageAndDescriptionInView("") -} - type OpenCommitMessagePanelOpts struct { CommitIndex int SummaryTitle string @@ -137,19 +142,35 @@ func (self *CommitsHelper) OpenCommitMessagePanel(opts *OpenCommitMessagePanelOp return opts.OnConfirm(summary, description) } + // When there's no explicit initial message but we're in a preservation + // context, fall back to any previously preserved message. This is stored as + // the "initial" value so the unchanged-message check on close still works + // correctly (in particular, clearing the panel then escaping will notice + // the difference and delete the preserved file). + initialMessage := opts.InitialMessage + initialMessageIsPreserved := false + if opts.PreserveMessage && initialMessage == "" { + initialMessage = self.c.Contexts().CommitMessage.GetPreservedMessageAndLogError() + initialMessageIsPreserved = true + } + self.c.Contexts().CommitMessage.SetPanelState( opts.CommitIndex, opts.SummaryTitle, opts.DescriptionTitle, opts.PreserveMessage, - opts.InitialMessage, + initialMessage, onConfirm, opts.OnSwitchToEditor, opts.ForceSkipHooks, opts.SkipHooksPrefix, ) - self.UpdateCommitPanelView(opts.InitialMessage) + if initialMessageIsPreserved { + self.SetPreservedMessageInView(initialMessage) + } else { + self.SetMessageAndDescriptionInView(initialMessage) + } self.c.Context().Push(self.c.Contexts().CommitMessage, types.OnFocusOpts{}) } @@ -161,7 +182,7 @@ func (self *CommitsHelper) ClearPreservedCommitMessage() { func (self *CommitsHelper) HandleCommitConfirm() error { summary, description := self.getCommitSummary(), self.getCommitDescription() - if summary == "" { + if strings.TrimSpace(summary) == "" { return errors.New(self.c.Tr.CommitWithoutMessageErr) } diff --git a/pkg/gui/controllers/helpers/working_tree_helper.go b/pkg/gui/controllers/helpers/working_tree_helper.go index d6289537b..b6a5407bb 100644 --- a/pkg/gui/controllers/helpers/working_tree_helper.go +++ b/pkg/gui/controllers/helpers/working_tree_helper.go @@ -195,9 +195,9 @@ func (self *WorkingTreeHelper) HandleWIPCommitPress() error { } func (self *WorkingTreeHelper) HandleCommitPress() error { - message := self.c.Contexts().CommitMessage.GetPreservedMessageAndLogError() - - if message == "" { + var initialMessage string + preservedMessage := self.c.Contexts().CommitMessage.GetPreservedMessageAndLogError() + if preservedMessage == "" { commitPrefixConfigs := self.commitPrefixConfigsForRepo() for _, commitPrefixConfig := range commitPrefixConfigs { prefixPattern := commitPrefixConfig.Pattern @@ -212,14 +212,13 @@ func (self *WorkingTreeHelper) HandleCommitPress() error { } if rgx.MatchString(branchName) { - prefix := rgx.ReplaceAllString(branchName, prefixReplace) - message = prefix + initialMessage = rgx.ReplaceAllString(branchName, prefixReplace) break } } } - return self.HandleCommitPressWithMessage(message, false) + return self.HandleCommitPressWithMessage(initialMessage, false) } func (self *WorkingTreeHelper) WithEnsureCommittableFiles(handler func() error) error { diff --git a/pkg/integration/tests/commit/preserve_commit_message_whitespace.go b/pkg/integration/tests/commit/preserve_commit_message_whitespace.go index 78ddda196..ea61d125e 100644 --- a/pkg/integration/tests/commit/preserve_commit_message_whitespace.go +++ b/pkg/integration/tests/commit/preserve_commit_message_whitespace.go @@ -33,10 +33,7 @@ var PreserveCommitMessageWhitespace = NewIntegrationTest(NewIntegrationTestArgs{ t.ExpectPopup().CommitMessagePanel(). Content(Equals("my commit message")). SwitchToDescription(). - /* EXPECTED: Content(Equals("\n\nbody ")). - ACTUAL: */ - Content(Equals("body")). Cancel() }, })