diff --git a/bin/commit-msg b/bin/commit-msg index e10d38b..86e7dc7 100755 --- a/bin/commit-msg +++ b/bin/commit-msg @@ -22,6 +22,7 @@ done ALLOWED_TYPES="feat fix chore docs style refactor test ci perf build" MAX_SUBJECT_LENGTH=72 AUTO_CORRECT=true +REQUIRE_SCOPE=false # Parse config if available (simple JSON parsing with grep/sed) if [[ -n "$CONFIG_FILE" ]]; then @@ -29,6 +30,7 @@ if [[ -n "$CONFIG_FILE" ]]; then ALLOWED_TYPES=$(jq -r '.types // empty | join(" ")' "$CONFIG_FILE" 2>/dev/null || echo "$ALLOWED_TYPES") MAX_SUBJECT_LENGTH=$(jq -r '.maxSubjectLength // empty' "$CONFIG_FILE" 2>/dev/null || echo "$MAX_SUBJECT_LENGTH") AUTO_CORRECT=$(jq -r '.autoCorrect // empty' "$CONFIG_FILE" 2>/dev/null || echo "$AUTO_CORRECT") + REQUIRE_SCOPE=$(jq -r '.requireScope // empty' "$CONFIG_FILE" 2>/dev/null || echo "$REQUIRE_SCOPE") fi fi @@ -47,8 +49,8 @@ SUBJECT=$(echo "$SUBJECT" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') # Collapse multiple spaces SUBJECT=$(echo "$SUBJECT" | sed 's/[[:space:]]\+/ /g') -# Pattern: type(scope): description OR type: description -CONVENTIONAL_RE='^([a-zA-Z]+)(\([a-zA-Z0-9_. /-]*\))?[[:space:]]*:[[:space:]]*(.*)' +# Pattern: type(scope)!: description OR type!: description OR type(scope): description OR type: description +CONVENTIONAL_RE='^([a-zA-Z]+)(\([a-zA-Z0-9_. /-]*\))?(!)?\s*:\s*(.*)' normalize_type() { echo "$1" | tr '[:upper:]' '[:lower:]' @@ -67,7 +69,8 @@ validate_type() { if [[ "$SUBJECT" =~ $CONVENTIONAL_RE ]]; then RAW_TYPE="${BASH_REMATCH[1]}" SCOPE="${BASH_REMATCH[2]}" - DESC="${BASH_REMATCH[3]}" + BREAKING="${BASH_REMATCH[3]}" + DESC="${BASH_REMATCH[4]}" TYPE=$(normalize_type "$RAW_TYPE") DESC=$(echo "$DESC" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') @@ -82,12 +85,18 @@ if [[ "$SUBJECT" =~ $CONVENTIONAL_RE ]]; then exit 1 fi + # Enforce requireScope + if [[ "$REQUIRE_SCOPE" == "true" && -z "$SCOPE" ]]; then + echo "ERROR: Scope is required. Use format: type(scope): description" >&2 + exit 1 + fi + # Lowercase first char of description if [[ "$AUTO_CORRECT" == "true" ]]; then DESC="$(echo "${DESC:0:1}" | tr '[:upper:]' '[:lower:]')${DESC:1}" fi - NORMALIZED="${TYPE}${SCOPE}: ${DESC}" + NORMALIZED="${TYPE}${SCOPE}${BREAKING}: ${DESC}" else if [[ "$AUTO_CORRECT" != "true" ]]; then echo "ERROR: Commit message does not follow conventional commits format." >&2 diff --git a/tests/test_commit_msg.sh b/tests/test_commit_msg.sh index c698cad..a60fe04 100755 --- a/tests/test_commit_msg.sh +++ b/tests/test_commit_msg.sh @@ -127,6 +127,62 @@ run_test "unknown type rejected" \ "" \ true +# Breaking change with ! (no scope) +run_test "breaking change without scope" \ + "feat!: drop legacy API" \ + "feat!: drop legacy API" + +# Breaking change with scope and ! +run_test "breaking change with scope" \ + "fix(api)!: remove deprecated endpoint" \ + "fix(api)!: remove deprecated endpoint" + +# Breaking change with uppercase type normalized +run_test "breaking change uppercase type" \ + "FEAT!: drop legacy API" \ + "feat!: drop legacy API" + +# Breaking change with scope and uppercase +run_test "breaking change scope uppercase" \ + "FIX(core)!: rewrite engine" \ + "fix(core)!: rewrite engine" + +# requireScope tests: create a temporary config with requireScope=true +SCOPE_CONFIG="$TEST_REPO/commit-normalize.config.json" +cat > "$SCOPE_CONFIG" <<'CFGEOF' +{ + "types": ["feat", "fix", "chore", "docs", "style", "refactor", "test", "ci", "perf", "build"], + "maxSubjectLength": 72, + "requireScope": true, + "autoCorrect": true +} +CFGEOF + +# With requireScope=true, commit without scope should fail +run_test "requireScope rejects missing scope" \ + "feat: add feature" \ + "" \ + true + +# With requireScope=true, commit with scope should pass +run_test "requireScope accepts scope" \ + "feat(ui): add button" \ + "feat(ui): add button" + +# With requireScope=true, breaking change with scope should pass +run_test "requireScope accepts breaking with scope" \ + "feat(api)!: drop v1" \ + "feat(api)!: drop v1" + +# With requireScope=true, breaking change without scope should fail +run_test "requireScope rejects breaking without scope" \ + "feat!: drop v1" \ + "" \ + true + +# Restore original config +cp "$SCRIPT_DIR/../commit-normalize.config.json" "$TEST_REPO/" + # All valid types for t in feat fix chore docs style refactor test ci perf build; do run_test "valid type: $t" \