[iOS] git pushでLintチェックを行う(Git Hooks + SwiftLint)

こんにちは。都内でiOSエンジニアをしている @zrn-ns です。

弊社のプロジェクトでは**[SwiftLint](https://github.com/realm/SwiftLint)**を利用しており、最低限のコーディング規約についてはSwiftLintでチェックする事ができるようになっています。

SwiftLintはビルドフェーズに含めることも想定されており、[公式のガイド](https://github.com/realm/SwiftLint#xcode)に従うだけでビルド時にLintチェックを行い、規約に沿った記述を徹底させることができます。

しかし今回諸事情があり、**ビルドフェーズではなく、Gitでリモートリポジトリにpushする直前でLintチェックを行うように**してみました。

## なぜビルドフェーズではなく、Push時にチェックするのか

**ビルドフェーズで実行する方式だとビルド時に多少のオーバーヘッドが発生してしまいます**（差分チェックを行ってくれるようなので、ほぼ気にならないレベルですが）。
また弊社社内では**Xcode派**と**AppCode派**が存在します。AppCodeではSwiftLintのプラグインが存在するため、Lintチェックをビルドフェーズに含める方式だと、2重でLintチェックが行われてしまうことになります。

これらの問題を解消するため、ビルドフェーズでLintチェックを行うのをやめ、リモートリポジトリにコードをpushするタイミングでLintチェックを行うようにしてみました。

## どうやってPush時に任意のスクリプトを実行するのか

Gitには、各アクション（commit, push, merge等）の実行の前後に任意のスクリプトを実行するための**[Git Hooks](https://git-scm.com/book/ja/v2/Git-%E3%81%AE%E3%82%AB%E3%82%B9%E3%82%BF%E3%83%9E%E3%82%A4%E3%82%BA-Git-%E3%83%95%E3%83%83%E3%82%AF)**という仕組みがあります。

Git Hooksでは、commitやpush, mergeの前後のタイミングで、各Gitリポジトリの`.git/hooks/`ディレクトリ下に配置したスクリプトが自動的に実行されます。
例えば、pushの直前でスクリプトを実行したい場合、`.git/hooks/pre-push`というスクリプトが存在すれば、それが自動的に呼び出されます。

## Git Hooksを利用して、push時にSwiftLintを実行する

今回はGit Hooksの設定を全メンバー間で共有できるようにし、全メンバーがpush前にLintチェックを行うことを強制できるようにします。

### スクリプトの作成

`.git/` ディレクトリ配下はgitで管理されない（コミットできない）ため、`.git/hooks/pre-push` にスクリプトを記述するとメンバー間で設定を共有することができません。

そこで、git管理下にGit Hooks用のスクリプトを入れておくための専用のディレクトリを作成し、そこに配置したpre-pushスクリプトを参照するようにgitの設定を変更します。

```shell
cd {プロジェクトディレクトリ}

# git hooksを入れるためのディレクトリとスクリプトを作成
mkdir .githooks
touch .githooks/pre-push

# スクリプトに実行権限を与える
chmod 755 .githooks/pre-push
```

場合によってはLintチェックを無視してpushしたい場合もあるかと思うので、Lintチェックでエラーがあった場合、pushを続行するか選択できるようにします。

pre-pushのスクリプトの中身は下記のようにします。（※swiftlintのバイナリへのパスは適宜書き換えてください）

```shell:.git/hooks/pre-push
#!/bin/bash

set -eu
cd `dirname $0`

echo -n 'Linting... '

lint_result=$(swiftlint --reporter emoji --quiet --path ../ --config ../.swiftlint.yml)

if [ -n "$lint_result" ]; then
  # Lintで警告が見つかった場合、push操作を続けるかユーザに判断させる
  echo 'Some issues found.'
  echo '====================================='
  echo "$lint_result" | sed -e "s/^/>> /"
  echo '====================================='
  echo ''
  echo -n 'Push anyway?[y/N]: '

  exec < /dev/tty
  read YN

  if [ "$YN" = "y" ]; then
    echo 'Ok. continue.'
  else
    echo 'Push aborted.';
    exit 1;
  fi
else
  echo 'No issues found!'
fi

exit 0
```

最後に、Git Hooksの読み込み先ディレクトリを変更します。
**この設定は各自の環境で実行する必要があるので、環境構築手順書に追加するのが良いかと思います**。

```shell
# Git Hooksのディレクトリを.githooksディレクトリに変更
git config --local core.hooksPath .githooks/
```


## 動作確認

実際に動作させてみた結果は下記のような感じになります。

### 規約違反なし
```shell
% git push origin HEAD
Linting... No issues found!
Enumerating objects: 33, done.
Counting objects: 100% (33/33), done.
Delta compression using up to 8 threads
Compressing objects: 100% (27/27), done.
Writing objects: 100% (33/33), 8.73 KiB | 2.18 MiB/s, done.
Total 33 (delta 5), reused 0 (delta 0)
remote: Resolving deltas: 100% (5/5), done.
To github.com:zrn-ns/swiftlint_on_pre_push.git
 * [new branch]      HEAD -> main

%
```

### 規約違反あり -> push続行

```shell
% git push origin HEAD
Linting... Some issues found.
=====================================
>> /path/to/project/AppDelegate.swift
>> ⚠️ Line 36: Files should have a single trailing newline.
>> ⚠️ Line 34: Limit vertical whitespace to a single empty line. Currently 2.
>> /path/to/project/SwiftLintOnPrePush/SceneDelegate.swift
>> ⚠️ Line 52: Files should have a single trailing newline.
>> ⚠️ Line 50: Limit vertical whitespace to a single empty line. Currently 2.
=====================================

Push anyway?[y/N]: y
Ok. continue.
Enumerating objects: 33, done.
Counting objects: 100% (33/33), done.
Delta compression using up to 8 threads
Compressing objects: 100% (27/27), done.
Writing objects: 100% (33/33), 8.73 KiB | 2.18 MiB/s, done.
Total 33 (delta 5), reused 0 (delta 0)
remote: Resolving deltas: 100% (5/5), done.
To github.com:zrn-ns/swiftlint_on_pre_push.git
 * [new branch]      HEAD -> main

%
```

### 規約違反あり -> push中止
```shell
% git push origin HEAD
Linting... Some issues found.
=====================================
>> /path/to/project/AppDelegate.swift
>> ⚠️ Line 36: Files should have a single trailing newline.
>> ⚠️ Line 34: Limit vertical whitespace to a single empty line. Currently 2.
>> /path/to/project/SwiftLintOnPrePush/SceneDelegate.swift
>> ⚠️ Line 52: Files should have a single trailing newline.
>> ⚠️ Line 50: Limit vertical whitespace to a single empty line. Currently 2.
=====================================

Push anyway?[y/N]: n
Push aborted.
error: failed to push some refs to 'git@github.com:zrn-ns/swiftlint_on_pre_push.git'

%
```

## サンプルプロジェクト

サンプルプロジェクトは[こちら](https://github.com/zrn-ns/swiftlint_on_pre_push/tree/main)にアップしました。

## FIXME

今回の手法では、Git Hooksのディレクトリをgit管理下に置くことで、全メンバーがスクリプトを共有する方法をとっています。
この方法は、スクリプトに修正が入った際にも環境間の同期が取れて便利な半面、**各メンバーが独自のGit Hooksスクリプトを配置できなくなってしまいます**。
（masterへの直pushを防止したりするためにもGit Hooksは便利に使えるので、個人でスクリプトを配置したい場面は多いと思います）

何か良いアイディアがあれば教えていただけると幸いです🙏

## まとめ

push前にSwiftLintのチェックを行う方法を解説しました。

この方法を使えば、各ビルドごとにLintチェックのオーバーヘッドがかかることを防げますし、PullRequestの作成前に問題に気づくことができるので、PullRequest作成後にコードを微調整するコストをかなり減らせるはずです。

ただし、新規メンバーが参入した場合には、ビルドごとにLintチェックを掛けてあげたほうが定着は早そうなので、その際はビルドフェーズに追加する方法でLintチェックを行ったほうが良いのかなと思います。

## 謝辞

下記のサイトを参考にさせていただきました。

- [GitHooksのpre-pushを共有してレポジトリを健全に保つ | by Tatsuya Asami | Medium](https://medium.com/@astatsuya/githooks%E3%81%AEpre-push%E3%82%92%E5%85%B1%E6%9C%89%E3%81%97%E3%81%A6%E3%83%AC%E3%83%9D%E3%82%B8%E3%83%88%E3%83%AA%E3%82%92%E5%81%A5%E5%85%A8%E3%81%AB%E4%BF%9D%E3%81%A4-7156def39b64)