Git hooksを使ってみた話

## はじめに
ソースコードをGit管理していた時の話。
Git-Flowにて、本番環境のソースのあるprodブランチへ変更入っちゃったらまずいよな、、という話から
いやそもそもリモートブランチへの変更ではなく、ローカルブランチ上でのCommit単位でいち早く状況が理解できれば手戻りも少なく、もっと良いのでは、、？と思い、そんな機能ないのかなと調べたところ、Gitのアクションをトリガーにしたスクリプトがあるらしい、、！となり調べてみました。

ちょっとでも参考になればと思います！ぜひ〜


## Git hooksってなに
GitHooksはGitの標準機能で、特定のGit操作、例えばコミットやプッシュなどに対してカスタムなスクリプトを実行する仕組みです。このスクリプトで、Git操作が正しいかどうかをチェックすることが可能です。
参照；[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)

GitHooksは大きく二つに分かれており、
ローカル環境下操作（commitやmerge）でのチェックができる、クライアントサイドフック
と
リモートリポジトリ下操作（push）でのチェックができる、サーバーサイドフックがあります。


GitHooksは`.git/hooks`に格納します。
<img width="50%" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1827273/6e0ba43a-9275-a4f4-b955-76b57db78187.png">

デフォルトで、`.sample`と末尾にあるサンプルファイルが格納されています。
.sampleを取り除くと、git hooksとして実行されます。

また、githookファイルはシェル以外でもRuby や Python などで書くこともできます。
ファイル名には、`.sh`や`.py`などの、拡張子をつける必要はありません。

## 共有方法

実行ファイルは`.git/hooks`に格納されており、このフォルダはGit管理外となるため、そのままでは共有できない状態です。
そのため、作成したカスタムフックは、リポジトリルート下にディレクトリを作成し、GitHooksの参照先を変更することで対応します。

1. リポジトリルート下に`.githooks`を作成します。
    ```
    mkdir /.githooks
    ```

2. `.githooks`配下にて、実行したいgithooksファイルを格納します。

3. Githooksの参照先を変更するコマンドを実行する。
    ```
    git config core.hooksPath .githooks
    ```
4. フォルダ権限を変更するコマンドを実行する。
    ```
    chmod -R +x .githooks/
    ```

    ※参照先をデフォルトに戻すには以下コマンドを実行してください
    ```
    git config core.hooksPath .git/hooks
    ```

また、上記フローを`package.json`に記載し、ライフサイクルに組み込むことが可能です。
参照:[追加の依存パッケージなしでプロジェクトごとのGitコミットフックを設定する方法](https://efcl.info/2021/08/21/git-precommit-hook/)


## クライアントサイドフックの種類と起動タイミング

### コミットワークフローフック
1. **pre-commit**  
   - コミットメッセージの入力前に実行される。  
   - 検査やテスト実行、スタイルチェックに利用される。  
   - ゼロ以外を返すとコミットが中断される。

2. **prepare-commit-msg**  
   - コミットメッセージエディター起動直前に実行される。  
   - デフォルトのコミットメッセージを編集可能。  
   - コミットのタイプやSHA-1を受け取り、テンプレートとの組み合わせが有効。

3. **commit-msg**  
   - コミットメッセージ保存後に実行される。  
   - メッセージの検査やフォーマットの確認に利用可能。  
   - ゼロ以外を返すとコミットが中断される。

4. **post-commit**  
   - コミット後に実行される。  
   - 通知や後処理に活用されることが多い。

---

1. **applypatch-msg**  
   - パッチ適用時にコミットメッセージを確認するために実行される。  
   - 不正なメッセージの検査・修正が可能。

2. **pre-applypatch**  
   - パッチ適用後、コミット前に実行される。  
   - テストやスナップショットの調査に使用。  
   - ゼロ以外を返すとコミットが中断される。

3. **post-applypatch**  
   - パッチ適用後に実行される。  
   - 通知や処理の完了報告を行う。

---

1. **pre-rebase**  
   - リベース前に実行される。  
   - 既にプッシュ済みのコミットのリベースを防ぐのに利用可能。

2. **post-rewrite**  
   - 既存のコミットを書き換えるコマンド（例: amend, rebase）後に実行される。  
   - 変更後のファイルリストの処理や通知に使用可能。

3. **post-checkout**  
   - チェックアウト完了後に実行される。  
   - 作業ディレクトリのカスタマイズや非追跡ファイルの同期に利用。

4. **post-merge**  
   - マージ後に実行される。  
   - 管理外のファイルの確認や復元処理を行う。

5. **pre-auto-gc**  
   - 自動ガベージコレクション直前に実行される。  
   - 実行の通知や中断処理に利用される。

### サーバーサイドフックの種類

1. **pre-receive**  
   - プッシュ時、参照リストが渡された直後に実行される。  
   - アクセス制御やfast-forward確認に利用可能。  
   - ゼロ以外を返すとすべてのプッシュが拒否される。

2. **update**  
   - プッシュされた各参照ごとに個別に実行される。  
   - ブランチ単位での検査や制御に使用可能。  
   - 該当参照のみを拒否可能。

3. **post-receive**  
   - プッシュ処理完了後に実行される。  
   - 外部通知や継続的インテグレーションへの連携に利用される。  
   - 中断処理はできないが、クライアントの接続を保持する。

## 実装例
今回は２種類のGit hooksを実装します。
コミット実行時にブランチとコードフォーマットのチェックを行う、pre-commit hooks、
そしてコミットメッセーに対しルールに沿ったメッセージかどうかのチェックを行う、commit-msg hooksです。
hook内では成功時には0を返し、エラー時には1を返すことでコミットが中止されます。

### 実装例：pre-commit hooks

```bash:pre-commit
#!/bin/sh

# 標準エラー出力へのリダイレクト
exec 1>&2

# 現在のブランチ名を取得
current_branch=$(git symbolic-ref --short HEAD 2>/dev/null)

# =================================
# 1. prodブランチへの直接Commit/Push防止
# =================================
if [ "$current_branch" = "prod" ]; then
    echo "エラー: prodブランチへの直接のコミットは禁止されています"
    exit 1
fi

# =================================
# 2. コードフォーマットの実行
# =================================
# ステージングされたPythonファイルを取得
STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.py$')

# ステージングされたファイルが存在しない場合はスキップ
if [ -z "$STAGED_FILES" ]; then
  exit 0
fi

# pyenv の初期化を行う
export PATH="$HOME/.pyenv/bin:$PATH"
eval "$(pyenv init --path)"
eval "$(pyenv init -)"

# Blackがインストールされているか確認   
if ! command -v black >/dev/null 2>&1; then
  echo "警告: Blackが見つかりません。Blackをインストールしてください"
  exit 1
fi

# フォーマット失敗を追跡するフラグ
FAILED=0

for FILE in $STAGED_FILES; do
  black "$FILE" >/dev/null 2>&1
  if [ $? -ne 0 ]; then
    echo "エラー: ファイル '$FILE' のフォーマットに失敗しました。" >&2
    FAILED=1
  else
    # Black が成功した場合、再ステージング
    git add "$FILE"
  fi
done

# フォーマット失敗があれば終了
if [ $FAILED -ne 0 ]; then
  echo "Black によるフォーマットの一部が失敗しました。修正を確認してください。" >&2
  exit 1
fi

# すべてのチェックが通過
exit 0
```
例えば本番ブランチへ間違ってコミットをした場合、以下のような警告文が表示されます。
<img width="40%" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1827273/f248985c-d3ac-d10b-d771-5279db4c330f.png">
今回の環境は'pyenv'環境下での実施のため初期化処理を設けています。

また今回はPythonで記述したコードレポジトリ内へフォーマットを実施したためblackでしたが、実際利用する言語に紐づくお好きなフォーマッターでの実行ください。

### 実装例：commit-msg hooksの場合
```bash
#!/bin/bash

# コミットメッセージファイルの取得
COMMIT_MSG_FILE=$1
BRANCH_NAME=$(git symbolic-ref --short HEAD)

# コミットメッセージの取得
COMMIT_MSG=$(head -n 1 "$COMMIT_MSG_FILE")
# =================================
# 1. プレフィックスのチェック
# =================================
if ! [[ "$COMMIT_MSG" =~ ^\[(feat|fix|chore|docs|refactor|perf|style|test)\]\  ]]; then
  echo "エラー: コミットメッセージの先頭に [feat], [fix], [chore] などのタグを付けてください。" >&2
  exit 1
fi

# =================================
# 2. メッセージの詳細説明をチェック
# =================================
MESSAGE_CONTENT=$(echo "$COMMIT_MSG" | sed -E 's/^\[(feat|fix|chore|docs|refactor|perf|style|test)\]\ //')
if [[ -z "$MESSAGE_CONTENT" ]]; then
  echo "エラー: タグだけのコミットメッセージは禁止です。具体的な内容を記述してください。" >&2
  exit 1
fi

# チェック成功
exit 0
```
例えばタグを付与しないでコミットした場合、以下のような警告文が表示されます。
<img width="40%" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1827273/154cb6b2-faaf-93dc-0dca-f0a840f9b119.png">

:::note warn
Git hooks実行される順序について
今回実装したGit hooksでは、
`pre-commit`　→　`commit-msg`
の順に実行されます。
実行順序にご注意ください！
:::

## まとめ
今回はGit hooksについて調べてみました。
内容の確認やブランチの確認などはCICDでも制御可能で、デプロイ前処理として実行することは可能だと思いますが、ローカル環境にて開発で切って離せないCommit時点でのチェックができるのはすごくいいなと思います。
開発グループでルールチェックをするもよし、個人でヒューマンエラー対策としてチェックするもよし。様々なGitアクションでの様々な制御ができるので、使いこなせたら便利な機能だろうなと思います！
引き続き、いろいろ勉強していきます〜！