Andrew Mercer
on this page

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 a Cargo.toml, as established earlier).
  • terraform.yml — terraform fmt -check, terraform validate, tflint. Included only by Terraform repos.
  • A future docs.yml for 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.hooksPath is 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) or lefthook (single Go binary, remotes: config that mirrors your GitLab include: pattern) both give you "fix once, apply everywhere" the way skel's CI includes already do.
  • Given your existing architecture, extending skel with a hooks/ folder (split by domain: common, rust, terraform, ...) and using lefthook's remotes: 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.