
Beyond Prompts: Enforcing Repository Rules With Git Hooks

You have probably already tried the obvious thing. You wrote the rules down - in AGENTS.md, CLAUDE.md, .cursor/rules, or whatever file your agent reads first. Do not commit without tests. Do not leave a component without a story. Run the type checker before you say you are done. The agent agreed, enthusiastically, and then went ahead and did something else anyway.
This is not a prompt-quality problem, and it is not specific to any one model. Large language models optimize for the next plausible token, not for your repository's conventions. In a small codebase you can review every diff and catch the drift by hand. In a large one you cannot: the agent touches a dozen files across a monorepo, the formatting is off, a component is missing its story, a test that used to pass now does not, and the commit lands anyway because nobody is running the checks at the moment the change is written.
The fix is not a better prompt; it is a gate. A Git hook runs your checks on every change, at git commit and git push, whoever - or whatever - wrote it. The tool I use to manage them is Lefthook: a single, dependency-free Go binary, driven by one YAML file, able to run jobs in parallel and re-stage the files it fixes. It reaches past Git, too: an ai: block (currently beta) lets Claude Code, Cursor, Codex, and Copilot run Lefthook hooks on their own events, so your checks fire when the agent stops working - before anything is ever committed. What follows is a working setup: the Git hooks first, then the same checks wired into the agent.
Quick Reference
If you want the short answer before the walkthrough, this is the whole tool at a glance:
| What it is | A hooks manager for Git and for coding agents; one binary, no runtime dependency |
| Config file | lefthook.yml, or a .lefthook.yaml / .toml / .json variant |
| Install the hooks | lefthook install |
| Run a hook by hand | lefthook run pre-commit |
| Validate the config | lefthook validate |
| Skip once | LEFTHOOK=0 git commit |
| Agent hooks (beta) | Run your Lefthook hooks on Claude Code, Cursor, Codex, or Copilot events |
| Current version | v2.1.17 (October 2026) |
The config we build in the next sections wires up five checks on commit and two on push; later, the same checks are attached to your coding agent's events.
Why Instructions Are Not Enforcement
A written rule is advisory. The agent may follow it, may partially follow it, or may follow it for the first three files and forget by the fourth - and you will not know which until you read the diff. A hook is deterministic. It runs the same command on the same files every single time, it fails closed, and it does not care who authored the change. If the check fails, the commit does not happen.
That difference matters more, not less, as codebases grow. Instructions have to be read, remembered, and applied; enforcement is automatic. It also gives you a single place to put the rules that are specific to your project - the ones no linter ships with, like "every component must have a story" - instead of repeating them across agent instruction files and hoping.
Lefthook fits here because it is small. It is a Go binary with no runtime to install, and it reads lefthook.yml on every hook run, so you never reinstall after editing the config - for Git hooks or for the agent hooks we add later.
Install Lefthook
The most common route is the package manager you already use. For a Node project that is the lefthook npm package:
pnpm add -D lefthook # or: npm install --save-dev lefthook / yarn add --dev lefthook
There are equivalents for almost every ecosystem, all documented in the installation guide: brew install lefthook on macOS and Linux, go install github.com/evilmartians/lefthook/v2@v2.1.17, pipx install lefthook, gem install lefthook, plus apt, rpm, apk, winget, scoop, snap, and mise. If you would rather not use a package manager at all, download the binary from the latest release and put it on your PATH; lefthook self-update keeps it current.
For CI the mechanics are slightly different, because Git hooks are a local, client-side feature. Nothing runs your pre-commit hook in a container that just cloned the repo - there is no .git/hooks/pre-commit doing work unless you install it. So the CI job installs Lefthook and then calls the hook explicitly:
pnpm add -D lefthook pnpm exec lefthook run pre-commit --all-files
--all-files replaces {staged_files} with every tracked file, which is what you want when there is no index to speak of. There is no official Lefthook GitHub Action and no official Docker image, so in CI you install the binary yourself - brew, curl, npm, or your base image's package manager - and call the hook explicitly. One detail to know: when the npm package is installed with CI=true set, its postinstall script deliberately skips hook installation, so a CI run will not try to wire up .git/hooks that nothing will execute. Your CI step calling lefthook run is what does the work.
Step 1: Write the Config
The whole configuration is one file at the repository root. This is the shape I use on a TypeScript/React project, and it is the config the runs below use:
# lefthook.yml min_version: 2.1.10 lefthook: pnpm exec lefthook glob_matcher: doublestar pre-commit: jobs: - name: format staged files glob: "**/*.{js,jsx,ts,tsx,json,md,yml,yaml,css}" run: pnpm format {staged_files} stage_fixed: true - name: lint staged files glob: "**/*.{js,jsx,ts,tsx}" run: pnpm lint {staged_files} stage_fixed: true - name: typecheck run: pnpm check:types - name: stories-present glob: "src/components/**/*.{ts,tsx}" exclude: "**/*.stories.{ts,tsx}" run: pnpm check:stories {staged_files} - name: unit tests run: pnpm test:unit pre-push: parallel: true jobs: - name: full verification run: pnpm verify - name: storybook tests run: pnpm test:storybook
Seven jobs in total, split across the two hooks:
| Job | Hook | What it enforces |
|---|---|---|
format staged files | pre-commit | Prettier rewrites staged files, then Lefthook re-stages them |
lint staged files | pre-commit | ESLint --fix rewrites staged files, then re-stages them |
typecheck | pre-commit | tsc --noEmit over the whole project |
stories-present | pre-commit | A custom rule: every component has a story file |
unit tests | pre-commit | node --test |
full verification | pre-push | Typecheck, tests, and the custom checks together |
storybook tests | pre-push | Component-level tests |
A few keys carry most of the weight. The format, lint, check:*, and test:* targets are your own package.json scripts, so the config stays tool-agnostic: replacing Prettier with Biome is a change to one script body, not to seven hook entries. jobs is the modern list form (the older commands: map still works, but jobs also support scripts and groups). glob filters the file list a job receives, and exclude removes files from that filtered list. run is the command, executed with sh. The {staged_files} template expands to the files staged for the current commit - for a pre-push job you would use {push_files}, and {all_files} gives you everything tracked.
stage_fixed: true is the one that changes how it feels to use. When a job rewrites its files - Prettier reformatting, ESLint applying an autofix - Lefthook runs git add on them afterward, so the fixes land in the same commit instead of leaving a dirty working tree behind. It only works on pre-commit, and because each of those jobs mutates the index, I keep the auto-fixing jobs sequential by leaving parallel at its default (false). If the git add fails, Lefthook fails the hook rather than let the commit go through with unfixed content. If you would rather fail than let a hook rewrite files, invert the two auto-fixing jobs: run prettier --check and eslint without --fix, drop stage_fixed, and the pre-commit hook becomes a pure, parallel-safe gate.
The typecheck, stories-present, and unit tests jobs do not use {staged_files}; they run against the whole project, because a change in one file can break a type or a test somewhere else. The stories-present job is the interesting one for the agent narrative: it is a rule that exists only in this repository. Its glob is the part that matters, and it is also the part that is easy to get wrong - see The Glob That Silently Disables Your Hook.
The pre-push hook is deliberately broader and parallel. It runs pnpm verify (types, unit tests, and the custom story check) alongside the component tests. This is the layer that catches what a distracted --no-verify commit slipped past, and it is the right place for the slower checks you do not want on every keystroke.
Step 2: Install and Run the Hooks
Install the hooks once per clone:
pnpm exec lefthook install
sync hooks: ✔️(pre-push, pre-commit)
That command writes small shell scripts into .git/hooks/ for each configured hook; each script simply calls lefthook run <hook>. Because the config is read fresh every time a hook runs, editing lefthook.yml never requires reinstalling. You can confirm the hooks are present and synchronized with:
pnpm exec lefthook validate # config is well-formed pnpm exec lefthook check-install # exit 0 if hooks are installed and in sync
To run a hook without committing, lefthook run is the entry point. lefthook run pre-commit runs the hook as-is, lefthook run pre-commit --all-files forces every tracked file through it, --file path/to/file forces a specific list, and --job or --tag narrows it to specific jobs. Any hook name is a valid target, so a custom block such as verify: becomes lefthook run verify.
Two escape hatches are worth knowing. LEFTHOOK=0 git commit disables hooks for a single command, and git commit --no-verify bypasses hooks at the Git level. I mention them because they are necessary - but they are also exactly why the push hook and CI matter: a rule enforced only at commit time is a rule that a tired developer or a confident agent can skip.
Finally, teams rarely want identical hooks on every machine. A lefthook-local.yml file (git-ignored) merges over lefthook.yml, so an individual can set skip: true on a slow job or re-tag a command without touching the shared config.
What It Looks Like When a Commit Is Blocked
Here is a real run, on a small demo repository, of an agent finishing a change and committing it. The change adds a greet helper written in double quotes with a let that is never reassigned, and a Modal component with no story file. The first attempt:
$ git commit -m "feat: add greeting helper and modal component" ╭───────────────────────────────────────╮ │ lefthook v2.1.17 hook: pre-commit │ ╰───────────────────────────────────────╯ ┃ format staged files ❯ $ prettier --write lefthook.yml src/components/Modal.ts src/greeting.ts lefthook.yml 12ms src/components/Modal.ts 18ms (unchanged) src/greeting.ts 3ms ┃ lint staged files ❯ $ eslint --fix src/components/Modal.ts src/greeting.ts ┃ typecheck ❯ $ tsc --noEmit ┃ stories-present ❯ $ node scripts/check-stories.mjs src/components/Modal.ts Missing story files for: src/components/Modal.ts [ELIFECYCLE] Command failed with exit code 1. exit status 1 ... ──────────────────────────────────── summary: (done in 1.24 seconds) ✓ format staged files (0.26 seconds) ✓ lint staged files (0.44 seconds) ✓ typecheck (0.37 seconds) ✓ unit tests (0.11 seconds) ✗ stories-present (0.05 seconds)
The commit does not happen. Two different things occurred in that run, and they are the two halves of why this works. First, format and lint silently fixed the sloppy code: Prettier rewrote the quotes and style, ESLint turned the let into a const, and stage_fixed re-staged both. You can prove the fixed content is what is staged, not just present in the working tree:
$ git show :src/greeting.ts export function greet(name: string) { const message = 'Hello, ' + name + '!' return message }
Second, stories-present refused the change. The component exists, the story does not, and no amount of instruction-following on the agent's part makes the commit land. Add the missing story and the same commit passes, every job green:
$ git add src/components/Modal.stories.ts $ git commit -m "feat: add greeting helper and modal component" ... ──────────────────────────────────── summary: (done in 1.17 seconds) ✓ format staged files (0.14 seconds) ✓ lint staged files (0.47 seconds) ✓ typecheck (0.38 seconds) ✓ stories-present (0.05 seconds) ✓ unit tests (0.12 seconds) [main de227fe] feat: add greeting helper and modal component 4 files changed, 17 insertions(+), 4 deletions(-)
git push runs the second gate. Both jobs run in parallel:
$ git push origin main ╭─────────────────────────────────────╮ │ lefthook v2.1.17 hook: pre-push │ ╰─────────────────────────────────────╯ ┃ storybook tests ❯ $ node scripts/check-stories.mjs Story check passed for 2 component file(s). ┃ full verification ❯ $ pnpm check:types && pnpm test:unit && pnpm check:stories $ tsc --noEmit $ node --test "src/**/*.test.ts" ✔ adds two numbers (0.467667ms) ℹ pass 1 ℹ fail 0 $ node scripts/check-stories.mjs Story check passed for 2 component file(s). ──────────────────────────────────── summary: (done in 0.65 seconds) ✓ storybook tests (0.06 seconds) ✓ full verification (0.55 seconds)
The Glob That Silently Disables Your Hook
The config above includes glob_matcher: doublestar, and that one line is doing more than it looks. A pattern like src/components/**/*.{ts,tsx} reads as correct, but without doublestar it never matches a file sitting directly in src/components/. Here is what that looks like on a commit that adds src/components/Modal.ts with no story:
$ git commit -m "feat: add modal component" ... │ stories-present (skip) no files for inspection ──────────────────────────────────── summary: (done in 1.10 seconds) ✓ format staged files (0.11 seconds) ✓ lint staged files (0.49 seconds) ✓ typecheck (0.38 seconds) ✓ unit tests (0.12 seconds) [main 511f599] feat: add modal component 1 file changed, 3 insertions(+)
The check did not fail. It did not run at all. In Lefthook's default matcher (gobwas), ** matches one or more directories, not zero or more, so src/components/**/*.ts requires at least one directory between src/components/ and the file - it matches src/components/Button/Button.ts but not src/components/Modal.ts. With no matching files, Lefthook prints (skip) no files for inspection and moves on, and the commit succeeds. A guard you believe is watching is worse than no guard, because you stop checking by hand.
There are two ways out. The recommended one is to switch to standard glob semantics for the whole config:
glob_matcher: doublestar
With doublestar, **/ matches zero or more directories, so src/components/**/*.{ts,tsx} matches Modal.ts as expected - this is the reason the setting appears at the top of the config above. The catch is that the same setting also makes * separator-aware: a bare *.ts now matches only files in the repository root, where the default matcher had let * cross /. That is why every file glob in the config above uses **/*.{...}: with doublestar, that is how you say "at any depth".
The second option is to keep the default matcher and write the pattern for its rules: because * crosses / there, src/components/*.{ts,tsx} already matches at any depth. That works, but it depends on a matcher behavior most people do not expect, so I prefer the explicit switch. Either way, the lesson generalizes: after you add a file-filtering rule, verify it actually fires. Lefthook's (skip) no files for inspection line in the output is the tell.
Make the Agent Run the Checks Too
The hooks above fire on git commit and git push. An agent that edits twenty files and stops - without committing - never triggers them. Lefthook has a beta feature for exactly this: an ai: block that declares which of your hooks an agent should call on which of its own events. During lefthook install, Lefthook generates the provider's settings file for you.
ai: claude: Stop: verify cursor: stop: verify verify: jobs: - name: typecheck run: pnpm check:types - name: unit tests run: pnpm test:unit
Stop is Claude Code's event for "the agent finished responding", and stop is the Cursor equivalent; both map to the verify hook that Lefthook then runs. Supported providers are claude, codex, cursor, and copilot. Running lefthook install with the config above updates .claude/settings.json:
{ "hooks": { "Stop": [ { "hooks": [{ "type": "command", "command": "pnpm exec lefthook run verify" }] } ] } }
Note the command. Generated hook commands use the top-level lefthook: value when it is set - here pnpm exec lefthook - and otherwise fall back to the absolute path of the binary that ran install. Since that absolute path is machine-specific and would poison a committed settings file, setting lefthook: to a portable command is the difference between a file your teammates can share and a file that only works on your laptop. This feature is marked beta, so treat the generated output as version-sensitive and keep an eye on it when you upgrade.
Lefthook vs. pre-commit vs. Husky
| Lefthook | pre-commit | Husky | |
|---|---|---|---|
| Runtime | Single Go binary, no runtime | Python framework | Node.js |
| Config | One YAML/TOML/JSON file | .pre-commit-config.yaml | Shell scripts in .husky/ |
| Runs against | Staged/pushed/all files via templates | Staged files by default | Whatever the script does |
| Parallel jobs | Yes | Limited | No (manual) |
| Isolated tool envs | No (uses your project's tools) | Yes (per-hook language envs) | No |
| Language ecosystems | Any (it just runs commands) | Many, via hook repos | Any, in Node projects |
There is no objective winner here; pick the one that fits how your repository already works. If you rely on pre-commit's managed, per-language hook environments, stay there - that isolation is real value. If your repo is Node-based, Husky is familiar and perfectly capable. I reach for Lefthook for three reasons: it is one binary so nothing new has to be installed at runtime, its jobs run in parallel so the hook stays fast, and {staged_files}, stage_fixed, and glob filtering are built into the config rather than pushed into scripts. As always with tools, the right answer is the one your team will actually keep running.
Troubleshooting & Gotchas
A glob that matches nothing silently skips the job. This is the failure from the section above, and it is the most dangerous one because the hook reports success. ** in the default gobwas matcher means one or more directories; (skip) no files for inspection in the output means your filter is excluding everything. Set glob_matcher: doublestar and use **/ where you mean any depth.
stage_fixed only works on pre-commit, and it needs the index. It calls git add after the job rewrites files, so it cannot fix anything on pre-push or a custom hook. Keep auto-fixing jobs on pre-commit and sequential: if several jobs run in parallel and rewrite the index at the same time, keep them out of that batch.
--no-verify and LEFTHOOK=0 bypass everything. Hooks are a convenience gate, not a security boundary. For rules that must hold, run the same checks in CI (lefthook run pre-commit --all-files) and make that job required. Do not rely on the client-side hook as your only enforcement.
pnpm gates the postinstall script. The lefthook npm package installs hooks from a postinstall script, but pnpm blocks dependency build scripts by default. In pnpm 11/12 the setting moved from pnpm.onlyBuiltDependencies in package.json to an allowBuilds map in pnpm-workspace.yaml; approve it and the postinstall runs. Failing that, lefthook install explicitly does the same thing.
Fresh releases can be held back by supply-chain policies. pnpm refused to install the just-published lefthook@2.1.17 because it was inside the default minimumReleaseAge window; minimumReleaseAge: 0 (or waiting out the window) is the adjustment. Useful to know when a version you just saw released will not install yet.
A long file list is split into several commands. Command-line length is capped on every OS, so Lefthook runs your command in batches when {staged_files} is large. A linter that prints per-run summaries will print one per batch.
Check that the hooks are actually installed. A fresh clone has no hooks until lefthook install runs - the npm postinstall usually handles it, but clones that skip install scripts will not, and CI=true makes the postinstall skip on purpose. lefthook check-install returns exit code 0 only when the hooks are present and in sync, which makes it a cheap guard in a setup script.
Conclusion
Agentic coding has not changed the need for quality gates; it has made them more valuable, because the volume of change goes up while the attention per change goes down. Writing the rules in an instruction file is still worth doing, but it is not enforcement. A Lefthook config is a few dozen lines of YAML that makes the same checks run on every commit and push, whoever - or whatever - wrote the code, and the beta ai: block extends the same jobs to the agent's own events. Start with one hook: format and lint the staged files, re-stage the fixes, and refuse the commit if the tests fail. In a large codebase, that one gate prevents more bad diffs than any prompt.
Resources
- Lefthook documentation
- Configuration reference
- Installation guide
glob_matcherstage_fixedai- agent hooks (beta)- GitHub repository
- pre-commit
Optimizing your AI development workflow?
Talk to u11d about integrating robust quality controls into your agentic coding environment to ensure codebase integrity.

Frequently Asked Questions
My AI coding agent keeps committing code that fails lint and tests. How do I stop it?
Install Git hooks with Lefthook so the checks run before the commit is created. If any Lefthook job exits non-zero, the commit is aborted, meaning code that fails your checks cannot be committed even if an agent ignores your written instructions.
How do I make Git hooks run automatically after every clone?
Add Lefthook to your project's dependencies and let the package's postinstall script run lefthook install. This writes the necessary hooks into .git/hooks for each developer automatically.
Why did my Lefthook job skip execution and report "no files for inspection"?
This happens when your glob pattern does not match any files due to the default matcher's behavior. Set glob_matcher: doublestar in your config to ensure / correctly identifies files at any directory depth.
How do I make Lefthook automatically commit files fixed by a formatter?
Set stage_fixed: true on your job in the pre-commit hook. After the formatter rewrites the files, Lefthook automatically runs git add to include the corrected content in the same commit.
Can I bypass Git hooks for a single commit?
Yes, you can use LEFTHOOK=0 git commit or the standard git commit --no-verify. Since these are intentional bypasses, you should also enforce these same checks in your CI pipeline using lefthook run pre-commit --all-files.
Does Lefthook work with AI agents like Claude Code or Cursor?
Yes. By using the ai: block in your lefthook.yml, you can map agent events like 'Stop' or 'stop' to specific Lefthook hooks. This ensures your verification suite runs immediately when an agent completes a task.





