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 enforce a shared baseline — on your machine, before CI.

Context: a baseline the team agrees on

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:

  • Reads local core.hooksPath; if it does not point to .githooks, configures it.
  • Verifies the .githooks/ directory exists.
  • 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

Hiding .githooks in the file explorer

What does this do in VS Code? Cursor and VS Code read workspace settings from .vscode/settings.json. The files.exclude key only hides folders in the file explorer — it does not delete them from disk or stop Git from using them. It is ergonomics: policy stays versioned and runs on its own after pnpm install; .githooks/ does not need to compete for attention while you write features. To open a hidden hook: Ctrl/Cmd+P and the path, or temporarily remove the entry from files.exclude.

Hooks must live in the repo and stay editable when the team changes policy. That was never in question. What we kept seeing was noise in the file tree: junior devs opened .githooks/, saw shell scripts, got nervous, or poked at them “just to see what happens.” Others got distracted by folders that are not part of day-to-day feature work.

This is not a security wall. Anyone can skip hooks with --no-verify or clear core.hooksPath. It is an ergonomics compromise: policy is versioned and runs on its own after pnpm install; it does not need to sit front and center while you write components.

In .vscode/settings.json — Cursor inherits the same config — we add .githooks to files.exclude:

json
"files.exclude": {
  ".githooks": true
}

The folder stays on disk. Git still uses it. If you need to change a hook, open the path directly or turn off the exclude for a moment. For everyone else, the repo looks cleaner and attention stays on src/, not wiring.

We later applied the same idea to .agents/ and AI editor folders: hide the mechanism, show only the source of truth.

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 the baseline 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 baseline 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.

Steps to implement in your repo

Before you start: you need a Git repo, Node.js 18+, and a root package.json. If you use npm or yarn instead of pnpm, swap the package manager in the examples — init.js stays the same.

1. Create the .githooks/ folder

At repo root:

bash
mkdir -p .githooks/scripts
touch .githooks/pre-commit .githooks/commit-msg .githooks/pre-push

Target structure:

text
.githooks/
├── pre-commit      # lint on staged — define later
├── commit-msg      # Conventional Commits + branch policy
├── pre-push        # typecheck, knip, full lint
├── scripts/
│   └── init.js     # ← copy the file below
└── README.md       # team docs (optional at first)

Each hook will be an executable #!/bin/sh script. Get init working first; add hook logic afterward.

2. Create the init — copy this entire file

Create .githooks/scripts/init.js and paste all of this (no extra npm packages; only Node and Git):

javascript
#!/usr/bin/env node
'use strict';

/**
 * Git hooks init — sets core.hooksPath and marks hooks executable.
 */

const { execFileSync } = require('child_process');
const fs = require('fs');
const path = require('path');

const HOOKS_PATH = '.githooks';
const root = path.resolve(__dirname, '../..');
const hooksDir = path.join(root, HOOKS_PATH);

function getHooksPath() {
  try {
    return execFileSync('git', ['config', '--get', 'core.hooksPath'], {
      cwd: root,
      encoding: 'utf8',
    }).trim();
  } catch {
    return '';
  }
}

const currentHooksPath = getHooksPath();

if (currentHooksPath !== HOOKS_PATH) {
  console.log('🔧 Initializing Git Auditor...');
  console.log(`   Configuring Git hooks path to ${HOOKS_PATH}...`);
  execFileSync('git', ['config', 'core.hooksPath', HOOKS_PATH], {
    cwd: root,
    stdio: 'inherit',
  });
  console.log('✅ Git Auditor initialized successfully');
} else {
  console.log('✅ Git Auditor already initialized');
}

if (!fs.existsSync(hooksDir)) {
  console.error(`⚠️  Warning: Hooks directory ${HOOKS_PATH} not found`);
  process.exit(1);
}

if (process.platform !== 'win32') {
  for (const entry of fs.readdirSync(hooksDir)) {
    const filePath = path.join(hooksDir, entry);
    if (fs.statSync(filePath).isFile()) {
      try {
        fs.chmodSync(filePath, 0o755);
      } catch {
        // Ignore chmod errors
      }
    }
  }
}

console.log('');
console.log('Git Auditor is now active.');
console.log('  Verify: git config --get core.hooksPath  →  should print .githooks');

What it does: tells Git to use .githooks/, checks the folder exists, and on Mac/Linux marks hook files executable. The link lives in local .git/config, so run init after every clone.

3. Wire init into package.json

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

Chain with other inits if needed:

json
"prepare": "pnpm run setup:githooks:init && pnpm run setup:ai:init"

Manual test:

bash
pnpm run setup:githooks:init
git config --get core.hooksPath

Should print exactly: .githooks.

4. Define what each hook validates

In our repo:

  • `pre-commit`lint-staged on staged files; skips merge commits.
  • `commit-msg` — Conventional Commits format, 120-char header max, different rules for main, develop, and issue branches.
  • `pre-push`pnpm typecheck, pnpm knip, pnpm lint.

Adjust commands to your stack. The pattern stays the same: shell orchestrates, the repo runtime executes.

5. Hide .githooks in VS Code (optional but recommended)

In .vscode/settings.json:

json
"files.exclude": {
  ".githooks": true
}

Cursor inherits this config. The folder stays on disk and Git uses it; it just disappears from the explorer so people do not get distracted or poke scripts by accident. Not security — ergonomics.

6. Document and verify

Keep .githooks/README.md explaining each hook and how to bypass (--no-verify) when needed.

Verification on a fresh clone:

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

Try a commit with an invalid message — commit-msg should fail. Push without clean types — pre-push should fail. When that works, the team floor is active.

Quick reference

text
Goal:         shared coding baseline, 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
Hide noise:   .vscode/settings.json → files.exclude → .githooks
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