aboutsummaryrefslogtreecommitdiffstatshomepage
diff options
context:
space:
mode:
-rwxr-xr-x.githooks/pre-push5
-rw-r--r--README.md10
-rw-r--r--dot_config/git/hooks/_dispatch.sh31
-rwxr-xr-xdot_config/git/hooks/executable_commit-msg4
-rwxr-xr-xdot_config/git/hooks/executable_post-commit14
-rwxr-xr-xdot_config/git/hooks/executable_pre-commit15
-rwxr-xr-xdot_config/git/hooks/executable_pre-push11
-rw-r--r--justfile7
-rw-r--r--remote-dev/home.nix3
9 files changed, 90 insertions, 10 deletions
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 `<repo>/.githooks/<hookname>` if present — so projects can drop their own hooks at `.githooks/<name>` 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 `<repo-top>/.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/<hookname>` (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/<hook>` 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
+# `<repo>/.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
+# `<repo>/.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 </dev/null | tr '0-9a-f' '0')
expected_name=$(git config user.name || true)
@@ -108,7 +117,7 @@ while read -r _local_ref local_sha remote_ref remote_sha; do
printf '\non %s:\n%s\n' "$remote_ref" "$bad" >&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 `<repo>/.githooks/<name>`, 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 ──────────────────────────────────────────────────