|
#!/usr/bin/env bash |
|
# |
|
# git-conventional-commit-hook: Zero-dependency Git hook to enforce Conventional Commits |
|
# |
|
# This script installs a global git commit-msg hook that validates all commit |
|
# messages against the Conventional Commits specification (https://www.conventionalcommits.org/). |
|
# Works across all repositories without external dependencies. |
|
# |
|
# Author: Deepraj Pandey |
|
# 25 Feb 2024 |
|
# Version: 1.7.0 |
|
# License: MIT |
|
|
|
set -eo pipefail |
|
|
|
# Constants |
|
readonly HOOKS_DIR="${HOME}/.git-templates/hooks" |
|
readonly COMMIT_MSG_HOOK="${HOOKS_DIR}/commit-msg" |
|
readonly CONFIG_FILE="${HOME}/.conventional-commit-config" |
|
readonly RED='\033[0;31m' |
|
readonly GREEN='\033[0;32m' |
|
readonly YELLOW='\033[0;33m' |
|
readonly BLUE='\033[0;34m' |
|
readonly NC='\033[0m' # No Color |
|
|
|
# Print styled messages |
|
info() { |
|
echo -e "${BLUE}INFO:${NC} $1" >&2 |
|
} |
|
|
|
success() { |
|
echo -e "${GREEN}SUCCESS:${NC} $1" >&2 |
|
} |
|
|
|
warning() { |
|
echo -e "${YELLOW}WARNING:${NC} $1" >&2 |
|
} |
|
|
|
error() { |
|
echo -e "${RED}ERROR:${NC} $1" >&2 |
|
exit 1 |
|
} |
|
|
|
# Create directory structure |
|
create_directories() { |
|
info "Creating hook directory: ${HOOKS_DIR}" |
|
mkdir -p "${HOOKS_DIR}" |
|
} |
|
|
|
# Install the hook script |
|
install_hook() { |
|
info "Installing conventional commit hook..." |
|
|
|
cat > "${COMMIT_MSG_HOOK}" << 'HOOK_SCRIPT' |
|
#!/usr/bin/env bash |
|
# |
|
# Pre-commit hook for enforcing Conventional Commits format |
|
# https://www.conventionalcommits.org/ |
|
# |
|
# Author: Deepraj Pandey |
|
# Version: 1.0.0 |
|
# License: MIT |
|
|
|
set -eo pipefail |
|
|
|
# Constants |
|
readonly COMMIT_MSG_FILE="$1" |
|
readonly CONFIG_FILE="${HOME}/.conventional-commit-config" |
|
readonly RED='\033[0;31m' |
|
readonly GREEN='\033[0;32m' |
|
readonly YELLOW='\033[0;33m' |
|
readonly BLUE='\033[0;34m' |
|
readonly NC='\033[0m' # No Color |
|
|
|
# Default types |
|
DEFAULT_TYPES=( |
|
"feat:A new feature" |
|
"fix:A bug fix" |
|
"docs:Documentation only changes" |
|
"style:Changes that do not affect the meaning of the code" |
|
"refactor:A code change that neither fixes a bug nor adds a feature" |
|
"perf:A code change that improves performance" |
|
"test:Adding missing tests or correcting existing tests" |
|
"build:Changes that affect the build system or external dependencies" |
|
"ci:Changes to CI configuration files and scripts" |
|
"chore:Other changes that don't modify src or test files" |
|
"revert:Reverts a previous commit" |
|
) |
|
|
|
# Load custom configuration if exists |
|
TYPES=("${DEFAULT_TYPES[@]}") |
|
if [[ -f "$CONFIG_FILE" ]]; then |
|
# shellcheck source=/dev/null |
|
source "$CONFIG_FILE" |
|
fi |
|
|
|
# Function to print error message and exit |
|
error_exit() { |
|
echo -e "${RED}ERROR:${NC} $1" >&2 |
|
exit 1 |
|
} |
|
|
|
# Function to print warning |
|
warning() { |
|
echo -e "${YELLOW}WARNING:${NC} $1" >&2 |
|
} |
|
|
|
# Function to print info |
|
info() { |
|
echo -e "${BLUE}INFO:${NC} $1" >&2 |
|
} |
|
|
|
# Function to print success |
|
success() { |
|
echo -e "${GREEN}SUCCESS:${NC} $1" >&2 |
|
} |
|
|
|
# Function to display help |
|
show_help() { |
|
echo -e "\n${BLUE}Conventional Commit Format Guide${NC}" |
|
echo -e "Required format: <type>[optional scope][!]: <description>\n" |
|
echo -e "[optional body]\n" |
|
echo -e "[optional footer(s)]" |
|
echo -e " ! indicates a breaking change\n" |
|
|
|
echo -e "${BLUE}Valid types:${NC}" |
|
for type_info in "${TYPES[@]}"; do |
|
IFS=":" read -r type description <<< "$type_info" |
|
printf " %-10s - %s\n" "$type" "$description" |
|
done |
|
|
|
echo -e "\n${BLUE}Examples:${NC}" |
|
echo " feat: add user authentication" |
|
echo " fix(auth): correct token validation" |
|
echo " feat!: add new API with breaking changes" |
|
echo " refactor(core)!: completely restructure error handling" |
|
|
|
echo -e "\n${BLUE}Full example with body and footer:${NC}" |
|
echo " feat(api)!: add new authentication endpoint" |
|
echo "" |
|
echo " This new endpoint allows for OAuth2 authentication with various" |
|
echo " providers and includes comprehensive validation." |
|
echo "" |
|
echo " BREAKING CHANGE: replaces the old basic auth endpoint" |
|
echo " Reviewed-by: Alice" |
|
echo " Refs: #123" |
|
} |
|
|
|
# Function to suggest a commit message |
|
suggest_commit_message() { |
|
local message="$1" |
|
local first_line |
|
first_line=$(echo "$message" | head -n 1) |
|
|
|
# Simple heuristic to guess type |
|
local suggested_type="chore" |
|
if echo "$first_line" | grep -qi 'fix\|bug\|issue\|error\|crash\|resolve'; then |
|
suggested_type="fix" |
|
elif echo "$first_line" | grep -qi 'add\|new\|feature\|implement\|support'; then |
|
suggested_type="feat" |
|
elif echo "$first_line" | grep -qi 'test\|spec\|assert'; then |
|
suggested_type="test" |
|
elif echo "$first_line" | grep -qi 'doc\|comment\|readme'; then |
|
suggested_type="docs" |
|
elif echo "$first_line" | grep -qi 'refactor\|clean\|restructure\|rewrite'; then |
|
suggested_type="refactor" |
|
elif echo "$first_line" | grep -qi 'performance\|speed\|optimize\|optimise'; then |
|
suggested_type="perf" |
|
elif echo "$first_line" | grep -qi 'build\|dependency\|package'; then |
|
suggested_type="build" |
|
elif echo "$first_line" | grep -qi 'ci\|pipeline\|workflow'; then |
|
suggested_type="ci" |
|
elif echo "$first_line" | grep -qi 'revert\|rollback\|undo'; then |
|
suggested_type="revert" |
|
fi |
|
|
|
# Check for scope |
|
local scope="" |
|
if echo "$first_line" | grep -qi 'in\|for\|on\|to the\|related to'; then |
|
# Try to extract scope from context |
|
for context in $(echo "$first_line" | grep -o -E '(in|for|on|to the|related to) [a-zA-Z0-9_-]+' | cut -d' ' -f2-); do |
|
if [[ -n "$context" && ${#context} -lt 20 ]]; then |
|
scope="($context)" |
|
break |
|
fi |
|
done |
|
fi |
|
|
|
# Check for breaking changes |
|
local breaking="" |
|
if echo "$first_line" | grep -qi 'break\|breaking\|major\|incompatible\|migration'; then |
|
breaking="!" |
|
fi |
|
|
|
# Format message - make sure this works in all POSIX shells |
|
local description |
|
description=$(echo "$first_line" | sed -E 's/^(Fix|Add|Update|Remove|Change|Implement|Refactor|Test|Document|Build|Revert)[[:space:]]*:?[[:space:]]*//') |
|
# Make first letter lowercase using tr instead of bash-specific features |
|
first_char=$(echo "${description:0:1}" | tr '[:upper:]' '[:lower:]') |
|
rest_of_desc="${description:1}" |
|
description="$first_char$rest_of_desc" |
|
description=$(echo "$description" | sed 's/^[[:space:]]*//') # Trim leading whitespace |
|
|
|
echo -e "\n${BLUE}Suggested message:${NC}" |
|
echo " $suggested_type$scope$breaking: $description" |
|
} |
|
|
|
# Main function to validate commit message |
|
validate_commit_message() { |
|
local commit_msg |
|
commit_msg=$(cat "$COMMIT_MSG_FILE") |
|
|
|
# Skip validation for merge commits |
|
if echo "$commit_msg" | grep -q "^Merge branch"; then |
|
info "Skipping validation for merge commit" |
|
exit 0 |
|
fi |
|
|
|
# Build the regex pattern from the defined types |
|
local types_regex="" |
|
for type_info in "${TYPES[@]}"; do |
|
IFS=":" read -r type _ <<< "$type_info" |
|
if [[ -n "$types_regex" ]]; then |
|
types_regex="$types_regex|$type" |
|
else |
|
types_regex="$type" |
|
fi |
|
done |
|
|
|
# Conventional Commit pattern with full format support |
|
# format: <type>[optional scope][optional !]: <description> |
|
# |
|
# [optional body] |
|
# |
|
# [optional footer(s)] |
|
local pattern="^($types_regex)(\([a-zA-Z0-9_-]+\))?(!)?:[[:space:]].{1,}" |
|
|
|
if ! echo "$commit_msg" | grep -qE "$pattern"; then |
|
echo -e "${RED}ERROR: Commit message does not follow Conventional Commits format.${NC}" |
|
echo -e "Your message: \"$commit_msg\"\n" |
|
|
|
show_help |
|
suggest_commit_message "$commit_msg" |
|
|
|
exit 1 |
|
fi |
|
|
|
success "Commit message follows the Conventional Commits format" |
|
} |
|
|
|
# Execute main function |
|
validate_commit_message |
|
HOOK_SCRIPT |
|
|
|
chmod +x "${COMMIT_MSG_HOOK}" |
|
success "Hook script installed successfully" |
|
} |
|
|
|
# Create sample configuration |
|
create_config() { |
|
if [[ ! -f "${CONFIG_FILE}" ]]; then |
|
info "Creating sample configuration file at ${CONFIG_FILE}" |
|
|
|
cat > "${CONFIG_FILE}" << 'CONFIG' |
|
# Conventional Commit Configuration |
|
# Uncomment and modify to customise your commit types |
|
|
|
# Custom types - format: "type:description" |
|
# TYPES=( |
|
# "feat:A new feature" |
|
# "fix:A bug fix" |
|
# "docs:Documentation only changes" |
|
# "style:Changes that do not affect the meaning of the code" |
|
# "refactor:A code change that neither fixes a bug nor adds a feature" |
|
# "perf:A code change that improves performance" |
|
# "test:Adding missing tests or correcting existing tests" |
|
# "build:Changes that affect the build system or external dependencies" |
|
# "ci:Changes to CI configuration files and scripts" |
|
# "chore:Other changes that don't modify src or test files" |
|
# "revert:Reverts a previous commit" |
|
# "security:Security-related changes" |
|
# ) |
|
|
|
# Skip validation for certain repositories |
|
# SKIP_REPOS=( |
|
# "legacy-project" |
|
# "vendor-code" |
|
# ) |
|
CONFIG |
|
|
|
success "Sample configuration file created" |
|
else |
|
info "Configuration file already exists at ${CONFIG_FILE}" |
|
fi |
|
} |
|
|
|
# Configure Git to use the template directory |
|
configure_git() { |
|
info "Configuring Git to use template directory" |
|
git config --global init.templateDir "${HOME}/.git-templates" |
|
success "Git global configuration updated" |
|
} |
|
|
|
# Main function |
|
main() { |
|
echo -e "${BLUE}=== Conventional Commit Hook Setup ===${NC}\n" |
|
|
|
create_directories |
|
install_hook |
|
create_config |
|
configure_git |
|
|
|
echo -e "\n${GREEN}Setup complete!${NC} The conventional commit hook is now globally installed." |
|
echo -e "\n${BLUE}Usage:${NC}" |
|
echo "• For existing repositories, run: git init" |
|
echo "• New repositories will automatically use the hook" |
|
echo "• To customise commit types, edit: ${CONFIG_FILE}" |
|
echo -e "\n${BLUE}Example valid commit messages:${NC}" |
|
echo "• feat: add user authentication" |
|
echo "• fix(auth): correct token validation" |
|
echo "• feat!: add new API with breaking changes" |
|
echo "• docs(readme): update installation instructions" |
|
} |
|
|
|
# Execute main function |
|
main |