Git hooks for AI engineering
By Flavio Copes
Use Git hooks as deterministic guardrails for AI coding agents, with fast checks before commits, stronger checks before pushes, and CI as the final gate.
AI coding agents can change a lot of code very quickly.
That speed is useful. It also makes small mistakes easier to miss. An agent might forget a test, stage an unrelated file, or commit code that breaks a project rule.
Git hooks give us a deterministic checkpoint.
A hook runs when something happens in Git. It does not matter whether the change came from Cursor, Codex, Claude Code, a desktop Git client, or a person using the terminal.
This makes hooks especially useful in projects built with AI agents.
Why Git hooks fit AI engineering
We already give agents instructions. We might have an AGENTS.md, project rules, a prompt, or a detailed plan.
Those instructions guide the agent. They do not guarantee the final result.
A Git hook checks what the agent is about to commit. It can run the same command every time and reject the commit when that command fails.
I use this split:
- instructions explain how to work
- hooks catch fast, local mistakes
- CI verifies the repository on a clean machine
Hooks turn a few important expectations into executable checks, while review still handles everything else.
Git hooks and agent hooks are different
The word hook can mean two different things here.
An agent hook runs around an agent event. For example, it might run before a shell command or after a tool call.
A Git hook runs around a Git event:
pre-commit
commit-msg
post-commit
pre-push
Agent hooks know about the agent, while Git hooks know about the repository.
If several agents and tools work on the same project, Git is the shared boundary. Every tool eventually creates a commit or pushes one.
I prefer Git hooks for repository checks because every tool reaches that boundary.
Where Git hooks live
Git keeps local hooks inside its hooks directory.
Ask Git for the exact path:
git rev-parse --git-path hooks
Do not assume the path is always .git/hooks.
You might see files ending in .sample inside that directory. Git only runs a hook when it has the exact hook name without .sample.
The file must also be executable:
chmod +x .git/hooks/pre-commit
If a hook never runs, check its filename and permissions first.
Start with a fast pre-commit check
A pre-commit hook runs before Git creates a commit.
For an agent-built JavaScript project, I might start with:
#!/bin/sh
git diff --cached --check || exit 1
npm run check:commit
Save this as .git/hooks/pre-commit, then make it executable.
The first command checks the staged patch for whitespace errors and unresolved conflict markers. The second calls a normal project command.
The project might define that command like this:
{
"scripts": {
"check:commit": "npm run lint && npm run test:fast"
}
}
The exact checks depend on the project. Keep them fast enough to run on every commit.
When a command exits with status 0, Git continues. A non-zero status stops the commit.
This creates a useful agent loop:
- the agent changes the code
- the agent tries to commit
- the hook finds a problem
- the agent fixes it
- the commit succeeds
The failure message becomes feedback for the agent.
Make failures easy for an agent to fix
Agents work better when a check reports a concrete problem.
This message is not useful:
Validation failed
This one is:
src/auth/session.ts imports src/ui/modal.ts
Server code cannot import browser components.
Whenever possible, print the file, rule, and expected fix.
This matters for people too. A clear hook saves everyone from opening the script to understand what failed.
Put project rules in normal scripts
I keep Git hook files short.
The hook should call a project command:
#!/bin/sh
npm run check:commit
The actual checks belong in package.json, a shell script, or a small program under scripts/.
This gives us one command that works everywhere:
npm run check:commit
An agent can run it before committing. The Git hook can run it again. CI can use the same command.
We avoid hiding important project logic inside .git.
Use hooks to protect architectural boundaries
Linting and tests are the obvious checks. Hooks can also protect rules specific to your project.
For example, a project might require:
- browser code never imports server-only modules
- generated files match their source files
- database migrations accompany schema changes
- public functions include input validation
- documentation changes accompany public API changes
Turn rules like these into scripts:
#!/bin/sh
npm run check:boundaries &&
npm run check:generated &&
npm run test:fast
This is where hooks become more valuable than another paragraph in an agent prompt.
The prompt explains the architecture. The script proves that a change still respects part of it.
Do not try to encode every preference. Pick rules that are objective, quick, and expensive to forget.
Check the staged snapshot
Git commits the staged snapshot, also called the index.
The file in your editor might contain more changes than the staged version. This happens with partial staging. It also happens when several tasks share one working directory.
See the exact patch with:
git diff --cached
That output is what the commit will contain.
My advice is to keep pre-commit hooks read-only. Let them report problems without rewriting files.
Automatic formatting can modify the complete file. That may mix staged work with changes that were supposed to remain unstaged.
If you use lint-staged or another tool that rewrites staged files, test partial staging carefully.
Catch secrets before an agent commits them
Agents sometimes inspect configuration while debugging. A secret can then end up in a patch by mistake.
A pre-commit hook is a good place to run a secret scanner:
#!/bin/sh
npm run secrets:check
Use a scanner designed for secrets. A small regular expression will miss many real credentials and flag harmless strings.
The hook reduces risk, but it is not the security boundary. Run secret scanning in CI and on the remote repository too.
If a real secret enters Git history, removing the line is not enough. Revoke and replace the credential.
Require useful commit messages
Agents can produce vague subjects like update files or fix stuff.
The commit-msg hook receives the commit message file as its first argument. We can reject known bad subjects:
#!/bin/sh
message_file=$1
subject=$(sed -n '1p' "$message_file")
case "$subject" in
"update files"|"fix stuff"|"changes")
echo "Write a commit subject that explains the change"
exit 1
;;
esac
Save this as .git/hooks/commit-msg.
I would not create a complicated grammar for commit messages. A hook cannot decide whether a message accurately describes the patch.
It can catch empty messages and subjects from a short denylist. Review still handles meaning.
Each Git hook receives different information. Check the Git hooks documentation before adding a hook you have not used before.
Run wider checks before a push
A pre-push hook runs before Git sends commits to the remote.
This is a good place for checks that take longer than the commit loop:
#!/bin/sh
npm test &&
npm run build
An agent may create several small commits before pushing. Running the full suite once at push time keeps each commit fast.
I still avoid very long browser suites here. Slow hooks teach people and agents to bypass them.
Run expensive integration and browser tests in CI.
Record completed agent work after a commit
The post-commit hook runs after Git creates the commit.
At this point, returning an error cannot stop or remove that commit. This makes post-commit useful for logs and notifications.
Here is a small local commit log:
#!/bin/sh
subject=$(git log -1 --pretty=%s)
printf '%s\n' "$subject" >> .git/commit-log.txt
The file stays inside .git, so it does not appear as a project change.
I use a more complete version to log every Git commit to one plain text file. Git records the completed event no matter which agent made the commit.
This gives me a compact record of finished work without saving prompts, transcripts, or tool output.
Share hooks with every agent environment
Files inside .git/hooks are local. Git does not include them in a clone.
For shared hooks, keep them in a tracked directory:
.githooks/
pre-commit
commit-msg
pre-push
Then configure the clone:
git config core.hooksPath .githooks
Check the setting with:
git config --get core.hooksPath
Every local or remote agent environment needs this setup. A tracked hook does nothing until Git knows where to find it.
You can add the command to the project setup script. For a JavaScript project, Husky can also install and share hooks.
I use core.hooksPath for a few short shell scripts. I would use Husky when a JavaScript team already depends on its workflow.
Read shared hooks before enabling them. A cloned hook is executable code from the repository.
Tell agents not to bypass hooks
Git lets a commit skip pre-commit and commit-msg:
git commit --no-verify -m 'skip local checks'
A push can skip pre-push too:
git push --no-verify
Your agent instructions should say not to use --no-verify.
That instruction is still not enforcement. An agent or person can bypass the hook, delete it, or work in a clone where hooks were never configured.
Required rules must also run in CI so they cannot be bypassed this way.
Use bypasses only when a hook itself is broken and you understand the risk. Fix the hook immediately afterward.
Hooks and CI have different jobs
Local hooks give the agent fast feedback. CI verifies the result in a clean environment.
I often run the same command in both:
npm run check:commit
The hook catches a mistake before it leaves the machine. CI catches it again if the hook was missing or skipped.
Branch protection can require CI to pass. It cannot require a local hook that the remote never sees.
Hooks help the workflow. CI enforces the shared standard.
Know what hooks cannot catch
A Git hook only runs at a Git event, so it cannot help while an agent keeps uncommitted changes. It also cannot tell whether the implementation solves the right problem or review a misleading test that passes.
Hooks also struggle with subjective rules. They cannot reliably decide whether code is clear, an abstraction is justified, or a user interface feels right.
Use hooks for deterministic evidence:
- lint passes
- types check
- tests pass
- generated files are current
- forbidden dependencies are absent
- known secret patterns are absent
Use human or agent review for judgment.
How I use Git hooks with AI agents
I keep the commit hook quick. It checks the staged patch, lint, types, and fast tests.
The push hook runs the full test suite and production build. CI repeats required checks on a clean machine.
I also use a post-commit hook for my work log. It records successful commits from every tool I use.
I place a few deterministic checks at the point where agent work becomes repository history. Adding more hooks is not the goal.
If staging and commits are still new to you, start with my free Git course. Hooks make more sense once the basic Git workflow feels familiar.
Want me to talk about your product? You can sponsor this site.