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.