diff --git a/docs-master/Config.md b/docs-master/Config.md index aa149e9e8ea..2283570c551 100644 --- a/docs-master/Config.md +++ b/docs-master/Config.md @@ -359,6 +359,28 @@ git: # If autoWrapCommitMessage is true, the width to wrap to autoWrapWidth: 72 + # AI commit message generation config + ai: + # CLI-based generation: runs a command with the staged diff piped to stdin. + # Example: "claude -p 'Generate a conventional commit message for this diff:'" + cli: + # The shell command to run. The staged diff is piped to stdin. + command: "" + + # OpenAI-compatible API generation + api: + # OpenAI-compatible base URL, e.g. "https://api.openai.com/v1" + endpoint: "" + + # Model name, e.g. "gpt-4o" or "llama3" + model: "" + + # API key value. Use {env:MY_API_KEY} to read from an environment variable. + apiKey: "" + + # System prompt. Defaults to a conventional commit prompt if omitted. + systemPrompt: "" + # Config relating to merging merging: # If true, run merges in a subprocess so that if a commit message is required, diff --git a/docs/Config.md b/docs/Config.md index aa149e9e8ea..7d631c93d3c 100644 --- a/docs/Config.md +++ b/docs/Config.md @@ -16,6 +16,8 @@ If you want to change the config directory: - MacOS: `export XDG_CONFIG_HOME="$HOME/.config"` +You can place a `.env` file in the same directory as your `config.yml` to define secrets (e.g. API keys) outside of version control. Reference them anywhere in your config using the `{env:VARIABLE_NAME}` syntax — for example, `apiKey: "{env:OPENAI_API_KEY}"`. Variables already set in your shell take precedence over values in `.env`. + In addition to the global config file you can create repo-specific config files in `/.git/lazygit.yml`. Settings in these files override settings in the global config file. In addition, files called `.lazygit.yml` in any of the parent directories of a repo will also be loaded; this can be useful if you have settings that you want to apply to a group of repositories. JSON schema is available for `config.yml` so that IntelliSense in Visual Studio Code (completion and error checking) is automatically enabled when the [YAML Red Hat][yaml] extension is installed. However, note that automatic schema detection only works if your config file is in one of the standard paths mentioned above. If you override the path to the file, you can still make IntelliSense work by adding @@ -359,6 +361,26 @@ git: # If autoWrapCommitMessage is true, the width to wrap to autoWrapWidth: 72 + # Config for AI-generated commit messages (accessible via the commit menu with then 'a') + # Use either the 'cli' or 'api' approach, not both. + ai: + # Use a local CLI tool (e.g. claude, opencode, llm) to generate commit messages. + # The staged diff is piped to stdin; the generated message is read from stdout. + # Markdown code fences in the output are stripped automatically. + # Example: 'claude -p "Generate a conventional commit message:"' + cli: + command: 'claude -p "Generated a conventional commit message for this diff"' + + # Use an OpenAI-compatible HTTP API to generate commit messages. + # Use {env:MY_VAR} to read the API key from an environment variable or + # from the .env file next to config.yml (see above). + api: + endpoint: "https://api.openai.com/v1/chat/completions" + model: "gpt-4o-mini" + apiKey: "{env:OPENAI_API_KEY}" + # Optional system prompt. A sensible default is used when left empty. + systemPrompt: "" + # 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/app_config.go b/pkg/config/app_config.go index 8205f7ef6cf..ca574ba7c91 100644 --- a/pkg/config/app_config.go +++ b/pkg/config/app_config.go @@ -7,6 +7,7 @@ import ( "os" "path/filepath" "reflect" + "regexp" "strings" "time" @@ -82,6 +83,8 @@ func NewAppConfig( return nil, err } + loadDotEnv(filepath.Join(configDir, ".env")) + var configFiles []*ConfigFile customConfigFiles := os.Getenv("LG_CONFIG_FILE") if customConfigFiles != "" { @@ -135,6 +138,45 @@ func findOrCreateConfigDir() (string, error) { return folder, os.MkdirAll(folder, 0o755) } +var envInterpolatePattern = regexp.MustCompile(`\{env:([^}]+)\}`) + +// envInterpolate replaces {env:KEY} placeholders in config content with the +// corresponding environment variable values. +func envInterpolate(content []byte) []byte { + return envInterpolatePattern.ReplaceAllFunc(content, func(match []byte) []byte { + key := string(match[5 : len(match)-1]) // strip "{env:" and "}" + return []byte(os.Getenv(key)) + }) +} + +// loadDotEnv reads a .env file and sets any key=value pairs as environment +// variables, skipping keys that are already set. Lines starting with '#' and +// blank lines are ignored. Quoted values have their quotes stripped. +func loadDotEnv(path string) { + data, err := os.ReadFile(path) + if err != nil { + return // file is optional + } + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + key, value, ok := strings.Cut(line, "=") + if !ok { + continue + } + key = strings.TrimSpace(key) + value = strings.TrimSpace(value) + if len(value) >= 2 && (value[0] == '"' || value[0] == '\'') && value[len(value)-1] == value[0] { + value = value[1 : len(value)-1] + } + if os.Getenv(key) == "" { + _ = os.Setenv(key, value) + } + } +} + func loadUserConfigWithDefaults(configFiles []*ConfigFile, isGuiInitialized bool) (*UserConfig, error) { return loadUserConfig(configFiles, GetDefaultConfig(), isGuiInitialized) } @@ -191,6 +233,7 @@ func loadUserConfig(configFiles []*ConfigFile, base *UserConfig, isGuiInitialize existingCustomCommands := base.CustomCommands + content = envInterpolate(content) if err := yaml.Unmarshal(content, base); err != nil { return nil, fmt.Errorf("The config at `%s` couldn't be parsed, please inspect it before opening up an issue.\n%w", path, err) } diff --git a/pkg/config/app_config_test.go b/pkg/config/app_config_test.go index 1109256a9d3..cb6c81df24f 100644 --- a/pkg/config/app_config_test.go +++ b/pkg/config/app_config_test.go @@ -6,6 +6,44 @@ import ( "github.com/stretchr/testify/assert" ) +func TestEnvInterpolate(t *testing.T) { + t.Setenv("MY_KEY", "hello") + t.Setenv("ANOTHER", "world") + + scenarios := []struct { + name string + input string + expected string + }{ + { + name: "no placeholders unchanged", + input: "apiKey: hardcoded", + expected: "apiKey: hardcoded", + }, + { + name: "single placeholder replaced", + input: "apiKey: {env:MY_KEY}", + expected: "apiKey: hello", + }, + { + name: "multiple placeholders replaced", + input: "{env:MY_KEY} and {env:ANOTHER}", + expected: "hello and world", + }, + { + name: "unset variable replaced with empty string", + input: "apiKey: {env:UNSET_VAR_XYZ}", + expected: "apiKey: ", + }, + } + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + result := envInterpolate([]byte(s.input)) + assert.Equal(t, s.expected, string(result)) + }) + } +} + func TestMigrationOfRenamedKeys(t *testing.T) { scenarios := []struct { name string diff --git a/pkg/config/user_config.go b/pkg/config/user_config.go index 192d13843d7..69ce3b178ea 100644 --- a/pkg/config/user_config.go +++ b/pkg/config/user_config.go @@ -355,6 +355,32 @@ type CommitConfig struct { AutoWrapCommitMessage bool `yaml:"autoWrapCommitMessage"` // If autoWrapCommitMessage is true, the width to wrap to AutoWrapWidth int `yaml:"autoWrapWidth"` + // AI commit message generation config + AI AIConfig `yaml:"ai"` +} + +type AIConfig struct { + // CLI-based generation: runs a command with the staged diff piped to stdin. + // Example: "claude -p 'Generate a conventional commit message for this diff:'" + CLI AICliConfig `yaml:"cli"` + // OpenAI-compatible API generation + API AIAPIConfig `yaml:"api"` +} + +type AICliConfig struct { + // The shell command to run. The staged diff is piped to stdin. + Command string `yaml:"command"` +} + +type AIAPIConfig struct { + // OpenAI-compatible base URL, e.g. "https://api.openai.com/v1" + Endpoint string `yaml:"endpoint"` + // Model name, e.g. "gpt-4o" or "llama3" + Model string `yaml:"model"` + // API key value. Use {env:MY_API_KEY} to read from an environment variable. + APIKey string `yaml:"apiKey"` + // System prompt. Defaults to a conventional commit prompt if omitted. + SystemPrompt string `yaml:"systemPrompt"` } 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..be2b0e1e685 100644 --- a/pkg/gui/controllers/helpers/commits_helper.go +++ b/pkg/gui/controllers/helpers/commits_helper.go @@ -1,9 +1,14 @@ package helpers import ( + "bytes" + "encoding/json" "errors" + "fmt" + "net/http" "path/filepath" "strings" + "sync/atomic" "time" "github.com/jesseduffield/gocui" @@ -20,6 +25,13 @@ type CommitsHelper struct { getCommitDescription func() string getUnwrappedCommitDescription func() string setCommitDescription func(string) + + // set to 1 while AI commit message generation is in progress + generating atomic.Int32 +} + +func (self *CommitsHelper) IsGenerating() bool { + return self.generating.Load() == 1 } func NewCommitsHelper( @@ -199,6 +211,15 @@ func (self *CommitsHelper) OpenCommitMenu(suggestionFunc func(string) []*types.S } } + aiConfig := self.c.UserConfig().Git.Commit.AI + aiConfigured := aiConfig.CLI.Command != "" || aiConfig.API.Endpoint != "" + var disabledReasonForAI *types.DisabledReason + if !aiConfigured { + disabledReasonForAI = &types.DisabledReason{ + Text: self.c.Tr.NoAIConfigured, + } + } + menuItems := []*types.MenuItem{ { Label: self.c.Tr.OpenInEditor, @@ -222,6 +243,14 @@ func (self *CommitsHelper) OpenCommitMenu(suggestionFunc func(string) []*types.S }, Key: 'p', }, + { + Label: self.c.Tr.GenerateCommitMessageWithAI, + OnPress: func() error { + return self.generateCommitMessageWithAI() + }, + Key: 'a', + DisabledReason: disabledReasonForAI, + }, } return self.c.Menu(types.CreateMenuOptions{ Title: self.c.Tr.CommitMenuTitle, @@ -263,3 +292,146 @@ func (self *CommitsHelper) pasteCommitMessageFromClipboard() error { }, }) } + +func (self *CommitsHelper) generateCommitMessageWithAI() 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.NoStagedChangesForAI) + return nil + }) + return nil + } + + cfg := self.c.UserConfig().Git.Commit.AI + var message string + if cfg.CLI.Command != "" { + message, err = self.generateViaAICLI(cfg.CLI.Command, diff) + } else { + message, err = generateViaAIAPI(cfg.API.Endpoint, cfg.API.Model, cfg.API.APIKey, cfg.API.SystemPrompt, diff) + } + if err != nil { + return err + } + + message = parseAIOutput(message) + if message == "" { + return errors.New("AI returned an empty commit message") + } + + self.c.OnUIThread(func() error { + self.SetMessageAndDescriptionInView(message) + return nil + }) + return nil + }) +} + +func (self *CommitsHelper) generateViaAICLI(command string, diff string) (string, error) { + return self.c.OS().Cmd.NewShell(command, "").SetStdin(diff).DontLog().RunWithOutput() +} + +type aiAPIRequest struct { + Model string `json:"model"` + Messages []aiMessage `json:"messages"` +} + +type aiMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + +type aiAPIResponse struct { + Choices []struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + } `json:"choices"` +} + +func generateViaAIAPI(endpoint, model, apiKey, systemPrompt, diff string) (string, error) { + if systemPrompt == "" { + systemPrompt = "Generate a conventional commit message for the following diff. Output only the commit message (subject and optional body), with no additional explanation or markdown fences." + } + + reqBody := aiAPIRequest{ + Model: model, + Messages: []aiMessage{ + {Role: "system", Content: systemPrompt}, + {Role: "user", Content: diff}, + }, + } + bodyBytes, err := json.Marshal(reqBody) + if err != nil { + return "", err + } + + req, err := http.NewRequest("POST", strings.TrimRight(endpoint, "/")+"/chat/completions", bytes.NewReader(bodyBytes)) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/json") + if apiKey != "" { + req.Header.Set("Authorization", "Bearer "+apiKey) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + return "", fmt.Errorf("AI API returned status %d", resp.StatusCode) + } + + var result aiAPIResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", err + } + if len(result.Choices) == 0 { + return "", errors.New("AI API returned no choices") + } + return result.Choices[0].Message.Content, nil +} + +// parseAIOutput strips markdown code fences and any preamble before them. +// Handles tools like opencode that emit lines like "> build • glm-5" before the fence. +func parseAIOutput(s string) string { + s = strings.TrimSpace(s) + if start := strings.Index(s, "```"); start >= 0 { + // skip past the opening fence line (e.g. "```" or "```text") + rest := s[start:] + if idx := strings.Index(rest, "\n"); idx >= 0 { + rest = rest[idx+1:] + } + // drop the closing fence + 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..026f1d2ae21 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, parseAIOutput(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..8674b3d004f 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -672,6 +672,10 @@ type TranslationSet struct { CommitURL string PasteCommitMessageFromClipboard string SurePasteCommitMessage string + GenerateCommitMessageWithAI string + GeneratingCommitMessageStatus string + NoStagedChangesForAI string + NoAIConfigured 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?", + GenerateCommitMessageWithAI: "Generate commit message with AI", + GeneratingCommitMessageStatus: "Generating commit message...", + NoStagedChangesForAI: "No staged changes to generate a commit message from", + NoAIConfigured: "No AI configured. Set git.commit.ai.cli.command or git.commit.ai.api.* 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..55e9d1a9b2e 100644 --- a/schema-master/config.json +++ b/schema-master/config.json @@ -3,6 +3,55 @@ "$id": "https://github.com/jesseduffield/lazygit/pkg/config/user-config", "$ref": "#/$defs/UserConfig", "$defs": { + "AIAPIConfig": { + "properties": { + "endpoint": { + "type": "string", + "description": "OpenAI-compatible base URL, e.g. \"https://api.openai.com/v1\"" + }, + "model": { + "type": "string", + "description": "Model name, e.g. \"gpt-4o\" or \"llama3\"" + }, + "apiKey": { + "type": "string", + "description": "API key value. Use {env:MY_API_KEY} to read from an environment variable." + }, + "systemPrompt": { + "type": "string", + "description": "System prompt. Defaults to a conventional commit prompt if omitted." + } + }, + "additionalProperties": false, + "type": "object", + "description": "OpenAI-compatible API generation" + }, + "AICliConfig": { + "properties": { + "command": { + "type": "string", + "description": "The shell command to run. The staged diff is piped to stdin." + } + }, + "additionalProperties": false, + "type": "object", + "description": "CLI-based generation: runs a command with the staged diff piped to stdin.\nExample: \"claude -p 'Generate a conventional commit message for this diff:'\"" + }, + "AIConfig": { + "properties": { + "cli": { + "$ref": "#/$defs/AICliConfig", + "description": "CLI-based generation: runs a command with the staged diff piped to stdin.\nExample: \"claude -p 'Generate a conventional commit message for this diff:'\"" + }, + "api": { + "$ref": "#/$defs/AIAPIConfig", + "description": "OpenAI-compatible API generation" + } + }, + "additionalProperties": false, + "type": "object", + "description": "AI commit message generation config" + }, "CommitConfig": { "properties": { "signOff": { @@ -19,6 +68,10 @@ "type": "integer", "description": "If autoWrapCommitMessage is true, the width to wrap to", "default": 72 + }, + "ai": { + "$ref": "#/$defs/AIConfig", + "description": "AI commit message generation config" } }, "additionalProperties": false,