// blog

Hooks that survive Tower, GitHub Desktop, and JetBrains.

Why git hooks installed from the command line silently fail when you commit from a GUI client — and the absolute-path embedding trick that makes commitbrief install-hook just work everywhere.

· CommitBrief

The first time I shipped commitbrief install-hook, I tested it from my terminal, watched the pre-commit fire, declared victory, and moved on. The next week three separate users opened the same issue: “the hook is installed but nothing happens when I commit from Tower / GitHub Desktop / JetBrains.” I reproduced it in Tower in about ninety seconds and then spent the rest of the afternoon understanding why.

What was happening is the kind of thing that’s invisible until you go looking for it: GUI git clients run hooks with a stripped $PATH. The commitbrief binary at /opt/homebrew/bin/commitbrief is unreachable. The hook’s exec commitbrief --staged … silently no-ops because the shell can’t find the binary. No error message, no log, just a commit that goes through without being reviewed. The user assumes the hook is working because nothing’s complaining; the hook is in fact not running at all.

The fix shipped as UC-27 in v1.0.0-rc.1. It’s small and it’s interesting in the way small reliability fixes usually are — most of the value isn’t in the code but in noticing the failure mode at all.

What the GUI clients do to $PATH

When you run git commit from a terminal, the hook inherits your shell’s full environment: ~/.zshrc has already exported /opt/homebrew/bin (or wherever your package manager installs binaries), so commitbrief resolves cleanly from $PATH. When you commit from Tower or GitHub Desktop or JetBrains’ VCS integration or Fork or any of the popular GUI clients, the hook inherits whatever environment the GUI process is running in — and that environment was constructed without sourcing your shell config.

On macOS this means $PATH looks roughly like:

/usr/bin:/bin:/usr/sbin:/sbin

/opt/homebrew/bin isn’t on that list. Neither is /usr/local/bin if you’re on an Intel Mac or anywhere else Homebrew puts its kegs. Neither is ~/go/bin for go install users. The hook script runs, the shell tries to resolve commitbrief, finds nothing, exits silently.

There’s a long-running debate about whether this is a bug in the GUI clients or a feature. The GUI vendors take the position that hook scripts should be self-contained and not depend on shell-config-derived environment. The hook authors take the position that this is a usability disaster. Neither side has moved; the practical effect is that any hook that calls a Homebrew-installed binary by name is broken under at least three popular git clients.

The fix: embed the absolute path

Instead of writing the hook body as:

exec commitbrief --staged --fail-on=critical --quiet --no-cost-check "$@"

— which depends on $PATH resolution — install-hook writes it as:

exec '/opt/homebrew/bin/commitbrief' --staged --fail-on=critical --quiet --no-cost-check "$@"

The absolute path is resolved at install time via os.Executable() followed by filepath.EvalSymlinks. The result is the canonical location of the running binary, with any symlinks resolved.

os.Executable() gives you the path to the running process’s binary, which for a Homebrew install is typically a symlink at /opt/homebrew/bin/commitbrief pointing at the keg at /opt/homebrew/Cellar/commitbrief/<version>/bin/commitbrief. The bare os.Executable() result gives you the symlink. filepath.EvalSymlinks resolves it to the keg path.

I chose to evaluate the symlinks because of the next problem in the chain: Homebrew upgrades.

Surviving brew upgrade

Homebrew’s atomic upgrade story works by installing the new version into a new keg directory and then re-pointing the /opt/homebrew/bin/commitbrief symlink at the new keg. The old keg eventually gets cleaned up by brew cleanup.

If the hook embedded /opt/homebrew/bin/commitbrief directly (the symlink), it would always run the currently-symlinked version — which is normally what you want, but means a brew upgrade silently swaps the binary your hook is calling. That’s a “what did you break my hook for” support ticket waiting to happen, even when the new version is fully backward-compatible.

If the hook embedded the resolved keg path (/opt/homebrew/Cellar/commitbrief/v1.0.0/bin/commitbrief), it would survive brew upgrade of unrelated formulas but would break the moment brew cleanup removed the old keg after you upgraded CommitBrief itself.

Neither pure-symlink nor pure-keg-path is the right answer. The actual right answer, which is what install-hook does, is to embed the resolved-once-at-install path and just accept that re-running install-hook after a major upgrade is sometimes necessary. The trade-off is: hooks survive day-to-day binary movement, and the recovery story when something does shift is a single commitbrief install-hook --yes re-run.

In practice this is rarely needed. Homebrew formulas keep their symlinks stable across upgrades; the keg path changes but the symlink doesn’t, so the embedded symlink stays valid as long as the binary at the other end is current. The recovery story matters mainly when you’ve manually moved your install (changed Homebrew prefixes, switched from Homebrew to a raw binary, etc.).

Single-quoting the path

The embedded path is wrapped in single quotes:

exec '/opt/homebrew/bin/commitbrief' ...

This is paranoia about path components with spaces or shell metacharacters. macOS users on default installs are fine; users on custom prefixes (e.g. /Users/jane/My Tools/bin/commitbrief) would break without quoting. Single-quoting also makes the path resilient against any future shell parsing weirdness — POSIX guarantees no expansion happens inside single quotes, so the path is interpreted literally even if it contains characters that would normally trigger globbing or variable expansion.

The generated marker

Every hook install-hook writes contains a verbatim comment:

Generated by `commitbrief install-hook`

The --uninstall path greps for this marker before removing anything. A hand-written hook of the same name is never silently clobbered. If you’ve customised your pre-commit and then run commitbrief install-hook --uninstall, the command refuses with <path> was not written by commitbrief; refusing to remove. Delete it manually if intended.

The conservative default matters because hook directories are the kind of place where a destructive command should err on the side of asking. The whole point of a hook is that it runs every time you commit; the cost of accidentally removing the wrong one is a workflow that quietly stops doing the thing it used to do.

What this generalises to

The narrow lesson — embed absolute paths in generated hooks — is specific to git hooks. The broader one is more useful: a generator that writes shell scripts to disk should treat the user’s $PATH as untrusted territory. The script will run in environments the generator can’t predict, and “rely on the binary being on $PATH” is a category of assumption that breaks silently more often than it breaks loudly.

The same logic applies to anything else CommitBrief might generate that’s going to be invoked by a third party: cron entries, launchd plists, CI step definitions for CI runners with stripped environments. None of those exist today, but if they did, they’d embed the absolute path for exactly the same reason.

If you’ve installed a hook and you’re not sure if it’s actually running, the diagnostic is commit something trivial through the GUI client and check whether you see the splash logo in the GUI's output panel. No logo means the hook didn’t fire — re-run commitbrief install-hook --yes and try again. If the issue persists, commitbrief doctor checks the install state and surfaces the first failing gate.

The next post in this series turns to a feature class that didn’t exist in v0.x at all: the subprocess-backed providers, claude-cli and gemini-cli, that let you reuse a subscription you’re already paying for.

Related reading


← all posts