From 1723edc68538915b36aa8a2e2cec3c635a3ad65b Mon Sep 17 00:00:00 2001 From: Lasse Larsen Date: Sat, 5 Sep 2026 02:05:51 +0200 Subject: [PATCH 1/2] feat(commits): add Conventional Commits message normalizer Add internal/commits with pure Normalize/validate functions enforcing the project's Conventional Commits rules (known type set, lowercase type and subject, 72-char subject limit, whitespace trimming, and 72-column body wrapping). Wire it into the CLI as 'nightshift commit normalize' (positional, --file, and stdin sources; --check to validate only; --file rewrites the message in place), ship a commit-msg git hook under scripts/ with make install-hooks support, and document the format and installation in docs/commit-messages.md. Nightshift-Task: commit-normalize Nightshift-Ref: https://github.com/marcus/nightshift --- Makefile | 4 +- cmd/nightshift/commands/commit.go | 94 ++++++++++ docs/commit-messages.md | 49 ++++++ internal/commits/normalizer.go | 255 ++++++++++++++++++++++++++++ internal/commits/normalizer_test.go | 138 +++++++++++++++ scripts/commit-msg.sh | 42 +++++ 6 files changed, 581 insertions(+), 1 deletion(-) create mode 100644 cmd/nightshift/commands/commit.go create mode 100644 docs/commit-messages.md create mode 100644 internal/commits/normalizer.go create mode 100644 internal/commits/normalizer_test.go create mode 100755 scripts/commit-msg.sh diff --git a/Makefile b/Makefile index 088be01..da36fd9 100644 --- a/Makefile +++ b/Makefile @@ -78,7 +78,9 @@ help: @echo " install-hooks - Install git pre-commit hook" @echo " help - Show this help" -# Install git pre-commit hook +# Install git hooks (pre-commit and commit-msg) install-hooks: @ln -sf ../../scripts/pre-commit.sh .git/hooks/pre-commit @echo "✓ pre-commit hook installed (.git/hooks/pre-commit → scripts/pre-commit.sh)" + @ln -sf ../../scripts/commit-msg.sh .git/hooks/commit-msg + @echo "✓ commit-msg hook installed (.git/hooks/commit-msg → scripts/commit-msg.sh)" diff --git a/cmd/nightshift/commands/commit.go b/cmd/nightshift/commands/commit.go new file mode 100644 index 0000000..a266c79 --- /dev/null +++ b/cmd/nightshift/commands/commit.go @@ -0,0 +1,94 @@ +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 + +The normalized message is printed to stdout and, when --file was given, +written back to that file. 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 { + return nil + } + + fmt.Fprintln(cmd.OutOrStdout(), normalized) + if file != "" { + if err := os.WriteFile(file, []byte(normalized+"\n"), 0o644); err != nil { + return fmt.Errorf("write %s: %w", file, err) + } + } + 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 and rewrite it in place (used 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 +} diff --git a/docs/commit-messages.md b/docs/commit-messages.md new file mode 100644 index 0000000..0d6a472 --- /dev/null +++ b/docs/commit-messages.md @@ -0,0 +1,49 @@ +# 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** — 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 +``` + +The normalized message is printed to stdout and, when `--file` was given, +written back to that file. 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`. diff --git a/internal/commits/normalizer.go b/internal/commits/normalizer.go new file mode 100644 index 0000000..3ac01d2 --- /dev/null +++ b/internal/commits/normalizer.go @@ -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: +// +// (): +// +// +// +// 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: capitalization is *not* touched +// here (an uppercase lead 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' +} diff --git a/internal/commits/normalizer_test.go b/internal/commits/normalizer_test.go new file mode 100644 index 0000000..657ec80 --- /dev/null +++ b/internal/commits/normalizer_test.go @@ -0,0 +1,138 @@ +package commits + +import ( + "errors" + "strings" + "testing" +) + +func TestNormalize(t *testing.T) { + tests := []struct { + name string + in string + want string + wantErr error + }{ + { + name: "valid simple feat", + in: "feat: add login screen", + want: "feat: add login screen", + }, + { + name: "valid with scope", + in: "fix(api): handle nil response", + want: "fix(api): handle nil response", + }, + { + name: "trims surrounding whitespace and trailing period", + in: " docs: update README. ", + want: "docs: update README", + }, + { + name: "lowercases an uppercased type", + in: "FEAT(ui): render button", + want: "feat(ui): render button", + }, + { + name: "preserves body and wraps long lines", + in: "feat: add thing\n\nthis is a body paragraph that is intentionally far longer than the configured wrap width so it must be hard wrapped onto multiple lines by the normalizer function", + want: "feat: add thing\n\n" + + "this is a body paragraph that is intentionally far longer than the\n" + + "configured wrap width so it must be hard wrapped onto multiple lines by\n" + + "the normalizer function", + }, + { + name: "keeps multi-paragraph bodies separate", + in: "docs: explain flags\n\nfirst paragraph\n\nsecond paragraph stays separate", + want: "docs: explain flags\n\nfirst paragraph\n\nsecond paragraph stays separate", + }, + { + name: "strips git comment lines", + in: "chore: tidy\n# please enter the commit message\n\nbody here", + want: "chore: tidy\n\nbody here", + }, + { + name: "missing type rejected", + in: "just a plain message", + wantErr: ErrMissingType, + }, + { + name: "unknown type rejected", + in: "wip: halfway done", + wantErr: ErrUnknownType, + }, + { + name: "missing subject rejected", + in: "feat:", + wantErr: ErrMissingSubject, + }, + { + name: "capitalized subject rejected", + in: "feat: Add login screen", + wantErr: ErrSubjectLowercase, + }, + { + name: "overlong subject rejected", + in: "feat: " + strings.Repeat("a", MaxSubjectLength+1), + wantErr: ErrSubjectTooLong, + }, + { + name: "empty message rejected", + in: "\n\n# only comments\n \n", + wantErr: ErrEmptyMessage, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := Normalize(tc.in) + if tc.wantErr != nil { + if err == nil { + t.Fatalf("Normalize(%q): expected error %v, got nil (result %q)", tc.in, tc.wantErr, got) + } + if !errors.Is(err, tc.wantErr) { + t.Fatalf("Normalize(%q): expected error to wrap %v, got %v", tc.in, tc.wantErr, err) + } + return + } + if err != nil { + t.Fatalf("Normalize(%q): unexpected error: %v", tc.in, err) + } + if got != tc.want { + t.Errorf("Normalize(%q):\n got: %q\nwant: %q", tc.in, got, tc.want) + } + }) + } +} + +func TestNormalizeIdempotent(t *testing.T) { + cases := []string{ + "feat: add login screen", + "fix(api): handle nil response\n\nLong body that explains the fix in more detail than the subject alone can manage so that we exercise the wrapping path too and then some more words here.", + "docs: update README\n\nfirst paragraph\n\nsecond paragraph stays separate", + } + for _, in := range cases { + once, err := Normalize(in) + if err != nil { + t.Fatalf("first Normalize(%q) errored: %v", in, err) + } + twice, err := Normalize(once) + if err != nil { + t.Fatalf("second Normalize(%q) errored: %v", once, err) + } + if once != twice { + t.Errorf("not idempotent for %q\n once: %q\n twice: %q", in, once, twice) + } + } +} + +func TestAllowedTypes(t *testing.T) { + for _, typ := range []string{"feat", "fix", "docs", "style", "refactor", "test", "chore", "perf", "build", "ci"} { + if !isAllowedType(typ) { + t.Errorf("expected %q to be an allowed type", typ) + } + } + if isAllowedType("wip") { + t.Error("did not expect wip to be allowed") + } +} diff --git a/scripts/commit-msg.sh b/scripts/commit-msg.sh new file mode 100755 index 0000000..4739531 --- /dev/null +++ b/scripts/commit-msg.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# commit-msg hook for nightshift +# +# Enforces Conventional Commits on every commit message and rewrites the +# message file into canonical form before the commit is created. Messages that +# cannot be normalized (missing/unknown type, capitalized or overlong subject) +# are rejected with a non-zero exit so the commit is aborted. +# +# Install: +# make install-hooks +# # or manually: +# ln -sf ../../scripts/commit-msg.sh .git/hooks/commit-msg +set -euo pipefail + +if [[ $# -lt 1 ]]; then + echo "usage: commit-msg " >&2 + exit 1 +fi + +MSG_FILE="$1" + +# Resolve the nightshift binary: prefer the one on $PATH, fall back to +# building the current source tree. +run_nightshift() { + if command -v nightshift >/dev/null 2>&1; then + nightshift "$@" + else + go run github.com/marcus/nightshift/cmd/nightshift "$@" + fi +} + +if ! run_nightshift commit normalize --file "$MSG_FILE" >/dev/null; then + echo "🪡 commit-msg: message does not follow Conventional Commits" >&2 + echo "" >&2 + echo " Expected format: (): " >&2 + echo " Types: feat fix docs style refactor test chore perf build ci" >&2 + echo " (rewrite your message, or bypass with: git commit --no-verify)" >&2 + exit 1 +fi + +echo "🪡 commit-msg: normalized" +exit 0 From 4d407e0ecc1755d6724788e7fb1193df99e1d811 Mon Sep 17 00:00:00 2001 From: Lasse Larsen Date: Sat, 5 Sep 2026 02:21:11 +0200 Subject: [PATCH 2/2] fix(commits): preserve trailer blocks and structured body lines wrapBody used to join every run of consecutive non-blank body lines into a single paragraph before wrapping, which merged multi-line git trailers (Signed-off-by, Co-authored-by) onto one line and destroyed intentional line structure such as lists and indented code blocks. The final paragraph is now detected as a git trailer block and emitted verbatim, and structured lines (list items, quotes, indented or fenced code) keep their own lines while prose around them is still joined and wrapped. Nightshift-Task: commit-normalize Nightshift-Ref: https://github.com/marcus/nightshift --- docs/commit-messages.md | 9 +++ internal/commits/normalizer.go | 117 ++++++++++++++++++++++++++-- internal/commits/normalizer_test.go | 69 ++++++++++++++++ 3 files changed, 190 insertions(+), 5 deletions(-) diff --git a/docs/commit-messages.md b/docs/commit-messages.md index 0d6a472..6055439 100644 --- a/docs/commit-messages.md +++ b/docs/commit-messages.md @@ -19,6 +19,15 @@ derive changelogs automatically. - **body** — optional, wrapped at 72 columns, separated from the subject by a blank line. +Prose paragraphs in the body are joined and hard-wrapped at 72 columns, but +intentional line structure is preserved: + +- a trailing git trailer block (`Signed-off-by:`, `Co-authored-by:`, + `Nightshift-Task:`, …) keeps each trailer on its own line and is never + wrapped, even when a trailer value exceeds 72 columns; +- list items (`-`, `*`, `+`, `1.`), block quotes, and indented or fenced code + blocks keep their original line breaks. + ## The `commit normalize` command Validate and reformat a message: diff --git a/internal/commits/normalizer.go b/internal/commits/normalizer.go index 3ac01d2..195766a 100644 --- a/internal/commits/normalizer.go +++ b/internal/commits/normalizer.go @@ -17,6 +17,7 @@ package commits import ( "errors" "fmt" + "regexp" "strings" "unicode/utf8" ) @@ -180,9 +181,18 @@ func formatHeader(typ, scope, subject string) string { 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. +// trailerLineRe matches a single git trailer line such as +// "Signed-off-by: Jane " or "Refs: #42". +var trailerLineRe = regexp.MustCompile(`^[A-Za-z0-9-]+:(?: .*)?$`) + +// orderedListRe matches an ordered list item such as "1. " or "42) ". +var orderedListRe = regexp.MustCompile(`^\d+[.)] `) + +// wrapBody collapses runs of blank lines, preserves paragraph breaks, and +// hard-wraps prose paragraphs to width. Intentional line structure is left +// verbatim: a trailing git trailer block (Signed-off-by, Co-authored-by, ...) +// is never joined or wrapped, and structured lines inside a paragraph (list +// items, block quotes, indented or fenced code) each keep their own line. func wrapBody(body []string, width int) string { var paragraphs [][]string var cur []string @@ -194,7 +204,7 @@ func wrapBody(body []string, width int) string { } continue } - cur = append(cur, strings.TrimSpace(l)) + cur = append(cur, l) } if len(cur) > 0 { paragraphs = append(paragraphs, cur) @@ -205,11 +215,108 @@ func wrapBody(body []string, width int) string { if i > 0 { b.WriteString("\n\n") } - b.WriteString(wrapParagraph(strings.Join(p, " "), width)) + if i == len(paragraphs)-1 && isTrailerBlock(p) { + writeVerbatim(&b, p) + continue + } + writeParagraph(&b, p, width) } return b.String() } +// isTrailerBlock reports whether the paragraph is a git trailer block: at +// least one line is a "Token: value" trailer and every other line is either a +// trailer or an indented continuation of the preceding one. +func isTrailerBlock(p []string) bool { + trailers := 0 + for _, l := range p { + trimmed := strings.TrimSpace(l) + if trailerLineRe.MatchString(trimmed) { + trailers++ + continue + } + // A line with leading whitespace continues the previous trailer; + // anything else means this paragraph is prose. + if l == trimmed { + return false + } + } + return trailers > 0 +} + +// writeVerbatim emits lines unchanged (bar trailing whitespace) without +// joining or wrapping. It is used for trailer blocks. +func writeVerbatim(b *strings.Builder, lines []string) { + for i, l := range lines { + if i > 0 { + b.WriteByte('\n') + } + b.WriteString(strings.TrimRight(l, " \t")) + } +} + +// writeParagraph emits a paragraph, joining and wrapping runs of consecutive +// prose lines while keeping structured lines verbatim on their own line. +func writeParagraph(b *strings.Builder, p []string, width int) { + var prose []string + wrote := false + inFence := false + newline := func() { + if wrote { + b.WriteByte('\n') + } + } + flush := func() { + if len(prose) == 0 { + return + } + newline() + b.WriteString(wrapParagraph(strings.Join(prose, " "), width)) + wrote = true + prose = nil + } + for _, l := range p { + trimmed := strings.TrimSpace(l) + if inFence { + newline() + b.WriteString(strings.TrimRight(l, " \t")) + wrote = true + if strings.HasPrefix(trimmed, "```") { + inFence = false + } + continue + } + if strings.HasPrefix(trimmed, "```") || isStructuredLine(l, trimmed) { + flush() + newline() + b.WriteString(strings.TrimRight(l, " \t")) + wrote = true + inFence = strings.HasPrefix(trimmed, "```") + continue + } + prose = append(prose, trimmed) + } + flush() +} + +// isStructuredLine reports whether a body line carries deliberate formatting — +// a list item, a block quote, or an indented (code or continuation) line — and +// must therefore not be merged into the surrounding prose. +func isStructuredLine(raw, trimmed string) bool { + if raw != trimmed { + return true + } + for _, marker := range []string{"- ", "* ", "+ "} { + if strings.HasPrefix(trimmed, marker) { + return true + } + } + if orderedListRe.MatchString(trimmed) { + return true + } + return strings.HasPrefix(trimmed, ">") +} + // 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 { diff --git a/internal/commits/normalizer_test.go b/internal/commits/normalizer_test.go index 657ec80..f48fa35 100644 --- a/internal/commits/normalizer_test.go +++ b/internal/commits/normalizer_test.go @@ -76,6 +76,74 @@ func TestNormalize(t *testing.T) { in: "feat: " + strings.Repeat("a", MaxSubjectLength+1), wantErr: ErrSubjectTooLong, }, + { + name: "preserves git trailers on separate lines", + in: "fix(core): stop crash on empty input\n\n" + + "the parser assumed at least one record and panicked otherwise.\n\n" + + "Signed-off-by: Jane Dev \n" + + "Co-authored-by: Some One ", + want: "fix(core): stop crash on empty input\n\n" + + "the parser assumed at least one record and panicked otherwise.\n\n" + + "Signed-off-by: Jane Dev \n" + + "Co-authored-by: Some One ", + }, + { + name: "does not wrap overlong trailer values", + in: "chore: sync deps\n\n" + + "bump everything to latest.\n\n" + + "Co-authored-by: A Very Long Display Name That Clearly Exceeds Seventy-Two Columns ", + want: "chore: sync deps\n\n" + + "bump everything to latest.\n\n" + + "Co-authored-by: A Very Long Display Name That Clearly Exceeds Seventy-Two Columns ", + }, + { + name: "keeps trailer continuation lines indented", + in: "feat: add export\n\n" + + "exports data.\n\n" + + "Refs: #42\n additional context on multiple lines\n continues here", + want: "feat: add export\n\n" + + "exports data.\n\n" + + "Refs: #42\n additional context on multiple lines\n continues here", + }, + { + name: "keeps list items on their own lines", + in: "feat: add flags\n\n" + + "two new flags were added:\n" + + "- --verbose prints more detail\n" + + "- --quiet prints less detail and this item is long enough that joining would wrap it differently than kept as a list line", + want: "feat: add flags\n\n" + + "two new flags were added:\n" + + "- --verbose prints more detail\n" + + "- --quiet prints less detail and this item is long enough that joining would wrap it differently than kept as a list line", + }, + { + name: "keeps indented code block verbatim", + in: "fix(parser): handle tabs\n\n" + + "before:\n" + + " if err != nil {\n" + + " return err\n" + + " }\n" + + "after, the check happens earlier so the branch above is unreachable now", + want: "fix(parser): handle tabs\n\n" + + "before:\n" + + " if err != nil {\n" + + " return err\n" + + " }\n" + + "after, the check happens earlier so the branch above is unreachable now", + }, + { + name: "wraps prose on both sides of a list without merging them", + in: "docs: describe flags\n\n" + + "this is a long prose introduction that would normally be wrapped because it goes past the wrap width by a fair margin indeed\n" + + "- a list item\n" + + "this is a long prose closing that would also be wrapped because it goes past the wrap width by a fair margin", + want: "docs: describe flags\n\n" + + "this is a long prose introduction that would normally be wrapped because\n" + + "it goes past the wrap width by a fair margin indeed\n" + + "- a list item\n" + + "this is a long prose closing that would also be wrapped because it goes\n" + + "past the wrap width by a fair margin", + }, { name: "empty message rejected", in: "\n\n# only comments\n \n", @@ -110,6 +178,7 @@ func TestNormalizeIdempotent(t *testing.T) { "feat: add login screen", "fix(api): handle nil response\n\nLong body that explains the fix in more detail than the subject alone can manage so that we exercise the wrapping path too and then some more words here.", "docs: update README\n\nfirst paragraph\n\nsecond paragraph stays separate", + "fix(core): stop crash\n\nexplanation text.\n\nSigned-off-by: Jane Dev \nCo-authored-by: Some One ", } for _, in := range cases { once, err := Normalize(in)