diff --git a/cmd/propose.go b/cmd/propose.go index 64a9909..38cf0a9 100644 --- a/cmd/propose.go +++ b/cmd/propose.go @@ -79,7 +79,8 @@ func runPropose(cmd *cobra.Command, args []string) error { } analyzer := analyzer.NewAnalyzer(changes, cfg) - commitMessage := analyzer.AnalyzeChanges(gitParser.TotalAdded, gitParser.TotalRemoved) + branchName, _ := gitParser.GetCurrentBranch() + commitMessage := analyzer.AnalyzeChanges(gitParser.TotalAdded, gitParser.TotalRemoved, branchName) if commitMessage == nil { return fmt.Errorf("could not analyze changes") } diff --git a/internal/analyzer/analyzer.go b/internal/analyzer/analyzer.go index ea4d106..43ba248 100644 --- a/internal/analyzer/analyzer.go +++ b/internal/analyzer/analyzer.go @@ -3,6 +3,7 @@ package analyzer import ( "bufio" "path/filepath" + "regexp" "strings" "gitmit/internal/config" @@ -44,7 +45,7 @@ func NewAnalyzer(changes []*parser.Change, cfg *config.Config) *Analyzer { } // AnalyzeChanges analyzes the git changes and returns a CommitMessage -func (a *Analyzer) AnalyzeChanges(totalAdded, totalRemoved int) *CommitMessage { +func (a *Analyzer) AnalyzeChanges(totalAdded, totalRemoved int, branchName string) *CommitMessage { if len(a.changes) == 0 { return nil } @@ -141,6 +142,25 @@ func (a *Analyzer) AnalyzeChanges(totalAdded, totalRemoved int) *CommitMessage { } } + // NEW: Extract intent from branch name + if branchName != "" { + branchAction, branchScope := a.parseBranchName(branchName) + if branchAction != "" { + commitMessage.Action = branchAction + } + if branchScope != "" { + commitMessage.Scope = branchScope + } + } + + // NEW: Learning from recent commit history (Commit History Consistency) + if historyScope := a.analyzeHistoryScopes(); historyScope != "" { + // Only override if scope is empty or "core" + if commitMessage.Scope == "" || commitMessage.Scope == "core" { + commitMessage.Scope = historyScope + } + } + // Use commit history context to suggest consistent topics if commitMessage.Topic == "" || commitMessage.Topic == "core" { // Try to get topic from recent commit history @@ -1151,3 +1171,91 @@ func (a *Analyzer) analyzeDiffStat(totalAdded, totalRemoved int) string { return "" } + +// parseBranchName extracts type and scope from branch name +func (a *Analyzer) parseBranchName(branch string) (string, string) { + // Patterns like feature/auth-login or bugfix/fix-memleak + // Format: /- or / + parts := strings.Split(branch, "/") + if len(parts) < 2 { + return "", "" + } + + branchType := strings.ToLower(parts[0]) + description := parts[1] + + action := "" + switch branchType { + case "feature", "feat": + action = "feat" + case "bugfix", "fix": + action = "fix" + case "hotfix": + action = "fix" + case "refactor": + action = "refactor" + case "chore": + action = "chore" + case "docs": + action = "docs" + case "style": + action = "style" + case "perf": + action = "perf" + case "test": + action = "test" + case "ci": + action = "ci" + case "build": + action = "build" + } + + scope := "" + // Try to extract scope from description: scope-description or scope_description + descParts := regexp.MustCompile(`[-_]`).Split(description, 2) + if len(descParts) > 1 { + scope = descParts[0] + } else if len(description) > 0 { + // If it's just feature/auth, then auth is the scope + scope = description + } + + return action, scope +} + +// analyzeHistoryScopes analyzes the last 5 commits for common scopes +func (a *Analyzer) analyzeHistoryScopes() string { + commits, err := history.GetRecentCommits(5) + if err != nil || len(commits) == 0 { + return "" + } + return a.calculateHistoryScope(commits) +} + +// calculateHistoryScope calculates the most frequent scope from a list of commit messages +func (a *Analyzer) calculateHistoryScope(commits []string) string { + scopeCounts := make(map[string]int) + re := regexp.MustCompile(`^[a-z]+\(([^)]+)\):`) + + for _, msg := range commits { + matches := re.FindStringSubmatch(msg) + if len(matches) > 1 { + scope := matches[1] + scopeCounts[scope]++ + } + } + + totalCommits := len(commits) + if totalCommits == 0 { + return "" + } + + for scope, count := range scopeCounts { + // If a single scope appears in more than 50% of the commits + if float64(count)/float64(totalCommits) > 0.5 { + return scope + } + } + + return "" +} diff --git a/internal/analyzer/analyzer_test.go b/internal/analyzer/analyzer_test.go new file mode 100644 index 0000000..34afeee --- /dev/null +++ b/internal/analyzer/analyzer_test.go @@ -0,0 +1,101 @@ +package analyzer + +import ( + "testing" +) + +func TestParseBranchName(t *testing.T) { + a := &Analyzer{} + + tests := []struct { + branch string + wantType string + wantScope string + }{ + {"feature/auth-login", "feat", "auth"}, + {"feat/ui-button", "feat", "ui"}, + {"bugfix/fix-memleak", "fix", "fix"}, + {"fix/typo", "fix", "typo"}, + {"hotfix/urgent-patch", "fix", "urgent"}, + {"refactor/api-cleanup", "refactor", "api"}, + {"chore/deps-update", "chore", "deps"}, + {"docs/readme-update", "docs", "readme"}, + {"feature/login", "feat", "login"}, + {"random-branch", "", ""}, + } + + for _, tt := range tests { + gotType, gotScope := a.parseBranchName(tt.branch) + if gotType != tt.wantType { + t.Errorf("parseBranchName(%q) gotType = %q, want %q", tt.branch, gotType, tt.wantType) + } + if gotScope != tt.wantScope { + t.Errorf("parseBranchName(%q) gotScope = %q, want %q", tt.branch, gotScope, tt.wantScope) + } + } +} + +func TestCalculateHistoryScope(t *testing.T) { + a := &Analyzer{} + + tests := []struct { + name string + commits []string + want string + }{ + { + "More than 50% frequency", + []string{ + "feat(auth): login", + "fix(auth): redirect", + "feat(auth): signup", + "chore: update deps", + "docs: update readme", + }, + "auth", + }, + { + "Exactly 50% frequency", + []string{ + "feat(auth): login", + "fix(auth): redirect", + "feat(ui): button", + "chore: update deps", + }, + "", // 2/4 is not > 50% + }, + { + "No scopes", + []string{ + "feat: login", + "fix: redirect", + }, + "", + }, + { + "Empty list", + []string{}, + "", + }, + { + "Different scopes", + []string{ + "feat(auth): login", + "feat(ui): button", + "feat(db): query", + "feat(api): endpoint", + "feat(docs): page", + }, + "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := a.calculateHistoryScope(tt.commits) + if got != tt.want { + t.Errorf("calculateHistoryScope() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/internal/parser/git.go b/internal/parser/git.go index 6940f0c..02dc96f 100644 --- a/internal/parser/git.go +++ b/internal/parser/git.go @@ -138,6 +138,18 @@ func (p *GitParser) ParseStagedChanges() ([]*Change, error) { return changes, nil } +// GetCurrentBranch returns the name of the current git branch +func (p *GitParser) GetCurrentBranch() (string, error) { + cmd := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD") + var out bytes.Buffer + cmd.Stdout = &out + err := cmd.Run() + if err != nil { + return "", fmt.Errorf("error getting current branch: %w", err) + } + return strings.TrimSpace(out.String()), nil +} + // getFileExtension returns the file extension of a given file path func getFileExtension(filename string) string { return strings.TrimPrefix(filepath.Ext(filename), ".")