···11+#!/usr/bin/env bash
22+#
33+# git commit-msg hook — lint the message against the Go commit conventions.
44+#
55+# Installed by pointing core.hooksPath at this directory:
66+# ./.tangled/hooks/install.sh
77+#
88+# Bypass for a single commit with `git commit --no-verify`.
99+#
1010+# Note: jj (jujutsu) does not run git hooks. jj users are covered by the
1111+# `commit-lint` CI workflow instead, and can lint locally on demand with:
1212+# .tangled/hooks/commit-msg-lint.sh --rev @
1313+1414+exec "$(dirname "$0")/commit-msg-lint.sh" --file "$1"
···11+#!/usr/bin/env bash
22+#
33+# commit-msg-lint.sh — enforce Go-style commit messages.
44+#
55+# Reference: https://go.dev/wiki/CommitMessage
66+#
77+# pkg/path: short summary in the imperative mood
88+#
99+# Optional body explaining what changed and why, wrapped at ~76 columns.
1010+# The blank line between the summary and the body is required.
1111+#
1212+# Fixes #123
1313+#
1414+# Usage:
1515+# commit-msg-lint.sh --file <path> # lint a single message file (git commit-msg hook)
1616+# commit-msg-lint.sh --rev <rev> # lint the message of one commit
1717+# commit-msg-lint.sh --range <a> <b> # lint every commit in a..b (exclusive of a)
1818+# ... | commit-msg-lint.sh # lint a message on stdin
1919+#
2020+# Flags:
2121+# --strict treat warnings as errors (exit non-zero on warnings too)
2222+#
2323+# Exit status: 0 = clean, 1 = at least one error (or warning under --strict).
2424+2525+set -euo pipefail
2626+2727+# Maximum length of the summary line. Go's guideline is "less than 76".
2828+MAX_SUMMARY_LEN=76
2929+3030+# Bare prefixes that look like Conventional Commits rather than a Go package
3131+# path. These are rejected because the summary should name the package/path
3232+# affected, e.g. `ogre: ...` instead of `fix: ...`.
3333+CONVENTIONAL_TYPES="feat fix chore refactor perf style ci revert"
3434+3535+# Special prefixes that are not repo paths but are conventionally allowed,
3636+# following the Go project (e.g. `all:` for tree-wide changes).
3737+PATH_ALLOWLIST="all"
3838+3939+# Space-separated list of real top-level entries in the repo (dirs and files),
4040+# used to validate that a prefix names an actual path. Populated per run from
4141+# the tree being linted; empty means "couldn't determine, skip path checks".
4242+TOPLEVEL=""
4343+4444+# is_toplevel <name> — true if <name> is a real top-level entry or allowlisted.
4545+is_toplevel() {
4646+ case " $TOPLEVEL " in *" $1 "*) return 0 ;; esac
4747+ case " $PATH_ALLOWLIST " in *" $1 "*) return 0 ;; esac
4848+ return 1
4949+}
5050+5151+# Non-imperative openers (past tense / gerund) that read as "described the
5252+# change" rather than "make the change". Warning only.
5353+NON_IMPERATIVE="added adds adding fixed fixes fixing updated updates updating \
5454+removed removes removing changed changes changing implemented implements \
5555+implementing created creates creating deleted refactored refactoring \
5656+improved improves improving bumped bumps"
5757+5858+STRICT=0
5959+errors=0
6060+warnings=0
6161+6262+err() { printf ' \033[31merror\033[0m %s\n' "$1" >&2; errors=$((errors + 1)); }
6363+warn() { printf ' \033[33mwarn\033[0m %s\n' "$1" >&2; warnings=$((warnings + 1)); }
6464+6565+# lint_message <label> <message>
6666+#
6767+# Validates a single commit message. <label> is used only in diagnostics.
6868+lint_message() {
6969+ local label="$1" msg="$2"
7070+ local before="$errors"
7171+7272+ # Read the message into an array of lines.
7373+ local -a lines=()
7474+ while IFS= read -r line || [ -n "$line" ]; do
7575+ lines+=("$line")
7676+ done <<<"$msg"
7777+7878+ local summary="${lines[0]:-}"
7979+8080+ # Skip messages that git/tools generate and that don't follow the format:
8181+ # merges, reverts, and rebase fixup/squash markers.
8282+ case "$summary" in
8383+ "Merge "* | "Revert "* | "fixup! "* | "squash! "* | "amend! "*)
8484+ return 0
8585+ ;;
8686+ esac
8787+8888+ # Ignore trailing comment/diff lines that `git commit` appends to the
8989+ # editor buffer (lines starting with '#').
9090+ local -a body=()
9191+ local i
9292+ for ((i = 0; i < ${#lines[@]}; i++)); do
9393+ [[ "${lines[$i]}" == \#* ]] && continue
9494+ body+=("${lines[$i]}")
9595+ done
9696+ lines=("${body[@]}")
9797+ summary="${lines[0]:-}"
9898+9999+ if [ -z "${summary// /}" ]; then
100100+ err "$label: empty commit message"
101101+ return 0
102102+ fi
103103+104104+ # --- summary line checks -------------------------------------------------
105105+106106+ # Must have a "prefix: summary" shape.
107107+ if [[ "$summary" != *": "* ]]; then
108108+ err "$label: summary must be \"pkg/path: short description\" (missing \"<prefix>: \")"
109109+ else
110110+ local prefix="${summary%%: *}"
111111+ local rest="${summary#*: }"
112112+ local flagged_prefix=0
113113+114114+ # The first path segment of the prefix: everything up to the first
115115+ # '/', ',' or '{'. e.g. "spindle/microvm" -> "spindle",
116116+ # "api,lexicons" -> "api", "appview/{config,state}" -> "appview".
117117+ local first_seg="${prefix%%[/,{]*}"
118118+119119+ # Reject bare Conventional-Commit type prefixes (feat/fix/...) that
120120+ # aren't package paths. A '/' means it's a real path, so allow it.
121121+ if [[ "$prefix" != */* ]]; then
122122+ local lower_prefix
123123+ lower_prefix="$(printf '%s' "$prefix" | tr '[:upper:]' '[:lower:]')"
124124+ # Strip a Conventional-Commit scope suffix, e.g. "fix(og)" -> "fix".
125125+ lower_prefix="${lower_prefix%%(*}"
126126+ local t
127127+ for t in $CONVENTIONAL_TYPES; do
128128+ if [ "$lower_prefix" = "$t" ]; then
129129+ err "$label: \"$prefix:\" looks like a Conventional Commit; name the affected package/path instead (e.g. \"ogre: ...\")"
130130+ flagged_prefix=1
131131+ break
132132+ fi
133133+ done
134134+ fi
135135+136136+ # The prefix must name a real path in the repo: a Go package, or the
137137+ # actual top-level dir. We validate the first segment against the set
138138+ # of top-level entries — this accepts package abbreviations the repo
139139+ # already uses (e.g. "spindle/microvm" for spindle/engines/microvm)
140140+ # while rejecting invented roots like "workflows/rust" (the real path
141141+ # is ".tangled/workflows").
142142+ if [ "$flagged_prefix" -eq 0 ] && [ -n "$TOPLEVEL" ] && ! is_toplevel "$first_seg"; then
143143+ 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: ...\")"
144144+ fi
145145+146146+ if [ -z "${rest// /}" ]; then
147147+ err "$label: summary has no description after \"$prefix:\""
148148+ else
149149+ # Description should be lowercase and imperative.
150150+ local first_word="${rest%% *}"
151151+ local first_char="${rest:0:1}"
152152+ if [[ "$first_char" =~ [A-Z] ]]; then
153153+ warn "$label: description should start lowercase (\"$first_word\")"
154154+ fi
155155+ local lower_word
156156+ lower_word="$(printf '%s' "$first_word" | tr '[:upper:]' '[:lower:]')"
157157+ local w
158158+ for w in $NON_IMPERATIVE; do
159159+ if [ "$lower_word" = "$w" ]; then
160160+ warn "$label: use the imperative mood (\"$lower_word\" -> imperative form)"
161161+ break
162162+ fi
163163+ done
164164+ fi
165165+ fi
166166+167167+ # No trailing period on the summary.
168168+ if [[ "$summary" == *. ]]; then
169169+ err "$label: summary must not end with a period"
170170+ fi
171171+172172+ # Length: Go suggests under 76, but this isn't strictly enforced, so warn.
173173+ if [ "${#summary}" -gt "$MAX_SUMMARY_LEN" ]; then
174174+ warn "$label: summary is ${#summary} chars (prefer under $MAX_SUMMARY_LEN)"
175175+ fi
176176+177177+ # --- body checks ---------------------------------------------------------
178178+179179+ # If there's more than one line, the second must be blank.
180180+ if [ "${#lines[@]}" -gt 1 ] && [ -n "${lines[1]// /}" ]; then
181181+ err "$label: leave a blank line between the summary and the body"
182182+ fi
183183+184184+ if [ "$errors" -eq "$before" ]; then
185185+ printf ' \033[32mok\033[0m %s\n' "$label" >&2
186186+ fi
187187+}
188188+189189+# --- argument handling -------------------------------------------------------
190190+191191+mode="stdin"
192192+a=""
193193+b=""
194194+195195+while [ "$#" -gt 0 ]; do
196196+ case "$1" in
197197+ --strict) STRICT=1; shift ;;
198198+ --file) mode="file"; a="${2:-}"; shift 2 ;;
199199+ --rev) mode="rev"; a="${2:-}"; shift 2 ;;
200200+ --range) mode="range"; a="${2:-}"; b="${3:-}"; shift 3 ;;
201201+ -h | --help)
202202+ sed -n '2,30p' "$0"; exit 0 ;;
203203+ *) shift ;;
204204+ esac
205205+done
206206+207207+# load_toplevel <ref> — populate TOPLEVEL from a tree, best-effort.
208208+load_toplevel() {
209209+ TOPLEVEL="$(git ls-tree --name-only "$1" 2>/dev/null | tr '\n' ' ')" || TOPLEVEL=""
210210+}
211211+212212+# load_toplevel_local — top-level entries for local linting (hook): the union
213213+# of what's committed at HEAD and what's on disk, so a commit that introduces a
214214+# new top-level dir can reference it without a false positive.
215215+load_toplevel_local() {
216216+ local root committed ondisk
217217+ root="$(git rev-parse --show-toplevel 2>/dev/null)" || { TOPLEVEL=""; return; }
218218+ committed="$(git ls-tree --name-only HEAD 2>/dev/null || true)"
219219+ ondisk="$(ls -A "$root" 2>/dev/null | grep -vxE '\.git|\.jj' || true)"
220220+ TOPLEVEL="$(printf '%s\n%s\n' "$committed" "$ondisk" | sort -u | tr '\n' ' ')"
221221+}
222222+223223+case "$mode" in
224224+ file)
225225+ load_toplevel_local
226226+ lint_message "commit message" "$(cat "$a")"
227227+ ;;
228228+ stdin)
229229+ load_toplevel_local
230230+ lint_message "(stdin)" "$(cat)"
231231+ ;;
232232+ rev)
233233+ load_toplevel "$a"
234234+ lint_message "$(git rev-parse --short "$a")" "$(git log -1 --format=%B "$a")"
235235+ ;;
236236+ range)
237237+ load_toplevel "$b"
238238+ revs="$(git rev-list --no-merges "$a..$b")"
239239+ if [ -z "$revs" ]; then
240240+ echo "commit-msg-lint: no commits in range $a..$b" >&2
241241+ exit 0
242242+ fi
243243+ while IFS= read -r rev; do
244244+ [ -z "$rev" ] && continue
245245+ label="$(git rev-parse --short "$rev"): $(git log -1 --format=%s "$rev")"
246246+ lint_message "$label" "$(git log -1 --format=%B "$rev")"
247247+ done <<<"$revs"
248248+ ;;
249249+esac
250250+251251+# --- summary -----------------------------------------------------------------
252252+253253+if [ "$errors" -gt 0 ] || { [ "$STRICT" -eq 1 ] && [ "$warnings" -gt 0 ]; }; then
254254+ printf '\ncommit-msg-lint: \033[31m%d error(s), %d warning(s)\033[0m\n' "$errors" "$warnings" >&2
255255+ printf 'See https://go.dev/wiki/CommitMessage — format: "pkg/path: short imperative summary"\n' >&2
256256+ exit 1
257257+fi
258258+259259+if [ "$warnings" -gt 0 ]; then
260260+ printf '\ncommit-msg-lint: %d warning(s)\n' "$warnings" >&2
261261+fi
262262+exit 0
···11+#!/usr/bin/env bash
22+#
33+# install.sh — enable the repo's git hooks for your local clone.
44+#
55+# This points git's core.hooksPath at .tangled/hooks so the version-controlled
66+# hooks in this directory run. One command, nothing copied, updates travel with
77+# the repo.
88+#
99+# ./.tangled/hooks/install.sh
1010+#
1111+# To undo:
1212+# git config --unset core.hooksPath
1313+1414+set -euo pipefail
1515+1616+# Resolve the repo root regardless of where this is invoked from.
1717+root="$(git rev-parse --show-toplevel)"
1818+cd "$root"
1919+2020+hooks_dir=".tangled/hooks"
2121+2222+git config core.hooksPath "$hooks_dir"
2323+chmod +x "$hooks_dir"/commit-msg "$hooks_dir"/commit-msg-lint.sh 2>/dev/null || true
2424+2525+echo "Installed git hooks: core.hooksPath -> $hooks_dir"
2626+echo
2727+echo "Note: jj (jujutsu) does not run git hooks. jj users are covered by the"
2828+echo "commit-lint CI workflow; lint locally on demand with:"
2929+echo " .tangled/hooks/commit-msg-lint.sh --rev @"
···11+when:
22+ - event: ["push", "pull_request"]
33+ branch: master
44+55+engine: microvm
66+image: nixos
77+88+dependencies:
99+ - git
1010+ - bash
1111+1212+# The default clone is depth 1 (single SHA), which is enough for a push but not
1313+# for computing a pull-request commit range. Fetch some history so we can find
1414+# the merge-base with the target branch.
1515+clone:
1616+ depth: 100
1717+1818+steps:
1919+ - name: lint commit messages
2020+ command: |
2121+ set -eu
2222+ lint=.tangled/hooks/commit-msg-lint.sh
2323+ chmod +x "$lint"
2424+2525+ kind="${TANGLED_PIPELINE_KIND:-push}"
2626+ head="${TANGLED_COMMIT_SHA:-HEAD}"
2727+2828+ # For a pull request, lint every commit between the target branch and the
2929+ # PR head. For a push, only the tip SHA is known, so lint just that.
3030+ if [ "$kind" = "pull_request" ]; then
3131+ base_branch="${TANGLED_PR_TARGET_BRANCH:-${TANGLED_REPO_DEFAULT_BRANCH:-master}}"
3232+ git fetch --depth=100 origin "$base_branch"
3333+ base="$(git merge-base FETCH_HEAD "$head" || true)"
3434+ if [ -n "$base" ]; then
3535+ exec "$lint" --range "$base" "$head"
3636+ fi
3737+ echo "commit-lint: no merge-base with $base_branch; linting tip only" >&2
3838+ fi
3939+4040+ exec "$lint" --rev "$head"