
pre-commitとは、Gitのコミット直前に動く「フック」を、リポジトリに置いた.pre-commit-config.yamlで管理するためのツールです。末尾の空白やYAMLの構文、Pythonやシェルスクリプトの静的解析、秘密情報の混入といったチェックを、各ツールの実行環境の用意まで含めて自動で行い、問題があればコミットを止めます。
以下は、執筆時点の最新版pre-commit 4.6.2で、Python・シェルスクリプト・YAMLが混ざった自動化スクリプト用のリポジトリを想定して設定し、実際にコミットを止めたときの出力です。設定を書いてみて初めて気づいた落とし穴(gitleaksを置くステージなど)も含めています。
この記事でわかること
- Gitフックとpre-commitの関係、フックごとに独立した実行環境が作られる仕組み
- ruff・shellcheck・gitleaks・ローカルのpytestを使う.pre-commit-config.yamlの書き方
- 自動修正されたファイルの扱いと、
SKIP・--no-verifyでの一時的な回避 - GitHub Actionsで同じチェックを全ファイルに走らせる設定
- stagesの指定、autoupdateの挙動、huskyとの衝突など運用上の注意
pre-commitとは:Gitフックとの関係と仕組み
Gitには、コミットやpushの前後に.git/hooks/のスクリプトを実行する仕組みがあり、pre-commitという名前のスクリプトが0以外で終了するとコミットは作られません。ただし.git/の中身はリポジトリで共有されないため、手書きのフックはメンバーごとに配る必要があり、使うツールのインストールも各自に任せることになります。
この配布と環境の準備を引き受けるのがpre-commitです。pre-commit installは.git/hooks/pre-commitに小さな起動用スクリプト(先頭に# File generated by pre-commit: https://pre-commit.comと書かれたもの)を置くだけで、中身のチェックはリポジトリにコミットした設定ファイルから読みます。各フックのリポジトリは~/.cache/pre-commitにcloneされ、Pythonなら仮想環境、Go製のツールならGoのツールチェーンごと、フック専用の環境が自動で作られます。対象になるのは、既定ではそのコミットに含まれる(ステージされた)ファイルだけです。
| 方式 | 設定の共有 | チェック用ツールの準備 | 向いている場面 |
|---|---|---|---|
| .git/hooksに手書き | 共有されない(各自でコピー) | 各自がインストール | 個人のリポジトリで1つだけ確認したい |
| pre-commit | .pre-commit-config.yamlをコミット | フックごとに自動で環境を作る(Python・Node.js・Goなど) | 言語が混在するリポジトリ、インフラのコード |
| husky | package.jsonと.husky/をコミット | npmで入れたツールを使う | Node.js中心のフロントエンドのリポジトリ |
pre-commitのインストールと有効化
pre-commit自体はPython製のCLIなので、プロジェクトの依存に混ぜず、uvのuv tool installやpipxで単独のツールとして入れます。Ubuntu 24.04のaptでも入りますが、版は3.6.2で、後述のlanguage: unsupportedなどの新しい書き方を知りません。
uv tool install pre-commit # または pipx install pre-commit
pre-commit --version # pre-commit 4.6.2(検証時)
cd ~/infra-scripts # 対象のリポジトリ
pre-commit install --install-hookspre-commit installed at .git/hooks/pre-commit
pre-commit installed at .git/hooks/pre-push2行出ているのは、後述の設定でdefault_install_hook_typesにpre-pushも指定しているためです。--install-hooksは各フックの環境作りもこの時点で済ませるオプションで、最初のコミットが待たされません。pre-commit installはcloneしたリポジトリごとに1回必要で、これを忘れたメンバーの手元ではチェックが動かない点は覚えておきます(そのためにCIでも同じチェックを走らせます)。
.pre-commit-config.yamlの書き方
# .pre-commit-config.yaml
default_install_hook_types: [pre-commit, pre-push]
default_stages: [pre-commit]
exclude: ^vendor/
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-added-large-files
args: ["--maxkb=500"]
- id: check-merge-conflict
- id: detect-private-key
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.16.8
hooks:
- id: ruff-check
args: [--fix]
- id: ruff-format
- repo: https://github.com/shellcheck-py/shellcheck-py
rev: v0.11.0.1
hooks:
- id: shellcheck
- repo: https://github.com/gitleaks/gitleaks
rev: v8.30.1
hooks:
- id: gitleaks
- repo: local
hooks:
- id: pytest
name: pytest (push前のみ)
entry: python -m pytest -q
language: system
pass_filenames: false
always_run: true
stages: [pre-push]reposの各要素は「どのリポジトリの、どの版(rev)の、どのフック(id)を使うか」です。revはタグかコミットのハッシュで固定し、同じ設定ならメンバー全員が同じ版のツールで検査します(版はすべて執筆時点の最新)。最初のリポジトリ(pre-commit-hooks)の6つは言語を問わない基本的な検査で、check-added-large-filesは500KBを超えるファイルの追加を止めます。ruffはPythonの静的解析とフォーマット、shellcheckはシェルスクリプトの検査、gitleaksはトークンなどの秘密情報の検出を受け持ちます。
最後のrepo: localは、リポジトリの中や手元にあるコマンドをそのままフックにする書き方です。テストのように時間がかかる処理は、stages: [pre-push]でpushの前だけに回し、コミットのたびには走らせません。language: systemは、pre-commit 4.4以降ではunsupportedという名前に改められ、旧名は内部で読み替えられます。aptの3.6.2のような古い版が混ざるチームではsystemのまま、全員が4.4以上ならunsupportedと書きます。
default_stagesを書かないと、stagesを指定していないフックは既定ですべてのステージで動き、pushのたびにも全チェックが走ります。ただしcheck-added-large-filesのように、フック側で対象ステージを定義しているものはdefault_stages: [pre-commit]でも上書きされず、検証ではpush時にも実行されました。
コミットが止まったときの流れ
わざと問題を仕込んだファイル(使っていないimport、行末の空白、末尾に改行のないREADME)をコミットすると、次のように止まります。
git add .
git commit -m "バックアップスクリプトを追加"
git add -u
git commit -m "バックアップスクリプトを追加"trim trailing whitespace.................................................Failed
- hook id: trailing-whitespace
- exit code: 1
- files were modified by this hook
Fixing backup.py
fix end of files.........................................................Failed
- hook id: end-of-file-fixer
- exit code: 1
- files were modified by this hook
Fixing README.md
check yaml...............................................................Passed
check for added large files..............................................Passed
check for merge conflicts................................................Passed
detect private key.......................................................Passed
ruff check...............................................................Failed
- hook id: ruff-check
- files were modified by this hook
Found 2 errors (2 fixed, 0 remaining).
ruff format..............................................................Failed
- hook id: ruff-format
- files were modified by this hook
1 file reformatted, 2 files left unchanged
shellcheck...........................................(no files to check)Skipped
Detect hardcoded secrets.................................................Passedfiles were modified by this hookは、フックが問題を自動で直したという意味です。直したのは作業ツリーのファイルで、コミットに入るインデックスは直す前のままなので、git add -uで直した内容をステージし直してからもう一度コミットします。2回目は全フックがPassedになり、[main (root-commit) 1126a6d] バックアップスクリプトを追加とコミットが作られました。
git add -uは、フックが直したファイルだけでなく、Gitで管理しているファイルの未ステージの変更をすべてステージします。コミットに入れるつもりのなかった編集途中の変更まで一緒に入るので、別の作業を抱えているときはgit diffで差分を確かめ、フックが直したファイルだけを名前を指定してgit addします。
自動で直せない問題は、内容を読んで手で直します。YAMLの括弧の閉じ忘れはcheck-yamlがexpected ',' or ']', but got '<stream end>'と行番号つきで止め、シェルスクリプトのrsync -a $TARGET /backup/はshellcheckがSC2086 (info): Double quote to prevent globbing and word splitting.を示し、"$TARGET"と書き直す案まで表示しました。
秘密情報の検出:gitleaksを置くステージに注意
GitHubのトークンの形をした文字列を含むファイルをコミットしようとすると、gitleaksが値を伏せ字にしたうえで止めました。
Detect hardcoded secrets.................................................Failed
- hook id: gitleaks
- exit code: 1
Finding: github_token = "REDACTED
Secret: REDACTED
RuleID: github-pat
Entropy: 4.634184
File: settings.ini
Line: 1
Fingerprint: settings.ini:github-pat:1このフックの中身はgitleaks git --pre-commit --redact --staged --verboseで、--stagedのとおりステージされた変更だけを調べます。最初は「pushの前にだけ走らせれば十分」と考えてstages: [pre-push]を付けたところ、トークン入りのファイルがそのままPassedでpushされました。push時点ではステージされた変更が何もないためです。gitleaksは必ずpre-commitステージで動かします。
もう1つの注意は初回の重さです。gitleaksのフックはGo製なので、pre-commitはGoのツールチェーンごと環境を作ります。検証では、この環境作りを含む最初のpushに約44秒かかり、gitleaksを含む全フックの環境を作ったあとの~/.cache/pre-commitは421MBになりました。ディスクや時間が気になる環境では、gitleaksのリポジトリが用意している別のフックID、gitleaks-system(インストール済みのgitleaksを使う)やgitleaks-docker(コンテナで実行する)に切り替えられます。
一時的に止める・一部だけ動かす
SKIP=trailing-whitespace,ruff-format git commit -m "作業途中" # 指定したフックだけ飛ばす
git commit --no-verify -m "緊急の修正" # フックをすべて飛ばす
pre-commit run --all-files # 全ファイルを検査
pre-commit run shellcheck --files backup.sh # 1つのフックを1ファイルにSKIPに書いたフックは出力でSkippedと表示され、残りは通常どおり動きます。--no-verifyはpre-commitに限らずGitのフックをすべて飛ばすので、検証でもフックの出力なしにコミットが作られました。どちらを使っても、CIで同じチェックを走らせていれば問題はプルリクエストの段階で見つかります。ローカルで飛ばすことを禁止するより、CIで必ず捕まえる設計のほうが現実的です。
CIでも同じチェックを走らせる:GitHub Actions
pre-commit installをし忘れたメンバーや、--no-verifyでのコミットに備えて、プルリクエストでは全ファイルを検査します。~/.cache/pre-commitを設定ファイルのハッシュをキーにしてキャッシュすると、2回目以降は環境作りを省けます。ワークフローの基本はGitHub Actionsで始めるCI/CDを参照してください(アクションの版は執筆時点の最新)。
name: pre-commit
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
jobs:
pre-commit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: "3.12"
- uses: actions/cache@v6
with:
path: ~/.cache/pre-commit
key: pre-commit-${{ hashFiles('.pre-commit-config.yaml') }}
- run: pip install pre-commit
- run: pre-commit run --all-files --show-diff-on-failure--show-diff-on-failureは、フックが自動修正した差分をログに出すオプションです。検証ではAll changes made by hooks:の見出しに続けてgit diff形式の差分が表示されたので、CIのログを見るだけで何を直せばよいかがわかります。pre-commit runは既定でpre-commitステージのフックだけを動かすため、pushの前だけに回したpytestはここでは実行されません。テストはCIの別のジョブで走らせます。
運用の注意:autoupdate・huskyとの衝突・別実装
pre-commit autoupdateは、各リポジトリの既定ブランチからたどれる最新のタグへrevを書き換えます。リリース一覧の最新と一致するとは限らず、検証ではgitleaksがupdating v8.30.1 -> v8.30.0と古い版へ、shellcheck-pyがv0.11.0.1 -> v0.11.0.1-1へ書き換えられました。autoupdateの結果はそのままコミットせず、差分を読んでから取り込みます。--freezeを付けると、タグ名の代わりにコミットのハッシュで固定されます。
huskyなどがcore.hooksPathを設定しているリポジトリでは、pre-commit installが[ERROR] Cowardly refusing to install hooks with `core.hooksPath` set.で拒否されます。どちらか一方にまとめるのが基本で、Node.jsのプロジェクトでhuskyを残すなら、huskyのフックからpre-commit runを呼ぶ形にします。なお、執筆時点ではRustで書き直された互換実装prekも登場しており、検証では同じ.pre-commit-config.yamlのままprek installとprek run --all-filesが動きました。
つまずきポイント
| 症状・エラーメッセージ | 原因 | 対処 |
|---|---|---|
files were modified by this hookが出て、コミットが作られない | フックが作業ツリーのファイルを直したが、インデックスは直す前のまま | 差分を確認してgit add -uし、もう一度コミットする |
[ERROR] Cowardly refusing to install hooks with `core.hooksPath` set. | huskyなどがGitのフックの置き場所を変えている | ツールを1つにまとめる。寄せる先がpre-commitならgit config --unset-all core.hooksPath |
トークン入りのファイルがgitleaksをPassedで通過した | gitleaksをstages: [pre-push]で動かしていた。ステージされた変更しか見ない | stagesを外し、pre-commitステージで動かす |
初回のコミットで[INFO] This may take a few minutes...のまま長く待たされる | フックごとの環境作り(gitleaksならGoの取得)が走っている | pre-commit install --install-hooksで先に済ませる。重いフックはgitleaks-systemなどに切り替える |
動作確認
設定を変えたら、全ファイルに対して一度走らせ、すべてPassed(対象ファイルがないものはSkipped)になることを確かめます。フックの起動用スクリプトが置かれているかも見ておきます。
$ pre-commit run --all-files
trim trailing whitespace.................................................Passed
fix end of files.........................................................Passed
check yaml...............................................................Passed
check for added large files..............................................Passed
check for merge conflicts................................................Passed
detect private key.......................................................Passed
ruff check...............................................................Passed
ruff format..............................................................Passed
shellcheck...............................................................Passed
Detect hardcoded secrets.................................................Passed
$ head -2 .git/hooks/pre-commit
#!/usr/bin/env bash
# File generated by pre-commit: https://pre-commit.comよくある質問
Pythonのpre-commitとは?
Pythonで書かれたGitフックの管理ツールで、pip install pre-commitやuv tool install pre-commitで入ります。本体はPython製ですが、フックはPython専用ではなく、Node.js・Go・Rustなどのツールもそれぞれの実行環境を自動で用意して動かせるので、シェルスクリプトやTerraformのリポジトリでも使えます。
Gitでpre-commitを無視するにはどうすればいいですか?
その1回だけすべてのフックを飛ばすならgit commit --no-verify(短縮形は-n)、特定のフックだけ飛ばすならSKIP=フックのID git commitを使います。恒久的にやめるならpre-commit uninstallです。飛ばしたチェックはCIで同じものを走らせて拾う前提で使ってください。
Precommit installとは?
リポジトリの.git/hooks/pre-commitに、pre-commitを呼び出す起動用スクリプトを書き込むコマンドです。.git/はGitで共有されないため、cloneするたびに各自が1回実行する必要があります。各フックの実行環境の準備まで同時に済ませたいときは--install-hooksを付けます。
Githubのpre-commitとは何ですか?
GitHub側にpre-commitの機能があるわけではなく、フックは各自のPCでコミットするときに動きます。GitHubで同じチェックを強制したい場合は、GitHub Actionsでpre-commit run --all-filesを実行し、ブランチ保護でそのジョブの成功をマージの条件にします。ホスト型のサービスとしてpre-commit.ciもあります。
まとめ
pre-commitは、設定ファイルをコミットし、各自がpre-commit install --install-hooksを1回実行するだけで、全員に同じ版のチェックを配れます。自動修正のあとはgit add -uで取り込み直し、テストのような重い処理はpre-push、gitleaksは必ずpre-commitに置きます。ローカルで飛ばされても困らないよう、CIで--all-filesの検査を必須にしておくのが仕上げです。
次に読む
- Gitブランチ戦略の選び方:CIのチェックをマージの条件にするブランチ保護の考え方
- git rebaseの使い方とreset・revertによる取り消し:フックで止まったコミットを整理し直す