Git hooks change PATH, and our gate cache missed 97% of the time
A validation cache keyed on the environment looked fine when I tested it by hand. Inside a Git hook, the environment was different.
Every commit and push in a product I run, split across five repositories, goes through a validation gate in a Git hook. The full gate takes more than a minute, so I gave it a cache: if the exact same content passed in the last hour, the hook reuses that result instead of running everything again. On 2026-09-25 I counted how often that happened. Of 517 hook runs, 15 reused a receipt.
That's a 97% miss rate on a cache that should have hit nearly every time. The intended flow is simple. After staging, you run the full gate once by hand, then commit and push the same content, and both of those should be free. Instead almost all of them paid for the whole gate again.
A receipt is a promise about exact inputs
A receipt is a small record that says this exact input passed. The key that names it binds six things:
- the Git tree being validated
- the validator commands
- the dependency bundle the validators run against
- the runtime files, meaning the node, bun and Python binaries the validators call
- the bytes of the gate runner itself
- the executor, so a pass on the host never stands in for a pass in a pod, or the reverse
A receipt is good for one hour. When it matches, reuse is cheap: on one repository the complete offline gate took 87.001 s fresh and 0.158 s when an exact-content receipt matched. Those 0.158 s aren't another test run. They're a lookup and a hash comparison.
So the design was fine on paper. What I needed to find was why the key a hook computed never matched the key I computed by hand for the same tree.
Git edits PATH before your hook runs
When I printed the environment from inside a hook, the difference was one entry at the front of PATH:
# Illustrative. PATH in my shell:
/home/me/.local/bin:/usr/local/bin:/usr/bin:/bin
# PATH inside pre-push, same shell, same repository:
/usr/lib/git-core:/home/me/.local/bin:/usr/local/bin:/usr/bin:/bin
Git puts its exec path at the front of PATH before it runs a hook, so the helper programs it ships can find each other. On Ubuntu that directory is /usr/lib/git-core, and git --exec-path prints it on any system. That's reasonable for Git and bad news for anything that hashes its environment.
The runtime-files part of the key resolved each tool through PATH and hashed what it found. In my shell, git resolved to /usr/bin/git. Inside a hook it resolved to /usr/lib/git-core/git, and on that machine those are two separate files with different hashes. A probe with identical input produced two unrelated keys, one per context.
Every receipt I prewarmed by hand was keyed for an environment the hook never saw. The hook missed, ran the full gate and wrote its own receipt, which my next manual run then missed in turn.
The second leak was stale runner bytes
While I was in there I counted something else. The runner's own bytes are in the key on purpose: if the gate logic changes, old passes shouldn't count. Each worktree gets an installed copy of the hook bundle, and on 2026-09-25, 196 of 262 installed bundles were running runner bytes that weren't the current reviewed version.
That's a correctness problem as much as a cache one. A stale bundle validates with stale rules, and its receipts can never match a prewarm made with the current runner. So two independent things were keeping keys apart. I suspect fixing only one would have left most of the misses in place, but I didn't measure them separately.
What I changed
The first fix is PATH normalization. The key no longer includes Git's exec-path entries, and the validators run with the same cleaned PATH, so the tools they find are the tools the key describes. Later duplicate entries are dropped too, keeping the first occurrence and the original order. A later copy of a directory can never answer a lookup, so removing it doesn't change which binary runs.
# Illustrative sketch of how the key's PATH is built now.
def key_path(path, git_exec_path):
exec_real = os.path.realpath(git_exec_path)
seen, kept = set(), []
for entry in path.split(os.pathsep):
ident = os.path.realpath(entry) if os.path.isabs(entry) else entry
if ident == exec_real or ident in seen:
continue
seen.add(ident)
kept.append(entry)
return kept
Absolute entries compare by their resolved real path, so /x/ after /x or a symlinked alias counts as a repeat. Relative and empty entries compare only by exact string, because they resolve against whatever directory the validator runs in.
The second fix is hook bundles that refresh themselves. At start, the pre-commit and pre-push shims compare their files with the committed versions on the reviewed main branch of the repository that owns them. If they differ, and that main copy is clean and matches the remote, the shims copy the new bytes in before running. On any doubt, the installed bundle runs and prints one line saying why. Merged hook changes now reach every worktree on its next commit or push without anyone reinstalling anything.
Nothing else about the key got looser. The tree, validators, dependency bundle, runtime files, runner bytes and executor are all still bound, and receipts still expire after an hour.
Before and after
Measured on 2026-09-25 with a real commit and push from a scratch worktree:
| Measurement | Time |
|---|---|
| Push after a prewarmed full gate, before the fix (key mismatch) | 76.5 s |
| Push after a prewarmed full gate, after the fix (key match) | 0.31 s |
| Complete offline gate, fresh | 87.001 s |
| Same gate, exact-content receipt reused | 0.158 s |
The 76.5 s and 0.31 s come from one pair of runs, not a distribution. I also haven't recounted hits over a comparable window of hook runs since the fix, so I can't give you a clean after number to set against 15 of 517.
Log the key's inputs next to the key
I'd log the inputs, not only the key. A cache key is a hash, and two hashes that differ tell you nothing about why. If the gate had written the resolved path and digest of each runtime next to its key, one diff of two records would have pointed at /usr/lib/git-core in minutes.
I'd also track the hit rate from day one. This cache looked healthy because hits are quiet and misses look like normal work. A push that takes 76 seconds doesn't feel broken when it has always taken 76 seconds.
And I'd expect the environment to drift again, because it did. A couple of weeks later a variant showed up: a shell in my agent fleet carried one more copy of a directory at the front of PATH than the process that prewarmed the gate, and the push re-ran everything. That's the case the duplicate-entry rule now covers. Any key built from ambient environment will keep finding new ways to differ, so key on what a tool resolves to, never on the string that got you there.