Writing Git Hooks With LLMs in November 2026: Check Only What Is Staged, Keep Hooks Fast, and Enforce the Rules Again in CI
Git hooks are small scripts that Git runs at fixed points: before a commit is recorded, after you write a commit message, before a push leaves your machine. They are a good fit for asking an LLM to write, because they are short, mostly shell, and follow well-documented conventions. They are also easy to get subtly wrong. A hook that lints the wrong files, takes forty seconds, or silently passes when a tool is missing is worse than no hook at all, because people learn to skip it.
This guide walks through how to get a useful hook out of a model, what to check before you trust it, and why the hook should never be your only line of defense.
How Git hooks actually work
Before you prompt anything, it helps to know the handful of facts the model has to get right:
- Location. By default Git looks in
.git/hooks/. That folder is not tracked, so hooks placed there are not shared with your team. To share hooks, commit them to a folder such as.githooks/and point Git at it withgit config core.hooksPath .githooks. - Names matter. The file must be named exactly after the hook, such as
pre-commit,commit-msg, orpre-push, with no file extension. - It must be executable. A hook without the executable bit is ignored. Run
chmod +x .githooks/pre-commit, and remember that Git tracks that bit, so commit it. - Exit codes decide. For
pre-commit,commit-msg, andpre-push, a non-zero exit stops the commit or push. Zero lets it through. - Arguments differ by hook.
commit-msgreceives the path to the message file as its first argument.pre-pushreceives the remote name and URL as arguments and reads one line per ref being pushed on standard input. - Hooks are optional for the person committing.
git commit --no-verifyskipspre-commitandcommit-msg, andgit push --no-verifyskipspre-push.
That last point shapes everything else. A hook is a convenience that catches mistakes early. It is not an enforcement mechanism.
Step 1: Decide what the hook is for, in one sentence
The most common failure is a hook that tries to do everything: format, lint, type-check, run the full test suite, and scan for secrets. That hook is slow, so people bypass it, and then it catches nothing.
Write the purpose down before you ask the model for code. Good examples:
- "Block commits that add files larger than 5 MB."
- "Run the formatter in check mode on staged Python files only."
- "Reject commit messages that do not start with a ticket ID like ABC-123."
- "Stop a push to
mainfrom a local branch."
Pick the right hook for the job. Fast checks on file contents belong in pre-commit. Message format checks belong in commit-msg. Slower checks, such as a quick test subset, belong in pre-push, where a few extra seconds hurt less.
Step 2: Give the model your real environment
Hooks run in whatever shell and tools exist on each developer's machine. Tell the model exactly what that is. A useful prompt looks like this:
Write a Git pre-commit hook in POSIX sh (not bash).
Team uses macOS and Linux; some developers use Git Bash on Windows.
Purpose: run "ruff check" on staged .py files only.
Requirements:
- Only check files that are staged (added, copied, or modified).
- Handle file names with spaces.
- If ruff is not installed, print a clear message and exit 1.
- Exit 0 when no Python files are staged.
- Print what it is doing in one line, and keep total runtime low.
Explain any assumption you make about Git or ruff versions.
Naming the shell matters. Models often reach for bash features such as arrays or [[ ]]. If your hook starts with #!/bin/sh, those can break on systems where sh is not bash. Either ask for POSIX sh or set the shebang to bash explicitly and confirm bash is available everywhere it must run.
Step 3: Check that it reads the staged files, not your working tree
This is the bug to look for first. A naive hook lints every file in the repository, or lints the working copy of each file. Neither matches what you are about to commit.
To list staged files, the reliable command is:
git diff --cached --name-only --diff-filter=ACM
--cached compares the index to HEAD. --diff-filter=ACM keeps added, copied, and modified files and drops deleted ones, which you cannot lint anyway. Add -z if you need to handle unusual file names safely and pair it with a NUL-aware reader such as xargs -0.
There is a second, subtler gap. If someone stages part of a file with git add -p, the working copy and the staged copy differ. A tool that reads the file from disk checks the working copy. For many teams this is an acceptable trade-off, but you should know it exists. If it matters, ask the model specifically how to check the staged content, for example by reading blobs with git show :path/to/file, or use an established hook manager that already handles partial staging. Do not accept a home-grown stash-and-restore trick without testing it carefully, because a bug there can lose uncommitted work.
A hook only catches what passes by it: make sure yours sees the staged files, not the working tree. Image: Fish hook MET 22.1.954 by The Metropolitan Museum of Art, CC0, via Wikimedia Commons.
Step 4: Make failures loud and missing tools obvious
A dangerous pattern in generated hooks is a tool call that fails quietly. If ruff is not on the path, a careless script might print "command not found" and then exit 0 because the last command in the script succeeded. The commit goes through and everyone assumes the check ran.
Look for these in the generated script:
- A check like
command -v ruff >/dev/null 2>&1 || { echo "ruff not found; install it or run: git commit --no-verify"; exit 1; }. - The hook's exit status coming from the actual check, not from a trailing
echo. - Error messages that say what failed and how to fix it, not just "pre-commit failed".
Here is a compact example of the shape you want:
#!/bin/sh
# pre-commit: run ruff on staged Python files
command -v ruff >/dev/null 2>&1 || {
echo "pre-commit: ruff not found. Install it, then retry." >&2
exit 1
}
files=$(git diff --cached --name-only --diff-filter=ACM -- '*.py')
[ -z "$files" ] && exit 0
echo "pre-commit: ruff check on staged Python files"
git diff --cached --name-only --diff-filter=ACM -z -- '*.py' \
| xargs -0 ruff check
Because the pipeline is the last command, the hook exits with the status of xargs, which is non-zero when ruff reports problems. Read through any generated script with that question in mind: what exit code does it return in each case?
Step 5: Keep it fast
A pre-commit hook runs on every commit. If it takes more than a few seconds, people start using --no-verify by habit. Some practical rules:
- Check staged files only, never the whole repository.
- Prefer linters and formatters in check mode over full builds.
- Move test runs to
pre-pushor CI, and keep any test run in a hook to a small, fast subset. - Avoid network calls. A hook that needs the internet fails on a plane and slows everyone else down.
Time it on a real commit with time git commit. If it is slow, ask the model where the time goes and what to move to a later stage, rather than asking it to make the script "faster" in general.
Step 6: Test every path before you share it
Hooks are easy to test by hand in a throwaway repository. Create one with git init, install the hook, and walk through each case:
- A commit with no matching files. It should pass instantly.
- A commit with a clean file. It should pass.
- A commit with a file that violates the rule. It should fail with a clear message.
- A file name with a space in it. It should still be checked.
- A deleted file in the commit. The hook should not try to lint it.
- The required tool temporarily removed from
PATH. The hook should fail loudly, not pass.
For a commit-msg hook, test both a valid and an invalid message with git commit -m, and remember that merge commits and reverts produce messages your pattern may not expect. Decide whether to allow them on purpose.
Step 7: Share it the boring way
You have a few choices for distributing hooks:
- A committed folder plus
core.hooksPath. No extra tools. Each developer runs one config command, which you can put in your setup script or README. - A hook manager. Tools such as pre-commit, Husky, and Lefthook install hooks from a config file and handle details like running on staged files. If your team already uses one, ask the model to write config for that tool instead of a raw script, and check the config keys against the tool's current documentation.
Whichever you choose, document how to skip the hook in an emergency. People will find --no-verify anyway; telling them openly keeps the hook from feeling like a trap.
Local hooks are a quick first screen; the checks that must hold belong in CI as well. Image: Luggage screening at VTBS by User:Mattes, Public domain, via Wikimedia Commons.
Step 8: Enforce the rule again in CI
Because hooks run on the developer's machine and can be skipped, anything that truly matters must also run on the server side: in your CI pipeline, as a required status check, or through branch protection rules on your Git host. The hook gives fast feedback. CI gives the guarantee.
The easiest way to keep them in sync is to have both call the same script or the same tool config. If the pre-commit hook runs ruff check with the project's settings, CI should run the same command with the same settings over the full changed set. Then a developer who skipped the hook gets the same answer a few minutes later, instead of a different one.
What not to put in a hook
- Secrets. Never paste tokens or credentials into a hook or into the prompt you give the model. If a hook needs to call a service, it probably belongs in CI.
- Silent rewrites. A hook that reformats files and then commits them without telling the user can surprise people. If you auto-format, print which files changed and make the user re-stage them, or follow the conventions of your hook manager.
- Destructive commands. Reject any generated hook that runs
git reset --hard,git clean, or deletes files. A hook should inspect, not discard work.
A quick review checklist
Before you commit a model-written hook, confirm:
- The file name matches the hook exactly and it is executable.
- The shebang matches the syntax used in the script.
- It checks staged files with
git diff --cached, and skips deleted ones. - It handles file names with spaces.
- It fails with a clear message when a required tool is missing.
- Its exit code comes from the real check.
- It runs in a few seconds on a typical commit.
- The same rule also runs in CI.
An LLM can draft a working hook in seconds. The useful part of your time goes into the checks above: confirming it looks at the right files, fails when it should, and stays fast enough that nobody has a reason to skip it.
Comments
Post a Comment