Skip to content

Instantly share code, notes, and snippets.

@DeeprajPandey
Last active March 27, 2025 18:04
Show Gist options
  • Save DeeprajPandey/738647ac224d62e98f014344e584b46f to your computer and use it in GitHub Desktop.
Save DeeprajPandey/738647ac224d62e98f014344e584b46f to your computer and use it in GitHub Desktop.
Bash-based Conventional Commit Hook: A lightweight, dependency-free git hook for enforcing Conventional Commits format. Features intelligent suggestions, breaking change support, and body/footer validation.

Git Conventional Commit Hook

A lightweight, zero-dependency Git hook that enforces the Conventional Commits format across all your repositories.

What is it?

This tool enforces consistent commit messages following the Conventional Commits specification. It runs as a Git commit-msg hook with:

  • Zero Dependencies: Pure Bash implementation - works everywhere Git does (No Node.js, Python, or external dependencies)
  • Global Installation: Works across all repositories automatically
  • Smart Validation: Provides helpful suggestions for invalid messages
  • Full Specification Support: Types, scopes, breaking changes with ! notation, body and footer validation
  • Customisable Types: Configure your own commit types via a simple configuration file

Installation

# Download and run the installer
curl -o git-conventional-commit-hook-installer.sh https://gist.githubusercontent.com/DeeprajPandey/738647ac224d62e98f014344e584b46f/raw/git-conventional-commit-hook-installer.sh
chmod +x git-conventional-commit-hook-installer.sh
./git-conventional-commit-hook-installer.sh

# Or in one command
curl -s https://gist.githubusercontent.com/DeeprajPandey/738647ac224d62e98f014344e584b46f/raw/git-conventional-commit-hook-installer.sh | bash

Usage

After installation, the hook is automatically activated for:

  • All newly created repositories
  • Existing repositories after running git init

Valid Commit Message Format

<type>[optional scope][!]: <description>

[optional body]

[optional footer(s)]

Commit Types

The default commit types are:

  • 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

Examples

Simple commits:

feat: add user authentication
fix(auth): correct token validation
docs: update installation instructions
feat!: redesign public API
refactor(core)!: simplify error handling

Commit with body and footer:

feat(api)!: add OAuth2 authentication

This implements OAuth2 authentication with support
for multiple providers including Google and GitHub.

BREAKING CHANGE: replaces the legacy auth system
Closes: #123
Reviewed-by: @username

Customisation

You can customise the commit types by editing ~/.conventional-commit-config:

# Custom types - format: "type:description"
TYPES=(
  "feat:A new feature"
  "fix:A bug fix"
  "docs:Documentation only changes"
  # Add your own types here
  "security:Security-related changes"
)

Uninstallation

curl -o git-conventional-commit-hook-uninstaller.sh https://gist.githubusercontent.com/DeeprajPandey/738647ac224d62e98f014344e584b46f/raw/git-conventional-commit-hook-uninstaller.sh
chmod +x git-conventional-commit-hook-uninstaller.sh
./git-conventional-commit-hook-uninstaller.sh

# Or in one command
curl -s https://gist.githubusercontent.com/DeeprajPandey/738647ac224d62e98f014344e584b46f/raw/git-conventional-commit-hook-uninstaller.sh | bash

How It Works

The hook works by:

  1. Intercepting commit messages before they're saved
  2. Validating them against the Conventional Commits format
  3. Providing helpful suggestions when the format is incorrect

It uses Git's template directory feature to install the hook globally for all repositories.

Manual Updates (if you update the hook script after installing)

If you update the hook script and want to apply it to existing repositories:

# Remove the old hook
rm /path/to/repo/.git/hooks/commit-msg

# Run git init to install the new hook
cd /path/to/repo
git init

Compatibility

Tested on:

  • Bash 3.2+ (macOS)
  • Bash 4.0+ (Linux)
  • Zsh
  • Git 2.0+

License

MIT

#!/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
#!/usr/bin/env bash
#
# git-conventional-commit-hook: Uninstaller
#
# This script removes the git-conventional-commit-hook and its related
# configuration files from your system. It will not remove the hook from
# individual repositories where it has already been installed.
#
# Author: Deepraj Pandey
# Version: 1.2.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
}
# Remove the hook script
remove_hook() {
if [[ -f "${COMMIT_MSG_HOOK}" ]]; then
info "Removing conventional commit hook..."
rm -f "${COMMIT_MSG_HOOK}"
success "Hook removed successfully"
else
info "Hook not found at ${COMMIT_MSG_HOOK}"
fi
}
# Remove configuration
remove_config() {
if [[ -f "${CONFIG_FILE}" ]]; then
info "Found configuration file at ${CONFIG_FILE}"
read -p "Do you want to remove the configuration file? (y/N) " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
rm -f "${CONFIG_FILE}"
success "Configuration file removed"
else
info "Configuration file preserved"
fi
else
info "No configuration file found at ${CONFIG_FILE}"
fi
}
# Reset Git configuration
reset_git_config() {
current_template=$(git config --global --get init.templateDir || echo "")
if [[ "${current_template}" == "${HOME}/.git-templates" ]]; then
info "Resetting Git template directory configuration"
git config --global --unset init.templateDir
success "Git configuration reset"
else
if [[ -n "${current_template}" ]]; then
info "Git template directory is set to: ${current_template}"
info "It was not set by this script, leaving it unchanged"
else
info "Git template directory is not configured"
fi
fi
}
# Clean up empty directories
cleanup_directories() {
if [[ -d "${HOOKS_DIR}" ]]; then
# Check if the hooks directory is empty
if [[ -z "$(ls -A "${HOOKS_DIR}")" ]]; then
info "Removing empty hooks directory"
rmdir "${HOOKS_DIR}" 2>/dev/null || true
else
info "Hooks directory contains other files, not removing"
fi
fi
# Try to remove parent directory if empty
templates_dir="${HOME}/.git-templates"
if [[ -d "${templates_dir}" && -z "$(ls -A "${templates_dir}")" ]]; then
info "Removing empty templates directory"
rmdir "${templates_dir}" 2>/dev/null || true
fi
}
# Main function
main() {
echo -e "${BLUE}=== Conventional Commit Hook Uninstall ===${NC}\n"
remove_hook
remove_config
reset_git_config
cleanup_directories
echo -e "\n${GREEN}Uninstall complete!${NC}"
echo -e "\n${YELLOW}Note:${NC} For existing repositories where the hook was activated,"
echo "the hook is still present in the .git/hooks directory of each repository."
echo "To remove those, you need to manually delete the file:"
echo " rm /path/to/repo/.git/hooks/commit-msg"
}
# Execute main function
main
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment