My git freshness hook, which fetches before my coding agents read git state, returned 0 on every call for at least 55 days and refreshed nothing. On 9 November 2026, 29 of the 47 local default branches on my build server were behind origin; after one run of the fixed hook, 19 were.
Does this apply to you?
- Agents on a second machine, a server or a container with a different home layout: yes. Check that the hook’s report exists there.
- Agents only on the laptop where you wrote and tested the hook: the path bug does not; the silent
|| truelesson still does. - No hooks at all: compare against
origin/<branch>after agit fetch, never against a localmain.
Why the hook exists
On 20 August 2026 an agent told me a branch was pushed but had no pull request. It had merged through two PRs, on 17 and 19 August. The local main was 75 commits behind origin/main, so git log main..branch showed 70 commits that looked unmerged.
The fix: two Claude Code hooks run one script, before every git command for that repo and at session start for all repos. In ~/.claude/settings.json, shortened:
"PreToolUse": [{ "matcher": "Bash", "hooks": [{ "type": "command", "if": "Bash(git *)", "timeout": 25,
"command": "python3 ~/scripts/git-freshness.py --mode touch 2>/dev/null || true" }] }],
"SessionStart": [{ "hooks": [{ "type": "command", "async": true,
"command": "python3 ~/scripts/git-freshness.py --mode prime 2>/dev/null || true" }] }]
Per repo, the script runs one fetch:
git -C "$repo" fetch origin --prune --no-tags '+refs/heads/*:refs/remotes/origin/*' "main:main"
The second refspec has no +, so git refuses a non-fast-forward update to main and local commits stay. A 15-minute stamp skips the network on repeat calls.
How it broke
The defaults named the Mac’s folders: repos in ~/Work, the report in ~/Library/Logs. Then I moved the agents to a Linux server, where repos live in ~/work; the hooks are in its config history from 15 September. There ~/Work does not exist, so both modes returned 0:
primefound no repo and returned 0.touchskipped every path, because none started with~/Work.- The hook command ends in
2>/dev/null || true, so nothing could surface.
No report file ever existed on the server.
Before and after
A script fetched each repo and counted local default branches behind origin:
| Default branches behind origin | |
|---|---|
| Before the fix, 06:33 | 29 of 47 |
After one prime run, 06:35 | 19 of 47 |
That prime run made 56 report entries: 11 fast-forwarded (10 of them among the 47 measured; one was an agent worktree), 35 skipped because the repo had its default branch checked out, 5 without an origin, 3 without a local default branch, 2 already current.
What still fails
Git will not fast-forward a branch that is checked out. The report records how far those repos stay behind. Their origin/* refs are fresh, so an agent comparing against origin/main is right; one comparing against main is not.
The guardrail on the guardrail
Agents make the mistakes now, so rules belong in the harness as hooks, defaults and checks, not in advice. A hook that fails silently enforces nothing.
- The defaults now cover
~/workand~/Work, deduplicated. - Off macOS, the cache and report move to
~/.cacheand~/.local/state. - When no repo root exists,
primewrites the error into the report and exits 1, so a later reader sees it even when stderr is discarded.
Two tests reproduce the failure, one per case.
The cost
A cold touch on my infrastructure repo took 1.34 s; the next two calls took 0.09 s and 0.07 s from the stamp. A repeat prime over all repos took 12.6 s, in the background at session start.
Check your own hook
My output at 06:37:
$ ls -l ~/.local/state/git-freshness/git-freshness.json
-rw-rw-r-- 1 chinh chinh 16897 Nov 9 06:36 /home/chinh/.local/state/git-freshness/git-freshness.json
$ jq -r '.error // empty, (.repos[] | .action)' ~/.local/state/git-freshness/git-freshness.json | sort | uniq -c
10 fast-forwarded
35 skipped-checked-out
1 skipped-fresh
3 skipped-no-local-branch
5 skipped-no-origin
2 up-to-date
A later touch rewrote one entry as skipped-fresh.
No file, or a file older than your last session, means the hook is not running, whatever its exit code says.