Check the result, not the config: how git-wrong-email works

git-wrong-email is a shell script that refuses to let a commit with the wrong email leave the machine. Why the hook that matters most is pre-push, what it costs to install a global hook without switching off every repo's own hooks, and what it still misses.

In the previous post I explained why git-wrong-email exists: a GUI git client rewrote my global user.email, and my personal projects started signing commits with a client’s email without anything warning me. The tool rests on one idea: don’t check the configuration, check the result. The email git is actually going to stamp on the commit.

This post is about how it’s built. It’s a POSIX shell script of about 140 lines, no dependencies, and almost every one of those lines exists because of a case I ran into while using it.

Read what git is going to write

Git configuration has a lot of layers: the global ~/.gitconfig, includeIf blocks, the repo’s .git/config, environment variables like GIT_AUTHOR_EMAIL, and whatever the editor or coding agent passes to the process. Trying to work out which one wins is exactly the mistake I wanted to avoid.

Git already answers that question, with git var:

git var GIT_AUTHOR_IDENT     # Sergio Infante <[email protected]> 1730000000 +0100
git var GIT_COMMITTER_IDENT

That’s the final identity, after every layer has been applied. The hook pulls the email out of it and compares it with the rule for the directory. It doesn’t care how the email got there.

It checks both, author and committer, because they aren’t always the same. In a cherry-pick or a rebase, the author is whoever wrote the change and the committer is you. If a repo carries someone else’s commits, their email can be allowed as an author:

git config --add wrongEmail.allowedAuthor [email protected]

Only as an author. The committer is always me, and there’s no escape for that.

The rules

Rules live in ~/.config/git-wrong-email/rules, one per line:

~/Code/client/     [email protected]
~/Code/personal/   [email protected]
*                  [email protected]

The first directory that contains the repo wins, so rules go from most to least specific, and * goes last. A repo that matches no rule is refused. A repo with no rule is a repo I haven’t thought about yet.

The comparison adds a trailing slash to both sides, so ~/Code/client/ doesn’t also accept ~/Code/client-notes. It’s the same detail you have to watch in the gitdir: of an includeIf, and it’s easy to get wrong.

Why pre-push is the one that matters

The pre-commit check is the visible one: the commit isn’t created and the message shows up in the terminal. But it isn’t the one doing the protecting.

A commit can reach the repo without going through pre-commit. With git commit --no-verify. With a tool that commits without running hooks. With a rebase or cherry-pick that rewrites commits. What really can’t be undone isn’t the local commit: it’s the commit reaching a public remote. A force-push afterwards doesn’t remove it from forks, clones or caches.

So pre-push doesn’t trust that pre-commit ran. It reads the commits about to be sent again:

git rev-list --format='%H %ae %ce' $local_sha --not --remotes

and checks the author and committer of each one. If you skipped the first check with --no-verify, the second one stops you before the commit leaves the machine.

The push I blocked the next day

The first version, from October 13, had pre-push check the range remote_sha..local_sha for each branch: everything on my branch that wasn’t on its remote copy.

The next day I merged main into a work branch that was already on GitHub, and the push was blocked. The merge had brought in the merge commits GitHub creates when it closes a PR, with committer [email protected]. Those commits weren’t on my remote branch, but they were on main. They were already public and nobody could fix them.

A check that blocks over history that can’t be fixed only teaches you to use --no-verify, and then you use it when the check was right, too. The fix was to change the question. Not “what does my branch have that its remote doesn’t”, but “what does my branch have that no remote has”:

git rev-list $local_sha --not $remote_sha --remotes

--remotes excludes everything already on any known remote branch. It depends on refs/remotes being up to date, and when it isn’t, the check gets stricter, never looser. That’s the direction I’d rather be wrong in.

What a global hook costs

To cover the whole machine without touching any repo, the installer points the global core.hooksPath at ~/.config/git-wrong-email/hooks. That covers the repos I already have, the ones I’ll clone tomorrow, and the client’s, where I can’t add anything.

The problem is that core.hooksPath doesn’t add to each repo’s hooks: it replaces them. When it’s set, git ignores .git/hooks entirely. A repo that uses a local pre-commit to run its linter would stop running it, silently. That’s why almost nobody does global checks with hooks.

The solution has two parts.

Install under every hook name. Not just the three that carry checks. The installer copies the same script under all 18 client-side hook names: commit-msg, post-checkout, post-merge, prepare-commit-msg and the rest. A name I don’t install is a local hook that stops running.

Chain to the repo’s hook. After its own checks, the script looks for the hook with the same name in the repo and runs it:

next="$(git rev-parse --git-common-dir)/hooks/$hook"
[ -x "$next" ] && exec "$next" "$@"

With one detail in pre-push: git passes it the list of refs on standard input, and the script has already read it to do its check. So it keeps a copy and passes it on to the repo’s hook.

If core.hooksPath already pointed somewhere else at install time, say lefthook’s directory or a dotfiles repo, the installer doesn’t silently overwrite it. It records it as wrongEmail.chainTo and chains to that directory instead of .git/hooks. On uninstall, it restores it.

What I found after publishing it

Pulling it out of my dotfiles and packaging it for Homebrew brought its own list of cases:

  • Homebrew runs commands through a symlink. The binary in $(brew --prefix)/bin is a link into the Cellar, and the installer looked for the hook next to the link, not next to the real file. It failed with “cannot find git-wrong-email-hook”. Now it follows the link.
  • git init fires hooks before the repo exists. The reference-transaction hook runs three times during git init, when there’s no .git directory to ask yet, and each time it printed a fatal: not a git repository. Now, with no repo, it exits quietly.
  • Don’t chain to yourself. My dotfiles manager writes core.hooksPath into .gitconfig before the installer creates the directory. The installer saw someone else’s directory, recorded it as chainTo, and on uninstall “restored” core.hooksPath to the directory it had just emptied. Now it recognizes its own path even when it’s empty.

What it still misses

  • A core.hooksPath inside the repo wins. husky, for example, sets one from npm’s prepare script, and in that repo git never runs my hooks. I can’t prevent that from the outside, but git wrong-email status, run inside the repo, now warns that the check doesn’t run there and how to find where it was set.
  • It sees the email, not the account. If I push to GitHub authenticated as the wrong account, the commit’s email can be fine and the problem is still there.
  • Rules aren’t compared with the includeIf blocks. If the two files disagree, I find out when the check refuses a commit. A doctor that compares them is next on the list.
  • brew upgrade doesn’t update the hooks. The installer copies the script, so after upgrading you have to run git wrong-email install again.

Try it

brew install neosergio/tap/git-wrong-email
git wrong-email install
$EDITOR ~/.config/git-wrong-email/rules
git wrong-email status

The tests build a temporary $HOME with its own global config and drive real git, so they never touch your ~/.gitconfig. The code is on GitHub, MIT licensed.

Comments