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
)した場合に対応してないことに気付いた。ファイル移動の場合、A
やM
の代わりに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 で監視する
あたりですかね。