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 new file mode 100644 index 0000000..39ce29c --- /dev/null +++ b/cmd/nightshift/commands/commit.go @@ -0,0 +1,156 @@ +package commands + +import ( + "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 + +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") + + raw, err := readCommitMessage(args, file) + if err != nil { + return err + } + + normalized, err := commits.Normalize(raw) + if err != nil { + return err + } + + if check { + 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(cmd.OutOrStdout(), 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 +} + +// 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 new file mode 100644 index 0000000..ac1f2c3 --- /dev/null +++ b/docs/commit-messages.md @@ -0,0 +1,62 @@ +# 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`, `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. + 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 + +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 +``` + +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 + +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..cd325b2 --- /dev/null +++ b/internal/commits/normalizer.go @@ -0,0 +1,324 @@ +// 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 +// 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" + "regexp" + "strings" + "unicode" + "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": {}, + "revert": {}, +} + +// 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) + // 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 +// 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, 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, breaking, subject)) + + wrapped := wrapBody(body, BodyWrapWidth) + if wrapped != "" { + b.WriteString("\n\n") + b.WriteString(wrapped) + } + + 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. +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, +// 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 "", "", false, "", ErrMissingType + } + prefix := header[:colon] + subject = strings.TrimSpace(header[colon+1:]) + + // 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 "", "", false, "", 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 "", "", false, "", ErrMissingType + } + if !isAllowedType(typ) { + return "", "", false, "", fmt.Errorf("%w: %q", ErrUnknownType, typ) + } + if strings.TrimSpace(subject) == "" { + return "", "", false, "", ErrMissingSubject + } + return typ, scope, breaking, subject, nil +} + +// 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 lowerFirst(s) +} + +// 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 + ")" + marker + ": " + 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. 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 + 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") + } + // 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. 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 { + return "" + } + var b strings.Builder + lineLen := 0 + for i, w := range words { + if i == 0 { + b.WriteString(w) + lineLen = utf8.RuneCountInString(w) + continue + } + if lineLen+1+utf8.RuneCountInString(w) <= width { + b.WriteByte(' ') + b.WriteString(w) + lineLen += 1 + utf8.RuneCountInString(w) + } else { + b.WriteByte('\n') + b.WriteString(w) + lineLen = utf8.RuneCountInString(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 +} + +// lowerFirst lowercases the first rune of s, leaving the rest untouched. +func lowerFirst(s string) string { + if s == "" { + return s + } + 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 new file mode 100644 index 0000000..75f81cc --- /dev/null +++ b/internal/commits/normalizer_test.go @@ -0,0 +1,232 @@ +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: "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: "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", + 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: "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 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", + "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", "revert"} { + 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..a3c6291 --- /dev/null +++ b/scripts/commit-msg.sh @@ -0,0 +1,63 @@ +#!/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, missing 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 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 + +# 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/^/ /' "$ERR_FILE" >&2 || true + echo "" >&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 +fi + +echo "🪡 commit-msg: normalized" +exit 0