白色卡片上的紅色 Git 菱形標誌與深棕色字樣

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

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

本頁目錄

引言

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

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

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

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

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

一行指令能修,但修錯了層

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

終端機視窗
git config user.email personal@example.com

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

先數清楚

在寫任何設定之前,我先數了一遍:

終端機視窗
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 就存在,而我從沒用過:

~/.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

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

依目錄解析 git identity兩個工作目錄都會讀取 ~/.gitconfig。~/Developer/work 路徑套用預設的 [user] 區塊。~/Developer/personal 路徑則額外符合 includeIf 的 gitdir 條件,載入 ~/.gitconfig-personal 並覆蓋預設值,解析出不同的 identity。~/Developer/work/some-repo~/Developer/personal/some-repo~/.gitconfig預設值 — 一律套用my-work-name<me@company.example>includeIf "gitdir:~/Developer/personal/"→ ~/.gitconfig-personal當 gitdir 相符時解析出的 identitymy-work-name<me@company.example>~/.gitconfig-personalmy-personal-handle<personal@example.com>↑ 覆蓋預設值解析出的 identitymy-personal-handle<personal@example.com>!結尾斜線很關鍵沒有它就只會比對該路徑本身,不包含底下的所有 repo。!順序很關鍵config 採後寫覆蓋前寫 —includeIf 必須寫在 [user] 之後。

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

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

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

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

不要假設,直接兩邊都驗證:

終端機視窗
git -C ~/Developer/personal/some-repo config user.email # personal
git -C ~/Developer/work/some-repo config user.email # work

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

用哪個信箱

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

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,它只會改寫比對得上的項目:

mailmap.txt
New Name <new@example.com> Old Name <old@company.example>
終端機視窗
git filter-repo --mailmap mailmap.txt --force

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

踩到的三個坑

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

tag 不會跟著改寫後的分支移動兩排各三個提交。上排是原始提交 C1 到 C3,v0.1.0 tag 指向 C3。虛線箭頭表示 git filter-repo 將每個提交改寫成下排的新物件 C1′ 到 C3′。main 分支移動去指向 C3′,但 v0.1.0 tag 仍停留在原本的 C3,變成不再被任何分支參照。改寫前改寫後C1C2C3C1′C2′C3′由 git filter-repo--mailmap 改寫v0.1.0(tag)仍指向這裡main(已強制推送)

filter-repomain移到改寫後的 commit 上。v0.1.0標籤是另一個獨立的 ref,filter-repo完全不會碰它,所以它繼續指向那個孤立的原始物件。

終端機視窗
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 回去。它會告訴你已經刪掉了,之後你再自己加回來:

終端機視窗
git remote add origin <url>

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

終端機視窗
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。

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

終端機視窗
git log -1 --format='%an <%ae>'

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

分享這篇文章