This repository has no description
0

Configure Feed

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

.tangled: add commit message lint (git hook & ci)

Signed-off-by: Anirudh Oppiliappan <x@icyphox.sh>

author
Anirudh Oppiliappan
committer
Anirudh Oppiliappan
date (Jul 31, 2026, 11:03 AM +0300) commit 7c7539ad parent 20c60fb4 change-id yvwnsksp
+345
+14
.tangled/hooks/commit-msg
··· 1 + #!/usr/bin/env bash 2 + # 3 + # git commit-msg hook — lint the message against the Go commit conventions. 4 + # 5 + # Installed by pointing core.hooksPath at this directory: 6 + # ./.tangled/hooks/install.sh 7 + # 8 + # Bypass for a single commit with `git commit --no-verify`. 9 + # 10 + # Note: jj (jujutsu) does not run git hooks. jj users are covered by the 11 + # `commit-lint` CI workflow instead, and can lint locally on demand with: 12 + # .tangled/hooks/commit-msg-lint.sh --rev @ 13 + 14 + exec "$(dirname "$0")/commit-msg-lint.sh" --file "$1"
+262
.tangled/hooks/commit-msg-lint.sh
··· 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 + 25 + set -euo pipefail 26 + 27 + # Maximum length of the summary line. Go's guideline is "less than 76". 28 + MAX_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: ...`. 33 + CONVENTIONAL_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). 37 + PATH_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". 42 + TOPLEVEL="" 43 + 44 + # is_toplevel <name> — true if <name> is a real top-level entry or allowlisted. 45 + is_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. 53 + NON_IMPERATIVE="added adds adding fixed fixes fixing updated updates updating \ 54 + removed removes removing changed changes changing implemented implements \ 55 + implementing created creates creating deleted refactored refactoring \ 56 + improved improves improving bumped bumps" 57 + 58 + STRICT=0 59 + errors=0 60 + warnings=0 61 + 62 + err() { printf ' \033[31merror\033[0m %s\n' "$1" >&2; errors=$((errors + 1)); } 63 + warn() { 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. 68 + lint_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 + 191 + mode="stdin" 192 + a="" 193 + b="" 194 + 195 + while [ "$#" -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 205 + done 206 + 207 + # load_toplevel <ref> — populate TOPLEVEL from a tree, best-effort. 208 + load_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. 215 + load_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 + 223 + case "$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 + ;; 249 + esac 250 + 251 + # --- summary ----------------------------------------------------------------- 252 + 253 + if [ "$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 257 + fi 258 + 259 + if [ "$warnings" -gt 0 ]; then 260 + printf '\ncommit-msg-lint: %d warning(s)\n' "$warnings" >&2 261 + fi 262 + exit 0
+29
.tangled/hooks/install.sh
··· 1 + #!/usr/bin/env bash 2 + # 3 + # install.sh — enable the repo's git hooks for your local clone. 4 + # 5 + # This points git's core.hooksPath at .tangled/hooks so the version-controlled 6 + # hooks in this directory run. One command, nothing copied, updates travel with 7 + # the repo. 8 + # 9 + # ./.tangled/hooks/install.sh 10 + # 11 + # To undo: 12 + # git config --unset core.hooksPath 13 + 14 + set -euo pipefail 15 + 16 + # Resolve the repo root regardless of where this is invoked from. 17 + root="$(git rev-parse --show-toplevel)" 18 + cd "$root" 19 + 20 + hooks_dir=".tangled/hooks" 21 + 22 + git config core.hooksPath "$hooks_dir" 23 + chmod +x "$hooks_dir"/commit-msg "$hooks_dir"/commit-msg-lint.sh 2>/dev/null || true 24 + 25 + echo "Installed git hooks: core.hooksPath -> $hooks_dir" 26 + echo 27 + echo "Note: jj (jujutsu) does not run git hooks. jj users are covered by the" 28 + echo "commit-lint CI workflow; lint locally on demand with:" 29 + echo " .tangled/hooks/commit-msg-lint.sh --rev @"
+40
.tangled/workflows/commit-lint.yml
··· 1 + when: 2 + - event: ["push", "pull_request"] 3 + branch: master 4 + 5 + engine: microvm 6 + image: nixos 7 + 8 + dependencies: 9 + - git 10 + - bash 11 + 12 + # The default clone is depth 1 (single SHA), which is enough for a push but not 13 + # for computing a pull-request commit range. Fetch some history so we can find 14 + # the merge-base with the target branch. 15 + clone: 16 + depth: 100 17 + 18 + steps: 19 + - name: lint commit messages 20 + command: | 21 + set -eu 22 + lint=.tangled/hooks/commit-msg-lint.sh 23 + chmod +x "$lint" 24 + 25 + kind="${TANGLED_PIPELINE_KIND:-push}" 26 + head="${TANGLED_COMMIT_SHA:-HEAD}" 27 + 28 + # For a pull request, lint every commit between the target branch and the 29 + # PR head. For a push, only the tip SHA is known, so lint just that. 30 + if [ "$kind" = "pull_request" ]; then 31 + base_branch="${TANGLED_PR_TARGET_BRANCH:-${TANGLED_REPO_DEFAULT_BRANCH:-master}}" 32 + git fetch --depth=100 origin "$base_branch" 33 + base="$(git merge-base FETCH_HEAD "$head" || true)" 34 + if [ -n "$base" ]; then 35 + exec "$lint" --range "$base" "$head" 36 + fi 37 + echo "commit-lint: no merge-base with $base_branch; linting tip only" >&2 38 + fi 39 + 40 + exec "$lint" --rev "$head"