Skip to content

Commit e905c08

Browse files
committed
feat(analyzer): extract intent from branch name
and recent history
1 parent 532957b commit e905c08

4 files changed

Lines changed: 224 additions & 2 deletions

File tree

cmd/propose.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,8 @@ func runPropose(cmd *cobra.Command, args []string) error {
7979
}
8080

8181
analyzer := analyzer.NewAnalyzer(changes, cfg)
82-
commitMessage := analyzer.AnalyzeChanges(gitParser.TotalAdded, gitParser.TotalRemoved)
82+
branchName, _ := gitParser.GetCurrentBranch()
83+
commitMessage := analyzer.AnalyzeChanges(gitParser.TotalAdded, gitParser.TotalRemoved, branchName)
8384
if commitMessage == nil {
8485
return fmt.Errorf("could not analyze changes")
8586
}

internal/analyzer/analyzer.go

Lines changed: 109 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package analyzer
33
import (
44
"bufio"
55
"path/filepath"
6+
"regexp"
67
"strings"
78

89
"gitmit/internal/config"
@@ -44,7 +45,7 @@ func NewAnalyzer(changes []*parser.Change, cfg *config.Config) *Analyzer {
4445
}
4546

4647
// AnalyzeChanges analyzes the git changes and returns a CommitMessage
47-
func (a *Analyzer) AnalyzeChanges(totalAdded, totalRemoved int) *CommitMessage {
48+
func (a *Analyzer) AnalyzeChanges(totalAdded, totalRemoved int, branchName string) *CommitMessage {
4849
if len(a.changes) == 0 {
4950
return nil
5051
}
@@ -141,6 +142,25 @@ func (a *Analyzer) AnalyzeChanges(totalAdded, totalRemoved int) *CommitMessage {
141142
}
142143
}
143144

145+
// NEW: Extract intent from branch name
146+
if branchName != "" {
147+
branchAction, branchScope := a.parseBranchName(branchName)
148+
if branchAction != "" {
149+
commitMessage.Action = branchAction
150+
}
151+
if branchScope != "" {
152+
commitMessage.Scope = branchScope
153+
}
154+
}
155+
156+
// NEW: Learning from recent commit history (Commit History Consistency)
157+
if historyScope := a.analyzeHistoryScopes(); historyScope != "" {
158+
// Only override if scope is empty or "core"
159+
if commitMessage.Scope == "" || commitMessage.Scope == "core" {
160+
commitMessage.Scope = historyScope
161+
}
162+
}
163+
144164
// Use commit history context to suggest consistent topics
145165
if commitMessage.Topic == "" || commitMessage.Topic == "core" {
146166
// Try to get topic from recent commit history
@@ -1151,3 +1171,91 @@ func (a *Analyzer) analyzeDiffStat(totalAdded, totalRemoved int) string {
11511171

11521172
return ""
11531173
}
1174+
1175+
// parseBranchName extracts type and scope from branch name
1176+
func (a *Analyzer) parseBranchName(branch string) (string, string) {
1177+
// Patterns like feature/auth-login or bugfix/fix-memleak
1178+
// Format: <type>/<scope>-<description> or <type>/<description>
1179+
parts := strings.Split(branch, "/")
1180+
if len(parts) < 2 {
1181+
return "", ""
1182+
}
1183+
1184+
branchType := strings.ToLower(parts[0])
1185+
description := parts[1]
1186+
1187+
action := ""
1188+
switch branchType {
1189+
case "feature", "feat":
1190+
action = "feat"
1191+
case "bugfix", "fix":
1192+
action = "fix"
1193+
case "hotfix":
1194+
action = "fix"
1195+
case "refactor":
1196+
action = "refactor"
1197+
case "chore":
1198+
action = "chore"
1199+
case "docs":
1200+
action = "docs"
1201+
case "style":
1202+
action = "style"
1203+
case "perf":
1204+
action = "perf"
1205+
case "test":
1206+
action = "test"
1207+
case "ci":
1208+
action = "ci"
1209+
case "build":
1210+
action = "build"
1211+
}
1212+
1213+
scope := ""
1214+
// Try to extract scope from description: scope-description or scope_description
1215+
descParts := regexp.MustCompile(`[-_]`).Split(description, 2)
1216+
if len(descParts) > 1 {
1217+
scope = descParts[0]
1218+
} else if len(description) > 0 {
1219+
// If it's just feature/auth, then auth is the scope
1220+
scope = description
1221+
}
1222+
1223+
return action, scope
1224+
}
1225+
1226+
// analyzeHistoryScopes analyzes the last 5 commits for common scopes
1227+
func (a *Analyzer) analyzeHistoryScopes() string {
1228+
commits, err := history.GetRecentCommits(5)
1229+
if err != nil || len(commits) == 0 {
1230+
return ""
1231+
}
1232+
return a.calculateHistoryScope(commits)
1233+
}
1234+
1235+
// calculateHistoryScope calculates the most frequent scope from a list of commit messages
1236+
func (a *Analyzer) calculateHistoryScope(commits []string) string {
1237+
scopeCounts := make(map[string]int)
1238+
re := regexp.MustCompile(`^[a-z]+\(([^)]+)\):`)
1239+
1240+
for _, msg := range commits {
1241+
matches := re.FindStringSubmatch(msg)
1242+
if len(matches) > 1 {
1243+
scope := matches[1]
1244+
scopeCounts[scope]++
1245+
}
1246+
}
1247+
1248+
totalCommits := len(commits)
1249+
if totalCommits == 0 {
1250+
return ""
1251+
}
1252+
1253+
for scope, count := range scopeCounts {
1254+
// If a single scope appears in more than 50% of the commits
1255+
if float64(count)/float64(totalCommits) > 0.5 {
1256+
return scope
1257+
}
1258+
}
1259+
1260+
return ""
1261+
}

internal/analyzer/analyzer_test.go

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
package analyzer
2+
3+
import (
4+
"testing"
5+
)
6+
7+
func TestParseBranchName(t *testing.T) {
8+
a := &Analyzer{}
9+
10+
tests := []struct {
11+
branch string
12+
wantType string
13+
wantScope string
14+
}{
15+
{"feature/auth-login", "feat", "auth"},
16+
{"feat/ui-button", "feat", "ui"},
17+
{"bugfix/fix-memleak", "fix", "fix"},
18+
{"fix/typo", "fix", "typo"},
19+
{"hotfix/urgent-patch", "fix", "urgent"},
20+
{"refactor/api-cleanup", "refactor", "api"},
21+
{"chore/deps-update", "chore", "deps"},
22+
{"docs/readme-update", "docs", "readme"},
23+
{"feature/login", "feat", "login"},
24+
{"random-branch", "", ""},
25+
}
26+
27+
for _, tt := range tests {
28+
gotType, gotScope := a.parseBranchName(tt.branch)
29+
if gotType != tt.wantType {
30+
t.Errorf("parseBranchName(%q) gotType = %q, want %q", tt.branch, gotType, tt.wantType)
31+
}
32+
if gotScope != tt.wantScope {
33+
t.Errorf("parseBranchName(%q) gotScope = %q, want %q", tt.branch, gotScope, tt.wantScope)
34+
}
35+
}
36+
}
37+
38+
func TestCalculateHistoryScope(t *testing.T) {
39+
a := &Analyzer{}
40+
41+
tests := []struct {
42+
name string
43+
commits []string
44+
want string
45+
}{
46+
{
47+
"More than 50% frequency",
48+
[]string{
49+
"feat(auth): login",
50+
"fix(auth): redirect",
51+
"feat(auth): signup",
52+
"chore: update deps",
53+
"docs: update readme",
54+
},
55+
"auth",
56+
},
57+
{
58+
"Exactly 50% frequency",
59+
[]string{
60+
"feat(auth): login",
61+
"fix(auth): redirect",
62+
"feat(ui): button",
63+
"chore: update deps",
64+
},
65+
"", // 2/4 is not > 50%
66+
},
67+
{
68+
"No scopes",
69+
[]string{
70+
"feat: login",
71+
"fix: redirect",
72+
},
73+
"",
74+
},
75+
{
76+
"Empty list",
77+
[]string{},
78+
"",
79+
},
80+
{
81+
"Different scopes",
82+
[]string{
83+
"feat(auth): login",
84+
"feat(ui): button",
85+
"feat(db): query",
86+
"feat(api): endpoint",
87+
"feat(docs): page",
88+
},
89+
"",
90+
},
91+
}
92+
93+
for _, tt := range tests {
94+
t.Run(tt.name, func(t *testing.T) {
95+
got := a.calculateHistoryScope(tt.commits)
96+
if got != tt.want {
97+
t.Errorf("calculateHistoryScope() = %q, want %q", got, tt.want)
98+
}
99+
})
100+
}
101+
}

internal/parser/git.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,18 @@ func (p *GitParser) ParseStagedChanges() ([]*Change, error) {
138138
return changes, nil
139139
}
140140

141+
// GetCurrentBranch returns the name of the current git branch
142+
func (p *GitParser) GetCurrentBranch() (string, error) {
143+
cmd := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD")
144+
var out bytes.Buffer
145+
cmd.Stdout = &out
146+
err := cmd.Run()
147+
if err != nil {
148+
return "", fmt.Errorf("error getting current branch: %w", err)
149+
}
150+
return strings.TrimSpace(out.String()), nil
151+
}
152+
141153
// getFileExtension returns the file extension of a given file path
142154
func getFileExtension(filename string) string {
143155
return strings.TrimPrefix(filepath.Ext(filename), ".")

0 commit comments

Comments
 (0)