9 August 2026
Stopping commits made with the wrong git identity
If you work on more than one codebase with more than one identity, you have
probably done it: committed to a work repo with your personal email, or
pushed to a client repository with an account that should never appear in its
history. Git happily lets you, because user.email is just a config value.
I hit this while building git tooling for a multi-client consulting setup, where a commit authored under the wrong identity is a compliance problem. Here is the approach that worked, with the exact steps to set it up.
Why the usual fix is not enough
The standard advice is conditional includes: an includeIf block in
~/.gitconfig that switches user.email based on where the repo lives. It
is worth doing, but it only sets a default. It relies on every repo living
under the right folder, and it does nothing about commits already made with
the wrong identity before you noticed.
The identity that matters is not what git config says right now. It is
what is baked into the commits you are about to share.
Step 1: a global hooks directory
Create one directory of hooks that applies to every repo on the machine:
mkdir -p ~/.git-hooks
git config --global core.hooksPath ~/.git-hooks
Step 2: a policy file
Next to the hooks, map remote prefixes to allowed or denied email domains:
# ~/.git-hooks/identity-policy
github.com/my-company/ require:@company.com
github.com/client-org/ require:@client.com
github.com/my-username/ deny:@company.com,@client.com
The last line is the rule most setups miss: work identities are also denied in personal repos. Without it, a work email quietly leaking into side projects passes every check.
Step 3: a pre-commit hook
~/.git-hooks/pre-commit gives instant feedback at commit time:
#!/bin/sh
url=$(git remote get-url origin 2>/dev/null) || exit 0
email=$(git config user.email)
while read -r prefix rule; do
case "$url" in *"$prefix"*)
case "$rule" in
require:*) case ",$email" in *"${rule#require:}"*) ;;
*) echo "blocked: $email not allowed here" >&2; exit 1;; esac;;
deny:*) case "${rule#deny:}" in *"${email#*@}"*)
echo "blocked: $email not allowed here" >&2; exit 1;; esac;;
esac;;
esac
done < ~/.git-hooks/identity-policy
Make both hooks executable: chmod +x ~/.git-hooks/pre-*.
Step 4: a pre-push hook that checks the commits themselves
This is the real gate. Pre-push receives the refs being pushed on stdin, so you can inspect the author and committer email of every commit in the range:
#!/bin/sh
while read -r _local local_sha _remote remote_sha; do
if [ "$remote_sha" = "0000000000000000000000000000000000000000" ]; then
range="$local_sha --not --remotes" # new branch: only unpushed commits
else
range="$remote_sha..$local_sha"
fi
git log --format='%ae%n%ce' $range | sort -u | while read -r email; do
# same policy lookup as pre-commit, against $email
check_email "$email" || exit 1
done || exit 1
done
This catches commits made before the hook existed, commits from another
machine, and rebases that resurrected an old identity. The new-branch case
matters: a naive remote..local range is empty on first push and would
bypass the whole check.
Takeaway
Config sets intent, hooks enforce it. Set user.email defaults with
includeIf, but validate the actual commits at push time, because that is
the only place the truth lives.