git のコミットメールをディレクトリ単位で強制する
グローバルの [user] を消しても、コミットは止まらない
複数のアカウントを使い分ける環境で、意図しないメールアドレスがコミットに混入した。再発を防ごうとして ~/.gitconfig からグローバルの [user] を削除したが、それだけでは止まらなかった。
git はホスト名とユーザー名から identity を自動生成し、警告を出したうえでコミットを通す。止めるには user.useConfigOnly = true が要る。この記事は、その挙動を実機で確認し、includeIf と組み合わせてツリーごとに identity を強制するまでの記録。
何が起きたか
自動化ツールにコミットさせたとき、-c user.email を明示していなかったために、グローバル設定のアドレスがそのまま author として記録された。本来そのリポジトリで使うべきアドレスとは別物だった。
厄介なのは、**これが「設定ミス」ではなく「設定どおりの動作」**だという点だ。グローバルに既定値がある限り、ローカル設定を書き忘れたリポジトリはすべてその既定値を拾う。人間が毎回 git config --local を打つ運用は、いつか必ず破れる。
調査で最初に踏んだ罠
どのリポジトリがローカル設定を持っているかを調べるとき、最初にこう書いた。
git -C "$repo" config --get user.email
これは 実効値 を返す。ローカル設定が無くてもグローバルの値が出るので、「全リポジトリにローカル設定が入っている」ように見えてしまった。正しくはこう。
git -C "$repo" config --local --get user.email
--local を付けて数え直したところ、12リポジトリ中8つがローカル設定を持っていなかった。最初の調査結果は丸ごと誤りだった。
実機で確かめた挙動
グローバルの [user] を削除した状態で、どのツリーにも属さない新規リポジトリにコミットしてみる。
cd /tmp && mkdir idtest && cd idtest
git init -q && echo x > a.txt && git add a.txt
git commit -m "test"
結果はこうなった。
[main (root-commit) f9e90f0] test
Committer: <氏名> <[email protected]>
Your name and email address were configured automatically based
on your username and hostname. Please check that they are accurate.
終了コードは 0。 警告は出るが、コミットは成立している。[email protected] という、実在しないがもっともらしいアドレスが記録された。
既定値を消すことは「止める」ことではなく、「別の既定値に差し替える」ことでしかなかった。
useConfigOnly を足す
[user]
useConfigOnly = true
これを入れて同じ操作を繰り返すと、こうなる。
Author identity unknown
*** Please tell me who you are.
コミットが失敗する。これが欲しかった挙動だった。
ツリーごとに割り当てる
あとは includeIf で、ディレクトリツリーごとに identity を割り当てる。
[user]
useConfigOnly = true
[includeIf "gitdir:~/private/"]
path = ~/.gitconfig-private
[includeIf "gitdir:~/work/"]
path = ~/.gitconfig-work
~/.gitconfig-private と ~/.gitconfig-work にはそれぞれ [user] だけを書く。
検証すると、~/private/ 配下に作った新規リポジトリでは何もしなくても正しい identity が付き、どちらのツリーにも属さない場所では Author identity unknown で止まった。どちらも意図どおり。
混入済みのコミットを消す
既に記録されてしまったアドレスは、書き換えないと残る。未 push ならこれで消える。
FILTER_BRANCH_SQUELCH_WARNING=1 git filter-branch -f --env-filter '
if [ "$GIT_AUTHOR_EMAIL" = "[email protected]" ]; then
export GIT_AUTHOR_NAME="new-name"
export GIT_AUTHOR_EMAIL="[email protected]"
fi
if [ "$GIT_COMMITTER_EMAIL" = "[email protected]" ]; then
export GIT_COMMITTER_NAME="new-name"
export GIT_COMMITTER_EMAIL="[email protected]"
fi
' -- --all
ここで終わりにしてはいけない。filter-branch は refs/original/ にバックアップを残すので、旧コミットは到達可能なまま残っている。
git for-each-ref --format='%(refname)' refs/original | xargs -n1 git update-ref -d
git reflog expire --expire=now --all
git gc --prune=now
消えたことの確認は、参照経由ではなく全オブジェクトを走査する。
git cat-file --batch-all-objects --batch-check='%(objectname) %(objecttype)' \
| awk '$2=="commit"{print $1}' \
| git cat-file --batch \
| grep -c "[email protected]"
0 が返って初めて、消えたと言える。
push 先は git では縛れない
コミットの identity は以上で固定できるが、push 先のアカウントは別問題だ。gh の認証アカウントはマシン全体で1つしかなく、ディレクトリ単位で切り替わらない。実際、作業の途中で意図しないアカウントがアクティブに戻っていた。
SSH 鍵をリポジトリごとに分ければトランスポート層で強制できるが、それを採らない場合は pre-push フックで止めるしかない。
#!/bin/sh
# 送信対象のコミットに禁止アドレスが含まれていないか検査する
while read -r _local_ref local_sha _remote_ref remote_sha; do
[ "$local_sha" = "0000000000000000000000000000000000000000" ] && continue
case "$remote_sha" in
0000000000000000000000000000000000000000) range="$local_sha" ;;
*) range="$remote_sha..$local_sha" ;;
esac
if git log --format='%H %ae %ce' "$range" | grep -E 'old\.example\.com'; then
echo "禁止アドレスを含むコミットがあります" >&2
exit 1
fi
done
exit 0
core.hooksPath をリポジトリ内のディレクトリに向けておけば、フック自体もバージョン管理できる。
検討したが採らなかった選択肢
グローバルの既定値を残す折衷案。 使用頻度の高い方を既定にして、もう一方だけ includeIf で上書きする案を検討した。設定は短くなるが、既定値がある限り、その既定値は書き忘れたリポジトリに漏れる。 今回漏れたのがまさにそのパターンだったので採らなかった。「止まると不便」より「静かに間違う」方が高くつく。
リポジトリごとの --local 設定だけで運用する。 実際これで運用していて破れた。新規リポジトリを作るたびに人間が思い出す必要があり、自動化ツールが作ったリポジトリでは誰も思い出さない。
SSH 鍵をアカウントごとに分ける。 最も強い強制になる。鍵が違えば push が認証エラーで落ちるので、フックのような「検査して止める」仕組みより確実だ。今回は SSH を使わない方針だったので見送ったが、強制の強さだけで言えばこれが上。
まとめ
- グローバルの
[user]を消しても git は identity を自動生成して通す。止めるにはuser.useConfigOnly = trueが要る git config --getは実効値を返す。ローカル設定の有無を調べるなら--localを付けるfilter-branchの後はrefs/originalの削除とgcまで行い、全オブジェクト走査で消えたことを確認する