appbrew Tech Blog

appbrewのエンジニアチームの日々です

pre-commitでこんな自動レビューをしています!手戻りが少なくて最高!

AppBrewでiOSエンジニアをしていますはるふ(@_ha1f)です。 2019/10にAppBrewに入社しまして、開発の傍らに、開発環境の改善などに取り組んでいます。

近年のiOS界隈を取り巻く「開発環境」といえば、Danger, mint, xcodegen, swiftlint等思い浮かべるかもしれませんが、 今回の記事ではそういうハイカラなツールではなく、iOSに限らず使えるpre-commitというGitの機能を紹介します。

pre-commitにより、コミットするブランチを間違えていないかや、コンフリクト未解消マーカーが含まれていないかなど、いろいろな制約を「ローカルでコミット前に」自動チェック出来ます。

Dangerなどを使っているとCIを待って修正して再度pushしないといけなかったり作業が煩わしいことがありますが、 ローカルなので手戻り少なく、レビューコストやミスを減らすことができます。

本記事では、我々が実際にどんな処理をしているのかも合わせてご紹介します。 

pre-commitとは

Gitには標準で、特定のタイミングでスクリプトを実行する機能があります。 pre-commitもその一つで、コミットの実行前に実行されます。 また、エラーを返すことでコミットを中断させることも出来ます。

参考: Git - Git フック

設定の仕方は簡単で、 .git/hooks/pre-commit というファイルが存在すれば自動的に実行されることになっています。

git init でリポジトリを作成した場合は、サンプルのファイル .git/hooks/pre-commit.sample が存在しています。 とりあえずはファイル名から .sample を取り除くと動作を確認することが出来ます。

sampleではnon-asciiなファイル名の追加や、末尾ホワイトスペースとタブスペースの混合を検出してエラーを出しています。

pre-commitの書き方

pre-commit.sampleを一部省略して抜粋しています。 

#!/bin/sh

if git rev-parse --verify HEAD >/dev/null 2>&1
then
    against=HEAD
else
    # Initial commit: diff against an empty tree object
    against=$(git hash-object -t tree /dev/null)
fi

# Redirect output to stderr.
exec 1>&2

if 条件; then
    echo "error"
    exit 1
fi

任意の場所で exit 1 とかくと、コミットが中断されます。 exec 1>&2 しておくとechoなどに渡した文字列がが標準エラー出力に出力されます。 何も起こらず一番下にたどり着いたり、exit 0 すると、そのままコミットが実行されます。

差分に対して何らかの処理、確認などを行いたいときには $against に保持している値を使います。 HEADが存在しない場合は、空のツリーと比較されます。

pre-commitをGitで管理する

強力なpre-commitですが、gitの管理ディレクトリの中にあるので、基本的にはgitで管理することが出来ません。 チームメンバで共有するためには、少し工夫をする必要があります。

Git管理下のファイルから.gitにコピーする

これを解決するために、Git管理下にある親ファイルから自動的に .git/hooks/pre-commit にコピーする仕組みを作りました。

LIPSのiOSプロジェクトでは、依存性のインストール、コード生成などをまとめて行うスクリプトが用意されています。 そのスクリプトの中でpre-commitファイルをコピーすることにより、pre-commitが適用されることになりました。

PRECOMMIT_MASTER_FILE='./script/pre-commit'
cp $PRECOMMIT_MASTER_FILE ./.git/hooks/pre-commit

  ただし、これでは一度コピーされても再度スクリプトが実行されるのはもっと先になるかもしれず、 pre-commitが確実に更新されるとは限らないという問題がありました。

確実に最新のpre-commitを走らせる

  pre-commitを確実に最新のものを使うため、pre-commitの冒頭で親ファイルとの差分を確認し、必要ならコピーしてから再度コミットさせるようにしました。

# pre-commit自体が最新かどうかをチェック
PRECOMMIT_MASTER_FILE='./script/pre-commit'
if [ -e $PRECOMMIT_MASTER_FILE ]; then
  diff -s $PRECOMMIT_MASTER_FILE ./.git/hooks/pre-commit > /dev/null 2>&1
  if [ $? -ne 0 ]; then
    cp $PRECOMMIT_MASTER_FILE ./.git/hooks/pre-commit
    echo "updated pre-commit."
    echo "pre-commit was not latest. Please run again." 1>&2
    exit 1
  fi
else
  echo "$PRECOMMIT_MASTER_FILE doesn't exist."
fi

$PRECOMMIT_MASTER_FILE が親ファイルです。 pre-commitを実行するたびにこのように差分チェックが実行され、コピーを行ったのちコミットが中断されます。

コピーされたときは2回commit実行する必要があり少し面倒ですが、単純のためにこうしています。

別の方法(gitconfigを使う)

git hooksのパスはgitconfigで書き換えることが出来るので、git管理下のディレクトリを指定しておくと実行されるようになります。

git config --local core.hooksPath script/githooks

この場合でもgit configは.git以下に保存されるので、一度各マシンで設定を行う必要があります。

実際に利用しているpre-commitコマンド

実際にLIPSのiOSのプロジェクトで使っているスクリプトをご紹介します!

master, develop branchでのcommitを禁止

master, developへはPullRequest経由でマージすることになっています。 それらのブランチでのcommitはミスのことが多いので、禁止するようにしました。

# master, develop branchでのcommitを禁止
BRANCH_NAME=`git symbolic-ref HEAD | sed -e 's:^refs/heads/::'`
if test $BRANCH_NAME = master -o $BRANCH_NAME = develop; then
    echo "We cannot commit on ${BRANCH_NAME} branch."
    exit 1
fi

参考: pre-commit hookでmasterへのcommitを禁止した - @znz blog

コンフリクト未解消ファイルがあったらコミットさせない

コンフリクト未解消マークが残ったままコミットすることはないので、これを禁止しています。 注意点としては当たり前ですが、このスクリプト自体にマーカーが入ってしまうので、検査対象ファイルから省いておく必要があります。

# コンフリクト未解消ファイルがあったら警告
for FILE in `git diff-index --name-status $against | grep -E '\.(swift|pbxproj|m|h|xib|storyboard)$' | cut -c3-`; do
    if [ -e $FILE ]; then
        # 削除されたファイルは飛ばす
        continue
    fi
    grep_result=`grep -E '(<<<<<<<|>>>>>>>)' $FILE | grep -v '^$'`
    if [ -n "${grep_result}" ]; then
        echo $'\e[1;31m'$FILE$'\e[m' ' <- コンフリクト未解消ファイルがあります'
        echo $grep_result
        exit 1
    fi
done

参考: git commit する前にコンフリクトの残りがないかチェックする - Qiita

CircleCIのconfigをvalidateする

CiecleCIのconfigファイルのフォーマットが正しいかどうか、ローカルでチェックすることが出来ます。 ※ homebrewなどでCircleCIのCLIツールをインストールしておく必要があります

configファイルを書き換えていたときのみ、validateを実行するようにしています。

# CircleCIのconfigをvalidate
for FILE in `git diff-index --name-status $against | grep -E '.circleci/config.yml' | cut -c3-`; do
  if ! eMSG=$(circleci config validate -c .circleci/config.yml); then
    echo "Error: CircleCI Configuration is invalid."
    echo $eMSG
    exit 1
  fi
done

注意点として、circleciコマンドはネットワークを挟んだりして、処理に時間がかかることがあったり、またまれにCircleCIの障害でCLIが正しく動かないことがあったりするので、 確実に変更があったときのみにするのがおすすめです。

また、いざというときにはpre-commitを無効化する方法をチームメンバが知っていると良いと思います。

register_devicesをvalidate

fastlane/register_devices ファイルはスペース区切りではなくタブ区切りです。 通常Xcodeなどで開いて編集するとスペースになってしまって、CIの時点でエラーに気づくことが多かったので、これも検査することにしました。

# register_devicesをvalidate
for FILE in `git diff-index --name-status $against | grep -E 'fastlane/register_devices.txt' | cut -c3-`; do
  for row in `cat fastlane/register_devices.txt | awk -F '\t' '{print NF}'`; do
    if [ $row != "2" ]; then
      echo "Error: lines in fastlane/register_devices.txt must be separated with tab character."
      exit 1
    fi
  done
done

おわりに

基本機能なので、Dangerを整備するのとそんなに変わらないぐらいの手間で手戻りも少なく自動検査できて、 pre-commitはかなり強い味方だと思います。

pre-commitは強力な機能ですが、かなりの回数実行されるので、ネットワークを挟む処理を避けたり、範囲を最小限にするなど、できるだけ短時間で終わるようにする必要があります。 そういう場合にはDangerや pre-push 等、その他の方法を検討してみると良いかもしれません。 実際LIPSではlinterの検査などはDangerで行っていたりします。

自分のチームでも最適な検査を見つけて、使ってみてください。

We are Hiring!

弊社AppBrewでは現在、500万DL突破のコスメクチコミアプリ「LIPS」をはじめとした「個性を解放するものづくりを。」に携わるエンジニアを積極採用中です。

目的に真摯に向き合い、技術でそれを解決したいと思うエンジニアの方はぜひ一度ご応募ください!お待ちしております。

www.wantedly.com

job-draft.jp