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

- Source: https://oharu121.com/blog/git-wrong-author-email-includeif-gitdir-filter-repo-mailmap/
- Published: 2026-08-04T11:58:39+09:00
- Tags: Git, Developer Tooling

---
## Introduction

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

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

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

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

```ini title="~/.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
```

```ini title="~/.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.

*Figure — IdentityByDirectory: 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:

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

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

```text title="mailmap.txt"
New Name <new@example.com> Old Name <old@company.example>
```

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

*Figure — TagBranchDivergence: `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.*

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

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

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

```bash
git log -1 --format='%an <%ae>'
```

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