A pre-commit hook is a script that Git runs every time you commit. If the script exits with an error, Git stops the commit. Point the hook at your linter and it stops any commit that breaks a lint rule.
A commit hook is one of three places the same check should run. Your coding agent can run it after each edit and again before it finishes, and CI runs it on every pull request. Use one command and one set of config files in all three, so a change can’t pass in one place and fail in another. This article sets up the commit hook for TypeScript, JavaScript, Python, Java and PHP with each Prickles pack’s lint config. Then it runs the same check in Claude Code, Codex and CI.
Every Git hook below was run in a fresh project in Docker on 13 October 2026. Each language’s hook let a clean commit through and blocked a commit that swapped a named constant for the bare number 18. The output quoted below comes from those runs.
What a pre-commit hook is
Git’s githooks documentation (2026) describes hooks as programs in a hooks folder, which is .git/hooks unless the core.hooksPath setting names another. The pre-commit hook takes no arguments and runs before Git creates the commit. If it exits with anything but 0, the commit is cancelled. Git ignores a hook file that isn’t executable.
Git keeps the hooks folder outside the files you commit, so a hook doesn’t travel with the code. Scott Chacon and Ben Straub’s Pro Git (2014) points out that client-side hooks aren’t copied when you clone a repository.
Each setup below gets around that the same way. You commit the hook’s config. A command every developer already runs then installs the hook. Husky and the Maven and Composer setups set core.hooksPath, so Git looks for hooks in a folder of the project. The pre-commit framework writes its own script into .git/hooks when you run pre-commit install.
One check, three places
Each place catches a problem at a different moment and covers a different amount of the project:
| Where | When it runs | What it checks | What skips it |
|---|---|---|---|
| The Git pre-commit hook | Before each commit | Staged files, or the whole project in Java and PHP | git commit --no-verify, or a clone that never installed it |
| Claude Code’s PostToolUse hook | After each file the agent edits | That file | "disableAllHooks": true in Claude Code’s settings |
| Claude Code’s Stop hook | When the agent’s turn ends | Whole project | Claude Code’s limit of eight blocks in a row |
| CI | On every pull request | Whole project | A repository admin, unless bypassing is turned off |
Both the commit hook and the edit hook run the same linters with the same config files, usually on fewer files, so they stay quick. A file that passes them passes the same rules in CI. Checks that look across files, such as the type checker, duplicate detection and the tests, belong in the whole check. It runs at the end of the agent’s turn and in CI.
Three tenets meet here:
- P5 Shift-Left Quality moves the check to before the code reaches review.
- P4 Continuous Quality Feedback runs it as close to the edit as the tools allow.
- TA1 Linter as Law makes every rule an error. A warning leaves the exit code alone, so it can’t stop a commit, a turn or a merge.
Your agent needs hooks of its own because an instruction to run the checks isn’t enough. Wenhao Yang, Runzhi He and Minghui Zhou tested this in A First Look at Coding Agents’ Compliance with AI Contribution Rules in Open-Source Communities (2026). Claude Code with Sonnet 4.6 met a repository’s rule to pass its required checks in 11 of 26 runs. Quoting the rule to it word for word left that at 11 of 26.
A hook runs the check whether or not the agent remembers. Agentic coding covers the rest of that loop.
Pre-commit hooks for TypeScript and JavaScript
Husky installs Git hooks from a folder in the repository, and lint-staged runs commands on the files you’ve staged. Install both, then let husky set itself up:
npm install --save-dev husky lint-staged
npx husky init
echo "npx lint-staged" > .husky/pre-commit
husky init creates .husky/pre-commit and adds a prepare script to package.json, according to Husky’s Get started page (2026). Its first pre-commit file runs npm test. The last line above swaps that for lint-staged. In the test, a fresh clone had no hook until npm install ran the prepare script. That script pointed core.hooksPath at .husky/_.
Then tell lint-staged what to run, and give the whole check a name, in package.json:
{
"scripts": {
"prepare": "husky",
"check": "eslint . && prettier --check . && tsc --noEmit && jscpd src --threshold 0"
},
"lint-staged": {
"*.{ts,tsx,js,mjs}": ["eslint", "prettier --check"]
}
}
According to the lint-staged README (2026), each command gets the staged files matching its pattern. Commands in a list run in order. So committing src/age.ts runs eslint src/age.ts with the pack’s eslint.config.mjs, then prettier --check src/age.ts. Both check without changing anything, the same as in CI. lint-staged backs up the original state with git stash first, and by default it hides unstaged changes in files you’ve only partly staged.
When a commit changed age >= ADULT_AGE to age >= 18, the hook stopped it:
✖ eslint:
/work/app/src/age.ts
1:57 error No magic number: 18 @typescript-eslint/no-magic-numbers
✖ 1 problem (1 error, 0 warnings)
husky - pre-commit script failed (code 1)
The JavaScript pack stopped the same commit through ESLint’s own no-magic-numbers rule. For JavaScript, leave tsc --noEmit out of check. The type checker and jscpd, the pack’s duplicate detector, both look across files, so they run in npm run check rather than on the staged files.
Pre-commit hooks for Python
For Python, use the pre-commit framework (2026). It manages hooks in any language from one file, .pre-commit-config.yaml. Install it with pip install pre-commit, commit this file, then run pre-commit install once in each clone:
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.17.0
hooks:
- id: ruff-check
- repo: local
hooks:
- id: pylint
name: pylint
entry: pylint
language: unsupported
types: [python]
require_serial: true
Ruff comes from Astral’s ruff-pre-commit repository (2026), at the version rev names. Pylint runs as a local hook from your own environment, because it has to import your modules and their dependencies. Pylint’s pre-commit integration page (2026) explains why and shows language: system. pre-commit 4.4.0 renamed that value unsupported. The same page recommends require_serial, because Pylint’s results can change when pre-commit splits the files across several runs.
Both tools read the pack’s pyproject.toml. It also loads the pack’s prickles_no_comments.py plugin into Pylint. The magic number stopped the commit at Ruff:
ruff check...............................................................Failed
- hook id: ruff-check
- exit code: 1
PLR2004 Magic value used in comparison, consider replacing `18` with a constant variable
--> age.py:2:19
|
1 | def is_adult(age: int) -> bool:
2 | return age >= 18
| ^^
Found 1 error.
pylint...................................................................Passed
For the whole check, pre-commit’s docs call pre-commit run --all-files useful in CI. It runs every hook on every file, with the same Ruff version as the commit hook. Add the pack’s jscpd command and your tests after it.
Pre-commit hooks for Java and PHP
Java and PHP projects can use the pre-commit framework too, with local hooks like the Pylint one, but then every developer needs Python. The alternative needs nothing new. Commit a hooks folder, and have the build tool everyone already runs point core.hooksPath at it.
Both hooks below are two-line shell scripts in .githooks/pre-commit. Git runs them only if they’re executable, so on Windows mark them in Git with git update-index --chmod=+x .githooks/pre-commit. A plain hook like these checks the files as they are on disk, including changes you haven’t staged.
Java with Maven
For Maven, the hook runs the PMD plugin:
#!/bin/sh
exec mvn -B pmd:check
Two plugins in pom.xml do the rest. Rudi Kershaw’s Git Build Hook Maven Plugin (2025) sets core.hooksPath. The PMD plugin runs the pack’s pmd-ruleset.xml with PMD 7.28.0:
<build>
<plugins>
<plugin>
<groupId>com.rudikershaw.gitbuildhook</groupId>
<artifactId>git-build-hook-maven-plugin</artifactId>
<version>3.6.0</version>
<configuration>
<gitConfig>
<core.hooksPath>.githooks</core.hooksPath>
</gitConfig>
</configuration>
<executions>
<execution>
<goals>
<goal>configure</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-pmd-plugin</artifactId>
<version>3.28.0</version>
<configuration>
<rulesets>
<ruleset>pmd-ruleset.xml</ruleset>
</rulesets>
<printFailingErrors>true</printFailingErrors>
</configuration>
<dependencies>
<dependency>
<groupId>net.sourceforge.pmd</groupId>
<artifactId>pmd-core</artifactId>
<version>7.28.0</version>
</dependency>
<dependency>
<groupId>net.sourceforge.pmd</groupId>
<artifactId>pmd-java</artifactId>
<version>7.28.0</version>
</dependency>
<dependency>
<groupId>net.sourceforge.pmd</groupId>
<artifactId>pmd-javascript</artifactId>
<version>7.28.0</version>
</dependency>
<dependency>
<groupId>net.sourceforge.pmd</groupId>
<artifactId>pmd-jsp</artifactId>
<version>7.28.0</version>
</dependency>
</dependencies>
<executions>
<execution>
<goals>
<goal>check</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
The PMD plugin’s Upgrading PMD at Runtime page (2025) says version 3.28.0 uses PMD 7.17.0 unless you override it, with a dependency on each PMD module at the version you want. The Prickles Java pack’s setup step pins PMD 7.28.0, the version it’s tested against, the same way. So the config above pins every module from the docs’ example to that version: pmd-core, pmd-java, pmd-javascript and pmd-jsp.
Git Build Hook’s configure goal is bound to Maven’s generate-sources phase. In the test, running mvn validate left core.hooksPath unset. Running mvn compile set it to .githooks. The PMD plugin’s check goal (2025) runs PMD first and fails the build on any violation. Bound as above, it also runs in mvn verify, so use mvn verify as the whole check. printFailingErrors is off by default, and turning it on prints each violation:
[INFO] --- pmd:3.28.0:check (default-cli) @ app ---
[WARNING] PMD Failure: com.example.Age:8 Rule:AvoidLiteralsInIfCondition Priority:3 Avoid using literals such as 18 in if statements.
[INFO] ------------------------------------------------------------------------
[INFO] BUILD FAILURE
After that, Maven’s error says “PMD 7.28.0 has found 1 violation”. PMD 7.28.0 also prints warnings about its auxiliary classpath, for example when target/classes doesn’t exist yet. Those warnings don’t fail the check.
The Maven plugin doesn’t take a list of files, so the hook checks the whole source tree. In the test project, each commit took three to five seconds. Only Maven was tested here. The Java pack’s own instructions also cover Gradle, where PMD runs in ./gradlew check.
PHP with Composer
For PHP, the hook runs a Composer script:
#!/bin/sh
exec composer lint
Put the scripts in composer.json:
{
"scripts": {
"post-install-cmd": "git config core.hooksPath .githooks",
"post-update-cmd": "git config core.hooksPath .githooks",
"lint": [
"phpcs",
"phpmd analyze",
"phpmnd src --extensions=all,-default_parameter,-array --ignore-numbers=-1"
]
}
}
Composer’s Scripts page (2026) says post-install-cmd runs after composer install and post-update-cmd after composer update. In the test, composer require set the hook as well. Composer puts its bin folder at the front of PATH before it runs a script, so phpcs finds vendor/bin/phpcs. The pack supplies the three lint tools. PHP_CodeSniffer and PHPMD read its phpcs.xml and phpmd.xml, and phpmnd finds magic numbers. phpmnd stopped the commit:
Age.php:11. Magic number: 18
> 11| return $age >= 18;
--------------------------------------------------------------------------------
Total of Magic Numbers: 1
Time: 00:00.010, Memory: 6.00 MB
Script phpmnd src --extensions=all,-default_parameter,-array --ignore-numbers=-1 handling the lint event returned with error code 1
For the whole check, add your tests and Psalm, also from the pack. Psalm needs a psalm.xml of its own, and vendor/bin/psalm --init writes one.
Claude Code hooks: lint after each edit
Claude Code runs hooks at set points in a session. Anthropic’s Hooks reference (2026) puts a project’s hooks in .claude/settings.json. You can commit that file so everyone gets them. A PostToolUse hook runs after a tool succeeds. The matcher Edit|Write limits it to Claude’s file edits. The hook gets the event as JSON on standard input, with the edited file’s absolute path in tool_input.file_path.
A hook’s exit code decides what Claude sees. On PostToolUse, exit code 2 shows the hook’s error output to Claude, though the edit has already happened. Exit code 1 counts as a non-blocking error that Claude doesn’t see, and most linters exit with 1 when they find a problem. So the hook’s script sends the linter’s output to standard error and turns any failure into a 2. Register it in .claude/settings.json:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "\"${CLAUDE_PROJECT_DIR}\"/.claude/hooks/lint-edited-file.sh",
"statusMessage": "Linting the edited file"
}
]
}
]
}
}
A script at .claude/hooks/lint-edited-file.sh picks the linter by file extension:
#!/usr/bin/env bash
file="$(jq -r '.tool_input.file_path // empty')"
case "$file" in
*.ts | *.tsx | *.js | *.mjs) npx eslint --no-warn-ignored "$file" ;;
*.py) ruff check "$file" && pylint "$file" ;;
*.java) mvn -B pmd:check ;;
*.php) composer lint ;;
*) exit 0 ;;
esac >&2 || exit 2
Keep the lines for your languages. ESLint, Ruff and Pylint lint the one file. PMD and the PHP tools run over the project, as in the commit hook. The script reads the JSON with jq, as the examples in Anthropic’s Automate actions with hooks (2026) do. Make the script executable. On Windows, Claude Code runs a command like this one through Git Bash when Git Bash is installed.
To test a hook, the guide’s troubleshooting section pipes sample JSON into it. With the magic number in a TypeScript file:
echo '{"tool_name":"Write","tool_input":{"file_path":"/work/app/src/age.ts"}}' | .claude/hooks/lint-edited-file.sh
Run against that file, the script printed ESLint’s error and exited with 2:
/work/app/src/age.ts
1:57 error No magic number: 18 @typescript-eslint/no-magic-numbers
✖ 1 problem (1 error, 0 warnings)
In every language, the same test gave exit code 2 for the bad file and 0 for the clean one. A README.md got 0, because the script skips files it has no linter for.
A Stop hook that runs the whole check
A Stop hook runs when Claude finishes responding. If it prints a JSON object with "decision": "block" and a reason, or exits with code 2, Claude can’t stop. It gets the reason instead and carries on working. With the whole check in that hook, the agent’s turn can’t end while the check fails.
This site’s repository uses one. Its npm run check runs lint, the Prettier check, type checking, duplicate detection and the unit tests at 100% coverage, and stops at the first failure. The end-to-end suite stays in CI. These are the scripts in its package.json:
{
"scripts": {
"check": "npm run lint && npm run format:check && npm run type-check && npm run dup-check && npm run test:coverage && npm run --silent check:record",
"check:record": "tsx --tsconfig scripts/tsconfig.json scripts/check-record.ts",
"check:stop-hook": "tsx --tsconfig scripts/tsconfig.json scripts/check-stop-hook.ts"
}
}
A hook in its .claude/settings.json calls the last of them. Its timeout raises Claude Code’s default of 600 seconds to 900:
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "npm run --silent check:stop-hook",
"timeout": 900,
"statusMessage": "Running npm run check"
}
]
}
]
}
}
Its logic is short:
import { spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
import { join } from "node:path";
import { isRecordedPass, readTreeState } from "./tree-state";
const TAIL_LINES = 60;
const COLOUR_CODES = /\u001B\[[\d;?]*[A-Za-z]/g;
const needsCheck = (cwd: string): boolean => {
const state = existsSync(join(cwd, "node_modules")) ? readTreeState(cwd) : null;
return state !== null && !isRecordedPass(state);
};
const tail = (output: string): string =>
output.replaceAll(COLOUR_CODES, "").trimEnd().split(/\r?\n/).slice(-TAIL_LINES).join("\n");
export const runStopHook = (cwd: string): string => {
if (!needsCheck(cwd)) {
return "";
}
const check = spawnSync("npm run check 2>&1", { cwd, encoding: "utf8", shell: true });
if (check.status === 0) {
return "";
}
const reason = `npm run check failed. Fix it and finish again.\n${tail(check.stdout)}`;
return JSON.stringify({ decision: "block", reason });
};
npm run check ends by recording the tree that passed, as a SHA-256 hash of HEAD, then git diff HEAD, then the untracked files. It keeps the record under the Git folder, so each worktree has its own. When the tree still matches that record, the turn ends at once. Otherwise the hook runs the whole check, and on a failure it blocks the turn with the last 60 lines of output. With no node_modules or no Git tree, it lets the turn end without a check.
This hook doesn’t count its own blocks. The Hooks reference says Claude Code ends the turn anyway after eight Stop blocks in a row with no tool call between them. The count resets each time Claude calls a tool. The hook was tested by piping sample Stop input into it rather than in a live session.
AGENTS.md in the same repository tells the agent about its hooks, so it runs the check before it tries to finish:
Run `npm run check` before you finish. The Stop hook in `.claude/settings.json` runs it
when your turn ends and won't let the turn end while it fails. The pre-commit hook in
`.husky/pre-commit` runs ESLint and the Prettier check on the files you stage.
For another language, point the hook at that language’s whole check instead: pre-commit run --all-files, mvn verify or composer lint. Anthropic’s Best practices for Claude Code (2026) describes it too: a Stop hook “runs your check as a script and blocks the turn from ending until it passes”.
Codex’s session-start hook
Codex has hooks too. OpenAI’s Hooks page (2026) puts a project’s hooks in .codex/hooks.json, with the same three levels of event, matcher and handler. The Prickles Codex pack includes one in its Strict layout. That layout keeps the tenets that matter most in AGENTS.md and the rest in part files under prickles/:
{
"description": "Prickles: put the rest of the canon into Codex's context when a session starts, is cleared or is compacted.",
"hooks": {
"SessionStart": [
{
"matcher": "^(startup|clear|compact)$",
"hooks": [
{
"type": "command",
"command": "git show HEAD:prickles/codex-canon-part-2.md HEAD:prickles/codex-canon-part-3.md",
"timeout": 30,
"statusMessage": "Loading the rest of the Prickles canon",
"additionalContextLimit": 4000
}
]
}
]
}
}
What each part of the hook does:
- Its matcher runs it when a session starts, is cleared or is compacted, and not when one resumes.
git showprints the committed copy of each part file, and Codex adds aSessionStarthook’s plain output to the model’s context.- By default, Codex saves a hook’s output to disk and sends the model a preview once the output passes about 2,500 tokens. The pack sets
additionalContextLimitfrom the part files’ size, so the whole text reaches the model.
In the pack’s Recommended layout, the rest of the canon loads as skills instead, with no hook.
Unless an administrator manages a hook, Codex runs it only after you’ve reviewed and trusted it, with /hooks in the CLI. It records the trust against the hook’s hash, so a changed hook needs trusting again. It also loads a project’s hooks only when the project’s .codex/ folder is trusted.
In a committed copy of the pack, the hook’s command printed both part files, and its matcher’s pattern matches startup, clear and compact but not resume. It wasn’t run inside Codex. Codex also has PostToolUse and Stop events, so the lint and Stop hooks above can move across, once you’ve checked its page for the input each event gets.
What pre-commit hooks can’t do
Anyone committing can skip a hook. Git’s docs say git commit --no-verify bypasses the pre-commit hook, and in the TypeScript test the magic number then went straight into the history. Husky’s How To page (2026) adds HUSKY=0 to turn its hooks off. pre-commit skips any hook named in SKIP. With SKIP set, the Python test committed past Ruff. A coding agent that runs git commit can pass the same flags.
A hook also runs only where it’s installed. A clone has none until someone runs npm install, pre-commit install, mvn compile or composer install. Pro Git draws the conclusion:
If your intent with these scripts is to enforce a policy, you’ll probably want to do that on the server side.
On a hosted service such as GitHub, that means CI. Run the whole check on every pull request, and make the job a required status check. GitHub’s About protected branches (2026) says every required check must pass before collaborators can merge into a protected branch. Admins aren’t bound by the rule unless you turn on Do not allow bypassing the above settings.
Every pull request to the repository behind this site runs one CI job. Its steps are ESLint, the Prettier check, type checking, duplicate detection, the unit tests at 100% coverage, a build, the end-to-end suite and a security audit. Its first five steps are the same as npm run check. Coding standards that the build enforces covers which rules belong in a job like that.
So the repository runs its gate before each commit, at the end of each turn and in CI. Its .husky/pre-commit runs npx lint-staged. That runs eslint --no-warn-ignored on staged .ts, .tsx, .astro, .mjs and .js files, and prettier --check --ignore-unknown on every staged file. Both only check. Type checking, duplicate detection and the unit tests stay in npm run check, which the Stop hook runs, and in CI.
How to set up pre-commit hooks for people and agents
- Put your whole gate behind one command: the pack’s lint config, your type checker and your tests.
- Name that command in AGENTS.md, so the agent knows to run it.
- Install the commit hook for your language as above, commit its config and run its install step once.
- Add the
PostToolUselint hook to.claude/settings.json, and commit it with its script. - Add a
Stophook that runs the whole command and blocks the turn while it fails. - If you use the Codex pack’s Strict layout, trust its session-start hook with
/hooks. - Run the whole command in CI on every pull request, make it a required status check and turn off bypassing for admins.
