Skip to content

Implementation — Git Hook Collection

Build each hook independently, then wire them together with the installer.

Hook 1 — pre-commit

Runs ShellCheck on staged .sh files and rejects commits that add trailing whitespace.

#!/usr/bin/env bash
# pre-commit — ShellCheck staged .sh files and block trailing whitespace
set -euo pipefail

PASS=true

# ── ShellCheck on staged shell scripts ───────────────────────────────────────
check_shellcheck() {
    if ! command -v shellcheck &>/dev/null; then
        echo "[pre-commit] WARNING: shellcheck not installed — skipping"
        return 0
    fi

    local staged_scripts
    staged_scripts=$(git diff --cached --name-only --diff-filter=ACM | grep '\.sh$' || true)

    [[ -z "$staged_scripts" ]] && return 0

    echo "[pre-commit] Running ShellCheck..."
    local failed=0
    while IFS= read -r file; do
        if shellcheck "$file"; then
            echo "[pre-commit]   OK: $file"
        else
            echo "[pre-commit]   FAIL: $file" >&2
            (( failed++ )) || true
        fi
    done <<< "$staged_scripts"

    if (( failed > 0 )); then
        echo "[pre-commit] $failed file(s) failed ShellCheck. Fix errors before committing." >&2
        PASS=false
    fi
}

# ── Trailing whitespace check ─────────────────────────────────────────────────
check_trailing_whitespace() {
    local offending
    offending=$(git diff --cached --check 2>&1 || true)

    if [[ -n "$offending" ]]; then
        echo "[pre-commit] Trailing whitespace detected:" >&2
        echo "$offending" >&2
        echo "[pre-commit] Fix with: git diff --cached | grep -n ' $'" >&2
        PASS=false
    fi
}

# ── Run checks ────────────────────────────────────────────────────────────────
check_shellcheck
check_trailing_whitespace

if [[ "$PASS" == false ]]; then
    echo ""
    echo "[pre-commit] Commit rejected. Fix the above issues and try again." >&2
    exit 1
fi

echo "[pre-commit] All checks passed."
exit 0

git diff --check

Git's own --check flag detects trailing whitespace and conflict markers in staged content. Using it avoids writing your own whitespace regex.

Hook 2 — commit-msg

Enforces the conventional commit format: type: subject.

#!/usr/bin/env bash
# commit-msg — Enforce conventional commit message format
set -euo pipefail

MSG_FILE="${1:?commit-msg: no message file provided}"
MSG="$(head -1 "$MSG_FILE")"

# Skip merge commits, fixups, and squash commits
if echo "$MSG" | grep -qE '^(Merge|Revert|fixup!|squash!)'; then
    exit 0
fi

VALID_TYPES="feat|fix|docs|style|refactor|test|chore|ci|build|perf"
PATTERN="^(${VALID_TYPES})(\([a-z0-9_-]+\))?: .{1,72}$"

if echo "$MSG" | grep -qE "$PATTERN"; then
    exit 0
fi

cat >&2 <<EOF

[commit-msg] Invalid commit message format.

  Got:      "$MSG"
  Expected: <type>: <subject>  (max 72 chars)

  Valid types: feat fix docs style refactor test chore ci build perf
  Examples:
    feat: add user login
    fix(auth): handle expired tokens
    docs: update README

EOF
exit 1

Allow scope in parentheses

feat(auth): add OAuth2 support — the (\([a-z0-9_-]+\))? part makes the scope optional. The parentheses are escaped as \( in the regex because we're using grep -E.

Hook 3 — pre-push

Blocks pushes to main unless they are fast-forward (prevents accidental force-push):

#!/usr/bin/env bash
# pre-push — Block force-pushes and direct pushes to main
set -euo pipefail

REMOTE="$1"
URL="$2"
PROTECTED_BRANCH="main"
PASS=true

echo "[pre-push] Remote: $REMOTE ($URL)"

while IFS=' ' read -r local_ref local_sha remote_ref remote_sha; do
    # Extract just the branch name from refs/heads/main
    branch="${remote_ref#refs/heads/}"

    if [[ "$branch" != "$PROTECTED_BRANCH" ]]; then
        continue
    fi

    # Detect force-push: remote_sha exists and is not an ancestor of local_sha
    if [[ "$remote_sha" != "0000000000000000000000000000000000000000" ]]; then
        if ! git merge-base --is-ancestor "$remote_sha" "$local_sha" 2>/dev/null; then
            echo "[pre-push] ERROR: Non-fast-forward push to '$PROTECTED_BRANCH' rejected." >&2
            echo "[pre-push] If this is intentional, use --no-verify (requires team lead approval)." >&2
            PASS=false
        fi
    fi

    # Warn if pushing large files
    local large_files
    large_files=$(git diff --name-only "$remote_sha..$local_sha" 2>/dev/null |
                  xargs -I{} sh -c 'test -f "{}" && du -sk "{}"' 2>/dev/null |
                  awk '$1 > 5120 {print $2}' || true)   # > 5 MB

    if [[ -n "$large_files" ]]; then
        echo "[pre-push] WARNING: Large file(s) being pushed:" >&2
        echo "$large_files" | while read -r f; do echo "  $f"; done >&2
    fi
done

[[ "$PASS" == false ]] && exit 1
exit 0

The Installer — install.sh

#!/usr/bin/env bash
# install.sh — Symlink hooks into .git/hooks/
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
GIT_ROOT="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel 2>/dev/null || echo "")"

[[ -z "$GIT_ROOT" ]] && { echo "Error: not inside a git repository" >&2; exit 1; }

HOOKS_DIR="${GIT_ROOT}/.git/hooks"
HOOKS=("pre-commit" "commit-msg" "pre-push")

echo "Installing hooks from $SCRIPT_DIR$HOOKS_DIR"

for hook in "${HOOKS[@]}"; do
    local src="${SCRIPT_DIR}/${hook}"
    local dst="${HOOKS_DIR}/${hook}"

    [[ -f "$src" ]] || { echo "  SKIP $hook (not found in $SCRIPT_DIR)"; continue; }

    if [[ -e "$dst" && ! -L "$dst" ]]; then
        echo "  WARN $hook: existing hook at $dst — backing up as ${dst}.bak"
        mv "$dst" "${dst}.bak"
    fi

    ln -sf "$src" "$dst"
    chmod +x "$src"
    echo "  OK   $hook$dst"
done

echo "Done. Run 'git log --oneline -1' to verify hooks are active."

Symlinks, not copies

Symlinking means the hook always runs the latest version from your project directory. If you copied the file, you'd have to reinstall after every edit.


setup | testing