The hooks were deadlocking during rebases. Not sometimes — every time. An AI coding agent would kick off a rebase, the hook would fire mid-operation, the hook would try to check pending commits, and the whole thing would just… freeze. The agent couldn’t continue the rebase because the hook was blocking it. The hook couldn’t commit because you can’t commit mid-rebase. A perfect little standoff, both sides waiting politely for the other to go first.
This is one of those bugs that’s obvious in retrospect. Git hooks fire on git operations. Rebases, merges, cherry-picks, and reverts are compound git operations — they create commits internally, which means hooks fire at moments when the repository is in a transitional state. The .git directory literally has breadcrumb files lying around (rebase-merge/, MERGE_HEAD, CHERRY_PICK_HEAD, REVERT_HEAD) that say “hey, I’m in the middle of something.” Nobody was checking those breadcrumbs.
The fix was a new function — git.IsInteractiveGitOp() — that checks for exactly those state files. Every hook now early-returns if Git is mid-operation. A surprisingly hefty chunk of new code for what amounts to “if busy, don’t bother.” The code-reviewer agent actually earned its keep here, catching two real issues before the commit landed: the git directory path wasn’t being resolved relative to the repo root (so it broke if you ran from a subdirectory), and pending.go was checking mid-operation status after it had already called GetPendingCommits, which defeated the purpose entirely. Both caught, both fixed. That’s the kind of thing that would have been another bug report in a week.
The right time to check whether you should run is before you do anything, not after.
With the deadlock fixed, that went out as v0.16.2. But shipping exposed a separate, subtler problem — one that had been lurking quietly.
Users reported that doctor --fix wasn’t actually cleaning up stale Claude hooks. You’d run the doctor, it would detect PreToolUse hooks from a retired event living in your global settings, dutifully offer to fix them, and then… nothing would change. Run it again, same detection, same offer, same result. The fix was broken.
The root cause was a scope mismatch. Detect() was correctly finding stale hooks wherever they lived — including global Claude settings. But Install(true) was hardcoded to write to project-local scope. So the retired-event cleanup logic worked perfectly — it was just running against the wrong file. Like fixing a typo in a photocopy while the original sits untouched in the filing cabinet. The actual fix was tiny: pass the detected scope from Detect() through to the install call, so cleanup targets the same file where the problem was found.
The pattern here is one that shows up constantly in systems with layered configuration: detection and remediation have to agree on where they’re operating, not just what they’re operating on. Git itself has this — global vs. system vs. local config — and every tool that layers on top inherits the same class of bug. If your “find” and your “fix” don’t share a scope parameter, you’ll write a fix that silently fixes nothing. It’s the configuration equivalent of sweeping the wrong room.
Both of these bugs share a family resemblance. The rebase deadlock happened because hooks didn’t know when they shouldn’t run. The stale-hook cleanup failed because the doctor didn’t know where it should write. In both cases, the logic was correct in isolation — the orchestration was wrong. The individual functions did their jobs. They just didn’t have enough context about the environment they were operating in.
The codebase is in a better spot now. Hooks know to stay quiet during compound git operations. The doctor actually fixes what it finds, regardless of which configuration layer the problem lives in. These aren’t glamorous changes, but they’re the kind that turn “this tool fights me sometimes” into “this tool just works.” That’s the whole game.
This post was co-authored with AI assistance.