git-prism's first git-interception mechanism was a Claude Code hook. It worked, until I watched an agent run make review: the Makefile shelled out to git diff, and the hook never fired. The interception I'd shipped was invisible to one of the cases it most needed to catch.
v0.9.0 fixes that. The hook is gone, replaced by a PATH shim that intercepts git at the process layer. This is the story of why a hook can't win here, what a spike proved, and what gave me the confidence to delete working, shipped code.
What git-prism is, in one paragraph
git-prism is an MCP server that gives AI coding agents structured git data instead of human-oriented porcelain. When an agent reads a unified diff, it pays tokens for @@ hunk headers, +/- line prefixes, and whitespace context that carry no semantic meaning. Then it has to reconstruct what actually changed (which functions, which imports, whether the file is generated) from raw text. git-prism hands it that structure directly. Five MCP tools cover it: get_change_manifest (what changed), get_file_snapshots (before/after content), get_commit_history (per-commit manifests), get_function_context (callers, callees, test references), and review_change (manifest plus context in one call, built to replace git diff <ref>..<ref>).
Here is that difference on a real change in git-prism's own history: the fix that taught the shim to return exit code 126 (found but not executable) instead of 127 (not found). The porcelain an agent gets from git diff:
@@ -67,8 +67,8 @@ impl<E: EnvSource> RealGitExec for StdRealGitExec<'_, E> {
- eprintln!("git-prism shim: failed: {err}");
- ExitCode::from(127)
+ eprintln!("git-prism shim: {} failed: {err}", real.display());
+ ExitCode::from(exec_failure_exit_code(&err))
}
@@ -90,6 +90,17 @@ impl<E: EnvSource> RealGitExec for StdRealGitExec<'_, E> {
+/// Map an io error to the conventional shell exit code.
The same change as a get_change_manifest payload (trimmed to the one source file):
{
"path": "src/shim/real_git.rs",
"language": "rust",
"change_type": "modified",
"lines_added": 32,
"lines_removed": 2,
"functions_changed": [
{
"change_type": "modified",
"name": "StdRealGitExec<'_, E>::passthrough",
"signature": "fn passthrough(&self, argv: &[&str]) -> ExitCode",
"start_line": 54, "end_line": 79
},
{
"change_type": "added",
"name": "exec_failure_exit_code",
"signature": "fn exec_failure_exit_code(err: &std::io::Error) -> u8",
"start_line": 97, "end_line": 102
}
]
}
The agent doesn't reconstruct which functions moved; it's told. That's the whole product. So the question driving this release is: how do you make sure an agent's git calls actually reach git-prism?
The first abstraction: a redirect hook
The original answer was a Claude Code PreToolUse hook (ADR-0008). When an agent issued a Bash command, the hook inspected the command string before execution. If it saw git diff main..HEAD, it rewrote the call to route through git-prism's structured output instead.
For commands an agent types directly, this works. It also has a property the shim can't match: it sees the agent's intent, the literal text with full conversational context, so it can advise or soft-warn in-band. That's a real capability, and it's why I didn't expect to throw it away.
The blind spot
A PreToolUse hook fires on the top-level Bash command string. It is not a syscall interceptor. Any git call issued inside a subprocess never triggers the event:
-
make review, where the Makefile target runsgit diff - a pre-push hook (
lefthook,husky) that runsgitunder the covers - a
cargobuild script, a test harness, any wrapper an agent invokes
The hook sees make review. It never sees the git diff three layers down. Those calls reach the real git untouched and hand the agent exactly the porcelain git-prism exists to replace.
No hook patch fixes this. It's the layer the hook works at, not the depth of the bug. As long as interception happens on the command string, anything that reaches git through a process the agent didn't type stays invisible.
The pivot: a PATH shim
The fix is to intercept one layer down, at the process layer instead of the command string (ADR-0009).
Put a binary named git on PATH, ahead of the real git. Now every git invocation in that process tree resolves to git-prism first, including the nested ones, because child processes inherit PATH. git-prism then decides, per call:
-
Intercept when an AI agent is detected, the subcommand is on the watch list (
diff/log/show/blame/pickaxe), and it carries a ref range (main..HEAD). Return structured JSON. -
Pass through otherwise. Humans, CI, non-agents, a bare
git status,git diff --staged: all hit vanilla git, unchanged.
A few design choices that mattered:
-
One binary,
argv[0]dispatch. The shim isgit-prism. At startup,mainchecks whether it was invoked under a name ending ingit; if so, it enters shim mode, otherwise it runs the normal CLI. One build, one release artifact, no version skew between the shim and the JSON path it reuses. It's the classic Unix multi-call trick (busybox, coreutils'[). -
A cross-process loop break. When the shim hands off to the real git, that git may itself shell out (hooks, build scripts), and those nested calls re-enter the shim, because it's still first on
PATH. An in-process counter can't see across a process boundary; only an inherited environment variable can. So the shim setsGIT_PRISM_INSIDE_SHIM=1in every child it spawns and passes straight through if it sees that flag on entry. As a bonus,GIT_PRISM_INSIDE_SHIM=1 git …is a user-facing escape hatch that forces vanilla git for one command. -
An exact port of the existing classifier. The watch-list and ref-range logic is a faithful port of the hook's Python classifier into
src/shim/classify.rs(same watch list, same ref-range detection), so the two interception points can't disagree about what counts as interceptable.
The shim also extends past git. With a gh symlink ahead of the real gh, the shim recognizes argv[0] == "gh" and routes gh pr diff <number> through the same manifest pipeline, resolving the PR's base..head with gh pr view and returning the same JSON. Every other gh subcommand passes through. One interception layer for every channel an agent reaches for.
The hard part: a frozen PATH
There was one load-bearing unknown, and it nearly sank the whole plan: does the shim's PATH entry actually reach the subshell Claude Code spawns for each Bash command? If Claude Code runs commands in a fresh shell that doesn't see the rc-edited PATH, the shim never resolves and the whole approach is dead.
I spiked it from inside a live Claude Code session, the exact process under investigation (ADR-0010). What I found:
- Each Bash tool call runs as
/bin/zsh -c, and the command is wrapped to first source a shell snapshot. - That snapshot is generated once per
claudelaunch, and it hardcodesPATHas a single resolved string. It does not re-evaluate your rc files per command. - The snapshot captures rc-defined aliases and functions too, which proves it sources your rc when it's built, even though the launching shell didn't.
So PATH inside the Bash tool is the rc-derived PATH from when claude was launched, frozen into a per-session snapshot.
Then I tested a fake git ahead of the real one, five ways:
| Invocation | Intercepted? |
|---|---|
Direct git status
|
yes |
sh -c 'git log' |
yes: children inherit PATH
|
git inside a Makefile target |
yes: build tools inherit PATH
|
script → script → git (2 levels) |
yes: transitive, arbitrarily deep |
env -i PATH=/usr/bin:/bin git |
no: strip PATH and the shim is gone |
Rows two through four are precisely the calls the redirect hook is structurally blind to. The shim catches all of them. That table is the empirical proof of the "shim ⊇ hook" claim.
It also defines the one constraint: the snapshot is frozen at launch, so you install the shim and then restart Claude Code. A PATH change after launch is invisible to the current session. That's a one-time step, not an ongoing race, but it has to be loud, which is why git-prism shim install prompts to append the export PATH line to your rc and then tells you, in plain terms, to restart Claude Code.
Deleting my own feature
Once the spike proved the shim is a strict superset of the hook on a correctly-configured PATH, the hook's status changed. It no longer covered anything the shim didn't, and it covered less. Keeping it would mean shipping two overlapping interception mechanisms, two classifiers to keep in sync, two things to document and reason about.
So v0.9.0 removes it (ADR-0011). It isn't deprecated-but-functional; it's deleted. The Python hook scripts are gone from the repo, the embed code is gone from src/hooks.rs, and git-prism hooks install now exits non-zero with a message pointing you at the shim. That commit removed 3,883 lines.
Deleting a shipped feature is the part that's supposed to feel reckless. It didn't, and the reason isn't nerve.
What let me hit delete without flinching
I'm a solo developer. There's no team to catch me, no second reviewer who remembers why the hook existed. The only thing standing between me and a confident-but-wrong deletion is the process I hold myself to. For this epic, that process was the safety net:
Spike → ADR, before any production code. The unknowns went on a disposable spike branch whose only deliverable is an ADR, with no TDD and no code to fall in love with. ADR-0008, 0009, and 0010 captured why the hook existed, why the shim supersedes it, and what evidence backs that, in writing, before I touched the implementation. By the time I got to removal, ADR-0011 wasn't a gut call; it was a conclusion with three documents of argument behind it. The "supersedes ADR-0008" line at the top of 0011 is the receipt.
BDD scenarios that bind to behavior, not internals. The acceptance tests are Gherkin run by
behave: Python driving the Rust binary as a black box, on purpose. A different language than production means the tests can't reach into internals; they can only assert on observable behavior. They encoded the exact cases the hook missed (nested git,gh pr diff) as the shim's contract before I wrote it. When they went green, "the shim covers what the hook couldn't" stopped being a claim and became a passing test.Strict TDD for the implementation. Red, green, triangulate, refactor. The exit-code fix I showed earlier shipped with
tests/shim_exit_codes.rsasserting 126 for an unexecutable git and 127 for a missing one. Every behavior the shim has, a test pins down.A pre-merge gauntlet, every time. Bug hunting, a quality audit, a security pass, adversarial QA, and a set of language-specific purity checks run before anything reaches
main. "Tests pass" is necessary, not sufficient. Nothing in this epic skipped it.A capstone demo as the final gate. The epic isn't done until there's a narrated, end-to-end recording proving it works, including the nested
make reviewcase andgh pr diffagainst a real PR. (That's the recording below.) If it can't be demonstrated, it isn't finished.
None of that is heroic. It's boring, and that's the point. The confidence to delete working code came from artifacts, not bravado: the ADRs said why, the BDD scenarios and the capstone said it still works. I could afford to be bold precisely because the process is conservative.
See it run
Demo GIF goes here: drag
capstone.gifinto the dev.to editor at this spot, or paste a hosted URL.
Six beats: git-prism shim install (with PATH consent) → git-prism shim status → a direct git diff main..feature returning JSON inside an agent session → the same diff caught when make review runs it as a subprocess → gh pr diff against a real PR returning a manifest → and git-prism hooks install erroring out, the hook formally retired.
(Full video: link the .mp4 once hosted.)
Migrating from the hook
If you were running the redirect hook, the upgrade is two commands plus a restart:
# Remove the old settings.json hook entry
git-prism hooks uninstall --scope user # or --scope project / local
# Install the PATH shim (prompts to add the export PATH line to your rc)
git-prism shim install
# Then restart Claude Code so its frozen shell snapshot picks up the new PATH
Verify with git-prism shim status, which shows whether the shim is active and where it lives. Note the deliberate breakages: git-prism hooks install now exits non-zero by design, and git-prism hooks install --path-shim still works this release as a deprecated alias for git-prism shim install (with a warning). git-prism hooks uninstall and hooks status stick around for legacy cleanup.
Try it
cargo install git-prism
# or
brew tap mikelane/tap && brew install git-prism
# register the MCP server with Claude Code
claude mcp add git-prism -- git-prism serve
# put the shim ahead of git on PATH, then restart Claude Code
git-prism shim install
The shim is one interception layer for every channel an agent reaches for git: direct calls, nested subprocesses, and gh pr diff. The redirect hook is retired. Code, ADRs, and the full changelog are at github.com/mikelane/git-prism; the crate is on crates.io.
The move that looks like nerve, deleting your own shipped feature, was really just process showing its work.
Top comments (0)