The diff was seven lines removed and seven added in .gitignore: four blanket-ignored .claude/* entries swapped for two narrow un-ignores. Small enough to skim, and small enough to almost ship without checking what else lived in that directory.

The change existed because of one hook. On July 21, a serial leading-wildcard scan (LIKE '%...') crawled six cache tables on the shared live prod DB and wedged it for about five minutes, throwing errors on every customer /accounts page. The fix was a PreToolUse hook, .claude/hooks/block-prod-db-scan.sh, dependency-free, with no ops-tools, node or python required. It inspects any Bash/Write/Edit/MultiEdit payload for a prod-DB connection marker (company_DB_*/company_CACHE_*, DSN=appdb..., pyodbc, sqlcmd, Invoke-Sqlcmd) combined with a leading-wildcard LIKE, and blocks it. The hook itself was straightforward. The hard part was where it had to live to apply to everyone.

Why the hook couldn’t stay personal

A hook only fires if it’s wired into .claude/settings.json. That file had been fully gitignored repo-wide since an earlier fix, so every developer’s settings were local and private, drifting independently. Paste the new guardrail into a single settings.local.json and it protects exactly one clone. Everyone else’s next git pull comes down clean, with no hook, no protection, and no signal that anything is missing.

So the fix was to un-ignore settings.json so the wiring could be shared and version-controlled. That’s the .gitignore diff:

-.claude/settings.local.json
-.claude/settings.json
-.claude/mcp.json
-.claude/*.lock
...
.claude/*
!.claude/skills/
+!.claude/hooks/
+!.claude/settings.json

Only then did we look at what the shared settings.json would replace, instead of assuming it was blank because it had never been tracked.

The wrong turn

Our first move was to add the new hook entry to settings.json and push it. Fast, minimal diff, done. Before committing we ran git ls-tree -r --name-only origin/develop -- .claude to see what was already tracked on develop, mostly as a formality.

It wasn’t one. Two other hooks were already sitting in .claude/hooks/ on develop: check-branch.sh, a guard that blocks Edit/Write directly on master/develop, and check-learnings.sh. Neither was referenced in any tracked settings.json, because there wasn’t one. Each developer’s settings.local.json was the only thing wiring those hooks in, and it had drifted per clone since the earlier fix gitignored the shared file. We checked .claude across all ten clones:

clone-1 hooks/ skills/ settings.local.json 3.6K
clone-2 hooks/ skills/ settings.local.json 1.0K
clone-3 hooks/ skills/ settings.local.json 549B
clone-4 hooks/ skills/ settings.json 261B settings.local.json 3.6K

The sizes ran from 549 bytes to 3.6K on files with the same nominal purpose. Had we committed a settings.json that wired only the new prod-DB hook, it would have become the tracked source of truth the moment anyone pulled it. check-branch.sh would have stopped firing for them, on develop and master both, the exact branches it exists to protect. We would have traded one incident risk for a second, worse one, and shipped it as a fix.

Shipping all three at once

The change went out with three hooks together: the branch guard, the pre-commit installer, and the new DB scan guardrail, all wired through the newly tracked settings.json. Before pushing, we rehearsed the migration in a spare clone to see what a real developer’s next pull would do. It throws a merge conflict on their local settings.json/settings.local.json instead of silently overwriting. That’s a feature: it forces the 30-second cleanup instead of letting it get skipped.

Then we wrote the message that actually goes out, instead of a changelog entry nobody reads:

FYI, we made some updates to help our development rules stay in sync across developers. This will cause your next pull to throw an error. Run the following prompt to get your local setup back in sync.

A paste-ready prompt was attached, and the two remaining local-config cleanups were filed as their own follow-up. The guardrail now propagates itself. Anyone who cloned since the earlier gitignore change gets the branch guard back, and anyone who clones fresh gets all three hooks by default, with no manual step to forget.

What the diff didn’t show

.claude/settings.json is a dependency graph, and every hook anyone has wired into it depends on whatever gets changed next. git diff on the file you’re changing says nothing about the files that already live next to it. Before sharing a config that was previously local, list what is already tracked and what each clone has wired locally, then ship the related guardrails together.