Skip to content
Open
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
88 changes: 88 additions & 0 deletions cmd/nightshift/commands/commit.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package commands

import (
"fmt"
"io"
"os"

"github.com/marcus/nightshift/internal/commits"
"github.com/spf13/cobra"
)

var commitCmd = &cobra.Command{
Use: "commit",
Short: "Conventional Commits helpers",
Long: `Tools for working with Conventional Commits messages.

Use "commit normalize" to validate and reformat a commit message so it
follows the project's rules (type prefix, lowercase type, subject length,
and wrapped body).`,
}

var commitNormalizeCmd = &cobra.Command{
Use: "normalize [MESSAGE]",
Short: "Normalize a commit message to Conventional Commits format",
Long: `Validate and rewrite a commit message into canonical Conventional
Commits form.

The message is read from a positional argument, from a file passed via
--file (typically .git/COMMIT_EDITMSG by a commit-msg hook), or from stdin
when no argument and no --file are given.

nightshift commit normalize "feat: add login"
nightshift commit normalize --file .git/COMMIT_EDITMSG
git log -1 --pretty=%B | nightshift commit normalize

Use --check to only validate without rewriting; the exit code is non-zero
when the message does not conform.`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
check, _ := cmd.Flags().GetBool("check")
file, _ := cmd.Flags().GetString("file")

raw, err := readCommitMessage(args, file)
if err != nil {
return err
}

normalized, err := commits.Normalize(raw)
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
return err
}

if check {
fmt.Fprintln(os.Stdout, normalized)
return nil
}
fmt.Fprintln(os.Stdout, normalized)
return nil
},
}

func init() {
commitNormalizeCmd.Flags().BoolP("check", "c", false, "Only validate; do not rewrite")
commitNormalizeCmd.Flags().StringP("file", "f", "", "Read the message from this file (use by the commit-msg hook)")
commitCmd.AddCommand(commitNormalizeCmd)
rootCmd.AddCommand(commitCmd)
}

// readCommitMessage resolves the message source in order: positional arg,
// --file, then stdin.
func readCommitMessage(args []string, file string) (string, error) {
if len(args) == 1 {
return args[0], nil
}
if file != "" {
b, err := os.ReadFile(file)
if err != nil {
return "", fmt.Errorf("read %s: %w", file, err)
}
return string(b), nil
}
b, err := io.ReadAll(os.Stdin)
if err != nil {
return "", fmt.Errorf("read stdin: %w", err)
}
return string(b), nil
}
47 changes: 47 additions & 0 deletions docs/commit-messages.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# Commit Messages

Nightshift uses [Conventional Commits](https://www.conventionalcommits.org/)
for all commit messages. This keeps the history readable and lets tooling
derive changelogs automatically.

## Format

```
<type>(<scope>): <subject>

<body>
```

- **type** — one of `feat`, `fix`, `docs`, `style`, `refactor`, `test`,
`chore`, `perf`, `build`, `ci`.
- **scope** — optional, e.g. `fix(api): ...`.
- **subject** — lowercase, imperative mood, no trailing period, max 72 chars.
- **body** — optional, wrapped at 72 columns, separated from the subject by a
blank line.

## The `commit normalize` command

Validate and reformat a message:

```sh
nightshift commit normalize "feat: add login screen"
nightshift commit normalize --file .git/COMMIT_EDITMSG
git log -1 --pretty=%B | nightshift commit normalize
```

Add `--check` to validate only. The command exits non-zero when a message
cannot be normalized (missing/unknown type, capitalized or overlong subject).

## commit-msg hook

To enforce the rules locally, install the hook:

```sh
make install-hooks
# or manually:
ln -sf ../../scripts/commit-msg.sh .git/hooks/commit-msg
```

The hook normalizes your message file in place before the commit is created and
rejects messages that cannot be fixed automatically. Bypass it with
`git commit --no-verify`.
255 changes: 255 additions & 0 deletions internal/commits/normalizer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,255 @@
// Package commits implements Conventional Commits message normalization and
// validation. It exposes pure, well-tested functions used by the CLI and by the
// commit-msg git hook to keep the project's history consistent.
//
// The supported format follows the Conventional Commits 1.0.0 specification:
//
// <type>(<scope>): <subject>
//
// <body>
//
// The normalizer is intentionally strict but constructive: rather than silently
// accepting malformed input it fixes the trivially fixable (whitespace, type
// casing, trailing punctuation, body wrapping) and rejects anything that needs
// a human decision (missing type, unknown type, missing subject).
package commits

import (
"errors"
"fmt"
"strings"
"unicode/utf8"
)

// MaxSubjectLength is the maximum number of runes allowed in a commit subject.
const MaxSubjectLength = 72

// BodyWrapWidth is the column at which the commit body is wrapped.
const BodyWrapWidth = 72

// allowedTypes is the set of Conventional Commit types this project accepts.
var allowedTypes = map[string]struct{}{
"feat": {},
"fix": {},
"docs": {},
"style": {},
"refactor": {},
"test": {},
"chore": {},
"perf": {},
"build": {},
"ci": {},
}

// Errors returned by the normalizer. They are wrapped so callers can match on
// the underlying cause with errors.Is.
var (
// ErrEmptyMessage is returned when the message contains no non-comment,
// non-whitespace content.
ErrEmptyMessage = errors.New("commit message is empty")
// ErrMissingType is returned when the subject line is not a Conventional
// Commit (no type prefix before the colon).
ErrMissingType = errors.New("commit message must start with a conventional commit type")
// ErrUnknownType is returned when the type prefix is not in the allowed set.
ErrUnknownType = errors.New("commit type is not in the allowed set")
// ErrMissingSubject is returned when the type prefix is present but no
// subject text follows the colon.
ErrMissingSubject = errors.New("commit subject is missing")
// ErrSubjectTooLong is returned when the subject exceeds MaxSubjectLength.
ErrSubjectTooLong = fmt.Errorf("commit subject exceeds %d characters", MaxSubjectLength)
// ErrSubjectLowercase is returned when the subject starts with an uppercase
// letter (the rule is "do not capitalize the subject").
ErrSubjectLowercase = errors.New("commit subject must not be capitalized")
)

// Normalize parses, validates, and rewrites a raw commit message so that it
// conforms to the project's Conventional Commits rules. It returns the
// canonical form and a non-nil error describing the first unrecoverable
// problem when the message cannot be normalized.
//
// Normalization is idempotent: Normalize(Normalize(m)) == Normalize(m).
func Normalize(msg string) (string, error) {
lines := stripComments(msg)
if len(lines) == 0 {
return "", ErrEmptyMessage
}

header := lines[0]
body := lines[1:]

typ, scope, subject, err := parseHeader(header)
if err != nil {
return "", err
}

subject = cleanSubject(subject)

var b strings.Builder
b.WriteString(formatHeader(typ, scope, subject))

wrapped := wrapBody(body, BodyWrapWidth)
if wrapped != "" {
b.WriteString("\n\n")
b.WriteString(wrapped)
}

return b.String(), nil
}

// stripComments removes git's commented-out lines (those beginning with "#"),
// trims trailing whitespace from every line, and drops leading/trailing blank
// lines. It returns the meaningful lines of the message.
func stripComments(msg string) []string {
rawLines := strings.Split(msg, "\n")
out := make([]string, 0, len(rawLines))
for _, l := range rawLines {
l = strings.TrimRight(l, " \t\r")
if strings.HasPrefix(strings.TrimSpace(l), "#") {
continue
}
out = append(out, l)
}
// Drop leading and trailing blank lines.
for len(out) > 0 && strings.TrimSpace(out[0]) == "" {
out = out[1:]
}
for len(out) > 0 && strings.TrimSpace(out[len(out)-1]) == "" {
out = out[:len(out)-1]
}
return out
}

// parseHeader splits the first line into its Conventional Commit components and
// validates them. The returned type is lower-cased to match the allowed set.
func parseHeader(header string) (typ, scope, subject string, err error) {
header = strings.TrimSpace(header)
colon := strings.Index(header, ":")
if colon <= 0 {
return "", "", "", ErrMissingType
}
prefix := header[:colon]
subject = strings.TrimSpace(header[colon+1:])

// Split an optional "(scope)" from the type.
prefix = strings.TrimSpace(prefix)
if strings.HasPrefix(prefix, "(") {
// A leading "(" with no type is not a valid conventional header.
return "", "", "", ErrMissingType
}
if open := strings.Index(prefix, "("); open > 0 && strings.HasSuffix(prefix, ")") {
typ = prefix[:open]
scope = prefix[open+1 : len(prefix)-1]
} else {
typ = prefix
}
typ = strings.ToLower(strings.TrimSpace(typ))
scope = strings.TrimSpace(scope)

if typ == "" {
return "", "", "", ErrMissingType
}
if !isAllowedType(typ) {
return "", "", "", fmt.Errorf("%w: %q", ErrUnknownType, typ)
}
if strings.TrimSpace(subject) == "" {
return "", "", "", ErrMissingSubject
}
if utf8.RuneCountInString(subject) > MaxSubjectLength {
return "", "", "", ErrSubjectTooLong
}
if startsUpper(subject) {
return "", "", "", ErrSubjectLowercase
}
return typ, scope, subject, nil
}

// cleanSubject normalizes the subject text: lowercases a leading uppercase
// letter is *not* done here (capitalization is a hard error, not a fix), but
// surrounding whitespace and a trailing period are removed.
func cleanSubject(subject string) string {
s := strings.TrimSpace(subject)
s = strings.TrimRight(s, ".")
return s
}

// formatHeader reassembles a canonical header line from its components.
func formatHeader(typ, scope, subject string) string {
if scope != "" {
return typ + "(" + scope + "): " + subject
}
return typ + ": " + subject
}

// wrapBody collapses runs of blank lines, preserves non-blank paragraphs, and
// hard-wraps each paragraph line to width. Paragraph breaks (a single blank
// line) are preserved.
func wrapBody(body []string, width int) string {
var paragraphs [][]string
var cur []string
for _, l := range body {
if strings.TrimSpace(l) == "" {
if len(cur) > 0 {
paragraphs = append(paragraphs, cur)
cur = nil
}
continue
}
cur = append(cur, strings.TrimSpace(l))
}
if len(cur) > 0 {
paragraphs = append(paragraphs, cur)
}

var b strings.Builder
for i, p := range paragraphs {
if i > 0 {
b.WriteString("\n\n")
}
b.WriteString(wrapParagraph(strings.Join(p, " "), width))
}
return b.String()
}

// wrapParagraph hard-wraps a single-line paragraph at width, breaking on word
// boundaries. A word longer than width is left intact rather than split.
func wrapParagraph(text string, width int) string {
words := strings.Fields(text)
if len(words) == 0 {
return ""
}
var b strings.Builder
lineLen := 0
for i, w := range words {
if i == 0 {
b.WriteString(w)
lineLen = len(w)
continue
}
if lineLen+1+len(w) <= width {
b.WriteByte(' ')
b.WriteString(w)
lineLen += 1 + len(w)
} else {
b.WriteByte('\n')
b.WriteString(w)
lineLen = len(w)
}
}
return b.String()
}

// isAllowedType reports whether typ is one of the accepted Conventional Commit
// types.
func isAllowedType(typ string) bool {
_, ok := allowedTypes[typ]
return ok
}

// startsUpper reports whether the first rune of s is an ASCII uppercase letter.
func startsUpper(s string) bool {
if s == "" {
return false
}
r, _ := utf8.DecodeRuneInString(s)
return r >= 'A' && r <= 'Z'
}
Loading