Skip to content
Merged
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
8 changes: 4 additions & 4 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,10 @@ builds:
- -trimpath
ldflags:
- -s -w
- -X main.Version={{.Version}}
- -X main.commit={{.Commit}}
- -X main.date={{.Date}}
- -X main.builtBy=goreleaser
- -X github.com/madstone-tech/git-msg/cmd.Version={{.Version}}
- -X github.com/madstone-tech/git-msg/cmd.Commit={{.Commit}}
- -X github.com/madstone-tech/git-msg/cmd.Date={{.Date}}
- -X github.com/madstone-tech/git-msg/cmd.BuiltBy=goreleaser
env:
- CGO_ENABLED=0

Expand Down
1 change: 1 addition & 0 deletions cmd/generate.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ func Run(ctx context.Context, opts GenerateOptions) error {
if err != nil {
return fmt.Errorf("LLM request failed\n → %w", err)
}
rawMessage = llm.CleanResponse(rawMessage)
if rawMessage == "" {
return fmt.Errorf("LLM returned an empty message\n → try again or switch provider")
}
Expand Down
18 changes: 18 additions & 0 deletions cmd/generate_cobra.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,24 @@ func NewGenerateCmd() *cobra.Command {
_ = cmd.Flags().MarkHidden("hook-mode")
_ = cmd.Flags().MarkHidden("hook-msg-file")
_ = cmd.Flags().MarkHidden("hook-source")
_ = cmd.RegisterFlagCompletionFunc("provider", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return []string{"openai", "anthropic", "gemini", "ollama"}, cobra.ShellCompDirectiveNoFileComp
})
_ = cmd.RegisterFlagCompletionFunc("template", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
store, err := prompt.NewFileStore()
if err != nil {
return nil, cobra.ShellCompDirectiveError
}
entries, err := store.List()
if err != nil {
return nil, cobra.ShellCompDirectiveError
}
var names []string
for _, e := range entries {
names = append(names, e.Name)
}
return names, cobra.ShellCompDirectiveNoFileComp
})

return cmd
}
7 changes: 6 additions & 1 deletion cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,12 @@ import (
)

// Version is set at build time via -ldflags "-X main.Version=vX.Y.Z".
var Version = "dev"
var (
Version = "dev"
Commit = "none"
Date = "unknown"
BuiltBy = "unknown"
)

// contextKey is an unexported type for context keys in this package.
type contextKey int
Expand Down
1 change: 1 addition & 0 deletions cmd/root_cobra.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ func newRootCmd(cfgStore config.Store, secrets secret.SecretStore) *cobra.Comman
NewConfigCmd(cfgStore),
NewPromptCmd(),
NewHookCmd(),
NewVersionCmd(),
)

return root
Expand Down
21 changes: 21 additions & 0 deletions cmd/version_cobra.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package cmd

import (
"fmt"

"github.com/spf13/cobra"
)

// NewVersionCmd returns a cobra command that prints the binary version and build info.
func NewVersionCmd() *cobra.Command {
return &cobra.Command{
Use: "version",
Short: "Print the version of git-msg",
Run: func(cmd *cobra.Command, args []string) {
fmt.Printf("git-msg %s\n", Version)
fmt.Printf("commit: %s\n", Commit)
fmt.Printf("date: %s\n", Date)
fmt.Printf("built by: %s\n", BuiltBy)
},
}
}
30 changes: 30 additions & 0 deletions internal/llm/clean.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package llm

import (
"regexp"
"strings"
)

var (
// fenceRegex matches markdown code fences and captures the content inside.
fenceRegex = regexp.MustCompile("(?s)```(?:[a-z]*\\n)?(.*?)\\n?```")

// fillerRegex matches common conversational prefixes LLMs use.
fillerRegex = regexp.MustCompile(`(?i)^(?:here is (?:the|your)? (?:generated )?commit message|suggested commit message|the commit message is|commit message|generated message):?\s*`)
)

// CleanResponse removes common LLM conversational filler and markdown fencing
// to extract the actual commit message.
func CleanResponse(input string) string {
output := strings.TrimSpace(input)

// 1. Remove markdown fences if present.
if matches := fenceRegex.FindStringSubmatch(output); len(matches) > 1 {
output = matches[1]
}

// 2. Remove common conversational fillers.
output = fillerRegex.ReplaceAllString(output, "")

return strings.TrimSpace(output)
}
63 changes: 63 additions & 0 deletions internal/llm/clean_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package llm

import (
"testing"
)

func TestCleanResponse(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{
name: "no cleaning needed",
input: "feat(ui): add new button",
expected: "feat(ui): add new button",
},
{
name: "markdown fences",
input: "```git\nfeat(ui): add new button\n```",
expected: "feat(ui): add new button",
},
{
name: "markdown fences with text",
input: "Here is the message:\n```\nfeat(ui): add new button\n```",
expected: "feat(ui): add new button",
},
{
name: "conversational filler",
input: "Here is your commit message: feat(ui): add new button",
expected: "feat(ui): add new button",
},
{
name: "case insensitive filler",
input: "COMMIT MESSAGE: feat(ui): add new button",
expected: "feat(ui): add new button",
},
{
name: "filler with colon and space",
input: "Suggested commit message: feat(ui): add new button ",
expected: "feat(ui): add new button",
},
{
name: "complex leakage",
input: "Based on the diff, here is the suggested commit message:\n\n```\nfix(core): resolve race condition in buffer\n```\n\nI hope this helps!",
expected: "fix(core): resolve race condition in buffer",
},
{
name: "empty input",
input: " ",
expected: "",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := CleanResponse(tt.input)
if got != tt.expected {
t.Errorf("CleanResponse(%q) = %q; want %q", tt.input, got, tt.expected)
}
})
}
}
3 changes: 3 additions & 0 deletions internal/llm/ollama.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ func (p *OllamaProvider) Generate(ctx context.Context, system, user string) (str
}
payload := map[string]interface{}{
"model": p.model,
"options": map[string]interface{}{
"temperature": 0,
},
"messages": []map[string]string{
{"role": "system", "content": system},
{"role": "user", "content": user},
Expand Down
3 changes: 2 additions & 1 deletion internal/llm/openai.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ func NewOpenAIProviderWithEndpoint(model, apiKey, endpoint string) *OpenAIProvid

func (p *OpenAIProvider) Generate(ctx context.Context, system, user string) (string, error) {
payload := map[string]interface{}{
"model": p.model,
"model": p.model,
"temperature": 0,
"messages": []map[string]string{
{"role": "system", "content": system},
{"role": "user", "content": user},
Expand Down
23 changes: 19 additions & 4 deletions internal/prompt/embedded/conventional.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,25 @@ name = "conventional"
description = "Conventional Commits with branch and log context"

system = """
You are a git commit message generator.
Follow the Conventional Commits specification (type(scope): subject).
Keep the subject line under 72 characters.
Output only the commit message. No explanation, no markdown fencing.
You are an expert git commit message generator.

CORE DIRECTIVE:
You MUST base your message ONLY on the provided staged diff.
1. ANALYZE: First, identify exactly which files changed and what the literal changes are.
2. GROUND: If the diff is small (e.g., a few lines in a config or prompt file), describe the literal change (e.g., "update prompt text") rather than inventing a high-level feature.
3. VERIFY: Before outputting, ensure every claim in your message is supported by a line in the diff.

NEVER:
- Invent files, dependencies, or features not present in the diff.
- Refer to changes as "improvements" or "updates" without specific evidence.
- Hallucinate a "standard" commit message based on the project type.

Follow the Conventional Commits specification:
1. FORMAT: <type>(<scope): <subject>
- Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert.
- Subject: imperative mood, no period, < 72 chars.
2. DETAIL: Provide a body for significant changes explaining the "what" and "why".
3. OUTPUT: Output ONLY the final commit message. No conversational filler, no markdown fences.
"""

user = """
Expand Down
Loading