I shipped a Claude Code hook pack last week called Did-It-Actually. The headline feature is one hook: a PostToolUse script that catches Claude when it claims an edit happened but nothing actually changed on disk.
The hook itself is twenty lines of bash. Writing it was an afternoon. Testing it was two days.
That gap surprised me enough that I want to walk through it, because I think it says something about the hook-authoring job that isn't obvious from the docs. If you're writing hooks of your own, the lesson is: the hook is the easy part. Deciding what counts as "a real change" is where the actual thinking lives.
Here are the 26 test cases I ended up with, grouped by the question each one answers.
Category 1: the "did anything change at all" cases (6 tests)
These are the ones you expect to write. They establish the baseline: given a PostToolUse event on Edit, did git see a diff?
test-01-basic-edit-produces-diff-passes
test-02-identical-content-write-fails
test-03-empty-file-to-empty-file-fails
test-04-create-new-file-passes
test-05-delete-via-write-fails # Write cannot delete; this is the empty-content edge
test-06-same-content-different-line-endings-fails
Nothing surprising here. The one worth calling out is test 06: Claude Code occasionally normalizes line endings on write, so a file with CRLF that gets rewritten as LF is a diff — but it's a diff that nobody intended. In practice this fires on Windows users under WSL, so I handle it with a git diff --ignore-cr-at-eol fallback.
Category 2: the whitespace cases (5 tests)
This is where the fun starts.
test-07-trailing-newline-only-fails
test-08-leading-whitespace-only-passes
test-09-trailing-whitespace-only-passes
test-10-indentation-change-only-passes
test-11-blank-line-insertion-passes
The judgment call: a trailing-newline-only diff is almost always a POSIX-compliance edit, not meaningful work. Nearly every modern editor adds one. Claude's model has internalized this and often adds a newline on write without being asked. If I count that as "the edit changed something", the hook never fires on a real no-op, because every save produces at least that one byte of diff.
So I strip the final-newline-only case explicitly, and treat it as a no-op.
But the other four — leading/trailing whitespace on content lines, indentation, blank-line insertion — I treat as real diffs. If Claude intended to fix indentation, that's work. The hook shouldn't second-guess intent; it should check whether work happened.
I expose this as a config flag, because teams that use pre-commit for whitespace will want to tighten it. The default is "trailing-newline-only is a no-op, everything else is real."
Category 3: the Claude-specific cases (6 tests)
These are the ones I only knew to write after I'd been bitten.
test-12-old-string-identical-to-new-string-fails
test-13-old-string-already-not-in-file-fails-silently
test-14-write-of-identical-content-after-read-fails
test-15-multiedit-with-one-noop-chunk-passes
test-16-multiedit-with-all-noop-chunks-fails
test-17-edit-of-a-generated-dotfile-fails-with-warning
Test 12 is the classic. Claude's Edit tool takes old_string and new_string; if they're identical (which the model occasionally produces when it "fixes" something that was already correct), the file is rewritten byte-for-byte and git sees no change. Without this test, the hook silently passes.
Test 13 is the one I lost half a day to. The Edit tool is supposed to error when old_string is not found. In some paths (particularly when the file was recently rewritten by a sibling tool call), the error is swallowed, the edit is reported as applied, and the file is untouched. The hook needs to catch the file being unchanged, independent of whether the tool reported success — because the tool report lies.
Test 14 is the one that comes up most in logs. Claude does a Read, then decides to "ensure" something is in the file, then does a Write with the exact bytes it just read. The write is successful; the diff is empty.
Tests 15 and 16 are the MultiEdit versions. MultiEdit applies a sequence of edits in one call. If any chunk produces a diff, I pass the hook (the model did real work, even if not all of it). Only an all-no-op MultiEdit fails.
Test 17 is a late addition. Some projects have generated dotfiles (.pre-commit-config.yaml, .github/workflows/*) that Claude will touch and then re-generate. The regen is a no-op, but the file is tracked. I log it but don't exit 2, because exit 2 would create a loop (Claude re-edits in response to the correction, which also produces a no-op, which also fires the hook).
Category 4: the git-state cases (5 tests)
These are the ones that have nothing to do with Claude and everything to do with git being quirky.
test-18-edit-of-untracked-file-passes-if-content-differs
test-19-edit-of-ignored-file-passes-via-fallback-diff
test-20-edit-during-merge-conflict-skips-hook
test-21-edit-of-file-in-detached-head-passes
test-22-edit-when-repo-has-no-commits-passes
git diff HEAD behaves surprisingly in several of these. For untracked files, git diff HEAD -- path returns empty. For .gitignore'd files, same. For a brand-new repo with no commits, there's no HEAD to diff against.
The fallback is a direct cmp against the file's state as it was captured by a sibling PreToolUse hook. The pre-hook stashes the pre-edit bytes in a tempfile keyed on the file path and the tool call ID. The post-hook does cmp -s of the current file against that tempfile. If they differ, there was a change; otherwise it's a no-op.
The merge conflict case is special: during a merge, the file is in an unstable state. The hook skips entirely and logs.
Category 5: the integration cases (4 tests)
test-23-hook-under-concurrent-edits
test-24-hook-with-large-file-10mb
test-25-hook-with-binary-file
test-26-hook-with-symlink
The concurrency test runs two Edit calls back-to-back on the same file; the hook has to key its state by tool-call ID or the second edit will see the first edit's pre-state and report a no-op.
The 10MB test is pure performance — I need the hook to complete in under 100ms so it doesn't make every Edit feel laggy. It currently runs in about 40ms on a 10MB text file; cmp is fast.
The binary case was unexpected: Claude occasionally edits binary files (compiled fixtures, PNGs). I bail out early if file --mime-type returns anything non-text, because comparing bytes is wasteful and false-positives aren't useful for binary.
The symlink case was the last to be written. I resolve symlinks in both the pre- and post-state, because otherwise an edit to a symlink target reads as a no-op on the symlink file itself.
What this cost me
Writing the test cases took about twice as long as writing the hook. Reading them back now, I'd say:
- Six of the 26 are tests I would have written without being bitten (the Category 1 baselines).
- Twelve are tests I added after specific failures in real sessions.
- Eight are tests I wrote because I was going through the git/filesystem edge-case list systematically, trying to find corners the hook didn't handle.
The 12 "added after failure" tests are the ones that matter most, and the reason the hook is worth running at all. If I'd shipped it with only Category 1, it would have caught maybe a third of the no-op cases I see in practice.
If you're writing hooks
Three things I'd pass on, now that this is behind me:
A hook is a classifier, not a filter. You're building something that answers "is this call a real edit or a no-op?" — the hook infrastructure around it is maybe 10% of the work. The other 90% is the classifier, and the classifier is where every edge case lives.
Write the test for every failure you see in a real session. Not "the kind of failure" — the specific sequence. Keep the Edit JSON, keep the file-before, keep the file-after. Replay them. These replays catch regressions when you change the normalization logic later.
Exit 2 is a feedback channel, not a punishment. Your
stderrgoes back to Claude as a correction prompt. Think of it as writing an error message for a specific audience that will try to work around it on the next turn. "The edit of foo.py produced no diff" is informative; "if the file already contained what you intended, say so explicitly" is actionable. The second gets the agent to produce a different behavior on the retry.
The full test suite + the hook source is in the public GitHub repo. The packaged install (with the other three hooks and an audit CLI) is Did-It-Actually on Gumroad — $9.50 with the SHOWDEV50 code through Oct 21.
Happy to answer specific "how do you handle X" questions in the comments — the no-op detector has edge cases I probably still haven't seen.
This post was drafted with AI assistance and reviewed for accuracy.
Top comments (0)