【Git Hooks】コミット時にコミットメッセージの自動チェックを行う。【commit-msg】

## 前提
<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">git commitの時にコミットメッセージに特定の文字が含まれているか確認するにはどうしたらいいんだろうか？<br><br>git hooks の pre-commitで表現できるのか🤔<br><br>変数でコミットメッセージ受け取る方法ある、、、？<a href="https://twitter.com/hashtag/Git?src=hash&amp;ref_src=twsrc%5Etfw">#Git</a></p>&mdash; まさ🧩アプリ開発 (@masa_soli_s) <a href="https://twitter.com/masa_soli_s/status/1567370721508360192?ref_src=twsrc%5Etfw">September 7, 2022</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

この記事ではコミットメッセージを自動的にチェックして、**ルールにそぐわない場合はコミットの実行を取り消す**方法について解説していきます。
今回は凡例としてコミットメッセージに以下のようなルールを作ります。
* Prefixを必ず付ける
* issue番号("#1", "#2", etc...)を必ず付ける

## Git Hooksとは
Git Hooksとはgitの機能の一つで、`git commit`や`git push`が実行された際に、スクリプトを実行することができ、実行結果によってコマンドを通すかどうか自動的に判断させることができます。
機能の詳細については以下をご参考ください。
[8.3 Git のカスタマイズ - Git フック](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)

## 完成形
### コマンド成功時
コミットメッセージ`'feat: test #1'`
![スクリーンショット 2022-10-09 21.14.43.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/643639/42a23578-7aa5-bbbe-4734-9bc34c3416dc.png)

### コマンド失敗時1
コミットメッセージ`'test'`
![スクリーンショット 2022-10-09 21.15.20.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/643639/71ecd5db-a9b1-7ee4-7d33-3458bfc6fdcd.png)

### コマンド失敗時2
コミットメッセージ`'fix: test`
![スクリーンショット 2022-10-09 21.15.46.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/643639/27ae761c-6ae7-047e-f880-649ea9c65a5f.png)

### コマンド失敗時3
コミットメッセージ`'test #1'`
![スクリーンショット 2022-10-09 21.17.25.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/643639/1d761536-ac7d-96e3-2e8a-f11d7002a634.png)

## 手順
`git init`を実行したディレクトリには`.git`というディレクトリが作られています。
`.git`の中にはhooksというディレクトリが存在しており、その配下に予約された名前のスクリプトファイルを設置しておくと、そのファイルの名前に従ったタイミングでファイル内に書かれているスクリプトが実行される仕組みです。

1. `.git/hooks/commit-msg`ファイルを作成する
1. コミットメッセージのルールをスクリプトに書く
1. コミットを作成してみる

### commit-msgファイルを作成
先に以下のコマンドでgit環境を作っておきます。
`$ mkdir sample; cd sample; git init; touch test.txt`

Git HooksはGitリポジトリごとに(git initごとに)設定ができます。
以下は`git init`したての状態のhooksディレクトリになります。
```
$ ls .git/hooks
applypatch-msg.sample     post-update.sample        pre-merge-commit.sample   pre-receive.sample        update.sample
commit-msg.sample         pre-applypatch.sample     pre-push.sample           prepare-commit-msg.sample
fsmonitor-watchman.sample pre-commit.sample         pre-rebase.sample         push-to-checkout.sample
```
ここに設置されているスクリプトファイルはそれぞれ所定のタイミングで実行されます。
(最初は全てのファイルが.sampleとなっており、これらのスクリプトは実行されていません。)

今回必要になるのは、`commit-msg`というファイルです。
元から用意されている`commit-msg.sample`には手をつけず、新規で作成していきます。

`$ vi .git/hooks/commit-msg`commit-msgファイルを作成し、以下のように書きます。

```shell
#!/bin/bash

echo -e "\033[37;1m🪝 Running Git Hooks: commit-msg\033[0m"
# コミットメッセージを定義
MSG="$(cat "$1")"
readonly MSG
# 終了コードを定義。0: OK, 1: NG
code=0

# Prefixの存在チェック
echo -en " - Prefixの存在チェック: "
## 必要なPrefixを定義
readonly CORRECT_PREFIXES=("feat" "fix" "docs" "style" "refactor" "pref" "test" "chore")
## 各要素に": "を追加
for i in "${!CORRECT_PREFIXES[@]}"; do
  correct_prefixes[i]="${CORRECT_PREFIXES[i]}: "
done
## `grep -E`で配列からOR検索をするため、半角スペース(" ")の区切り文字をパイプ("|")に変更
prefixes="$(
  IFS="|"
  echo "${correct_prefixes[*]}"
)"
if ! echo "$MSG" | grep -Eq "${prefixes}"; then
  echo -e "\033[31;22mNG"
  echo -e "================================================================"
  echo -e "コミットメッセージにPrefixが含まれていません。"
  echo -e ""
  echo -e "Prefix"
  echo -e "  ${correct_prefixes[*]}"
  echo -e "================================================================\033[0m\n"
  code=1
else
  echo -e "\033[32;22mOK\033[0m"
fi

# issue番号の存在チェック
echo -en " - issue番号の存在チェック: "
readonly ISSUE_NO="\#[1-9]"
if ! echo "$MSG" | grep -q "${ISSUE_NO}"; then
  echo -e "\033[31;22mNG"
  echo -e "================================================================"
  echo -e "コミットメッセージにissue番号が含まれていません。"
  echo -e ""
  echo -e "Example: #1234"
  echo -e "================================================================\033[0m\n"
  code=1
else
  echo -e "\033[32;22mOK\033[0m"
fi

# 終了宣言
if [ ${code} -eq 0 ]; then
  echo ""
  echo -e "\033[32;1m✨ALL PASS!!\033[0m\n\n"
else
  echo ""
  echo -e "\033[31;1mGit Hooks: commit-msg: NG\033[0m\n\n"
fi

exit ${code}

```
コードの解説は後述します。

最後に、作成したスクリプトファイルに実行権を与えます。
`$ chmod a+x .git/hooks/commit-msg`

### 解説
```shell
echo -e "\033[37;1m🪝 Running Git Hooks: commit-msg\033[0m"
# コミットメッセージを定義
MSG="$(cat "$1")"
readonly MSG
# 終了コードを定義。0: OK, 1: NG
code=0
```
* `echo -e "\033[37;1m🪝 Running Git Hooks: commit-msg\033[0m"`
commit-msgファイルが実行されたことをわかりやすくするために、コミット実行中にターミナルに文字を出力しています。
echoで出力される文字の装飾については以下をご参考ください。
[シェルでechoの文字に色をつける方法](https://r-h.hatenablog.com/entry/20110119/1295452747)

* `MSG="$(cat "$1")"`
変数`MSG`を定義し、`$ cat $1`の出力結果を代入しています。
変数$1には最新のコミットメッセージが書かれているファイルのパス`.git/COMMIT_EDITMSG`が代入されており、その中をcatコマンドで出力(変数MSGに代入)しています。

* `code=0`
このスクリプトがエラー終了なのか正常終了なのかのフラグです。
初期値0は正常終了、つまりコミットのプロセスが問題なく実行されます。
この後のチェック項目に失敗すると、codeに1を代入しています。

```shell
# Prefixの存在チェック
echo -en " - Prefixの存在チェック: "
## 必要なPrefixを定義
readonly CORRECT_PREFIXES=("feat" "fix" "docs" "style" "refactor" "pref" "test" "chore")
## 各要素に": "を追加
for i in "${!CORRECT_PREFIXES[@]}"; do
  correct_prefixes[i]="${CORRECT_PREFIXES[i]}: "
done
## `grep -E`で配列からOR検索をするため、半角スペース(" ")の区切り文字をパイプ("|")に変更
prefixes="$(
  IFS="|"
  echo "${correct_prefixes[*]}"
)"
if ! echo "$MSG" | grep -Eq "${prefixes}"; then
  echo -e "\033[31;22mNG"
  echo -e "================================================================"
  echo -e "コミットメッセージにPrefixが含まれていません。"
  echo -e ""
  echo -e "Prefix"
  echo -e "  ${correct_prefixes[*]}"
  echo -e "================================================================\033[0m\n"
  code=1
else
  echo -e "\033[32;22mOK\033[0m"
fi
```
* `readonly CORRECT_PREFIXES=("feat" "fix" "docs" "style" "refactor" "pref" "test" "chore")`
コミットメッセージに必要となるPrefixを定義しています。
チームによって「どのようなPrefixをつけるか」、「そもそもPrefixはつけない」などあると思うので、この設定はご自身の環境に合わせてください。
ここで設定しているPrefixは以下の記事を参考にしています。
[僕が考える最強のコミットメッセージの書き方](https://qiita.com/konatsu_p/items/dfe199ebe3a7d2010b3e)
readonlyによって、変数`CORRECT_PREFIXES`は読み込み専用と

* `for i in "${!CORRECT_PREFIXES[@]}";
do
  correct_prefixes[i]="${CORRECT_PREFIXES[i]}: "
done`
配列で定義した各要素の末尾に": "を追加して配列を新しく作っています。

* `prefixes="$(IFS="|"; echo "${correct_prefixes[*]}")"`
この後のgrepでOR検索ができるように、配列の区切り文字の半角スペース(" ")をパイプ("|")に変換しています。

* `if ! echo "$MSG" | grep -Eq "${prefixes}";`
`grep`コマンドの成否判定でPrefixの有無を確認します。
`-q`オプションはターミナルにgrepコマンドの結果を出力させず、
`-E`オプションはパイプ("|")で区切られた文字をOR検索します。
詳しくは`$ man grep`をご参考ください。

次にissue番号の有無を確認します。
```shell
echo -en " - issue番号の存在チェック: "
readonly ISSUE_NO="\#[1-9]"
if ! echo "$MSG" | grep -q "${ISSUE_NO}"; then
  echo -e "\033[31;22mNG"
  echo -e "================================================================"
  echo -e "コミットメッセージにissue番号が含まれていません。"
  echo -e ""
  echo -e "Example: #1234"
  echo -e "================================================================\033[0m\n"
  code=1
else
  echo -e "\033[32;22mOK\033[0m"
fi

```
* `ISSUE_NO="\#[1-9]"`
変数issue_numberにはハッシュ("#")から始まり、後に数字の1~9のいずれかが続く文字列を代入しています。
[正規表現：数字の表現。桁数や範囲など](https://www-creators.com/archives/4241)
こちらもreadonlyによって変数`ISSUE_NO`は読み取り専用になっています。

* `if ! echo "$MSG" | grep -q "${ISSUE_NO}";`
`grep`コマンドの成否判定でissue番号(変数ISSUE_NO)の有無を確認します。

* `exit ${code}`
ここまでのチェックを通して、チェックに失敗していた場合はcodeに1が代入されているので、`exit 1`エラー終了となります。
スクリプトがエラー終了した場合は、コミットのプロセスは中断されます。(`git commit`前、`git add`後の状態に戻ります。)
スクリプトが`exit 0`正常終了した場合は、そのままコミットのプロセスは継続されます。(他にスクリプトがなければこれでコミットも完了します。)

```shell
# 終了宣言
if [ ${code} -eq 0 ]; then
  echo ""
  echo -e "\033[32;1m✨ALL PASS!!\033[0m\n\n"
else
  echo ""
  echo -e "\033[31;1mGit Hooks: commit-msg: NG\033[0m\n\n"
fi

exit ${code}
```
* `if [ ${code} -eq 0 ]; then`
ここまでのチェックで問題がなければcodeの値は`0`のままとなるので`✨ALL PASS!!`の文字を表示させるか、
チェックに問題がありcodeの値が`1`になっていれば`Git Hooks: commit-msg: NG`の文字を表示させ、
最後にexitで正常終了か異常終了します。
このスクリプトが正常終了の場合はgit commitコマンドが実行され、異常終了の場合はgit commitコマンドは取り消されます。

### 実行
実際に動かしていきます。
準備の所で用意した`test.txt`をステージングし、コミットメッセージは'test'としてコミットしてみます。

`$ git add test.txt`
`$ git commit -m 'test'`
![スクリーンショット 2022-10-09 21.15.20.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/643639/71ecd5db-a9b1-7ee4-7d33-3458bfc6fdcd.png)
コミットメッセージ"test"にはPrefixもissue番号も入っていないため、上記のようなエラーがターミナル上に出力され、`$ git log`を確認してみてもコミットが実行されていないことがわかります。

### コミット時にスクリプトの実行をスキップしたい場合
スクリプトの実行をスキップ(今回で言うとコミットメッセージのチェックを行いたくない)したい場合は`--no-verify`オプションつけて`$ git commit`を行えば、チェックを通さず、すぐにコミットが実行されます。

### Sourcetreeの場合
コミット時に以下のようなダイアログが表示されます。
![スクリーンショット 2022-10-09 21.41.00.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/643639/5f075707-b7f6-9b74-634d-2630845e64b6.png)
(Sourcetreeだとテキスト装飾がそのまま表示されるのは解決できませんでした・・・。おそらくシェルの違いによるものだと思われます。)

Sourcetreeではコミット作成時の右にあるコミットオプションから[コミットフックをバイパス]にチェックを入れることで、ターミナルコマンドの`--no-verify`オプションと同じ挙動になります。
![スクリーンショット 2022-09-12 20.26.09.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/643639/c7d011c7-639b-eb17-8b40-3e0685872a0f.png)

## まとめ
今回はPrefix、issue番号の存在チェックを例にして紹介しましたが、チームによってルールはさまざまかと思いますので、ご自身の環境に合わせてカスタマイズしてみてください。

今回ご紹介したスクリプトファイルは[私のGitHubリポジトリ](https://github.com/MasaoSasaki/sample-codes/blob/githooks/main/githooks/.githooks/commit-msg)にも同じものを置いてあります。

参考になれば幸いです。

## エラー集
```
hint: The '.git/hooks/commit-msg' hook was ignored because it's not set as executable.
hint: You can disable this warning with `git config advice.ignoredHook false`.
```
この場合は作成したファイルに実行権が与えられていないため、スクリプトは実行されていません。

```
$ ls -l .git/hooks
...
-rw-r--r--  1 masaosasaki  staff   256 Sep  8 23:09 commit-msg
-rwxr-xr-x  1 masaosasaki  staff   896 Jun  5 14:22 commit-msg.sample
...
```
上記の.sampleように本来であれば実行権(x)が必要となるので、`chmod a+x .git/hooks/commit-msg`を実行します。

```
$ chmod a+x .git/hooks/commit-msg
$ ls -l .git/hooks
...
-rwxr-xr-x  1 masaosasaki  staff   256 Sep  8 23:09 commit-msg
-rwxr-xr-x  1 masaosasaki  staff   896 Jun  5 14:22 commit-msg.sample
...
```

## 参考
### サイト
[Linking a pull request to an issue](https://docs.github.com/ja/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue#keywords-for-closing-issues)
[8.3 Git のカスタマイズ - Git フック](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)
[シェルでechoの文字に色をつける方法](https://r-h.hatenablog.com/entry/20110119/1295452747)
[正規表現：数字の表現。桁数や範囲など](https://www-creators.com/archives/4241)
[逆引きシェルスクリプト/if文の条件式でand, or, notを使う方法](https://linux.just4fun.biz/?%E9%80%86%E5%BC%95%E3%81%8D%E3%82%B7%E3%82%A7%E3%83%AB%E3%82%B9%E3%82%AF%E3%83%AA%E3%83%97%E3%83%88/if%E6%96%87%E3%81%AE%E6%9D%A1%E4%BB%B6%E5%BC%8F%E3%81%A7and%2C+or%2C+not%E3%82%92%E4%BD%BF%E3%81%86%E6%96%B9%E6%B3%95)
[僕が考える最強のコミットメッセージの書き方](https://qiita.com/konatsu_p/items/dfe199ebe3a7d2010b3e)
[Bashの配列でjoinやsplitする](https://qiita.com/kawaz/items/b82da76ac93b32ddc364)
[【Shellスクリプト】文字列置換「bash」「sed」について！](https://www.pmi-sfbac.org/string-replacement/)
[シェルスクリプトに挑戦しよう（16）配列［基本編］](https://atmarkit.itmedia.co.jp/ait/articles/1905/22/news004.html)
[ShellCheck - VScode拡張機能](https://marketplace.visualstudio.com/items?itemName=timonwong.shellcheck)