diff --git a/cmd/propose.go b/cmd/propose.go index 2e83c90..96c1234 100644 --- a/cmd/propose.go +++ b/cmd/propose.go @@ -1,9 +1,11 @@ package cmd import ( + "bufio" "fmt" "os" "os/exec" + "strings" "github.com/fatih/color" "github.com/spf13/cobra" @@ -17,16 +19,29 @@ import ( ) var ( - stagedFlag bool - summaryFlag bool - autoFlag bool - dryRunFlag bool - debugFlag bool + stagedFlag bool + summaryFlag bool + autoFlag bool + dryRunFlag bool + debugFlag bool + contextFlag bool + maxSuggestions int proposeCmd = &cobra.Command{ Use: "propose", - Short: "Propose a commit message from a git diff", - RunE: runPropose, + Short: "Propose commit messages from git diff", + Long: `Analyze staged changes and suggest commit messages based on the context. + +When using --interactive (-i) or --suggestions (-s), multiple suggestions will be shown +ranked by how well they match the context (file types, changes, purposes). + +The --context flag shows what was analyzed to help understand the suggestions.`, + Example: ` gitmit propose # Get best suggestion + gitmit propose -i # Choose from multiple suggestions + gitmit propose -s # Show ranked suggestions + gitmit propose --context # Show what was analyzed + gitmit propose --auto # Auto-commit with best suggestion`, + RunE: runPropose, } ) @@ -35,9 +50,11 @@ func init() { proposeCmd.Flags().BoolVar(&stagedFlag, "staged", true, "Only parse staged files (default: true)") proposeCmd.Flags().BoolVar(&summaryFlag, "summary", false, "Print short output (summary only)") - proposeCmd.Flags().BoolVar(&autoFlag, "auto", false, "Commit with the generated message") + proposeCmd.Flags().BoolVar(&autoFlag, "auto", false, "Auto-commit with the generated message") proposeCmd.Flags().BoolVar(&dryRunFlag, "dry-run", false, "Preview without committing") proposeCmd.Flags().BoolVar(&debugFlag, "debug", false, "Print debug info (analyzer output + chosen templates)") + proposeCmd.Flags().BoolVar(&contextFlag, "context", false, "Show what was analyzed to generate suggestions") + proposeCmd.Flags().IntVar(&maxSuggestions, "max-suggestions", 5, "Maximum number of suggestions to show") } func runPropose(cmd *cobra.Command, args []string) error { @@ -72,13 +89,33 @@ func runPropose(cmd *cobra.Command, args []string) error { return err } + // Show analysis context if requested + if contextFlag || debugFlag { + color.Blue("\nšŸ“Š Analysis Context:") + fmt.Printf("Action: %s\n", commitMessage.Action) + fmt.Printf("Topic: %s\n", commitMessage.Topic) + if commitMessage.Item != "" { + fmt.Printf("Item: %s\n", commitMessage.Item) + } + if commitMessage.Purpose != "" { + fmt.Printf("Purpose: %s\n", commitMessage.Purpose) + } + if commitMessage.Scope != "" { + fmt.Printf("Scope: %s\n", commitMessage.Scope) + } + fmt.Printf("Files: +%d -%d\n", commitMessage.TotalAdded, commitMessage.TotalRemoved) + if len(commitMessage.FileExtensions) > 0 { + fmt.Printf("Types: %v\n", commitMessage.FileExtensions) + } + fmt.Println() + } + if debugFlag { - // Print analyzer output - fmt.Printf("Analyzer result: %+v\n", commitMessage) - // Print available templates/action/topic info from templater + // Print more detailed debug info + fmt.Printf("Full analyzer output: %+v\n", commitMessage) if act, tpls := templater.DebugInfo(commitMessage); tpls != nil { - fmt.Printf("Resolved action key: %s\n", act) - fmt.Printf("Candidate templates (first 10):\n") + fmt.Printf("Template group: %s\n", act) + fmt.Printf("Candidate templates:\n") for i, t := range tpls { if i >= 10 { break @@ -88,21 +125,154 @@ func runPropose(cmd *cobra.Command, args []string) error { } } - initialMessage, err := templater.GetMessage(commitMessage) - if err != nil { - return err + // 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 + } + suggestions = []string{msg} } formatter := formatter.NewFormatter() - finalMessage := formatter.FormatMessage(initialMessage, commitMessage.IsMajor) - if summaryFlag { - fmt.Println(finalMessage) + if len(suggestions) == 0 { + return fmt.Errorf("no suitable commit messages found") + } + + // Format all suggestions + formattedSuggestions := make([]string, len(suggestions)) + for i, msg := range suggestions { + formattedSuggestions[i] = formatter.FormatMessage(msg, commitMessage.IsMajor) + } + + // Default to first/best suggestion + finalMessage := formattedSuggestions[0] + + 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) + } else { + fmt.Printf("%d. %s\n", i+1, msg) + } + } + fmt.Println() + } + + 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)) + + var choice string + fmt.Scanln(&choice) + + 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() + + } + + // 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 { + for { + color.Blue("What would you like to do?") + fmt.Println("y - Accept and commit") + fmt.Println("n - Reject and exit") + fmt.Println("e - Edit message") + fmt.Println("c - Create new message") + fmt.Printf("\nChoice [y/n/e/c]: ") + + var choice string + fmt.Scanln(&choice) + fmt.Println() + + switch strings.ToLower(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 (press Enter when done):") + fmt.Printf("%s", finalMessage) + + var editedMessage string + scanner := bufio.NewScanner(os.Stdin) + if scanner.Scan() { + editedMessage = scanner.Text() + } + + if editedMessage != "" { + finalMessage = editedMessage + // Show the edited message and prompt again + color.Green("\nUpdated commit message:") + fmt.Printf("%s\n\n", finalMessage) + continue + } + + case "c": + color.Blue("šŸ“ Enter your commit message:") + scanner := bufio.NewScanner(os.Stdin) + if scanner.Scan() { + finalMessage = scanner.Text() + } + + if finalMessage != "" { + // Show the new message and prompt again + color.Green("\nNew commit message:") + fmt.Printf("%s\n\n", finalMessage) + continue + } + + default: + color.Yellow("Invalid choice. Please try again.") + continue + } + } + } } else { - color.Green(finalMessage) - fmt.Println("\nCopy the message above and use it to commit.") + fmt.Println(finalMessage) } + // Handle auto-commit and dry-run cases if autoFlag && !dryRunFlag { commitCmd := exec.Command("git", "commit", "-m", finalMessage) commitCmd.Stdout = os.Stdout @@ -111,8 +281,8 @@ func runPropose(cmd *cobra.Command, args []string) error { if err != nil { return fmt.Errorf("error committing changes: %w", err) } - fmt.Println("Changes committed successfully.") - history.AddEntry(finalMessage, initialMessage) // Pass actual template used + color.Green("āœ… Changes committed successfully.") + history.AddEntry(finalMessage, "") // Save to history if err := history.SaveHistory(); err != nil { return err } diff --git a/cmd/root.go b/cmd/root.go index d008135..58fc416 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1,21 +1,49 @@ package cmd import ( + "os" + "github.com/spf13/cobra" ) var ( version = "0.1.0" + // Global flags + interactiveFlag bool + suggestionsFlag bool rootCmd = &cobra.Command{ Use: "gitmit", Short: "🧠 Smart Git Commit Message Generator", Long: `Gitmit is a lightweight CLI tool that analyzes your staged changes -and suggests professional commit messages following Conventional Commits format.`, +and suggests professional commit messages following Conventional Commits format. + +Examples: + gitmit # Analyze changes and suggest commit message + gitmit propose # Same as above + gitmit propose -i # Interactive mode with multiple suggestions + gitmit propose -s # Show multiple suggestions + gitmit propose --auto # Auto-commit with best suggestion`, Version: version, + PersistentPreRun: func(cmd *cobra.Command, args []string) { + // Add global validation or setup here + if suggestionsFlag { + interactiveFlag = true // -s implies -i + } + }, } ) +func init() { + // Global flags available to all commands + rootCmd.PersistentFlags().BoolVarP(&interactiveFlag, "interactive", "i", false, "Interactive mode with multiple suggestions") + rootCmd.PersistentFlags().BoolVarP(&suggestionsFlag, "suggestions", "s", false, "Show multiple ranked suggestions") +} + func Execute() error { + // āœ… Added: if no subcommand provided, fallback to "propose" + if len(os.Args) == 1 { + return proposeCmd.RunE(rootCmd, nil) + } return rootCmd.Execute() } diff --git a/internal/templater/templater.go b/internal/templater/templater.go index 7992e92..294e6dc 100644 --- a/internal/templater/templater.go +++ b/internal/templater/templater.go @@ -7,6 +7,7 @@ import ( "math/rand" "os" "path/filepath" + "sort" "strings" "gitmit/internal/analyzer" @@ -224,7 +225,7 @@ func (t *Templater) GetMessage(msg *analyzer.CommitMessage) (string, error) { score += 1 } // small randomness to diversify choices - score += rand.Intn(2) + score += rand.Intn(2) candidates = append(candidates, scored{tmpl: tmpl, score: score}) } @@ -289,6 +290,125 @@ func (t *Templater) GetMessage(msg *analyzer.CommitMessage) (string, error) { return formattedMsg, nil } +// GetSuggestions returns multiple commit message suggestions ranked by context matching +func (t *Templater) GetSuggestions(msg *analyzer.CommitMessage, maxSuggestions int) ([]string, error) { + actionKey, candidates := t.DebugInfo(msg) + if candidates == nil || len(candidates) == 0 { + return nil, fmt.Errorf("no templates found for action: %s", actionKey) + } + + // Score all candidates + type scoredTemplate struct { + template string + score int + } + + var scored []scoredTemplate + + // Prepare placeholder values + source := "" + target := "" + if len(msg.RenamedFiles) > 0 { + source = msg.RenamedFiles[0].Source + target = msg.RenamedFiles[0].Target + } + + for _, tmpl := range candidates { + score := 0 + + // Core context matching + if strings.Contains(tmpl, "{item}") && msg.Item != "" { + score += 3 + } + if strings.Contains(tmpl, "{purpose}") && msg.Purpose != "" && msg.Purpose != "general update" { + score += 2 + } + if strings.Contains(tmpl, "{source}") && source != "" { + score += 3 + } + if strings.Contains(tmpl, "{target}") && target != "" { + score += 3 + } + if strings.Contains(tmpl, "{topic}") && msg.Topic != "" { + score += 1 + } + + // Additional heuristics + if msg.IsDocsOnly && strings.Contains(strings.ToLower(tmpl), "doc") { + score += 2 + } + if msg.IsConfigOnly && strings.Contains(strings.ToLower(tmpl), "config") { + score += 2 + } + if msg.IsDepsOnly && strings.Contains(strings.ToLower(tmpl), "dep") { + score += 2 + } + + // File type bonus + for _, ext := range msg.FileExtensions { + if strings.Contains(strings.ToLower(tmpl), ext) { + score++ + } + } + + // Small randomness for variety + score += rand.Intn(2) + + scored = append(scored, scoredTemplate{tmpl, score}) + } + + // Sort by score descending + sort.Slice(scored, func(i, j int) bool { + return scored[i].score > scored[j].score + }) + + // Get top N suggestions + suggestions := make([]string, 0, maxSuggestions) + usedMessages := make(map[string]bool) + + replacer := strings.NewReplacer( + "{topic}", msg.Topic, + "{item}", msg.Item, + "{purpose}", msg.Purpose, + "{source}", source, + "{target}", target, + ) + + // Take top scored templates until we have enough unique messages + for _, s := range scored { + if len(suggestions) >= maxSuggestions { + break + } + + message := replacer.Replace(s.template) + + // Skip if we've seen this exact message or it's in history + if usedMessages[message] || t.history.Contains(message) { + continue + } + + suggestions = append(suggestions, message) + usedMessages[message] = true + } + + // If we don't have enough suggestions, include some that might be in history + if len(suggestions) < maxSuggestions { + for _, s := range scored { + if len(suggestions) >= maxSuggestions { + break + } + + message := replacer.Replace(s.template) + if !usedMessages[message] { + suggestions = append(suggestions, message) + usedMessages[message] = true + } + } + } + + return suggestions, nil +} + // DebugInfo returns the resolved action key and the candidate templates for a CommitMessage func (t *Templater) DebugInfo(msg *analyzer.CommitMessage) (string, []string) { // same mapping as in GetMessage