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
3 changes: 2 additions & 1 deletion cmd/propose.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
110 changes: 109 additions & 1 deletion internal/analyzer/analyzer.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package analyzer
import (
"bufio"
"path/filepath"
"regexp"
"strings"

"gitmit/internal/config"
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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: <type>/<scope>-<description> or <type>/<description>
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 ""
}
101 changes: 101 additions & 0 deletions internal/analyzer/analyzer_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
12 changes: 12 additions & 0 deletions internal/parser/git.go
Original file line number Diff line number Diff line change
Expand Up @@ -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), ".")
Expand Down
Loading