Skip to content

Instantly share code, notes, and snippets.

@mt89vein
Last active June 22, 2026 18:28
Show Gist options
  • Select an option

  • Save mt89vein/ee1d87c8101fb29350ce2be2b1e6cf15 to your computer and use it in GitHub Desktop.

Select an option

Save mt89vein/ee1d87c8101fb29350ce2be2b1e6cf15 to your computer and use it in GitHub Desktop.
prepare-commit-msg git hook, that adds jira ticket number to commit scope
#!/bin/sh
# Commit message linter based on Conventional Commits
# https://www.conventionalcommits.org/en/v1.0.0/
more_info="More info: https://www.conventionalcommits.org/en/v1.0.0/"
# Load config (if available), fall back to defaults
HOOK_DIR=$(dirname "$0")
[ -f "$HOOK_DIR/config" ] && . "$HOOK_DIR/config"
: "${TASK_PREFIXES:=SSTV}"
: "${ALLOWED_TYPES:=feat|bug|ci|wip|fix|perf|refactor|docs|build|chore|revert|style|test}"
: "${MAX_SUBJECT_LENGTH:=90}"
: "${SCOPE_CHARS:=a-zA-Z0-9_./-}"
: "${SKIP_BRANCHES:=}"
# Skip processing on configured branches
if [ -n "$SKIP_BRANCHES" ]; then
BRANCH_NAME=$(git symbolic-ref --short HEAD 2>/dev/null)
if [ -n "$BRANCH_NAME" ]; then
for pattern in $SKIP_BRANCHES; do
case "$BRANCH_NAME" in
$pattern) echo "Skipped (branch '$BRANCH_NAME' matches '$pattern')"; exit 0 ;;
esac
done
fi
fi
msg=$(head -n1 "$1")
len=${#msg}
echo "---"
echo "$msg"
echo "---"
# ------ Length ------
if [ $len -gt $MAX_SUBJECT_LENGTH ]; then
echo "ERROR: Commit message is $len characters (max $MAX_SUBJECT_LENGTH)"
echo " $msg"
echo "$more_info"
exit 1
fi
# ------ Extract and validate type ------
commit_type=$(echo "$msg" | sed 's/^\([a-z]*\).*/\1/')
if ! echo "$commit_type" | grep -Eq "^($ALLOWED_TYPES)$"; then
echo "ERROR: Invalid type '$commit_type'"
echo " Allowed types: $ALLOWED_TYPES"
echo " Format: type[(scope)][!]: subject"
echo " Example: feat: add user login"
echo "$more_info"
exit 1
fi
# ------ Parse rest after type ------
rest="${msg#$commit_type}"
case "$rest" in
\(*)
# type(something)...
scope=$(echo "$rest" | sed -n -E "s/^\(([$SCOPE_CHARS]+)\).*/\1/p")
if [ -z "$scope" ]; then
echo "ERROR: Invalid characters in scope"
echo " Allowed: letters, digits, _, ., /, -"
echo " Found: $(echo "$rest" | sed 's/^\(([^)]*)\).*/\1/')"
echo " Example: feat(${TASK_PREFIXES%% *}-1234): subject"
exit 1
fi
# Validate scope against task prefix
prefix_ok=false
for p in $TASK_PREFIXES; do
if echo "$scope" | grep -q "^${p}-"; then
prefix_ok=true; break
fi
done
if [ "$prefix_ok" = false ]; then
echo "ERROR: Scope '$scope' must start with a task prefix [$TASK_PREFIXES]"
echo " Example: feat(${TASK_PREFIXES%% *}-1234): subject"
exit 1
fi
# Check for breaking indicator and colon
after_scope="${rest#\($scope\)}"
case "$after_scope" in
"!:"*)
# type(scope)!: subject — valid
subject="${after_scope#!:}"
;;
":"*)
# type(scope): subject — valid
subject="${after_scope#:}"
;;
*)
echo "ERROR: Expected ':' or '!:' after scope '$scope'"
echo " Found: '${after_scope:0:20}'"
echo " Example: feat(${TASK_PREFIXES%% *}-1234): subject or feat(${TASK_PREFIXES%% *}-1234)!: subject"
exit 1
;;
esac
;;
!:*)
# type!: subject — breaking without scope, valid
subject="${rest#!:}"
;;
:*)
# type: subject — standard, valid
subject="${rest#:}"
;;
*)
echo "ERROR: Missing ': ' after commit type"
echo " Found after type '$commit_type': '${rest:0:30}'"
echo " Expected: '[(scope)][!]: subject'"
echo " Example: feat: add user login"
echo "$more_info"
exit 1
;;
esac
# ------ Validate subject ------
# Trim leading space (from ": " or "!: " separator)
subject="${subject# }"
if [ -z "$subject" ]; then
echo "ERROR: Subject is empty"
echo " Format: type[(scope)][!]: <subject>"
echo " Example: feat: add user login"
exit 1
fi
if echo "$subject" | grep -q '\.$'; then
echo "ERROR: Subject ends with a period (.)"
echo " Subject: '$subject'"
echo " Remove the trailing dot."
exit 1
fi
if echo "$subject" | grep -q '[[:space:]]$'; then
echo "ERROR: Subject ends with whitespace"
echo " Subject: '$subject'"
exit 1
fi
exit 0
# Hook configuration
# Edit this file to customize hook behavior for this repository.
# Changes are version-controlled — commit them to share with the team.
# Space-separated list of allowed task (Jira) prefixes for scope validation
# Example: TASK_PREFIXES="SSTV PROJ"
TASK_PREFIXES="SSTV"
# List of allowed Conventional Commit types (| separated, for use in grep -E)
ALLOWED_TYPES="feat|bug|ci|wip|fix|perf|refactor|docs|build|chore|revert|style|test"
# Maximum subject line length (characters)
MAX_SUBJECT_LENGTH=90
# Default commit type when none is detected in the message
FALLBACK_COMMIT_TYPE="chore"
# Filter string for `dotnet test`
TEST_FILTER="TestCategory=UnitTests"
# File extensions to check for formatting and diff (| separated, for grep -E)
# Example: FILE_PATTERNS="\.cs$|\.vb$|\.fs$"
FILE_PATTERNS="\.cs$"
# Regex pattern for extracting ticket ID from branch name (for grep -Eoi)
# Jira: "[A-Z]+-[0-9]+" GitHub: "#[0-9]+" Custom: "ABC[0-9]+"
TICKET_PATTERN="[A-Z]+-[0-9]+"
# Regex group for allowed characters in commit scope (inserted inside [...]+)
SCOPE_CHARS="a-zA-Z0-9_./-"
# Space-separated list of branch globs to skip hook processing entirely
# SKIP_BRANCHES="main master release/*"
# Allow push when tests leave uncommitted changes (e.g. golden/snapshot files)
# Set to "true" to warn instead of blocking the push
ALLOW_DIRTY_PUSH="false"
#!/bin/sh
# Load config (if available), fall back to defaults
HOOK_DIR=$(dirname "$0")
[ -f "$HOOK_DIR/config" ] && . "$HOOK_DIR/config"
: "${TEST_FILTER:=TestCategory=UnitTests}"
: "${FILE_PATTERNS:=\.cs$}"
: "${SKIP_BRANCHES:=}"
: "${ALLOW_DIRTY_PUSH:=false}"
# If deleting a branch, skip all checks
STDIN=$(cat -)
if echo "$STDIN" | grep -q "^(delete)"; then
echo "(delete) found, skipping pre-push hook"
exit 0
fi
# Determine changed .cs files in the current branch (not yet pushed)
if git rev-parse @{upstream} >/dev/null 2>&1; then
CHANGED_FILES=$(git diff --name-only @{upstream}..HEAD | grep -E "$FILE_PATTERNS" | sort -u)
else
# No upstream — diff against empty tree (handles initial/single-commit branches)
CHANGED_FILES=$(git diff --name-only 4b825dc642cb6eb9a060e54bf899d153036ddaa7..HEAD | grep -E "$FILE_PATTERNS" | sort -u)
fi
# Skip processing on configured branches
if [ -n "$SKIP_BRANCHES" ]; then
BRANCH_NAME=$(git symbolic-ref --short HEAD 2>/dev/null)
if [ -n "$BRANCH_NAME" ]; then
for pattern in $SKIP_BRANCHES; do
case "$BRANCH_NAME" in
$pattern) echo "Skipped (branch '$BRANCH_NAME' matches '$pattern')"; exit 0 ;;
esac
done
fi
fi
if [ -z "$CHANGED_FILES" ]; then
echo "No matching files changed, skip formatting check and unit tests"
exit 0
fi
echo "Checking formatting of changed files..."
CHANGED_FILES_CSV=$(echo "$CHANGED_FILES" | tr '\n' ';')
CHANGED_FILES_CSV=${CHANGED_FILES_CSV%;}
if ! dotnet format --verify-no-changes --include "$CHANGED_FILES_CSV"; then
echo ""
echo "ERROR: Some files are not formatted correctly."
echo " Run 'dotnet format' locally to auto-fix, then review and commit."
exit 1
fi
echo " Formatting check passed."
echo ""
echo "Running unit tests..."
dotnet test --filter "$TEST_FILTER"
test_exit=$?
if [ $test_exit -ne 0 ]; then
echo ""
echo "ERROR: Unit tests failed (exit code: $test_exit). Fix before pushing."
exit 1
fi
echo " All unit tests passed."
# Check for uncommitted changes after tests (e.g. golden/snapshot files)
dirty=$(git status -s)
if [ -n "$dirty" ]; then
echo ""
if [ "$ALLOW_DIRTY_PUSH" = "true" ]; then
echo "WARNING: Tests generated uncommitted changes (ALLOW_DIRTY_PUSH=true):"
echo "$dirty"
echo ""
echo " Pushing anyway. Review these changes after push."
else
echo "ERROR: Tests generated uncommitted changes:"
echo "$dirty"
echo ""
echo " Review and commit these changes, or add them to .gitignore."
echo " Set ALLOW_DIRTY_PUSH=true in .hooks/config to allow pushing anyway."
exit 1
fi
fi
exit 0
#!/bin/sh
COMMIT_MSG_FILE=$1
# Load config (if available), fall back to defaults
HOOK_DIR=$(dirname "$0")
[ -f "$HOOK_DIR/config" ] && . "$HOOK_DIR/config"
: "${FALLBACK_COMMIT_TYPE:=chore}"
: "${TICKET_PATTERN:=[A-Z]+-[0-9]+}"
: "${SCOPE_CHARS:=a-zA-Z0-9_./-}"
: "${SKIP_BRANCHES:=}"
BRANCH_NAME=$(git symbolic-ref --short HEAD 2>/dev/null)
if [ -z "$BRANCH_NAME" ]; then
exit 0
fi
# Skip processing on configured branches
if [ -n "$SKIP_BRANCHES" ]; then
for pattern in $SKIP_BRANCHES; do
case "$BRANCH_NAME" in
$pattern) exit 0 ;;
esac
done
fi
# Skip merge and squash commits (auto-generated messages)
if [ "$2" = "merge" ] || [ "$2" = "squash" ]; then
exit 0
fi
# Parse ticket ID from branch name (default: Jira-style, e.g. SSTV-123)
TICKET_NAME=$(echo "$BRANCH_NAME" | grep -Eoi "$TICKET_PATTERN" || true)
ORIGINAL_MSG=$(cat "$COMMIT_MSG_FILE")
# If scope already exists (including with breaking change !), skip
if echo "$ORIGINAL_MSG" | grep -Eq "^[a-z]+\([$SCOPE_CHARS]+\)!?: "; then
exit 0
fi
# Extract commit type, handling optional scope and breaking change indicator
COMMIT_TYPE=$(echo "$ORIGINAL_MSG" | sed -n 's/^\([a-z]*\)\(([^)]*)\)*!*:.*/\1/p')
if [ -z "$COMMIT_TYPE" ]; then
if [ -z "$TICKET_NAME" ]; then
NEW_MSG="$FALLBACK_COMMIT_TYPE: $ORIGINAL_MSG"
else
NEW_MSG="$FALLBACK_COMMIT_TYPE($TICKET_NAME): $ORIGINAL_MSG"
fi
else
if [ -z "$TICKET_NAME" ]; then
exit 0
else
# Add ticket scope before the optional breaking indicator
NEW_MSG=$(echo "$ORIGINAL_MSG" | sed "s/^$COMMIT_TYPE/$COMMIT_TYPE($TICKET_NAME)/")
fi
fi
echo "INFO: prepare-commit-msg added ticket scope:"
echo " Before: $ORIGINAL_MSG"
echo " After: $NEW_MSG"
echo "$NEW_MSG" > "$COMMIT_MSG_FILE"
exit 0
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment