|
| 1 | +#!/usr/bin/env bash |
| 2 | +# Shared checker: reject commits whose author/committer/trailers match the |
| 3 | +# identity blocklist. Used by local hooks and CI. |
| 4 | +# |
| 5 | +# Usage: |
| 6 | +# check-git-identity.sh --message-file FILE [--author "N <e>"] [--committer "N <e>"] |
| 7 | +# check-git-identity.sh --range A..B |
| 8 | +# check-git-identity.sh --commit SHA |
| 9 | +# check-git-identity.sh --stdin # reads a full commit message from stdin |
| 10 | +# |
| 11 | +# Exit 0 = clean, 1 = blocked identity found, 2 = usage/setup error. |
| 12 | + |
| 13 | +set -euo pipefail |
| 14 | + |
| 15 | +SCRIPT_DIR=$(CDPATH= cd -P "$(dirname "$0")" && pwd) |
| 16 | +ROOT=$(CDPATH= cd -P "$SCRIPT_DIR/.." && pwd) |
| 17 | +BLOCKLIST=${KOMA_IDENTITY_BLOCKLIST:-"$SCRIPT_DIR/git-identity-blocklist.txt"} |
| 18 | + |
| 19 | +die() { echo "check-git-identity: $*" >&2; exit 2; } |
| 20 | +fail() { echo "check-git-identity: BLOCKED: $*" >&2; exit 1; } |
| 21 | + |
| 22 | +[ -f "$BLOCKLIST" ] || die "blocklist not found: $BLOCKLIST" |
| 23 | + |
| 24 | +# Load patterns (lowercase) into a bash array. |
| 25 | +mapfile -t PATTERNS < <( |
| 26 | + awk ' |
| 27 | + /^[[:space:]]*#/ { next } |
| 28 | + /^[[:space:]]*$/ { next } |
| 29 | + { |
| 30 | + line = $0 |
| 31 | + sub(/^[[:space:]]+/, "", line) |
| 32 | + sub(/[[:space:]]+$/, "", line) |
| 33 | + if (line != "") print tolower(line) |
| 34 | + } |
| 35 | + ' "$BLOCKLIST" |
| 36 | +) |
| 37 | +[ "${#PATTERNS[@]}" -gt 0 ] || die "blocklist is empty: $BLOCKLIST" |
| 38 | + |
| 39 | +identity_hits() { |
| 40 | + # $1 = label, $2 = free-form identity text |
| 41 | + local label=$1 |
| 42 | + local text |
| 43 | + text=$(printf '%s' "${2:-}" | tr '[:upper:]' '[:lower:]') |
| 44 | + [ -n "$text" ] || return 0 |
| 45 | + local p |
| 46 | + for p in "${PATTERNS[@]}"; do |
| 47 | + case "$text" in |
| 48 | + *"$p"*) echo "$label matches blocklist pattern '$p' ← $2" ;; |
| 49 | + esac |
| 50 | + done |
| 51 | +} |
| 52 | + |
| 53 | +check_message_trailers() { |
| 54 | + # Scan full commit message for Co-authored-by / Signed-off-by / Reviewed-by. |
| 55 | + local msg=$1 |
| 56 | + local line name_email hits |
| 57 | + while IFS= read -r line || [ -n "$line" ]; do |
| 58 | + case "$line" in |
| 59 | + [Cc][Oo]-[Aa][Uu][Tt][Hh][Oo][Rr][Ee][Dd]-[Bb][Yy]:*|\ |
| 60 | + [Ss][Ii][Gg][Nn][Ee][Dd]-[Oo][Ff][Ff]-[Bb][Yy]:*|\ |
| 61 | + [Rr][Ee][Vv][Ii][Ee][Ww][Ee][Dd]-[Bb][Yy]:*|\ |
| 62 | + [Aa][Cc][Kk][Nn][Oo][Ww][Ll][Ee][Dd][Gg][Ee][Dd]-[Bb][Yy]:*) |
| 63 | + name_email=${line#*:} |
| 64 | + name_email=${name_email## } |
| 65 | + hits=$(identity_hits "trailer '$line'" "$name_email" || true) |
| 66 | + if [ -n "${hits:-}" ]; then |
| 67 | + printf '%s\n' "$hits" |
| 68 | + fi |
| 69 | + ;; |
| 70 | + esac |
| 71 | + done <<EOF |
| 72 | +$msg |
| 73 | +EOF |
| 74 | +} |
| 75 | + |
| 76 | +check_one_commit() { |
| 77 | + local sha=$1 |
| 78 | + local author committer msg hits |
| 79 | + author=$(git -C "$ROOT" log -1 --format='%an <%ae>' "$sha") |
| 80 | + committer=$(git -C "$ROOT" log -1 --format='%cn <%ce>' "$sha") |
| 81 | + msg=$(git -C "$ROOT" log -1 --format='%B' "$sha") |
| 82 | + hits=$( |
| 83 | + { |
| 84 | + identity_hits "author" "$author" |
| 85 | + identity_hits "committer" "$committer" |
| 86 | + check_message_trailers "$msg" |
| 87 | + } | sed '/^$/d' |
| 88 | + ) |
| 89 | + if [ -n "${hits:-}" ]; then |
| 90 | + echo "commit $sha:" |
| 91 | + printf '%s\n' "$hits" | sed 's/^/ /' |
| 92 | + return 1 |
| 93 | + fi |
| 94 | + return 0 |
| 95 | +} |
| 96 | + |
| 97 | +MODE="" |
| 98 | +MSG_FILE="" |
| 99 | +AUTHOR="" |
| 100 | +COMMITTER="" |
| 101 | +RANGE="" |
| 102 | +COMMIT="" |
| 103 | + |
| 104 | +while [ "$#" -gt 0 ]; do |
| 105 | + case "$1" in |
| 106 | + --message-file) MSG_FILE=$2; MODE=message; shift 2 ;; |
| 107 | + --author) AUTHOR=$2; shift 2 ;; |
| 108 | + --committer) COMMITTER=$2; shift 2 ;; |
| 109 | + --range) RANGE=$2; MODE=range; shift 2 ;; |
| 110 | + --commit) COMMIT=$2; MODE=commit; shift 2 ;; |
| 111 | + --stdin) MODE=stdin; shift ;; |
| 112 | + -h|--help) |
| 113 | + sed -n '1,20p' "$0" |
| 114 | + exit 0 |
| 115 | + ;; |
| 116 | + *) die "unknown arg: $1" ;; |
| 117 | + esac |
| 118 | +done |
| 119 | + |
| 120 | +[ -n "$MODE" ] || die "specify --message-file, --range, --commit, or --stdin" |
| 121 | + |
| 122 | +case "$MODE" in |
| 123 | + message|stdin) |
| 124 | + if [ "$MODE" = message ]; then |
| 125 | + [ -n "$MSG_FILE" ] && [ -f "$MSG_FILE" ] || die "--message-file missing" |
| 126 | + MSG=$(cat "$MSG_FILE") |
| 127 | + else |
| 128 | + MSG=$(cat) |
| 129 | + fi |
| 130 | + # Resolve author/committer: explicit flag > git env > git config. |
| 131 | + if [ -z "$AUTHOR" ] || [ -z "${AUTHOR// }" ] || [ "$AUTHOR" = " <>" ]; then |
| 132 | + an=${GIT_AUTHOR_NAME:-$(git -C "$ROOT" config user.name 2>/dev/null || true)} |
| 133 | + ae=${GIT_AUTHOR_EMAIL:-$(git -C "$ROOT" config user.email 2>/dev/null || true)} |
| 134 | + AUTHOR="$an <$ae>" |
| 135 | + fi |
| 136 | + if [ -z "$COMMITTER" ] || [ -z "${COMMITTER// }" ] || [ "$COMMITTER" = " <>" ]; then |
| 137 | + cn=${GIT_COMMITTER_NAME:-$(git -C "$ROOT" config user.name 2>/dev/null || true)} |
| 138 | + ce=${GIT_COMMITTER_EMAIL:-$(git -C "$ROOT" config user.email 2>/dev/null || true)} |
| 139 | + COMMITTER="$cn <$ce>" |
| 140 | + fi |
| 141 | + HITS=$( |
| 142 | + { |
| 143 | + identity_hits "author" "$AUTHOR" |
| 144 | + identity_hits "committer" "$COMMITTER" |
| 145 | + check_message_trailers "$MSG" |
| 146 | + } | sed '/^$/d' |
| 147 | + ) |
| 148 | + if [ -n "${HITS:-}" ]; then |
| 149 | + echo "check-git-identity: blocked identity in pending commit:" >&2 |
| 150 | + printf '%s\n' "$HITS" | sed 's/^/ /' >&2 |
| 151 | + echo "Remove the bot trailer/author or re-commit as a human." >&2 |
| 152 | + echo "Blocklist: $BLOCKLIST" >&2 |
| 153 | + exit 1 |
| 154 | + fi |
| 155 | + ;; |
| 156 | + commit) |
| 157 | + [ -n "$COMMIT" ] || die "--commit needs a SHA" |
| 158 | + if ! check_one_commit "$COMMIT"; then |
| 159 | + fail "forbidden identity in $COMMIT" |
| 160 | + fi |
| 161 | + ;; |
| 162 | + range) |
| 163 | + [ -n "$RANGE" ] || die "--range needs A..B" |
| 164 | + # Empty range (e.g. PR with no commits) is fine. |
| 165 | + SHAS=$(git -C "$ROOT" rev-list "$RANGE" 2>/dev/null || true) |
| 166 | + BAD=0 |
| 167 | + for sha in $SHAS; do |
| 168 | + if ! check_one_commit "$sha"; then |
| 169 | + BAD=1 |
| 170 | + fi |
| 171 | + done |
| 172 | + if [ "$BAD" -ne 0 ]; then |
| 173 | + fail "forbidden identity in range $RANGE" |
| 174 | + fi |
| 175 | + ;; |
| 176 | +esac |
| 177 | + |
| 178 | +exit 0 |
0 commit comments