Skip to content

Commit 749d195

Browse files
marcusclaude
andcommitted
feat: add commit message normalizer
Standardize commit messages before they reach git: trim whitespace, capitalize subject, remove trailing periods, truncate to 72 chars, and enforce blank line between subject and body. Nightshift-Task: commit-normalize Nightshift-Ref: https://github.com/marcus/nightshift Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 79c46a8 commit 749d195

3 files changed

Lines changed: 133 additions & 1 deletion

File tree

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
package gitstatus
2+
3+
import (
4+
"strings"
5+
"unicode"
6+
"unicode/utf8"
7+
)
8+
9+
const maxSubjectLen = 72
10+
11+
// NormalizeCommitMessage standardizes a commit message:
12+
// - trims surrounding whitespace
13+
// - capitalizes the first letter of the subject line
14+
// - removes a trailing period from the subject
15+
// - truncates the subject to 72 characters (with ellipsis)
16+
// - ensures a blank line between subject and body
17+
func NormalizeCommitMessage(msg string) string {
18+
msg = strings.TrimSpace(msg)
19+
if msg == "" {
20+
return ""
21+
}
22+
23+
// Split into subject and optional body.
24+
// A body is separated by at least one blank line.
25+
subject, body := splitSubjectBody(msg)
26+
27+
subject = strings.TrimSpace(subject)
28+
if subject == "" {
29+
return ""
30+
}
31+
32+
// Capitalize the first letter
33+
subject = capitalizeFirst(subject)
34+
35+
// Remove trailing period
36+
subject = strings.TrimRight(subject, ".")
37+
38+
// Truncate long subjects
39+
if len(subject) > maxSubjectLen {
40+
subject = subject[:maxSubjectLen-1] + "…"
41+
}
42+
43+
if body == "" {
44+
return subject
45+
}
46+
return subject + "\n\n" + body
47+
}
48+
49+
// splitSubjectBody splits at the first blank line. If there is no blank line
50+
// but the message is multi-line, the first line is the subject and the rest
51+
// is the body (with an enforced blank-line separator).
52+
func splitSubjectBody(msg string) (subject, body string) {
53+
// Look for a blank-line separator
54+
if idx := strings.Index(msg, "\n\n"); idx >= 0 {
55+
return msg[:idx], strings.TrimSpace(msg[idx+2:])
56+
}
57+
58+
// Single line
59+
if !strings.Contains(msg, "\n") {
60+
return msg, ""
61+
}
62+
63+
// Multi-line without blank separator – split on first newline
64+
parts := strings.SplitN(msg, "\n", 2)
65+
return parts[0], strings.TrimSpace(parts[1])
66+
}
67+
68+
func capitalizeFirst(s string) string {
69+
r, size := utf8.DecodeRuneInString(s)
70+
if r == utf8.RuneError || size == 0 {
71+
return s
72+
}
73+
return string(unicode.ToUpper(r)) + s[size:]
74+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package gitstatus
2+
3+
import (
4+
"strings"
5+
"testing"
6+
)
7+
8+
func TestNormalizeCommitMessage(t *testing.T) {
9+
tests := []struct {
10+
name string
11+
in string
12+
want string
13+
}{
14+
{name: "empty", in: "", want: ""},
15+
{name: "whitespace only", in: " \n\t ", want: ""},
16+
{name: "trim whitespace", in: " fix bug ", want: "Fix bug"},
17+
{name: "capitalize first letter", in: "fix bug", want: "Fix bug"},
18+
{name: "already capitalized", in: "Fix bug", want: "Fix bug"},
19+
{name: "remove trailing period", in: "Fix bug.", want: "Fix bug"},
20+
{name: "remove multiple trailing periods", in: "Fix bug...", want: "Fix bug"},
21+
{name: "keep interior periods", in: "Fix v1.2.3 bug.", want: "Fix v1.2.3 bug"},
22+
{name: "short subject unchanged", in: "Add feature", want: "Add feature"},
23+
{
24+
name: "truncate long subject",
25+
in: strings.Repeat("a", 80),
26+
want: "A" + strings.Repeat("a", 70) + "…",
27+
},
28+
{
29+
name: "subject with body separated by blank line",
30+
in: "fix bug\n\nMore details here.",
31+
want: "Fix bug\n\nMore details here.",
32+
},
33+
{
34+
name: "enforce blank line between subject and body",
35+
in: "fix bug\nMore details here.",
36+
want: "Fix bug\n\nMore details here.",
37+
},
38+
{
39+
name: "body preserves multiple lines",
40+
in: "fix bug\n\nLine one.\nLine two.",
41+
want: "Fix bug\n\nLine one.\nLine two.",
42+
},
43+
{
44+
name: "single char",
45+
in: "x",
46+
want: "X",
47+
},
48+
}
49+
50+
for _, tt := range tests {
51+
t.Run(tt.name, func(t *testing.T) {
52+
got := NormalizeCommitMessage(tt.in)
53+
if got != tt.want {
54+
t.Errorf("NormalizeCommitMessage(%q)\n got %q\n want %q", tt.in, got, tt.want)
55+
}
56+
})
57+
}
58+
}

internal/plugins/gitstatus/update_handlers.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -813,7 +813,7 @@ func (p *Plugin) updateCommit(msg tea.KeyMsg) (plugin.Plugin, tea.Cmd) {
813813

814814
// tryCommit attempts to execute the commit (or amend) if message is valid.
815815
func (p *Plugin) tryCommit() tea.Cmd {
816-
message := strings.TrimSpace(p.commitMessage.Value())
816+
message := NormalizeCommitMessage(p.commitMessage.Value())
817817
if message == "" {
818818
p.commitError = "Commit message cannot be empty"
819819
return nil

0 commit comments

Comments
 (0)