#!/bin/sh # Format the code being committed, so what lands in git is already formatted. # # C with clang-format (.clang-format), Python with ruff -- imports sorted, then # laid out (ruff.toml). # # Install with `make hooks`, which points `core.hooksPath` here. Hooks are not # versioned by git, so a hook in a repo has to be a file somebody opts into -- # there is no way to ship one that runs on clone, and a repo that could would be # a repo that runs code on clone. # # Skip a commit with `git commit --no-verify` when you mean to. set -e # Only what is staged, and never the vendored tree: libghostty-vt is somebody # else's code, pinned by commit (D12), and reformatting it would turn every # future re-vendor into a merge. staged=$(git diff --cached --name-only --diff-filter=ACMR | grep -v '^vendor/' || true) c_files=$(printf '%s\n' "$staged" | grep -E '\.[ch]$' || true) py_files=$(printf '%s\n' "$staged" | grep -E '\.py$' || true) # ...plus the contrib scripts, which are Python with no extension on them. for f in $staged; do case $f in *.*) continue ;; esac if [ -f "$f" ] && head -1 "$f" | grep -q python; then py_files=$(printf '%s\n%s' "$py_files" "$f") fi done c_files=$(printf '%s\n' "$c_files" | sed '/^$/d') py_files=$(printf '%s\n' "$py_files" | sed '/^$/d') files=$(printf '%s\n%s\n' "$c_files" "$py_files" | sed '/^$/d') [ -n "$files" ] || exit 0 # Where ruff is. The repo-local venv first, before $PATH, for the same reason the # Makefile prefers it: ruff.toml pins a version, and a system ruff of the wrong # one would refuse the commit while the venv sitting right there has the version # it asked for. Off the toplevel, so this works from a subdirectory. root=$(git rev-parse --show-toplevel) if [ -x "$root/.venv/bin/ruff" ]; then ruff="$root/.venv/bin/ruff" else ruff=$(command -v ruff 2>/dev/null || true) fi # Named together, so somebody setting up a machine installs both at once rather # than discovering the second one on their next commit. missing="" if [ -n "$c_files" ] && ! command -v clang-format >/dev/null 2>&1; then missing="$missing clang-format" fi if [ -n "$py_files" ] && [ -z "$ruff" ]; then missing="$missing ruff" fi if [ -n "$missing" ]; then echo "pre-commit: not installed:$missing" >&2 echo " and this commit touches code they format." >&2 echo " ruff: make tools (a venv in .venv, version pinned)" >&2 echo " clang-format: apt install clang-format | brew install clang-format" >&2 echo " ...or commit with --no-verify if you know what you are doing." >&2 exit 1 fi # A file with unstaged changes is the one case where formatting in place is # dangerous: the formatters rewrite the *working tree*, and re-staging afterwards # would commit work that was deliberately left out of the index. So say so and # stop, rather than quietly widening a commit somebody built with `git add -p`. partial="" for f in $files; do if ! git diff --quiet -- "$f"; then partial="$partial $f"; fi done if [ -n "$partial" ]; then echo "pre-commit: these files are only partly staged, so formatting them" >&2 echo " in place would add work you left out of the commit:" >&2 for f in $partial; do echo " $f" >&2; done echo " stage them fully, stash the rest, or use --no-verify." >&2 exit 1 fi if [ -n "$c_files" ]; then clang-format -i $c_files fi if [ -n "$py_files" ]; then # Imports first, then layout: sorting can leave a line the formatter then wraps. # `--select I` on the command line as well as in ruff.toml, so a stray `select` # somewhere else cannot turn this hook into a linter that blocks commits over an # unused variable. "$ruff" check -q --select I --fix $py_files "$ruff" format -q $py_files fi git add -- $files # Said out loud, because a hook that changes your commit without mentioning it is # a hook people learn to distrust. echo "pre-commit: formatted $(printf '%s' "$files" | tr '\n' ' ' | sed 's/ *$//')" exit 0