Git のフックとコードチェッカを組み合わせようとして痛い目に遭った話

2年半くらい前、Git を使いはじめて間もないころに pre-commit フックと Rubocop で遊んで痛い目に遭った思い出を供養がてら記事にしてみる。
ちなみにコマンドの結果などは当時のものを記憶の限り再現してます。同じことをやっても現在は異なる結果になるかもしれません。

やっていたこと

当時、1人で Ruby アプリケーションを Git で管理しながら書いていた(ちゃんと Git を使うのはこれが初めてだった)。また、後の自分を苦しめないためにも、コードチェッカとして Rubocop を使っていた(こちらもちゃんと使うのは初めてだった)。

余談だが、Rubocop のデフォルト設定にはもちろん早々に愛想を尽かして、ある程度緩めた設定を使っていた。

やろうとしたこと

commit したファイルに Rubocop を通していないコードが混入するのが嫌だったので、Git のpre-commitフックを使うことにした。pre-commitフックとはざっくり言うと、commit 時に.git/hooks/pre-commitを実行し、終了ステータスが0以外の場合に commit を中断させる仕組み。ググるとよく出てくるやつですね。
もちろん当時もググったらすぐにpre-commitフックの紹介が出てきたので、早速こんな感じのスクリプト.git/hooks/pre-commitに書いた。

#!/bin/bash
bundle exec rubocop

これでコーディング規約に従っていない Ruby ファイルが commit されることはなくなった。が、適用してすぐに「commit するたびに全ての Ruby ファイルを Rubocop に掛けるのは色々と非効率だ」と思い至った。変更の遭った commit 対象の Ruby ファイルだけを Rubocop に掛ければいいだろう、と。

この時欲しかったのは、pre-commit の中で commit 予定のファイル一覧だけを取り出す方法である。Git レポジトリの状態に一番詳しいのは Git なのでgit statusを使うことを考えたが、この出力は人間に優しく機械にはちょっと厳しい(使おうと思えば使えそうだけど)。

「俺が思いつくようなことは Git も考慮しているはず」と重いgit help statusを眺めたところ、--porcelainなるオプションの存在を知った。これは機械に優しい出力形式をしてくれるオプションで、例えば普通のgit statusだと

$ git status
On branch master
Changes to be committed:
  (use "git reset HEAD <file>..." to unstage)

    new file:   goodbye.rb
    modified:   hello.rb

Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git checkout -- <file>..." to discard changes in working directory)

    modified:   hi.rb

と出力される時に--porcelainオプションを付けると

$ git status --porcelain
A  goodbye.rb
M  hello.rb
 M hi.rb

と出力される。余分なメッセージはなく、各ファイルのステータスを行頭2文字に付けて出力してくれる。 詳細はgit help statusに任せるが、ざっくりいうと1文字目がインデックスにおけるステータス、2文字目がワークツリーにおけるステータスになる。

そこで commit 予定の Ruby スクリプトだけを Rubocop に掛けるべく、フックスクリプトを次のように修正した。

#!/bin/bash
files=$(ruby -n -e '$_ =~ /^[MA][^D]/ && $_ =~ /\.rb$/ && puts($_[3..-1])' <(git --porcelain))
if [[ -n $files ]]; then
  bundle exec rubocop $files
fi

ワンライナー ruby でゴチャゴチャやってるが、要するにgit status --porcelainの出力を元に追加/変更のある拡張子.rbのファイルだけを抜き出して Rubocop に掛けている。(2文字目にDを含む場合はワークツリーに ファイルが存在しないので、除外している)

これでさっきの状態で commit しようとすると、bundle exec rubocop goodbye.rb hello.rbが実行される(そしてもし何かしらエラーがあれば commit が中断される)という算段。

やってしまったこと

ここまでで作ったフックでも十分に機能していたが、commit 済みのファイルを移動(git mv)した場合に対応してないことに気付いた。ファイル移動の場合、AMの代わりにRが出力されるということは、最初にgit help statusで調べた時点で覚えていた。Rの後ろには恐らく移動後のファイル名が出力されるだろう。
ということでファイル移動した場合のgit status --porcelainの出力を確認すること無くフックスクリプトをちょっとだけ修正した。

#!/bin/bash

files=$(ruby -n -e '$_ =~ /^[MAR][^D]/ && $_ =~ /\.rb$/ && puts($_[3..-1])' <(git --porcelain))
#                              ^ Rを追加
if [[ -n $files ]]; then
  bundle exec rubocop $files
fi

さて、実際にgit mvをしたファイルを commit をした時に事件は起きた。

$ git st
On branch master
Changes to be committed:
  (use "git reset HEAD <file>..." to unstage)

    renamed:    hi.rb -> hey.rb

この状態で Rubocop が hey.rb をチェックすることを期待して commit したところ……

$ git commit -m 'rename hi.rb to hey.rb'
Error: No such file or directory: /path/to/repo/hi.rb

思わぬ出力と共に commit が中断した。 嫌な予感がしてgit statusを叩いたところ……

On branch master
Changes to be committed:
  (use "git reset HEAD <file>..." to unstage)

    renamed:    hi.rb -> hey.rb

Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git checkout -- <file>..." to discard changes in working directory)

    modified:   hey.rb

ぼく「なにもしてないのに変更がある……」

さらに嫌な予感がして変更していないはずのhey.rbを見てみると……

$ cat hey.rb
Inspecting 2 files


0 files inspected, no offenses detected

ぼく「なにもしてないのに壊れた……」

まさか Git の操作経由でファイルが壊れると思ってなかったが、幸いにもこのレポジトリは Git で管理されている。深呼吸をしながら(そして git reset の使い方を調べながら)ワークツリーの状態を commit 直前に戻そうと試みた。戻せた。ありがとう Git。

さて、さっきの hey.rb の状態から察するに、明らかに pre-commit で実行した Rubocop が悪さをしている。 ここで初めて、ファイル移動がある場合のgit status --porcelainがどう出力するかを確認した。

$ git st --porcelain
R  hi.rb -> hey.rb

ぼく「あっ……」

勝手に移動先の新ファイル名だけが出力されると思っていたが、現実は丁寧に旧ファイル名と新ファイル名が->でつながれていた。これにより、pre-commitでは以下のコマンドが実行されていた。

bundle exec rubocop hi.rb -> hey.rb

つまり、既に存在しないhi.rbと最初から存在しない-という2つのファイルを Rubocop に掛けようとして、その結果を hey.rb に書き込んでいたとさ。

ちなみにこの後どうしたかというと、

  • そもそもファイル移動する時は移動単体で commit する
  • つまりコードは変更しない
  • あれ Rubocop に掛ける必要なくね?

ということで、pre-commit スクリプトは当初の新規追加/修正のみを対象に Rubocop を掛けるようになったのでした。めでたしめでたし。

まとめ

ということで「Git で調子に乗ってたら痛い目にあったけど、Git によって事なきを得た」という話でした。

  • 外部コマンド出力に頼るときはフォーマットをちゃんと調べましょう
  • こだわるのは程々にしましょう

今だったらどうするかというと、Git のフックでは無理をせずに

  • ローカル実行にこだわりすぎずに CI サービス使う
  • ローカルでやりたかったら Guard で監視する

あたりですかね。