DEV Community

Cover image for Your Coding Agent Skips Your Lint. lefthook Makes Every Commit Run It Anyway
Gabriel Anhaia
Gabriel Anhaia

Posted on AI-assisted

Your Coding Agent Skips Your Lint. lefthook Makes Every Commit Run It Anyway


git log shows the agent's commit: feat: refund endpoint. Inside it are tabs where your code style wants spaces, a method PHPStan would reject at level 8, and a domain class that now imports an Eloquent model.

None of that is the agent being careless. PHP-CS-Fixer, PHPStan and your layer rules exist, but they run when you remember to run them, or in CI after the push. Nothing ran them at the moment the agent typed git commit. So nothing stopped it.

A git hook does run at that moment, for the agent the same as for you. This post sets one up with lefthook, on a PHP project, so a bad commit fails in the agent's own terminal and the agent reads the error before you ever see the diff. Then it covers the two ways the gate quietly opens: a fresh worktree, and --no-verify.

Step 1: install lefthook as a dev dependency

lefthook is a git hooks manager written in Go and shipped as one binary. Its releases page lists v2.2.2 as the latest, from October 13, 2026. For a PHP project, the PHP install docs use Composer:

composer require --dev evilmartians/lefthook
vendor/bin/lefthook version
vendor/bin/lefthook install
Enter fullscreen mode Exit fullscreen mode

install writes the hook scripts into git's hooks folder. You run it once per clone, not after every config change: the install docs say lefthook.yml is read every time a hook runs. The hook finds the binary through composer exec, which matters in Step 4.

Step 2: a pre-commit that checks staged files only

Here is the whole lefthook.yml. Commit it at the repo root.

# lefthook.yml
assert_lefthook_installed: true
glob_matcher: doublestar

pre-commit:
  parallel: true
  jobs:
    - name: php-cs-fixer
      glob: "**/*.php"
      run: >-
        vendor/bin/php-cs-fixer fix
        --path-mode=intersection {staged_files}
      stage_fixed: true
    - name: phpstan
      glob: "**/*.php"
      run: vendor/bin/phpstan analyse --no-progress {staged_files}
    - name: deptrac
      glob: "src/**/*.php"
      run: vendor/bin/deptrac analyse
      fail_text: "A layer imports a class it must not."

pre-push:
  jobs:
    - name: phpstan-full
      glob: "**/*.php"
      run: vendor/bin/phpstan analyse --no-progress
    - name: phpunit
      glob: "**/*.php"
      run: vendor/bin/phpunit
Enter fullscreen mode Exit fullscreen mode

What each piece does, according to the lefthook docs:

{staged_files} expands to the files staged for this commit (run docs). An agent that touched three files gets three files checked, not the whole repo, so the hook stays fast enough that nobody is tempted to switch it off.

glob filters that list. When no staged file matches, the job is skipped (glob docs), so a commit that only edits a README runs nothing. The default matcher treats ** as one or more folders, so **/*.php would miss a PHP file at the repo root. glob_matcher: doublestar switches to the usual meaning, where **/*.php matches app.php and src/app.php alike (glob_matcher docs).

parallel: true runs the three jobs at the same time (parallel docs).

stage_fixed: true makes lefthook call git add on the files after the job runs, and it works only in pre-commit (stage_fixed docs). PHP-CS-Fixer rewrites the formatting, the fixed files go back into the commit, and the agent never has to think about spaces. One catch: git add stages the whole file, so if a file had unstaged edits too, they ride along.

--path-mode=intersection tells PHP-CS-Fixer to check only the paths that are both in its config's finder and on the command line (usage docs). Without it, a path you pass overrides your config, and files you meant to exclude get formatted.

Deptrac is the layer check. It reads your deptrac.php, where you declare layers such as Domain, Application and Infrastructure and which may depend on which. It analyses the configured paths, not single files, so the job takes no {staged_files}. The glob only decides whether it runs. This is the job that catches the Eloquent import in the domain class, and fail_text adds a line telling the agent what kind of mistake it made (fail_text docs).

PHPStan on staged files only has a blind spot: change a method's signature and the caller you didn't touch is not analysed. The pre-push hook closes it with a full PHPStan run plus the test suite. Slower, but it runs once per push instead of once per commit. Run without paths, PHPStan reads them from the paths parameter in your config (CLI docs), so the push job analyses everything phpstan.neon lists.

A coding agent at a desk pushing a commit through a gate that stops it and hands back a note
The hook runs inside the agent's own git commit, so the failure lands where the agent can read it.

Step 3: what the agent sees when the commit fails

Try it by hand first. vendor/bin/lefthook run pre-commit runs the hook without committing (run command), so you can stage a deliberately broken file and watch each job.

When an agent such as Claude Code runs git commit, the same hook runs inside that command. A failing job makes the hook exit non-zero, git aborts the commit, and the hook's output is part of what the command returned. The agent gets PHPStan's error with file and line, in the terminal it is already working in, as the result of its own action.

That is the difference from CI. A red pipeline after the push needs someone to read it and paste it back. A failed commit is feedback the agent receives inside its loop, so its usual next move is to fix the file and commit again. You can make that explicit with one line in your CLAUDE.md or AGENTS.md: "If a git hook fails, fix the reported problem and commit again. Never bypass the hook."

Step 4: the worktree trap, and why the gate opens silently

If you run agents in parallel, you give each one its own git worktree. Git keeps the hooks folder in the shared repository directory: the repository layout docs say a linked worktree uses $GIT_COMMON_DIR/hooks. You do not need to run lefthook install again in every worktree.

Two coding agent sessions side by side, Codex and Claude Code, each on its own hermes/ worktree, with the sessions sidebar
Hermes 2.3.4: Codex and Claude Code in split panes, each in its own hermes/ worktree, with status, context use, cost and branch in the sidebar.

What a new worktree does not have is vendor/. A worktree holds the tracked files only, and vendor/ is ignored. So the hook fires, looks for lefthook through composer exec and the other places on its list and, unless lefthook is also installed globally, finds nothing.

Then it prints Can't find lefthook in PATH and lets the commit through. The behaviour comes from lefthook's hook template: unless you opt in to failing, a missing binary is not an error. The agent in that worktree commits unchecked code and never sees a problem.

The config above starts with assert_lefthook_installed: true for this reason. With it, the hook exits 1 when the binary can't be found (docs). The commit fails with a message the agent can act on, and the agent can run composer install itself. This option is written into the hook script, since the hook can't read lefthook.yml without the binary, so run vendor/bin/lefthook install once more after you add it.

The cleaner fix is to install dependencies before the agent starts. With plain git:

git worktree add -b agent/refund ../app-refund
cd ../app-refund
composer install --no-interaction
Enter fullscreen mode Exit fullscreen mode

I build Hermes IDE, so weigh this part with that in mind. Hermes starts each task (⌘N, Ctrl+Shift+N on Windows and Linux) on its own hermes/ branch in its own worktree, and runs a recipe from .hermes/worktree.toml in that worktree before the agent starts (recipe docs):

# .hermes/worktree.toml
setup = [
  "composer install --no-interaction",
  "vendor/bin/lefthook install",
]
done_when = ["vendor/bin/phpunit"]
Enter fullscreen mode Exit fullscreen mode

The Hermes New task sheet with a refund-endpoint task typed and chips for Claude Code, the orders-api project and a new hermes/ worktree
Hermes 2.3.4: the ⌘N New task sheet, with Claude Code, the orders-api project, a new hermes/ worktree and the "Hermes will run" preview.

Setup commands run in order, and the first one that fails stops the rest, stays on the task's card and goes to the inbox, so a broken composer install is something you see instead of something the agent quietly works around. The lefthook install line is redundant once the main checkout has run it, since the hooks folder is shared, but it costs nothing and covers a repo where nobody ran it yet. Hermes shows you the commands and waits for Run setup the first time it sees the recipe, and again whenever it changes. Skip starts the session without running setup, so leave it alone on repos that depend on the gate. done_when sets the task's default check.

Step 5: close --no-verify

Every hook has an exit. git commit -n or --no-verify bypasses pre-commit and commit-msg (git-commit), git push --no-verify skips pre-push (git-push), and LEFTHOOK=0 git commit turns lefthook off for one command (LEFTHOOK env). An agent stuck on a failing check can reach for any of them.

For Claude Code, add deny rules to .claude/settings.json and commit it:

{
  "permissions": {
    "deny": [
      "Bash(git commit *--no-verify*)",
      "Bash(git commit -n*)",
      "Bash(git commit * -n *)",
      "Bash(git push *--no-verify*)"
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

In Claude Code's permission rules, * matches any text and can sit anywhere in the pattern, deny beats any allow rule, and a deny rule also applies to each part of a compound command such as cd app && git commit --no-verify.

Read the limits on that same page before you trust it. A Bash rule matches the command text Claude writes, and the docs say it is not a security boundary: git -C . push slips past Bash(git push *), and so does sh -c '...'. Short flags can be spelled more ways than three patterns cover. Treat these rules as a guard against the common case and keep the real gate in CI, running the same PHP-CS-Fixer, PHPStan, Deptrac and PHPUnit commands as the hooks.

None of this setup is agent-specific. The same hooks stop you at 6 p.m. on a Friday.


If this was useful

The Deptrac job is the one I would keep if I could keep only one. Formatting is cosmetic and PHPStan catches local mistakes, but a domain class that quietly starts depending on the framework is the change that makes a codebase hard to move years later. Decoupled PHP is my book on drawing those layer lines in a PHP app, so a tool like Deptrac has rules worth enforcing.

If you run several agents on one repo, Hermes IDE gives each task its own worktree and runs your setup recipe first. It is free to download, source-available under BSL 1.1, runs on macOS, Windows and Linux, and is built in the open on GitHub.

Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework

Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)