Skip to content
Closed
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
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,30 @@ git add .
lazycommit commit | fzf --prompt='Pick commit> ' | xargs -r -I {} git commit -m "{}"
```

Use gc-style parameters:

```bash
# stage all tracked, deleted, and untracked files first, then generate 3 suggestions in Spanish with emoji
lazycommit commit --stage-all -g 3 -l Spanish -e

# emoji output is normalized even when the upstream model response is inconsistent

# force opencode provider/model for one run
lazycommit commit -p opencode \
-m opencode/minimax-m2.5-free

# print only first generated message (for scripting)
lazycommit commit -o

# debug mode (prints provider/model/diff diagnostics)
lazycommit commit -d

# stage all files first, then generate 3 suggestions with emoji
lazycommit commit --stage-all -g 3 -e


```

Generate PR titles against `main` branch:

```bash
Expand Down
217 changes: 191 additions & 26 deletions cmd/commit.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"context"
"fmt"
"os"
"regexp"
"strings"

"github.com/m7medvision/lazycommit/internal/config"
"github.com/m7medvision/lazycommit/internal/git"
Expand All @@ -19,82 +21,157 @@ type CommitProvider interface {

func init() {
RootCmd.AddCommand(commitCmd)

commitCmd.Flags().StringVarP(&commitProviderFlag, "provider", "p", "", "Provider override: opencode, openai, copilot, anthropic, gemini")
commitCmd.Flags().StringVarP(&commitModelFlag, "model", "m", "", "Model override for selected provider")
commitCmd.Flags().IntVarP(&commitGenerateFlag, "generate", "g", 0, "Number of commit message suggestions to generate")
commitCmd.Flags().StringVarP(&commitLanguageFlag, "lang", "l", "", "Language override for generated commit messages")
commitCmd.Flags().BoolVarP(&commitEmojiFlag, "emoji", "e", false, "Prefix generated commit messages with gitmoji")
commitCmd.Flags().BoolVarP(&commitMessageOnlyFlag, "message-only", "o", false, "Print only the first generated message")
commitCmd.Flags().BoolVar(&commitStageAllFlag, "stage-all", false, "Stage all tracked, deleted, and untracked changes before generating commit messages")
commitCmd.Flags().BoolVarP(&commitSilentEmptyFlag, "silent-empty", "n", false, "Stay silent when there are no staged changes")
commitCmd.Flags().BoolVarP(&commitDebugFlag, "debug", "d", false, "Show debug diagnostics")
}

var (
commitProviderFlag string
commitModelFlag string
commitGenerateFlag int
commitLanguageFlag string
commitEmojiFlag bool
commitMessageOnlyFlag bool
commitStageAllFlag bool
commitSilentEmptyFlag bool
commitDebugFlag bool
)

var conventionalTypePattern = regexp.MustCompile(`^([a-z]+)(\([^)]+\))?:\s+`)

var commitCmd = &cobra.Command{
Use: "commit",
Short: "Generate commit message suggestions",
Long: `Analyzes your staged changes and generates a list of 10 conventional commit message suggestions.`,
Long: `Analyzes your staged changes and generates conventional commit message suggestions.`,
Example: ` lazycommit commit
lazycommit commit --stage-all
lazycommit commit -p opencode -m opencode/minimax-m2.5-free
lazycommit commit -g 3 -l Spanish
lazycommit commit -o`,
Run: func(cmd *cobra.Command, args []string) {
if commitStageAllFlag {
hasChanges, err := git.HasChanges()
if err != nil {
fmt.Fprintf(os.Stderr, "Error checking git status: %v\n", err)
os.Exit(1)
}
if !hasChanges {
if !commitSilentEmptyFlag {
fmt.Println("No changes to stage.")
}
return
}
if err := git.StageAll(); err != nil {
fmt.Fprintf(os.Stderr, "Error staging changes: %v\n", err)
os.Exit(1)
}
if commitDebugFlag {
fmt.Fprintln(os.Stderr, "debug: staged all changes with git add --all")
}
}

diff, err := git.GetStagedDiff()
if err != nil {
fmt.Fprintf(os.Stderr, "Error getting staged diff: %v\n", err)
os.Exit(1)
}

if diff == "" {
fmt.Println("No staged changes to commit.")
if !commitSilentEmptyFlag {
fmt.Println("No staged changes to commit.")
}
if commitDebugFlag {
fmt.Fprintln(os.Stderr, "debug: staged diff is empty")
}
return
}

providerName := strings.TrimSpace(config.GetProvider())
if strings.TrimSpace(commitProviderFlag) != "" {
providerName = strings.TrimSpace(commitProviderFlag)
}

if providerName == "" {
fmt.Fprintln(os.Stderr, "Provider is empty. Set one with 'lazycommit config set' or use --provider.")
os.Exit(1)
}
if !isSupportedCommitProvider(providerName) {
fmt.Fprintf(os.Stderr, "Unsupported provider: %s\n", providerName)
os.Exit(1)
}

var aiProvider CommitProvider

providerName := config.GetProvider()
generateCount := commitGenerateFlag
if generateCount <= 0 {
generateCount = config.GetNumSuggestionsForProvider(providerName)
}
if generateCount <= 0 {
generateCount = 10
}

// API keys are not needed for CLI-backed providers.
var apiKey string
if providerName != "anthropic" && providerName != "gemini" && providerName != "opencode" {
var err error
apiKey, err = config.GetAPIKey()
apiKey, err = config.GetAPIKeyForProvider(providerName)
if err != nil {
fmt.Fprintf(os.Stderr, "Error getting API key: %v\n", err)
os.Exit(1)
}
}

var model string
if providerName == "copilot" || providerName == "openai" || providerName == "anthropic" || providerName == "gemini" || providerName == "opencode" {
if strings.TrimSpace(commitModelFlag) != "" {
model = strings.TrimSpace(commitModelFlag)
} else if providerName == "copilot" || providerName == "openai" || providerName == "anthropic" || providerName == "gemini" || providerName == "opencode" {
var err error
model, err = config.GetModel()
model, err = config.GetModelForProvider(providerName)
if err != nil {
fmt.Fprintf(os.Stderr, "Error getting model: %v\n", err)
os.Exit(1)
}
}

endpoint, err := config.GetEndpoint()
endpoint, err := config.GetEndpointForProvider(providerName)
if err != nil {
fmt.Fprintf(os.Stderr, "Error getting endpoint: %v\n", err)
os.Exit(1)
}

if commitDebugFlag {
fmt.Fprintf(os.Stderr, "debug: provider=%s model=%s generate=%d lang=%q emoji=%t message_only=%t silent_empty=%t\n",
providerName, model, generateCount, commitLanguageFlag, commitEmojiFlag, commitMessageOnlyFlag, commitSilentEmptyFlag)
fmt.Fprintf(os.Stderr, "debug: stage_all=%t\n", commitStageAllFlag)
fmt.Fprintf(os.Stderr, "debug: diff_bytes=%d endpoint=%q\n", len(diff), endpoint)
}

provider.SetRuntimeCommitPromptOptions(provider.CommitPromptOptions{
Generate: generateCount,
Language: strings.TrimSpace(commitLanguageFlag),
Emoji: commitEmojiFlag,
})
defer provider.ResetRuntimeCommitPromptOptions()

switch providerName {
case "copilot":
aiProvider = provider.NewCopilotProviderWithModel(apiKey, model, endpoint)
case "openai":
aiProvider = provider.NewOpenAIProvider(apiKey, model, endpoint)
case "anthropic":
// Get num_suggestions from config (default to 10)
numSuggestions := config.GetNumSuggestions()
if numSuggestions <= 0 {
numSuggestions = 10
}
aiProvider = provider.NewAnthropicProvider(model, numSuggestions)
aiProvider = provider.NewAnthropicProvider(model, generateCount)
case "gemini":
numSuggestions := config.GetNumSuggestions()
if numSuggestions <= 0 {
numSuggestions = 10
}
aiProvider = provider.NewGeminiProvider(model, numSuggestions)
aiProvider = provider.NewGeminiProvider(model, generateCount)
case "opencode":
numSuggestions := config.GetNumSuggestions()
if numSuggestions <= 0 {
numSuggestions = 10
}
aiProvider = provider.NewOpencodeProvider(model, config.GetFallbackModels(), numSuggestions)
default:
// Default to copilot if provider is not set or unknown
aiProvider = provider.NewCopilotProvider(apiKey, endpoint)
aiProvider = provider.NewOpencodeProvider(model, config.GetFallbackModelsForProvider(providerName), generateCount)
}

commitMessages, err := aiProvider.GenerateCommitMessages(context.Background(), diff)
Expand All @@ -108,8 +185,96 @@ var commitCmd = &cobra.Command{
return
}

if generateCount > 0 && len(commitMessages) > generateCount {
commitMessages = commitMessages[:generateCount]
}

commitMessages = applyOutputOverrides(commitMessages, commitEmojiFlag)

if commitDebugFlag {
fmt.Fprintf(os.Stderr, "debug: generated_messages=%d\n", len(commitMessages))
}

if commitMessageOnlyFlag {
fmt.Println(commitMessages[0])
return
}

for _, msg := range commitMessages {
fmt.Println(msg)
}
},
}

func isSupportedCommitProvider(providerName string) bool {
switch providerName {
case "copilot", "openai", "anthropic", "gemini", "opencode":
return true
default:
return false
}
}

func applyOutputOverrides(messages []string, addEmoji bool) []string {
out := make([]string, 0, len(messages))
for _, msg := range messages {
updated := msg
if addEmoji {
updated = ensureGitmojiPrefix(updated)
}
out = append(out, updated)
}
return out
}

func ensureGitmojiPrefix(msg string) string {
trimmed := strings.TrimSpace(msg)
if trimmed == "" {
return msg
}
if hasLeadingEmoji(trimmed) {
return msg
}
matches := conventionalTypePattern.FindStringSubmatch(trimmed)
if len(matches) < 2 {
return msg
}
emojiByType := map[string]string{
"feat": "✨",
"fix": "🐛",
"refactor": "♻️",
"perf": "⚡️",
"docs": "📝",
"style": "🎨",
"test": "🧪",
"chore": "🔧",
"ci": "👷",
"build": "📦",
"revert": "⏪️",
"security": "🔒️",
}
if emoji, ok := emojiByType[matches[1]]; ok {
return emoji + " " + trimmed
}
return msg
}

func hasLeadingEmoji(s string) bool {
for _, r := range strings.TrimSpace(s) {
return isEmojiRune(r)
}
return false
}

func isEmojiRune(r rune) bool {
switch {
case r >= 0x1F000 && r <= 0x1FAFF:
return true
case r >= 0x2600 && r <= 0x27BF:
return true
case r == 0x00A9 || r == 0x00AE || r == 0x3030:
return true
default:
return false
}
}
38 changes: 38 additions & 0 deletions cmd/commit_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package cmd

import "testing"

func TestApplyOutputOverrides(t *testing.T) {
in := []string{"feat: add provider and model flags"}
out := applyOutputOverrides(in, true)

if len(out) != 1 {
t.Fatalf("expected 1 message, got %d", len(out))
}
if out[0] != "✨ feat: add provider and model flags" {
t.Fatalf("unexpected output: %q", out[0])
}
}

func TestEnsureGitmojiPrefix(t *testing.T) {
got := ensureGitmojiPrefix("fix: handle empty staged diff")
want := "🐛 fix: handle empty staged diff"
if got != want {
t.Fatalf("got %q, want %q", got, want)
}
}

func TestEnsureGitmojiPrefix_NoDoubleEmoji(t *testing.T) {
msg := "✨ feat: add provider override"
if got := ensureGitmojiPrefix(msg); got != msg {
t.Fatalf("emoji should not be duplicated, got %q", got)
}
}

func TestEnsureGitmojiPrefix_NonASCIIDescription(t *testing.T) {
got := ensureGitmojiPrefix("feat: añade validación")
want := "✨ feat: añade validación"
if got != want {
t.Fatalf("got %q, want %q", got, want)
}
}
Loading