Gitのpre-pushフックで、テストが失敗したらpushを止める方法
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 などフックを管理するツールは試していません。

テストと push 先を用意する
テストには Node.js に入っているテストランナー(node --test)を使いました。cat.js の関数 meow が「にゃ」を繰り返すことを、cat.test.js で確かめます。
$ 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 が指しているコミットを確かめられます。
$ 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 の公式リファレンスによると、実行権限のないフックは無視されます。
$ 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 の「にゃ」を「ニャ」に変えてコミットしました。このままではテストが失敗します。
$ 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 に置き換えています。
$ 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つ先に進んだ状態で残っています。
$ git ls-remote origin
26b0cd1d7e02bbcde50fa0188de08a21f3997f58 HEAD
26b0cd1d7e02bbcde50fa0188de08a21f3997f58 refs/heads/main
$ git status -sb
## main...origin/main [ahead 1]
「ニャ」を「にゃ」に戻すコミットを足して push すると、テストが通り(. が1つ)、今度は push できました。リモートの main も a2ad9f9 に進んでいます。
$ 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 と表示しました。送るものがなくても、フック自体は呼ばれています。
$ 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 されました。
$ 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 はテストが通る内容です。
$ 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 がリモートに上がりました。
$ 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行ずつ届きます。
<ローカルの ref> <ローカルのコミット> <リモートの ref> <リモートのコミット>
そこで、この行を読んで、ローカルのコミットの中身を git archive で取り出し、取り出したフォルダーでテストするフックに書き換えました。git archive は、指定したコミットのファイルをアーカイブにまとめて標準出力に書き出すコマンドです(git archive の公式リファレンス)。それを tar -x で .git/pre-push-test に展開しています。届いた行は、確認のために標準エラー出力にも出しています。
$ 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 に置き換えています)。
$ 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 できました。
$ 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 でテストを飛ばし、削除はそのまま進みました。
$ 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 の中が一度も実行されなかったためです。最初のフックのように、送るものがなくても毎回テストが走ることはありません。
$ 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 には何も出ませんでした。
$ ls .git/pre-push-test && git status --short; echo "exit=$?"
cat.js
cat.test.js
package.json
exit=0




