Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 13 additions & 4 deletions bin/commit-msg
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,15 @@ 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
if command -v jq &>/dev/null; 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

Expand All @@ -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:]'
Expand All @@ -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:]]*$//')
Expand All @@ -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
Expand Down
56 changes: 56 additions & 0 deletions tests/test_commit_msg.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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" \
Expand Down