This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

core / .tangled / hooks / commit-msg-lint.sh
8.9 kB 262 lines
1#!/usr/bin/env bash 2# 3# commit-msg-lint.sh — enforce Go-style commit messages. 4# 5# Reference: https://go.dev/wiki/CommitMessage 6# 7# pkg/path: short summary in the imperative mood 8# 9# Optional body explaining what changed and why, wrapped at ~76 columns. 10# The blank line between the summary and the body is required. 11# 12# Fixes #123 13# 14# Usage: 15# commit-msg-lint.sh --file <path> # lint a single message file (git commit-msg hook) 16# commit-msg-lint.sh --rev <rev> # lint the message of one commit 17# commit-msg-lint.sh --range <a> <b> # lint every commit in a..b (exclusive of a) 18# ... | commit-msg-lint.sh # lint a message on stdin 19# 20# Flags: 21# --strict treat warnings as errors (exit non-zero on warnings too) 22# 23# Exit status: 0 = clean, 1 = at least one error (or warning under --strict). 24 25set -euo pipefail 26 27# Maximum length of the summary line. Go's guideline is "less than 76". 28MAX_SUMMARY_LEN=76 29 30# Bare prefixes that look like Conventional Commits rather than a Go package 31# path. These are rejected because the summary should name the package/path 32# affected, e.g. `ogre: ...` instead of `fix: ...`. 33CONVENTIONAL_TYPES="feat fix chore refactor perf style ci revert" 34 35# Special prefixes that are not repo paths but are conventionally allowed, 36# following the Go project (e.g. `all:` for tree-wide changes). 37PATH_ALLOWLIST="all" 38 39# Space-separated list of real top-level entries in the repo (dirs and files), 40# used to validate that a prefix names an actual path. Populated per run from 41# the tree being linted; empty means "couldn't determine, skip path checks". 42TOPLEVEL="" 43 44# is_toplevel <name> — true if <name> is a real top-level entry or allowlisted. 45is_toplevel() { 46 case " $TOPLEVEL " in *" $1 "*) return 0 ;; esac 47 case " $PATH_ALLOWLIST " in *" $1 "*) return 0 ;; esac 48 return 1 49} 50 51# Non-imperative openers (past tense / gerund) that read as "described the 52# change" rather than "make the change". Warning only. 53NON_IMPERATIVE="added adds adding fixed fixes fixing updated updates updating \ 54removed removes removing changed changes changing implemented implements \ 55implementing created creates creating deleted refactored refactoring \ 56improved improves improving bumped bumps" 57 58STRICT=0 59errors=0 60warnings=0 61 62err() { printf ' \033[31merror\033[0m %s\n' "$1" >&2; errors=$((errors + 1)); } 63warn() { printf ' \033[33mwarn\033[0m %s\n' "$1" >&2; warnings=$((warnings + 1)); } 64 65# lint_message <label> <message> 66# 67# Validates a single commit message. <label> is used only in diagnostics. 68lint_message() { 69 local label="$1" msg="$2" 70 local before="$errors" 71 72 # Read the message into an array of lines. 73 local -a lines=() 74 while IFS= read -r line || [ -n "$line" ]; do 75 lines+=("$line") 76 done <<<"$msg" 77 78 local summary="${lines[0]:-}" 79 80 # Skip messages that git/tools generate and that don't follow the format: 81 # merges, reverts, and rebase fixup/squash markers. 82 case "$summary" in 83 "Merge "* | "Revert "* | "fixup! "* | "squash! "* | "amend! "*) 84 return 0 85 ;; 86 esac 87 88 # Ignore trailing comment/diff lines that `git commit` appends to the 89 # editor buffer (lines starting with '#'). 90 local -a body=() 91 local i 92 for ((i = 0; i < ${#lines[@]}; i++)); do 93 [[ "${lines[$i]}" == \#* ]] && continue 94 body+=("${lines[$i]}") 95 done 96 lines=("${body[@]}") 97 summary="${lines[0]:-}" 98 99 if [ -z "${summary// /}" ]; then 100 err "$label: empty commit message" 101 return 0 102 fi 103 104 # --- summary line checks ------------------------------------------------- 105 106 # Must have a "prefix: summary" shape. 107 if [[ "$summary" != *": "* ]]; then 108 err "$label: summary must be \"pkg/path: short description\" (missing \"<prefix>: \")" 109 else 110 local prefix="${summary%%: *}" 111 local rest="${summary#*: }" 112 local flagged_prefix=0 113 114 # The first path segment of the prefix: everything up to the first 115 # '/', ',' or '{'. e.g. "spindle/microvm" -> "spindle", 116 # "api,lexicons" -> "api", "appview/{config,state}" -> "appview". 117 local first_seg="${prefix%%[/,{]*}" 118 119 # Reject bare Conventional-Commit type prefixes (feat/fix/...) that 120 # aren't package paths. A '/' means it's a real path, so allow it. 121 if [[ "$prefix" != */* ]]; then 122 local lower_prefix 123 lower_prefix="$(printf '%s' "$prefix" | tr '[:upper:]' '[:lower:]')" 124 # Strip a Conventional-Commit scope suffix, e.g. "fix(og)" -> "fix". 125 lower_prefix="${lower_prefix%%(*}" 126 local t 127 for t in $CONVENTIONAL_TYPES; do 128 if [ "$lower_prefix" = "$t" ]; then 129 err "$label: \"$prefix:\" looks like a Conventional Commit; name the affected package/path instead (e.g. \"ogre: ...\")" 130 flagged_prefix=1 131 break 132 fi 133 done 134 fi 135 136 # The prefix must name a real path in the repo: a Go package, or the 137 # actual top-level dir. We validate the first segment against the set 138 # of top-level entries — this accepts package abbreviations the repo 139 # already uses (e.g. "spindle/microvm" for spindle/engines/microvm) 140 # while rejecting invented roots like "workflows/rust" (the real path 141 # is ".tangled/workflows"). 142 if [ "$flagged_prefix" -eq 0 ] && [ -n "$TOPLEVEL" ] && ! is_toplevel "$first_seg"; then 143 err "$label: \"$first_seg\" is not a path in the repo; use the affected Go package or the real top-level path (e.g. \".tangled/workflows: ...\")" 144 fi 145 146 if [ -z "${rest// /}" ]; then 147 err "$label: summary has no description after \"$prefix:\"" 148 else 149 # Description should be lowercase and imperative. 150 local first_word="${rest%% *}" 151 local first_char="${rest:0:1}" 152 if [[ "$first_char" =~ [A-Z] ]]; then 153 warn "$label: description should start lowercase (\"$first_word\")" 154 fi 155 local lower_word 156 lower_word="$(printf '%s' "$first_word" | tr '[:upper:]' '[:lower:]')" 157 local w 158 for w in $NON_IMPERATIVE; do 159 if [ "$lower_word" = "$w" ]; then 160 warn "$label: use the imperative mood (\"$lower_word\" -> imperative form)" 161 break 162 fi 163 done 164 fi 165 fi 166 167 # No trailing period on the summary. 168 if [[ "$summary" == *. ]]; then 169 err "$label: summary must not end with a period" 170 fi 171 172 # Length: Go suggests under 76, but this isn't strictly enforced, so warn. 173 if [ "${#summary}" -gt "$MAX_SUMMARY_LEN" ]; then 174 warn "$label: summary is ${#summary} chars (prefer under $MAX_SUMMARY_LEN)" 175 fi 176 177 # --- body checks --------------------------------------------------------- 178 179 # If there's more than one line, the second must be blank. 180 if [ "${#lines[@]}" -gt 1 ] && [ -n "${lines[1]// /}" ]; then 181 err "$label: leave a blank line between the summary and the body" 182 fi 183 184 if [ "$errors" -eq "$before" ]; then 185 printf ' \033[32mok\033[0m %s\n' "$label" >&2 186 fi 187} 188 189# --- argument handling ------------------------------------------------------- 190 191mode="stdin" 192a="" 193b="" 194 195while [ "$#" -gt 0 ]; do 196 case "$1" in 197 --strict) STRICT=1; shift ;; 198 --file) mode="file"; a="${2:-}"; shift 2 ;; 199 --rev) mode="rev"; a="${2:-}"; shift 2 ;; 200 --range) mode="range"; a="${2:-}"; b="${3:-}"; shift 3 ;; 201 -h | --help) 202 sed -n '2,30p' "$0"; exit 0 ;; 203 *) shift ;; 204 esac 205done 206 207# load_toplevel <ref> — populate TOPLEVEL from a tree, best-effort. 208load_toplevel() { 209 TOPLEVEL="$(git ls-tree --name-only "$1" 2>/dev/null | tr '\n' ' ')" || TOPLEVEL="" 210} 211 212# load_toplevel_local — top-level entries for local linting (hook): the union 213# of what's committed at HEAD and what's on disk, so a commit that introduces a 214# new top-level dir can reference it without a false positive. 215load_toplevel_local() { 216 local root committed ondisk 217 root="$(git rev-parse --show-toplevel 2>/dev/null)" || { TOPLEVEL=""; return; } 218 committed="$(git ls-tree --name-only HEAD 2>/dev/null || true)" 219 ondisk="$(ls -A "$root" 2>/dev/null | grep -vxE '\.git|\.jj' || true)" 220 TOPLEVEL="$(printf '%s\n%s\n' "$committed" "$ondisk" | sort -u | tr '\n' ' ')" 221} 222 223case "$mode" in 224 file) 225 load_toplevel_local 226 lint_message "commit message" "$(cat "$a")" 227 ;; 228 stdin) 229 load_toplevel_local 230 lint_message "(stdin)" "$(cat)" 231 ;; 232 rev) 233 load_toplevel "$a" 234 lint_message "$(git rev-parse --short "$a")" "$(git log -1 --format=%B "$a")" 235 ;; 236 range) 237 load_toplevel "$b" 238 revs="$(git rev-list --no-merges "$a..$b")" 239 if [ -z "$revs" ]; then 240 echo "commit-msg-lint: no commits in range $a..$b" >&2 241 exit 0 242 fi 243 while IFS= read -r rev; do 244 [ -z "$rev" ] && continue 245 label="$(git rev-parse --short "$rev"): $(git log -1 --format=%s "$rev")" 246 lint_message "$label" "$(git log -1 --format=%B "$rev")" 247 done <<<"$revs" 248 ;; 249esac 250 251# --- summary ----------------------------------------------------------------- 252 253if [ "$errors" -gt 0 ] || { [ "$STRICT" -eq 1 ] && [ "$warnings" -gt 0 ]; }; then 254 printf '\ncommit-msg-lint: \033[31m%d error(s), %d warning(s)\033[0m\n' "$errors" "$warnings" >&2 255 printf 'See https://go.dev/wiki/CommitMessage — format: "pkg/path: short imperative summary"\n' >&2 256 exit 1 257fi 258 259if [ "$warnings" -gt 0 ]; then 260 printf '\ncommit-msg-lint: %d warning(s)\n' "$warnings" >&2 261fi 262exit 0