From 67868f51bbab5bc3ef5c8ba15433ba401a297f1a Mon Sep 17 00:00:00 2001 From: sommerfeld Date: Tue, 19 May 2026 16:45:17 +0100 Subject: feat(git): user-level hooks auto-dispatch into /.githooks/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inverts the hook delegation model. Previously per-repo hooks required a project to either (a) write the entire hook themselves and lose the global signed-commit / agent-author gate, or (b) override core.hooksPath and write passthrough stubs that exec back to $HOME/.config/git/hooks/*. Both are ergonomically miserable. Now: the global hooks at ~/.config/git/hooks/ are *always* the entry point. Each one calls a shared dispatcher (_dispatch.sh) that runs /.githooks/ if it exists, propagating its exit status, and then continues with whatever the global hook itself wants to do. Projects just drop an executable file at .githooks/ — no core.hooksPath, no stubs, no boilerplate. Repos that don't have a .githooks/ dir keep working exactly as before. GIT_HOOK_DISPATCHED guards against re-entry so legacy repos using the old stub-and-exec pattern don't loop. pre-push tees stdin so both the repo hook and the global ref-list loop see the full push payload. Adds two new always-no-op global hooks (pre-commit, post-commit) purely so the dispatch happens for those events too — previously only commit-msg and pre-push existed globally. Refactors this dotfiles repo to use the new pattern: drops the self-delegating .githooks/pre-push stub and removes the per-repo core.hooksPath override from `just init` (now an idempotent unsetter to clean up the override from past bootstraps). The remote-dev VM's home-manager profile symlinks all four hooks plus _dispatch.sh. --- .githooks/pre-push | 5 ----- README.md | 10 +++++++--- dot_config/git/hooks/_dispatch.sh | 31 +++++++++++++++++++++++++++++ dot_config/git/hooks/executable_commit-msg | 4 ++++ dot_config/git/hooks/executable_post-commit | 14 +++++++++++++ dot_config/git/hooks/executable_pre-commit | 15 ++++++++++++++ dot_config/git/hooks/executable_pre-push | 11 +++++++++- justfile | 7 ++++++- remote-dev/home.nix | 3 +++ 9 files changed, 90 insertions(+), 10 deletions(-) delete mode 100755 .githooks/pre-push create mode 100644 dot_config/git/hooks/_dispatch.sh create mode 100755 dot_config/git/hooks/executable_post-commit create mode 100755 dot_config/git/hooks/executable_pre-commit diff --git a/.githooks/pre-push b/.githooks/pre-push deleted file mode 100755 index a04e596..0000000 --- a/.githooks/pre-push +++ /dev/null @@ -1,5 +0,0 @@ -#!/bin/sh -# Delegate to the global pre-push (signed-commits gate). This repo -# overrides core.hooksPath to .githooks, so the global hook would not -# otherwise run here. -exec "$HOME/.config/git/hooks/pre-push" "$@" diff --git a/README.md b/README.md index 83c621d..0a0c5f3 100644 --- a/README.md +++ b/README.md @@ -134,10 +134,14 @@ Verify with `sudo nft list ruleset`. ## Git hooks -Activated by `just init` via `git config core.hooksPath .githooks`: +The user-level hooks at `~/.config/git/hooks/` (set as `core.hooksPath` in `dot_config/git/config`) apply globally and auto-dispatch into any repo's `/.githooks/` if present — so projects can drop their own hooks at `.githooks/` without ever touching `core.hooksPath` or writing passthrough stubs. Per-event behavior: -- `pre-commit` → `just check`. Blocks commits that fail formatting or linting. Bypass with `git commit --no-verify`. -- `post-commit` → `chezmoi apply`. Keeps `$HOME` in sync whenever a tracked file changes in the repo. +- `pre-commit` → repo's `.githooks/pre-commit` (if any). No global logic. In this repo: `just check`. +- `commit-msg` → repo's `.githooks/commit-msg` (if any), then strips any `Co-authored-by:` whose identity matches an AI agent (Copilot/Claude/Codex/…) so they don't trip the push gate. +- `pre-push` → repo's `.githooks/pre-push` (if any), then rejects pushes that contain unsigned commits, commits with a foreign committer, or commits authored/co-authored by an AI agent. +- `post-commit` → repo's `.githooks/post-commit` (if any). No global logic. In this repo: `chezmoi apply`. + +Bypass any of these with `--no-verify` on `commit`/`push`. ## Disaster recovery diff --git a/dot_config/git/hooks/_dispatch.sh b/dot_config/git/hooks/_dispatch.sh new file mode 100644 index 0000000..7dcd89c --- /dev/null +++ b/dot_config/git/hooks/_dispatch.sh @@ -0,0 +1,31 @@ +#!/bin/sh +# Sourced by every hook in this directory. Runs the per-repo hook of the +# same name from `/.githooks/` if it exists, then returns +# control so the calling user-level hook can do its own work after. +# +# Repos opt in by just dropping `.githooks/` (executable) in +# the working tree — no per-repo `core.hooksPath` setting, no stubs. +# If the per-repo hook exits non-zero we abort with that status so git +# sees the failure. +# +# GIT_HOOK_DISPATCHED guards against re-entry: if some legacy repo has +# its own `.githooks/` that ends with `exec "$HOME/.config/..."` +# (the old pattern), we won't dispatch back into it a second time. + +# shellcheck shell=sh +dispatch_repo_hook() { + hookname=$1 + shift + + [ -n "${GIT_HOOK_DISPATCHED:-}" ] && return 0 + + root=$(git rev-parse --show-toplevel 2>/dev/null) || return 0 + repo_hook="$root/.githooks/$hookname" + [ -x "$repo_hook" ] || return 0 + + GIT_HOOK_DISPATCHED=1 "$repo_hook" "$@" + rc=$? + if [ "$rc" -ne 0 ]; then + exit "$rc" + fi +} diff --git a/dot_config/git/hooks/executable_commit-msg b/dot_config/git/hooks/executable_commit-msg index 78dba63..e484ccb 100755 --- a/dot_config/git/hooks/executable_commit-msg +++ b/dot_config/git/hooks/executable_commit-msg @@ -12,6 +12,10 @@ set -eu +# shellcheck source=./_dispatch.sh +. "${0%/*}/_dispatch.sh" +dispatch_repo_hook commit-msg "$@" + msg_file=$1 # Keep this list in sync with executable_pre-push. diff --git a/dot_config/git/hooks/executable_post-commit b/dot_config/git/hooks/executable_post-commit new file mode 100755 index 0000000..45f13f0 --- /dev/null +++ b/dot_config/git/hooks/executable_post-commit @@ -0,0 +1,14 @@ +#!/bin/sh +# User-level post-commit. No global checks — exists purely so that +# `/.githooks/post-commit` gets picked up automatically without +# the project needing to override core.hooksPath. If there is no +# per-repo hook this is a no-op. post-commit's exit status is ignored +# by git, but we still propagate it for clarity. + +set -eu + +# shellcheck source=./_dispatch.sh +. "${0%/*}/_dispatch.sh" +dispatch_repo_hook post-commit "$@" + +exit 0 diff --git a/dot_config/git/hooks/executable_pre-commit b/dot_config/git/hooks/executable_pre-commit new file mode 100755 index 0000000..548925b --- /dev/null +++ b/dot_config/git/hooks/executable_pre-commit @@ -0,0 +1,15 @@ +#!/bin/sh +# User-level pre-commit. No global checks — exists purely so that +# `/.githooks/pre-commit` gets picked up automatically without +# the project needing to override core.hooksPath. If there is no +# per-repo hook this is a no-op. +# +# Bypass: git commit --no-verify + +set -eu + +# shellcheck source=./_dispatch.sh +. "${0%/*}/_dispatch.sh" +dispatch_repo_hook pre-commit "$@" + +exit 0 diff --git a/dot_config/git/hooks/executable_pre-push b/dot_config/git/hooks/executable_pre-push index ba7dc60..286958b 100755 --- a/dot_config/git/hooks/executable_pre-push +++ b/dot_config/git/hooks/executable_pre-push @@ -15,6 +15,15 @@ set -eu +# shellcheck source=./_dispatch.sh +. "${0%/*}/_dispatch.sh" +# Buffer stdin so both the per-repo hook and our own loop below get the +# full ref list (git only feeds it once). +_stdin_buf=$(mktemp) +trap 'rm -f "$_stdin_buf"' EXIT INT TERM +cat >"$_stdin_buf" +dispatch_repo_hook pre-push "$@" <"$_stdin_buf" + zero=$(git hash-object --stdin &2 fail=1 fi -done +done <"$_stdin_buf" if [ "$fail" -ne 0 ]; then printf '\nfix sig + committer:\n' >&2 diff --git a/justfile b/justfile index bd8a9a2..8cb4dc6 100644 --- a/justfile +++ b/justfile @@ -1127,7 +1127,12 @@ _chezmoi-init: chezmoi init -S . _install-hooks: - git config core.hooksPath .githooks + # User-level dotfiles git hooks (~/.config/git/hooks) now auto-dispatch + # into `/.githooks/`, so a per-repo core.hooksPath override + # is no longer needed (and would suppress the global agent-author / + # signed-commits checks). Strip any leftover override from previous + # bootstraps. + git config --local --unset core.hooksPath 2>/dev/null || true # Install all flatpaks declared in meta/flatpak.txt. Flathub IDs are batched # into a single install call; URL bundles are downloaded and installed only diff --git a/remote-dev/home.nix b/remote-dev/home.nix index 5dc55d0..a94278b 100644 --- a/remote-dev/home.nix +++ b/remote-dev/home.nix @@ -161,7 +161,10 @@ in # so map each hook to its stripped name explicitly. The executable bit # comes from the working-tree file mode (git resolves the symlink). "git/hooks/pre-push".source = link "dot_config/git/hooks/executable_pre-push"; + "git/hooks/pre-commit".source = link "dot_config/git/hooks/executable_pre-commit"; "git/hooks/commit-msg".source = link "dot_config/git/hooks/executable_commit-msg"; + "git/hooks/post-commit".source = link "dot_config/git/hooks/executable_post-commit"; + "git/hooks/_dispatch.sh".source = link "dot_config/git/hooks/_dispatch.sh"; }; # ── Rootless podman config ────────────────────────────────────────────────── -- cgit v1.3.1