The Git diamond logo in red and the git wordmark in dark brown on a white card

My work email was in all 27 personal repos — fixing it with git includeIf and filter-repo --mailmap

Git identity belongs to the directory, not the repo. includeIf gitdir fixes it going forward; git filter-repo --mailmap fixes the commits already pushed.

On this page

Introduction

I set up a personal blog, pushed the first commit, and looked at the author line:

Author: my-work-name <me@company.example>

My employer’s address, on a personal project, in a repo I intended to make public. Nothing broke. Git had done exactly what I configured it to do years ago and then never thought about again. And when I went to check how far it had spread, it was not one repo. It was twenty-seven.

The fix turned out to be a git feature I had never used: includeIf "gitdir:", which picks an identity based on where the repository lives, so the answer follows the directory instead of having to be set per repo. The commits already pushed needed something else again, git filter-repo --mailmap.

This article covers the count that reframed the problem, the one-command fix I tried first and why it was the wrong layer, the conditional-include setup and the two details that silently break it, and what went wrong rewriting history that was already on GitHub.

The one-command fix, and why it was the wrong layer

The obvious fix is one command:

Terminal window
git config user.email personal@example.com

That fixes this repo. It does not fix the problem, because the problem was never this repo. I only saw that after counting.

Count first

Before writing any config, I counted:

Terminal window
for d in ~/Developer/personal/*/; do
[ -d "$d/.git" ] || continue
printf '%-30s %s\n' "$(basename $d)" "$(git -C "$d" config user.email)"
done

Twenty-seven repositories. Every one of them the work address. Not because I had made twenty-seven mistakes. Because I had made zero decisions: every repo inherited the same global default, and the default was set once, on a work machine, for work.

That reframes the fix. git config user.email in one repo is a patch applied at the wrong layer. It leaves twenty-six repos wrong and guarantees the twenty-eighth will be wrong too, because nothing about creating a new repo will remind me.

Conditional includes

Git can pick an identity from where the repository lives. This has been in git since 2.13 and I had never used it:

~/.gitconfig
; Default: work
[user]
name = my-work-name
email = me@company.example
; Anything under ~/Developer/personal/ overrides the above
[includeIf "gitdir:~/Developer/personal/"]
path = ~/.gitconfig-personal
~/.gitconfig-personal
[user]
name = my-personal-handle
email = personal@example.com

Now the identity is a property of where the code lives, which is the thing that actually determines which identity is correct. New personal repos are covered the moment they are created. No per-repo setup, nothing to remember.

Git identity resolved by directoryTwo working directories both read ~/.gitconfig. The ~/Developer/work path gets the default [user] block. The ~/Developer/personal path additionally matches an includeIf gitdir condition, which loads ~/.gitconfig-personal and overrides the default, resolving to a different identity.~/Developer/work/some-repo~/Developer/personal/some-repo~/.gitconfigDefault — always appliesmy-work-name<me@company.example>includeIf "gitdir:~/Developer/personal/"→ ~/.gitconfig-personalif gitdir matchesResolved identitymy-work-name<me@company.example>~/.gitconfig-personalmy-personal-handle<personal@example.com>↑ overrides the defaultResolved identitymy-personal-handle<personal@example.com>!Trailing slash mattersWithout it, only that exactpath matches — not repos under it.!Order mattersConfig is last-write-wins —includeIf must come after [user].

Both directories read the same ~/.gitconfig. Only the personal one also matches the includeIf condition, so it alone pulls in the second file.

Two details silently break this, and neither errors:

The trailing slash matters. gitdir:~/Developer/personal/ matches that directory and everything beneath it. Without the trailing slash you are matching a single path, and every repo inside it misses.

Order matters. Git config is last-write-wins, so the includeIf must come after the [user] block it overrides. Put it at the top of the file and the default [user] below simply overwrites what the include just set. The file looks correct. The behaviour is unchanged.

Verify against both sides rather than assuming:

Terminal window
git -C ~/Developer/personal/some-repo config user.email # personal
git -C ~/Developer/work/some-repo config user.email # work

If the second one has silently become your personal address, your pattern is too broad.

Which address

The instinct is to use your real personal email. Consider the GitHub noreply address instead:

26102772+oharu121@users.noreply.github.com

That is <user-id>+<username>@users.noreply.github.com, and you can get your id from gh api user --jq .id. Commits authored with it still link to your account and still count toward your contribution graph, but no real inbox ends up in a public repository.

The real-address version has a failure mode worth knowing. If the address is not verified on your GitHub account, commits show up unlinked, with no avatar, no profile link, and no contribution credit. And if you have Block command line pushes that expose my email enabled, pushing with it fails outright.

Fixing what you already pushed

Going forward is solved. The commits that already exist are not.

This is worth doing immediately or never. With two commits it is a five-minute job. With two years of history and other people’s clones, it is a migration. The cost only goes up.

git commit --amend --reset-author is the tempting one-liner, and it is wrong for anything but the tip commit. It rewrites authorship to your current identity for whatever it touches, including commits that were never yours. Use a mailmap instead, which rewrites only entries that match:

mailmap.txt
New Name <new@example.com> Old Name <old@company.example>
Terminal window
git filter-repo --mailmap mailmap.txt --force

Because it matches on the old identity, commits from bots and collaborators pass through untouched. In my case a Dependabot commit sat on top of mine; a blanket rewrite would have reattributed it to me.

Three things that bit me

Tags do not follow the branch. My v0.1.0 tag pointed at the commit being rewritten, so after the rewrite it still referenced the old object. The branch and the tag are separate refs and both need force-pushing:

The tag does not follow the rewritten branchTwo rows of three commits each. The top row is the original commits C1 to C3, with the v0.1.0 tag pointing at C3. Dashed arrows show git filter-repo rewriting each commit into a new object on the bottom row, C1-prime to C3-prime. The main branch moves to point at C3-prime, but the v0.1.0 tag stays on the original C3, which is now unreferenced by any branch.beforeafterC1C2C3C1′C2′C3′rewritten bygit filter-repo --mailmapv0.1.0 (tag)still points heremain(force-pushed)

filter-repo moves main onto the rewritten commits. The v0.1.0 tag is a separate ref it never touches, so it keeps pointing at the orphaned original.

Terminal window
git push --force-with-lease=main:<old-sha> origin main
git push --force origin refs/tags/v0.1.0

Use --force-with-lease with an explicit expected SHA, not a bare --force. If anything landed on the remote while you were working (in my case CI had auto-merged a dependency PR minutes earlier), the push aborts instead of destroying it.

git filter-repo deletes your origin remote. Deliberately: it assumes you are operating on a throwaway clone and wants to stop you reflexively pushing a rewritten history back. It tells you, then you re-add it:

Terminal window
git remote add origin <url>

Check the release survived. A GitHub Release is bound to a tag name, so force-updating the tag keeps the release attached, but verify rather than assume:

Terminal window
gh release view v0.1.0 --json tagName,body --jq '.tagName, (.body | length)'
gh api repos/<owner>/<repo>/git/ref/tags/v0.1.0 --jq '.object.sha'

One caveat worth stating honestly: the old objects remain on GitHub, unreferenced but reachable by SHA, until they are garbage collected. If the exposure genuinely matters rather than merely being untidy, that needs a support request; the force push alone does not erase them.

Summary

The bug lived one layer up from any config file: identity was set globally when it is actually a property of which project you are in.

  1. Count how many repos are affected before deciding where to apply the fix.
  2. Use includeIf "gitdir:…/" so the answer follows the directory. Mind the trailing slash and put it after [user].
  3. Prefer the GitHub noreply address over a real inbox.
  4. Rewrite existing history now, while it is two commits. Use a mailmap so you only rewrite yourself, and remember tags are separate refs.

The check that would have caught this years earlier is one line:

Terminal window
git log -1 --format='%an <%ae>'

Run it once after the first commit in a new repo.

Share this article