CIを待たずにphpcsの検証を自動実行するためのエディタ中立な手法を模索する

2021/12/03に公開

ランサーズ Advent Calendar 2021 3日目の記事です。
https://qiita.com/advent-calendar/2021/lancers

本稿ではgitの機能であるgit hooksを使って、コミット前にコーディング規約違反を検知するという割とありきたりな内容となっております。しかしながら実際に自分でやってみると奥が深かったというか、gitの機能に対する理解が深まったのでgitのしくみについてあまり詳しくない方向けに執筆させて頂きました。

モチベーション

開発をしていると稀に良くこういう感じになります。

Pasted image 20211107162925

最終的にrebaseしてコミットをまとめてしまうのでmasterの履歴としては残らないのですが、リモートリポジトリにpushしてCIの実行を待ち、phpcsのコーディングチェックを受けるのは少々煩わしいです。かといって、いちいち開発環境のコンテナに入ってコマンドを叩くのも手間、docker runコマンドで外からワンライナーでコマンドを実行しようとするとエントリーポイントの関係でpathが深くなるし補完が効かないので不便です。また筆者は、作業内容によってvim,vscode,phpstormを使い分けるスタイルで開発を行っているため、各エディタの機能やプラグインに依存した機能はあまり使いたくないです。この問題を改善に取り組んでみましょう。

要件

あらためて要件をまとめると以下の3点です。

  • コーディング規約に違反しているコードをコミットできないようにしたい
  • vscode,phpstorm,vimを選ばないエディタ中立な方法が良い
  • dockerコンテナ内もしくはホスト上を選ばない、開発環境中立な方法が良い

そこでphpcsを実行するフィルタリング処理用のコンテナイメージを開発環境とは別で用意し、git hooksで実行する方法を採用します。

コンテナイメージ

まずはコンテナイメージを用意します。

dockerfile
FROM php:7.4-alpine

COPY --from=composer /usr/bin/composer /usr/bin/composer

RUN COMPOSER_HOME="/composer" composer global require --prefer-dist --no-progress --dev squizlabs/php_codesniffer
RUN COMPOSER_HOME="/composer" composer global require --prefer-dist --no-progress --dev slevomat/coding-standard

ENV PATH /composer/vendor/bin:${PATH}

WORKDIR "/app"

単純に最新のcomposerを取ってきて、phpcs本体と必要なコーディング規約を入れるだけとなっています。バージョン指定はあえてしません。漢は黙って最新版
一応レジストリにも置いおきました。
https://hub.docker.com/repository/docker/isanasan/phpcs

これでdocker run isanasan/phpcsを実行するとphpcsをオンデマンドで実行できるようになりました。

git hooksとは

各種git操作を検知してスクリプトを実行する機能です。
公式のマニュアルはこちら

https://git-scm.com/book/ja/v2/Git-のカスタマイズ-Git-フック

git管理下のディレクトリで.git/hooks/配下を覗くとsampleが置いてあるのが確認できます。このスクリプトのことをフックスクリプトと呼びます。

$ ls .git/hooks/
applypatch-msg.sample*      pre-merge-commit.sample*
commit-msg*                 pre-push.sample*
commit-msg.sample*          pre-rebase.sample*
fsmonitor-watchman.sample*  pre-receive.sample*
post-update.sample*         prepare-commit-msg.sample*
pre-applypatch.sample*      push-to-checkout.sample*
pre-commit*                 update.sample*

git hooksについて調べていて疑問だったのはwindows環境上だとフックスクリプトを実行する主体は何なのかという点です。
結論としてはgit bashでした。なので、powershellを普段使っている場合も普通にshell scriptを書けば動きます。また、フックスクリプトの検証をする際はgit bash内で実行すれば問題ありません。
ちなみにフックスクリプトから外部コマンドも呼べるので、あんまり複雑なことをする場合はgoなりphpなりdenoなり、shell scriptほど辛みのない言語をつかうのが良いかと思います。

今回はpre-commitを使い、commitの前にフックスクリプトが実行されるようにします。

成果物

とりあえず作成した.git/hooks/pre-commitはこちらになります。

.git/hooks/pre-commit
#!/bin/bash
docker run --rm -v `pwd -W`:/app isanasan/phpcs phpcs --standard=.circleci/phpcs.xml `git diff --cached --name-only` -n

docker run --rm -v pwd -W:/app isanasan/phpcs phpcs

ここまでがphpcsをコンテナで実行するコマンドです。--standardオブションでコーディング規約の設定ファイルを指定しています。ここは各プロジェクトごとに必要なものを指定してください。-nはwarningを抑制するoptionです。一行120文字を越えているとwarningが出るのですが、これはコーディング規約とは関係無いので抑制してしまいます。
phpcsの引数に渡しているgit diff --cached --name-onlyは現在ステージング中のファイル名を取得するコマンドです。

問題点

上記のスクリプトでも十分機能するのですが、一点課題が残ってしまいました。
具体例を確認してみましょう。わざと規約違反のコードを作成してコミットします。

src/Hoge.php
<?php
declare(strict_types=1);

namespace App\Domain;

class Hoge
{
}

するとphpcsが規約違反を検知してコミットが失敗します。

$ git commit -m "hoge"

FILE: /app/src/Hoge.php
----------------------------------------------------------------------
FOUND 2 ERRORS AFFECTING 2 LINES
----------------------------------------------------------------------
 1 | ERROR | [x] End of line character is invalid; expected "\n" but
   |       |     found "\r\n"
 2 | ERROR | [x] Expected 1 line before declare statement, found 0.
----------------------------------------------------------------------
PHPCBF CAN FIX THE 2 MARKED SNIFF VIOLATIONS AUTOMATICALLY
----------------------------------------------------------------------

指摘内容を修正するためにphpcbfを実行します。

$ docker run --rm -v `pwd -W`:/app isanasan/phpcs phpcbf --standard=.circleci/phpcs.xml `git
 diff --cached --name-only` -n

PHPCBF RESULT SUMMARY
----------------------------------------------------------------------
FILE                                                 FIXED  REMAINING
----------------------------------------------------------------------
/app/src/Hoge.php                                     2      0
----------------------------------------------------------------------
A TOTAL OF 2 ERRORS WERE FIXED IN 1 FILE
---------------------------------------------------------------------- 

この時ステージング状態のファイルとワークツリー上のファイルの間には差分があります。

$ git diff
diff --git a/src/Hoge.php b/src/Hoge.php
index 61f1e49a..75b0cc23 100644
--- a/src/Hoge.php
+++ b/src/Hoge.php
@@ -1,4 +1,5 @@
 <?php
+
 declare(strict_types=1);

 namespace App\Domain;

この状態だとフックスクリプトがエラーとならず、コミットできてしまいます。

$ git commit -m "hoge"
[test 15892951] hoge
 1 file changed, 8 insertions(+)
 create mode 100644 src/Hoge.php

現状のフックスクリプトだとphpcsがチェックしているのはワークツリー上のファイルであり、ステージングされたインデックス上のオブジェクトではないためこのような現象が発生してしまいます。このままではあまり便利ではないため、インデックス上のオブジェクトをphpcsで検証で出来るようにフックスクリプトをブラッシュアップしていきます。

ステージング状態の中身を取得する

gitの仕組み上、ステージングされたファイルはスナップショットが作られて.git/indexにblobオブジェクトとして保存されています。
.git/indexの中身はgit ls-files --stageコマンドで確認できます。

https://git-scm.com/docs/git-ls-files

また、引数にgit diff --cached --name-onlyを渡すことで現在ステージング中のファイルに絞って表示することが出来ます。

$ git ls-files --stage `git diff --cached --name-only`
100644 61f1e49aa6341d7a125d845b7664537a937333a7 0       src/Hoge.php

戻り値の2つ目の要素がsrc/Hoge.phpに対応するblobオブジェクトのハッシュ値となっています。
このハッシュ値をgit cat-file -pコマンドに渡すことでハッシュ値が示すオブジェクトの中身を確認することが出来ます。
https://git-scm.com/book/ja/v2/Gitの内側-Gitオブジェクト

$ git cat-file -p 61f1e49aa6341d7a125d845b7664537a937333a7
<?php
declare(strict_types=1);

namespace App\Domain;

class Hoge
{
}

git ls-filesコマンドでオブジェクトのハッシュ値を収集し、git cat-file -pに渡すことでステージング中のコードを取得することが出来ました。

取得したコードをphpcsで検証する

phpcsは標準入力を受けとることが出来るのでgit cat-file -pの標準出力をパイプで繋ぐだけで検証を実行することが出来ます。
docker runコマンドはオプションに-iを指定すればそのまま標準入力に渡してくれます。

$ git cat-file -p 61f1e49aa6341d7a125d845b7664537a937333a7 | docker run -i --rm -v `pwd -W`:
/app isanasan/phpcs phpcs --standard=.circleci/phpcs.xml

FILE: STDIN
----------------------------------------------------------------------
FOUND 1 ERROR AFFECTING 1 LINE
----------------------------------------------------------------------
 2 | ERROR | [x] Expected 1 line before declare statement, found 0.
----------------------------------------------------------------------
PHPCBF CAN FIX THE 1 MARKED SNIFF VIOLATIONS AUTOMATICALLY
----------------------------------------------------------------------

出力結果がFILE: STDINとなって分かりにくいので--stdin-pathオプションを使ってファイル名を教えてあげます。

$ git cat-file -p 61f1e49aa6341d7a125d845b7664537a937333a7 | docker run -i --rm -v `pwd -W`:/app isanasan/phpcs phpcs --standard=.circleci/phpcs.xml --stdin-path=src/Hoge.php

FILE: /app/src/Hoge.php
----------------------------------------------------------------------
FOUND 1 ERROR AFFECTING 1 LINE
----------------------------------------------------------------------
 2 | ERROR | [x] Expected 1 line before declare statement, found 0.
----------------------------------------------------------------------
PHPCBF CAN FIX THE 1 MARKED SNIFF VIOLATIONS AUTOMATICALLY
----------------------------------------------------------------------

これで無事ステージング状態のオブジェクトをphpcsで検証出来るようになったので、フックスクリプトでループで回してやれば要件を実現することが出来ます。

最終版

こちらが完成したフックスクリプトになります。

#!/bin/bash

ret_phpcs=0

while read mode object stage_number file; do
  if [ $stage_number -ne 0 ]; then
    continue
  fi

  git cat-file -p $object | docker run -i --rm -v `pwd -W`:/app isanasan/phpcs phpcs --standard=.circleci/phpcs.xml --stdin-path=$file -n

  if [ $? -ne 0 ]; then
    ret_phpcs=1
  fi
done < <(git ls-files --stage `git diff --cached --name-only`)

if [ $ret_phpcs -ne 0 ]; then
  exit 1
fi

副産物

今回作成したdockerコンテナを使うとphpcbfの実行がとても楽になりました。
下記のコマンドを実行することでステージング中のファイルにフォーマッタを掛けられるのでシェルでaliasを張っておくと便利です。

$ docker run --rm -v `pwd -W`:/app isanasan/phpcs phpcbf --standard=.circleci/phpcs.xml `git diff --cached --name-only`

まとめ

以上でエディタや開発環境に依存することなくphpcsを自動実行して規約違反のコードをコミットする前に検知することが出来るようになりました。
明日は@igaraさんによる「(仮) NextJS モノレポ運用」です。お楽しみに!!

謝辞

ほぼほぼ同じ内容の記事があって、参考にさせて頂きました。というか完成したスクリプトの内容は同じです。
https://tdomy.com/2021/01/how-to-use-php-codesniffer-2/

参考

gitの仕組みについてはこちらの記事を参考にしました。
https://zenn.dev/kaityo256/articles/inside_the_index
https://zenn.dev/uzimaru0000/books/impl-git-in-rust/viewer/2-what-is-git

Discussion