Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs-master/Config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (<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,
Expand Down
5 changes: 5 additions & 0 deletions pkg/config/user_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (<c-o> then 'g').
// Example: "claude -p 'Generate a conventional commit message for this diff:'"
GenerateCommand string `yaml:"generateCommand"`
}

type MergingConfig struct {
Expand Down
8 changes: 8 additions & 0 deletions pkg/gui/controllers/commit_message_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<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
Expand All @@ -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
}
Expand Down
90 changes: 90 additions & 0 deletions pkg/gui/controllers/helpers/commits_helper.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"errors"
"path/filepath"
"strings"
"sync/atomic"
"time"

"github.com/jesseduffield/gocui"
Expand All @@ -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(
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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
}
44 changes: 44 additions & 0 deletions pkg/gui/controllers/helpers/commits_helper_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions pkg/i18n/english.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions schema-master/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down