Gitのpre-pushフックで、テストが失敗したらpushを止める方法

​​Git​Node.js,シェルスクリプト,テスト

Git の pre-push フックを使うと、git push がリモートに何かを送る前に、テストを自動で実行できます。テストが失敗してフックが 0 以外で終わると、push は何も送らずに止まります。テストが通らないコードを、うっかりリモート(GitHub など、ほかの人と共有しているリポジトリ)に上げてしまうのを防ぎたいときに使う仕組みです。ここでは、push 先の bare リポジトリ cat-app.git と、作業するリポジトリ cat-app を用意し、フックがあるときの git push の出力と、リモートのブランチの変化を確かめました。

補足

検証環境は Ubuntu 24.04、Git 2.43.0、Node.js 22 です。作者の名前とメールアドレスは例示用の値(Neko <neko@example.com>)にし、システム全体の設定ファイルは読まない状態(GIT_CONFIG_NOSYSTEM=1)で試しました。push 先は同じマシンの中の bare リポジトリで、GitHub などのサーバーへの push や、Windows・macOS での動作、Husky などフックを管理するツールは試していません。

git push を実行すると pre-push フックが動き、フックにはリモートの名前と URL が引数で、push する ref とコミットが標準入力で渡される。終了コード 0 なら push してリモートのブランチが進み、0 以外なら何も送らずに止まる。--no-verify を付けるとフックを飛ばして push する
pre-push フックの終了コードで、push するかどうかが決まる

テストと push 先を用意する

テストには Node.js に入っているテストランナー(node --test)を使いました。cat.js の関数 meow が「にゃ」を繰り返すことを、cat.test.js で確かめます。

console
$ cat cat.js cat.test.js package.json
export const meow = (n) => "にゃ".repeat(n);
import { test } from "node:test";
import assert from "node:assert/strict";
import { meow } from "./cat.js";

test("meow(2) は「にゃにゃ」", () => {
  assert.equal(meow(2), "にゃにゃ");
});
{ "type": "module" }

$ node --test --test-reporter=dot; echo "exit=$?"
.
exit=0

--test-reporter=dot は、テストの結果を短く出す指定です。Node.js のテストランナーの説明によると、通ったテストは .、失敗したテストは X で表示されます。

cat-app の1つ上のフォルダーに、push 先として bare リポジトリ cat-app.git を作っておき、origin として登録して最初のコミットを push しました。git ls-remote origin で、リモートの main が指しているコミットを確かめられます。

console
$ git add . && git commit -q -m "meow を追加" && git remote add origin ../cat-app.git && git push -u origin main
To ../cat-app.git
 * [new branch]      main -> main
branch 'main' set up to track 'origin/main'.

$ git ls-remote origin
26b0cd1d7e02bbcde50fa0188de08a21f3997f58	HEAD
26b0cd1d7e02bbcde50fa0188de08a21f3997f58	refs/heads/main

pre-push フックを書く

フックは .git/hooks/pre-push という名前(拡張子なし)のファイルに書き、chmod +x で実行権限を付けます。githooks の公式リファレンスによると、実行権限のないフックは無視されます。

console
$ cat .git/hooks/pre-push
#!/bin/sh
# 1つ目の引数はリモートの名前、2つ目は URL
echo "pre-push: $1 ($2) へ push する前に、テストを実行します" >&2
if ! node --test --test-reporter=dot; then
  echo "pre-push: テストが失敗したので、push を止めました" >&2
  exit 1
fi

$ ls -l .git/hooks/pre-push | cut -c1-10
-rwxr-xr-x

githooks の公式リファレンスの pre-push の節によると、フックには push 先のリモートの名前と場所(URL)が引数で渡されます。フックが 0 以外で終わると、git push は何も push せずに中止します。push を止めた理由は、標準エラー出力に書けば利用者に伝わります。このフックでも、メッセージは >&2 で標準エラー出力に出しています。

テストが失敗すると、push が止まる

cat.js の「にゃ」を「ニャ」に変えてコミットしました。このままではテストが失敗します。

console
$ sed -i "s/\"にゃ\"/\"ニャ\"/" cat.js && git commit -q -am "鳴き声をカタカナにする" && git log --oneline
9e359be 鳴き声をカタカナにする
26b0cd1 meow を追加

git push を実行すると、フックがテストを実行し、失敗したテストの内容を出して push を止めました。git push は 1 で終わっています。出力の中の検証環境のパスは /path/to/cat-app に置き換えています。

console
$ git push; echo "exit=$?"
pre-push: origin (../cat-app.git) へ push する前に、テストを実行します
X

Failed tests:

✖ meow(2) は「にゃにゃ」 (1.210301ms)
  AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
  
  'ニャニャ' !== 'にゃにゃ'
  
      at TestContext.<anonymous> (file:///path/to/cat-app/cat.test.js:6:10)
      at Test.runInAsyncScope (node:async_hooks:214:14)
      at Test.run (node:internal/test_runner/test:1047:25)
      at Test.start (node:internal/test_runner/test:944:17)
      at startSubtestAfterBootstrap (node:internal/test_runner/harness:296:17) {
    generatedMessage: true,
    code: 'ERR_ASSERTION',
    actual: 'ニャニャ',
    expected: 'にゃにゃ',
    operator: 'strictEqual',
    diff: 'simple'
  }
pre-push: テストが失敗したので、push を止めました
error: failed to push some refs to '../cat-app.git'
exit=1

引数の $1 には origin、$2 には登録した URL の ../cat-app.git が入っていました。push のあとでリモートを見ると、main は最初のコミット 26b0cd1 のままです。手元の main は、リモートより1つ先に進んだ状態で残っています。

console
$ git ls-remote origin
26b0cd1d7e02bbcde50fa0188de08a21f3997f58	HEAD
26b0cd1d7e02bbcde50fa0188de08a21f3997f58	refs/heads/main

$ git status -sb
## main...origin/main [ahead 1]

「ニャ」を「にゃ」に戻すコミットを足して push すると、テストが通り(. が1つ)、今度は push できました。リモートの main も a2ad9f9 に進んでいます。

console
$ sed -i "s/\"ニャ\"/\"にゃ\"/" cat.js && git commit -q -am "鳴き声をひらがなに戻す" && git log --oneline
a2ad9f9 鳴き声をひらがなに戻す
9e359be 鳴き声をカタカナにする
26b0cd1 meow を追加

$ git push; echo "exit=$?"
pre-push: origin (../cat-app.git) へ push する前に、テストを実行します
.
To ../cat-app.git
   26b0cd1..a2ad9f9  main -> main
exit=0

$ git ls-remote origin
a2ad9f90d3f1b9607d8908fcfcce6e5e4dc4b2ad	HEAD
a2ad9f90d3f1b9607d8908fcfcce6e5e4dc4b2ad	refs/heads/main

push するものが何もないときにもう一度 git push を実行すると、このフックはテストを実行してから Everything up-to-date と表示しました。送るものがなくても、フック自体は呼ばれています。

console
$ git push; echo "exit=$?"
pre-push: origin (../cat-app.git) へ push する前に、テストを実行します
.
Everything up-to-date
exit=0

–no-verify でフックを飛ばす

テストが失敗するのを承知で push したいときは、git push --no-verify を使います。git push の公式リファレンスによると、--no-verify を付けると pre-push フックをまったく実行しません。テストが失敗するコミットを作って試すと、フックのメッセージは出ず、そのまま push されました。

console
$ sed -i "s/\"にゃ\"/\"ニャ\"/" cat.js && git commit -q -am "鳴き声をカタカナにする" && git log --oneline -1
94da14a 鳴き声をカタカナにする

$ git push --no-verify; echo "exit=$?"
To ../cat-app.git
   a2ad9f9..94da14a  main -> main
exit=0

$ git ls-remote origin
94da14aff68fad309265495e6699dd1628181b83	HEAD
94da14aff68fad309265495e6699dd1628181b83	refs/heads/main

リモートの main は、テストが通らないコミット 94da14a に進みました。確かめたあと、直すコミットを足して push し直しました。

テストは、作業ツリーのファイルで動いている

ここまでのフックは、node --test をそのまま実行しています。githooks の公式リファレンスによると、通常のリポジトリでは、フックは作業ツリーのいちばん上のフォルダーで実行されます。そのため、テストが読むのは作業ツリーにあるファイルで、push するコミットの中身ではありません。コミットしていない変更があると、push するコミットとは違う内容をテストすることになります。

確かめるために、「ニャ」に変えた cat.js をコミットしたあと、作業ツリーのファイルだけ「にゃ」に戻しました。コミットされた cat.js はテストが失敗する内容で、作業ツリーの cat.js はテストが通る内容です。

console
$ sed -i "s/\"にゃ\"/\"ニャ\"/" cat.js && git commit -q -am "鳴き声をカタカナにする" && sed -i "s/\"ニャ\"/\"にゃ\"/" cat.js && git status --short
 M cat.js

$ git show HEAD:cat.js
export const meow = (n) => "ニャ".repeat(n);

$ cat cat.js
export const meow = (n) => "にゃ".repeat(n);

この状態で git push を実行すると、フックのテストは通り、テストが失敗するコミット 5a30c9d がリモートに上がりました。

console
$ git push; echo "exit=$?"
pre-push: origin (../cat-app.git) へ push する前に、テストを実行します
.
To ../cat-app.git
   bb7c01b..5a30c9d  main -> main
exit=0

$ git ls-remote origin && git log --oneline -1
5a30c9de7e7ad1466d1249aab553ed74819b5bae	HEAD
5a30c9de7e7ad1466d1249aab553ed74819b5bae	refs/heads/main
5a30c9d 鳴き声をカタカナにする

このあと、作業ツリーの変更をコミットして push し、リモートをテストが通る状態に戻しました。

push するコミットそのものをテストする

push するコミットは、フックの標準入力から分かります。pre-push の節によると、標準入力には push する ref ごとに、次の形の行が1行ずつ届きます。

text
<ローカルの ref> <ローカルのコミット> <リモートの ref> <リモートのコミット>

そこで、この行を読んで、ローカルのコミットの中身を git archive で取り出し、取り出したフォルダーでテストするフックに書き換えました。git archive は、指定したコミットのファイルをアーカイブにまとめて標準出力に書き出すコマンドです(git archive の公式リファレンス)。それを tar -x で .git/pre-push-test に展開しています。届いた行は、確認のために標準エラー出力にも出しています。

console
$ cat .git/hooks/pre-push
#!/bin/sh
# 標準入力に、push する ref が1行ずつ届く
# <ローカルの ref> <ローカルのコミット> <リモートの ref> <リモートのコミット>
zero=0000000000000000000000000000000000000000
while read local_ref local_oid remote_ref remote_oid; do
  echo "pre-push: $local_ref $local_oid $remote_ref $remote_oid" >&2
  # ブランチを消す push には、テストするコミットがない
  if [ "$local_oid" = "$zero" ]; then
    continue
  fi
  # push するコミットの中身を取り出して、そこでテストする
  dir="$(git rev-parse --git-dir)/pre-push-test"
  rm -rf "$dir" && mkdir "$dir"
  git archive "$local_oid" | tar -x -C "$dir"
  if ! (cd "$dir" && node --test --test-reporter=dot); then
    echo "pre-push: $local_ref のテストが失敗したので、push を止めました" >&2
    exit 1
  fi
done
exit 0

さきほどと同じく、テストが失敗する内容をコミットし、作業ツリーだけを直した状態で push しました。今度は、push するコミット 31cfcd6 の中身でテストが実行されて失敗し、push が止まりました。失敗したテストのファイルが .git/pre-push-test の中にあることからも、取り出したコミットの中身をテストしたことが分かります(パスは /path/to/cat-app に置き換えています)。

console
$ sed -i "s/\"にゃ\"/\"ニャ\"/" cat.js && git commit -q -am "鳴き声をカタカナにする" && sed -i "s/\"ニャ\"/\"にゃ\"/" cat.js && git status --short
 M cat.js

$ git push; echo "exit=$?"
pre-push: refs/heads/main 31cfcd60e717cb72276b844b461ba496b133b83a refs/heads/main d7d9e7e88f33b02477650d94b1225d5ad95d681a
X

Failed tests:

✖ meow(2) は「にゃにゃ」 (1.158033ms)
  AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
  
  'ニャニャ' !== 'にゃにゃ'
  
      at TestContext.<anonymous> (file:///path/to/cat-app/.git/pre-push-test/cat.test.js:6:10)
      at Test.runInAsyncScope (node:async_hooks:214:14)
      at Test.run (node:internal/test_runner/test:1047:25)
      at Test.start (node:internal/test_runner/test:944:17)
      at startSubtestAfterBootstrap (node:internal/test_runner/harness:296:17) {
    generatedMessage: true,
    code: 'ERR_ASSERTION',
    actual: 'ニャニャ',
    expected: 'にゃにゃ',
    operator: 'strictEqual',
    diff: 'simple'
  }
pre-push: refs/heads/main のテストが失敗したので、push を止めました
error: failed to push some refs to '../cat-app.git'
exit=1

$ git ls-remote origin
d7d9e7e88f33b02477650d94b1225d5ad95d681a	HEAD
d7d9e7e88f33b02477650d94b1225d5ad95d681a	refs/heads/main

作業ツリーの変更をコミットしてから push すると、テストが通って push できました。

console
$ git commit -q -am "鳴き声をひらがなに戻す" && git push; echo "exit=$?"
pre-push: refs/heads/main 31474a597ac49e14d2f582aa94b069364223ff1e refs/heads/main d7d9e7e88f33b02477650d94b1225d5ad95d681a
.
To ../cat-app.git
   d7d9e7e..31474a5  main -> main
exit=0

新しいブランチの push と、ブランチの削除

標準入力の行は、push の種類によって中身が変わります。リモートにまだない topic ブランチを push すると、リモートのコミットがすべて 0 の値(0 が40個並んだ値)になっていました。git push origin --delete topic でブランチを消すときは、ローカルの ref が (delete)、ローカルのコミットがすべて 0 の値です。どちらも pre-push の節に書かれているとおりです。消すときはテストするコミットがないので、フックは continue でテストを飛ばし、削除はそのまま進みました。

console
$ git switch -q -c topic && git push origin topic; echo "exit=$?"
pre-push: refs/heads/topic 31474a597ac49e14d2f582aa94b069364223ff1e refs/heads/topic 0000000000000000000000000000000000000000
.
To ../cat-app.git
 * [new branch]      topic -> topic
exit=0

$ git push origin --delete topic; echo "exit=$?"
pre-push: (delete) 0000000000000000000000000000000000000000 refs/heads/topic 31474a597ac49e14d2f582aa94b069364223ff1e
To ../cat-app.git
 - [deleted]         topic
exit=0

main に戻って、push するものがない状態で git push を実行すると、今度は pre-push: の行もテストの . も出ませんでした。標準入力に行が1つも届かず、while の中が一度も実行されなかったためです。最初のフックのように、送るものがなくても毎回テストが走ることはありません。

console
$ git switch -q main && git push; echo "exit=$?"
Everything up-to-date
exit=0

なお、.git/pre-push-test は、フックの中で毎回 rm -rf してから作り直していますが、push のあとも残ります。.git の中に置いたので、git status --short には何も出ませんでした。

console
$ ls .git/pre-push-test && git status --short; echo "exit=$?"
cat.js
cat.test.js
package.json
exit=0

​Posted by 86