Skip to content

Solution — Git Hook Collection

Full reference implementations for all three hooks and the installer.

pre-commit

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

PASS=true

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

    local staged
    staged=$(git diff --cached --name-only --diff-filter=ACM | grep '\.sh$' || true)
    [[ -z "$staged" ]] && return 0

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

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

# ── Trailing whitespace ───────────────────────────────────────────────────────
check_whitespace() {
    local result
    result=$(git diff --cached --check 2>&1 || true)
    if [[ -n "$result" ]]; then
        echo "[pre-commit] Trailing whitespace:" >&2
        echo "$result" >&2
        PASS=false
    fi
}

check_shellcheck
check_whitespace

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

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

commit-msg

#!/usr/bin/env bash
# =============================================================================
# commit-msg — Enforce conventional commit format: type[(scope)]: subject
# =============================================================================
set -euo pipefail

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

# Pass-through: merges, reverts, fixups
if echo "$MSG" | grep -qE '^(Merge|Revert|fixup!|squash!)'; then
    exit 0
fi

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

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

cat >&2 <<EOF

[commit-msg] Invalid commit message.

  Got:      "$MSG"
  Expected: <type>[(scope)]: <subject>  (max 72 chars)
  Types:    feat fix docs style refactor test chore ci build perf

  Examples:
    feat: add user authentication
    fix(auth): handle expired tokens
    docs: update README with examples

EOF
exit 1

pre-push

#!/usr/bin/env bash
# =============================================================================
# pre-push — Block non-fast-forward pushes to the main branch
# =============================================================================
set -euo pipefail

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

while IFS=' ' read -r local_ref local_sha remote_ref remote_sha; do
    branch="${remote_ref#refs/heads/}"
    [[ "$branch" == "$PROTECTED" ]] || continue

    # remote_sha all-zeros means this is a new branch — allow it
    if [[ "$remote_sha" == "0000000000000000000000000000000000000000" ]]; then
        continue
    fi

    if ! git merge-base --is-ancestor "$remote_sha" "$local_sha" 2>/dev/null; then
        cat >&2 <<EOF

[pre-push] Non-fast-forward push to '$PROTECTED' rejected.
[pre-push] Remote: $REMOTE ($URL)
[pre-push] If this is intentional, coordinate with your team first.
[pre-push] To override (not recommended): git push --no-verify

EOF
        PASS=false
    fi
done

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

install.sh

#!/usr/bin/env bash
# =============================================================================
# install.sh — Symlink hooks into the current repo's .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 git hooks → $HOOKS_DIR"
echo ""

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

    if [[ ! -f "$src" ]]; then
        printf "  SKIP  %-15s (not found)\n" "$hook"
        continue
    fi

    if [[ -e "$dst" && ! -L "$dst" ]]; then
        printf "  BACK  %-15s → %s.bak\n" "$hook" "$dst"
        mv "$dst" "${dst}.bak"
    fi

    chmod +x "$src"
    ln -sf "$src" "$dst"
    printf "  OK    %-15s → %s\n" "$hook" "$dst"
done

echo ""
echo "Hooks installed. Test with: git commit --allow-empty -m 'test: verify hooks'"

Sample Output

$ bash install.sh Installing git hooks → /home/user/my-project/.git/hooks OK pre-commit → .git/hooks/pre-commit OK commit-msg → .git/hooks/commit-msg OK pre-push → .git/hooks/pre-push Hooks installed. Test with: git commit --allow-empty -m 'test: verify hooks'
$ git commit -m "Fixed stuff" [commit-msg] Invalid commit message. Got: "Fixed stuff" Expected: <type>[(scope)]: <subject> (max 72 chars) ... error: failed to commit

Key Design Decisions

Decision Why
Symlinks in install.sh Hook always runs latest version; no reinstall after edits
--diff-filter=ACM Skip deleted files — ShellCheck can't run on removed files
Pass-through for merge commits Merge messages are generated by git; shouldn't need a type prefix
merge-base --is-ancestor Correct force-push detection; compares history, not just SHAs
PASS=true flag pattern Run all checks before exiting so developer sees all failures at once

enhancements | overview