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
Binary file added demo.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 6 additions & 0 deletions docs-master/Config.md
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,12 @@ git:
# If true, pass '--signoff' flag when committing
signOff: false

# Command that generates a commit message. Lazygit runs the command from the
# current git project root and uses stdout as the editable commit message. If
# the command fails, stderr is shown to the user. See
# https://github.com/jesseduffield/lazygit/blob/master/docs/Generated_Commit_Messages.md.
messageGeneratorCommand: ""

# Automatic WYSIWYG wrapping of the commit message as you type
autoWrapCommitMessage: true

Expand Down
175 changes: 175 additions & 0 deletions docs-master/Generated_Commit_Messages.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
# Generated Commit Messages

Lazygit can run an external command to generate the text for a new commit message. This is useful if you want a local script, an LLM tool, or another formatter to inspect the staged diff and suggest a message.

Configure the command under `git.commit.messageGeneratorCommand`:

```yaml
git:
commit:
messageGeneratorCommand: ~/bin/generate-staged-commit-message.sh
```

When this option is set, the commit menu shows `Generate Commit Message`. Selecting it runs the configured command and puts the command's stdout into the commit message fields. Lazygit does not commit immediately, so you can review and edit the generated message before submitting.

## Command Contract

Lazygit runs the configured command from the Git project root. For example, if your config contains:

```yaml
git:
commit:
messageGeneratorCommand: ~/bin/generate-staged-commit-message.sh --style conventional
```

Lazygit runs it like this:

```sh
(cd /path/to/repo && ~/bin/generate-staged-commit-message.sh --style conventional)
```

The command should write only the commit message to stdout. If stdout contains a blank line, Lazygit treats the first paragraph as the commit summary and the rest as the commit description.

If the command exits with a non-zero status, Lazygit shows a notification with stderr. The current commit message is left unchanged.

## Simple Script

This example uses the staged file summary to build a basic message:

```sh
#!/usr/bin/env sh
set -eu

if git diff --cached --quiet --exit-code; then
echo "no staged changes" >&2
exit 1
fi

files=$(git diff --cached --name-only | sed -n '1,3p' | paste -sd ', ' -)
count=$(git diff --cached --name-only | wc -l | tr -d ' ')

if [ "$count" -eq 1 ]; then
printf 'update %s\n' "$files"
else
printf 'update %s files\n\n%s\n' "$count" "$files"
fi
```

Save it somewhere on your `PATH`, make it executable, and point Lazygit at it:

```sh
chmod +x ~/bin/generate-staged-commit-message.sh
```

```yaml
git:
commit:
messageGeneratorCommand: ~/bin/generate-staged-commit-message.sh
```

## Codex Example

This example asks `codex exec` to inspect the staged diff and output only a commit message. It checks that staged changes exist in the current Git repository and lets you override the model or add extra `codex exec` arguments with environment variables.

```sh
#!/usr/bin/env bash
set -euo pipefail

usage() {
cat >&2 <<'USAGE'
Usage: generate-staged-commit-message.sh

Calls Codex to generate a commit message for the currently staged changes in
the current Git repository.

Environment:
CODEX_MODEL Optional model name passed to `codex exec -m`.
CODEX_EXTRA_ARGS Optional additional arguments appended to `codex exec`.
USAGE
}

die() {
printf 'error: %s\n' "$*" >&2
exit 1
}

if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
usage
exit 0
fi

[[ $# -eq 0 ]] || {
usage
exit 2
}

command -v git >/dev/null 2>&1 || die "git is not installed or not on PATH"
command -v codex >/dev/null 2>&1 || die "codex is not installed or not on PATH"

repo_root=$(git rev-parse --show-toplevel 2>/dev/null) ||
die "not a Git repository: $PWD"

if git -C "$repo_root" diff --cached --quiet --exit-code; then
die "no staged changes found in $repo_root"
fi

prompt=$(cat <<'PROMPT'
Generate a concise, high-quality Git commit message for the currently staged changes.

Inspect the staged diff with:

git diff --cached --stat
git diff --cached

Requirements:
- Base the message only on staged changes.
- Prefer a single-line subject under 72 characters.
- Add a short body if it's not trivial commit and it materially improves clarity.
- Output only the commit message. Do not wrap it in Markdown, quotes, or commentary.
- Use the conventional commits format:
```
<type>[(scope)]: <description>

[optional body]

Signed-off-by: Your Name <you@example.com>
```
PROMPT
)

codex_args=(
exec
--cd "$repo_root"
--sandbox read-only
--ephemeral
--color never
)

if [[ -n "${CODEX_MODEL:-}" ]]; then
codex_args+=(-m "$CODEX_MODEL")
fi

if [[ -n "${CODEX_EXTRA_ARGS:-}" ]]; then
# shellcheck disable=SC2206
extra_args=(${CODEX_EXTRA_ARGS})
codex_args+=("${extra_args[@]}")
fi

exec codex "${codex_args[@]}" "$prompt"
```

Then configure Lazygit:

```yaml
git:
commit:
messageGeneratorCommand: ~/bin/generate-staged-commit-message.sh
```

## Tips

Keep the command non-interactive because Lazygit captures stdout and stderr. If the command needs authentication or confirmation, handle that outside Lazygit first.

Make the command fail when it cannot produce a useful message. Lazygit will show stderr and leave the current message intact.

Quote paths inside scripts. Repository paths can contain spaces.
1 change: 1 addition & 0 deletions docs-master/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

* [Configuration](./Config.md).
* [Custom Commands](./Custom_Command_Keybindings.md)
* [Generated Commit Messages](./Generated_Commit_Messages.md)
* [Custom Pagers](./Custom_Pagers.md)
* [Dev docs](./dev)
* [Keybindings](./keybindings)
Expand Down
9 changes: 6 additions & 3 deletions pkg/config/user_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,8 @@ type PagingConfig struct {
type CommitConfig struct {
// If true, pass '--signoff' flag when committing
SignOff bool `yaml:"signOff"`
// Command that generates a commit message. Lazygit runs the command from the current git project root and uses stdout as the editable commit message. If the command fails, stderr is shown to the user. See https://github.com/jesseduffield/lazygit/blob/master/docs/Generated_Commit_Messages.md.
MessageGeneratorCommand string `yaml:"messageGeneratorCommand"`
// Automatic WYSIWYG wrapping of the commit message as you type
AutoWrapCommitMessage bool `yaml:"autoWrapCommitMessage"`
// If autoWrapCommitMessage is true, the width to wrap to
Expand Down Expand Up @@ -902,9 +904,10 @@ func GetDefaultConfigForPlatform(platform string) *UserConfig {
},
Git: GitConfig{
Commit: CommitConfig{
SignOff: false,
AutoWrapCommitMessage: true,
AutoWrapWidth: 72,
SignOff: false,
MessageGeneratorCommand: "",
AutoWrapCommitMessage: true,
AutoWrapWidth: 72,
},
Merging: MergingConfig{
ManualCommit: false,
Expand Down
67 changes: 65 additions & 2 deletions pkg/gui/context/commit_message_context.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import (
"path/filepath"
"strconv"
"strings"
"time"

"github.com/jesseduffield/lazygit/pkg/gui/presentation"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/spf13/afero"
Expand Down Expand Up @@ -41,6 +43,10 @@ type CommitMessageViewModel struct {
// invoked when pressing the switch-to-editor key binding
onSwitchToEditor func(string) error

generatingCommitMessage bool
cancelingCommitMessage bool
cancelGenerateCommitMessageFn func()

// the following two fields are used for the display of the "hooks disabled" subtitle
forceSkipHooks bool
skipHooksPrefix string
Expand Down Expand Up @@ -164,13 +170,70 @@ func (self *CommitMessageContext) SetPanelState(
self.GetView().Title = summaryTitle
self.c.Views().CommitDescription.Title = descriptionTitle

self.RenderCommitDescriptionSubtitle()

self.c.Views().CommitDescription.Visible = true
}

func (self *CommitMessageContext) StartGeneratingCommitMessage(cancel func()) {
self.viewModel.generatingCommitMessage = true
self.viewModel.cancelingCommitMessage = false
self.viewModel.cancelGenerateCommitMessageFn = cancel
self.c.Views().CommitMessage.Editable = false
self.c.Views().CommitDescription.Editable = false
self.RenderCommitDescriptionSubtitle()
}

func (self *CommitMessageContext) StopGeneratingCommitMessage() {
self.viewModel.generatingCommitMessage = false
self.viewModel.cancelingCommitMessage = false
self.viewModel.cancelGenerateCommitMessageFn = nil
self.c.Views().CommitMessage.Editable = true
self.c.Views().CommitDescription.Editable = true
self.RenderCommitDescriptionSubtitle()
}

func (self *CommitMessageContext) IsGeneratingCommitMessage() bool {
return self.viewModel.generatingCommitMessage
}

func (self *CommitMessageContext) CancelGenerateCommitMessage() bool {
if !self.viewModel.generatingCommitMessage {
return false
}

if !self.viewModel.cancelingCommitMessage {
self.viewModel.cancelingCommitMessage = true
if self.viewModel.cancelGenerateCommitMessageFn != nil {
self.viewModel.cancelGenerateCommitMessageFn()
}
}
self.RenderCommitDescriptionSubtitle()
return true
}

func (self *CommitMessageContext) RenderCommitDescriptionSubtitle() {
if self.viewModel.generatingCommitMessage {
loader := presentation.Loader(time.Now(), self.c.UserConfig().Gui.Spinner)
if self.viewModel.cancelingCommitMessage {
self.c.Views().CommitDescription.Subtitle = utils.ResolvePlaceholderString(self.c.Tr.CancelingGenerateCommitMessageSubTitle,
map[string]string{"spinner": loader})
return
}

self.c.Views().CommitDescription.Subtitle = utils.ResolvePlaceholderString(self.c.Tr.GenerateCommitMessageSubTitle,
map[string]string{
"spinner": loader,
"cancelKey": self.c.UserConfig().Keybinding.Universal.Return.String(),
})
return
}

self.c.Views().CommitDescription.Subtitle = utils.ResolvePlaceholderString(self.c.Tr.CommitDescriptionSubTitle,
map[string]string{
"togglePanelKeyBinding": self.c.UserConfig().Keybinding.Universal.TogglePanel.String(),
"commitMenuKeybinding": self.c.UserConfig().Keybinding.CommitMessage.CommitMenu.String(),
})

self.c.Views().CommitDescription.Visible = true
}

func (self *CommitMessageContext) RenderSubtitle() {
Expand Down
68 changes: 68 additions & 0 deletions pkg/gui/context/commit_message_context_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package context

import (
"testing"

"github.com/jesseduffield/lazygit/pkg/common"
"github.com/jesseduffield/lazygit/pkg/config"
"github.com/jesseduffield/lazygit/pkg/gocui"
guiTypes "github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/i18n"
"github.com/sirupsen/logrus"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
)

type commitMessageTestGuiCommon struct {
guiTypes.IGuiCommon
views guiTypes.Views
}

func (self commitMessageTestGuiCommon) Views() guiTypes.Views {
return self.views
}

func TestCommitMessageGenerationSubtitle(t *testing.T) {
userConfig := config.GetDefaultConfig()
cmn := &common.Common{
Log: logrus.NewEntry(logrus.New()),
Tr: i18n.EnglishTranslationSet(),
Fs: afero.NewMemMapFs(),
}
cmn.SetUserConfig(userConfig)

commitMessageView := gocui.NewView("commitMessage", 0, 0, 80, 3, gocui.OutputNormal)
commitDescriptionView := gocui.NewView("commitDescription", 0, 4, 80, 10, gocui.OutputNormal)
commitMessageView.Editable = true
commitDescriptionView.Editable = true

ctx := NewCommitMessageContext(&ContextCommon{
Common: cmn,
IGuiCommon: commitMessageTestGuiCommon{views: guiTypes.Views{
CommitMessage: commitMessageView,
CommitDescription: commitDescriptionView,
}},
})

ctx.SetPanelState(NoCommitIndex, "Commit summary", "Commit description", false, "", nil, nil, false, "")
assert.Contains(t, commitDescriptionView.Subtitle, "Press <tab> to toggle focus")

cancelCalled := false
ctx.StartGeneratingCommitMessage(func() {
cancelCalled = true
})

assert.False(t, commitMessageView.Editable)
assert.False(t, commitDescriptionView.Editable)
assert.Contains(t, commitDescriptionView.Subtitle, "Generating commit message")
assert.Contains(t, commitDescriptionView.Subtitle, "<esc>")

assert.True(t, ctx.CancelGenerateCommitMessage())
assert.True(t, cancelCalled)
assert.Contains(t, commitDescriptionView.Subtitle, "Canceling commit message generation")

ctx.StopGeneratingCommitMessage()
assert.True(t, commitMessageView.Editable)
assert.True(t, commitDescriptionView.Editable)
assert.Contains(t, commitDescriptionView.Subtitle, "Press <tab> to toggle focus")
}
Loading