From e27f7bdf9a0ba41d266b95e35080b2679a862b54 Mon Sep 17 00:00:00 2001 From: Tobias Deiminger Date: Sat, 5 Sep 2026 12:32:19 +0200 Subject: [PATCH] Add amend menu entry "Sign off" This builds on the native 'git commit --signoff' feature to append the committer's Signed-off-by trailer to commit messages. Such a trailer is mandatory in certain projects like the Linux kernel and can now be easily applied to ranges of commits in hindsight with lazygit. The change is implemented as new CommitCommands/RebaseCommands methods that hook into GenericAmend to operate on range selections. It supports a configurable keybinding (default "s") and integrates with the command log. --- docs-master/Config.md | 1 + docs-master/keybindings/Keybindings_en.md | 2 +- docs-master/keybindings/Keybindings_ko.md | 2 +- docs-master/keybindings/Keybindings_nl.md | 2 +- docs-master/keybindings/Keybindings_ru.md | 2 +- docs-master/keybindings/Keybindings_zh-TW.md | 2 +- pkg/commands/git_commands/commit.go | 9 ++ pkg/commands/git_commands/rebase.go | 6 ++ pkg/config/user_config.go | 2 + .../controllers/local_commits_controller.go | 21 +++++ pkg/i18n/english.go | 8 +- pkg/integration/tests/commit/sign_off.go | 36 ++++++++ .../tests/commit/sign_off_range.go | 83 +++++++++++++++++++ pkg/integration/tests/test_list.go | 2 + schema-master/config.json | 14 ++++ 15 files changed, 186 insertions(+), 6 deletions(-) create mode 100644 pkg/integration/tests/commit/sign_off.go create mode 100644 pkg/integration/tests/commit/sign_off_range.go diff --git a/docs-master/Config.md b/docs-master/Config.md index 857a4e359..10454cd4a 100644 --- a/docs-master/Config.md +++ b/docs-master/Config.md @@ -809,6 +809,7 @@ keybinding: resetAuthor: a setAuthor: A addCoAuthor: c + signOff: s stash: popStash: g renameStash: r diff --git a/docs-master/keybindings/Keybindings_en.md b/docs-master/keybindings/Keybindings_en.md index 3ec731bf2..0cc9759b0 100644 --- a/docs-master/keybindings/Keybindings_en.md +++ b/docs-master/keybindings/Keybindings_en.md @@ -102,7 +102,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` V `` | Paste (cherry-pick) | | | `` B `` | Mark as base commit for rebase | Select a base commit for the next rebase. When you rebase onto a branch, only commits above the base commit will be brought across. This uses the `git rebase --onto` command. | | `` A `` | Amend | Amend commit with staged changes. If the selected commit is the HEAD commit, this will perform `git commit --amend`. Otherwise the commit will be amended via a rebase. | -| `` a `` | Amend commit attribute | Set/Reset commit author or set co-author. | +| `` a `` | Amend commit attribute | Set/Reset commit author, set co-author, or sign off the commit. | | `` t `` | Revert | Create a revert commit for the selected commit, which applies the selected commit's changes in reverse. | | `` T `` | Tag commit | Create a new tag pointing at the selected commit. You'll be prompted to enter a tag name and optional description. | | `` `` | View log options | View options for commit log e.g. changing sort order, hiding the git graph, showing the whole git graph. | diff --git a/docs-master/keybindings/Keybindings_ko.md b/docs-master/keybindings/Keybindings_ko.md index a0e5d84dc..ca35d3f2a 100644 --- a/docs-master/keybindings/Keybindings_ko.md +++ b/docs-master/keybindings/Keybindings_ko.md @@ -313,7 +313,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` V `` | 커밋을 붙여넣기 (cherry-pick) | | | `` B `` | Mark as base commit for rebase | Select a base commit for the next rebase. When you rebase onto a branch, only commits above the base commit will be brought across. This uses the `git rebase --onto` command. | | `` A `` | Amend | Amend commit with staged changes | -| `` a `` | Amend commit attribute | Set/Reset commit author or set co-author. | +| `` a `` | Amend commit attribute | Set/Reset commit author, set co-author, or sign off the commit. | | `` t `` | Revert | Create a revert commit for the selected commit, which applies the selected commit's changes in reverse. | | `` T `` | Tag commit | Create a new tag pointing at the selected commit. You'll be prompted to enter a tag name and optional description. | | `` `` | 로그 메뉴 열기 | View options for commit log e.g. changing sort order, hiding the git graph, showing the whole git graph. | diff --git a/docs-master/keybindings/Keybindings_nl.md b/docs-master/keybindings/Keybindings_nl.md index 7d95e72dd..78baa081d 100644 --- a/docs-master/keybindings/Keybindings_nl.md +++ b/docs-master/keybindings/Keybindings_nl.md @@ -174,7 +174,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` V `` | Plak commits (cherry-pick) | | | `` B `` | Markeer als basiscommit voor rebase | Selecteer een basiscommit voor de volgende rebase. Als je rebased op een branch worden alleen commits boven de basiscommit meegenomen. Hiervoor wordt het `git rebase --onto` commando gebruikt. | | `` A `` | Amend | Wijzig commit met staged veranderingen | -| `` a `` | Amend commit attribute | Set/Reset commit author or set co-author. | +| `` a `` | Amend commit attribute | Set/Reset commit author, set co-author, or sign off the commit. | | `` t `` | Revert | Maak een revert commit voor de geselecteerde commit, die de wijzigingen in deze commit terugdraait. | | `` T `` | Tag commit | Maak een nieuwe tag die naar de geselecteerde commit wijst. Je wordt gevraagd om een tag naam en optionele omschrijving. | | `` `` | Log opties weergeven | View options for commit log e.g. changing sort order, hiding the git graph, showing the whole git graph. | diff --git a/docs-master/keybindings/Keybindings_ru.md b/docs-master/keybindings/Keybindings_ru.md index 3d03f9ca6..f686d7420 100644 --- a/docs-master/keybindings/Keybindings_ru.md +++ b/docs-master/keybindings/Keybindings_ru.md @@ -184,7 +184,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` V `` | Вставить отобранные коммиты (cherry-pick) | | | `` B `` | Mark as base commit for rebase | Select a base commit for the next rebase. When you rebase onto a branch, only commits above the base commit will be brought across. This uses the `git rebase --onto` command. | | `` A `` | Amend | Править последний коммит с проиндексированными изменениями | -| `` a `` | Установить/убрать автора коммита | Set/Reset commit author or set co-author. | +| `` a `` | Установить/убрать автора коммита | Set/Reset commit author, set co-author, or sign off the commit. | | `` t `` | Revert | Create a revert commit for the selected commit, which applies the selected commit's changes in reverse. | | `` T `` | Пометить коммит тегом | Create a new tag pointing at the selected commit. You'll be prompted to enter a tag name and optional description. | | `` `` | Открыть меню журнала | View options for commit log e.g. changing sort order, hiding the git graph, showing the whole git graph. | diff --git a/docs-master/keybindings/Keybindings_zh-TW.md b/docs-master/keybindings/Keybindings_zh-TW.md index 1706a627e..40b95f765 100644 --- a/docs-master/keybindings/Keybindings_zh-TW.md +++ b/docs-master/keybindings/Keybindings_zh-TW.md @@ -198,7 +198,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` V `` | 貼上提交 (揀選) | | | `` B `` | 為了變基已標注提交為基準提交 | 請為了下一次變基選擇一項基準提交;此將執行 `git rebase --onto`。 | | `` A `` | 修改 | 使用已預存的更改修正提交 | -| `` a `` | 設定/重設提交作者 | Set/Reset commit author or set co-author. | +| `` a `` | 設定/重設提交作者 | Set/Reset commit author, set co-author, or sign off the commit. | | `` t `` | 還原 | Create a revert commit for the selected commit, which applies the selected commit's changes in reverse. | | `` T `` | 打標籤到提交 | Create a new tag pointing at the selected commit. You'll be prompted to enter a tag name and optional description. | | `` `` | 開啟記錄選單 | View options for commit log e.g. changing sort order, hiding the git graph, showing the whole git graph. | diff --git a/pkg/commands/git_commands/commit.go b/pkg/commands/git_commands/commit.go index d067e9831..271caea08 100644 --- a/pkg/commands/git_commands/commit.go +++ b/pkg/commands/git_commands/commit.go @@ -29,6 +29,15 @@ func (self *CommitCommands) ResetAuthor() error { return self.cmd.New(cmdArgs).Run() } +// Adds a Signed-off-by trailer to the topmost commit +func (self *CommitCommands) SignOff() error { + cmdArgs := NewGitCmd("commit"). + Arg("--allow-empty", "--allow-empty-message", "--only", "--no-edit", "--amend", "--signoff"). + ToArgv() + + return self.cmd.New(cmdArgs).Run() +} + // Sets the commit's author to the supplied value. Value is expected to be of the form 'Name ' func (self *CommitCommands) SetAuthor(value string) error { cmdArgs := NewGitCmd("commit"). diff --git a/pkg/commands/git_commands/rebase.go b/pkg/commands/git_commands/rebase.go index 74278b18d..d8363eeb9 100644 --- a/pkg/commands/git_commands/rebase.go +++ b/pkg/commands/git_commands/rebase.go @@ -80,6 +80,12 @@ func (self *RebaseCommands) SetCommitAuthor(commits []*models.Commit, start, end }) } +func (self *RebaseCommands) SignOffCommit(commits []*models.Commit, start, end int) error { + return self.GenericAmend(commits, start, end, func(_ *models.Commit) error { + return self.commit.SignOff() + }) +} + func (self *RebaseCommands) AddCommitCoAuthor(commits []*models.Commit, start, end int, value string) error { return self.GenericAmend(commits, start, end, func(commit *models.Commit) error { return self.commit.AddCoAuthor(commit.Hash(), value) diff --git a/pkg/config/user_config.go b/pkg/config/user_config.go index 9738186d9..5361f8682 100644 --- a/pkg/config/user_config.go +++ b/pkg/config/user_config.go @@ -646,6 +646,7 @@ type KeybindingAmendAttributeConfig struct { ResetAuthor Keybinding `yaml:"resetAuthor"` SetAuthor Keybinding `yaml:"setAuthor"` AddCoAuthor Keybinding `yaml:"addCoAuthor"` + SignOff Keybinding `yaml:"signOff"` } type KeybindingStashConfig struct { @@ -1159,6 +1160,7 @@ func GetDefaultConfigForPlatform(platform string) *UserConfig { ResetAuthor: Keybinding{"a"}, SetAuthor: Keybinding{"A"}, AddCoAuthor: Keybinding{"c"}, + SignOff: Keybinding{"s"}, }, Stash: KeybindingStashConfig{ PopStash: Keybinding{"g"}, diff --git a/pkg/gui/controllers/local_commits_controller.go b/pkg/gui/controllers/local_commits_controller.go index 1e1a01427..328ebc869 100644 --- a/pkg/gui/controllers/local_commits_controller.go +++ b/pkg/gui/controllers/local_commits_controller.go @@ -1301,10 +1301,31 @@ func (self *LocalCommitsController) amendAttribute(_ []*models.Commit, start, en Keys: opts.GetKeys(opts.Config.AmendAttribute.AddCoAuthor), Tooltip: self.c.Tr.AddCoAuthorTooltip, }, + { + Label: self.c.Tr.SignOffCommit, + OnPress: func() error { return self.signOffCommit(commits, start, end) }, + Keys: opts.GetKeys(opts.Config.AmendAttribute.SignOff), + Tooltip: self.c.Tr.SignOffCommitTooltip, + }, }, }) } +func (self *LocalCommitsController) signOffCommit(commits []*models.Commit, start, end int) error { + return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{ + Message: self.c.Tr.AmendingStatus, + HideWorkingTreeState: true, + }, func(gocui.Task) error { + self.c.LogAction(self.c.Tr.Actions.SignOffCommit) + if err := self.c.Git().Rebase.SignOffCommit(commits, start, end); err != nil { + return err + } + + self.c.RefreshFromWorker(types.RefreshOptions{}) + return nil + }) +} + func (self *LocalCommitsController) resetAuthor(commits []*models.Commit, start, end int) error { return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{ Message: self.c.Tr.AmendingStatus, diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 9c72b53df..beb65c1eb 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -197,6 +197,8 @@ type TranslationSet struct { ResetAuthorTooltip string SetAuthor string SetAuthorTooltip string + SignOffCommit string + SignOffCommitTooltip string AddCoAuthor string AmendCommitAttribute string AmendCommitAttributeTooltip string @@ -1037,6 +1039,7 @@ type Actions struct { ResetCommitAuthor string SetCommitAuthor string AddCommitCoAuthor string + SignOffCommit string RevertCommit string CreateFixupCommit string SquashAllAboveFixupCommits string @@ -1355,9 +1358,11 @@ func EnglishTranslationSet() *TranslationSet { ResetAuthorTooltip: "Reset the commit's author to the currently configured user. This will also renew the author timestamp", SetAuthor: "Set author", SetAuthorTooltip: "Set the author based on a prompt", + SignOffCommit: "Sign off", + SignOffCommitTooltip: "Add a Signed-off-by trailer to the commit.", AddCoAuthor: "Add co-author", AmendCommitAttribute: "Amend commit attribute", - AmendCommitAttributeTooltip: "Set/Reset commit author or set co-author.", + AmendCommitAttributeTooltip: "Set/Reset commit author, set co-author, or sign off the commit.", SetAuthorPromptTitle: "Set author (must look like 'Name ')", AddCoAuthorPromptTitle: "Add co-author (must look like 'Name ')", AddCoAuthorTooltip: "Add co-author using the Github/Gitlab metadata Co-authored-by.", @@ -2154,6 +2159,7 @@ func EnglishTranslationSet() *TranslationSet { ResetCommitAuthor: "Reset commit author", SetCommitAuthor: "Set commit author", AddCommitCoAuthor: "Add commit co-author", + SignOffCommit: "Sign off commit", RevertCommit: "Revert commit", CreateFixupCommit: "Create fixup commit", SquashAllAboveFixupCommits: "Squash all above fixup commits", diff --git a/pkg/integration/tests/commit/sign_off.go b/pkg/integration/tests/commit/sign_off.go new file mode 100644 index 000000000..f19d0c99f --- /dev/null +++ b/pkg/integration/tests/commit/sign_off.go @@ -0,0 +1,36 @@ +package commit + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var SignOff = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Sign off a commit", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.EmptyCommit("initial commit") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Commits(). + Focus(). + Lines( + Contains("initial commit").IsSelected(), + ). + Press(keys.Commits.ResetCommitAuthor). + Tap(func() { + t.ExpectPopup().Menu(). + Title(Equals("Amend commit attribute")). + Select(Contains("Sign off")). + Confirm() + }) + + t.Views().Main().ContainsLines( + Equals(" initial commit"), + Equals(" "), + Equals(" Signed-off-by: CI "), + ) + }, +}) diff --git a/pkg/integration/tests/commit/sign_off_range.go b/pkg/integration/tests/commit/sign_off_range.go new file mode 100644 index 000000000..707c143a2 --- /dev/null +++ b/pkg/integration/tests/commit/sign_off_range.go @@ -0,0 +1,83 @@ +package commit + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var SignOffRange = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Sign off a range of commits", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.EmptyCommit("fourth commit") + shell.EmptyCommit("third commit") + shell.EmptyCommit("second commit") + shell.EmptyCommit("first commit") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Commits(). + Focus(). + Lines( + Contains("first commit").IsSelected(), + Contains("second commit"), + Contains("third commit"), + Contains("fourth commit"), + ). + SelectNextItem(). + Press(keys.Universal.ToggleRangeSelect). + SelectNextItem(). + SelectedLines( + Contains("second commit"), + Contains("third commit"), + ). + Press(keys.Commits.ResetCommitAuthor). + Tap(func() { + t.ExpectPopup().Menu(). + Title(Equals("Amend commit attribute")). + Select(Contains("Sign off")). + Confirm() + }). + PressEscape(). + SelectNextItem(). + SelectedLine(Contains("fourth commit")) + + t.Views().Main().Content( + Contains("fourth commit"). + DoesNotContain("Signed-off-by: CI "), + ) + + t.Views().Commits(). + IsFocused(). + SelectPreviousItem(). + SelectedLine(Contains("third commit")) + + t.Views().Main().ContainsLines( + Equals(" third commit"), + Equals(" "), + Equals(" Signed-off-by: CI "), + ) + + t.Views().Commits(). + IsFocused(). + SelectPreviousItem(). + SelectedLine(Contains("second commit")) + + t.Views().Main().ContainsLines( + Equals(" second commit"), + Equals(" "), + Equals(" Signed-off-by: CI "), + ) + + t.Views().Commits(). + IsFocused(). + SelectPreviousItem(). + SelectedLine(Contains("first commit")) + + t.Views().Main().Content( + Contains("first commit"). + DoesNotContain("Signed-off-by: CI "), + ) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 0d25ddba6..686ec21fd 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -158,6 +158,8 @@ var tests = []*components.IntegrationTest{ commit.Search, commit.SetAuthor, commit.SetAuthorRange, + commit.SignOff, + commit.SignOffRange, commit.StageRangeOfLines, commit.Staged, commit.StagedWithoutHooks, diff --git a/schema-master/config.json b/schema-master/config.json index 45a2b9efe..7b79438e6 100644 --- a/schema-master/config.json +++ b/schema-master/config.json @@ -986,6 +986,20 @@ } ], "default": "c" + }, + "signOff": { + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "default": "s" } }, "additionalProperties": false,