Organizing Git Hooks Across Projects and Organizations¶
The core mechanism: core.hooksPath¶
Git looks for hook scripts in whatever directory core.hooksPath resolves
to — by default, .git/hooks/ inside each repo, which is not tracked by
git (it lives outside the working tree, in the .git/ metadata
directory). That's why your current workflow requires manually copying
files into it per repo.
core.hooksPath can be set at three levels, in increasing precedence:
| Scope | Command | Applies to |
|---|---|---|
| System | git config --system core.hooksPath <path> |
Every repo on the machine, every user |
| Global | git config --global core.hooksPath <path> |
Every repo for the current user |
| Local | git config core.hooksPath <path> (run inside a repo) |
That one repo only |
Local overrides global overrides system. A relative path in a local
config is resolved relative to the repo's working directory — which is
what makes a tracked, in-repo hooks directory possible: commit a
.githooks/ folder to the repo, then run git config core.hooksPath
.githooks once per clone, and git will use those tracked scripts.
Why git doesn't do this automatically on clone¶
This is a deliberate security decision, not an oversight. If cloning a
repo could silently enable arbitrary script execution on your next
commit/push, then cloning any untrusted repo would be equivalent to
running its code. So there is no "hooksPath: .githooks" setting you can
put in a tracked file that git will honor automatically — every hook
manager (Husky, lefthook, pre-commit) works around this the same way: by
requiring an explicit one-time install command after clone, which is
the moment you're trusting that specific repo's hooks.
This matters for your repo-create scaffolding: shipping .githooks/ in
repo-template gets the files into every new repo, but someone still has
to run the equivalent of git config core.hooksPath .githooks (or a hook
manager's install command) once after cloning before anything fires.
repo-create can automate that step since it already does other
first-time setup on a freshly created repo.
Plain tracked .githooks/ vs. a hook manager¶
What you have right now (a .githooks/pre-push script + core.hooksPath)
is the simplest version of "each repo can configure its own hook
directory" — and the answer to that question is yes, unconditionally:
core.hooksPath is always a per-repo local git-config value, so there's
no structural reason you couldn't have entirely different hook setups per
repo, per language, per team.
But plain tracked hooks have a real limitation for your situation
specifically: updates don't propagate. If you fix a bug in the
version-check logic, every existing repo that already cloned
repo-template is still running the old copy until someone manually pulls
or copies the updated file in. Compare this to your GitLab CI setup, where
include: project: 'andrewmercer/skel' fetches the current version of
that file from skel at pipeline-run time, every time — so fixing a
bug in skel fixes it everywhere instantly, with zero action needed in
downstream repos. Plain .githooks/ gives you none of that; it's really
just a slightly more organized version of what you have today with
~/.local/git_hooks/.
If "fix once, apply everywhere automatically" matters to you for hooks the
way it clearly does for CI (that's the whole reason skel exists), you
want a tool that fetches shared config from a central repo at runtime.
Two real options exist:
Option A: pre-commit framework¶
The long-established standard, despite the name it handles any hook type
(pre-commit, pre-push, commit-msg, etc.). Each repo gets a
.pre-commit-config.yaml that references other git repos by URL and
pinned revision:
repos:
- repo: https://gitlab.com/andrewmercer/git-hooks-rust
rev: v1.4.0
hooks:
- id: cargo-version-bump-check
- repo: https://gitlab.com/andrewmercer/git-hooks-common
rev: v2.1.0
hooks:
- id: no-secrets
pre-commit install wires up the actual git hook to invoke the
pre-commit tool, which fetches (and caches) the pinned hook repos and
runs them in isolated, language-appropriate environments. Pins are
per-repo and explicit (rev: v1.4.0), so nothing changes underneath you
unexpectedly — you bump the rev deliberately (there's a built-in
pre-commit autoupdate for that).
Tradeoffs for your setup: it's a mature Python tool, so every machine
running hooks needs Python + pre-commit installed (not just your Rust
toolchain), and it spins up a small isolated environment per hook
language the first time. There's also an existing, well-maintained
pre-commit-terraform hook repo if you go this route for your Terraform
projects — you wouldn't be writing Terraform-specific checks from
scratch.
Option B: lefthook¶
A single Go binary (no Python runtime needed), configured via
lefthook.yml, with a remotes: block that plays almost exactly the role
include: project: plays in your GitLab CI files:
remotes:
- git_url: https://gitlab.com/andrewmercer/skel
ref: main
configs:
- hooks/common.yml
- hooks/rust.yml
Running lefthook install pulls the referenced config from the remote
repo and wires up the actual git hooks to invoke lefthook, which then
runs whatever commands those configs define. Being a single static binary
fits your Rust-CLI-tool aesthetic better than pulling in a Python
dependency, and since skel is already a git repo you control, this
slots directly into it rather than needing a new repo.
One caveat: double-check lefthook's current docs for exactly when it
re-fetches the remote (at install time vs. automatically on later runs)
— this has evolved across versions, and you want to know whether
"fix once in skel" requires everyone to re-run lefthook install or
happens for free on their next push.
Recommendation, given what you already have¶
You've already built exactly this pattern once, for CI: skel holds
composable, opt-in files split by domain (/release/..., /security/...,
/build/...), and each project's .gitlab-ci.yml just lists which ones it
wants. Do the same thing for hooks, and put them in skel too rather than
standing up a separate repo:
skel/
hooks/
common.yml # secret scanning, commit-msg format — applies to everything
rust.yml # cargo fmt/clippy, Cargo.toml version-bump check
terraform.yml # terraform fmt, terraform validate, tflint
release/
... # (already exists)
security/
... # (already exists)
build/
... # (already exists)
A Rust CLI project's lefthook.yml would pull common.yml + rust.yml;
a Terraform repo would pull common.yml + terraform.yml. This gives you
the "Rust tools share one hook set, Terraform shares a different one"
split you asked about, without inventing a second shared-config
repo alongside skel — you're just extending the same one with a new
top-level folder, the same way build/ got added for the Docker-image
job.
Should there be one hooks config or several?¶
Split by what the check needs to exist to make sense, same principle as your CI includes — a Terraform repo including a Cargo.toml-version-bump check would just silently no-op (harmless but pointless) or, worse, error out because there's no Cargo.toml. Concretely:
common.yml— checks with no language dependency: secret scanning, commit message format, large-file guards, whitespace/EOL checks. Every repo includes this.rust.yml—cargo fmt --check,cargo clippy, the Cargo.toml version-bump check. Included by Rust CLI tools and Docker-app repos alike (both have aCargo.toml, as established earlier).terraform.yml—terraform fmt -check,terraform validate,tflint. Included only by Terraform repos.- A future
docs.ymlfor the Django docs site, if/when you want Python-side checks (black,flake8, whatever) — not relevant to Rust or Terraform repos at all.
This also answers "can each repo configure its own hook directory" a
second way: yes, and it should — each repo's lefthook.yml (or
.pre-commit-config.yaml) is where it declares which shared config
fragments actually apply to it. The directory itself lives centrally in
skel; what each repo pulls in is local, per-repo, and explicit.
Where enforcement actually needs to live¶
One more thing worth being clear-eyed about, given the version-bump use
case that started this: client-side hooks are always bypassable —
git push --no-verify skips every one of them, whether it's a raw shell
script, pre-commit, or lefthook. That's true no matter how well the
hook is written or where its config is centralized.
So for anything that genuinely must not slip through (a version bump
before merging to main, say), the hook is a fast-feedback convenience —
catch the mistake before it costs you a push/CI round-trip — but the real
enforcement has to live server-side. In your setup, that's already
check_version_bumped running as a required job on the merge-request
pipeline, which nobody can skip with a flag on their laptop. If you're
self-hosting GitLab (Premium/Ultimate), Push Rules or protected-branch
settings are the other server-side lever; on GitLab.com's free tier, a
required MR pipeline job is the equivalent you already have.
Client-side hooks (via .githooks/, pre-commit, or lefthook) are the
right tool for "tell the developer immediately, locally, before they even
push" — they're not the right tool for "this absolutely must never
happen," which is what CI enforcement is for.
Summary¶
core.hooksPathis always per-repo local config — different repos can point at completely different hook setups with zero conflict.- Plain tracked
.githooks/(what you have now) works but doesn't propagate updates — every repo is a frozen copy until manually refreshed. pre-commit(Python, very mature, huge ecosystem including Terraform-specific hooks) orlefthook(single Go binary,remotes:config that mirrors your GitLabinclude:pattern) both give you "fix once, apply everywhere" the wayskel's CI includes already do.- Given your existing architecture, extending
skelwith ahooks/folder (split by domain:common,rust,terraform, ...) and usinglefthook'sremotes:to pull the relevant pieces per repo is the most consistent choice — same mental model you've already built for CI, same repo, no new infrastructure to maintain. - Whatever you pick, remember it's advisory — the version-bump rule you actually need enforced without exception stays in CI, not in a git hook.