Git Auditor: Native Shell Hooks for a Minimum Viable Team Standard

When teams grow, commit styles drift and lint catches up too late. Git Auditor uses versioned shell hooks in .githooks/ to reinforce a minimum viable standard — locally, before CI.

EP

Ender Puentes

Context: a minimum viable standard for the team

When a team grows — or when you return to a repo after a while — the same friction shows up: everyone commits with a different style, branch names are hard to read at a glance, and lint or type issues surface late, on push or in CI. It is not bad intent; it is the lack of an agreed common floor.

This note describes how we address that with Git Auditor: native hooks in .githooks/ that reinforce a minimum viable standard — readable commits, traceable branches, lint and types before push — without making day-to-day work heavier.

The idea is simple: rules live in the repo, activate automatically when dependencies are installed, and help everyone write code in a similar way before the remote or CI has to step in.

How it is structured

The pattern splits three layers that should stay separate:

  • Init (prepare) · Activates hooks on each developer machine — Node (bootstrap only)
  • Hooks (.githooks/*) · Applies agreed rules on every commit/push — Shell + Git (universal)
  • Invoked commands · Run lint, types, dead-code checks, etc. — Project runtime (pnpm, go test, ruff, …)

Hooks are #!/bin/sh scripts. Lightweight policy — commit format, branch names, what runs on pre-commit vs pre-push — lives there with tools Git already runs. Heavier checks (linter, TypeScript, etc.) delegate to the project toolchain.

We chose native hooks with core.hooksPath because Git already provides the hook point; we version scripts in the repo and avoid another layer just to orchestrate the same thing. Husky, Commitlint, and similar tools can play a similar role; here we prioritize readable scripts, few dependencies for message validation, and a pattern reusable across stacks by changing only the commands each hook invokes.

Why .git/ does not version hooks (and why a script is required)

Git stores local repository metadata inside .git/. That directory is not pushed to the remote: each clone is independent. Hooks Git runs by default live in .git/hooks/ — also local, also outside version control.

In practice, a pre-commit copied by hand into .git/hooks/ does not reach the rest of the team on git pull. Someone who clones from scratch does not inherit those scripts. Sharing rules requires a different approach.

The solution is a two-piece pattern:

  • Audit scripts · .githooks/ at repo root — Yes — travel with the code
  • Link “use these scripts as hooks” · core.hooksPath in .git/config — No — local per clone

core.hooksPath tells Git: when running pre-commit, commit-msg, etc., look in .githooks/ in the working tree, not in .git/hooks/. The files are in the repo; the link is not. That is why an init script (setup:githooks:init) must run, at minimum:

bash
git config core.hooksPath .githooks

Without that step, the team shares rules in .githooks/ but Git never invokes them. The script closes that gap on each machine. Init variants may delegate to init.sh / init.bat on Windows or consolidate logic in a single init.js; the problem and solution are the same.

How activation works: the prepare script

In package.json:

json
"setup:githooks:init": "node .githooks/scripts/init.js",
"prepare": "pnpm run setup:githooks:init"

prepare is an npm/pnpm lifecycle script that runs automatically after `pnpm install`. Cloning the repo and installing dependencies already triggers init — no extra README step to remember.

On each run, init:

1. Reads local core.hooksPath; if it does not point to .githooks, configures it.

2. Verifies the .githooks/ directory exists.

3. On Unix, marks hooks executable (chmod +x).

If someone cloned before prepare existed, or cleared their .git/config, they can reactivate manually:

bash
pnpm run setup:githooks:init
# equivalent:
git config core.hooksPath .githooks

How rules apply on each machine

Worth distinguishing two things: the rules the repo defines and how they activate on each computer.

  • Versioned: content of .githooks/* — what to validate, error messages, which commands to run.
  • Local: activation (core.hooksPath) and execution on each developer machine on git commit or git push.

Every team member has their own .git/config. Two people on the same repo can be in different states: one with hooks active, another with an empty core.hooksPath if they never ran install/init. Auditing is not centralized like a remote pre-receive hook; it is a process agreement reinforced locally.

That implies explicit limits:

  • A developer can run git commit --no-verify or git push --no-verify and skip hooks.
  • They can edit or remove core.hooksPath in local config.
  • Updating .githooks/pre-push in the repo does not retroactively “install” anything on machines that do not run init or pnpm install again (where prepare revalidates the link).

Hooks are team standard support — better developer experience, not a security wall. They help early, in the usual flow, for anyone who completed init. CI and branch protection on the remote remain the safety net for what local hooks cannot enforce.

Why we use shell in hooks

Git runs a hook as a child process of the git binary. The contract is simple: an executable that exits with code 0 (ok) or non-zero (block the operation).

Shell meets that contract on Linux, macOS, and most dev environments without compiling anything:

  • Pattern portability: the same .githooks/ + prepare scheme works on any stack. Only the invoked commands change (pnpm lint, make test, cargo clippy, etc.).
  • Lightweight validation without a runtime: Conventional Commits rules, header length, branch name regex, or merge commit detection do not need Node. A grep -qE in commit-msg is enough and starts in milliseconds.
  • Single source of truth: .githooks/commit-msg is the source of truth for commit format; repo documentation points there.
  • Fewer failure points in the hook itself: if the hook depended on node node_modules/.bin/commitlint, an incomplete install or broken PATH would block commits before the message is validated.

Hooks do delegate to the project stack for the heavy part of the standard: linter, type checking, dead-code detection. Shell orchestrates; the repo language executes.

What a minimum viable standard can include

Concrete rules depend on the team and repo maturity. The pattern supports layering without changing mechanism:

  • pre-commit · Before creating the commit — Lint on staged files; formatting; quick tests. Skips merges.
  • commit-msg · When writing the message — Conventional Commits; max length; branch policy (main merge/hotfix only).
  • pre-push · Before pushing — Typecheck; full lint; dead-code analysis; branch name validation.

Explicit bypass (when needed): git commit --no-verify / git push --no-verify.

The init calls itself Git Auditor in the console: it summarizes the intent to channel the standard before code reaches the remote or CI.

The minimum viable standard grows with the team — more rules in pre-push as the repo matures, release windows, branch protection — but always under the same contract: policy versioned in `.githooks/`, local activation via script, shell execution, heavy checks delegated to the project toolchain.

When it makes sense (and when it does not)

Makes sense when:

  • You want the whole team to write code and commits under the same baseline rules, without relying on memory or constant manual review.
  • The team uses Git consistently and you can hook init to pnpm install (or equivalent prepare on npm/yarn).
  • You prefer readable, adjustable hooks without an extra framework for message validation.

Honest limitations (complement to local enforcement above):

  • Windows needs attention (sh, Git Bash, or .bat scripts in init).
  • If rules grow large, extract shared functions in .githooks/lib/ (still shell) before migrating to another tool.

Quick reference

text
Goal:         minimum viable coding standard, enforced locally
Activation:   pnpm install → prepare → setup:githooks:init
Git config:   git config core.hooksPath .githooks   (local, in .git/config)
Hooks:        .githooks/pre-commit | commit-msg | pre-push   (versioned)
Init:         .githooks/scripts/init.js
Docs:         .githooks/README.md

Manual verification:

bash
git config --get core.hooksPath   # should print: .githooks
pnpm run setup:githooks:init      # re-run init if needed