Skip to content

Commit

Permalink
refactor: Introduce util files to make the bash scripts less verbose …
Browse files Browse the repository at this point in the history
…and more modular
  • Loading branch information
Colin23 committed Dec 29, 2024
1 parent 8d0adc1 commit 8943dbf
Show file tree
Hide file tree
Showing 7 changed files with 405 additions and 126 deletions.
13 changes: 9 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ This repository provides a set of Git hooks that enforce quality checks and best
## Purpose

This repository is designed to be used as a Git submodule in other projects, specifically
the [nullpunk](https://github.com/zufall-labs/nullpunkt) repository.
the [nullpunk](https://github.com/zufall-labs/nullpunkt) repository (although this is not a requirement).
When integrated, it automatically configures Git hooks for the repository to follow certain rules and checks.

## How it works
Expand All @@ -14,26 +14,29 @@ The key files in this repository are located under the `hooks/` directory.
Each script has a special name that Git knows.
A full list of them can be found there [Git Hook](https://git-scm.com/book/ms/v2/Customizing-Git-Git-Hooks).

The [setup-hooks.sh](scripts/setup-hooks) script configures Git to use the hooks from this directory,
The [setup-hooks](scripts/setup-hooks) script configures Git to use the hooks from this directory,
ensuring that any action made within the repository is checked according to the rules.
The script also configures Git to look for hooks in the `qa/hooks/` directory instead of the default `.git/hooks/`.
This depends on the value of the `qa_config_dir` variable at the start of the [setup-hooks](scripts/setup-hooks) script.
The scripts are also made executable, so that Git can actually work with them.
Git triggers each hook automatically, depending on the performed action.

## Customizing the workflow

The hooks in this repository are modular, so they can be easily extended as needed for other Git actions, like
pre-commit, post-commit, pre-push, etc. The scripts in the `qa/hooks/` directory can be added/modified, and they will
pre-commit, post-commit, pre-push, etc. The scripts in the `hooks/` directory can be added/modified, and they will
automatically be used by Git during the corresponding Git action.

## Overview

This setup results in roughly this architecture:

- The [setup-hooks.sh](scripts/setup-hooks) script configures Git and the hooks
- The [setup-hooks](scripts/setup-hooks) script configures Git and the hooks
- The `hooks/` directory contains scripts that Git automatically executes, depending on the performed action, and the
name of the script
- Each script in the `hooks/` directory contains logic specific to the action the file name correlates to
- The other scripts under the `utils/` directory contain separated and self-contained logic that can be reused where
needed

## Working with this repository

Expand All @@ -43,3 +46,5 @@ repository.
## Contribution

See [contributing](https://github.com/zufall-labs/.github/blob/main/CONTRIBUTING.md)
As for naming things, we are following Google's
conventions [document](https://google.github.io/styleguide/shellguide.html#s7-naming-conventions).
127 changes: 37 additions & 90 deletions hooks/commit-msg
Original file line number Diff line number Diff line change
@@ -1,93 +1,53 @@
#!/usr/bin/env bash
#
# commit-msg
#
# Description:
# This script validates the commit message format according to the Conventional Commit spec.
# It checks if the commit message file exists, validates the message format, checks if the referenced
# issue exists on GitHub, and validates any footers.
#
# Usage:
# This file shouldn't be used directly. Is is used automatically by git, if set up correctly (this is done by the 'setup-hooks' script).
#
# Requirements:
# - Git must be installed
# - The repository must be configured with a '.zflbs' file containing configuration values for GitHub.
# - The commit message should follow the Conventional Commit spec.
#

# Load config from .zflbs file
if [ -f "$(git rev-parse --show-toplevel)/.zflbs" ]; then
source "$(git rev-parse --show-toplevel)/.zflbs"
else
echo "Error: Configuration file .zflbs not found"
exit 1
fi

# Set defaults if not found in config
GITHUB_ORG=${GITHUB_ORG:-"zufall-labs"}
GITHUB_REPO=${GITHUB_REPO:-"your-repo"}
GITHUB_TOKEN=${GITHUB_TOKEN:-""}
# Get the root directory of the Git repository
REPO_ROOT=$(git rev-parse --show-toplevel)
readonly REPO_ROOT

# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
# Load util scripts from the utils/ directory
source "$REPO_ROOT/utils/logging"
source "$REPO_ROOT/utils/commit-validation"
source "$REPO_ROOT/utils/load-zflbs-config"

# Helper functions
colored_echo() {
echo -e "${1}${2}${NC}"
}
# Load config from '.zflbs' file
load_zflbs_config "$REPO_ROOT"

fail() {
colored_echo $RED "$1"
exit 1
}
# Set default GitHub values if not found in config
readonly GITHUB_ORG=${GITHUB_ORG:-"zufall-labs"}
readonly GITHUB_REPO=${GITHUB_REPO:-"your-repo"}
readonly GITHUB_TOKEN=${GITHUB_TOKEN:-""}

warn() {
colored_echo $YELLOW "Warning: $1"
}

# Read commit message
# Read commit message from the file passed as argument
msg_file="$1"
if [[ ! -f "$msg_file" ]]; then
fail "Error: Commit message file not found"
fi
msg=$(cat "$msg_file")

# Conventional Commit Regex Patterns
type_regex="^(feat|fix|docs|style|refactor|test|chore|perf|ci|build|revert|config|ux|ui|security|i18n|ops|dependencies|design)"
scope_regex="(\\([a-zA-Z0-9-]+\\))?"
breaking_change_regex="!?"
colon_space_regex=": "
description_regex=".{1,}"

# Combine to make full conventional commit regex pattern
commit_regex="${type_regex}${scope_regex}${breaking_change_regex}${colon_space_regex}${description_regex}"

# Footer patterns
footer_regex="^(BREAKING[ -]CHANGE|[A-Za-z-]+): "
breaking_change_footer_regex="^BREAKING[ -]CHANGE: "

# Check commit message format
if ! [[ $msg =~ $commit_regex ]]; then
fail "Invalid commit message format.\nExpected format: <type>(<scope>)!?: <description>\nExample: feat(auth): add OAuth support"
fi
# Validate commit message format
validate_commit_message "$msg"

# Validate issue references
issue_regex="#([0-9]+)"
if [[ $msg =~ $issue_regex ]]; then
issue_number=${BASH_REMATCH[1]}

api_url="https://api.github.com/repos/$GITHUB_ORG/$GITHUB_REPO/issues/$issue_number"

headers=()
if [[ -n "$GITHUB_TOKEN" ]]; then
headers+=(-H "Authorization: token $GITHUB_TOKEN")
fi

response=$(curl -s -w "%{http_code}" "${headers[@]}" "$api_url")
http_code=${response: -3}
body=${response:0:-3}

case $http_code in
200)
;;
404)
fail "Issue #$issue_number not found in $GITHUB_ORG/$GITHUB_REPO"
;;
403)
fail "API rate limit exceeded. Please set GITHUB_TOKEN"
;;
*)
fail "GitHub API error (HTTP $http_code): $body"
;;
esac
validate_issue_exists "$GITHUB_ORG" "$GITHUB_REPO" "$issue_number" "$issue_regex"
fi

# Validate footers
Expand All @@ -97,22 +57,9 @@ if [[ $msg =~ $footer_regex ]]; then
# Skip empty lines and description
[[ -z "$line" ]] && continue
[[ $line =~ $commit_regex ]] && continue

# Check if line is a valid footer
if [[ $line =~ $footer_regex ]]; then
# Special validation for BREAKING CHANGE footer
if [[ $line =~ $breaking_change_footer_regex ]]; then
if [[ ${#line} -lt 20 ]]; then
fail "BREAKING CHANGE footer must include a description"
fi
fi
elif [[ $line =~ ^[[:space:]]*$ ]]; then
continue # Skip blank lines
else
fail "Invalid footer format: '$line'\nFooters must be in the format: 'Type: Description'"
fi
done <<< "$msg"
validate_footer "$line"
done <<<"$msg"
fi

colored_echo $GREEN "✓ Commit message follows Conventional Commit spec"
exit 0
success "✓ Commit message follows Conventional Commit spec"
exit 0
88 changes: 56 additions & 32 deletions scripts/setup-hooks
Original file line number Diff line number Diff line change
@@ -1,60 +1,84 @@
#!/usr/bin/env bash
#
# setup-hooks
#
# Description:
# This script configures Git hooks for a repository. It checks if the repository is correctly set up,
# ensures that hooks are executable, and configures Git to use hooks from a specified directory.
# If the directory is not passed as an argument, it defaults to 'qa/hooks'.
#
# Usage:
# ./setup-hooks [optional-config-directory]
#
# Arguments:
# optional-config-directory Path to the directory containing hook scripts (default is 'qa/hooks').
#
# Requirements:
# - Git must be installed
# - The repository must be a Git repository
#
# Example:
# ./setup-hooks hooks # Uses 'hooks' as the hooks directory
#

# Enable strict error handling:
# - 'set -e' causes the script to exit immediately if any command fails (non-zero exit status).
# - 'set -u' causes the script to exit if any variable is used before being set.
# - 'set -o pipefail' ensures that the script exits if any command in a pipeline fails, not just the last one.
set -euo pipefail

echo "Starting the 'setup-hooks.sh' script"

echo "Checking if 'git' is installed"
if ! command -v git > /dev/null 2>&1; then
echo "Error: Git is required, but it is not installed." >&2
exit 1
# Default config directory for git hooks (can be overridden by passing a directory as an argument)
qa_config_dir="qa/hooks"
# If an argument is passed to the script, override the default 'qa/hooks' directory
if [ $# -gt 0 ]; then
qa_config_dir="$1"
fi
# Remove any trailing slashes from the directory path to avoid inconsistency
qa_config_dir="${qa_config_dir%/}"

echo "Checking if 'tput' is installed (for colored output)"
if ! command -v tput > /dev/null 2>&1; then
echo "Warning: tput is not installed. Colored output will be disabled."
TPUT_INSTALLED=false
else
TPUT_INSTALLED=true
echo "Starting the 'setup-hooks' script"

# Get the root directory of the Git repository
repo_root=$(git rev-parse --show-toplevel)
# Load util scripts from the utils/ directory
source "$repo_root/utils/logging"

echo "Checking if 'git' is installed"
if ! command -v git >/dev/null 2>&1; then
fail "Git is required, but it's not installed."
fi

echo "Checking if the current directory is a git repository"
if ! git rev-parse --git-dir > /dev/null 2>&1; then
echo "Error: not in a git repository" >&2
exit 1
if ! git rev-parse --git-dir >/dev/null 2>&1; then
fail "Not in a git repository"
fi

echo "Checking if the git hooks are already configured"
# Try to get the current hooks path; The command returns an error if it is not set, therefore the redirect and the empty string.
current_hooks_path=$(git config --get-all core.hooksPath 2>/dev/null || echo "")
if [ "$current_hooks_path" == "qa/hooks" ]; then
echo "Git hooks are already configured to use 'qa/hooks/' directory."
else
echo "Configuring Git to use hooks from 'qa/hooks/' directory..."
if [ ! -d "./qa/hooks" ]; then
echo "Error: 'qa/hooks' directory does not exist. Please check your repository setup." >&2
if [ "$current_hooks_path" == "$qa_config_dir" ]; then
success "Git hooks are already configured to use the $qa_config_dir directory."
exit 1
fi
git config core.hooksPath qa/hooks
echo "Git hooks path successfully configured."
else
echo "Configuring Git to use hooks from '$qa_config_dir' directory..."
if [ ! -d "./$qa_config_dir" ]; then
fail "$qa_config_dir directory does not exist. Please check your repository setup."
fi
git config core.hooksPath "$qa_config_dir"
echo "Git hooks path successfully configured."
fi

echo "Configuring Git to ignore changes to file permissions"
git config core.fileMode false
if [ -d "qa/.git" ] || [ -f "qa/.git" ]; then # handles both regular and submodule .git
echo "Configuring Git in the 'qa' submodule to ignore changes to file permissions"
(cd qa && git config core.fileMode false)
echo "Configuring Git in the 'qa' submodule to ignore changes to file permissions"
(cd qa && git config core.fileMode false)
fi

echo "Ensuring that hook scripts are executable..."
chmod +x ./qa/hooks/* || { echo "Error: Failed to set executable permissions for hook scripts." >&2; exit 1; }
chmod +x ./"$qa_config_dir"/* || {
fail "Failed to set executable permissions for hook scripts."
}
echo "Hook scripts are now executable."

if [ "$TPUT_INSTALLED" = true ]; then
echo "$(tput bold)$(tput setaf 2)✓ Git hooks installed successfully!$(tput sgr0)"
else
echo -e "\033[1;32m✓ Git hooks installed successfully!\033[0m"
fi
success "✓ Git hooks installed successfully!"
74 changes: 74 additions & 0 deletions utils/commit-validation
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
#!/usr/bin/env bash
#
# commit-validation
#
# Description:
# This script contains functions to validate commit messages according to the Conventional Commit spec.
# It uses regular expressions to validate the structure of the commit message and any footers.
#
# Usage:
# Source this script in other Bash scripts to use its functions.
#

# Conventional Commit Regex Patterns
readonly TYPE_REGEX="^(feat|fix|docs|style|refactor|test|chore|perf|ci|build|revert|config|ux|ui|security|i18n|ops|dependencies|design)"
readonly SCOPE_REGEX="(\\([a-zA-Z0-9-]+\\))?"
readonly BREAKING_CHANGE_REGEX="!?"
readonly COLON_SPACE_REGEX=": "
readonly DESCRIPTION_REGEX=".{1,}"

# Combine to make full conventional commit regex pattern
readonly COMMIT_REGEX="${TYPE_REGEX}${SCOPE_REGEX}${BREAKING_CHANGE_REGEX}${COLON_SPACE_REGEX}${DESCRIPTION_REGEX}"

# Footer patterns
readonly FOOTER_REGEX="^(BREAKING[ -]CHANGE|[A-Za-z-]+): "
readonly BREAKING_CHANGE_FOOTER_REGEX="^BREAKING[ -]CHANGE: "

##########################################
# Validate commit message format
#
# DESCRIPTION:
# This function validates the commit message to ensure it follows the Conventional Commit format.
#
# ARGUMENTS:
# msg - The commit message string to validate.
#
# RETURNS:
# Exits with a non-zero status if the message is invalid, otherwise returns successfully.
##########################################
function validate_commit_message() {
local msg="$1"
if ! [[ $msg =~ $COMMIT_REGEX ]]; then
fail "Invalid commit message format.\nExpected format: <type>(<scope>)!?: <description>\nExample: feat(auth): add OAuth support"
fi
}

##########################################
# Validate commit message footer format
#
# DESCRIPTION:
# This function validates the footer lines of a commit message.
# It checks that footers are formatted correctly and ensures that the "BREAKING CHANGE" footer
# includes a description if it's used.
#
# ARGUMENTS:
# footer - The footer string to validate.
#
# RETURNS:
# Exits with a non-zero status if the footer format is invalid, otherwise returns successfully.
##########################################
function validate_footer() {
local footer="$1"
# Check if line is a valid footer
if [[ $footer =~ $FOOTER_REGEX ]]; then
# Special validation for BREAKING CHANGE footer
if [[ $footer =~ $BREAKING_CHANGE_FOOTER_REGEX && ${#footer} -lt 20 ]]; then
fail "BREAKING CHANGE footer must include a description"
fi
# Skip blank lines
elif [[ $footer =~ ^[[:space:]]*$ ]]; then
return
else
fail "Invalid footer format: '$footer'\nFooters must be in the format: 'Type: Description'"
fi
}
Loading

0 comments on commit 8943dbf

Please sign in to comment.