This commit is contained in:
Carlo Taleon 2026-09-09 17:51:52 -05:00 committed by GitHub
commit d961b2e130
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 166 additions and 0 deletions

View file

@ -405,6 +405,13 @@ git:
# If autoWrapCommitMessage is true, the width to wrap to
autoWrapWidth: 72
# Shell command that generates a commit message from a diff piped to stdin.
# The command's stdout is used as the commit message (markdown fences are
# stripped automatically).
# Accessible via the commit menu (<c-o> then 'g').
# Example: "claude -p 'Generate a conventional commit message for this diff:'"
generateCommand: ""
# Config relating to merging
merging:
# If true, run merges in a subprocess so that if a commit message is required,

View file

@ -397,6 +397,11 @@ type CommitConfig struct {
AutoWrapCommitMessage bool `yaml:"autoWrapCommitMessage"`
// If autoWrapCommitMessage is true, the width to wrap to
AutoWrapWidth int `yaml:"autoWrapWidth"`
// Shell command that generates a commit message from a diff piped to stdin.
// The command's stdout is used as the commit message (markdown fences are stripped automatically).
// Accessible via the commit menu (<c-o> then 'g').
// Example: "claude -p 'Generate a conventional commit message for this diff:'"
GenerateCommand string `yaml:"generateCommand"`
}
type MergingConfig struct {

View file

@ -174,6 +174,10 @@ func (self *CommitMessageController) setCommitMessageAtIndex(index int) (bool, e
}
func (self *CommitMessageController) confirm() error {
if self.c.Helpers().Commits.IsGenerating() {
return nil
}
// The default keybinding for this action is "<enter>", which means that we
// also get here when pasting multi-line text that contains newlines. In
// that case we don't want to confirm the commit, but switch to the
@ -192,6 +196,10 @@ func (self *CommitMessageController) confirm() error {
}
func (self *CommitMessageController) close() error {
if self.c.Helpers().Commits.IsGenerating() {
return nil
}
self.c.Helpers().Commits.CloseCommitMessagePanel()
return nil
}

View file

@ -4,6 +4,7 @@ import (
"errors"
"path/filepath"
"strings"
"sync/atomic"
"time"
"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
@ -20,6 +21,13 @@ type CommitsHelper struct {
getCommitDescription func() string
getUnwrappedCommitDescription func() string
setCommitDescription func(string)
// set to 1 while commit message generation is in progress
generating atomic.Int32
}
func (self *CommitsHelper) IsGenerating() bool {
return self.generating.Load() == 1
}
func NewCommitsHelper(
@ -222,6 +230,13 @@ func (self *CommitsHelper) OpenCommitMenu(suggestionFunc func(string) []*types.S
}
}
var disabledReasonForGenerate *types.DisabledReason
if self.c.UserConfig().Git.Commit.GenerateCommand == "" {
disabledReasonForGenerate = &types.DisabledReason{
Text: self.c.Tr.NoGenerateCommandConfigured,
}
}
menuItems := []*types.MenuItem{
{
Label: self.c.Tr.OpenInEditor,
@ -245,6 +260,14 @@ func (self *CommitsHelper) OpenCommitMenu(suggestionFunc func(string) []*types.S
},
Keys: menuKey('p'),
},
{
Label: self.c.Tr.GenerateCommitMessage,
OnPress: func() error {
return self.generateCommitMessage()
},
Key: 'g',
DisabledReason: disabledReasonForGenerate,
},
}
return self.c.Menu(types.CreateMenuOptions{
Title: self.c.Tr.CommitMenuTitle,
@ -286,3 +309,70 @@ func (self *CommitsHelper) pasteCommitMessageFromClipboard() error {
},
})
}
func (self *CommitsHelper) generateCommitMessage() error {
self.generating.Store(1)
self.c.Views().CommitMessage.Editable = false
self.c.Views().CommitDescription.Editable = false
originalTitle := self.c.Views().CommitMessage.Title
self.c.Views().CommitMessage.Title = self.c.Tr.GeneratingCommitMessageStatus
restore := func() {
self.c.OnUIThread(func() error {
self.generating.Store(0)
self.c.Views().CommitMessage.Editable = true
self.c.Views().CommitDescription.Editable = true
self.c.Views().CommitMessage.Title = originalTitle
return nil
})
}
return self.c.WithWaitingStatus(self.c.Tr.GeneratingCommitMessageStatus, func(_ gocui.Task) error {
defer restore()
diff, err := self.c.Git().Diff.GetDiff(true)
if err != nil {
return err
}
if strings.TrimSpace(diff) == "" {
self.c.OnUIThread(func() error {
self.c.ErrorToast(self.c.Tr.NoStagedChangesForGenerate)
return nil
})
return nil
}
command := self.c.UserConfig().Git.Commit.GenerateCommand
message, err := self.c.OS().Cmd.NewShell(command, "").SetStdin(diff).DontLog().RunWithOutput()
if err != nil {
return err
}
message = parseGenerateOutput(message)
if message == "" {
return errors.New("generate command returned an empty commit message")
}
self.c.OnUIThread(func() error {
self.SetMessageAndDescriptionInView(message)
return nil
})
return nil
})
}
// parseGenerateOutput strips markdown code fences and any preamble before them.
func parseGenerateOutput(s string) string {
s = strings.TrimSpace(s)
if start := strings.Index(s, "```"); start >= 0 {
rest := s[start:]
if idx := strings.Index(rest, "\n"); idx >= 0 {
rest = rest[idx+1:]
}
if idx := strings.LastIndex(rest, "```"); idx >= 0 {
rest = rest[:idx]
}
s = strings.TrimSpace(rest)
}
return s
}

View file

@ -6,6 +6,50 @@ import (
"github.com/stretchr/testify/assert"
)
func TestParseAIOutput(t *testing.T) {
scenarios := []struct {
name string
input string
expected string
}{
{
name: "plain text unchanged",
input: "feat: add login button",
expected: "feat: add login button",
},
{
name: "strips surrounding whitespace",
input: " feat: add login button ",
expected: "feat: add login button",
},
{
name: "strips plain code fence",
input: "```\nfeat: add login button\n```",
expected: "feat: add login button",
},
{
name: "strips fenced block with language tag",
input: "```text\nfeat: add login button\n```",
expected: "feat: add login button",
},
{
name: "strips fenced block with surrounding text",
input: "Here is your commit:\n```\nfeat: add login button\n```\nDone.",
expected: "feat: add login button",
},
{
name: "multiline commit message in fence",
input: "```\nfeat: add login button\n\nCloses #123\n```",
expected: "feat: add login button\n\nCloses #123",
},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
assert.Equal(t, s.expected, parseGenerateOutput(s.input))
})
}
}
func TestTryRemoveHardLineBreaks(t *testing.T) {
scenarios := []struct {
name string

View file

@ -698,6 +698,10 @@ type TranslationSet struct {
CommitURL string
PasteCommitMessageFromClipboard string
SurePasteCommitMessage string
GenerateCommitMessage string
GeneratingCommitMessageStatus string
NoStagedChangesForGenerate string
NoGenerateCommandConfigured string
CommitMessage string
CommitMessageBody string
CommitSubject string
@ -1861,6 +1865,10 @@ func EnglishTranslationSet() *TranslationSet {
CommitURL: "Commit URL",
PasteCommitMessageFromClipboard: "Paste commit message from clipboard",
SurePasteCommitMessage: "Pasting will overwrite the current commit message, continue?",
GenerateCommitMessage: "Generate commit message",
GeneratingCommitMessageStatus: "Generating commit message...",
NoStagedChangesForGenerate: "No staged changes to generate a commit message from",
NoGenerateCommandConfigured: "No generate command configured. Set git.commit.generateCommand in your config",
CommitMessage: "Commit message (subject and body)",
CommitMessageBody: "Commit message body",
CommitSubject: "Commit subject",

View file

@ -19,6 +19,10 @@
"type": "integer",
"description": "If autoWrapCommitMessage is true, the width to wrap to",
"default": 72
},
"generateCommand": {
"type": "string",
"description": "Shell command that generates a commit message from a diff piped to stdin.\nThe command's stdout is used as the commit message (markdown fences are stripped automatically).\nAccessible via the commit menu (\u003cc-o\u003e then 'g').\nExample: \"claude -p 'Generate a conventional commit message for this diff:'\""
}
},
"additionalProperties": false,