# 用 git includeIf 與 filter-repo --mailmap 清掉洩漏的公司信箱 — 27 個個人儲存庫全中招

> Git identity 屬於目錄，不是儲存庫。includeIf gitdir 修正日後的設定，git filter-repo --mailmap 修正已經 push 的提交。

- Source: https://oharu121.com/zh-tw/blog/git-wrong-author-email-includeif-gitdir-filter-repo-mailmap/
- Published: 2026-08-04T11:58:39+09:00
- Tags: Git, 開發工具

---
## 引言

我架設了一個個人部落格，push 了第一個 commit，然後看了一眼 author 那一行：

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

公司信箱，出現在一個個人專案裡，而且是一個我打算公開的儲存庫。沒有任何東西壞掉。Git 只是忠實地做了我幾年前設定好、之後就再也沒想過的事。等我去確認這件事到底擴散了多遠，發現不只一個儲存庫。是 27 個。

最後的修法是一個我從沒用過的 git 功能：`includeIf "gitdir:"`，它會依儲存庫所在的位置決定要用哪個 identity，讓答案跟著目錄走，而不必每個儲存庫各自設定。至於已經 push 出去的 commit，需要另一套處理方式：`git filter-repo --mailmap`。

本文整理了讓我重新看待這個問題的計數過程、最先嘗試的一行指令修法以及它為什麼修錯了層、條件式 include 的設定與兩個會悄悄讓它失效的細節，以及改寫已經在 GitHub 上的歷史時出的差錯。

## 一行指令能修，但修錯了層

明顯的修法只要一行指令：

```bash
git config user.email personal@example.com
```

這樣能修好這個儲存庫。但問題並沒有解決，因為問題從來就不是這個儲存庫。我是數過之後才看清這一點的。

## 先數清楚

在寫任何設定之前，我先數了一遍：

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

27 個儲存庫，每一個都是公司信箱。不是因為我犯了 27 次錯，而是因為我根本**沒做過任何決定**：每個儲存庫都繼承了同一個全域預設值，而那個預設值只在一台工作用的機器上、為了工作，設定過一次。

這重新框定了修法。對單一儲存庫下的`git config user.email`，是打在錯誤層上的補丁。它留下另外 26 個儲存庫繼續錯下去，也保證了第 28 個儲存庫一樣會出錯，因為建立新儲存庫這件事根本不會提醒我。

## 條件式 include

Git 可以依儲存庫所在的位置決定 identity。這個功能從 git 2.13 就存在，而我從沒用過：

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

現在 identity 變成了*程式碼放在哪裡*的屬性，而這才是真正決定哪個 identity 才對的東西。新的個人儲存庫從建立的那一刻起就被涵蓋，不必逐一設定，也沒什麼需要記住的。

*Figure — IdentityByDirectory: 兩個目錄讀的都是同一份`~/.gitconfig`。只有個人用的那個目錄同時符合`includeIf`的條件，所以只有它會額外讀入第二份檔案。*

有兩個細節會悄悄讓這套設定失效，而且都不會報錯：

**結尾的斜線很關鍵。** `gitdir:~/Developer/personal/`比對的是那個目錄及其底下的所有內容。沒有結尾斜線的話，比對的只是單一路徑，該路徑底下的每個儲存庫都會被漏掉。

**順序也很關鍵。** Git 的設定採後寫覆蓋前寫，所以`includeIf`必須寫在它要覆蓋的`[user]`區塊*之後*。把它放在檔案開頭，下面預設的`[user]`就會直接覆蓋掉 include 剛設定好的內容。檔案看起來沒問題，但行為完全沒變。

不要假設，直接兩邊都驗證：

```bash
git -C ~/Developer/personal/some-repo config user.email   # personal
git -C ~/Developer/work/some-repo config user.email       # work
```

如果第二個指令的結果不知不覺變成了個人信箱，代表你的比對範圍設得太寬了。

## 用哪個信箱

直覺是用自己真正的個人信箱。但可以考慮改用 GitHub 的 noreply 信箱：

```text
26102772+oharu121@users.noreply.github.com
```

格式是`<user-id>+<username>@users.noreply.github.com`，id 可以用`gh api user --jq .id`取得。用這個信箱 author 的 commit，一樣會連結到你的帳號，一樣算進 contribution 圖，但不會有任何真實的收件匣暴露在公開儲存庫裡。

**用真實信箱有一個值得知道的失敗模式。** 如果那個信箱在 GitHub 帳號上還沒驗證，commit 就會顯示成未連結的狀態，沒有頭像、沒有個人檔案連結，contribution 也不會算數。而且如果你開啟了*Block command line pushes that expose my email*，用那個信箱 push 會直接失敗。

## 修正已經 push 的部分

往後的部分解決了，但已經存在的 commit 沒有。

這件事值得**現在立刻做，或者永遠不要做**。如果只有兩個 commit，五分鐘就搞定；如果是兩年的歷史，加上別人手上的 clone，那就是一場遷移了。成本只會愈拖愈高。

`git commit --amend --reset-author`是很誘人的一行指令，但除了最上面那個 commit 之外，用在其他地方都是錯的。它會把碰到的每個 commit 的 authorship 都改成你*現在*的 identity，包括那些根本不是你的 commit。應該改用 mailmap，它只會改寫比對得上的項目：

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

```bash
git filter-repo --mailmap mailmap.txt --force
```

因為它比對的是舊的 identity，bot 和協作者的 commit 都會原封不動地通過。以我的情況來說，有一個 Dependabot 的 commit 疊在我自己的 commit 上面；如果是一律改寫，它就會被重新歸到我頭上。

### 踩到的三個坑

**標籤不會跟著分支走。** 我的`v0.1.0`標籤原本指向被改寫的那個 commit，改寫之後它仍然指向*舊的*物件。分支和標籤是兩個獨立的 ref，兩個都需要 force push：

*Figure — TagBranchDivergence: `filter-repo`把`main`移到改寫後的 commit 上。`v0.1.0`標籤是另一個獨立的 ref，`filter-repo`完全不會碰它，所以它繼續指向那個孤立的原始物件。*

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

要用帶有明確預期 SHA 的`--force-with-lease`，而不是單純的`--force`。如果你作業期間遠端多了新東西（以我的情況來說，CI 在幾分鐘前自動合併了一個依賴套件的 PR），push 會直接中止，而不是把它蓋掉。

**`git filter-repo`會刪掉你的`origin`遠端。** 這是刻意的行為：它假設你是在一個用完即丟的 clone 上操作，想擋下你反射性地把改寫後的歷史 push 回去。它會告訴你已經刪掉了，之後你再自己加回來：

```bash
git remote add origin <url>
```

**確認 release 有沒有事。** GitHub 的 release 是綁定在標籤的*名稱*上的，所以強制更新標籤仍然會讓 release 保持連結，但不要假設，要去確認：

```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'
```

有一點值得老實說清楚：舊的物件仍然留在 GitHub 上，沒有任何東西參照它，但仍能用 SHA 存取，直到被垃圾回收為止。如果外洩本身真的是問題，而不只是不美觀，就需要另外提出支援請求；光靠 force push 並不會把它們清除。

## 總結

這個 bug 藏在比任何設定檔都高一層的地方：identity 被設成全域的，但它其實是*你現在人在哪個專案裡*的屬性。

1. 在決定要在哪裡套用修法之前，先數清楚受影響的儲存庫有幾個。
2. 使用`includeIf "gitdir:…/"`，讓答案跟著目錄走。注意結尾的斜線，並且把它放在`[user]`之後。
3. 比起真實的收件匣，優先使用 GitHub 的 noreply 信箱。
4. 趁現在只有兩個 commit 的時候改寫既有歷史。用 mailmap 只改寫自己，並且記得標籤是獨立的 ref。

原本可以早幾年就抓到這個問題的檢查，只要一行：

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

在新儲存庫的第一個 commit 之後執行一次。
