Skip to content

Commit e06b267

Browse files
DEV: add Claude Code ruff hooks (auto-format on edit + pre-push lint guard) (#1046)
Recurring red on the Linters CI job (all ruff-format failures) motivated two project-level Claude Code hooks, wired in .claude/settings.json: - PostToolUse (Edit|Write|MultiEdit): ruff_on_edit.py runs `ruff format` + `ruff check --fix` on the edited .py file (~instant), keeping edits clean. - PreToolUse (Bash): ruff_guard_push.py blocks `git push` when `ruff check` or `ruff format --check` would fail, before it turns CI red. Both invoke ruff as `<python> -m ruff`, are cross-platform (pure Python, no shell/OS-specific paths), and no-op when ruff is not installed. Pylint stays in CI only (too slow per-file for an interactive guard). See .claude/hooks/README.md.
1 parent 51b98d3 commit e06b267

4 files changed

Lines changed: 204 additions & 0 deletions

File tree

.claude/hooks/README.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# Claude Code hooks
2+
3+
Project-level [Claude Code hooks](https://docs.claude.com/en/docs/claude-code/hooks)
4+
that keep Claude's edits lint-clean and stop lint failures from reaching CI. They
5+
are wired up in [`.claude/settings.json`](../settings.json) and run automatically
6+
for anyone using Claude Code in this repo.
7+
8+
Both hooks only run **ruff** (the fast linter/formatter, ~0.15s for the whole
9+
repo). Pylint is intentionally left to CI: on this codebase its per-file startup
10+
cost is seconds, too slow for an interactive guard. ruff is also what has actually
11+
been breaking the `Linters` job.
12+
13+
## Hooks
14+
15+
| Hook | Event | What it does |
16+
| --- | --- | --- |
17+
| [`ruff_on_edit.py`](ruff_on_edit.py) | `PostToolUse` on `Edit`/`Write`/`MultiEdit` | After Claude edits a `.py` file, runs `ruff format` then `ruff check --fix` on that one file. Surfaces any unfixable lint issues back to Claude. |
18+
| [`ruff_guard_push.py`](ruff_guard_push.py) | `PreToolUse` on `Bash` | Before a `git push`, runs `ruff check` + `ruff format --check` over the repo and **blocks the push** if either would fail (i.e. before it turns CI red). |
19+
20+
## Design guarantees
21+
22+
- **Cross-platform** (Windows / macOS / Linux): pure Python, no shell built-ins,
23+
no OS-specific paths. ruff is invoked as `<current python> -m ruff` so it
24+
resolves to the same environment the hook runs in.
25+
- **Never gets in the way**: if `ruff` is not installed, both hooks are a silent
26+
no-op. The push guard only acts on actual `git push` commands.
27+
28+
## Requirements
29+
30+
A working `python` on `PATH` (an activated virtualenv is the normal setup) with
31+
`ruff` installed: `pip install ruff` — already included in `pip install .[tests]`.
32+
33+
## Testing a hook manually
34+
35+
Pipe a sample event payload to a hook on stdin:
36+
37+
```bash
38+
printf '{"tool_input":{"file_path":"some_file.py"}}' | python .claude/hooks/ruff_on_edit.py
39+
printf '{"tool_input":{"command":"git push"}}' | python .claude/hooks/ruff_guard_push.py
40+
```

.claude/hooks/ruff_guard_push.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
#!/usr/bin/env python3
2+
"""Claude Code ``PreToolUse`` hook: block ``git push`` if ruff would fail in CI.
3+
4+
Runs before every ``Bash`` command. If the command is a ``git push``, it runs the
5+
two fast ruff steps from the Linters CI job (``ruff check .`` and
6+
``ruff format --check .``) over the repo. If either would fail, the push is
7+
blocked (exit code 2) and the failure is reported back to Claude so it gets fixed
8+
*before* it turns the CI red.
9+
10+
Only ruff runs here (both steps together are ~0.15s). Pylint is intentionally left
11+
to CI: on this repo pylint costs seconds-per-file of startup, which is too slow for
12+
an interactive guard. ruff is what has actually been breaking the Linters job.
13+
14+
Design notes
15+
------------
16+
* ruff is invoked as ``<current python> -m ruff`` (see ``ruff_on_edit.py``).
17+
* Cross-platform: pure Python, no shell built-ins, no OS-specific paths.
18+
* Silent no-op when the command is not a push, or when ruff is not installed.
19+
"""
20+
21+
import importlib.util
22+
import json
23+
import os
24+
import re
25+
import subprocess
26+
import sys
27+
28+
# Match ``git push`` as a real command (start of line or after a shell
29+
# separator), tolerating global flags like ``git -C . push``. Avoids most
30+
# false positives from the substring "push" appearing elsewhere.
31+
_GIT_PUSH = re.compile(r"(?:^|[\s;&|(`])git(?:\s+-\S+|\s+--\S+)*\s+push\b")
32+
33+
34+
def main() -> int:
35+
try:
36+
payload = json.load(sys.stdin)
37+
except (json.JSONDecodeError, ValueError):
38+
return 0
39+
40+
command = (payload.get("tool_input") or {}).get("command", "")
41+
if not isinstance(command, str) or not _GIT_PUSH.search(command):
42+
return 0
43+
44+
if importlib.util.find_spec("ruff") is None:
45+
return 0 # ruff not installed: don't block pushes.
46+
47+
# Target the project root explicitly. ``CLAUDE_PROJECT_DIR`` is set by Claude
48+
# Code to the native-format project root; passing it as a path argument (not
49+
# as the subprocess ``cwd``) keeps this robust across platforms/shells. Falls
50+
# back to ".", which resolves against the hook's working directory.
51+
target = os.environ.get("CLAUDE_PROJECT_DIR") or "."
52+
ruff = [sys.executable, "-m", "ruff"]
53+
check = subprocess.run(ruff + ["check", target], capture_output=True, text=True)
54+
fmt = subprocess.run(
55+
ruff + ["format", "--check", target], capture_output=True, text=True
56+
)
57+
if check.returncode == 0 and fmt.returncode == 0:
58+
return 0
59+
60+
out = [
61+
"[ruff] Push blocked — this would fail the Linters CI job. "
62+
"Fix the issues below, then push again.\n"
63+
]
64+
if check.returncode != 0:
65+
out.append("--- ruff check ---\n" + (check.stdout or "") + (check.stderr or ""))
66+
if fmt.returncode != 0:
67+
out.append(
68+
"--- ruff format --check . ---\n"
69+
+ (fmt.stdout or "")
70+
+ (fmt.stderr or "")
71+
+ "\nFix formatting with: python -m ruff format .\n"
72+
)
73+
sys.stderr.write("\n".join(out))
74+
return 2
75+
76+
77+
if __name__ == "__main__":
78+
raise SystemExit(main())

.claude/hooks/ruff_on_edit.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
#!/usr/bin/env python3
2+
"""Claude Code ``PostToolUse`` hook: auto-format + lint-fix edited Python files.
3+
4+
Runs after every ``Edit``/``Write``/``MultiEdit``. Reads the hook payload from
5+
stdin, and if the touched file is a ``.py`` file, runs ``ruff format`` followed by
6+
``ruff check --fix`` on *just that one file*. ruff is ~instant per file, so this
7+
keeps Claude's edits continuously formatted and lint-clean without slowing the
8+
session down.
9+
10+
Design notes
11+
------------
12+
* ruff is invoked as ``<current python> -m ruff`` so it always resolves to the
13+
same environment the hook runs in (matches the ``python -m ruff`` convention
14+
the repo already uses) instead of relying on a ``ruff`` binary being on PATH.
15+
* Cross-platform: pure Python, no shell built-ins, no OS-specific paths.
16+
* Degrades to a silent no-op when ruff is not installed, so it can never block
17+
editing for a contributor who has not installed the linter yet.
18+
* Exit code 2 feeds any *unfixable* lint issues back to Claude so it fixes them;
19+
the common case (format + autofix succeed) exits 0 silently.
20+
"""
21+
22+
import importlib.util
23+
import json
24+
import subprocess
25+
import sys
26+
27+
28+
def main() -> int:
29+
try:
30+
payload = json.load(sys.stdin)
31+
except (json.JSONDecodeError, ValueError):
32+
return 0
33+
34+
tool_input = payload.get("tool_input") or {}
35+
file_path = tool_input.get("file_path")
36+
if not isinstance(file_path, str) or not file_path.endswith(".py"):
37+
return 0
38+
39+
if importlib.util.find_spec("ruff") is None:
40+
# ruff not installed in this environment: never block the workflow.
41+
return 0
42+
43+
ruff = [sys.executable, "-m", "ruff"]
44+
subprocess.run(ruff + ["format", file_path], capture_output=True, text=True)
45+
fix = subprocess.run(
46+
ruff + ["check", "--fix", file_path], capture_output=True, text=True
47+
)
48+
49+
if fix.returncode != 0:
50+
sys.stderr.write(
51+
f"[ruff] Lint issues ruff could not auto-fix in {file_path}:\n"
52+
f"{fix.stdout}{fix.stderr}"
53+
)
54+
return 2
55+
56+
return 0
57+
58+
59+
if __name__ == "__main__":
60+
raise SystemExit(main())

.claude/settings.json

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,5 +16,31 @@
1616
"Bash(python -m pytest tests/unit/test_plots.py -k \"animate_trajectory or animate_rotate\" -p no:cacheprovider -q)",
1717
"Bash(python -m pytest tests/unit/test_plots.py -p no:cacheprovider -q)"
1818
]
19+
},
20+
"hooks": {
21+
"PostToolUse": [
22+
{
23+
"matcher": "Edit|Write|MultiEdit",
24+
"hooks": [
25+
{
26+
"type": "command",
27+
"command": "python .claude/hooks/ruff_on_edit.py",
28+
"statusMessage": "ruff: formatting edited file"
29+
}
30+
]
31+
}
32+
],
33+
"PreToolUse": [
34+
{
35+
"matcher": "Bash",
36+
"hooks": [
37+
{
38+
"type": "command",
39+
"command": "python .claude/hooks/ruff_guard_push.py",
40+
"statusMessage": "ruff: pre-push lint guard"
41+
}
42+
]
43+
}
44+
]
1945
}
2046
}

0 commit comments

Comments
 (0)