Skip to content
Chinh Hoang
Go back

My agents' git freshness hook did nothing for at least 55 days after I moved them to Linux

Markdown
Open in ChatGPTOpen in Claude
Contents
  1. Does this apply to you?
  2. Why the hook exists
  3. How it broke
  4. Before and after
  5. What still fails
  6. The guardrail on the guardrail
  7. The cost
  8. Check your own hook

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?

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:

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:3329 of 47
After one prime run, 06:3519 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.

  1. The defaults now cover ~/work and ~/Work, deduplicated.
  2. Off macOS, the cache and report move to ~/.cache and ~/.local/state.
  3. When no repo root exists, prime writes 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.

My agent drafted this post from my logs and notes. Every claim was checked against its source, and I approved it.


Share this post:

Previous Post
Claude Code prompt cache: 1 hour or 5 minutes? A replay of 49 days of my transcripts