Skip to content

Commit 13185af

Browse files
committed
feat: do not run quality checks when no files have changed
1 parent 747dac8 commit 13185af

7 files changed

Lines changed: 106 additions & 9 deletions

File tree

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,10 @@ Each time a file is edited:
1414
When Claude is ready to stop:
1515
- Reformat edited files with `ruff format`
1616
- Repair lints of edited files with `ruff check --fix`
17-
- Type-check edited files with `mypy`
18-
- Run the tests with `pytest`
17+
- Type-check the project with `mypy` (only if files were edited)
18+
- Run the tests with `pytest` (only if files were edited)
1919

20-
Note: We defer `ruff format` and `ruff check` until Claude stops to avoid changing files while Claude is working. Changing files during editing would spoil Claude's edits and force it to reread files.
20+
Note: We defer all quality checks until Claude stops to avoid changing files while Claude is working. Changing files during editing would spoil Claude's edits and force it to reread files. Quality checks only run when at least one Python file has been edited during the session.
2121
## Installation
2222

2323
```bash

src/python_claude/hooks/edited_hook.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,10 @@ def run(self) -> int:
5050
if not file_path or not self.is_python_file(file_path):
5151
return 0
5252

53-
# Track for both ruff check and ruff format
53+
# Track for all quality checks
5454
self._track_file(self.check_track_file, file_path)
5555
self._track_file(self.format_track_file, file_path)
56+
self._track_file(self.mypy_track_file, file_path)
57+
self._track_file(self.pytest_track_file, file_path)
5658

5759
return 0

src/python_claude/hooks/mypy_hook.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@ def run(self) -> int:
2525
"""Run mypy on the edited file or entire project if enabled.
2626
2727
If file_path is provided and is a Python file, run mypy on that file.
28-
If no file_path is provided (e.g., Stop hook), run mypy on entire project.
28+
If no file_path is provided (e.g., Stop hook), run mypy on entire project
29+
only if files were edited.
2930
"""
3031
state = QualityCheckState(self.project_dir)
3132
if not state.is_enabled("mypy"):
@@ -41,7 +42,11 @@ def run(self) -> int:
4142
return 0
4243
mypy_target = file_path
4344
else:
44-
# No file path (Stop hook) - check entire project
45+
# No file path (Stop hook) - check if any Python files were edited
46+
if not self.track_file.exists() or self.track_file.stat().st_size == 0:
47+
self.log("No edited Python files")
48+
return 0
49+
# Check entire project
4550
mypy_target = "."
4651

4752
self.log(mypy_target)
@@ -56,6 +61,10 @@ def run(self) -> int:
5661
exit_code = result.returncode
5762
self.log(f"exit {exit_code}")
5863

64+
# Clean up tracking file on success (only for Stop hook)
65+
if exit_code == 0 and not file_path:
66+
self.track_file.unlink(missing_ok=True)
67+
5968
# Map mypy exit code 1 (type errors) to exit code 2 for Claude Code correction
6069
if exit_code == 1:
6170
return 2

src/python_claude/hooks/pytest_hook.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,19 @@ def track_file(self) -> Path:
2222
return self.log_dir / "pytest-files.txt"
2323

2424
def run(self) -> int:
25-
"""Run pytest if enabled."""
25+
"""Run pytest if enabled and files were edited."""
2626
state = QualityCheckState(self.project_dir)
2727
if not state.is_enabled("pytest"):
2828
self.log("Skipped (disabled)")
2929
return 0
3030

31+
# Check if any Python files were edited
32+
if not self.track_file.exists() or self.track_file.stat().st_size == 0:
33+
self.log("No edited Python files")
34+
return 0
35+
36+
self.log("Running pytest")
37+
3138
result = subprocess.run(
3239
["poetry", "run", "pytest"],
3340
cwd=self.project_dir,
@@ -40,4 +47,9 @@ def run(self) -> int:
4047
if exit_code == 1:
4148
exit_code = 2
4249
self.log(f"exit {exit_code}")
50+
51+
# Clean up tracking file on success
52+
if exit_code == 0:
53+
self.track_file.unlink(missing_ok=True)
54+
4355
return exit_code

tests/test_edited_hook.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,15 @@ def test_tracks_python_file(self, tmp_path: Path) -> None:
1919
hook = EditedHook(hook_input)
2020
exit_code = hook.run()
2121
assert exit_code == 0
22-
# Check both tracking files
22+
# Check all tracking files
2323
assert hook.check_track_file.exists()
2424
assert "/test/file.py" in hook.check_track_file.read_text()
2525
assert hook.format_track_file.exists()
2626
assert "/test/file.py" in hook.format_track_file.read_text()
27+
assert hook.mypy_track_file.exists()
28+
assert "/test/file.py" in hook.mypy_track_file.read_text()
29+
assert hook.pytest_track_file.exists()
30+
assert "/test/file.py" in hook.pytest_track_file.read_text()
2731

2832
def test_ignores_non_python_file(self, tmp_path: Path) -> None:
2933
hook_input = HookInput(
@@ -37,6 +41,8 @@ def test_ignores_non_python_file(self, tmp_path: Path) -> None:
3741
assert exit_code == 0
3842
assert not hook.check_track_file.exists()
3943
assert not hook.format_track_file.exists()
44+
assert not hook.mypy_track_file.exists()
45+
assert not hook.pytest_track_file.exists()
4046

4147
def test_does_not_duplicate_files(self, tmp_path: Path) -> None:
4248
hook_input = HookInput(
@@ -49,8 +55,12 @@ def test_does_not_duplicate_files(self, tmp_path: Path) -> None:
4955
hook1.run()
5056
hook2 = EditedHook(hook_input)
5157
hook2.run()
52-
# Check both tracking files don't have duplicates
58+
# Check all tracking files don't have duplicates
5359
check_content = hook2.check_track_file.read_text()
5460
assert check_content.count("/test/file.py") == 1
5561
format_content = hook2.format_track_file.read_text()
5662
assert format_content.count("/test/file.py") == 1
63+
mypy_content = hook2.mypy_track_file.read_text()
64+
assert mypy_content.count("/test/file.py") == 1
65+
pytest_content = hook2.pytest_track_file.read_text()
66+
assert pytest_content.count("/test/file.py") == 1

tests/test_mypy_hook.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,10 @@ def test_stop_hook_no_file_path_success(self, tmp_path: Path) -> None:
8787
)
8888
with patch.dict(os.environ, {"CLAUDE_PROJECT_DIR": str(tmp_path)}):
8989
hook = MypyHook(hook_input)
90+
# Create tracking file with edited files
91+
hook.track_file.parent.mkdir(parents=True, exist_ok=True)
92+
hook.track_file.write_text("/path/to/file.py\n")
93+
9094
mock_result = MagicMock()
9195
mock_result.returncode = 0
9296
with patch("subprocess.run", return_value=mock_result) as mock_run:
@@ -96,6 +100,8 @@ def test_stop_hook_no_file_path_success(self, tmp_path: Path) -> None:
96100
# Verify it ran mypy on current directory
97101
call_args = mock_run.call_args
98102
assert call_args[0][0] == ["poetry", "run", "mypy", "."]
103+
# Verify tracking file was cleaned up on success
104+
assert not hook.track_file.exists()
99105

100106
def test_stop_hook_type_errors(self, tmp_path: Path) -> None:
101107
"""Test Stop hook with type errors (exit 1) transforms to exit code 2."""
@@ -110,12 +116,36 @@ def test_stop_hook_type_errors(self, tmp_path: Path) -> None:
110116
)
111117
with patch.dict(os.environ, {"CLAUDE_PROJECT_DIR": str(tmp_path)}):
112118
hook = MypyHook(hook_input)
119+
# Create tracking file with edited files
120+
hook.track_file.parent.mkdir(parents=True, exist_ok=True)
121+
hook.track_file.write_text("/path/to/file.py\n")
122+
113123
mock_result = MagicMock()
114124
mock_result.returncode = 1
115125
with patch("subprocess.run", return_value=mock_result):
116126
exit_code = hook.run()
117127
assert exit_code == 2
118128

129+
def test_stop_hook_no_files_edited(self, tmp_path: Path) -> None:
130+
"""Test Stop hook is skipped when no files were edited."""
131+
hook_input = HookInput(
132+
session_id="abc123",
133+
tool_input={},
134+
raw={
135+
"session_id": "abc123",
136+
"hook_event_name": "Stop",
137+
"stop_hook_active": True,
138+
},
139+
)
140+
with patch.dict(os.environ, {"CLAUDE_PROJECT_DIR": str(tmp_path)}):
141+
hook = MypyHook(hook_input)
142+
# Don't create tracking file
143+
with patch("subprocess.run") as mock_run:
144+
exit_code = hook.run()
145+
assert exit_code == 0
146+
# Verify subprocess was not called
147+
mock_run.assert_not_called()
148+
119149
def test_empty_file_path_runs_full_check(self, tmp_path: Path) -> None:
120150
"""Test empty string file_path is treated as Stop hook."""
121151
hook_input = HookInput(
@@ -125,6 +155,10 @@ def test_empty_file_path_runs_full_check(self, tmp_path: Path) -> None:
125155
)
126156
with patch.dict(os.environ, {"CLAUDE_PROJECT_DIR": str(tmp_path)}):
127157
hook = MypyHook(hook_input)
158+
# Create tracking file with edited files
159+
hook.track_file.parent.mkdir(parents=True, exist_ok=True)
160+
hook.track_file.write_text("/path/to/file.py\n")
161+
128162
mock_result = MagicMock()
129163
mock_result.returncode = 0
130164
with patch("subprocess.run", return_value=mock_result) as mock_run:

tests/test_pytest_hook.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,17 +15,27 @@ def test_pytest_success(self, tmp_path: Path) -> None:
1515
hook_input = HookInput(session_id=None, tool_input={}, raw={})
1616
with patch.dict(os.environ, {"CLAUDE_PROJECT_DIR": str(tmp_path)}):
1717
hook = PytestHook(hook_input)
18+
# Create tracking file with edited files
19+
hook.track_file.parent.mkdir(parents=True, exist_ok=True)
20+
hook.track_file.write_text("/path/to/file.py\n")
21+
1822
mock_result = MagicMock()
1923
mock_result.returncode = 0
2024
with patch("subprocess.run", return_value=mock_result):
2125
exit_code = hook.run()
2226
assert exit_code == 0
27+
# Verify tracking file was cleaned up on success
28+
assert not hook.track_file.exists()
2329

2430
def test_pytest_test_failures(self, tmp_path: Path) -> None:
2531
"""Test that exit code 1 (test failures) is transformed to exit code 2."""
2632
hook_input = HookInput(session_id=None, tool_input={}, raw={})
2733
with patch.dict(os.environ, {"CLAUDE_PROJECT_DIR": str(tmp_path)}):
2834
hook = PytestHook(hook_input)
35+
# Create tracking file with edited files
36+
hook.track_file.parent.mkdir(parents=True, exist_ok=True)
37+
hook.track_file.write_text("/path/to/file.py\n")
38+
2939
mock_result = MagicMock()
3040
mock_result.returncode = 1
3141
with patch("subprocess.run", return_value=mock_result):
@@ -37,6 +47,10 @@ def test_pytest_other_error(self, tmp_path: Path) -> None:
3747
hook_input = HookInput(session_id=None, tool_input={}, raw={})
3848
with patch.dict(os.environ, {"CLAUDE_PROJECT_DIR": str(tmp_path)}):
3949
hook = PytestHook(hook_input)
50+
# Create tracking file with edited files
51+
hook.track_file.parent.mkdir(parents=True, exist_ok=True)
52+
hook.track_file.write_text("/path/to/file.py\n")
53+
4054
mock_result = MagicMock()
4155
mock_result.returncode = 3
4256
with patch("subprocess.run", return_value=mock_result):
@@ -48,12 +62,28 @@ def test_pytest_no_tests_collected(self, tmp_path: Path) -> None:
4862
hook_input = HookInput(session_id=None, tool_input={}, raw={})
4963
with patch.dict(os.environ, {"CLAUDE_PROJECT_DIR": str(tmp_path)}):
5064
hook = PytestHook(hook_input)
65+
# Create tracking file with edited files
66+
hook.track_file.parent.mkdir(parents=True, exist_ok=True)
67+
hook.track_file.write_text("/path/to/file.py\n")
68+
5169
mock_result = MagicMock()
5270
mock_result.returncode = 5
5371
with patch("subprocess.run", return_value=mock_result):
5472
exit_code = hook.run()
5573
assert exit_code == 5
5674

75+
def test_pytest_no_files_edited(self, tmp_path: Path) -> None:
76+
"""Test that pytest is skipped when no files were edited."""
77+
hook_input = HookInput(session_id=None, tool_input={}, raw={})
78+
with patch.dict(os.environ, {"CLAUDE_PROJECT_DIR": str(tmp_path)}):
79+
hook = PytestHook(hook_input)
80+
# Don't create tracking file
81+
with patch("subprocess.run") as mock_run:
82+
exit_code = hook.run()
83+
assert exit_code == 0
84+
# Verify subprocess was not called
85+
mock_run.assert_not_called()
86+
5787
def test_pytest_skipped_when_disabled(self, tmp_path: Path) -> None:
5888
"""Test that pytest is skipped when disabled in state."""
5989
hook_input = HookInput(session_id=None, tool_input={}, raw={})

0 commit comments

Comments
 (0)