
git includeIf と filter-repo --mailmap で直す — 27個の個人リポジトリに紛れ込んでいた会社のメールアドレス
Gitのidentityはリポジトリではなくディレクトリに属する。includeIf gitdirで以後を直し、git filter-repo --mailmapで既にpushしたコミットを直す。
目次
はじめに
個人ブログを立ち上げて最初のコミットをpushし、authorの行を見た。
Author: my-work-name <me@company.example>勤務先のアドレスが、個人プロジェクトの、しかも公開するつもりのリポジトリに入っていた。何かが壊れたわけではない。Gitは何年も前に設定した通りに、そのまま忠実に動いていただけだ。そして、どこまで広がっているか確認しに行くと、1つのリポジトリどころではなかった。27個だった。
見つかった修正方法は、使ったことのないgitの機能だった。includeIf "gitdir:"は、リポジトリが置かれている場所に応じてidentityを選ぶので、リポジトリごとに設定しなくても、答えがディレクトリに従うようになる。すでにpushしたコミットには、また別の対処が必要だった。git filter-repo --mailmapだ。
この記事では、問題の捉え方を変えた「数える」作業、最初に試したワンコマンドの修正とそれが間違った層だった理由、条件付きincludeの設定と静かに壊す2つの落とし穴、そしてすでに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)"done27個のリポジトリ。そのすべてが勤務先のアドレスだった。27回間違えたからではない。0回しか決めていなかったからだ。すべてのリポジトリが同じグローバルなデフォルトを継承していて、そのデフォルトは仕事用マシンで、仕事のために、一度だけ設定されたものだった。
これで修正の捉え方が変わる。1つのリポジトリに対するgit config user.emailは、間違った層に当てたパッチにすぎない。残り26個は間違ったままだし、28個目のリポジトリを作るときも、思い出させてくれるものは何もないので、また間違えることが確定している。
条件付き include
Gitはリポジトリが置かれている場所からidentityを選べる。この機能はgit 2.13から存在していて、使ったことがなかった。
; 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[user] name = my-personal-handle email = personal@example.comこれでidentityはコードがどこにあるかのプロパティになった。これこそが、どのidentityが正しいかを実際に決めているものだ。新しい個人リポジトリは、作成された瞬間からカバーされる。リポジトリごとの設定はいらないし、覚えておくことも何もない。
どちらのディレクトリも同じ~/.gitconfigを読む。個人用の方だけがincludeIfの条件にも一致するので、そちらだけが2つ目のファイルを追加で読み込む。
これを静かに壊す落とし穴が2つあり、どちらもエラーにはならない。
末尾のスラッシュが重要だ。 gitdir:~/Developer/personal/は、そのディレクトリとその配下すべてに一致する。末尾のスラッシュがないと、単一のパスにしか一致せず、その中のリポジトリはすべて対象から外れる。
順序も重要だ。 Gitのconfigは後勝ちなので、includeIfは、それが上書きする対象の[user]ブロックより後に書かなければならない。ファイルの先頭に置くと、下にあるデフォルトの[user]が、includeが設定したばかりの内容を単純に上書きしてしまう。ファイルを見た目上は正しく見えるのに、動作は変わらない。
思い込まずに、両方のサイドを確認する。
git -C ~/Developer/personal/some-repo config user.email # personalgit -C ~/Developer/work/some-repo config user.email # work2つ目の出力がいつの間にか個人アドレスになっていたら、パターンが広すぎる。
どのアドレスを使うか
本能的には自分の実際の個人メールアドレスを使いたくなる。代わりにGitHubのnoreplyアドレスを検討するとよい。
26102772+oharu121@users.noreply.github.comこれは<user-id>+<username>@users.noreply.github.comという形式で、idはgh api user --jq .idで取得できる。このアドレスでauthorしたコミットも自分のアカウントにリンクされ、contributionグラフにもきちんとカウントされる。それでいて、実際の受信箱が公開リポジトリに残ることはない。
実アドレス版には知っておく価値のある失敗モードがある。 そのアドレスがGitHubアカウントで未検証だと、コミットはリンクされない状態で表示され、アバターもプロフィールリンクもcontributionのカウントもつかない。しかもBlock command line pushes that expose my emailを有効にしていると、そのアドレスでのpushはそもそも失敗する。
すでにpushした分を直す
これで今後は解決した。しかし、すでに存在するコミットは解決していない。
これは今すぐやるか、一生やらないかのどちらかにする価値がある。コミットが2つなら5分の作業だ。2年分の履歴と他人のcloneがあれば、それはもう移行作業になる。コストは上がる一方だ。
git commit --amend --reset-authorは誘惑的なワンライナーだが、先頭のコミット以外に使うと間違っている。触れたコミットすべてのauthorshipを現在のidentityに書き換えてしまい、それには自分のものではなかったコミットも含まれる。代わりにmailmapを使う。一致したエントリだけを書き換えられる。
New Name <new@example.com> Old Name <old@company.example>git filter-repo --mailmap mailmap.txt --force古いidentityにマッチする方式なので、botや共同作業者のコミットはそのまま素通りする。私の場合、自分のコミットの直後にDependabotのコミットが乗っていた。一律の書き換えだったら、それが自分に付け替えられてしまうところだった。
3つのハマりどころ
タグはブランチに追従しない。 v0.1.0タグは書き換え対象のコミットを指していたので、書き換え後もそのタグは古いオブジェクトを指したままだった。ブランチとタグは別々のrefであり、どちらもforce-pushが必要だ。
filter-repoはmainを書き換え後のコミットへ移動させる。v0.1.0タグは別のrefであり、filter-repoが一切触れないので、孤立した元のコミットを指したままになる。
git push --force-with-lease=main:<old-sha> origin maingit push --force origin refs/tags/v0.1.0素の--forceではなく、期待するSHAを明示した--force-with-leaseを使う。作業中にリモートに何か新しいものが入っていたら(私の場合、数分前にCIが依存関係のPRを自動マージしていた)、破壊する代わりにpushが中止される。
git filter-repoはoriginリモートを削除する。 意図的な挙動だ。使い捨てのcloneで作業していると想定していて、書き換えた履歴を反射的にpushし返すことを止めたがっている。削除したことは教えてくれるので、その後で自分で追加し直す。
git remote add origin <url>リリースが無事かを確認する。 GitHubのリリースはタグの名前に紐づいているので、タグをforceで更新してもリリースは付いたままになる。ただし、思い込まずに確認する。
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'正直に言っておくべき注意点が1つある。古いオブジェクトはGitHub上に残り続ける。どこからも参照されないがSHAで到達可能な状態のまま、ガベージコレクションされるまで残る。単に見た目が汚いという話ではなく、露出そのものが本当に問題になる場合は、サポートへの依頼が必要になる。force pushだけでは消えない。
まとめ
バグは、どのconfigファイルよりも一段上の層に住んでいた。identityはグローバルに設定されていたが、実際には今どのプロジェクトにいるかのプロパティだった。
- 修正をどこに当てるか決める前に、影響を受けるリポジトリの数を数える。
includeIf "gitdir:…/"を使い、答えがディレクトリに従うようにする。末尾のスラッシュに注意し、[user]より後に置く。- 実際の受信箱より、GitHubのnoreplyアドレスを優先する。
- 既存の履歴は、まだコミットが2つのうちに書き換える。mailmapを使って自分だけを書き換え、タグは別のrefであることを忘れない。
何年も前にこれを見つけられていたはずのチェックは、たった一行だ。
git log -1 --format='%an <%ae>'新しいリポジトリで最初のコミットをした後に、一度実行する。



