diff --git a/docs-master/Config.md b/docs-master/Config.md index aa149e9e8ea..33c3c49d078 100644 --- a/docs-master/Config.md +++ b/docs-master/Config.md @@ -359,6 +359,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 ( 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, diff --git a/pkg/config/user_config.go b/pkg/config/user_config.go index 192d13843d7..520d320742f 100644 --- a/pkg/config/user_config.go +++ b/pkg/config/user_config.go @@ -355,6 +355,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 ( then 'g'). + // Example: "claude -p 'Generate a conventional commit message for this diff:'" + GenerateCommand string `yaml:"generateCommand"` } type MergingConfig struct { diff --git a/pkg/gui/controllers/commit_message_controller.go b/pkg/gui/controllers/commit_message_controller.go index e9ff1bc67a3..a51fae6e4c6 100644 --- a/pkg/gui/controllers/commit_message_controller.go +++ b/pkg/gui/controllers/commit_message_controller.go @@ -167,6 +167,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 "", 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 @@ -185,6 +189,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 } diff --git a/pkg/gui/controllers/helpers/commits_helper.go b/pkg/gui/controllers/helpers/commits_helper.go index 47170606488..00824694435 100644 --- a/pkg/gui/controllers/helpers/commits_helper.go +++ b/pkg/gui/controllers/helpers/commits_helper.go @@ -4,6 +4,7 @@ import ( "errors" "path/filepath" "strings" + "sync/atomic" "time" "github.com/jesseduffield/gocui" @@ -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( @@ -199,6 +207,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, @@ -222,6 +237,14 @@ func (self *CommitsHelper) OpenCommitMenu(suggestionFunc func(string) []*types.S }, Key: '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, @@ -263,3 +286,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 +} diff --git a/pkg/gui/controllers/helpers/commits_helper_test.go b/pkg/gui/controllers/helpers/commits_helper_test.go index 6197c3916ce..c8dacae41d3 100644 --- a/pkg/gui/controllers/helpers/commits_helper_test.go +++ b/pkg/gui/controllers/helpers/commits_helper_test.go @@ -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 diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 4c423c2bcbd..753c816c0c5 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -672,6 +672,10 @@ type TranslationSet struct { CommitURL string PasteCommitMessageFromClipboard string SurePasteCommitMessage string + GenerateCommitMessage string + GeneratingCommitMessageStatus string + NoStagedChangesForGenerate string + NoGenerateCommandConfigured string CommitMessage string CommitMessageBody string CommitSubject string @@ -1789,6 +1793,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", diff --git a/schema-master/config.json b/schema-master/config.json index 54f9fa9ec4d..3fcd9899c32 100644 --- a/schema-master/config.json +++ b/schema-master/config.json @@ -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,