Last active
June 22, 2026 18:28
-
-
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # 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" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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