From 92e866d41d09668c86d9250f2e5d3a881f6093a9 Mon Sep 17 00:00:00 2001 From: Lasse Larsen Date: Sun, 28 Jun 2026 02:10:21 +0200 Subject: [PATCH 1/3] 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, lowercase 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), ship a commit-msg git hook under scripts/, and document the format and installation in docs/commit-messages.md. Nightshift-Task: commit-normalize Nightshift-Ref: https://github.com/marcus/nightshift --- cmd/nightshift/commands/commit.go | 88 ++++++++++ docs/commit-messages.md | 47 +++++ internal/commits/normalizer.go | 255 ++++++++++++++++++++++++++++ internal/commits/normalizer_test.go | 133 +++++++++++++++ scripts/commit-msg.sh | 46 +++++ 5 files changed, 569 insertions(+) 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/cmd/nightshift/commands/commit.go b/cmd/nightshift/commands/commit.go new file mode 100644 index 0000000..7e018fb --- /dev/null +++ b/cmd/nightshift/commands/commit.go @@ -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 +} diff --git a/docs/commit-messages.md b/docs/commit-messages.md new file mode 100644 index 0000000..ab6e009 --- /dev/null +++ b/docs/commit-messages.md @@ -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** — 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`. diff --git a/internal/commits/normalizer.go b/internal/commits/normalizer.go new file mode 100644 index 0000000..62526b3 --- /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: 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' +} diff --git a/internal/commits/normalizer_test.go b/internal/commits/normalizer_test.go new file mode 100644 index 0000000..2121633 --- /dev/null +++ b/internal/commits/normalizer_test.go @@ -0,0 +1,133 @@ +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: "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..3320a40 --- /dev/null +++ b/scripts/commit-msg.sh @@ -0,0 +1,46 @@ +#!/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. +NIGHTSHIFT="$(command -v nightshift || true)" +if [[ -z "$NIGHTSHIFT" ]]; then + NIGHTSHIFT="go run github.com/marcus/nightshift/cmd/nightshift" +fi + +NORMALIZED="$($NIGHTSHIFT commit normalize --file "$MSG_FILE" 2>/tmp/nightshift-commit-msg.err)" +STATUS=$? + +if [[ $STATUS -ne 0 ]]; then + echo "🪡 commit-msg: message does not follow Conventional Commits" >&2 + sed 's/^/ /' /tmp/nightshift-commit-msg.err >&2 || true + 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 + +# Rewrite the message file into canonical form. +printf '%s\n' "$NORMALIZED" > "$MSG_FILE" +echo "🪡 commit-msg: normalized" +exit 0 From 7be8185941bbee369342496e45f9e1838ef429c3 Mon Sep 17 00:00:00 2001 From: Lasse Larsen Date: Fri, 21 Aug 2026 02:07:00 +0200 Subject: [PATCH 2/3] feat(commits): add Conventional Commits message normalizer Add internal/commits with pure Normalize/Validate functions enforcing the project's Conventional Commits rules: a known type set (feat, fix, docs, style, refactor, test, chore, perf, build, ci, revert), lowercase type and subject, a 72-character subject limit, whitespace trimming, and 72-column body wrapping. Trivially fixable issues are rewritten automatically; missing or unknown types, missing subjects, and overlong subjects are rejected with actionable errors. Wire the package into the CLI as 'nightshift commit normalize' (positional, --file, and stdin sources; --check validates with a diff-style report and exits non-zero without modifying anything; --file rewrites the message file in place). Ship an installable scripts/commit-msg.sh hook, extend 'make install-hooks' to install it, and document the format, command, and hook in docs/commit-messages.md with a pointer from the README. Nightshift-Task: commit-normalize Nightshift-Ref: https://github.com/marcus/nightshift --- Makefile | 4 +- README.md | 12 +++++ cmd/nightshift/commands/commit.go | 80 ++++++++++++++++++++++++++--- docs/commit-messages.md | 13 +++-- internal/commits/normalizer.go | 55 ++++++++++++++------ internal/commits/normalizer_test.go | 74 +++++++++++++++++++++++--- scripts/commit-msg.sh | 16 +++--- 7 files changed, 211 insertions(+), 43 deletions(-) diff --git a/Makefile b/Makefile index 088be01..5dce048 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 + @ln -sf ../../scripts/commit-msg.sh .git/hooks/commit-msg @echo "✓ pre-commit hook installed (.git/hooks/pre-commit → scripts/pre-commit.sh)" + @echo "✓ commit-msg hook installed (.git/hooks/commit-msg → scripts/commit-msg.sh)" diff --git a/README.md b/README.md index 84f92cd..eae4a64 100644 --- a/README.md +++ b/README.md @@ -273,6 +273,18 @@ This symlinks `scripts/pre-commit.sh` into `.git/hooks/pre-commit`. The hook run To bypass in a pinch: `git commit --no-verify` +### Commit messages + +Commit messages follow the [Conventional Commits](docs/commit-messages.md) format. +Install the commit-msg hook to normalize and validate them automatically: + +```bash +ln -s ../../scripts/commit-msg.sh .git/hooks/commit-msg +``` + +See [docs/commit-messages.md](docs/commit-messages.md) for the format rules, examples, +and the `nightshift commit normalize` command. + ## Uninstalling ```bash diff --git a/cmd/nightshift/commands/commit.go b/cmd/nightshift/commands/commit.go index 7e018fb..39ce29c 100644 --- a/cmd/nightshift/commands/commit.go +++ b/cmd/nightshift/commands/commit.go @@ -4,6 +4,7 @@ import ( "fmt" "io" "os" + "strings" "github.com/marcus/nightshift/internal/commits" "github.com/spf13/cobra" @@ -33,9 +34,14 @@ when no argument and no --file are given. 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), +With --file the normalized message is written back to the file; otherwise it +is printed to stdout. + +Use --check to only validate without rewriting; a diff-style report is +printed and the exit code is non-zero when the message is not in canonical +form or cannot be normalized.`, + SilenceUsage: true, + Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { check, _ := cmd.Flags().GetBool("check") file, _ := cmd.Flags().GetString("file") @@ -47,15 +53,26 @@ when the message does not conform.`, 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) + current := strings.Join(commits.StripComments(raw), "\n") + if current != normalized { + printDiff(os.Stderr, current, normalized) + return commits.ErrNotCanonical + } + fmt.Fprintln(cmd.OutOrStdout(), normalized) + return nil + } + + if file != "" { + if err := os.WriteFile(file, []byte(normalized+"\n"), 0o644); err != nil { + return fmt.Errorf("write %s: %w", file, err) + } return nil } - fmt.Fprintln(os.Stdout, normalized) + fmt.Fprintln(cmd.OutOrStdout(), normalized) return nil }, } @@ -86,3 +103,54 @@ func readCommitMessage(args []string, file string) (string, error) { } return string(b), nil } + +// printDiff writes a diff-style report of the changes between oldText and +// newText to w: removed lines prefixed with '-', added lines with '+', and +// unchanged context lines with a space. +func printDiff(w io.Writer, oldText, newText string) { + oldLines := strings.Split(oldText, "\n") + newLines := strings.Split(newText, "\n") + + // lcs[i][j] is the length of the longest common subsequence of + // oldLines[i:] and newLines[j:]. + lcs := make([][]int, len(oldLines)+1) + for i := range lcs { + lcs[i] = make([]int, len(newLines)+1) + } + for i := len(oldLines) - 1; i >= 0; i-- { + for j := len(newLines) - 1; j >= 0; j-- { + switch { + case oldLines[i] == newLines[j]: + lcs[i][j] = lcs[i+1][j+1] + 1 + case lcs[i+1][j] >= lcs[i][j+1]: + lcs[i][j] = lcs[i+1][j] + default: + lcs[i][j] = lcs[i][j+1] + } + } + } + + fmt.Fprintln(w, "--- current") + fmt.Fprintln(w, "+++ normalized") + i, j := 0, 0 + for i < len(oldLines) && j < len(newLines) { + switch { + case oldLines[i] == newLines[j]: + fmt.Fprintf(w, " %s\n", oldLines[i]) + i++ + j++ + case lcs[i+1][j] >= lcs[i][j+1]: + fmt.Fprintf(w, "- %s\n", oldLines[i]) + i++ + default: + fmt.Fprintf(w, "+ %s\n", newLines[j]) + j++ + } + } + for ; i < len(oldLines); i++ { + fmt.Fprintf(w, "- %s\n", oldLines[i]) + } + for ; j < len(newLines); j++ { + fmt.Fprintf(w, "+ %s\n", newLines[j]) + } +} diff --git a/docs/commit-messages.md b/docs/commit-messages.md index ab6e009..d1029d1 100644 --- a/docs/commit-messages.md +++ b/docs/commit-messages.md @@ -13,7 +13,7 @@ derive changelogs automatically. ``` - **type** — one of `feat`, `fix`, `docs`, `style`, `refactor`, `test`, - `chore`, `perf`, `build`, `ci`. + `chore`, `perf`, `build`, `ci`, `revert`. - **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 @@ -29,8 +29,15 @@ 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). +Trivially fixable issues (whitespace, uppercase type or subject, trailing +period, body wrapping) are fixed automatically; messages that need a human +decision (missing/unknown type, missing or overlong subject) are rejected. +With `--file` the normalized message is written back to the file, otherwise it +is printed to stdout. + +Add `--check` to validate only. A diff-style report is printed and the command +exits non-zero when the message is not in canonical form or cannot be +normalized. ## commit-msg hook diff --git a/internal/commits/normalizer.go b/internal/commits/normalizer.go index 62526b3..c79b2ac 100644 --- a/internal/commits/normalizer.go +++ b/internal/commits/normalizer.go @@ -10,14 +10,16 @@ // // 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). +// and subject casing, trailing punctuation, body wrapping) and rejects anything +// that needs a human decision (missing type, unknown type, missing subject, +// overlong subject). package commits import ( "errors" "fmt" "strings" + "unicode" "unicode/utf8" ) @@ -39,6 +41,7 @@ var allowedTypes = map[string]struct{}{ "perf": {}, "build": {}, "ci": {}, + "revert": {}, } // Errors returned by the normalizer. They are wrapped so callers can match on @@ -57,9 +60,9 @@ var ( 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") + // ErrNotCanonical is returned by Validate when the message differs from + // its canonical normalized form. + ErrNotCanonical = errors.New("commit message is not in canonical form") ) // Normalize parses, validates, and rewrites a raw commit message so that it @@ -96,6 +99,28 @@ func Normalize(msg string) (string, error) { return b.String(), nil } +// Validate reports whether msg already conforms to the canonical Conventional +// Commits form. It returns nil when no normalization is needed, and a non-nil +// error — either an error from Normalize or ErrNotCanonical — when the message +// would change under normalization. +func Validate(msg string) error { + normalized, err := Normalize(msg) + if err != nil { + return err + } + if strings.Join(StripComments(msg), "\n") != normalized { + return ErrNotCanonical + } + return nil +} + +// StripComments removes git's commented-out template lines, trims trailing +// whitespace from every line, and drops leading/trailing blank lines. It +// returns the user-authored text of the message. +func StripComments(msg string) []string { + return stripComments(msg) +} + // 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. @@ -157,19 +182,15 @@ func parseHeader(header string) (typ, scope, subject string, err error) { 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. +// cleanSubject normalizes the subject text: surrounding whitespace and a +// trailing period are removed and a leading uppercase letter is lowercased. func cleanSubject(subject string) string { s := strings.TrimSpace(subject) s = strings.TrimRight(s, ".") - return s + return lowerFirst(s) } // formatHeader reassembles a canonical header line from its components. @@ -245,11 +266,11 @@ func isAllowedType(typ string) bool { return ok } -// startsUpper reports whether the first rune of s is an ASCII uppercase letter. -func startsUpper(s string) bool { +// lowerFirst lowercases the first rune of s, leaving the rest untouched. +func lowerFirst(s string) string { if s == "" { - return false + return s } - r, _ := utf8.DecodeRuneInString(s) - return r >= 'A' && r <= 'Z' + r, size := utf8.DecodeRuneInString(s) + return string(unicode.ToLower(r)) + s[size:] } diff --git a/internal/commits/normalizer_test.go b/internal/commits/normalizer_test.go index 2121633..12e3261 100644 --- a/internal/commits/normalizer_test.go +++ b/internal/commits/normalizer_test.go @@ -33,6 +33,16 @@ func TestNormalize(t *testing.T) { in: "FEAT(ui): render button", want: "feat(ui): render button", }, + { + name: "lowercases a capitalized subject", + in: "feat: Add login screen", + want: "feat: add login screen", + }, + { + name: "revert type is allowed", + in: "revert: feat: add login screen", + want: "revert: feat: add login screen", + }, { 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", @@ -61,11 +71,6 @@ func TestNormalize(t *testing.T) { 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), @@ -100,6 +105,63 @@ func TestNormalize(t *testing.T) { } } +func TestValidate(t *testing.T) { + tests := []struct { + name string + in string + wantErr error + }{ + { + name: "canonical message passes", + in: "fix(api): handle nil response\n\nbody wrapped at seventy-two columns by hand\n", + }, + { + name: "comment lines ignored", + in: "feat: add login\n# git template comment\n", + }, + { + name: "uppercase type fails", + in: "FEAT: add login", + wantErr: ErrNotCanonical, + }, + { + name: "capitalized subject fails", + in: "feat: Add login", + wantErr: ErrNotCanonical, + }, + { + name: "unwrapped body fails", + in: "feat: add login\n\nthis body paragraph is intentionally far longer than the configured wrap width so it must be hard wrapped by the normalizer", + wantErr: ErrNotCanonical, + }, + { + name: "unknown type fails", + in: "wip: halfway done", + wantErr: ErrUnknownType, + }, + { + name: "empty message fails", + in: "\n\n# only comments\n", + wantErr: ErrEmptyMessage, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := Validate(tc.in) + if tc.wantErr == nil { + if err != nil { + t.Fatalf("Validate(%q): unexpected error: %v", tc.in, err) + } + return + } + if !errors.Is(err, tc.wantErr) { + t.Fatalf("Validate(%q): expected error to wrap %v, got %v", tc.in, tc.wantErr, err) + } + }) + } +} + func TestNormalizeIdempotent(t *testing.T) { cases := []string{ "feat: add login screen", @@ -122,7 +184,7 @@ func TestNormalizeIdempotent(t *testing.T) { } func TestAllowedTypes(t *testing.T) { - for _, typ := range []string{"feat", "fix", "docs", "style", "refactor", "test", "chore", "perf", "build", "ci"} { + for _, typ := range []string{"feat", "fix", "docs", "style", "refactor", "test", "chore", "perf", "build", "ci", "revert"} { if !isAllowedType(typ) { t.Errorf("expected %q to be an allowed type", typ) } diff --git a/scripts/commit-msg.sh b/scripts/commit-msg.sh index 3320a40..0b370e6 100755 --- a/scripts/commit-msg.sh +++ b/scripts/commit-msg.sh @@ -3,8 +3,8 @@ # # 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. +# cannot be normalized (missing/unknown type, missing or overlong subject) are +# rejected with a non-zero exit so the commit is aborted. # # Install: # make install-hooks @@ -27,20 +27,16 @@ if [[ -z "$NIGHTSHIFT" ]]; then NIGHTSHIFT="go run github.com/marcus/nightshift/cmd/nightshift" fi -NORMALIZED="$($NIGHTSHIFT commit normalize --file "$MSG_FILE" 2>/tmp/nightshift-commit-msg.err)" -STATUS=$? - -if [[ $STATUS -ne 0 ]]; then +NORMALIZED_ERR=/tmp/nightshift-commit-msg.err +if ! "$NIGHTSHIFT" commit normalize --file "$MSG_FILE" 2>"$NORMALIZED_ERR"; then echo "🪡 commit-msg: message does not follow Conventional Commits" >&2 - sed 's/^/ /' /tmp/nightshift-commit-msg.err >&2 || true + sed 's/^/ /' "$NORMALIZED_ERR" >&2 || true echo "" >&2 echo " Expected format: (): " >&2 - echo " Types: feat fix docs style refactor test chore perf build ci" >&2 + echo " Types: feat fix docs style refactor test chore perf build ci revert" >&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 From 35b5ed61ea6f0fd02d522d62ace994e4a498bbb3 Mon Sep 17 00:00:00 2001 From: Lasse Larsen Date: Fri, 21 Aug 2026 02:14:27 +0200 Subject: [PATCH 3/3] fix(commits): repair hook fallback and normalizer edge cases The commit-msg hook invoked its go-run fallback as a single quoted string, so the exec failed and every commit was rejected with a misleading formatting error. The hook now stores the command as an array, probes that the tool runs before blaming the message, uses a mktemp'd error file cleaned up by a trap, and distinguishes tooling failures from format rejections. The normalizer now accepts and preserves Conventional Commits breaking-change markers (feat!:, feat(scope)!:), checks the 72-char subject limit after trailing-period trimming, measures body wrap width in runes rather than bytes, and keeps trailing trailer/footer blocks verbatim instead of re-wrapping them. Nightshift-Task: commit-normalize Nightshift-Ref: https://github.com/marcus/nightshift --- docs/commit-messages.md | 14 +++- internal/commits/normalizer.go | 100 ++++++++++++++++++++-------- internal/commits/normalizer_test.go | 37 ++++++++++ scripts/commit-msg.sh | 39 ++++++++--- 4 files changed, 152 insertions(+), 38 deletions(-) diff --git a/docs/commit-messages.md b/docs/commit-messages.md index d1029d1..ac1f2c3 100644 --- a/docs/commit-messages.md +++ b/docs/commit-messages.md @@ -7,7 +7,7 @@ derive changelogs automatically. ## Format ``` -(): +()!: ``` @@ -15,9 +15,17 @@ derive changelogs automatically. - **type** — one of `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`, `perf`, `build`, `ci`, `revert`. - **scope** — optional, e.g. `fix(api): ...`. +- **`!`** — optional breaking-change marker after the type or scope, e.g. + `feat!:` or `feat(api)!:`. It is preserved as-is. (A `BREAKING CHANGE:` + footer is also valid; body text is passed through unchanged apart from + wrapping.) - **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 72-char limit is checked after normalization, so a subject that only + fits once its trailing period is trimmed is accepted. +- **body** — optional, wrapped at 72 columns (counted in characters, not + bytes), separated from the subject by a blank line. A final paragraph made + up entirely of trailer/footer lines (e.g. `Reviewed-by: ...`, + `BREAKING CHANGE: ...`) is preserved verbatim rather than re-wrapped. ## The `commit normalize` command diff --git a/internal/commits/normalizer.go b/internal/commits/normalizer.go index c79b2ac..cd325b2 100644 --- a/internal/commits/normalizer.go +++ b/internal/commits/normalizer.go @@ -4,11 +4,12 @@ // // The supported format follows the Conventional Commits 1.0.0 specification: // -// (): +// ()!: // // // -// The normalizer is intentionally strict but constructive: rather than silently +// 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 // and subject casing, trailing punctuation, body wrapping) and rejects anything // that needs a human decision (missing type, unknown type, missing subject, @@ -18,6 +19,7 @@ package commits import ( "errors" "fmt" + "regexp" "strings" "unicode" "unicode/utf8" @@ -80,15 +82,18 @@ func Normalize(msg string) (string, error) { header := lines[0] body := lines[1:] - typ, scope, subject, err := parseHeader(header) + typ, scope, breaking, subject, err := parseHeader(header) if err != nil { return "", err } subject = cleanSubject(subject) + if utf8.RuneCountInString(subject) > MaxSubjectLength { + return "", ErrSubjectTooLong + } var b strings.Builder - b.WriteString(formatHeader(typ, scope, subject)) + b.WriteString(formatHeader(typ, scope, breaking, subject)) wrapped := wrapBody(body, BodyWrapWidth) if wrapped != "" { @@ -145,21 +150,30 @@ func stripComments(msg string) []string { } // 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) { +// validates them. The returned type is lower-cased to match the allowed set, +// and breaking reports whether the optional "!" breaking-change marker was +// present after the type or scope. The subject length is not checked here; the +// caller checks it after cleaning, because cleaning can shorten the subject. +func parseHeader(header string) (typ, scope string, breaking bool, subject string, err error) { header = strings.TrimSpace(header) colon := strings.Index(header, ":") if colon <= 0 { - return "", "", "", ErrMissingType + return "", "", false, "", ErrMissingType } prefix := header[:colon] subject = strings.TrimSpace(header[colon+1:]) - // Split an optional "(scope)" from the type. + // Strip an optional breaking-change "!" after the type or scope. prefix = strings.TrimSpace(prefix) + if strings.HasSuffix(prefix, "!") { + breaking = true + prefix = strings.TrimSuffix(prefix, "!") + } + + // Split an optional "(scope)" from the type. if strings.HasPrefix(prefix, "(") { // A leading "(" with no type is not a valid conventional header. - return "", "", "", ErrMissingType + return "", "", false, "", ErrMissingType } if open := strings.Index(prefix, "("); open > 0 && strings.HasSuffix(prefix, ")") { typ = prefix[:open] @@ -171,18 +185,15 @@ func parseHeader(header string) (typ, scope, subject string, err error) { scope = strings.TrimSpace(scope) if typ == "" { - return "", "", "", ErrMissingType + return "", "", false, "", ErrMissingType } if !isAllowedType(typ) { - return "", "", "", fmt.Errorf("%w: %q", ErrUnknownType, typ) + return "", "", false, "", fmt.Errorf("%w: %q", ErrUnknownType, typ) } if strings.TrimSpace(subject) == "" { - return "", "", "", ErrMissingSubject - } - if utf8.RuneCountInString(subject) > MaxSubjectLength { - return "", "", "", ErrSubjectTooLong + return "", "", false, "", ErrMissingSubject } - return typ, scope, subject, nil + return typ, scope, breaking, subject, nil } // cleanSubject normalizes the subject text: surrounding whitespace and a @@ -193,17 +204,24 @@ func cleanSubject(subject string) string { return lowerFirst(s) } -// formatHeader reassembles a canonical header line from its components. -func formatHeader(typ, scope, subject string) string { +// formatHeader reassembles a canonical header line from its components, +// preserving an optional "!" breaking-change marker after the type/scope. +func formatHeader(typ, scope string, breaking bool, subject string) string { + marker := "" + if breaking { + marker = "!" + } if scope != "" { - return typ + "(" + scope + "): " + subject + return typ + "(" + scope + ")" + marker + ": " + subject } - return typ + ": " + subject + return typ + marker + ": " + 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. +// line) are preserved. A trailing block of trailer/footer lines (e.g. +// "Reviewed-by: x" or "BREAKING CHANGE: ...") is kept verbatim, one entry per +// line, because re-wrapping would corrupt it. func wrapBody(body []string, width int) string { var paragraphs [][]string var cur []string @@ -226,13 +244,43 @@ func wrapBody(body []string, width int) string { if i > 0 { b.WriteString("\n\n") } + // In the final paragraph a trailing run of trailer lines is kept + // verbatim (git recognizes trailers there even without a preceding + // blank line); only the prose above it is wrapped. + if i == len(paragraphs)-1 { + if n := trailerSuffixLen(p); n > 0 { + head, tail := p[:len(p)-n], p[len(p)-n:] + if len(head) > 0 { + b.WriteString(wrapParagraph(strings.Join(head, " "), width)) + b.WriteString("\n") + } + b.WriteString(strings.Join(tail, "\n")) + continue + } + } b.WriteString(wrapParagraph(strings.Join(p, " "), width)) } return b.String() } +// trailerLineRe matches a git trailer or Conventional Commits footer entry, +// e.g. "Reviewed-by: lasse", "Nightshift-Task: x", or "BREAKING CHANGE: y". +var trailerLineRe = regexp.MustCompile(`^[A-Za-z0-9-]+( [A-Za-z0-9-]+)?: \S`) + +// trailerSuffixLen returns the length of the trailing run of trailer/footer +// lines in lines, or 0 when the last line is not a trailer. +func trailerSuffixLen(lines []string) int { + n := 0 + for i := len(lines) - 1; i >= 0 && trailerLineRe.MatchString(lines[i]); i-- { + n++ + } + return n +} + // wrapParagraph hard-wraps a single-line paragraph at width, breaking on word -// boundaries. A word longer than width is left intact rather than split. +// boundaries. Width is measured in runes, not bytes, so multi-byte characters +// count as a single column. 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 { @@ -243,17 +291,17 @@ func wrapParagraph(text string, width int) string { for i, w := range words { if i == 0 { b.WriteString(w) - lineLen = len(w) + lineLen = utf8.RuneCountInString(w) continue } - if lineLen+1+len(w) <= width { + if lineLen+1+utf8.RuneCountInString(w) <= width { b.WriteByte(' ') b.WriteString(w) - lineLen += 1 + len(w) + lineLen += 1 + utf8.RuneCountInString(w) } else { b.WriteByte('\n') b.WriteString(w) - lineLen = len(w) + lineLen = utf8.RuneCountInString(w) } } return b.String() diff --git a/internal/commits/normalizer_test.go b/internal/commits/normalizer_test.go index 12e3261..75f81cc 100644 --- a/internal/commits/normalizer_test.go +++ b/internal/commits/normalizer_test.go @@ -43,6 +43,43 @@ func TestNormalize(t *testing.T) { in: "revert: feat: add login screen", want: "revert: feat: add login screen", }, + { + name: "breaking-change marker is preserved", + in: "feat!: drop support for v1", + want: "feat!: drop support for v1", + }, + { + name: "breaking-change marker with scope is preserved", + in: "FEAT(API)!: Drop support for v1", + want: "feat(API)!: drop support for v1", + }, + { + name: "subject shrinking to the limit after trimming the trailing period is accepted", + in: "feat: " + strings.Repeat("a", MaxSubjectLength) + ".", + want: "feat: " + strings.Repeat("a", MaxSubjectLength), + }, + { + name: "trailer block in the last paragraph is preserved verbatim", + in: "chore: release v1.2.3\n\nbody text that is long enough to be wrapped when the normalizer reflows this paragraph onto multiple lines at the configured width\n" + + "Nightshift-Task: release\nNightshift-Ref: https://github.com/marcus/nightshift", + want: "chore: release v1.2.3\n\n" + + "body text that is long enough to be wrapped when the normalizer reflows\n" + + "this paragraph onto multiple lines at the configured width\n" + + "Nightshift-Task: release\nNightshift-Ref: https://github.com/marcus/nightshift", + }, + { + name: "breaking change footer is preserved verbatim", + in: "feat!: drop the legacy API\n\nBREAKING CHANGE: removes /v1 endpoints entirely, use /v2 instead", + want: "feat!: drop the legacy API\n\nBREAKING CHANGE: removes /v1 endpoints entirely, use /v2 instead", + }, + { + name: "body wrapping counts runes not bytes", + // Three 10-rune words of 3-byte CJK characters: 32 runes but 92 + // bytes. At rune width they fit on one line; byte counting would + // split them. + in: "docs: wrapping\n\n" + strings.Repeat("日", 10) + " " + strings.Repeat("日", 10) + " " + strings.Repeat("日", 10), + want: "docs: wrapping\n\n" + strings.Repeat("日", 10) + " " + strings.Repeat("日", 10) + " " + strings.Repeat("日", 10), + }, { 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", diff --git a/scripts/commit-msg.sh b/scripts/commit-msg.sh index 0b370e6..a3c6291 100755 --- a/scripts/commit-msg.sh +++ b/scripts/commit-msg.sh @@ -20,19 +20,40 @@ fi MSG_FILE="$1" -# Resolve the nightshift binary: prefer the one on $PATH, fall back to -# building the current source tree. -NIGHTSHIFT="$(command -v nightshift || true)" -if [[ -z "$NIGHTSHIFT" ]]; then - NIGHTSHIFT="go run github.com/marcus/nightshift/cmd/nightshift" +# Resolve the nightshift command: prefer the binary on $PATH, fall back to +# running from the source tree via go run. Kept as an array so the multi-word +# fallback is expanded as separate words when invoked. +NIGHTSHIFT=(nightshift) +if ! command -v nightshift >/dev/null 2>&1; then + if ! command -v go >/dev/null 2>&1; then + echo "🪡 commit-msg: neither 'nightshift' nor 'go' found on PATH;" >&2 + echo " cannot check the commit message" >&2 + exit 1 + fi + NIGHTSHIFT=(go run github.com/marcus/nightshift/cmd/nightshift) fi -NORMALIZED_ERR=/tmp/nightshift-commit-msg.err -if ! "$NIGHTSHIFT" commit normalize --file "$MSG_FILE" 2>"$NORMALIZED_ERR"; then +# Make sure the tool itself can run (binary present, go run compiles) before +# blaming the commit message for any failure. +probe_status=0 +"${NIGHTSHIFT[@]}" commit normalize --help >/dev/null 2>&1 || probe_status=$? +if [[ $probe_status -ne 0 ]]; then + echo "🪡 commit-msg: failed to run nightshift (exit $probe_status);" >&2 + echo " this is a tooling problem, not a message-format problem" >&2 + exit 1 +fi + +ERR_FILE="$(mktemp "${TMPDIR:-/tmp}/nightshift-commit-msg.XXXXXX")" +trap 'rm -f "$ERR_FILE"' EXIT + +status=0 +"${NIGHTSHIFT[@]}" commit normalize --file "$MSG_FILE" 2>"$ERR_FILE" || status=$? + +if [[ $status -ne 0 ]]; then echo "🪡 commit-msg: message does not follow Conventional Commits" >&2 - sed 's/^/ /' "$NORMALIZED_ERR" >&2 || true + sed 's/^/ /' "$ERR_FILE" >&2 || true echo "" >&2 - echo " Expected format: (): " >&2 + echo " Expected format: ()!: " >&2 echo " Types: feat fix docs style refactor test chore perf build ci revert" >&2 echo " (rewrite your message, or bypass with: git commit --no-verify)" >&2 exit 1