This repository has no description
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