From 17051b59bae7b002cdf89798a2a27d5604c53d53 Mon Sep 17 00:00:00 2001 From: andev0x Date: Tue, 19 May 2026 10:28:07 +0700 Subject: [PATCH 1/2] feat(ai): implement local ai integration via ollama with hybrid engine ui --- README.md | 46 ++++- cmd/propose.go | 313 ++++++++++++++++++---------------- internal/ai/ai_test.go | 59 +++++++ internal/ai/ollama.go | 77 +++++++++ internal/ai/prompt.go | 128 ++++++++++++++ internal/analyzer/analyzer.go | 4 + internal/config/config.go | 31 ++++ 7 files changed, 508 insertions(+), 150 deletions(-) create mode 100644 internal/ai/ai_test.go create mode 100644 internal/ai/ollama.go create mode 100644 internal/ai/prompt.go diff --git a/README.md b/README.md index 839851f..a7c1b8d 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,8 @@ A lightweight CLI tool that analyzes your staged changes and generates professio ## Features +- **Hybrid Intelligence** - Merges deterministic heuristic algorithms with deep semantic understanding via Local LLMs +- **Local AI Integration (Ollama)** - Seamlessly integrates with Ollama to generate context-rich commit messages offline - **Intelligent Analysis** - Analyzes git status and diff to understand your changes using advanced pattern detection - **Conventional Commits** - Follows the Conventional Commits specification for standardized messages - **Configuration Hierarchy** - Local (`.gitmit.json`) → Global (`~/.gitmit.json`) → Default (Embedded) config support @@ -30,10 +32,46 @@ A lightweight CLI tool that analyzes your staged changes and generates professio - **Pattern Detection** - Detects error handling, tests, API changes, database operations, and more - **Multiple Commit Types** - Supports feat, fix, refactor, chore, test, docs, style, perf, ci, build, security, and more - **Zero Configuration** - Works out of the box with sensible defaults -- **Offline First** - Complete offline operation, no AI or external dependencies required +- **Hybrid Offline Approach** - Operates completely locally; chooses between heuristic rules or local AI - **History Tracking** - Learns from your commit history to avoid repetitive suggestions +## Hybrid Intelligence: Local AI via Ollama + +Gitmit evolves from a rule-based utility into a **Hybrid Intelligence** tool by integrating with **Ollama**. This allows you to use powerful local LLMs (like Qwen2.5-Coder) to generate highly descriptive and contextually accurate commit messages without sacrificing privacy or performance. + +### Prerequisites + +1. **Install Ollama**: Download from [ollama.com](https://ollama.com) +2. **Pull the Model**: + ```bash + ollama pull qwen2.5-coder:3b + ``` + +### Enabling the AI Engine + +To switch from the default heuristic engine to the AI engine, update your `.gitmit.json`: + +```json +{ + "engine": "ollama", + "ollama": { + "model": "qwen2.5-coder:3b", + "url": "http://localhost:11434", + "temperature": 0.2 + } +} +``` + +### How the Hybrid Pipeline Works + +Gitmit uses a tiered approach to ensure you always get a high-quality suggestion: + +1. **Refined Context Engineering**: Instead of piping raw diffs, Gitmit extracts structured metadata (modified files, symbols, change ratios, project type) and feeds it into a strict system prompt. +2. **Local LLM Processing**: Ollama processes the context using your specified model to generate a precise Conventional Commit message. +3. **Deterministic Fallback**: If the Ollama daemon is unreachable, the model is missing, or the output is malformatted, Gitmit **instantly falls back** to the Phase 1 Heuristic engine. Your workflow remains uninterrupted. + + ## Installation ### From Releases @@ -237,6 +275,12 @@ Gitmit uses intelligent offline algorithms to analyze your changes: - Special case detection - Diversity algorithms for variations +11. **Local AI Generation (Optional)** - When enabled: + - Serializes extracted context into a structured LLM prompt + - Dispatches generation requests to local Ollama daemon + - Validates AI output against Conventional Commit standards + - Provides deterministic fallback to heuristic engine on error + ## Commit Types Gitmit supports the following commit types (automatically detected): diff --git a/cmd/propose.go b/cmd/propose.go index 38cf0a9..ca0101e 100644 --- a/cmd/propose.go +++ b/cmd/propose.go @@ -11,6 +11,7 @@ import ( "github.com/spf13/cobra" "gitmit/internal/analyzer" + "gitmit/internal/ai" "gitmit/internal/config" "gitmit/internal/formatter" "gitmit/internal/history" @@ -90,6 +91,37 @@ func runPropose(cmd *cobra.Command, args []string) error { return err } + f := formatter.NewFormatter() + + // Calculate Heuristic Suggestion (Always available) + heuristicMsg, err := templater.GetMessage(commitMessage) + if err != nil { + return err + } + formattedHeuristic := f.FormatMessage(heuristicMsg, commitMessage.IsMajor) + + var aiMsg string + var finalMessage string + var usingAI bool + + // AI Engine Logic + if cfg.Engine == "ollama" { + prompt, err := ai.RenderPrompt(commitMessage, cfg.ProjectType, branchName) + if err == nil { + client := ai.NewOllamaClient(cfg.Ollama) + aiResponse, err := client.Generate(prompt) + if err == nil && ai.IsValidCommitMessage(aiResponse) { + aiMsg = strings.TrimSpace(aiResponse) + usingAI = true + finalMessage = aiMsg + } + } + } + + if !usingAI { + finalMessage = formattedHeuristic + } + // Show analysis context if requested if contextFlag || debugFlag { color.Blue("\nšŸ“Š Analysis Context:") @@ -111,183 +143,166 @@ func runPropose(cmd *cobra.Command, args []string) error { fmt.Println() } - if debugFlag { - // Print more detailed debug info - fmt.Printf("Full analyzer output: %+v\n", commitMessage) - if act, tpls := templater.DebugInfo(commitMessage); tpls != nil { - fmt.Printf("Template group: %s\n", act) - fmt.Printf("Candidate templates:\n") - for i, t := range tpls { - if i >= 10 { - break - } - fmt.Printf(" - %s\n", t) - } - } - } - - // Get multiple suggestions if interactive/suggestions mode - var suggestions []string - if interactiveFlag || suggestionsFlag { - suggestions, err = templater.GetSuggestions(commitMessage, maxSuggestions) - if err != nil { - return err - } - } else { - // Just get best message - msg, err := templater.GetMessage(commitMessage) - if err != nil { - return err + if suggestionsFlag && !usingAI { + // Show ranked suggestions only for Heuristic + color.Blue("\nšŸ’” Ranked Suggestions:") + suggestions, _ := templater.GetSuggestions(commitMessage, maxSuggestions) + for i, msg := range suggestions { + fmt.Printf("%d. %s\n", i+1, f.FormatMessage(msg, commitMessage.IsMajor)) } - suggestions = []string{msg} + fmt.Println() } - formatter := formatter.NewFormatter() + // Interactive Mode logic + if !summaryFlag && !autoFlag && !dryRunFlag { + usedSuggestions := map[string]bool{finalMessage: true} + regenerationCount := 0 + const maxRegenerations = 10 - if len(suggestions) == 0 { - return fmt.Errorf("no suitable commit messages found") - } + for { + fmt.Println() + if usingAI { + color.Cyan("Generated via: Local AI Engine [%s]", cfg.Ollama.Model) + } else { + color.Blue("Generated via: Heuristic Engine [Matrix Scored]") + } - // Format all suggestions - formattedSuggestions := make([]string, len(suggestions)) - for i, msg := range suggestions { - formattedSuggestions[i] = formatter.FormatMessage(msg, commitMessage.IsMajor) - } + color.Green("\nšŸ’” Suggested commit message:") + fmt.Printf("%s\n\n", finalMessage) - // Default to first/best suggestion - finalMessage := formattedSuggestions[0] + color.Blue("Actions:") + fmt.Println(" y - Accept and commit") + fmt.Println(" n - Reject and exit") + fmt.Println(" e - Edit message manually") - if suggestionsFlag { - // Show all suggestions with ranking - color.Blue("\nšŸ’” Ranked Suggestions:") - for i, msg := range formattedSuggestions { - if i == 0 { - color.Green("1. %s (recommended)\n", msg) + if usingAI { + fmt.Println(" r - Regenerate an alternative AI suggestion") + fmt.Println(" h - Fallback to classic Heuristic suggestion") } else { - fmt.Printf("%d. %s\n", i+1, msg) + fmt.Println(" r - Regenerate different suggestion (Heuristic)") + fmt.Println(" a - Upgrade suggestion with Local AI (Ollama)") } - } - fmt.Println() - } + fmt.Printf("\nChoice [y/n/e/r/%s]: ", map[bool]string{true: "h", false: "a"}[usingAI]) + + reader := bufio.NewReader(os.Stdin) + input, _ := reader.ReadString('\n') + choice := strings.TrimSpace(strings.ToLower(input)) + fmt.Println() + + switch choice { + case "y", "": + // Commit the message + commitCmd := exec.Command("git", "commit", "-m", finalMessage) + commitCmd.Stdout = os.Stdout + commitCmd.Stderr = os.Stderr + err := commitCmd.Run() + if err != nil { + return fmt.Errorf("error committing changes: %w", err) + } + color.Green("āœ… Changes committed successfully.") + history.AddEntry(finalMessage, "") // Save to history + if err := history.SaveHistory(); err != nil { + return err + } + return nil - if interactiveFlag && len(formattedSuggestions) > 1 { - // TODO: Add interactive selection using a proper terminal UI library - // For now, just show numbered options and read input - color.Blue("\nšŸ“ Choose a commit message:") - for i, msg := range formattedSuggestions { - fmt.Printf("%d. %s\n", i+1, msg) - } - fmt.Printf("\nEnter number (1-%d) [1]: ", len(formattedSuggestions)) + case "n": + color.Yellow("āŒ Commit cancelled.") + return nil - var choice string - fmt.Scanln(&choice) + case "e": + color.Blue("šŸ“ Edit the commit message:") + fmt.Printf("Current: %s\n", finalMessage) + fmt.Print("New message: ") - if choice != "" { - var num int - if _, err := fmt.Sscanf(choice, "%d", &num); err == nil && num > 0 && num <= len(formattedSuggestions) { - finalMessage = formattedSuggestions[num-1] - } - } - fmt.Println() + editedMessage, _ := reader.ReadString('\n') + editedMessage = strings.TrimSpace(editedMessage) - } + if editedMessage != "" { + finalMessage = editedMessage + usedSuggestions[finalMessage] = true + color.Green("\nāœ“ Updated commit message:") + } else { + color.Yellow("⚠ No changes made. Keeping current message.\n") + } + continue - // If not in summary mode, show the suggestion and prompt for action - if !summaryFlag { - color.Green("\nšŸ’” Suggested commit message:") - fmt.Printf("%s\n\n", finalMessage) - - if !autoFlag && !dryRunFlag { - // Track used suggestions to avoid repetition with 'r' option - usedSuggestions := map[string]bool{finalMessage: true} - regenerationCount := 0 - const maxRegenerations = 10 - - for { - color.Blue("Actions:") - fmt.Println(" y - Accept and commit") - fmt.Println(" n - Reject and exit") - fmt.Println(" e - Edit message manually") - fmt.Println(" r - Regenerate different suggestion") - fmt.Printf("\nChoice [y/n/e/r]: ") - - reader := bufio.NewReader(os.Stdin) - choice, _ := reader.ReadString('\n') - choice = strings.TrimSpace(strings.ToLower(choice)) - fmt.Println() - - switch choice { - case "y", "": - // Commit the message - commitCmd := exec.Command("git", "commit", "-m", finalMessage) - commitCmd.Stdout = os.Stdout - commitCmd.Stderr = os.Stderr - err := commitCmd.Run() - if err != nil { - return fmt.Errorf("error committing changes: %w", err) - } - color.Green("āœ… Changes committed successfully.") - history.AddEntry(finalMessage, "") // Save to history - if err := history.SaveHistory(); err != nil { - return err - } - return nil - - case "n": - color.Yellow("āŒ Commit cancelled.") - return nil - - case "e": - color.Blue("šŸ“ Edit the commit message:") - fmt.Printf("Current: %s\n", finalMessage) - fmt.Print("New message: ") - - editedMessage, _ := reader.ReadString('\n') - editedMessage = strings.TrimSpace(editedMessage) - - if editedMessage != "" { - finalMessage = editedMessage - usedSuggestions[finalMessage] = true - // Show the edited message and prompt again - color.Green("\nāœ“ Updated commit message:") - fmt.Printf("%s\n\n", finalMessage) - continue - } else { - color.Yellow("⚠ No changes made. Keeping current message.\n\n") - continue - } + case "r": + if regenerationCount >= maxRegenerations { + color.Yellow("⚠ Maximum regeneration attempts reached.\n") + continue + } - case "r": - if regenerationCount >= maxRegenerations { - color.Yellow("⚠ Maximum regeneration attempts reached.\n\n") - continue + if usingAI { + prompt, err := ai.RenderPrompt(commitMessage, cfg.ProjectType, branchName) + if err == nil { + client := ai.NewOllamaClient(cfg.Ollama) + aiResponse, err := client.Generate(prompt) + if err == nil && ai.IsValidCommitMessage(aiResponse) { + finalMessage = strings.TrimSpace(aiResponse) + regenerationCount++ + } } - - // Generate a new alternative suggestion + } else { newSuggestion, err := templater.GetAlternativeSuggestion(commitMessage, usedSuggestions) - if err != nil || newSuggestion == "" { - color.Yellow("⚠ Could not generate alternative suggestion. Try editing instead.\n\n") - continue + if err == nil && newSuggestion != "" { + finalMessage = f.FormatMessage(newSuggestion, commitMessage.IsMajor) + regenerationCount++ } + } + usedSuggestions[finalMessage] = true + continue - regenerationCount++ - finalMessage = formatter.FormatMessage(newSuggestion, commitMessage.IsMajor) - usedSuggestions[finalMessage] = true - - color.Green("\nšŸ’” Alternative suggestion #%d:", regenerationCount) - fmt.Printf("%s\n\n", finalMessage) + case "a": + if usingAI { continue + } + // Try to connect to Ollama + prompt, err := ai.RenderPrompt(commitMessage, cfg.ProjectType, branchName) + if err == nil { + client := ai.NewOllamaClient(cfg.Ollama) + aiResponse, err := client.Generate(prompt) + if err == nil && ai.IsValidCommitMessage(aiResponse) { + aiMsg = strings.TrimSpace(aiResponse) + finalMessage = aiMsg + usingAI = true + } else { + color.Red("\nāš ļø Ollama connection not detected on %s", cfg.Ollama.URL) + fmt.Println("To enable Local AI generation, please ensure:") + fmt.Println(" 1. Ollama is running locally (`ollama serve`)") + fmt.Printf(" 2. The required model is pulled (`ollama pull %s`)\n", cfg.Ollama.Model) + fmt.Println(" 3. Your .gitmit.json sets \"engine\": \"ollama\"") + fmt.Println("\nFalling back to interactive options...") + } + } + continue - default: - color.Yellow("⚠ Invalid choice. Please select y, n, e, or r.\n\n") + case "h": + if !usingAI { continue } + usingAI = false + finalMessage = formattedHeuristic + continue + + default: + color.Yellow("⚠ Invalid choice. Please select a valid option.\n") + continue } } - } else { + } + + // Handle non-interactive cases (summary, auto, dry-run) + if summaryFlag { fmt.Println(finalMessage) + return nil } + color.Green("\nšŸ’” Suggested commit message:") + fmt.Printf("%s\n\n", finalMessage) + + + // Handle auto-commit and dry-run cases if autoFlag && !dryRunFlag { commitCmd := exec.Command("git", "commit", "-m", finalMessage) diff --git a/internal/ai/ai_test.go b/internal/ai/ai_test.go new file mode 100644 index 0000000..3a1cea5 --- /dev/null +++ b/internal/ai/ai_test.go @@ -0,0 +1,59 @@ +package ai + +import ( + "strings" + "testing" + + "gitmit/internal/analyzer" +) + +func TestRenderPrompt(t *testing.T) { + msg := &analyzer.CommitMessage{ + Action: "feat", + Topic: "auth", + Files: []string{"internal/auth/login.go", "internal/auth/logout.go"}, + DetectedFunctions: []string{"Login", "Logout"}, + TotalAdded: 50, + TotalRemoved: 10, + } + + prompt, err := RenderPrompt(msg, "go", "feature/auth-implementation") + if err != nil { + t.Fatalf("RenderPrompt failed: %v", err) + } + + expectedParts := []string{ + "Project Type: go", + "Active Branch Name: feature/auth-implementation", + "Detected Intent/Type Bonus: feat", + "internal/auth/login.go", + "[func] Login", + "Added/Deleted Line Ratio: 0.83", + } + + for _, part := range expectedParts { + if !strings.Contains(prompt, part) { + t.Errorf("Prompt missing expected part: %s", part) + } + } +} + +func TestIsValidCommitMessage(t *testing.T) { + tests := []struct { + msg string + expected bool + }{ + {"feat(auth): add login functionality", true}, + {"fix: resolve memory leak", true}, + {"chore(deps): update dependencies", true}, + {"Invalid message", false}, + {"feat add something", false}, + {"", false}, + } + + for _, tt := range tests { + if got := IsValidCommitMessage(tt.msg); got != tt.expected { + t.Errorf("IsValidCommitMessage(%q) = %v; want %v", tt.msg, got, tt.expected) + } + } +} diff --git a/internal/ai/ollama.go b/internal/ai/ollama.go new file mode 100644 index 0000000..ca88502 --- /dev/null +++ b/internal/ai/ollama.go @@ -0,0 +1,77 @@ +package ai + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "time" + + "gitmit/internal/config" +) + +// OllamaRequest represents the request body for Ollama's /api/generate endpoint +type OllamaRequest struct { + Model string `json:"model"` + Prompt string `json:"prompt"` + Stream bool `json:"stream"` + Temperature float64 `json:"temperature,omitempty"` +} + +// OllamaResponse represents the response body from Ollama +type OllamaResponse struct { + Model string `json:"model"` + Response string `json:"response"` + Done bool `json:"done"` +} + +// OllamaClient handles communication with the local Ollama daemon +type OllamaClient struct { + config config.OllamaConfig +} + +// NewOllamaClient creates a new OllamaClient +func NewOllamaClient(cfg config.OllamaConfig) *OllamaClient { + return &OllamaClient{config: cfg} +} + +// Generate sends a prompt to Ollama and returns the generated response +func (c *OllamaClient) Generate(prompt string) (string, error) { + reqBody := OllamaRequest{ + Model: c.config.Model, + Prompt: prompt, + Stream: false, + Temperature: c.config.Temperature, + } + + jsonData, err := json.Marshal(reqBody) + if err != nil { + return "", fmt.Errorf("error marshaling ollama request: %w", err) + } + + url := fmt.Sprintf("%s/api/generate", c.config.URL) + + client := &http.Client{ + Timeout: 30 * time.Second, + } + + resp, err := client.Post(url, "application/json", bytes.NewBuffer(jsonData)) + if err != nil { + return "", fmt.Errorf("ollama daemon unreachable at %s: %w", url, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + if resp.StatusCode == http.StatusNotFound { + return "", fmt.Errorf("model '%s' not found. please run: ollama pull %s", c.config.Model, c.config.Model) + } + return "", fmt.Errorf("ollama returned status code: %d", resp.StatusCode) + } + + var ollamaResp OllamaResponse + if err := json.NewDecoder(resp.Body).Decode(&ollamaResp); err != nil { + return "", fmt.Errorf("error decoding ollama response: %w", err) + } + + return ollamaResp.Response, nil +} diff --git a/internal/ai/prompt.go b/internal/ai/prompt.go new file mode 100644 index 0000000..33afc77 --- /dev/null +++ b/internal/ai/prompt.go @@ -0,0 +1,128 @@ +package ai + +import ( + "bytes" + "fmt" + "strings" + "text/template" + + "gitmit/internal/analyzer" +) + +// PromptContext represents the data structure passed to the prompt template +type PromptContext struct { + ProjectType string + CurrentBranch string + RecommendedType string + Files []string + CodeSymbols []string + DependencyAlert string + DiffSummary DiffSummary +} + +// DiffSummary contains ratio of changes +type DiffSummary struct { + Ratio float64 +} + +const promptTemplate = `You are an expert developer assistant. Analyze the provided structured git diff metadata and generate a single-line commit message following the Conventional Commits specification. + +Guidelines: +1. Format MUST be: (): +2. Allowed types: feat, fix, refactor, chore, test, docs, style, perf, ci, build, security +3. Do NOT include any markdown, backticks, quotes, or introductory text like "Here is your commit message:". +4. Output ONLY the raw string of the commit message. + +Metadata Context: +- Project Type: {{.ProjectType}} +- Active Branch Name: {{.CurrentBranch}} +- Detected Intent/Type Bonus: {{.RecommendedType}} +- Modified Files: {{range .Files}}{{.}}, {{end}} +- Key Code Symbols Altered: {{range .CodeSymbols}}{{.}}, {{end}} +- Dependency Changes: {{.DependencyAlert}} +- Added/Deleted Line Ratio: {{printf "%.2f" .DiffSummary.Ratio}} + +Output: +` + +// RenderPrompt generates the prompt string using the provided context +func RenderPrompt(msg *analyzer.CommitMessage, projectType, branchName string) (string, error) { + tmpl, err := template.New("prompt").Parse(promptTemplate) + if err != nil { + return "", fmt.Errorf("error parsing prompt template: %w", err) + } + + var codeSymbols []string + for _, f := range msg.DetectedFunctions { + codeSymbols = append(codeSymbols, fmt.Sprintf("[func] %s", f)) + } + for _, s := range msg.DetectedStructs { + codeSymbols = append(codeSymbols, fmt.Sprintf("[struct] %s", s)) + } + for _, m := range msg.DetectedMethods { + codeSymbols = append(codeSymbols, fmt.Sprintf("[method] %s", m)) + } + + depAlert := "None" + if msg.IsDepsOnly { + depAlert = "Dependency changes detected in package manager files" + } + + ratio := 0.0 + total := msg.TotalAdded + msg.TotalRemoved + if total > 0 { + ratio = float64(msg.TotalAdded) / float64(total) + } + + ctx := PromptContext{ + ProjectType: projectType, + CurrentBranch: branchName, + RecommendedType: msg.Action, + Files: msg.Files, + CodeSymbols: codeSymbols, + DependencyAlert: depAlert, + DiffSummary: DiffSummary{ + Ratio: ratio, + }, + } + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, ctx); err != nil { + return "", fmt.Errorf("error executing prompt template: %w", err) + } + + return buf.String(), nil +} + +// IsValidCommitMessage checks if the AI output follows the Conventional Commits format +func IsValidCommitMessage(msg string) bool { + // Simple regex check for (): or : + // Conventional commits regex: ^([a-z]+)(\([a-z0-9/,-]+\))?!?: .+$ + // We'll use a slightly more relaxed one as requested in the blueprint + + msg = strings.TrimSpace(msg) + if msg == "" { + return false + } + + // Basic check for type and colon + types := []string{"feat", "fix", "refactor", "chore", "test", "docs", "style", "perf", "ci", "build", "security"} + + hasType := false + for _, t := range types { + if strings.HasPrefix(msg, t) { + hasType = true + break + } + } + + if !hasType { + return false + } + + if !strings.Contains(msg, ": ") { + return false + } + + return true +} diff --git a/internal/analyzer/analyzer.go b/internal/analyzer/analyzer.go index a01aec8..e99c48d 100644 --- a/internal/analyzer/analyzer.go +++ b/internal/analyzer/analyzer.go @@ -21,6 +21,7 @@ type CommitMessage struct { IsMajor bool TotalAdded int TotalRemoved int + Files []string FileExtensions []string RenamedFiles []*parser.Change CopiedFiles []*parser.Change @@ -55,6 +56,7 @@ func (a *Analyzer) AnalyzeChanges(totalAdded, totalRemoved int, branchName strin TotalRemoved: totalRemoved, } + var allFiles []string var allFileExtensions []string var allTopics []string var allPurposes []string @@ -65,6 +67,7 @@ func (a *Analyzer) AnalyzeChanges(totalAdded, totalRemoved int, branchName strin var allPatterns []string for _, change := range a.changes { + allFiles = append(allFiles, change.File) if change.IsRename { commitMessage.RenamedFiles = append(commitMessage.RenamedFiles, change) } @@ -95,6 +98,7 @@ func (a *Analyzer) AnalyzeChanges(totalAdded, totalRemoved int, branchName strin allPatterns = append(allPatterns, patterns...) } + commitMessage.Files = uniqueStrings(allFiles) commitMessage.FileExtensions = uniqueStrings(allFileExtensions) commitMessage.DetectedFunctions = uniqueStrings(allFunctions) commitMessage.DetectedStructs = uniqueStrings(allStructs) diff --git a/internal/config/config.go b/internal/config/config.go index 5b550b1..aa61d4f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -9,6 +9,8 @@ import ( // Config represents the structure of .gitmit.json type Config struct { + Engine string `json:"engine"` // heuristic or ollama + Ollama OllamaConfig `json:"ollama"` // Ollama specific config TopicMappings map[string]string `json:"topicMappings"` KeywordMappings map[string]string `json:"keywordMappings"` ProjectType string `json:"projectType"` // go, nodejs, python, etc. @@ -17,10 +19,23 @@ type Config struct { DiffStatThreshold float64 `json:"diffStatThreshold"` // Threshold for add/delete ratio } +// OllamaConfig represents the structure of the ollama configuration block +type OllamaConfig struct { + Model string `json:"model"` + URL string `json:"url"` + Temperature float64 `json:"temperature"` +} + // LoadConfig loads the configuration with hierarchy: Local (.gitmit.json) → Global (~/.gitmit.json) → Default (embedded) func LoadConfig() (*Config, error) { // Initialize with default empty config cfg := &Config{ + Engine: "heuristic", + Ollama: OllamaConfig{ + Model: "qwen2.5-coder:3b", + URL: "http://localhost:11434", + Temperature: 0.2, + }, TopicMappings: make(map[string]string), KeywordMappings: make(map[string]string), Keywords: make(map[string]map[string]int), @@ -186,6 +201,22 @@ func mergeConfigFromFile(cfg *Config, path string) error { } // Merge the loaded config into the existing config + // Engine + if fileCfg.Engine != "" { + cfg.Engine = fileCfg.Engine + } + + // Ollama + if fileCfg.Ollama.Model != "" { + cfg.Ollama.Model = fileCfg.Ollama.Model + } + if fileCfg.Ollama.URL != "" { + cfg.Ollama.URL = fileCfg.Ollama.URL + } + if fileCfg.Ollama.Temperature > 0 { + cfg.Ollama.Temperature = fileCfg.Ollama.Temperature + } + // Topic mappings if fileCfg.TopicMappings != nil { for k, v := range fileCfg.TopicMappings { From b934ff429ca60367338aa25ecfe30937a7a985d9 Mon Sep 17 00:00:00 2001 From: andev0x Date: Tue, 19 May 2026 10:37:25 +0700 Subject: [PATCH 2/2] feat(cli): package assets with go:embed and wire interactive AI choice --- README.md | 5 +++- assets/assets.go | 45 ++++++++++++++++++++++++++++++ assets/messages/init_success.txt | 7 +++++ assets/messages/ollama_warning.txt | 7 +++++ assets/prompts/system_prompt.txt | 18 ++++++++++++ cmd/init.go | 9 +++--- cmd/propose.go | 9 ++---- internal/ai/prompt.go | 26 ++++------------- 8 files changed, 94 insertions(+), 32 deletions(-) create mode 100644 assets/assets.go create mode 100644 assets/messages/init_success.txt create mode 100644 assets/messages/ollama_warning.txt create mode 100644 assets/prompts/system_prompt.txt diff --git a/README.md b/README.md index a7c1b8d..21e7c12 100644 --- a/README.md +++ b/README.md @@ -148,8 +148,9 @@ Actions: n - Reject and exit e - Edit message manually r - Regenerate different suggestion + a - Upgrade suggestion with Local AI (Ollama) -Choice [y/n/e/r]: +Choice [y/n/e/r/a]: ``` **Interactive Options:** @@ -157,6 +158,8 @@ Choice [y/n/e/r]: - **`n`** - Reject and exit without committing - **`e`** - Edit the message manually with your own text - **`r`** - Regenerate a completely different suggestion using intelligent variation algorithms +- **`a`** - **Upgrade to Local AI**: If you are using the heuristic engine, this attempts to connect to Ollama for a more semantic suggestion +- **`h`** - **Fallback to Heuristic**: If you are in AI mode, this switches back to the classic rule-based engine ### Command-Line Options diff --git a/assets/assets.go b/assets/assets.go new file mode 100644 index 0000000..d19367b --- /dev/null +++ b/assets/assets.go @@ -0,0 +1,45 @@ +package assets + +import ( + "bytes" + "embed" + "text/template" +) + +//go:embed prompts/* messages/* +var Files embed.FS + +// GetPrompt returns the system prompt template +func GetPrompt() (string, error) { + b, err := Files.ReadFile("prompts/system_prompt.txt") + return string(b), err +} + +// GetOllamaWarning returns the Ollama warning message +func GetOllamaWarning() (string, error) { + b, err := Files.ReadFile("messages/ollama_warning.txt") + return string(b), err +} + +// GetInitSuccess returns the initialization success message +func GetInitSuccess() (string, error) { + b, err := Files.ReadFile("messages/init_success.txt") + return string(b), err +} + +// RenderOllamaWarning renders the Ollama warning message with the provided context +func RenderOllamaWarning(url, model string) (string, error) { + warningTmpl, err := GetOllamaWarning() + if err != nil { + return "", err + } + tmpl, err := template.New("warning").Parse(warningTmpl) + if err != nil { + return "", err + } + var buf bytes.Buffer + if err := tmpl.Execute(&buf, struct{ URL, Model string }{URL: url, Model: model}); err != nil { + return "", err + } + return buf.String(), nil +} diff --git a/assets/messages/init_success.txt b/assets/messages/init_success.txt new file mode 100644 index 0000000..5efb940 --- /dev/null +++ b/assets/messages/init_success.txt @@ -0,0 +1,7 @@ + +You can now customize the configuration to fit your project's needs. + +Configuration hierarchy: + 1. Local (.gitmit.json) - project-specific settings + 2. Global (~/.gitmit.json) - user-wide settings + 3. Default (embedded) - built-in defaults diff --git a/assets/messages/ollama_warning.txt b/assets/messages/ollama_warning.txt new file mode 100644 index 0000000..adffa4a --- /dev/null +++ b/assets/messages/ollama_warning.txt @@ -0,0 +1,7 @@ +āš ļø Ollama connection not detected on {{.URL}} +To enable Local AI generation, please ensure: + 1. Ollama is running locally (`ollama serve`) + 2. The required model is pulled (`ollama pull {{.Model}}`) + 3. Your .gitmit.json sets "engine": "ollama" + +Falling back to interactive options... diff --git a/assets/prompts/system_prompt.txt b/assets/prompts/system_prompt.txt new file mode 100644 index 0000000..ffa2b96 --- /dev/null +++ b/assets/prompts/system_prompt.txt @@ -0,0 +1,18 @@ +You are an expert developer assistant. Analyze the provided structured git diff metadata and generate a single-line commit message following the Conventional Commits specification. + +Guidelines: +1. Format MUST be: (): +2. Allowed types: feat, fix, refactor, chore, test, docs, style, perf, ci, build, security +3. Do NOT include any markdown, backticks, quotes, or introductory text like "Here is your commit message:". +4. Output ONLY the raw string of the commit message. + +Metadata Context: +- Project Type: {{.ProjectType}} +- Active Branch Name: {{.CurrentBranch}} +- Detected Intent/Type Bonus: {{.RecommendedType}} +- Modified Files: {{range .Files}}{{.}}, {{end}} +- Key Code Symbols Altered: {{range .CodeSymbols}}{{.}}, {{end}} +- Dependency Changes: {{.DependencyAlert}} +- Added/Deleted Line Ratio: {{printf "%.2f" .DiffSummary.Ratio}} + +Output: diff --git a/cmd/init.go b/cmd/init.go index a975e79..ca84950 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -8,6 +8,7 @@ import ( "github.com/fatih/color" "github.com/spf13/cobra" + "gitmit/assets" "gitmit/internal/config" ) @@ -152,11 +153,9 @@ func runInit(cmd *cobra.Command, args []string) error { color.Green("āœ… Created config file: %s", configPath) color.Blue("\nšŸ“ Detected project type: %s", projectType) - fmt.Println("\nYou can now customize the configuration to fit your project's needs.") - fmt.Println("\nConfiguration hierarchy:") - fmt.Println(" 1. Local (.gitmit.json) - project-specific settings") - fmt.Println(" 2. Global (~/.gitmit.json) - user-wide settings") - fmt.Println(" 3. Default (embedded) - built-in defaults") + + msg, _ := assets.GetInitSuccess() + fmt.Println(msg) return nil } diff --git a/cmd/propose.go b/cmd/propose.go index ca0101e..7f52f83 100644 --- a/cmd/propose.go +++ b/cmd/propose.go @@ -10,6 +10,7 @@ import ( "github.com/fatih/color" "github.com/spf13/cobra" + "gitmit/assets" "gitmit/internal/analyzer" "gitmit/internal/ai" "gitmit/internal/config" @@ -267,12 +268,8 @@ func runPropose(cmd *cobra.Command, args []string) error { finalMessage = aiMsg usingAI = true } else { - color.Red("\nāš ļø Ollama connection not detected on %s", cfg.Ollama.URL) - fmt.Println("To enable Local AI generation, please ensure:") - fmt.Println(" 1. Ollama is running locally (`ollama serve`)") - fmt.Printf(" 2. The required model is pulled (`ollama pull %s`)\n", cfg.Ollama.Model) - fmt.Println(" 3. Your .gitmit.json sets \"engine\": \"ollama\"") - fmt.Println("\nFalling back to interactive options...") + warning, _ := assets.RenderOllamaWarning(cfg.Ollama.URL, cfg.Ollama.Model) + color.Red("\n%s", warning) } } continue diff --git a/internal/ai/prompt.go b/internal/ai/prompt.go index 33afc77..879c54a 100644 --- a/internal/ai/prompt.go +++ b/internal/ai/prompt.go @@ -6,6 +6,7 @@ import ( "strings" "text/template" + "gitmit/assets" "gitmit/internal/analyzer" ) @@ -25,28 +26,13 @@ type DiffSummary struct { Ratio float64 } -const promptTemplate = `You are an expert developer assistant. Analyze the provided structured git diff metadata and generate a single-line commit message following the Conventional Commits specification. - -Guidelines: -1. Format MUST be: (): -2. Allowed types: feat, fix, refactor, chore, test, docs, style, perf, ci, build, security -3. Do NOT include any markdown, backticks, quotes, or introductory text like "Here is your commit message:". -4. Output ONLY the raw string of the commit message. - -Metadata Context: -- Project Type: {{.ProjectType}} -- Active Branch Name: {{.CurrentBranch}} -- Detected Intent/Type Bonus: {{.RecommendedType}} -- Modified Files: {{range .Files}}{{.}}, {{end}} -- Key Code Symbols Altered: {{range .CodeSymbols}}{{.}}, {{end}} -- Dependency Changes: {{.DependencyAlert}} -- Added/Deleted Line Ratio: {{printf "%.2f" .DiffSummary.Ratio}} - -Output: -` - // RenderPrompt generates the prompt string using the provided context func RenderPrompt(msg *analyzer.CommitMessage, projectType, branchName string) (string, error) { + promptTemplate, err := assets.GetPrompt() + if err != nil { + return "", fmt.Errorf("error loading prompt template: %w", err) + } + tmpl, err := template.New("prompt").Parse(promptTemplate) if err != nil { return "", fmt.Errorf("error parsing prompt template: %w", err)