From 1589d0417fe5f3e58eafe67007f3514e63d34774 Mon Sep 17 00:00:00 2001 From: vabatta <2137077+vabatta@users.noreply.github.com> Date: Mon, 9 Mar 2026 00:38:30 +0100 Subject: [PATCH 1/9] fix(cz)!: align error messages and validation to Conventional Commits spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Accept BREAKING-CHANGE as synonym for BREAKING CHANGE (spec #16) - BREAKING CHANGE must be uppercase — lowercase is rejected (spec #15) - Error messages now quote spec language: - "commits MUST be prefixed with a type" (spec #1) - "description MUST immediately follow the colon and space" (spec #5) - "breaking changes MUST be indicated by a BREAKING CHANGE footer" (spec #13) - Move _err/_hint helpers to shared helpers.sh - All error output uses _err/_hint consistently across commands BREAKING CHANGE: error message strings changed — tooling that parses stderr output (e.g. CI scripts matching specific error text) may need updating --- dist/cz/bin/cz | 62 ++++++++++++++++++++++------------------ scripts/cz/cmd_create.sh | 10 ++++--- scripts/cz/cmd_hook.sh | 15 ++++++---- scripts/cz/cmd_init.sh | 4 ++- scripts/cz/cmd_lint.sh | 21 +++++++------- scripts/cz/config.sh | 4 ++- scripts/cz/docs/cz.adoc | 7 +++-- scripts/cz/helpers.sh | 4 +++ scripts/cz/main_spec.sh | 47 +++++++++++++++++++++--------- 9 files changed, 108 insertions(+), 66 deletions(-) diff --git a/dist/cz/bin/cz b/dist/cz/bin/cz index 554157e..53be5cb 100755 --- a/dist/cz/bin/cz +++ b/dist/cz/bin/cz @@ -214,6 +214,18 @@ GETOPTIONSHERE # --- begin: scripts/cz/config.sh --- +# --- begin: scripts/cz/helpers.sh --- + + +_err() { [[ -n "${QUIET:-}" ]] || echo "cz: error: $1" >&2; } +_hint() { [[ -n "${QUIET:-}" ]] || echo "$1" >&2; } + +_trim() { + local v="${!1}" + v="${v#"${v%%[![:space:]]*}"}" + printf -v "$1" '%s' "${v%"${v##*[![:space:]]}"}" +} +# --- end: scripts/cz/helpers.sh --- # --- begin: scripts/cz/config_defaults.sh --- format_config() { @@ -330,7 +342,7 @@ find_config() { load_config() { if [[ -n "$CONFIG_FILE" ]]; then if [[ ! -f "$CONFIG_FILE" ]]; then - echo "cz: error: config file not found: $CONFIG_FILE" >&2 + _err "config file not found: $CONFIG_FILE" exit 1 fi @@ -393,6 +405,7 @@ cmd_parse() { # --- begin: scripts/cz/cmd_init.sh --- +# --- skipped (already included): scripts/cz/helpers.sh --- # --- skipped (already included): scripts/cz/config_defaults.sh --- cmd_init() { @@ -404,7 +417,7 @@ cmd_init() { fi if [[ -f "$OUTPUT_FILE" && -z "${FORCE:-}" ]]; then - echo "cz: error: '$OUTPUT_FILE' already exists (use -f to overwrite)" >&2 + _err "'$OUTPUT_FILE' already exists (use -f to overwrite)" return 1 fi @@ -414,12 +427,14 @@ cmd_init() { # --- begin: scripts/cz/cmd_hook.sh --- +# --- skipped (already included): scripts/cz/helpers.sh --- + cmd_hook() { local action="${1:-status}" local git_dir git_dir="$(git rev-parse --git-dir 2>/dev/null)" || { - echo "cz: error: not a git repository" >&2 + _err "not a git repository" return 1 } @@ -437,8 +452,8 @@ cmd_hook() { hook_status "$hook_path" "$hook_marker" ;; *) - echo "cz: error: unknown hook action '$action'" >&2 - echo "Usage: cz hook [install|uninstall|status]" >&2 + _err "unknown hook action '$action'" + _hint "Usage: cz hook [install|uninstall|status]" return 2 ;; esac @@ -453,8 +468,8 @@ hook_install() { echo "cz: hook already installed" return 0 else - echo "cz: error: existing commit-msg hook found" >&2 - echo "Remove it manually or add 'cz lint < \"\$1\"' to it" >&2 + _err "existing commit-msg hook found" + _hint "Remove it manually or add 'cz lint < \"\$1\"' to it" return 1 fi fi @@ -485,7 +500,7 @@ hook_uninstall() { fi if ! grep -q "$hook_marker" "$hook_path" 2>/dev/null; then - echo "cz: error: commit-msg hook was not installed by cz" >&2 + _err "commit-msg hook was not installed by cz" return 1 fi @@ -518,15 +533,7 @@ hook_status() { # --- begin: scripts/cz/path_validator.sh --- -# --- begin: scripts/cz/helpers.sh --- - - -_trim() { - local v="${!1}" - v="${v#"${v%%[![:space:]]*}"}" - printf -v "$1" '%s' "${v%"${v##*[![:space:]]}"}" -} -# --- end: scripts/cz/helpers.sh --- +# --- skipped (already included): scripts/cz/helpers.sh --- # --- skipped (already included): scripts/cz/config_parser.sh --- file_matches_pattern() { @@ -633,8 +640,6 @@ validate_strict_no_scope() { } # --- end: scripts/cz/path_validator.sh --- -_err() { [[ -n "${QUIET:-}" ]] || echo "cz: error: $1" >&2; } -_hint() { [[ -n "${QUIET:-}" ]] || echo "$1" >&2; } _scope_err() { _err "unknown scope '$1'" _hint "Defined scopes: ${!CFG_SCOPES[*]}" @@ -673,8 +678,8 @@ cmd_lint() { local pattern='^([a-z]+)(\(([a-zA-Z0-9_@/,*-]+)\))?(!)?: (.+)$' if [[ ! "$first_line" =~ $pattern ]]; then - _err "invalid commit format" - _hint "Expected: [(scope)]: " + _err "commits MUST be prefixed with a type, followed by a colon and space" + _hint "Expected: [()][!]: " return 1 fi @@ -688,7 +693,7 @@ cmd_lint() { fi [[ -z "$description" || "$description" =~ ^[[:space:]]*$ ]] && { - _err "description cannot be empty" + _err "description MUST immediately follow the colon and space" return 1 } @@ -700,8 +705,8 @@ cmd_lint() { breaking_footer="${CFG_SETTINGS[breaking_footer]:-true}" fi - [[ "$breaking_footer" == "true" && -n "$breaking" && ! "$message" =~ BREAKING[[:space:]]CHANGE: ]] && { - _err "breaking change (!) requires 'BREAKING CHANGE:' footer" + [[ "$breaking_footer" == "true" && -n "$breaking" && ! "$message" =~ BREAKING[-[:space:]]CHANGE: ]] && { + _err "if included in the type/scope prefix, breaking changes MUST be indicated by a BREAKING CHANGE footer" return 1 } @@ -816,6 +821,7 @@ validate_paths_if_needed() { # --- begin: scripts/cz/cmd_create.sh --- +# --- skipped (already included): scripts/cz/helpers.sh --- # --- skipped (already included): scripts/cz/config.sh --- _gum() { gum "$@" || exit 130; } @@ -825,8 +831,8 @@ _scope_input_custom() { _gum input --header "Enter scope:" --placeholder "e.g., cmd_create() { if ! command -v gum &>/dev/null; then - echo "cz: error: gum is required for interactive mode" >&2 - echo "See: https://github.com/charmbracelet/gum" >&2 + _err "gum is required for interactive mode" + _hint "See: https://github.com/charmbracelet/gum" exit 1 fi @@ -899,7 +905,7 @@ cmd_create() { local description="" while [[ -z "$description" ]]; do description=$(_gum input --header "Description (required):" --placeholder "Short summary of the change") - [[ -z "$description" ]] && echo "cz: error: description cannot be empty" >&2 + [[ -z "$description" ]] && _err "description MUST immediately follow the colon and space" done local body @@ -910,7 +916,7 @@ cmd_create() { local breaking_explanation="" while [[ -z "$breaking_explanation" ]]; do breaking_explanation=$(_gum write --header "Breaking change explanation (required):" --placeholder "Describe what breaks and how to migrate...") - [[ -z "$breaking_explanation" ]] && echo "cz: error: breaking change explanation is required" >&2 + [[ -z "$breaking_explanation" ]] && _err "breaking change explanation is required" done footer="BREAKING CHANGE: $breaking_explanation" else diff --git a/scripts/cz/cmd_create.sh b/scripts/cz/cmd_create.sh index b852631..24a42bd 100644 --- a/scripts/cz/cmd_create.sh +++ b/scripts/cz/cmd_create.sh @@ -2,6 +2,8 @@ # cz create - compose a commit message interactively +# @bundle source +. ./helpers.sh # @bundle source . ./config.sh @@ -15,8 +17,8 @@ _scope_input_custom() { _gum input --header "Enter scope:" --placeholder "e.g., cmd_create() { # Check gum dependency if ! command -v gum &>/dev/null; then - echo "cz: error: gum is required for interactive mode" >&2 - echo "See: https://github.com/charmbracelet/gum" >&2 + _err "gum is required for interactive mode" + _hint "See: https://github.com/charmbracelet/gum" exit 1 fi @@ -102,7 +104,7 @@ cmd_create() { local description="" while [[ -z "$description" ]]; do description=$(_gum input --header "Description (required):" --placeholder "Short summary of the change") - [[ -z "$description" ]] && echo "cz: error: description cannot be empty" >&2 + [[ -z "$description" ]] && _err "description MUST immediately follow the colon and space" done # Get body (optional) @@ -115,7 +117,7 @@ cmd_create() { local breaking_explanation="" while [[ -z "$breaking_explanation" ]]; do breaking_explanation=$(_gum write --header "Breaking change explanation (required):" --placeholder "Describe what breaks and how to migrate...") - [[ -z "$breaking_explanation" ]] && echo "cz: error: breaking change explanation is required" >&2 + [[ -z "$breaking_explanation" ]] && _err "breaking change explanation is required" done footer="BREAKING CHANGE: $breaking_explanation" else diff --git a/scripts/cz/cmd_hook.sh b/scripts/cz/cmd_hook.sh index 9d4bb48..9a06379 100644 --- a/scripts/cz/cmd_hook.sh +++ b/scripts/cz/cmd_hook.sh @@ -2,13 +2,16 @@ # cz hook - manage the commit-msg git hook +# @bundle source +. ./helpers.sh + cmd_hook() { local action="${1:-status}" # Find git directory local git_dir git_dir="$(git rev-parse --git-dir 2>/dev/null)" || { - echo "cz: error: not a git repository" >&2 + _err "not a git repository" return 1 } @@ -26,8 +29,8 @@ cmd_hook() { hook_status "$hook_path" "$hook_marker" ;; *) - echo "cz: error: unknown hook action '$action'" >&2 - echo "Usage: cz hook [install|uninstall|status]" >&2 + _err "unknown hook action '$action'" + _hint "Usage: cz hook [install|uninstall|status]" return 2 ;; esac @@ -42,8 +45,8 @@ hook_install() { echo "cz: hook already installed" return 0 else - echo "cz: error: existing commit-msg hook found" >&2 - echo "Remove it manually or add 'cz lint < \"\$1\"' to it" >&2 + _err "existing commit-msg hook found" + _hint "Remove it manually or add 'cz lint < \"\$1\"' to it" return 1 fi fi @@ -77,7 +80,7 @@ hook_uninstall() { fi if ! grep -q "$hook_marker" "$hook_path" 2>/dev/null; then - echo "cz: error: commit-msg hook was not installed by cz" >&2 + _err "commit-msg hook was not installed by cz" return 1 fi diff --git a/scripts/cz/cmd_init.sh b/scripts/cz/cmd_init.sh index d2e6526..34847a3 100644 --- a/scripts/cz/cmd_init.sh +++ b/scripts/cz/cmd_init.sh @@ -2,6 +2,8 @@ # cz init - generate a starter .gitcommitizen file +# @bundle source +. ./helpers.sh # @bundle source . ./config_defaults.sh @@ -16,7 +18,7 @@ cmd_init() { # Write to file if [[ -f "$OUTPUT_FILE" && -z "${FORCE:-}" ]]; then - echo "cz: error: '$OUTPUT_FILE' already exists (use -f to overwrite)" >&2 + _err "'$OUTPUT_FILE' already exists (use -f to overwrite)" return 1 fi diff --git a/scripts/cz/cmd_lint.sh b/scripts/cz/cmd_lint.sh index 3a4a485..863880e 100644 --- a/scripts/cz/cmd_lint.sh +++ b/scripts/cz/cmd_lint.sh @@ -7,9 +7,6 @@ # @bundle source . ./path_validator.sh -# Output helpers - respect QUIET flag -_err() { [[ -n "${QUIET:-}" ]] || echo "cz: error: $1" >&2; } -_hint() { [[ -n "${QUIET:-}" ]] || echo "$1" >&2; } _scope_err() { _err "unknown scope '$1'" _hint "Defined scopes: ${!CFG_SCOPES[*]}" @@ -54,8 +51,9 @@ cmd_lint() { local pattern='^([a-z]+)(\(([a-zA-Z0-9_@/,*-]+)\))?(!)?: (.+)$' if [[ ! "$first_line" =~ $pattern ]]; then - _err "invalid commit format" - _hint "Expected: [(scope)]: " + # Spec #1: commits MUST be prefixed with a type, followed by colon and space + _err "commits MUST be prefixed with a type, followed by a colon and space" + _hint "Expected: [()][!]: " return 1 fi @@ -69,9 +67,9 @@ cmd_lint() { return 1 fi - # Validate description is not empty + # Spec #5: description MUST immediately follow the colon and space [[ -z "$description" || "$description" =~ ^[[:space:]]*$ ]] && { - _err "description cannot be empty" + _err "description MUST immediately follow the colon and space" return 1 } @@ -84,9 +82,12 @@ cmd_lint() { breaking_footer="${CFG_SETTINGS[breaking_footer]:-true}" fi - # Validate breaking change has BREAKING CHANGE footer - [[ "$breaking_footer" == "true" && -n "$breaking" && ! "$message" =~ BREAKING[[:space:]]CHANGE: ]] && { - _err "breaking change (!) requires 'BREAKING CHANGE:' footer" + # Spec #13: if included in the type/scope prefix, breaking changes MUST + # be indicated by a BREAKING CHANGE footer + # Spec #16: BREAKING-CHANGE MUST be synonymous with BREAKING CHANGE + # Spec #15: BREAKING CHANGE MUST be uppercase + [[ "$breaking_footer" == "true" && -n "$breaking" && ! "$message" =~ BREAKING[-[:space:]]CHANGE: ]] && { + _err "if included in the type/scope prefix, breaking changes MUST be indicated by a BREAKING CHANGE footer" return 1 } diff --git a/scripts/cz/config.sh b/scripts/cz/config.sh index 2436c59..85ccc43 100644 --- a/scripts/cz/config.sh +++ b/scripts/cz/config.sh @@ -2,6 +2,8 @@ # Find and load .gitcommitizen configuration +# @bundle source +. ./helpers.sh # @bundle source . ./config_defaults.sh # @bundle source @@ -40,7 +42,7 @@ find_config() { load_config() { if [[ -n "$CONFIG_FILE" ]]; then if [[ ! -f "$CONFIG_FILE" ]]; then - echo "cz: error: config file not found: $CONFIG_FILE" >&2 + _err "config file not found: $CONFIG_FILE" exit 1 fi diff --git a/scripts/cz/docs/cz.adoc b/scripts/cz/docs/cz.adoc index dd028d5..796a2cf 100644 --- a/scripts/cz/docs/cz.adoc +++ b/scripts/cz/docs/cz.adoc @@ -107,8 +107,9 @@ in a terminal, or *lint* mode if receiving input on standard input. Disallow multiple scopes. Overrides `multi-scope` config. *--breaking-footer*:: - Require a `BREAKING CHANGE:` footer when the commit has a breaking change - indicator (`!`). Overrides `breaking-footer` config. Default when unset: true. + Require a `BREAKING CHANGE:` (or `BREAKING-CHANGE:`) footer when the commit + has a breaking change indicator (`!`). Overrides `breaking-footer` config. + Default when unset: true. *--no-breaking-footer*:: Allow breaking changes without a `BREAKING CHANGE:` footer. Overrides @@ -173,7 +174,7 @@ The *-r*, *-d*, and *-e* flags affect the *lint* command: |Scope required and must match patterns |*--breaking-footer* -|Breaking `!` requires `BREAKING CHANGE:` footer (default) +|Breaking `!` requires `BREAKING CHANGE:` or `BREAKING-CHANGE:` footer (default) |*--no-breaking-footer* |Breaking `!` allowed without footer diff --git a/scripts/cz/helpers.sh b/scripts/cz/helpers.sh index 97edc3b..a3e1979 100644 --- a/scripts/cz/helpers.sh +++ b/scripts/cz/helpers.sh @@ -2,6 +2,10 @@ # Shared helper functions for cz +# Output helpers - respect QUIET flag +_err() { [[ -n "${QUIET:-}" ]] || echo "cz: error: $1" >&2; } +_hint() { [[ -n "${QUIET:-}" ]] || echo "$1" >&2; } + # Trim leading/trailing whitespace from variable # Usage: _trim varname _trim() { diff --git a/scripts/cz/main_spec.sh b/scripts/cz/main_spec.sh index c23e62d..3c0f964 100644 --- a/scripts/cz/main_spec.sh +++ b/scripts/cz/main_spec.sh @@ -112,28 +112,28 @@ Describe 'cz' Data " " When run script "$BIN" lint The status should be failure - The stderr should include "invalid commit format" + The stderr should include "commits MUST be prefixed with a type" End It 'rejects missing colon' Data "feat add feature" When run script "$BIN" lint The status should be failure - The stderr should include "invalid commit format" + The stderr should include "commits MUST be prefixed with a type" End It 'rejects missing description after colon' Data "feat:" When run script "$BIN" lint The status should be failure - The stderr should include "invalid commit format" + The stderr should include "commits MUST be prefixed with a type" End It 'rejects missing description after colon and space' Data "feat: " When run script "$BIN" lint The status should be failure - The stderr should include "invalid commit format" + The stderr should include "commits MUST be prefixed with a type" End It 'rejects unknown type' @@ -147,28 +147,28 @@ Describe 'cz' Data "FEAT: add feature" When run script "$BIN" lint The status should be failure - The stderr should include "invalid commit format" + The stderr should include "commits MUST be prefixed with a type" End It 'rejects breaking ! without BREAKING CHANGE footer' Data "feat!: breaking change" When run script "$BIN" lint The status should be failure - The stderr should include "BREAKING CHANGE:" + The stderr should include "BREAKING CHANGE footer" End It 'rejects empty scope' Data "feat(): add feature" When run script "$BIN" lint The status should be failure - The stderr should include "invalid commit format" + The stderr should include "commits MUST be prefixed with a type" End It 'rejects whitespace-only description' Data "feat: " When run script "$BIN" lint The status should be failure - The stderr should include "description cannot be empty" + The stderr should include "description MUST immediately follow the colon and space" End End @@ -839,7 +839,7 @@ EOF Data "feat!: breaking change" When run script "$BIN" lint The status should be failure - The stderr should include "breaking change (!) requires 'BREAKING CHANGE:' footer" + The stderr should include "if included in the type/scope prefix, breaking changes MUST be indicated by a BREAKING CHANGE footer" End It 'allows breaking change without footer when --no-breaking-footer is set' @@ -852,7 +852,7 @@ EOF Data "feat!: breaking change" When run script "$BIN" --breaking-footer lint The status should be failure - The stderr should include "breaking change (!) requires 'BREAKING CHANGE:' footer" + The stderr should include "if included in the type/scope prefix, breaking changes MUST be indicated by a BREAKING CHANGE footer" End It 'accepts breaking change with footer when --breaking-footer is set' @@ -899,7 +899,7 @@ EOF Data "feat!: breaking change" When run script "$BIN" --breaking-footer lint The status should be failure - The stderr should include "breaking change (!) requires 'BREAKING CHANGE:' footer" + The stderr should include "if included in the type/scope prefix, breaking changes MUST be indicated by a BREAKING CHANGE footer" End It 'has no effect on non-breaking commits' @@ -907,6 +907,27 @@ EOF When run script "$BIN" --no-breaking-footer lint The status should be success End + + It 'accepts BREAKING-CHANGE footer as synonym for BREAKING CHANGE' + Data + #|feat!: breaking change + #| + #|BREAKING-CHANGE: this is breaking + End + When run script "$BIN" lint + The status should be success + End + + It 'rejects lowercase breaking change footer' + Data + #|feat!: breaking change + #| + #|breaking change: this is breaking + End + When run script "$BIN" lint + The status should be failure + The stderr should include "BREAKING CHANGE" + End End #─────────────────────────────────────────────────────────── @@ -1898,7 +1919,7 @@ MOCK Data "invalid message" When run script "$BIN" The status should be failure - The stderr should include "invalid commit format" + The stderr should include "commits MUST be prefixed with a type" End End @@ -1993,7 +2014,7 @@ EOF Data "feat: " When run script "$BIN" lint The status should be failure - The stderr should include "empty" + The stderr should include "description MUST immediately follow the colon and space" End It 'handles config keys with hyphens correctly' From 1b0dc181da9fd6a97d5a0491386b6f41db94c4cb Mon Sep 17 00:00:00 2001 From: vabatta <2137077+vabatta@users.noreply.github.com> Date: Mon, 9 Mar 2026 00:48:29 +0100 Subject: [PATCH 2/9] fix(cz): address review feedback on spec alignment - cmd_create uses "description MUST NOT be empty" (practical for interactive) - Strict BREAKING CHANGE regex: space or hyphen only (no tab/newline) - config.sh warning converted to _hint helper - Added test for BREAKING-CHANGE with explicit --breaking-footer flag --- dist/cz/bin/cz | 6 +++--- scripts/cz/cmd_create.sh | 2 +- scripts/cz/cmd_lint.sh | 2 +- scripts/cz/config.sh | 2 +- scripts/cz/main_spec.sh | 10 ++++++++++ 5 files changed, 16 insertions(+), 6 deletions(-) diff --git a/dist/cz/bin/cz b/dist/cz/bin/cz index 53be5cb..c190446 100755 --- a/dist/cz/bin/cz +++ b/dist/cz/bin/cz @@ -349,7 +349,7 @@ load_config() { parse_config <"$CONFIG_FILE" if [[ ${#CFG_TYPES[@]} -eq 0 ]]; then - [[ -z "${QUIET:-}" ]] && echo "cz: warning: no [types] in $CONFIG_FILE, using defaults" >&2 + _hint "cz: warning: no [types] in $CONFIG_FILE, using defaults" _set_default_types fi else @@ -705,7 +705,7 @@ cmd_lint() { breaking_footer="${CFG_SETTINGS[breaking_footer]:-true}" fi - [[ "$breaking_footer" == "true" && -n "$breaking" && ! "$message" =~ BREAKING[-[:space:]]CHANGE: ]] && { + [[ "$breaking_footer" == "true" && -n "$breaking" && ! "$message" =~ BREAKING[\ -]CHANGE: ]] && { _err "if included in the type/scope prefix, breaking changes MUST be indicated by a BREAKING CHANGE footer" return 1 } @@ -905,7 +905,7 @@ cmd_create() { local description="" while [[ -z "$description" ]]; do description=$(_gum input --header "Description (required):" --placeholder "Short summary of the change") - [[ -z "$description" ]] && _err "description MUST immediately follow the colon and space" + [[ -z "$description" ]] && _err "description MUST NOT be empty" done local body diff --git a/scripts/cz/cmd_create.sh b/scripts/cz/cmd_create.sh index 24a42bd..92b65e6 100644 --- a/scripts/cz/cmd_create.sh +++ b/scripts/cz/cmd_create.sh @@ -104,7 +104,7 @@ cmd_create() { local description="" while [[ -z "$description" ]]; do description=$(_gum input --header "Description (required):" --placeholder "Short summary of the change") - [[ -z "$description" ]] && _err "description MUST immediately follow the colon and space" + [[ -z "$description" ]] && _err "description MUST NOT be empty" done # Get body (optional) diff --git a/scripts/cz/cmd_lint.sh b/scripts/cz/cmd_lint.sh index 863880e..7225374 100644 --- a/scripts/cz/cmd_lint.sh +++ b/scripts/cz/cmd_lint.sh @@ -86,7 +86,7 @@ cmd_lint() { # be indicated by a BREAKING CHANGE footer # Spec #16: BREAKING-CHANGE MUST be synonymous with BREAKING CHANGE # Spec #15: BREAKING CHANGE MUST be uppercase - [[ "$breaking_footer" == "true" && -n "$breaking" && ! "$message" =~ BREAKING[-[:space:]]CHANGE: ]] && { + [[ "$breaking_footer" == "true" && -n "$breaking" && ! "$message" =~ BREAKING[\ -]CHANGE: ]] && { _err "if included in the type/scope prefix, breaking changes MUST be indicated by a BREAKING CHANGE footer" return 1 } diff --git a/scripts/cz/config.sh b/scripts/cz/config.sh index 85ccc43..171e478 100644 --- a/scripts/cz/config.sh +++ b/scripts/cz/config.sh @@ -50,7 +50,7 @@ load_config() { # If no types defined, use default types but preserve settings/scopes if [[ ${#CFG_TYPES[@]} -eq 0 ]]; then - [[ -z "${QUIET:-}" ]] && echo "cz: warning: no [types] in $CONFIG_FILE, using defaults" >&2 + _hint "cz: warning: no [types] in $CONFIG_FILE, using defaults" _set_default_types fi else diff --git a/scripts/cz/main_spec.sh b/scripts/cz/main_spec.sh index 3c0f964..d7cca5c 100644 --- a/scripts/cz/main_spec.sh +++ b/scripts/cz/main_spec.sh @@ -918,6 +918,16 @@ EOF The status should be success End + It 'accepts BREAKING-CHANGE footer with --breaking-footer flag' + Data + #|feat!: breaking change + #| + #|BREAKING-CHANGE: this is breaking + End + When run script "$BIN" --breaking-footer lint + The status should be success + End + It 'rejects lowercase breaking change footer' Data #|feat!: breaking change From ec7910c1bc8a3b48c052a8101e52acf77e06099b Mon Sep 17 00:00:00 2001 From: vabatta <2137077+vabatta@users.noreply.github.com> Date: Mon, 9 Mar 2026 00:53:19 +0100 Subject: [PATCH 3/9] docs(cz): add spec compliance table to gitcommitizen(5) --- dist/cz/man/man5/gitcommitizen.5 | 91 ++++++++++++++++++++++++++++++ scripts/cz/docs/gitcommitizen.adoc | 37 ++++++++++++ 2 files changed, 128 insertions(+) diff --git a/dist/cz/man/man5/gitcommitizen.5 b/dist/cz/man/man5/gitcommitizen.5 index 87bf263..f44c457 100644 --- a/dist/cz/man/man5/gitcommitizen.5 +++ b/dist/cz/man/man5/gitcommitizen.5 @@ -315,6 +315,97 @@ Scopes may include letters, numbers, dashes, underscores, and \f(CR@\fP (e.g., \ .\} Glob patterns follow standard shell globbing with \f(CR**\fP for recursive matching. .RE +.SH "SPEC COMPLIANCE" +.sp +Validation rules from \c +.URL "https://www.conventionalcommits.org/en/v1.0.0/" "Conventional Commits v1.0.0" ":" +.TS +allbox tab(:); +ltB ltB ltB. +T{ +.sp +Rule +T}:T{ +.sp +Enforcement +T}:T{ +.sp +Ref +T} +.T& +lt lt lt. +T{ +.sp +Type prefix required +T}:T{ +.sp +Always (lint, create) +T}:T{ +.sp +§1 +T} +T{ +.sp +Description required +T}:T{ +.sp +Always +T}:T{ +.sp +§5 +T} +T{ +.sp +Blank line before body +T}:T{ +.sp +Always (lint) +T}:T{ +.sp +§6 +T} +T{ +.sp +BREAKING CHANGE uppercase +T}:T{ +.sp +Always (lint) +T}:T{ +.sp +§15 +T} +T{ +.sp +BREAKING\-CHANGE synonym +T}:T{ +.sp +Accepted in footers +T}:T{ +.sp +§16 +T} +T{ +.sp +Scope defined/required +T}:T{ +.sp +Configurable (\f(CR\-r\fP, \f(CR\-d\fP, \f(CR\-e\fP) +T}:T{ +.sp +— +T} +T{ +.sp +Breaking footer required +T}:T{ +.sp +Configurable (\f(CR\-\-breaking\-footer\fP) +T}:T{ +.sp +— +T} +.TE +.sp .SH "SEE ALSO" .sp git\-commit(1) diff --git a/scripts/cz/docs/gitcommitizen.adoc b/scripts/cz/docs/gitcommitizen.adoc index ad774ea..836daa3 100644 --- a/scripts/cz/docs/gitcommitizen.adoc +++ b/scripts/cz/docs/gitcommitizen.adoc @@ -135,6 +135,43 @@ Place **.gitcommitizen** in the root of a Git repository. * Scopes may include letters, numbers, dashes, underscores, and `@` (e.g., `@types/node`). * Glob patterns follow standard shell globbing with `**` for recursive matching. +== SPEC COMPLIANCE + +Validation rules from https://www.conventionalcommits.org/en/v1.0.0/[Conventional Commits v1.0.0]: + +[cols="2,3,1", options="header"] +|=== +|Rule |Enforcement |Ref + +|Type prefix required +|Always (lint, create) +|§1 + +|Description required +|Always +|§5 + +|Blank line before body +|Always (lint) +|§6 + +|BREAKING CHANGE uppercase +|Always (lint) +|§15 + +|BREAKING-CHANGE synonym +|Accepted in footers +|§16 + +|Scope defined/required +|Configurable (`-r`, `-d`, `-e`) +|— + +|Breaking footer required +|Configurable (`--breaking-footer`) +|— +|=== + == SEE ALSO git-commit(1):: From c6c4027262c9184f8455bb152738cd70d727d3c9 Mon Sep 17 00:00:00 2001 From: vabatta <2137077+vabatta@users.noreply.github.com> Date: Mon, 9 Mar 2026 00:59:05 +0100 Subject: [PATCH 4/9] feat(cz): add -b short flag for --breaking-footer --- dist/cz/bin/cz | 6 +++--- dist/cz/completions/bash/cz.bash | 2 +- dist/cz/completions/zsh/_cz | 4 ++-- dist/cz/man/man1/cz.1 | 13 +++++++------ dist/cz/man/man5/gitcommitizen.5 | 2 +- scripts/cz/completions/_cz | 4 ++-- scripts/cz/completions/cz.bash | 2 +- scripts/cz/docs/cz.adoc | 6 +++--- scripts/cz/docs/gitcommitizen.adoc | 2 +- scripts/cz/options.sh | 2 +- 10 files changed, 22 insertions(+), 21 deletions(-) diff --git a/dist/cz/bin/cz b/dist/cz/bin/cz index c190446..17dfa7b 100755 --- a/dist/cz/bin/cz +++ b/dist/cz/bin/cz @@ -101,7 +101,7 @@ parse() { -[c]?*) OPTARG=$1; shift eval 'set -- "${OPTARG%"${OPTARG#??}"}" "${OPTARG#??}"' ${1+'"$@"'} ;; - -[rdemqhV]?*) OPTARG=$1; shift + -[rdembqhV]?*) OPTARG=$1; shift eval 'set -- "${OPTARG%"${OPTARG#??}"}" -"${OPTARG#??}"' ${1+'"$@"'} case $2 in --*) set -- "$1" unknown "$2" && REST=x; esac;OPTARG= ;; esac @@ -132,7 +132,7 @@ parse() { eval '[ ${OPTARG+x} ] &&:' && OPTARG='1' || OPTARG='' MULTI_SCOPE="$OPTARG" ;; - '--breaking-footer'|'--no-breaking-footer') + '-b'|'--breaking-footer'|'--no-breaking-footer') [ "${OPTARG:-}" ] && OPTARG=${OPTARG#*\=} && set "noarg" "$1" && break eval '[ ${OPTARG+x} ] &&:' && OPTARG='1' || OPTARG='' BREAKING_FOOTER="$OPTARG" @@ -191,7 +191,7 @@ Options: -d, --{no-}defined-scope Scope must exist in [scopes] -e, --{no-}enforce-patterns Scope must match file patterns (implies -d) -m, --{no-}multi-scope Allow multiple scopes like feat(api,db): - --{no-}breaking-footer Require BREAKING CHANGE footer when ! is used + -b, --{no-}breaking-footer Require BREAKING CHANGE footer when ! is used -q, --quiet Suppress warnings and non-essential output -h, --help -V, --version diff --git a/dist/cz/completions/bash/cz.bash b/dist/cz/completions/bash/cz.bash index 7b955cb..727fc32 100644 --- a/dist/cz/completions/bash/cz.bash +++ b/dist/cz/completions/bash/cz.bash @@ -7,7 +7,7 @@ _cz() { prev="${COMP_WORDS[COMP_CWORD-1]}" local commands="create lint parse init hook" - local global_opts="-c --config-file -r --require-scope --no-require-scope -d --defined-scope --no-defined-scope -e --enforce-patterns --no-enforce-patterns -m --multi-scope --no-multi-scope --breaking-footer --no-breaking-footer -q --quiet -h --help -V --version" + local global_opts="-c --config-file -r --require-scope --no-require-scope -d --defined-scope --no-defined-scope -e --enforce-patterns --no-enforce-patterns -m --multi-scope --no-multi-scope -b --breaking-footer --no-breaking-footer -q --quiet -h --help -V --version" # Complete config file path after -c/--config-file if [[ "$prev" == "-c" || "$prev" == "--config-file" ]]; then diff --git a/dist/cz/completions/zsh/_cz b/dist/cz/completions/zsh/_cz index b23af50..026482f 100644 --- a/dist/cz/completions/zsh/_cz +++ b/dist/cz/completions/zsh/_cz @@ -16,8 +16,8 @@ _cz() { '(-e --enforce-patterns --no-enforce-patterns)--no-enforce-patterns[Disable pattern enforcement]' \ '(-m --multi-scope --no-multi-scope)'{-m,--multi-scope}'[Allow multiple scopes]' \ '(-m --multi-scope --no-multi-scope)--no-multi-scope[Disallow multiple scopes]' \ - '(--breaking-footer --no-breaking-footer)--breaking-footer[Require BREAKING CHANGE footer]' \ - '(--breaking-footer --no-breaking-footer)--no-breaking-footer[Allow breaking changes without footer]' \ + '(-b --breaking-footer --no-breaking-footer)'{-b,--breaking-footer}'[Require BREAKING CHANGE footer]' \ + '(-b --breaking-footer --no-breaking-footer)--no-breaking-footer[Allow breaking changes without footer]' \ '(-q --quiet)'{-q,--quiet}'[Suppress warnings and non-essential output]' \ '1:command:->commands' \ '*::arg:->args' diff --git a/dist/cz/man/man1/cz.1 b/dist/cz/man/man1/cz.1 index 64db2ca..6eaf3da 100644 --- a/dist/cz/man/man1/cz.1 +++ b/dist/cz/man/man1/cz.1 @@ -31,7 +31,7 @@ cz \- conventional commit message builder .SH "SYNOPSIS" .sp -\fBcz\fP [\fB\-r\fP | \fB\-\-no\-require\-scope\fP] [\fB\-d\fP | \fB\-\-no\-defined\-scope\fP] [\fB\-e\fP | \fB\-\-no\-enforce\-patterns\fP] [\fB\-m\fP | \fB\-\-no\-multi\-scope\fP] [\fB\-\-breaking\-footer\fP | \fB\-\-no\-breaking\-footer\fP] [\fB\-q\fP] [\fB\-c\fP \fIFILE\fP] [\fISUBCOMMAND\fP] +\fBcz\fP [\fB\-r\fP | \fB\-\-no\-require\-scope\fP] [\fB\-d\fP | \fB\-\-no\-defined\-scope\fP] [\fB\-e\fP | \fB\-\-no\-enforce\-patterns\fP] [\fB\-m\fP | \fB\-\-no\-multi\-scope\fP] [\fB\-b\fP | \fB\-\-no\-breaking\-footer\fP] [\fB\-q\fP] [\fB\-c\fP \fIFILE\fP] [\fISUBCOMMAND\fP] .sp \fBcz\fP \fBcreate\fP .br @@ -155,10 +155,11 @@ Allow multiple scopes like \f(CRfeat(api,db):\fP. Overrides \f(CRmulti\-scope\fP Disallow multiple scopes. Overrides \f(CRmulti\-scope\fP config. .RE .sp -\fB\-\-breaking\-footer\fP +\fB\-b\fP, \fB\-\-breaking\-footer\fP .RS 4 -Require a \f(CRBREAKING CHANGE:\fP footer when the commit has a breaking change -indicator (\f(CR!\fP). Overrides \f(CRbreaking\-footer\fP config. Default when unset: true. +Require a \f(CRBREAKING CHANGE:\fP (or \f(CRBREAKING\-CHANGE:\fP) footer when the commit +has a breaking change indicator (\f(CR!\fP). Overrides \f(CRbreaking\-footer\fP config. +Default when unset: true. .RE .sp \fB\-\-no\-breaking\-footer\fP @@ -285,10 +286,10 @@ Scope required and must match patterns T} T{ .sp -\fB\-\-breaking\-footer\fP +\fB\-b\fP / \fB\-\-breaking\-footer\fP T}:T{ .sp -Breaking \f(CR!\fP requires \f(CRBREAKING CHANGE:\fP footer (default) +Breaking \f(CR!\fP requires \f(CRBREAKING CHANGE:\fP or \f(CRBREAKING\-CHANGE:\fP footer (default) T} T{ .sp diff --git a/dist/cz/man/man5/gitcommitizen.5 b/dist/cz/man/man5/gitcommitizen.5 index f44c457..9f54663 100644 --- a/dist/cz/man/man5/gitcommitizen.5 +++ b/dist/cz/man/man5/gitcommitizen.5 @@ -399,7 +399,7 @@ T{ Breaking footer required T}:T{ .sp -Configurable (\f(CR\-\-breaking\-footer\fP) +Configurable (\f(CR\-b\fP, \f(CR\-\-breaking\-footer\fP) T}:T{ .sp — diff --git a/scripts/cz/completions/_cz b/scripts/cz/completions/_cz index b23af50..026482f 100644 --- a/scripts/cz/completions/_cz +++ b/scripts/cz/completions/_cz @@ -16,8 +16,8 @@ _cz() { '(-e --enforce-patterns --no-enforce-patterns)--no-enforce-patterns[Disable pattern enforcement]' \ '(-m --multi-scope --no-multi-scope)'{-m,--multi-scope}'[Allow multiple scopes]' \ '(-m --multi-scope --no-multi-scope)--no-multi-scope[Disallow multiple scopes]' \ - '(--breaking-footer --no-breaking-footer)--breaking-footer[Require BREAKING CHANGE footer]' \ - '(--breaking-footer --no-breaking-footer)--no-breaking-footer[Allow breaking changes without footer]' \ + '(-b --breaking-footer --no-breaking-footer)'{-b,--breaking-footer}'[Require BREAKING CHANGE footer]' \ + '(-b --breaking-footer --no-breaking-footer)--no-breaking-footer[Allow breaking changes without footer]' \ '(-q --quiet)'{-q,--quiet}'[Suppress warnings and non-essential output]' \ '1:command:->commands' \ '*::arg:->args' diff --git a/scripts/cz/completions/cz.bash b/scripts/cz/completions/cz.bash index 7b955cb..727fc32 100644 --- a/scripts/cz/completions/cz.bash +++ b/scripts/cz/completions/cz.bash @@ -7,7 +7,7 @@ _cz() { prev="${COMP_WORDS[COMP_CWORD-1]}" local commands="create lint parse init hook" - local global_opts="-c --config-file -r --require-scope --no-require-scope -d --defined-scope --no-defined-scope -e --enforce-patterns --no-enforce-patterns -m --multi-scope --no-multi-scope --breaking-footer --no-breaking-footer -q --quiet -h --help -V --version" + local global_opts="-c --config-file -r --require-scope --no-require-scope -d --defined-scope --no-defined-scope -e --enforce-patterns --no-enforce-patterns -m --multi-scope --no-multi-scope -b --breaking-footer --no-breaking-footer -q --quiet -h --help -V --version" # Complete config file path after -c/--config-file if [[ "$prev" == "-c" || "$prev" == "--config-file" ]]; then diff --git a/scripts/cz/docs/cz.adoc b/scripts/cz/docs/cz.adoc index 796a2cf..bb43039 100644 --- a/scripts/cz/docs/cz.adoc +++ b/scripts/cz/docs/cz.adoc @@ -13,7 +13,7 @@ cz - conventional commit message builder == SYNOPSIS -*cz* [*-r* | *--no-require-scope*] [*-d* | *--no-defined-scope*] [*-e* | *--no-enforce-patterns*] [*-m* | *--no-multi-scope*] [*--breaking-footer* | *--no-breaking-footer*] [*-q*] [*-c* _FILE_] [_SUBCOMMAND_] +*cz* [*-r* | *--no-require-scope*] [*-d* | *--no-defined-scope*] [*-e* | *--no-enforce-patterns*] [*-m* | *--no-multi-scope*] [*-b* | *--no-breaking-footer*] [*-q*] [*-c* _FILE_] [_SUBCOMMAND_] *cz* *create* + *cz* *lint* [*-p* _PATHS_] + @@ -106,7 +106,7 @@ in a terminal, or *lint* mode if receiving input on standard input. *--no-multi-scope*:: Disallow multiple scopes. Overrides `multi-scope` config. -*--breaking-footer*:: +*-b*, *--breaking-footer*:: Require a `BREAKING CHANGE:` (or `BREAKING-CHANGE:`) footer when the commit has a breaking change indicator (`!`). Overrides `breaking-footer` config. Default when unset: true. @@ -173,7 +173,7 @@ The *-r*, *-d*, and *-e* flags affect the *lint* command: |*-r -e* |Scope required and must match patterns -|*--breaking-footer* +|*-b* / *--breaking-footer* |Breaking `!` requires `BREAKING CHANGE:` or `BREAKING-CHANGE:` footer (default) |*--no-breaking-footer* diff --git a/scripts/cz/docs/gitcommitizen.adoc b/scripts/cz/docs/gitcommitizen.adoc index 836daa3..f8bd144 100644 --- a/scripts/cz/docs/gitcommitizen.adoc +++ b/scripts/cz/docs/gitcommitizen.adoc @@ -168,7 +168,7 @@ Validation rules from https://www.conventionalcommits.org/en/v1.0.0/[Conventiona |— |Breaking footer required -|Configurable (`--breaking-footer`) +|Configurable (`-b`, `--breaking-footer`) |— |=== diff --git a/scripts/cz/options.sh b/scripts/cz/options.sh index fd195d9..389d04d 100644 --- a/scripts/cz/options.sh +++ b/scripts/cz/options.sh @@ -14,7 +14,7 @@ parser_definition() { flag DEFINED_SCOPE -d --{no-}defined-scope init:@unset -- "Scope must exist in [scopes]" flag ENFORCE_PATTERNS -e --{no-}enforce-patterns init:@unset validate:'DEFINED_SCOPE=1' -- "Scope must match file patterns (implies -d)" flag MULTI_SCOPE -m --{no-}multi-scope init:@unset -- "Allow multiple scopes like feat(api,db):" - flag BREAKING_FOOTER --{no-}breaking-footer init:@unset -- "Require BREAKING CHANGE footer when ! is used" + flag BREAKING_FOOTER -b --{no-}breaking-footer init:@unset -- "Require BREAKING CHANGE footer when ! is used" flag QUIET -q --quiet -- "Suppress warnings and non-essential output" disp :usage -h --help disp VERSION -V --version From 23fd37c96ace0c8dbf783aff93fd95fc9c593d58 Mon Sep 17 00:00:00 2001 From: vabatta <2137077+vabatta@users.noreply.github.com> Date: Mon, 9 Mar 2026 01:20:55 +0100 Subject: [PATCH 5/9] feat(cz): add kebab-case error code registry for machine-readable output Centralize all error messages in an associative array registry (error_codes.sh) with commitlint-style codes like [type-enum], [scope-required], [body-leading-blank]. All _err calls now use code lookups with printf formatting, making errors parseable. --- dist/cz/bin/cz | 84 +++++++++++++++++++++++++++++---------- scripts/cz/cmd_create.sh | 6 +-- scripts/cz/cmd_hook.sh | 8 ++-- scripts/cz/cmd_init.sh | 2 +- scripts/cz/cmd_lint.sh | 24 +++++------ scripts/cz/config.sh | 2 +- scripts/cz/error_codes.sh | 31 +++++++++++++++ scripts/cz/helpers.sh | 17 +++++++- scripts/cz/main_spec.sh | 45 +++++++++++++++++++++ 9 files changed, 175 insertions(+), 44 deletions(-) create mode 100644 scripts/cz/error_codes.sh diff --git a/dist/cz/bin/cz b/dist/cz/bin/cz index 17dfa7b..ea893a1 100755 --- a/dist/cz/bin/cz +++ b/dist/cz/bin/cz @@ -217,7 +217,47 @@ GETOPTIONSHERE # --- begin: scripts/cz/helpers.sh --- -_err() { [[ -n "${QUIET:-}" ]] || echo "cz: error: $1" >&2; } +# --- begin: scripts/cz/error_codes.sh --- + + +declare -gA ERR_CODES=( + ["empty-message"]="empty commit message" + ["header-format"]="commits MUST be prefixed with a type, followed by a colon and space" + ["type-enum"]="unknown type '%s'" + ["description-empty"]="description MUST immediately follow the colon and space" + ["body-leading-blank"]="body MUST begin one blank line after the description" + ["breaking-footer"]="if included in the type/scope prefix, breaking changes MUST be indicated by a BREAKING CHANGE footer" + ["scope-required"]="scope required" + ["scope-enum"]="unknown scope '%s'" + ["scope-missing-config"]="scope '%s' used but no scopes defined in config" + ["scope-file-required"]="scope required for scoped files" + ["multi-scope-disabled"]="multi-scope not enabled" + ["files-scope-mismatch"]="files do not match scope '%s'" + ["files-scopes-mismatch"]="files do not match scopes '%s'" + ["config-not-found"]="config file not found: %s" + ["gum-not-found"]="gum is required for interactive mode" + ["description-required"]="description MUST NOT be empty" + ["breaking-explanation"]="breaking change explanation is required" + ["file-exists"]="'%s' already exists (use -f to overwrite)" + ["not-git-repo"]="not a git repository" + ["hook-action-unknown"]="unknown hook action '%s'" + ["hook-exists"]="existing commit-msg hook found" + ["hook-foreign"]="commit-msg hook was not installed by cz" +) +# --- end: scripts/cz/error_codes.sh --- + +_err() { + [[ -n "${QUIET:-}" ]] && return + local code="$1" + shift + local msg="${ERR_CODES[$code]}" + if [[ -n "$msg" ]]; then + printf -v msg "$msg" "$@" + echo "cz: error: $msg [$code]" >&2 + else + echo "cz: error: $code $* [unknown]" >&2 + fi +} _hint() { [[ -n "${QUIET:-}" ]] || echo "$1" >&2; } _trim() { @@ -342,7 +382,7 @@ find_config() { load_config() { if [[ -n "$CONFIG_FILE" ]]; then if [[ ! -f "$CONFIG_FILE" ]]; then - _err "config file not found: $CONFIG_FILE" + _err config-not-found "$CONFIG_FILE" exit 1 fi @@ -417,7 +457,7 @@ cmd_init() { fi if [[ -f "$OUTPUT_FILE" && -z "${FORCE:-}" ]]; then - _err "'$OUTPUT_FILE' already exists (use -f to overwrite)" + _err file-exists "$OUTPUT_FILE" return 1 fi @@ -434,7 +474,7 @@ cmd_hook() { local git_dir git_dir="$(git rev-parse --git-dir 2>/dev/null)" || { - _err "not a git repository" + _err not-git-repo return 1 } @@ -452,7 +492,7 @@ cmd_hook() { hook_status "$hook_path" "$hook_marker" ;; *) - _err "unknown hook action '$action'" + _err hook-action-unknown "$action" _hint "Usage: cz hook [install|uninstall|status]" return 2 ;; @@ -468,7 +508,7 @@ hook_install() { echo "cz: hook already installed" return 0 else - _err "existing commit-msg hook found" + _err hook-exists _hint "Remove it manually or add 'cz lint < \"\$1\"' to it" return 1 fi @@ -500,7 +540,7 @@ hook_uninstall() { fi if ! grep -q "$hook_marker" "$hook_path" 2>/dev/null; then - _err "commit-msg hook was not installed by cz" + _err hook-foreign return 1 fi @@ -641,7 +681,7 @@ validate_strict_no_scope() { # --- end: scripts/cz/path_validator.sh --- _scope_err() { - _err "unknown scope '$1'" + _err scope-enum "$1" _hint "Defined scopes: ${!CFG_SCOPES[*]}" } _show_errors() { @@ -668,7 +708,7 @@ cmd_lint() { message="$(cat)" [[ -z "$message" ]] && { - _err "empty commit message" + _err empty-message return 1 } @@ -678,7 +718,7 @@ cmd_lint() { local pattern='^([a-z]+)(\(([a-zA-Z0-9_@/,*-]+)\))?(!)?: (.+)$' if [[ ! "$first_line" =~ $pattern ]]; then - _err "commits MUST be prefixed with a type, followed by a colon and space" + _err header-format _hint "Expected: [()][!]: " return 1 fi @@ -687,13 +727,13 @@ cmd_lint() { local breaking="${BASH_REMATCH[4]}" description="${BASH_REMATCH[5]}" if [[ ! -v CFG_TYPES["$type"] ]]; then - _err "unknown type '$type'" + _err type-enum "$type" _hint "Allowed types: ${!CFG_TYPES[*]}" return 1 fi [[ -z "$description" || "$description" =~ ^[[:space:]]*$ ]] && { - _err "description MUST immediately follow the colon and space" + _err description-empty return 1 } @@ -706,7 +746,7 @@ cmd_lint() { fi [[ "$breaking_footer" == "true" && -n "$breaking" && ! "$message" =~ BREAKING[\ -]CHANGE: ]] && { - _err "if included in the type/scope prefix, breaking changes MUST be indicated by a BREAKING CHANGE footer" + _err breaking-footer return 1 } @@ -757,13 +797,13 @@ validate_paths_if_needed() { fi if [[ "$require_scope" == "true" && -z "$scope" ]]; then - _err "scope required" + _err scope-required return 1 fi if [[ "$defined_scope" == "true" && -n "$scope" ]]; then [[ ${#CFG_SCOPES[@]} -eq 0 ]] && { - _err "scope '$scope' used but no scopes defined in config" + _err scope-missing-config "$scope" return 1 } if is_multi_scope "$scope"; then @@ -782,7 +822,7 @@ validate_paths_if_needed() { if [[ -z "$scope" ]]; then if ! validate_strict_no_scope "${files[@]}"; then - _err "scope required for scoped files" + _err scope-file-required _show_errors "${STRICT_MATCHES[@]}" _hint "Hint: add a scope that matches these files" return 1 @@ -794,13 +834,13 @@ validate_paths_if_needed() { if is_multi_scope "$scope"; then [[ "$multi_scope" != "true" ]] && { - _err "multi-scope not enabled" + _err multi-scope-disabled _hint "Use --multi-scope flag or set multi-scope = true in [settings]" return 1 } _check_scopes_exist "$scope" || return 1 if ! validate_files_against_scopes "$scope" "${files[@]}"; then - _err "files do not match scopes '$scope'" + _err files-scopes-mismatch "$scope" _show_errors "${VALIDATION_ERRORS[@]}" return 1 fi @@ -812,7 +852,7 @@ validate_paths_if_needed() { return 1 } if ! validate_files_against_scope "$scope" "${files[@]}"; then - _err "files do not match scope '$scope'" + _err files-scope-mismatch "$scope" _show_errors "${VALIDATION_ERRORS[@]}" return 1 fi @@ -831,7 +871,7 @@ _scope_input_custom() { _gum input --header "Enter scope:" --placeholder "e.g., cmd_create() { if ! command -v gum &>/dev/null; then - _err "gum is required for interactive mode" + _err gum-not-found _hint "See: https://github.com/charmbracelet/gum" exit 1 fi @@ -905,7 +945,7 @@ cmd_create() { local description="" while [[ -z "$description" ]]; do description=$(_gum input --header "Description (required):" --placeholder "Short summary of the change") - [[ -z "$description" ]] && _err "description MUST NOT be empty" + [[ -z "$description" ]] && _err description-required done local body @@ -916,7 +956,7 @@ cmd_create() { local breaking_explanation="" while [[ -z "$breaking_explanation" ]]; do breaking_explanation=$(_gum write --header "Breaking change explanation (required):" --placeholder "Describe what breaks and how to migrate...") - [[ -z "$breaking_explanation" ]] && _err "breaking change explanation is required" + [[ -z "$breaking_explanation" ]] && _err breaking-explanation done footer="BREAKING CHANGE: $breaking_explanation" else diff --git a/scripts/cz/cmd_create.sh b/scripts/cz/cmd_create.sh index 92b65e6..ed2bd2e 100644 --- a/scripts/cz/cmd_create.sh +++ b/scripts/cz/cmd_create.sh @@ -17,7 +17,7 @@ _scope_input_custom() { _gum input --header "Enter scope:" --placeholder "e.g., cmd_create() { # Check gum dependency if ! command -v gum &>/dev/null; then - _err "gum is required for interactive mode" + _err gum-not-found _hint "See: https://github.com/charmbracelet/gum" exit 1 fi @@ -104,7 +104,7 @@ cmd_create() { local description="" while [[ -z "$description" ]]; do description=$(_gum input --header "Description (required):" --placeholder "Short summary of the change") - [[ -z "$description" ]] && _err "description MUST NOT be empty" + [[ -z "$description" ]] && _err description-required done # Get body (optional) @@ -117,7 +117,7 @@ cmd_create() { local breaking_explanation="" while [[ -z "$breaking_explanation" ]]; do breaking_explanation=$(_gum write --header "Breaking change explanation (required):" --placeholder "Describe what breaks and how to migrate...") - [[ -z "$breaking_explanation" ]] && _err "breaking change explanation is required" + [[ -z "$breaking_explanation" ]] && _err breaking-explanation done footer="BREAKING CHANGE: $breaking_explanation" else diff --git a/scripts/cz/cmd_hook.sh b/scripts/cz/cmd_hook.sh index 9a06379..e0191c8 100644 --- a/scripts/cz/cmd_hook.sh +++ b/scripts/cz/cmd_hook.sh @@ -11,7 +11,7 @@ cmd_hook() { # Find git directory local git_dir git_dir="$(git rev-parse --git-dir 2>/dev/null)" || { - _err "not a git repository" + _err not-git-repo return 1 } @@ -29,7 +29,7 @@ cmd_hook() { hook_status "$hook_path" "$hook_marker" ;; *) - _err "unknown hook action '$action'" + _err hook-action-unknown "$action" _hint "Usage: cz hook [install|uninstall|status]" return 2 ;; @@ -45,7 +45,7 @@ hook_install() { echo "cz: hook already installed" return 0 else - _err "existing commit-msg hook found" + _err hook-exists _hint "Remove it manually or add 'cz lint < \"\$1\"' to it" return 1 fi @@ -80,7 +80,7 @@ hook_uninstall() { fi if ! grep -q "$hook_marker" "$hook_path" 2>/dev/null; then - _err "commit-msg hook was not installed by cz" + _err hook-foreign return 1 fi diff --git a/scripts/cz/cmd_init.sh b/scripts/cz/cmd_init.sh index 34847a3..ea5893e 100644 --- a/scripts/cz/cmd_init.sh +++ b/scripts/cz/cmd_init.sh @@ -18,7 +18,7 @@ cmd_init() { # Write to file if [[ -f "$OUTPUT_FILE" && -z "${FORCE:-}" ]]; then - _err "'$OUTPUT_FILE' already exists (use -f to overwrite)" + _err file-exists "$OUTPUT_FILE" return 1 fi diff --git a/scripts/cz/cmd_lint.sh b/scripts/cz/cmd_lint.sh index 7225374..1e51d71 100644 --- a/scripts/cz/cmd_lint.sh +++ b/scripts/cz/cmd_lint.sh @@ -8,7 +8,7 @@ . ./path_validator.sh _scope_err() { - _err "unknown scope '$1'" + _err scope-enum "$1" _hint "Defined scopes: ${!CFG_SCOPES[*]}" } _show_errors() { @@ -38,7 +38,7 @@ cmd_lint() { message="$(cat)" [[ -z "$message" ]] && { - _err "empty commit message" + _err empty-message return 1 } @@ -52,7 +52,7 @@ cmd_lint() { if [[ ! "$first_line" =~ $pattern ]]; then # Spec #1: commits MUST be prefixed with a type, followed by colon and space - _err "commits MUST be prefixed with a type, followed by a colon and space" + _err header-format _hint "Expected: [()][!]: " return 1 fi @@ -62,14 +62,14 @@ cmd_lint() { # Validate type if [[ ! -v CFG_TYPES["$type"] ]]; then - _err "unknown type '$type'" + _err type-enum "$type" _hint "Allowed types: ${!CFG_TYPES[*]}" return 1 fi # Spec #5: description MUST immediately follow the colon and space [[ -z "$description" || "$description" =~ ^[[:space:]]*$ ]] && { - _err "description MUST immediately follow the colon and space" + _err description-empty return 1 } @@ -87,7 +87,7 @@ cmd_lint() { # Spec #16: BREAKING-CHANGE MUST be synonymous with BREAKING CHANGE # Spec #15: BREAKING CHANGE MUST be uppercase [[ "$breaking_footer" == "true" && -n "$breaking" && ! "$message" =~ BREAKING[\ -]CHANGE: ]] && { - _err "if included in the type/scope prefix, breaking changes MUST be indicated by a BREAKING CHANGE footer" + _err breaking-footer return 1 } @@ -150,14 +150,14 @@ validate_paths_if_needed() { # -r: require scope to be present if [[ "$require_scope" == "true" && -z "$scope" ]]; then - _err "scope required" + _err scope-required return 1 fi # -d: validate scope exists in config (if scope provided) if [[ "$defined_scope" == "true" && -n "$scope" ]]; then [[ ${#CFG_SCOPES[@]} -eq 0 ]] && { - _err "scope '$scope' used but no scopes defined in config" + _err scope-missing-config "$scope" return 1 } if is_multi_scope "$scope"; then @@ -179,7 +179,7 @@ validate_paths_if_needed() { # No scope provided - check if files match any pattern if [[ -z "$scope" ]]; then if ! validate_strict_no_scope "${files[@]}"; then - _err "scope required for scoped files" + _err scope-file-required _show_errors "${STRICT_MATCHES[@]}" _hint "Hint: add a scope that matches these files" return 1 @@ -193,14 +193,14 @@ validate_paths_if_needed() { # Multi-scope validation if is_multi_scope "$scope"; then [[ "$multi_scope" != "true" ]] && { - _err "multi-scope not enabled" + _err multi-scope-disabled _hint "Use --multi-scope flag or set multi-scope = true in [settings]" return 1 } # Validate scopes exist _check_scopes_exist "$scope" || return 1 if ! validate_files_against_scopes "$scope" "${files[@]}"; then - _err "files do not match scopes '$scope'" + _err files-scopes-mismatch "$scope" _show_errors "${VALIDATION_ERRORS[@]}" return 1 fi @@ -213,7 +213,7 @@ validate_paths_if_needed() { return 1 } if ! validate_files_against_scope "$scope" "${files[@]}"; then - _err "files do not match scope '$scope'" + _err files-scope-mismatch "$scope" _show_errors "${VALIDATION_ERRORS[@]}" return 1 fi diff --git a/scripts/cz/config.sh b/scripts/cz/config.sh index 171e478..3b18804 100644 --- a/scripts/cz/config.sh +++ b/scripts/cz/config.sh @@ -42,7 +42,7 @@ find_config() { load_config() { if [[ -n "$CONFIG_FILE" ]]; then if [[ ! -f "$CONFIG_FILE" ]]; then - _err "config file not found: $CONFIG_FILE" + _err config-not-found "$CONFIG_FILE" exit 1 fi diff --git a/scripts/cz/error_codes.sh b/scripts/cz/error_codes.sh new file mode 100644 index 0000000..e5f986c --- /dev/null +++ b/scripts/cz/error_codes.sh @@ -0,0 +1,31 @@ +# shellcheck shell=bash + +# Error code registry - centralized error messages for cz +# Each code maps to a printf format string +# Usage: _err [args...] + +# shellcheck disable=SC2034 # ERR_CODES is used by _err in helpers.sh +declare -gA ERR_CODES=( + ["empty-message"]="empty commit message" + ["header-format"]="commits MUST be prefixed with a type, followed by a colon and space" + ["type-enum"]="unknown type '%s'" + ["description-empty"]="description MUST immediately follow the colon and space" + ["body-leading-blank"]="body MUST begin one blank line after the description" + ["breaking-footer"]="if included in the type/scope prefix, breaking changes MUST be indicated by a BREAKING CHANGE footer" + ["scope-required"]="scope required" + ["scope-enum"]="unknown scope '%s'" + ["scope-missing-config"]="scope '%s' used but no scopes defined in config" + ["scope-file-required"]="scope required for scoped files" + ["multi-scope-disabled"]="multi-scope not enabled" + ["files-scope-mismatch"]="files do not match scope '%s'" + ["files-scopes-mismatch"]="files do not match scopes '%s'" + ["config-not-found"]="config file not found: %s" + ["gum-not-found"]="gum is required for interactive mode" + ["description-required"]="description MUST NOT be empty" + ["breaking-explanation"]="breaking change explanation is required" + ["file-exists"]="'%s' already exists (use -f to overwrite)" + ["not-git-repo"]="not a git repository" + ["hook-action-unknown"]="unknown hook action '%s'" + ["hook-exists"]="existing commit-msg hook found" + ["hook-foreign"]="commit-msg hook was not installed by cz" +) diff --git a/scripts/cz/helpers.sh b/scripts/cz/helpers.sh index a3e1979..41a75a8 100644 --- a/scripts/cz/helpers.sh +++ b/scripts/cz/helpers.sh @@ -2,8 +2,23 @@ # Shared helper functions for cz +# @bundle source +. ./error_codes.sh + # Output helpers - respect QUIET flag -_err() { [[ -n "${QUIET:-}" ]] || echo "cz: error: $1" >&2; } +_err() { + [[ -n "${QUIET:-}" ]] && return + local code="$1" + shift + local msg="${ERR_CODES[$code]}" + if [[ -n "$msg" ]]; then + # shellcheck disable=SC2059 # intentional printf format from registry + printf -v msg "$msg" "$@" + echo "cz: error: $msg [$code]" >&2 + else + echo "cz: error: $code $* [unknown]" >&2 + fi +} _hint() { [[ -n "${QUIET:-}" ]] || echo "$1" >&2; } # Trim leading/trailing whitespace from variable diff --git a/scripts/cz/main_spec.sh b/scripts/cz/main_spec.sh index d7cca5c..3f2475e 100644 --- a/scripts/cz/main_spec.sh +++ b/scripts/cz/main_spec.sh @@ -106,6 +106,7 @@ Describe 'cz' When run script "$BIN" lint The status should be failure The stderr should include "empty commit message" + The stderr should include "[empty-message]" End It 'rejects whitespace-only message' @@ -113,6 +114,7 @@ Describe 'cz' When run script "$BIN" lint The status should be failure The stderr should include "commits MUST be prefixed with a type" + The stderr should include "[header-format]" End It 'rejects missing colon' @@ -120,6 +122,7 @@ Describe 'cz' When run script "$BIN" lint The status should be failure The stderr should include "commits MUST be prefixed with a type" + The stderr should include "[header-format]" End It 'rejects missing description after colon' @@ -127,6 +130,7 @@ Describe 'cz' When run script "$BIN" lint The status should be failure The stderr should include "commits MUST be prefixed with a type" + The stderr should include "[header-format]" End It 'rejects missing description after colon and space' @@ -134,6 +138,7 @@ Describe 'cz' When run script "$BIN" lint The status should be failure The stderr should include "commits MUST be prefixed with a type" + The stderr should include "[header-format]" End It 'rejects unknown type' @@ -141,6 +146,7 @@ Describe 'cz' When run script "$BIN" lint The status should be failure The stderr should include "unknown type" + The stderr should include "[type-enum]" End It 'rejects uppercase type' @@ -148,6 +154,7 @@ Describe 'cz' When run script "$BIN" lint The status should be failure The stderr should include "commits MUST be prefixed with a type" + The stderr should include "[header-format]" End It 'rejects breaking ! without BREAKING CHANGE footer' @@ -155,6 +162,7 @@ Describe 'cz' When run script "$BIN" lint The status should be failure The stderr should include "BREAKING CHANGE footer" + The stderr should include "[breaking-footer]" End It 'rejects empty scope' @@ -162,6 +170,7 @@ Describe 'cz' When run script "$BIN" lint The status should be failure The stderr should include "commits MUST be prefixed with a type" + The stderr should include "[header-format]" End It 'rejects whitespace-only description' @@ -169,6 +178,7 @@ Describe 'cz' When run script "$BIN" lint The status should be failure The stderr should include "description MUST immediately follow the colon and space" + The stderr should include "[description-empty]" End End @@ -203,6 +213,7 @@ EOF When run script "$BIN" lint The status should be failure The stderr should include "unknown type" + The stderr should include "[type-enum]" End It 'uses --config-file option' @@ -231,6 +242,7 @@ EOF The status should be failure The stderr should include "config file not found" The stderr should include "nonexistent.ini" + The stderr should include "[config-not-found]" End It 'handles config with only scopes (uses default types)' @@ -342,6 +354,7 @@ EOF When run script "$BIN" -e lint --paths "src/ui/button.tsx" The status should be failure The stderr should include "does not match scope" + The stderr should include "[files-scope-mismatch]" End It 'fails when some files do not match scope' @@ -395,6 +408,7 @@ EOF When run script "$BIN" -e lint --paths "scripts/nested/main.sh" The status should be failure The stderr should include "does not match scope" + The stderr should include "[files-scope-mismatch]" End It 'matches multi-pattern scope (first pattern)' @@ -473,6 +487,7 @@ EOF When run script "$BIN" -e lint --paths "src/api/x.go" The status should be failure The stderr should include "multi-scope not enabled" + The stderr should include "[multi-scope-disabled]" End It 'rejects multi-scope by default when validating files' @@ -488,6 +503,7 @@ EOF When run script "$BIN" -e lint --paths "src/api/x.go" The status should be failure The stderr should include "multi-scope not enabled" + The stderr should include "[multi-scope-disabled]" End It 'rejects multi-scope with unknown scope' @@ -505,6 +521,7 @@ EOF When run script "$BIN" -e lint --paths "src/api/x.go" The status should be failure The stderr should include "unknown scope" + The stderr should include "[scope-enum]" End It '--multi-scope flag enables multi-scope without config' @@ -537,6 +554,7 @@ EOF When run script "$BIN" --no-multi-scope -e lint --paths "src/api/x.go" The status should be failure The stderr should include "multi-scope not enabled" + The stderr should include "[multi-scope-disabled]" End It '-m shorthand enables multi-scope' @@ -580,6 +598,7 @@ EOF When run script "$BIN" -m -e lint --paths "src/api/handler.go src/other/file.txt" The status should be failure The stderr should include "does not match" + The stderr should include "[files-scopes-mismatch]" End End @@ -599,6 +618,7 @@ EOF When run script "$BIN" -d lint The status should be failure The stderr should include "unknown scope" + The stderr should include "[scope-enum]" End It 'accepts defined scope when -d is set' @@ -636,6 +656,7 @@ EOF When run script "$BIN" -d lint The status should be failure The stderr should include "no scopes defined" + The stderr should include "[scope-missing-config]" End It '--no-defined-scope overrides config' @@ -669,6 +690,7 @@ EOF When run script "$BIN" lint The status should be failure The stderr should include "unknown scope" + The stderr should include "[scope-enum]" End End @@ -685,6 +707,7 @@ EOF When run script "$BIN" -r lint The status should be failure The stderr should include "scope required" + The stderr should include "[scope-required]" End It 'accepts scope when -r is set' @@ -746,6 +769,7 @@ EOF When run script "$BIN" -e lint --paths "src/ui/button.tsx" The status should be failure The stderr should include "does not match scope" + The stderr should include "[files-scope-mismatch]" End It 'passes when scope matches files with -e' @@ -759,6 +783,7 @@ EOF When run script "$BIN" -e lint --paths "src/api/handler.go" The status should be failure The stderr should include "scope required" + The stderr should include "[scope-file-required]" End It 'allows no scope for unscoped files when -e is set' @@ -772,6 +797,7 @@ EOF When run script "$BIN" -e lint --paths "other/file.txt" The status should be failure The stderr should include "unknown scope" + The stderr should include "[scope-enum]" End It '-e with unknown scope shows defined scopes hint' @@ -779,6 +805,7 @@ EOF When run script "$BIN" -e lint --paths "src/api/x.go" The status should be failure The stderr should include "unknown scope" + The stderr should include "[scope-enum]" The stderr should include "api" The stderr should include "ui" End @@ -814,6 +841,7 @@ EOF When run script "$BIN" lint --paths "src/ui/button.tsx" The status should be failure The stderr should include "does not match scope" + The stderr should include "[files-scope-mismatch]" End It 'config enforce-patterns without defined-scope rejects unknown scope' @@ -831,6 +859,7 @@ EOF When run script "$BIN" lint --paths "src/api/x.go" The status should be failure The stderr should include "unknown scope" + The stderr should include "[scope-enum]" End End @@ -840,6 +869,7 @@ EOF When run script "$BIN" lint The status should be failure The stderr should include "if included in the type/scope prefix, breaking changes MUST be indicated by a BREAKING CHANGE footer" + The stderr should include "[breaking-footer]" End It 'allows breaking change without footer when --no-breaking-footer is set' @@ -853,6 +883,7 @@ EOF When run script "$BIN" --breaking-footer lint The status should be failure The stderr should include "if included in the type/scope prefix, breaking changes MUST be indicated by a BREAKING CHANGE footer" + The stderr should include "[breaking-footer]" End It 'accepts breaking change with footer when --breaking-footer is set' @@ -900,6 +931,7 @@ EOF When run script "$BIN" --breaking-footer lint The status should be failure The stderr should include "if included in the type/scope prefix, breaking changes MUST be indicated by a BREAKING CHANGE footer" + The stderr should include "[breaking-footer]" End It 'has no effect on non-breaking commits' @@ -937,6 +969,7 @@ EOF When run script "$BIN" lint The status should be failure The stderr should include "BREAKING CHANGE" + The stderr should include "[breaking-footer]" End End @@ -1025,6 +1058,7 @@ EOF When run script "$BIN" init -o .gitcommitizen The status should be failure The stderr should include "already exists" + The stderr should include "[file-exists]" End It '-f overwrites existing file' @@ -1112,6 +1146,7 @@ EOF When run script "$BIN" hook install The status should be failure The stderr should include "existing commit-msg hook" + The stderr should include "[hook-exists]" End End @@ -1136,6 +1171,7 @@ EOF When run script "$BIN" hook uninstall The status should be failure The stderr should include "not installed by cz" + The stderr should include "[hook-foreign]" End End @@ -1145,6 +1181,7 @@ EOF When run script "$BIN" hook status The status should be failure The stderr should include "not a git repository" + The stderr should include "[not-git-repo]" End It 'install errors outside git repo' @@ -1152,6 +1189,7 @@ EOF When run script "$BIN" hook install The status should be failure The stderr should include "not a git repository" + The stderr should include "[not-git-repo]" End It 'uninstall errors outside git repo' @@ -1159,6 +1197,7 @@ EOF When run script "$BIN" hook uninstall The status should be failure The stderr should include "not a git repository" + The stderr should include "[not-git-repo]" End End @@ -1166,6 +1205,7 @@ EOF When run script "$BIN" hook unknown The status should be failure The stderr should include "unknown" + The stderr should include "[hook-action-unknown]" End End @@ -1261,6 +1301,7 @@ EOF The status should be failure The stderr should include "config file not found" The stderr should include "/nonexistent/config" + The stderr should include "[config-not-found]" End End @@ -1276,6 +1317,7 @@ EOF When run script "$BIN" create The status should be failure The stderr should include "gum is required" + The stderr should include "[gum-not-found]" End End @@ -1930,6 +1972,7 @@ MOCK When run script "$BIN" The status should be failure The stderr should include "commits MUST be prefixed with a type" + The stderr should include "[header-format]" End End @@ -1997,6 +2040,7 @@ EOF When run script "$BIN" --defined-scope lint The status should be failure The stderr should include "unknown scope" + The stderr should include "[scope-enum]" The stderr should include "api" The stderr should include "ui" End @@ -2025,6 +2069,7 @@ EOF When run script "$BIN" lint The status should be failure The stderr should include "description MUST immediately follow the colon and space" + The stderr should include "[description-empty]" End It 'handles config keys with hyphens correctly' From 3b01121883d48b0975a51545f3c1f40821907f35 Mon Sep 17 00:00:00 2001 From: vabatta <2137077+vabatta@users.noreply.github.com> Date: Mon, 9 Mar 2026 01:24:17 +0100 Subject: [PATCH 6/9] test(cz): check only error codes in stderr assertions Remove redundant message text checks from stderr assertions, keeping only the [error-code] bracket checks. Value-specific checks (filenames, scope names) are preserved. --- scripts/cz/main_spec.sh | 45 ----------------------------------------- 1 file changed, 45 deletions(-) diff --git a/scripts/cz/main_spec.sh b/scripts/cz/main_spec.sh index 3f2475e..8709981 100644 --- a/scripts/cz/main_spec.sh +++ b/scripts/cz/main_spec.sh @@ -105,7 +105,6 @@ Describe 'cz' Data "" When run script "$BIN" lint The status should be failure - The stderr should include "empty commit message" The stderr should include "[empty-message]" End @@ -113,7 +112,6 @@ Describe 'cz' Data " " When run script "$BIN" lint The status should be failure - The stderr should include "commits MUST be prefixed with a type" The stderr should include "[header-format]" End @@ -121,7 +119,6 @@ Describe 'cz' Data "feat add feature" When run script "$BIN" lint The status should be failure - The stderr should include "commits MUST be prefixed with a type" The stderr should include "[header-format]" End @@ -129,7 +126,6 @@ Describe 'cz' Data "feat:" When run script "$BIN" lint The status should be failure - The stderr should include "commits MUST be prefixed with a type" The stderr should include "[header-format]" End @@ -137,7 +133,6 @@ Describe 'cz' Data "feat: " When run script "$BIN" lint The status should be failure - The stderr should include "commits MUST be prefixed with a type" The stderr should include "[header-format]" End @@ -145,7 +140,6 @@ Describe 'cz' Data "unknown: some change" When run script "$BIN" lint The status should be failure - The stderr should include "unknown type" The stderr should include "[type-enum]" End @@ -153,7 +147,6 @@ Describe 'cz' Data "FEAT: add feature" When run script "$BIN" lint The status should be failure - The stderr should include "commits MUST be prefixed with a type" The stderr should include "[header-format]" End @@ -161,7 +154,6 @@ Describe 'cz' Data "feat!: breaking change" When run script "$BIN" lint The status should be failure - The stderr should include "BREAKING CHANGE footer" The stderr should include "[breaking-footer]" End @@ -169,7 +161,6 @@ Describe 'cz' Data "feat(): add feature" When run script "$BIN" lint The status should be failure - The stderr should include "commits MUST be prefixed with a type" The stderr should include "[header-format]" End @@ -177,7 +168,6 @@ Describe 'cz' Data "feat: " When run script "$BIN" lint The status should be failure - The stderr should include "description MUST immediately follow the colon and space" The stderr should include "[description-empty]" End End @@ -212,7 +202,6 @@ EOF Data "feat: not in config" When run script "$BIN" lint The status should be failure - The stderr should include "unknown type" The stderr should include "[type-enum]" End @@ -240,7 +229,6 @@ EOF Data "feat: something" When run script "$BIN" --config-file nonexistent.ini lint The status should be failure - The stderr should include "config file not found" The stderr should include "nonexistent.ini" The stderr should include "[config-not-found]" End @@ -353,7 +341,6 @@ EOF Data "feat(api): add endpoint" When run script "$BIN" -e lint --paths "src/ui/button.tsx" The status should be failure - The stderr should include "does not match scope" The stderr should include "[files-scope-mismatch]" End @@ -407,7 +394,6 @@ EOF Data "feat(scripts): update script" When run script "$BIN" -e lint --paths "scripts/nested/main.sh" The status should be failure - The stderr should include "does not match scope" The stderr should include "[files-scope-mismatch]" End @@ -486,7 +472,6 @@ EOF Data "feat(api,ui): cross-cutting change" When run script "$BIN" -e lint --paths "src/api/x.go" The status should be failure - The stderr should include "multi-scope not enabled" The stderr should include "[multi-scope-disabled]" End @@ -502,7 +487,6 @@ EOF Data "feat(api,ui): cross-cutting change" When run script "$BIN" -e lint --paths "src/api/x.go" The status should be failure - The stderr should include "multi-scope not enabled" The stderr should include "[multi-scope-disabled]" End @@ -520,7 +504,6 @@ EOF Data "feat(api,unknown): change" When run script "$BIN" -e lint --paths "src/api/x.go" The status should be failure - The stderr should include "unknown scope" The stderr should include "[scope-enum]" End @@ -553,7 +536,6 @@ EOF Data "feat(api,ui): cross-cutting change" When run script "$BIN" --no-multi-scope -e lint --paths "src/api/x.go" The status should be failure - The stderr should include "multi-scope not enabled" The stderr should include "[multi-scope-disabled]" End @@ -597,7 +579,6 @@ EOF Data "feat(api,ui): cross-cutting change" When run script "$BIN" -m -e lint --paths "src/api/handler.go src/other/file.txt" The status should be failure - The stderr should include "does not match" The stderr should include "[files-scopes-mismatch]" End End @@ -617,7 +598,6 @@ EOF Data "feat(unknown): add feature" When run script "$BIN" -d lint The status should be failure - The stderr should include "unknown scope" The stderr should include "[scope-enum]" End @@ -655,7 +635,6 @@ EOF Data "feat(anything): add feature" When run script "$BIN" -d lint The status should be failure - The stderr should include "no scopes defined" The stderr should include "[scope-missing-config]" End @@ -689,7 +668,6 @@ EOF Data "feat(unknown): add feature" When run script "$BIN" lint The status should be failure - The stderr should include "unknown scope" The stderr should include "[scope-enum]" End End @@ -706,7 +684,6 @@ EOF Data "feat: add feature" When run script "$BIN" -r lint The status should be failure - The stderr should include "scope required" The stderr should include "[scope-required]" End @@ -768,7 +745,6 @@ EOF Data "feat(api): add endpoint" When run script "$BIN" -e lint --paths "src/ui/button.tsx" The status should be failure - The stderr should include "does not match scope" The stderr should include "[files-scope-mismatch]" End @@ -782,7 +758,6 @@ EOF Data "feat: add feature" When run script "$BIN" -e lint --paths "src/api/handler.go" The status should be failure - The stderr should include "scope required" The stderr should include "[scope-file-required]" End @@ -796,7 +771,6 @@ EOF Data "feat(unknown): add feature" When run script "$BIN" -e lint --paths "other/file.txt" The status should be failure - The stderr should include "unknown scope" The stderr should include "[scope-enum]" End @@ -804,7 +778,6 @@ EOF Data "feat(unknown): add feature" When run script "$BIN" -e lint --paths "src/api/x.go" The status should be failure - The stderr should include "unknown scope" The stderr should include "[scope-enum]" The stderr should include "api" The stderr should include "ui" @@ -840,7 +813,6 @@ EOF Data "feat(api): add endpoint" When run script "$BIN" lint --paths "src/ui/button.tsx" The status should be failure - The stderr should include "does not match scope" The stderr should include "[files-scope-mismatch]" End @@ -858,7 +830,6 @@ EOF Data "feat(unknown): add feature" When run script "$BIN" lint --paths "src/api/x.go" The status should be failure - The stderr should include "unknown scope" The stderr should include "[scope-enum]" End End @@ -868,7 +839,6 @@ EOF Data "feat!: breaking change" When run script "$BIN" lint The status should be failure - The stderr should include "if included in the type/scope prefix, breaking changes MUST be indicated by a BREAKING CHANGE footer" The stderr should include "[breaking-footer]" End @@ -882,7 +852,6 @@ EOF Data "feat!: breaking change" When run script "$BIN" --breaking-footer lint The status should be failure - The stderr should include "if included in the type/scope prefix, breaking changes MUST be indicated by a BREAKING CHANGE footer" The stderr should include "[breaking-footer]" End @@ -930,7 +899,6 @@ EOF Data "feat!: breaking change" When run script "$BIN" --breaking-footer lint The status should be failure - The stderr should include "if included in the type/scope prefix, breaking changes MUST be indicated by a BREAKING CHANGE footer" The stderr should include "[breaking-footer]" End @@ -968,7 +936,6 @@ EOF End When run script "$BIN" lint The status should be failure - The stderr should include "BREAKING CHANGE" The stderr should include "[breaking-footer]" End End @@ -1057,7 +1024,6 @@ EOF touch .gitcommitizen When run script "$BIN" init -o .gitcommitizen The status should be failure - The stderr should include "already exists" The stderr should include "[file-exists]" End @@ -1145,7 +1111,6 @@ EOF printf '#!/bin/sh\necho "other"\n' > .git/hooks/commit-msg When run script "$BIN" hook install The status should be failure - The stderr should include "existing commit-msg hook" The stderr should include "[hook-exists]" End End @@ -1170,7 +1135,6 @@ EOF printf '#!/bin/sh\necho "other"\n' > .git/hooks/commit-msg When run script "$BIN" hook uninstall The status should be failure - The stderr should include "not installed by cz" The stderr should include "[hook-foreign]" End End @@ -1180,7 +1144,6 @@ EOF rm -rf .git When run script "$BIN" hook status The status should be failure - The stderr should include "not a git repository" The stderr should include "[not-git-repo]" End @@ -1188,7 +1151,6 @@ EOF rm -rf .git When run script "$BIN" hook install The status should be failure - The stderr should include "not a git repository" The stderr should include "[not-git-repo]" End @@ -1196,7 +1158,6 @@ EOF rm -rf .git When run script "$BIN" hook uninstall The status should be failure - The stderr should include "not a git repository" The stderr should include "[not-git-repo]" End End @@ -1204,7 +1165,6 @@ EOF It 'errors with unknown subcommand' When run script "$BIN" hook unknown The status should be failure - The stderr should include "unknown" The stderr should include "[hook-action-unknown]" End End @@ -1299,7 +1259,6 @@ EOF It 'fails if explicit config file does not exist' When run script "$BIN" --config-file /nonexistent/config parse The status should be failure - The stderr should include "config file not found" The stderr should include "/nonexistent/config" The stderr should include "[config-not-found]" End @@ -1316,7 +1275,6 @@ EOF command -v gum &>/dev/null && Skip "gum is installed system-wide" When run script "$BIN" create The status should be failure - The stderr should include "gum is required" The stderr should include "[gum-not-found]" End End @@ -1971,7 +1929,6 @@ MOCK Data "invalid message" When run script "$BIN" The status should be failure - The stderr should include "commits MUST be prefixed with a type" The stderr should include "[header-format]" End End @@ -2039,7 +1996,6 @@ EOF Data "feat(unknown): add feature" When run script "$BIN" --defined-scope lint The status should be failure - The stderr should include "unknown scope" The stderr should include "[scope-enum]" The stderr should include "api" The stderr should include "ui" @@ -2068,7 +2024,6 @@ EOF Data "feat: " When run script "$BIN" lint The status should be failure - The stderr should include "description MUST immediately follow the colon and space" The stderr should include "[description-empty]" End From bf837f2e4cf0901a8447051a1fbd22efc804392f Mon Sep 17 00:00:00 2001 From: vabatta <2137077+vabatta@users.noreply.github.com> Date: Mon, 9 Mar 2026 01:25:32 +0100 Subject: [PATCH 7/9] fix(cz): enforce blank line between subject and body per spec The Conventional Commits spec requires: "body MUST begin one blank line after the description." Single-line messages (no body) are unaffected. Closes #75. --- dist/cz/bin/cz | 6 +++++ scripts/cz/cmd_lint.sh | 8 +++++++ scripts/cz/main_spec.sh | 50 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+) diff --git a/dist/cz/bin/cz b/dist/cz/bin/cz index ea893a1..3dd99e4 100755 --- a/dist/cz/bin/cz +++ b/dist/cz/bin/cz @@ -737,6 +737,12 @@ cmd_lint() { return 1 } + local rest="${message#*$'\n'}" + [[ "$rest" != "$message" && "${rest%%$'\n'*}" != "" ]] && { + _err body-leading-blank + return 1 + } + if [[ "${BREAKING_FOOTER-unset}" == "1" ]]; then breaking_footer=true elif [[ "${BREAKING_FOOTER-unset}" == "" ]]; then diff --git a/scripts/cz/cmd_lint.sh b/scripts/cz/cmd_lint.sh index 1e51d71..cb1193f 100644 --- a/scripts/cz/cmd_lint.sh +++ b/scripts/cz/cmd_lint.sh @@ -73,6 +73,14 @@ cmd_lint() { return 1 } + # Validate blank line after subject when body/footer present + # Spec: "body MUST begin one blank line after the description" + local rest="${message#*$'\n'}" + [[ "$rest" != "$message" && "${rest%%$'\n'*}" != "" ]] && { + _err body-leading-blank + return 1 + } + # Determine breaking-footer mode if [[ "${BREAKING_FOOTER-unset}" == "1" ]]; then breaking_footer=true diff --git a/scripts/cz/main_spec.sh b/scripts/cz/main_spec.sh index 8709981..a461c3f 100644 --- a/scripts/cz/main_spec.sh +++ b/scripts/cz/main_spec.sh @@ -97,6 +97,56 @@ Describe 'cz' End End + #─────────────────────────────────────────────────────────── + # Body blank line (spec: body MUST begin one blank line) + #─────────────────────────────────────────────────────────── + Describe 'body blank line' + It 'accepts single-line message (no body)' + Data "docs: correct spelling of CHANGELOG" + When run script "$BIN" lint + The status should be success + End + + It 'accepts message with blank line before body' + Data + #|fix: prevent racing of requests + #| + #|Introduce a request id and a reference to latest request. Dismiss + #|incoming responses other than from latest request. + End + When run script "$BIN" lint + The status should be success + End + + It 'rejects body without blank line separator' + Data + #|fix: prevent racing of requests + #|Introduce a request id and a reference to latest request. + End + When run script "$BIN" lint + The status should be failure + The stderr should include "[body-leading-blank]" + End + + It 'rejects footer without blank line separator' + Data + #|feat: allow provided config object to extend other configs + #|BREAKING CHANGE: `extends` key in config file is now used for extending other config files + End + When run script "$BIN" lint + The status should be failure + The stderr should include "[body-leading-blank]" + End + + It 'accepts message with only a trailing newline' + Data + #|docs: correct spelling of CHANGELOG + End + When run script "$BIN" lint + The status should be success + End + End + #─────────────────────────────────────────────────────────── # Invalid conventional commits #─────────────────────────────────────────────────────────── From 4a93d0224a1ee4f55bc3562e7994a1437027e882 Mon Sep 17 00:00:00 2001 From: vabatta <2137077+vabatta@users.noreply.github.com> Date: Mon, 9 Mar 2026 01:28:29 +0100 Subject: [PATCH 8/9] chore(cz): exclude error code registry from kcov coverage --- dist/cz/bin/cz | 2 ++ scripts/cz/error_codes.sh | 2 ++ 2 files changed, 4 insertions(+) diff --git a/dist/cz/bin/cz b/dist/cz/bin/cz index 3dd99e4..3adc126 100755 --- a/dist/cz/bin/cz +++ b/dist/cz/bin/cz @@ -220,6 +220,7 @@ GETOPTIONSHERE # --- begin: scripts/cz/error_codes.sh --- +# @start-kcov-exclude - static data table declare -gA ERR_CODES=( ["empty-message"]="empty commit message" ["header-format"]="commits MUST be prefixed with a type, followed by a colon and space" @@ -244,6 +245,7 @@ declare -gA ERR_CODES=( ["hook-exists"]="existing commit-msg hook found" ["hook-foreign"]="commit-msg hook was not installed by cz" ) +# @end-kcov-exclude # --- end: scripts/cz/error_codes.sh --- _err() { diff --git a/scripts/cz/error_codes.sh b/scripts/cz/error_codes.sh index e5f986c..9e43150 100644 --- a/scripts/cz/error_codes.sh +++ b/scripts/cz/error_codes.sh @@ -5,6 +5,7 @@ # Usage: _err [args...] # shellcheck disable=SC2034 # ERR_CODES is used by _err in helpers.sh +# @start-kcov-exclude - static data table declare -gA ERR_CODES=( ["empty-message"]="empty commit message" ["header-format"]="commits MUST be prefixed with a type, followed by a colon and space" @@ -29,3 +30,4 @@ declare -gA ERR_CODES=( ["hook-exists"]="existing commit-msg hook found" ["hook-foreign"]="commit-msg hook was not installed by cz" ) +# @end-kcov-exclude From 3e5955685a733cd3204d6980691da5e843770e13 Mon Sep 17 00:00:00 2001 From: vabatta <2137077+vabatta@users.noreply.github.com> Date: Mon, 9 Mar 2026 01:44:00 +0100 Subject: [PATCH 9/9] fix(cz): use exact character class for BREAKING CHANGE separator --- dist/cz/bin/cz | 2 +- scripts/cz/cmd_lint.sh | 2 +- scripts/cz/main_spec.sh | 11 +++++++++++ 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/dist/cz/bin/cz b/dist/cz/bin/cz index 3adc126..a92dd28 100755 --- a/dist/cz/bin/cz +++ b/dist/cz/bin/cz @@ -753,7 +753,7 @@ cmd_lint() { breaking_footer="${CFG_SETTINGS[breaking_footer]:-true}" fi - [[ "$breaking_footer" == "true" && -n "$breaking" && ! "$message" =~ BREAKING[\ -]CHANGE: ]] && { + [[ "$breaking_footer" == "true" && -n "$breaking" && ! "$message" =~ BREAKING[-\ ]CHANGE: ]] && { _err breaking-footer return 1 } diff --git a/scripts/cz/cmd_lint.sh b/scripts/cz/cmd_lint.sh index cb1193f..000b065 100644 --- a/scripts/cz/cmd_lint.sh +++ b/scripts/cz/cmd_lint.sh @@ -94,7 +94,7 @@ cmd_lint() { # be indicated by a BREAKING CHANGE footer # Spec #16: BREAKING-CHANGE MUST be synonymous with BREAKING CHANGE # Spec #15: BREAKING CHANGE MUST be uppercase - [[ "$breaking_footer" == "true" && -n "$breaking" && ! "$message" =~ BREAKING[\ -]CHANGE: ]] && { + [[ "$breaking_footer" == "true" && -n "$breaking" && ! "$message" =~ BREAKING[-\ ]CHANGE: ]] && { _err breaking-footer return 1 } diff --git a/scripts/cz/main_spec.sh b/scripts/cz/main_spec.sh index a461c3f..dca6a36 100644 --- a/scripts/cz/main_spec.sh +++ b/scripts/cz/main_spec.sh @@ -978,6 +978,17 @@ EOF The status should be success End + It 'rejects BREAKING!CHANGE as footer (not a valid separator)' + Data + #|feat!: breaking change + #| + #|BREAKING!CHANGE: this is breaking + End + When run script "$BIN" lint + The status should be failure + The stderr should include "[breaking-footer]" + End + It 'rejects lowercase breaking change footer' Data #|feat!: breaking change