From cc33a6a33a1e34fdf31594fd41b7c4cb0ec61915 Mon Sep 17 00:00:00 2001 From: Amolith Date: Tue, 21 Oct 2025 18:45:53 -0600 Subject: [PATCH] feat: implement git trailer validation 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 --- main.go | 11 +++++---- trailers.go | 68 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 5 deletions(-) create mode 100644 trailers.go diff --git a/main.go b/main.go index da9ab7cedfaea036da8c486e0ee88879f51eef29..00e67aa8ec3773fe5ece3008153bfe69a8c891bb 100644 --- a/main.go +++ b/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)") diff --git a/trailers.go b/trailers.go new file mode 100644 index 0000000000000000000000000000000000000000..82b6b7299866e223d3a43fa3dca4334dbd2dabde --- /dev/null +++ b/trailers.go @@ -0,0 +1,68 @@ +// SPDX-FileCopyrightText: Amolith +// +// 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 +}