diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 2e76ebf..faaa2b1 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -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 diff --git a/cmd/generate.go b/cmd/generate.go index f64bd9f..8b20abe 100644 --- a/cmd/generate.go +++ b/cmd/generate.go @@ -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") } diff --git a/cmd/generate_cobra.go b/cmd/generate_cobra.go index a1c3903..ec33a73 100644 --- a/cmd/generate_cobra.go +++ b/cmd/generate_cobra.go @@ -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 } diff --git a/cmd/root.go b/cmd/root.go index 398e354..374a246 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -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 diff --git a/cmd/root_cobra.go b/cmd/root_cobra.go index a394212..0e2a4fc 100644 --- a/cmd/root_cobra.go +++ b/cmd/root_cobra.go @@ -46,6 +46,7 @@ func newRootCmd(cfgStore config.Store, secrets secret.SecretStore) *cobra.Comman NewConfigCmd(cfgStore), NewPromptCmd(), NewHookCmd(), + NewVersionCmd(), ) return root diff --git a/cmd/version_cobra.go b/cmd/version_cobra.go new file mode 100644 index 0000000..f6d0194 --- /dev/null +++ b/cmd/version_cobra.go @@ -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) + }, + } +} diff --git a/internal/llm/clean.go b/internal/llm/clean.go new file mode 100644 index 0000000..9a5d955 --- /dev/null +++ b/internal/llm/clean.go @@ -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) +} diff --git a/internal/llm/clean_test.go b/internal/llm/clean_test.go new file mode 100644 index 0000000..5bc56ab --- /dev/null +++ b/internal/llm/clean_test.go @@ -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) + } + }) + } +} diff --git a/internal/llm/ollama.go b/internal/llm/ollama.go index c9526ce..149ec24 100644 --- a/internal/llm/ollama.go +++ b/internal/llm/ollama.go @@ -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}, diff --git a/internal/llm/openai.go b/internal/llm/openai.go index aac3c73..d9368e2 100644 --- a/internal/llm/openai.go +++ b/internal/llm/openai.go @@ -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}, diff --git a/internal/prompt/embedded/conventional.toml b/internal/prompt/embedded/conventional.toml index bb84f28..dfc57a1 100644 --- a/internal/prompt/embedded/conventional.toml +++ b/internal/prompt/embedded/conventional.toml @@ -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: ( + - 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 = """