Git Hooks (commit-msg) でコミットメッセージの書式チェック

# この記事は？

Git Hooks の`commit-msg`を用いて、コミットメッセージの書式が一定のルールに沿っているかを確認するスクリプトを実装してみました。

Git の基本、基本的なコマンド (ちょっと応用的な話もあるけど･･･最低限`cat`や`echo`、パイプなど) が分かる前提で話を進めます。

:::note warn
**2022/07/29 編集**
過去に自分の書いた実装方法 ([作る](#作る-旧版)の項) が、後になって見返してみたらあまりにも気に入らなかったので作り直しました。ただ、まるっきり消し去るのもちょっと忍びなかったので、旧版は折りたたみに格納しました。また、他の項目についても一部編集を加えました。
:::

# Git Hooks とは？

簡単に Git Hooks の説明。

Git Hooks は、Git リポジトリで特定の操作が発生した際に、特定のプログラム (シェルスクリプト) を実行する仕組みです。

そのうちの 1つ`commit-msg`は、コミットメッセージの入力が完了し、コミットが生成される直前に呼び出されるものです。`exit 1`など、返り値 0以外を返した場合、コミットが中止されます。

# 作りたいもの

メッセージの書式は、`[Prefix] Message`というように、接頭詞と本文で構成されているようにします。具体的には、以下のようなルールを設けることにします。

* [ ] 1行だけであること
* 接頭詞と本文の間に半角スペースが 1つだけあること
  * [ ] 0文字でないこと
  * [ ] 2文字以上でないこと
* [ ] 接頭詞の前にスペースなどが入っていないこと
* [ ] `[Prefix]`は`[Add], [Mod], [Del]`のいずれかであること
* [ ] `Message`が空でないこと (`[Prefix]`だけ書かれたメッセージでないこと)

:::note warn
`git commit -m <message>` で 1行だけのコミットメッセージを入れることが大半ですが、複数行のコミットメッセージを入れることも可能です。「メッセージは簡潔であるべき」ということにし、ここでは 2行以上のメッセージは許可しないこととします。(1つのコミットで変更が多くなり過ぎないようにすべき、という考え方にも基づきます)
:::

# 作る (旧版)

<details><summary>クリックして旧班を表示</summary>

## コミットメッセージの取得

まずは、入力されたコミットメッセージの取得です。さほど難しいことはありません。
`commit-msg`のコマンド引数の 1つ目に、入力されたコミットメッセージが書かれている`COMMIT_EDITMSG`へのパスが入力されます。
これを`cat`コマンドで取得してやれば OK です。

```sh
mes=`cat $1`
```

(シェルスクリプトのお約束`#!/bin/sh`は、ここでは省略しています。)

## メッセージの下処理

さて、`COMMIT_EDITMSG`の内容ですが、下処理をしなくてはなりません。
`git commit`で`-m`オプションを付けてメッセージが入力された場合はそこまで問題にはなりません。

```COMMIT_EDITMSG.
Any message
```

しかし、`-m`オプション無しで、テキストエディタからメッセージが入力された場合は、次のような入力が考えられます。

```COMMIT_EDITMSG.
Any message

# Please enter the commit message for your changes. Lines starting
# with '#' will be ignored, and an empty message aborts the commit.
#
# ブランチ master
#
# 最初のコミット
#
# コミット予定の変更点:
#	new file:   any_file
#
```

コミット生成で、`#`から始まる行は無視されます。
また、ファイルの初めから文字のある行までの間に空行があった場合、それも無視されます。
正しく書式判定をするため、メッセージ取得でこの処理を書きます。

```sh
mes=`cat $1 | sed -e '/^#/d'`
while [ "`echo "$mes" | sed -n -e "1p"`" = "" ]; do
  mes=`echo "$mes" | sed -e "1d"`
done
```

1. `#`で始まる行 (正規表現で`^#`) を削除 (`/.../d`) し、変数`mes`へ格納。
2. 変数`mes`の 1行目を取得し、
  * 空であれば (空行であれば)、`sed`コマンドで`mes`の 1行目を削除 (`1d`) し、`mes`へ格納
  * 空でなければ (文字のある行であれば) ループ終了

## 接頭詞判定

下処理のされたメッセージが格納されている`mes`から、接頭詞`[Prefix]`を取得します。

```sh
pre=`echo "$mes" | sed -n -e "1p" | sed -e 's/\] .*//' | sed -e 's/^\[//'`

if [ "$pre" != "Add" ] && [ "$pre" != "Upd" ] && [ "$pre" != "Fix" ] && [ "$pre" != "Rmv" ]; then
  echo "Invalid prefix."
  exit 1
fi
```

1. `mes`の 1行目を取得 (`1p`) (無くてもいいかも)
2. `]_` (`_`: スペース) 以降を削除 (「`]_`の後に任意の文字 0字以上 (`.*`) が続く文字列」を無に置換)
3. テキストの一番最初の`[`を削除 (「テキストの初め (`^`) と`[`が続く文字列」を無に置換)

`[Add] First commit`というメッセージが入力されたなら、`pre`には`Add`という文字列が入ります。
`[Add]First commit`と入力されたなら`Add]First commit`と。(2 の処理が行われない)
`First commit`と入力されたなら`First commit`と得られます。(2, 3 の処理が行われない)

ここまできたら、あとは`if`を使って条件分岐するだけです。
ルールとしている接頭詞のいずれにも該当しない場合は、`exit 1` (返す数字は任意) と返して、コミットを中止します。

## 本文判定

今度は、接頭詞以降の文字を取得し、ボディの有無を判定します。

```sh
bod=`echo "$mes" | sed -e 's/^.*\] //'`

if [ "$bod" = "" ]; then
  echo "No message body specified."
  exit 2
fi
```

* 文字の初めから`]_` (`_`: スペース) までを削除 (「テキストの初め (`^`) と任意の文字 0字以上 (`.*`) と`]_`が続く文字列」を無に置換)

`[Add] First commit`と入力されたなら、`bod`には`First commit`という文字列が入ります。
`[Add]_`と入力されたなら、`bod`の中は空になります。

これで、接頭詞`[Prefix]_`の部分を削除できます。
あとは`if`を使って条件分岐するだけです。
`bod`が空であれば、ボディが無いということになりますので、`exit 2` (返す数字は任意) と返して、コミットを中止します。

## 完成品

以上をまとめ、以下のようなスクリプトが出来上がります。
(エラーメッセージも具体的にしました。`echo`の代わりに`cat <<`を使っていますが、どちらでもできることは基本同じです。)

```sh
#!/bin/sh

# Message pre-process
mes=`cat $1 | sed -e '/^#/d'`
while [ "`echo "$mes" | sed -n -e "1p"`" = "" ]; do
  mes=`echo "$mes" | sed -e "1d"`
done

# Prefix get
pre=`echo "$mes" | sed -n -e "1p" | sed -e 's/\] .*//' | sed -e 's/^\[//'`

# Body get
bod=`echo "$mes" | sed -e 's/^.*\] //'`

# Prefix check
if [ "$pre" != "Add" ] && [ "$pre" != "Upd" ] && [ "$pre" != "Fix" ] && [ "$pre" != "Rmv" ]; then
  cat << EOF
Invalid commit message prefix.

Commit syntax:
  [Prefix] <Message body>

Prefixes:
  [Add] : Added file
  [Upd] : Updated file
  [Fix] : Fixed file
  [Rmv] : Removed file

EOF
  exit 1
fi

# Body check
if [ "$bod" = "" ]; then
  cat << EOF
No commit message body specified.
Can't set empty body commit message.

Commit syntac:
  [Prefix] <Message body>

EOF
  exit 2
fi

# Quit
exit 0
```

</details>

# 作る (新版)

実装するスクリプトは、大まかに以下のような手順になります。

1. メッセージを取得
2. 下処理
3. 判定: メッセージが 1行だけであるか
4. `] ` (カッコと半角スペース 1つ) を境に分割 (接頭詞と本文に分割)
5. 判定: 接頭詞
6. 判定: 本文

## メッセージを取得

コマンド引数の 1つ目に、入力されたコミットメッセージが書かれている`COMMIT_EDITMSG`へのパスが入力されます。これを`cat`コマンドで取得してやれば OK です。

```sh
mes=`cat $1`
```

(シェルスクリプトのお約束`#!/bin/sh`は、ここでは省略しています。)

## 下処理

さて、取得したメッセージですが、以下のルールに従っての下処理が必要です。

* `#`で始まる行はコメントアウトとして扱われる
  * -> 削除
* 空白文字や改行のみのメッセージは登録できない
  * -> 空白文字だけの行、および空行を削除

以上の下処理を`sed`コマンドで実装します。

```sh
mes=`cat $1 | sed -e '/^#/d' | sed -e '/^\s*$/d' | sed -e '/^$/d'`
```

## 判定： メッセージは 1行であるか

> * [x] 1行だけであること
>   * -> `wc -l`の結果が 1になる

`wc`コマンドで、メッセージが何行であるかを確認します。結果、1行でなければエラーとします。(ついでに、メッセージが空であった場合の判定も入れておきます。)

```sh
if [ "$mes" = "" ]; then
  # On empty message
elif [ `echo "$mes" | wc -l` != "1" ]; then
  # On equal or over 2 lines message
fi
```

## 接頭詞と本文を分割

`] `(カッコと半角スペース 1つ) を境に分割します。分割した左側は接頭詞、右側は本文となります。

```sh
pre=`echo "${mes%\] *}"`
bod=`echo "${mes#*\] }"`
```

```
mes が "[Add] Any Mes"　とあるとき
pre: [Add
bod: Any Mes
```

:::note
POSIX で規定されている機能により、`sed`を使わずに部分削除などを行うことができます。詳細は以下の記事が参考になります。
:::

https://qiita.com/aosho235/items/c36568830a8d47288284

## 判定: 接頭詞

> * 接頭詞と本文の間に半角スペースが 1つだけあること
>   * [x] 0文字でないこと
>     * -> 0文字のとき、接頭詞の分割がされない
> * [x] 接頭詞の前にスペースなどが入っていないこと
> * [x] `[Prefix]`は`[Add], [Mod], [Del]`のいずれかであること

考えられる不正な入力と、そのときの変数`$pre` `$bod`は、以下が考えられます。

```
mes が "[Add]Wrong Smp" (スペースが無い) とあるとき
pre: [Add]Wrong Smp
bod: [Add]Wrong Smp (いずれも部分削除がされない)

mes が "_[Add] Wrong Smp" (接頭詞前に何らかの文字) とあるとき
pre: _[Add]
bod: Wrong Smp
```

上記に対応するようエラーチェックを書きます。

```sh
if [ "$pre" != "[Add" ] && [ "$pre" != "[Mod" ] && [ "$pre" != "[Del" ]; then
  # On invalid prefix
fi
```

いずれについても、変数`$pre`を`if then`するだけの簡単なお仕事です。(ここで、先の工程で閉じカッコ`]`が消えている点に配慮します。)

## 判定: 本文

> * 接頭詞と本文の間に半角スペースが 1つだけあること
>   * [x] 2文字以上でないこと
>     * -> 2文字以上のとき、`$bod`がスペースで始まる
> * [x] `Message`が空でないこと (`[Prefix]`だけ書かれたメッセージでないこと)
>   * -> 空のとき、`$bod`が空、または`$pre`と同一になる

```
mes が "[Add]  Wrong Smp" (スペース 2つ以上) とあるとき
pre: [Add]
bod:  Wrong Smp (スペースで始まる)

mes が "[Add]" (接頭詞のみ) とあるとき
pre: [Add]
bod: [Add] (部分削除がされない)

mes が "[Add] " (接頭詞とスペースのみ) とあるとき
pre: [Add]
bod: (空)
```

上記に対応するようエラーチェックを書きます。今度はちょっと複雑になります。

```sh
if [ "`echo "$bod" | grep '^\s'`" != "" ] || [ "$bod" = "$pre" ] || [ "$bod" = "" ]; then
  # On invalid body
fi
```

後半 2つはイコールで比較するだけですが、最初については少し工夫が要ります。

* `$bod`がスペースで始まる
  * -> スペースで始まる行がある
    * -> `grep`でスペースから始まる行 (`^\s`) を抽出した結果が空でない (`!= ""`)

というように実装しました。

## 完成品

以上の点とエラーメッセージなどを整え、完成したスクリプトがこちらになります。

```sh
#!/bin/sh

mes=`cat "$1" | sed -e '/^#/d' | sed -e 's/\s*$//g'  | sed -e '/^$/d'`
pre=`echo "${mes%\] *}"`
bod=`echo "${mes#*\] }"`

is_ok=1

if [ "$mes" = "" ]; then
  echo "Invalid: Empty message"
  is_ok=0
elif [ "`echo "$mes" | wc -l`" != "1" ]; then
  echo "Invalid: Equal or over 2 lines"
  is_ok=0
fi

if [ "$pre" != "[Add" ] && [ "$pre" != "[Mod" ] && [ "$pre" != "[Del" ]; then
  echo "Invalid: Prefix"
  is_ok=0
fi

if [ "`echo "$bod" | grep '^\s'`" != "" ] || [ "$bod" = "$pre" ] || [ "$bod" = "" ]; then
  echo "Invalid: Body"
  is_ok=0
fi

if [ "$is_ok" != "1" ]; then
  echo ''
  echo 'Rule:'
  echo '  * Syntax: [Pre] Message'
  echo '  * Specify 1 line message (not be over 2 lines left)'
  echo '  * "[Pre]" must be "[Add]", "[Mod]" or "[Del]"'
  echo '  * Between "[Pre]" and "Message", only 1 space must be left'
  echo '  * "Message" must be left any character(s) without space'
  exit 1
fi

exit 0
```

# インストール

一応、スクリプトのインストール方法を簡単に。

1. 上のコードを`.git/hooks/commit-msg`に書き込む
2. 実行権限を付与する (`chmod +x .git/hooks/commit-msg`)

`.git`は、ローカルリポジトリのルート上に隠しフォルダで存在します。また、(セキュリティの観点などから) リポジトリのクローンで Hook スクリプトはクローンされません。クローンする都度、設定する必要があります。

# おしまいに

やっぱり、テキスト処理に`sed`コマンドは欠かせませんね。

ちなみに、このようなコミットメッセージの lint (文法チェック) を行うスクリプトを生成してくれるツールなんかもあるみたいです。非常に特殊なルール管理が要求されるのでない場合は、こういったツールを使う方が早いです。~~(記事の存在意義ェ)~~