diff --git a/Makefile b/Makefile index 088be01..9d0f10b 100644 --- a/Makefile +++ b/Makefile @@ -75,10 +75,12 @@ help: @echo " check - Run tests and lint" @echo " install - Build and install to Go bin directory" @echo " calibrate-providers - Compare local Claude/Codex session usage for calibration" - @echo " install-hooks - Install git pre-commit hook" + @echo " install-hooks - Install git pre-commit and commit-msg hooks" @echo " help - Show this help" -# Install git pre-commit hook +# Install git pre-commit and commit-msg hooks 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..c1a6089 --- /dev/null +++ b/cmd/nightshift/commands/commit.go @@ -0,0 +1,101 @@ +package commands + +import ( + "errors" + "fmt" + "io" + "os" + "strings" + + "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: nothing is printed and the +exit code is non-zero when the message is invalid or not already in canonical +form.`, + Args: cobra.MaximumNArgs(1), + SilenceUsage: true, + 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 { + return err + } + + if check { + if !isCanonical(raw, normalized) { + return errors.New("message is not in canonical Conventional Commits form (run without --check to print the normalized message)") + } + return nil + } + if _, err := fmt.Fprintln(os.Stdout, normalized); err != nil { + return 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 (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 +} + +// isCanonical reports whether raw is already in normalized form, ignoring a +// single trailing newline. +func isCanonical(raw, normalized string) bool { + return strings.TrimSuffix(raw, "\n") == normalized +} diff --git a/cmd/nightshift/commands/commit_test.go b/cmd/nightshift/commands/commit_test.go new file mode 100644 index 0000000..28cfaf0 --- /dev/null +++ b/cmd/nightshift/commands/commit_test.go @@ -0,0 +1,61 @@ +package commands + +import ( + "os" + "path/filepath" + "testing" +) + +func TestReadCommitMessage(t *testing.T) { + t.Run("positional argument wins", func(t *testing.T) { + got, err := readCommitMessage([]string{"feat: add thing"}, "ignored-file") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "feat: add thing" { + t.Errorf("got %q, want %q", got, "feat: add thing") + } + }) + + t.Run("reads from file", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "msg") + if err := os.WriteFile(path, []byte("fix: correct thing\n"), 0o644); err != nil { + t.Fatal(err) + } + got, err := readCommitMessage(nil, path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "fix: correct thing\n" { + t.Errorf("got %q, want %q", got, "fix: correct thing\n") + } + }) + + t.Run("missing file errors", func(t *testing.T) { + if _, err := readCommitMessage(nil, filepath.Join(t.TempDir(), "nope")); err == nil { + t.Error("expected an error for a missing file") + } + }) +} + +func TestIsCanonical(t *testing.T) { + tests := []struct { + name string + raw string + normalized string + want bool + }{ + {"exact match", "feat: add login", "feat: add login", true}, + {"trailing newline ignored", "feat: add login\n", "feat: add login", true}, + {"needs rewriting", "FEAT: add login", "feat: add login", false}, + {"trailing blank lines", "feat: add login\n\n", "feat: add login", false}, + {"extra whitespace", " feat: add login ", "feat: add login", false}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := isCanonical(tc.raw, tc.normalized); got != tc.want { + t.Errorf("isCanonical(%q, %q) = %v, want %v", tc.raw, tc.normalized, got, tc.want) + } + }) + } +} diff --git a/cmd/nightshift/commands/config.go b/cmd/nightshift/commands/config.go index 4300470..ebb398f 100644 --- a/cmd/nightshift/commands/config.go +++ b/cmd/nightshift/commands/config.go @@ -370,7 +370,7 @@ func printStruct(v reflect.Value, indent int) { case reflect.Struct: fmt.Printf("%s%s:\n", prefix, tag) printStruct(value, indent+1) - case reflect.Ptr: + case reflect.Pointer: if !value.IsNil() { if value.Elem().Kind() == reflect.Struct { fmt.Printf("%s%s:\n", prefix, tag) @@ -408,7 +408,7 @@ func printStruct(v reflect.Value, indent int) { func isZero(v reflect.Value) bool { switch v.Kind() { - case reflect.Ptr, reflect.Interface: + case reflect.Pointer, reflect.Interface: return v.IsNil() case reflect.Slice, reflect.Map: return v.Len() == 0 diff --git a/docs/commit-messages.md b/docs/commit-messages.md new file mode 100644 index 0000000..29ddc15 --- /dev/null +++ b/docs/commit-messages.md @@ -0,0 +1,51 @@ +# 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): ...`. +- **breaking change** — append `!` after the type or scope, e.g. `feat(api)!:` — + a breaking change must also be called out in the body. +- **subject** — lowercase, imperative mood, no trailing period, max 72 chars. +- **body** — optional, wrapped at 72 columns, separated from the subject by a + blank line. Paragraph breaks are preserved and bullet-list items (`- ` / + `* `) stay on their own lines. + +## 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: nothing is printed, and the command exits +non-zero when a message cannot be normalized (missing/unknown type, capitalized +or overlong subject) or is not already in canonical form. + +## 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..451afa7 --- /dev/null +++ b/internal/commits/normalizer.go @@ -0,0 +1,293 @@ +// 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: +// +// ()!: +// +// +// +// where the scope and the "!" breaking-change marker are optional. 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, breaking, err := parseHeader(header) + if err != nil { + return "", err + } + + subject = cleanSubject(subject) + + var b strings.Builder + b.WriteString(formatHeader(typ, scope, subject, breaking)) + + 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. A +// trailing "!" on the type (after the optional scope) marks a breaking change +// per Conventional Commits 1.0.0 and is preserved. +func parseHeader(header string) (typ, scope, subject string, breaking bool, err error) { + header = strings.TrimSpace(header) + colon := strings.Index(header, ":") + if colon <= 0 { + return "", "", "", false, 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 "", "", "", false, ErrMissingType + } + breaking = strings.HasSuffix(prefix, "!") + if breaking { + prefix = strings.TrimSuffix(prefix, "!") + } + 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.ToLower(strings.TrimSpace(scope)) + + if typ == "" { + return "", "", "", false, ErrMissingType + } + if !isAllowedType(typ) { + return "", "", "", false, fmt.Errorf("%w: %q", ErrUnknownType, typ) + } + if strings.TrimSpace(subject) == "" { + return "", "", "", false, ErrMissingSubject + } + if utf8.RuneCountInString(subject) > MaxSubjectLength { + return "", "", "", false, ErrSubjectTooLong + } + if startsUpper(subject) { + return "", "", "", false, ErrSubjectLowercase + } + return typ, scope, subject, breaking, 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, +// keeping a "!" breaking-change marker after the type or scope. +func formatHeader(typ, scope, subject string, breaking bool) string { + bang := "" + if breaking { + bang = "!" + } + if scope != "" { + return typ + "(" + scope + ")" + bang + ": " + subject + } + return typ + bang + ": " + subject +} + +// wrapBody collapses runs of blank lines, preserves paragraph breaks, keeps +// bullet-list items on their own lines, and hard-wraps each paragraph to width. +// Paragraph and list blocks are separated by a single blank line; consecutive +// bullet items stay on adjacent lines so lists remain lists instead of being +// merged into prose. +func wrapBody(body []string, width int) string { + var blocks []string + var para []string + var list []string + + flushPara := func() { + if len(para) > 0 { + blocks = append(blocks, wrapParagraph(strings.Join(para, " "), width)) + para = nil + } + } + flushList := func() { + if len(list) > 0 { + blocks = append(blocks, strings.Join(list, "\n")) + list = nil + } + } + + for _, l := range body { + l = strings.TrimSpace(l) + if l == "" { + flushPara() + flushList() + continue + } + if isBulletItem(l) { + flushPara() + list = append(list, wrapParagraph(l, width)) + continue + } + if len(list) > 0 { + // Continuation of the preceding bullet item (e.g. the second + // line of a wrapped item) — keep it inside the list block so + // normalization stays idempotent. + list = append(list, wrapParagraph(l, width)) + continue + } + para = append(para, l) + } + flushPara() + flushList() + + return strings.Join(blocks, "\n\n") +} + +// isBulletItem reports whether a body line is a bullet-list item, i.e. it +// starts with "- " or "* ". +func isBulletItem(l string) bool { + return strings.HasPrefix(l, "- ") || strings.HasPrefix(l, "* ") +} + +// 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..7b022f4 --- /dev/null +++ b/internal/commits/normalizer_test.go @@ -0,0 +1,155 @@ +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: "strips git comment lines", + in: "chore: tidy\n# please enter the commit message\n\nbody here", + want: "chore: tidy\n\nbody here", + }, + { + name: "breaking-change marker without scope preserved", + in: "feat!: drop the legacy config loader", + want: "feat!: drop the legacy config loader", + }, + { + name: "breaking-change marker with scope preserved", + in: "FEAT(API)!: drop the legacy config loader.", + want: "feat(api)!: drop the legacy config loader", + }, + { + name: "bullet-list body items stay on their own lines", + in: "feat: add export\n\nadds csv export:\n\n- item one\n- item two with a description that is long enough to need wrapping onto a second line by the wrapper\n* item three\n\ntrailing paragraph", + want: "feat: add export\n\n" + + "adds csv export:\n\n" + + "- item one\n" + + "- item two with a description that is long enough to need wrapping onto\n" + + "a second line by the wrapper\n" + + "* item three\n\n" + + "trailing paragraph", + }, + { + 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", + "feat(api)!: rework the public API\n\nrationale:\n\n- first reason\n- second reason that is long enough to require wrapping when the normalizer processes this list item", + } + 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..f61a5a2 --- /dev/null +++ b/scripts/commit-msg.sh @@ -0,0 +1,57 @@ +#!/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 +# chmod +x scripts/commit-msg.sh +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. Kept as an array so the multi-word +# "go run ..." fallback expands correctly. +NIGHTSHIFT="$(command -v nightshift || true)" +if [[ -n "$NIGHTSHIFT" ]]; then + NIGHTSHIFT_CMD=("$NIGHTSHIFT") +else + NIGHTSHIFT_CMD=(go run github.com/marcus/nightshift/cmd/nightshift) +fi + +ERR_FILE="$(mktemp "${TMPDIR:-/tmp}/nightshift-commit-msg.XXXXXX")" +trap 'rm -f "$ERR_FILE"' EXIT + +# Disable errexit around the assignment: its exit status is the command +# substitution's status, and under `set -e` a failing assignment would abort +# the script before the error guidance below could run. +set +e +NORMALIZED="$("${NIGHTSHIFT_CMD[@]}" commit normalize --file "$MSG_FILE" 2>"$ERR_FILE")" +STATUS=$? +set -e + +if [[ $STATUS -ne 0 ]]; then + echo "🪡 commit-msg: message does not follow Conventional Commits" >&2 + sed 's/^/ /' "$ERR_FILE" >&2 || true + echo "" >&2 + echo " Expected format: ()!: (scope and ! optional)" >&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 + +# Rewrite the message file into canonical form. +printf '%s\n' "$NORMALIZED" > "$MSG_FILE" +echo "🪡 commit-msg: normalized" +exit 0