🪝

Git で push 前にテストやフォーマット済みかをチェックする

2023/07/26に公開

TL;DR

次の手順を実行する

  • リポジトリに infra/git-hooks のような名前でディレクトリを作成し、下記の pre-push スクリプトファイルを追加する。
    • このとき、 chmod +x infra/git-hooks/pre-push のようにして pre-push スクリプトに実行権限を付与しておく。
  • .gitignore ファイルに .wt-pre-push-test を追加する。
    • .wt-pre-push-test は pre-push スクリプトが push 前チェックに使用する作業用ディレクトリ
  • 上記変更を push する。

各開発者の環境で上記の変更を取り込んだ段階で git config core.hooksPath infra/git-hooks コマンドを実行して、フックを有効化する。

infra/git-hooks/pre-push
#!/bin/bash

# git で push を行う際に、テストが失敗したり
# formatter が適用されていなければエラーにする

set -e -u -o pipefail

CURRENT_DIR=$(pwd)
DEBUG=0 # デバッグ出力を有効にするかどうか

function echo_debug {
  if [ "${DEBUG}" -eq 1 ]; then
    echo "${@}" 1>&2
  fi
}

while read local_ref local_sha1 remote_ref remote_sha1
do
  echo_debug "local_ref ${local_ref}"
  echo_debug "local_sha1 ${local_sha1}"
  echo_debug "remote_ref ${remote_ref}"
  echo_debug "remote_sha1 ${remote_sha1}"

  cd "${CURRENT_DIR}"

  # チェック用のディレクトリがない場合は git worktree コマンドで準備
  if [ ! -d .wt-pre-push-test ]; then
    git worktree add .wt-pre-push-test ${local_ref}
  fi

  cd .wt-pre-push-test

  # チェック用のディレクトリで、チェック対象のブランチをチェックアウト
  git restore .
  git checkout ${local_ref}
  
  # 以下の内容はプロジェクトの構成に合わせて変更すること。

  # プロジェクトのセットアップを行う。
  # 今回の例では npm install を実行
  set +e
  echo_debug "Setup" 1>&2
  RESULT=$(npm install)
  EXIT_CODE=$?
  set -e

  if [ ${EXIT_CODE} -ne 0 ]; then
    echo "[ERROR] failed to setup." 1>&2
    exit 1
  fi

  # セットアップが完了したら、必要なチェック処理を行う。
  # 今回の例では prettier が適用済みかどうかの確認とテストが通るかどうか結果の確認。
  
  ##################################################
  set +e
  echo_debug "Check prettier" 1>&2
  RESULT=$(npm run prettier)
  EXIT_CODE=$?
  set -e

  if [ ${EXIT_CODE} -ne 0 ]; then
    echo "[ERROR] branch ${local_ref} should be formatted." 1>&2
    exit 1
  fi

  ##################################################

  set +e
  echo_debug "Check test" 1>&2
  RESULT=$(npm run test)
  EXIT_CODE=$?
  set -e

  if [ ${EXIT_CODE} -ne 0 ]; then
    echo "[ERROR] branch ${local_ref} should be fixed to pass tests." 1>&2
    exit 1
  fi
done

これによって、以下のように push 時にチェック処理が走るようになり、チェックに失敗したときは push が中断されるようになる。

% git push origin my-feature1
Previous HEAD position was 071b995 feat: add tests for undefined values
HEAD is now at 2fb56c4 feat: add XXX class
[warn] src/add.js
[warn] Code style issues found in the above file. Run Prettier to fix.
[ERROR] branch refs/heads/my-feature1 should be formatted.

動機

プロジェクトに CI を設定すれば、 push 後に自動でテストやフォーマット済みかどうかをチェックできる。

ただし、実際に push してチェック結果が出るまで時間がかかることがあり、 CI でのチェックが失敗したのを確認してからコードを直して再 push して再度 CI でチェックを行うのは効率が悪い。
(これに対して、手元の環境で事前に必要なチェックを行ってから push すれば良いのだが、その手順を習慣化することは難しい。)

なので、 push 前に手元の環境で自動でテストやフォーマッタが適用済みかどうかをチェックするフックを設定し、手元でのチェックが通った段階で push できるようにしたいと考えた。

git hooks について

https://git-scm.com/book/ja/v2/Git-のカスタマイズ-Git-フック

git hooks は、 commit 時やマージ時や push 時などにカスタムの処理を実行できる仕組み。これを使うと commit 時にテストが通ることをチェックしたりできる。

フック用のディレクトリ(デフォルトは .git/hooks)に、 pre-commit や post-merge などの名前のスクリプトファイルや実行ファイルを配置しておくことで、 git がコミット前やマージ後など特定のタイミングでそのスクリプトを呼び出す。

導入した pre-push スクリプトについて

pre-push は push 前に実行されるフックで、 push しようとしている branch に対して push 可能かどうかをチェックするために使用できる。終了コードで0以外の値を返すとエラー扱いとなり push が中断される。

今回のスクリプトでは最初の while 文で、標準入力から4つの値を受け取っている。

while read local_ref local_sha1 remote_ref remote_sha1
do
   ...
done

これはそれぞれ ローカルのブランチ名ローカルのコミットIDリモートのブランチ名リモートのコミットIDを表していて、 git が pre-push フックに対して標準入力経由でこれらの値を渡すようになっている。

git push コマンドは一度に複数の push 設定を指定できるが、その場合はこの4つ組の値が複数回渡される。例えば、

git push origin my-feature1:develop my-feature2:experimetal

のようにコマンドを実行すると、 while ループの1回目と2回目でそれぞれ local_ref の値は refs/heads/my-feature1, refs/heads/my-feature2 となり、 remote_ref の値は refs/heads/develop, refs/heads/experimental となる。

次の箇所では local_ref ブランチで取得したブランチを .wt-pre-push-test ディレクトリにチェックアウトしている。

  # チェック用のディレクトリがない場合は git worktree コマンドで準備
  if [ ! -d .wt-pre-push-test ]; then
    git worktree add .wt-pre-push-test ${local_ref}
  fi

  cd .wt-pre-push-test

  # チェック用のディレクトリで、チェック対象のブランチをチェックアウト
  git restore .
  git checkout ${local_ref}

テストやフォーマットのチェックを行う際、現在のワーキングディレクトリの状態は push しようとしているブランチと異なっている可能性がある。push 予定のブランチの状態で正しくチェックを行うため、このスクリプトでは git の worktree 機能を使い、リポジトリ内の .wt-pre-push-test ディレクトリに push 予定のブランチをチェックアウトするようにしている。

https://git-scm.com/docs/git-worktree

以降の箇所では、チェックアウトしたブランチに対してプロジェクトのセットアップやテストの実行など、 push 前に行いたい一連の処理を記載している。

セットアップ中にエラーが起きたり、チェックが失敗した場合は0以外の値を指定してスクリプトを終了することで、 push 処理を中断させている。

(このあたりの処理は別のスクリプトに切り出して、 pre-push フックではそのスクリプトを呼び出すだけにしたほうが汎用性があっていいかもしれない)

  # プロジェクトのセットアップを行う。
  # 今回の例では npm install を実行
  set +e
  echo_debug "Setup" 1>&2
  RESULT=$(npm install)
  EXIT_CODE=$?
  set -e

  if [ ${EXIT_CODE} -ne 0 ]; then
    echo "[ERROR] failed to setup." 1>&2
    exit 1
  fi

  # セットアップが完了したら、必要なチェック処理を行う。
  # 今回の例では prettier が適用済みかどうかの確認とテストが通るかどうか結果の確認。
  
  ##################################################
  set +e
  echo_debug "Check prettier" 1>&2
  RESULT=$(npm run prettier)
  EXIT_CODE=$?
  set -e

  if [ ${EXIT_CODE} -ne 0 ]; then
    echo "[ERROR] branch ${local_ref} should be formatted." 1>&2
    exit 1
  fi

  ##################################################

  set +e
  echo_debug "Check test" 1>&2
  RESULT=$(npm run test)
  EXIT_CODE=$?
  set -e

  if [ ${EXIT_CODE} -ne 0 ]; then
    echo "[ERROR] branch ${local_ref} should be fixed to pass tests." 1>&2
    exit 1
  fi

フックのスクリプトの共有について

フックのスクリプトはデフォルトでは .git/hooks ディレクトリに配置する仕様だが、このディレクトリ内のファイルは git の追跡対象にならないため、このディレクトリにスクリプトを配置してもそれをチームメンバーで共有できない。

これに対して infra/git-hooks のような通常のディレクトリにスクリプトを配置しておき、各開発者の環境で git config core.hooksPath infra/git-hooks コマンドを実行してフックに使用するディレクトリを切り替えておくことで、チームメンバー間でフックのスクリプトを共有できる。

一応 node のプロジェクトであれば husky を利用するのも手軽だが、今回のように明示的に git config コマンドを実行する方法は node を使用しないプロジェクトでも利用できる利点がある。

フックを無視して push する方法について

この機能を導入したあとで pre-push フックを無視して強制的に push したい場合は、 push 時に --no-verify オプションを指定する。

https://git-scm.com/docs/git-push#Documentation/git-push.txt---no-verify

Discussion