infralog

git のコミットメールをディレクトリ単位で強制する

git開発環境

目次
  1. グローバルの [user] を消しても、コミットは止まらない
  2. 何が起きたか
  3. 実機で確かめた挙動
  4. 検討したが採らなかった選択肢
  5. まとめ
  6. 関連記事

グローバルの [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-branchrefs/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 を使わない方針だったので見送ったが、強制の強さだけで言えばこれが上。

まとめ

関連記事