Skip to content

Commit b573ae8

Browse files
committed
feat: defer changes until claude stops
1 parent 5aa24fe commit b573ae8

7 files changed

Lines changed: 99 additions & 51 deletions

File tree

.claude/settings.json

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@
1717
"Stop": [
1818
{
1919
"hooks": [
20+
{
21+
"type": "command",
22+
"command": "poetry run python-claude ruff format"
23+
},
2024
{
2125
"type": "command",
2226
"command": "poetry run python-claude ruff check"
@@ -36,17 +40,9 @@
3640
{
3741
"matcher": "Write|Edit",
3842
"hooks": [
39-
{
40-
"type": "command",
41-
"command": "poetry run python-claude ruff format"
42-
},
4343
{
4444
"type": "command",
4545
"command": "poetry run python-claude edited"
46-
},
47-
{
48-
"type": "command",
49-
"command": "poetry run python-claude mypy"
5046
}
5147
]
5248
}

README.md

Lines changed: 15 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,15 @@ Python hooks for Claude Code for projects using:
99
The hooks ensure that the quality tools are automatically used:
1010

1111
Each time a file is edited:
12-
- Reformat with `ruff format`
13-
- Type-check with `mypy`
14-
- Record the file
12+
- Record the file for later processing
1513

16-
When claude is ready to stop:
17-
- Repair lints of edited files with `ruff check --fix`. (We do not do this while editing files, becaue it Claude tends to add `import` statements in a dedicated edit, ahead of adding the code that uses it. In this case, `ruff check --fix` would erase the "unused" import.)
18-
- Run the tests with `pytest`.
14+
When Claude is ready to stop:
15+
- Reformat edited files with `ruff format`
16+
- Repair lints of edited files with `ruff check --fix`
17+
- Type-check edited files with `mypy`
18+
- Run the tests with `pytest`
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.
2021
## Installation
2122

2223
```bash
@@ -29,12 +30,12 @@ This package provides hooks that can be used with Claude Code's hook system.
2930

3031
### Available Commands
3132

32-
- `edited` - Tracks edited Python files for deferred processing
33+
- `edited` - Tracks edited Python files for deferred processing (used in PostToolUse hook)
3334
- `git status` - Shows git status
34-
- `mypy` - Runs mypy type checking on edited files when used as a PostToolUse hook or all files when used as a Stop hook
35+
- `mypy` - Runs mypy type checking on edited files (used in Stop hook)
3536
- `pytest` - Runs pytest
36-
- `ruff check` - Runs ruff check on collected files with auto-fix
37-
- `ruff format` - Runs ruff format on edited files
37+
- `ruff check` - Runs ruff check on collected files with auto-fix (used in Stop hook)
38+
- `ruff format` - Runs ruff format on collected files (used in Stop hook)
3839
- `session start` - Prints introductory message about automatic hooks
3940

4041
### Claude Code Settings
@@ -61,6 +62,10 @@ Add hooks to your Claude Code settings.json:
6162
"Stop": [
6263
{
6364
"hooks": [
65+
{
66+
"type": "command",
67+
"command": "poetry run python-claude ruff format"
68+
},
6469
{
6570
"type": "command",
6671
"command": "poetry run python-claude ruff check"
@@ -80,17 +85,9 @@ Add hooks to your Claude Code settings.json:
8085
{
8186
"matcher": "Write|Edit",
8287
"hooks": [
83-
{
84-
"type": "command",
85-
"command": "poetry run python-claude ruff format"
86-
},
8788
{
8889
"type": "command",
8990
"command": "poetry run python-claude edited"
90-
},
91-
{
92-
"type": "command",
93-
"command": "poetry run python-claude mypy"
9491
}
9592
]
9693
}

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "python-claude"
3-
version = "0.2.0"
3+
version = "0.3.0"
44
description = "Python hooks for Claude Code"
55
authors = [{name = "CVector", email = "support@cvector.com"}]
66
readme = "README.md"
Lines changed: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""Collects edited Python files for deferred ruff check."""
1+
"""Collects edited Python files for deferred processing."""
22

33
from pathlib import Path
44

@@ -14,25 +14,34 @@ def __init__(self, hook_input: HookInput | None = None) -> None:
1414
super().__init__(hook_input)
1515

1616
@property
17-
def track_file(self) -> Path:
18-
"""Get the path to the edited files tracking file."""
17+
def check_track_file(self) -> Path:
18+
"""Get the path to the ruff check tracking file."""
1919
return self.log_dir / "edited-files.txt"
2020

21-
def run(self) -> int:
22-
"""Track the edited file if it's a Python file."""
23-
file_path = self.input.file_path
24-
if not file_path or not self.is_python_file(file_path):
25-
return 0
21+
@property
22+
def format_track_file(self) -> Path:
23+
"""Get the path to the ruff format tracking file."""
24+
return self.log_dir / "format-files.txt"
2625

27-
# Read existing tracked files
26+
def _track_file(self, track_file: Path, file_path: str) -> None:
27+
"""Add file to tracking file if not already present."""
2828
tracked_files: set[str] = set()
29-
if self.track_file.exists():
30-
tracked_files = set(self.track_file.read_text().strip().split("\n"))
29+
if track_file.exists():
30+
tracked_files = set(track_file.read_text().strip().split("\n"))
3131
tracked_files.discard("")
3232

33-
# Add file if not already tracked
3433
if file_path not in tracked_files:
35-
with open(self.track_file, "a") as f:
34+
with open(track_file, "a") as f:
3635
f.write(f"{file_path}\n")
3736

37+
def run(self) -> int:
38+
"""Track the edited file if it's a Python file."""
39+
file_path = self.input.file_path
40+
if not file_path or not self.is_python_file(file_path):
41+
return 0
42+
43+
# Track for both ruff check and ruff format
44+
self._track_file(self.check_track_file, file_path)
45+
self._track_file(self.format_track_file, file_path)
46+
3847
return 0

src/python_claude/hooks/ruff_format_hook.py

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,32 +2,54 @@
22

33
import subprocess
44
import sys
5+
from pathlib import Path
56

67
from python_claude.hooks.base import Hook, HookInput
78

89

910
class RuffFormatHook(Hook):
10-
"""Runs ruff format on edited Python files."""
11+
"""Runs ruff format on all files collected during the session."""
1112

1213
name = "ruff-format"
1314

1415
def __init__(self, hook_input: HookInput | None = None) -> None:
1516
super().__init__(hook_input)
1617

18+
@property
19+
def track_file(self) -> Path:
20+
"""Get the path to the format files tracking file."""
21+
return self.log_dir / "format-files.txt"
22+
1723
def run(self) -> int:
18-
"""Run ruff format on the edited file if it's a Python file."""
19-
file_path = self.input.file_path
20-
if not file_path or not self.is_python_file(file_path):
24+
"""Run ruff format on all tracked files."""
25+
# Check if tracking file exists and has content
26+
if not self.track_file.exists() or self.track_file.stat().st_size == 0:
27+
self.log("No edited Python files to format")
28+
return 0
29+
30+
files: list[str] = []
31+
for line in self.track_file.read_text().strip().split("\n"):
32+
file_path = line.strip()
33+
if file_path and Path(file_path).exists():
34+
files.append(file_path)
35+
36+
if not files:
37+
self.log("No existing Python files to format")
38+
self.track_file.unlink(missing_ok=True)
2139
return 0
2240

23-
self.log(file_path)
41+
self.log(f"Formatting {len(files)} files: {' '.join(files)}")
2442

2543
result = subprocess.run(
26-
["poetry", "run", "ruff", "format", file_path],
44+
["poetry", "run", "ruff", "format", *files],
2745
cwd=self.project_dir,
2846
stdout=sys.stderr,
2947
)
3048

3149
exit_code = result.returncode
3250
self.log(f"exit {exit_code}")
51+
52+
if exit_code == 0:
53+
self.track_file.unlink(missing_ok=True)
54+
3355
return exit_code

tests/test_edited_hook.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,11 @@ 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-
assert hook.track_file.exists()
23-
assert "/test/file.py" in hook.track_file.read_text()
22+
# Check both tracking files
23+
assert hook.check_track_file.exists()
24+
assert "/test/file.py" in hook.check_track_file.read_text()
25+
assert hook.format_track_file.exists()
26+
assert "/test/file.py" in hook.format_track_file.read_text()
2427

2528
def test_ignores_non_python_file(self, tmp_path: Path) -> None:
2629
hook_input = HookInput(
@@ -32,7 +35,8 @@ def test_ignores_non_python_file(self, tmp_path: Path) -> None:
3235
hook = EditedHook(hook_input)
3336
exit_code = hook.run()
3437
assert exit_code == 0
35-
assert not hook.track_file.exists()
38+
assert not hook.check_track_file.exists()
39+
assert not hook.format_track_file.exists()
3640

3741
def test_does_not_duplicate_files(self, tmp_path: Path) -> None:
3842
hook_input = HookInput(
@@ -45,5 +49,8 @@ def test_does_not_duplicate_files(self, tmp_path: Path) -> None:
4549
hook1.run()
4650
hook2 = EditedHook(hook_input)
4751
hook2.run()
48-
content = hook2.track_file.read_text()
49-
assert content.count("/test/file.py") == 1
52+
# Check both tracking files don't have duplicates
53+
check_content = hook2.check_track_file.read_text()
54+
assert check_content.count("/test/file.py") == 1
55+
format_content = hook2.format_track_file.read_text()
56+
assert format_content.count("/test/file.py") == 1

tests/test_ruff_format_hook.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
"""Tests for RuffFormatHook."""
2+
3+
import os
4+
from pathlib import Path
5+
from unittest.mock import patch
6+
7+
from python_claude.hooks.base import HookInput
8+
from python_claude.hooks.ruff_format_hook import RuffFormatHook
9+
10+
11+
class TestRuffFormatHook:
12+
def test_no_files_to_format(self, tmp_path: Path) -> None:
13+
hook_input = HookInput(session_id=None, tool_input={}, raw={})
14+
with patch.dict(os.environ, {"CLAUDE_PROJECT_DIR": str(tmp_path)}):
15+
hook = RuffFormatHook(hook_input)
16+
exit_code = hook.run()
17+
assert exit_code == 0

0 commit comments

Comments
 (0)