# 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.

Published June 30, 2026 by CommitBrief (https://commitbrief.com)

Canonical URL: https://commitbrief.com/blog/hooks-in-gui-git-clients/
Tags: hooks, git, tooling

---

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:

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

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

```sh
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:

```sh
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.