feat: implement git trailer validation

Amolith and Crush created

Add comprehensive trailer validation following git's trailer
specification. Each trailer is validated for proper key:value format
with no whitespace allowed before or inside the key. Multiline values
are supported using RFC 822 folding with continuation lines
requiring whitespace indentation. Trailers are now properly assembled
into a block at the end of commit messages separated by blank lines.

Implements: bug-896472f
Co-authored-by: Crush <crush@charm.land>

Change summary

main.go     | 11 ++++----
trailers.go | 68 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 74 insertions(+), 5 deletions(-)

Detailed changes

main.go 🔗

@@ -67,11 +67,12 @@ formatted-commit -t refactor -s "web/git-bug" -m "fancy shmancy" \
 		}
 
 		if len(trailers) > 0 {
-			commitMsg.WriteString("\n\n")
-			for _, trailer := range trailers {
-				commitMsg.WriteString(trailer)
-				commitMsg.WriteString("\n")
+			trailersBlock, err := buildTrailersBlock(trailers)
+			if err != nil {
+				return fmt.Errorf("failed to build trailers: %w", err)
 			}
+			commitMsg.WriteString("\n\n")
+			commitMsg.WriteString(trailersBlock)
 		}
 
 		fmt.Print(commitMsg.String())
@@ -82,7 +83,7 @@ formatted-commit -t refactor -s "web/git-bug" -m "fancy shmancy" \
 func init() {
 	rootCmd.Flags().StringVarP(&commitType, "type", "t", "", "commit type (required)")
 	rootCmd.Flags().StringVarP(&message, "message", "m", "", "commit message (required)")
-	rootCmd.Flags().StringSliceVarP(&trailers, "trailer", "T", []string{}, "trailer in 'Sentence-case-key: value' format (optional, repeatable)")
+	rootCmd.Flags().StringArrayVarP(&trailers, "trailer", "T", []string{}, "trailer in 'Sentence-case-key: value' format (optional, repeatable)")
 	rootCmd.Flags().StringVarP(&body, "body", "b", "", "commit body (optional)")
 	rootCmd.Flags().StringVarP(&scope, "scope", "s", "", "commit scope (optional)")
 	rootCmd.Flags().BoolVarP(&breakingChange, "breaking", "B", false, "mark as breaking change (optional)")

trailers.go 🔗

@@ -0,0 +1,68 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package main
+
+import (
+	"fmt"
+	"strings"
+	"unicode"
+)
+
+func validateTrailer(trailer string) error {
+	if trailer == "" {
+		return fmt.Errorf("trailer cannot be empty")
+	}
+
+	lines := strings.Split(trailer, "\n")
+	firstLine := lines[0]
+
+	if len(firstLine) > 0 && unicode.IsSpace(rune(firstLine[0])) {
+		return fmt.Errorf("trailer key cannot start with whitespace: %q", trailer)
+	}
+
+	colonIdx := strings.Index(firstLine, ":")
+	if colonIdx == -1 {
+		return fmt.Errorf("trailer must contain ':' separator: %q", trailer)
+	}
+
+	key := firstLine[:colonIdx]
+
+	if strings.TrimSpace(key) != key {
+		return fmt.Errorf("trailer key cannot have leading or trailing whitespace: %q", key)
+	}
+
+	if strings.ContainsAny(key, " \t") {
+		return fmt.Errorf("trailer key cannot contain whitespace: %q", key)
+	}
+
+	if key == "" {
+		return fmt.Errorf("trailer key cannot be empty: %q", trailer)
+	}
+
+	for i, line := range lines[1:] {
+		if line == "" {
+			continue
+		}
+		if len(line) > 0 && !unicode.IsSpace(rune(line[0])) {
+			return fmt.Errorf("trailer continuation line %d must start with whitespace: %q", i+2, line)
+		}
+	}
+
+	return nil
+}
+
+func buildTrailersBlock(trailers []string) (string, error) {
+	if len(trailers) == 0 {
+		return "", nil
+	}
+
+	for _, trailer := range trailers {
+		if err := validateTrailer(trailer); err != nil {
+			return "", err
+		}
+	}
+
+	return strings.Join(trailers, "\n"), nil
+}