Skip to content

Commit 4edcdcb

Browse files
committed
feat(formatter): implement line-length constraints
and enhanced AI context - Add maxSubjectLength and maxBodyLength configuration options. - Update Formatter to support automatic line wrapping and subject-to-body overflow with blank line separation. - Enrich AI prompt with summarized git diff content for better commit body generation. - Refine wrapping logic to preserve multi-paragraph structures. - Add unit tests for the new formatting and wrapping behavior.
1 parent cc443a1 commit 4edcdcb

9 files changed

Lines changed: 232 additions & 36 deletions

File tree

assets/prompts/system_prompt.txt

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
1-
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.
1+
You are an expert developer assistant. Analyze the provided structured git diff metadata and generate a commit message following the Conventional Commits specification.
22

33
Guidelines:
44
1. Format MUST be: <type>(<scope>): <short description in present tense>
55
2. Allowed types: feat, fix, refactor, chore, test, docs, style, perf, ci, build, security
6-
3. Do NOT include any markdown, backticks, quotes, or introductory text like "Here is your commit message:".
7-
4. Output ONLY the raw string of the commit message.
6+
3. If the changes are complex, you MAY include a body separated by a blank line after the subject.
7+
4. Keep the subject line short (aim for ~50 characters).
8+
5. Wrap body lines at ~72 characters.
9+
6. Do NOT include any markdown, backticks, quotes, or introductory text like "Here is your commit message:".
10+
7. Output ONLY the raw string of the commit message.
811

912
Metadata Context:
1013
- Project Type: {{.ProjectType}}
@@ -15,4 +18,7 @@ Metadata Context:
1518
- Dependency Changes: {{.DependencyAlert}}
1619
- Added/Deleted Line Ratio: {{printf "%.2f" .DiffSummary.Ratio}}
1720

21+
Summarized Git Diff:
22+
{{.DiffContent}}
23+
1824
Output:

cmd/propose.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ func runPropose(cmd *cobra.Command, args []string) error {
9292
return err
9393
}
9494

95-
f := formatter.NewFormatter()
95+
f := formatter.NewFormatter(cfg.MaxSubjectLength, cfg.MaxBodyLength)
9696

9797
// Calculate Heuristic Suggestion (Always available)
9898
heuristicMsg, err := templater.GetMessage(commitMessage)
@@ -112,7 +112,7 @@ func runPropose(cmd *cobra.Command, args []string) error {
112112
client := ai.NewOllamaClient(cfg.Ollama)
113113
aiResponse, err := client.Generate(prompt)
114114
if err == nil && ai.IsValidCommitMessage(aiResponse) {
115-
aiMsg = strings.TrimSpace(aiResponse)
115+
aiMsg = f.FormatMessage(strings.TrimSpace(aiResponse), commitMessage.IsMajor)
116116
usingAI = true
117117
finalMessage = aiMsg
118118
}
@@ -220,7 +220,7 @@ func runPropose(cmd *cobra.Command, args []string) error {
220220
editedMessage = strings.TrimSpace(editedMessage)
221221

222222
if editedMessage != "" {
223-
finalMessage = editedMessage
223+
finalMessage = f.FormatMessage(editedMessage, commitMessage.IsMajor)
224224
usedSuggestions[finalMessage] = true
225225
color.Green("\n✓ Updated commit message:")
226226
} else {
@@ -240,7 +240,7 @@ func runPropose(cmd *cobra.Command, args []string) error {
240240
client := ai.NewOllamaClient(cfg.Ollama)
241241
aiResponse, err := client.Generate(prompt)
242242
if err == nil && ai.IsValidCommitMessage(aiResponse) {
243-
finalMessage = strings.TrimSpace(aiResponse)
243+
finalMessage = f.FormatMessage(strings.TrimSpace(aiResponse), commitMessage.IsMajor)
244244
regenerationCount++
245245
}
246246
}
@@ -264,7 +264,7 @@ func runPropose(cmd *cobra.Command, args []string) error {
264264
client := ai.NewOllamaClient(cfg.Ollama)
265265
aiResponse, err := client.Generate(prompt)
266266
if err == nil && ai.IsValidCommitMessage(aiResponse) {
267-
aiMsg = strings.TrimSpace(aiResponse)
267+
aiMsg = f.FormatMessage(strings.TrimSpace(aiResponse), commitMessage.IsMajor)
268268
finalMessage = aiMsg
269269
usingAI = true
270270
} else {

docs/CONFIGURATION.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,24 @@ Defines the confidence weights for different signal sources. Only used when `nor
133133
}
134134
```
135135

136+
### Message Length Constraints
137+
138+
**`maxSubjectLength`** (int, default: 50)
139+
140+
Specifies the maximum character length for the first line (subject) of the commit message. If the generated or edited subject exceeds this limit, it will be automatically wrapped to the next line.
141+
142+
**`maxBodyLength`** (int, default: 72)
143+
144+
Specifies the maximum character length for each line in the body of the commit message. If the body text exceeds this limit, it will be wrapped at word boundaries.
145+
146+
**Example:**
147+
```json
148+
{
149+
"maxSubjectLength": 50,
150+
"maxBodyLength": 72
151+
}
152+
```
153+
136154
### Topic Mappings
137155

138156
**`topicMappings`** (object)

internal/ai/ai_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ func TestIsValidCommitMessage(t *testing.T) {
4444
expected bool
4545
}{
4646
{"feat(auth): add login functionality", true},
47+
{"feat(auth): add login\n\nThis is a body.", true},
4748
{"fix: resolve memory leak", true},
4849
{"chore(deps): update dependencies", true},
4950
{"Invalid message", false},

internal/ai/prompt.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ type PromptContext struct {
1919
CodeSymbols []string
2020
DependencyAlert string
2121
DiffSummary DiffSummary
22+
DiffContent string
2223
}
2324

2425
// DiffSummary contains ratio of changes
@@ -70,6 +71,7 @@ func RenderPrompt(msg *analyzer.CommitMessage, projectType, branchName string) (
7071
DiffSummary: DiffSummary{
7172
Ratio: ratio,
7273
},
74+
DiffContent: msg.FullDiff,
7375
}
7476

7577
var buf bytes.Buffer

internal/analyzer/analyzer.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package analyzer
22

33
import (
44
"bufio"
5+
"fmt"
56
"path/filepath"
67
"regexp"
78
"strings"
@@ -32,6 +33,7 @@ type CommitMessage struct {
3233
DetectedStructs []string
3334
DetectedMethods []string
3435
ChangePatterns []string
36+
FullDiff string
3537
}
3638

3739
// Analyzer is responsible for analyzing git changes and generating commit message components
@@ -105,6 +107,15 @@ func (a *Analyzer) AnalyzeChanges(totalAdded, totalRemoved int, branchName strin
105107
commitMessage.DetectedMethods = uniqueStrings(allMethods)
106108
commitMessage.ChangePatterns = uniqueStrings(allPatterns)
107109

110+
// Collect summarized diff for AI
111+
var diffSummary strings.Builder
112+
for _, change := range a.changes {
113+
diffSummary.WriteString(fmt.Sprintf("File: %s\n", change.File))
114+
diffSummary.WriteString(a.summarizeDiff(change.Diff))
115+
diffSummary.WriteString("\n")
116+
}
117+
commitMessage.FullDiff = diffSummary.String()
118+
108119
// Determine if changes are only documentation, config, or dependencies
109120
commitMessage.IsDocsOnly = a.isDocsOnly()
110121
commitMessage.IsConfigOnly = a.isConfigOnly()
@@ -1273,3 +1284,29 @@ func (a *Analyzer) calculateHistoryScope(commits []string) string {
12731284

12741285
return ""
12751286
}
1287+
1288+
// summarizeDiff extracts the most relevant lines from a diff to keep it concise for AI
1289+
func (a *Analyzer) summarizeDiff(diff string) string {
1290+
var summary strings.Builder
1291+
scanner := bufio.NewScanner(strings.NewReader(diff))
1292+
lineCount := 0
1293+
maxLines := 20 // Limit lines per file to avoid context bloat
1294+
1295+
for scanner.Scan() {
1296+
line := scanner.Text()
1297+
// Only include added/removed lines and hunk headers
1298+
if strings.HasPrefix(line, "+") || strings.HasPrefix(line, "-") || strings.HasPrefix(line, "@@") {
1299+
if strings.HasPrefix(line, "+++") || strings.HasPrefix(line, "---") {
1300+
continue
1301+
}
1302+
summary.WriteString(line)
1303+
summary.WriteString("\n")
1304+
lineCount++
1305+
}
1306+
if lineCount >= maxLines {
1307+
summary.WriteString("... (truncated)\n")
1308+
break
1309+
}
1310+
}
1311+
return summary.String()
1312+
}

internal/config/config.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ type Config struct {
1919
DiffStatThreshold float64 `json:"diffStatThreshold"` // Threshold for add/delete ratio
2020
NormalizeScoring bool `json:"normalizeScoring"` // Whether to use normalized confidence weights
2121
SignalWeights map[string]float64 `json:"signalWeights"` // Weights for different signal sources
22+
MaxSubjectLength int `json:"maxSubjectLength"` // Max length for the first line
23+
MaxBodyLength int `json:"maxBodyLength"` // Max length for body lines
2224
}
2325

2426
// OllamaConfig represents the structure of the ollama configuration block
@@ -50,6 +52,8 @@ func LoadConfig() (*Config, error) {
5052
"keywords": 0.25,
5153
"patterns": 0.15,
5254
},
55+
MaxSubjectLength: 50,
56+
MaxBodyLength: 72,
5357
}
5458

5559
// 1. Try to load embedded default config (optional)
@@ -291,5 +295,13 @@ func mergeConfigFromFile(cfg *Config, path string) error {
291295
}
292296
}
293297

298+
// Message lengths
299+
if fileCfg.MaxSubjectLength > 0 {
300+
cfg.MaxSubjectLength = fileCfg.MaxSubjectLength
301+
}
302+
if fileCfg.MaxBodyLength > 0 {
303+
cfg.MaxBodyLength = fileCfg.MaxBodyLength
304+
}
305+
294306
return nil
295307
}

internal/formatter/formatter.go

Lines changed: 87 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -6,44 +6,103 @@ import (
66
)
77

88
// Formatter is responsible for applying final formatting to commit messages
9-
type Formatter struct{}
9+
type Formatter struct {
10+
MaxSubjectLength int
11+
MaxBodyLength int
12+
}
1013

1114
// NewFormatter creates a new Formatter
12-
func NewFormatter() *Formatter {
13-
return &Formatter{}
15+
func NewFormatter(maxSubject, maxBody int) *Formatter {
16+
return &Formatter{
17+
MaxSubjectLength: maxSubject,
18+
MaxBodyLength: maxBody,
19+
}
1420
}
1521

1622
// FormatMessage applies formatting rules to the commit message
1723
func (f *Formatter) FormatMessage(msg string, isMajor bool) string {
18-
// Capitalize the first letter
19-
/* if len(msg) > 0 {
20-
r := []rune(msg)
21-
r[0] = unicode.ToUpper(r[0])
22-
msg = string(r)
23-
} */
24-
25-
// Remove redundant phrases
26-
msg = strings.ReplaceAll(msg, "add add new", "add new")
27-
msg = strings.ReplaceAll(msg, "feat feat", "feat")
28-
msg = strings.ReplaceAll(msg, "fix fix", "fix")
29-
30-
// Enforce summary length (soft limit for now, try to break at word boundaries)
31-
if len(msg) > 72 {
32-
truncatedMsg := msg
33-
if len(truncatedMsg) > 72 {
34-
truncatedMsg = truncatedMsg[:72]
35-
lastSpace := strings.LastIndex(truncatedMsg, " ")
36-
if lastSpace != -1 {
37-
truncatedMsg = truncatedMsg[:lastSpace]
24+
if msg == "" {
25+
return ""
26+
}
27+
28+
// Split into subject and body
29+
parts := strings.SplitN(msg, "\n", 2)
30+
subject := strings.TrimSpace(parts[0])
31+
body := ""
32+
if len(parts) > 1 {
33+
body = strings.TrimSpace(parts[1])
34+
}
35+
36+
// Remove redundant phrases from subject
37+
subject = strings.ReplaceAll(subject, "add add new", "add new")
38+
subject = strings.ReplaceAll(subject, "feat feat", "feat")
39+
subject = strings.ReplaceAll(subject, "fix fix", "fix")
40+
41+
// Add optional suffixes to subject
42+
if isMajor {
43+
subject = fmt.Sprintf("%s (massive refactor)", subject)
44+
}
45+
46+
// Wrap subject if too long
47+
if f.MaxSubjectLength > 0 && len(subject) > f.MaxSubjectLength {
48+
wrapped := f.wrapString(subject, f.MaxSubjectLength)
49+
subjectParts := strings.SplitN(wrapped, "\n", 2)
50+
subject = subjectParts[0]
51+
if len(subjectParts) > 1 {
52+
if body != "" {
53+
body = subjectParts[1] + "\n\n" + body
54+
} else {
55+
body = subjectParts[1]
3856
}
39-
msg = fmt.Sprintf("%s...", truncatedMsg)
4057
}
4158
}
4259

43-
// Add optional suffixes
44-
if isMajor {
45-
msg = fmt.Sprintf("%s (massive refactor)", msg)
60+
// Wrap body if exists
61+
if body != "" && f.MaxBodyLength > 0 {
62+
body = f.wrapString(body, f.MaxBodyLength)
63+
}
64+
65+
if body != "" {
66+
return subject + "\n\n" + body
67+
}
68+
return subject
69+
}
70+
71+
// wrapString wraps a string at the specified limit, preserving paragraphs
72+
func (f *Formatter) wrapString(s string, limit int) string {
73+
if limit <= 0 {
74+
return s
75+
}
76+
77+
paragraphs := strings.Split(s, "\n\n")
78+
var result strings.Builder
79+
80+
for i, p := range paragraphs {
81+
if i > 0 {
82+
result.WriteString("\n\n")
83+
}
84+
85+
words := strings.Fields(p)
86+
if len(words) == 0 {
87+
continue
88+
}
89+
90+
currentLineLength := 0
91+
for j, word := range words {
92+
if j > 0 {
93+
if currentLineLength+1+len(word) > limit {
94+
result.WriteString("\n")
95+
currentLineLength = 0
96+
} else {
97+
result.WriteString(" ")
98+
currentLineLength++
99+
}
100+
}
101+
102+
result.WriteString(word)
103+
currentLineLength += len(word)
104+
}
46105
}
47106

48-
return msg
107+
return result.String()
49108
}

0 commit comments

Comments
 (0)