ハクソク

世界を動かす技術を、日本語で。

プリコミットフックが壊れています

概要

  • Rustプロジェクトの開始からpre-commitフックの作成までの流れ
  • pre-commitフックが抱える根本的な問題点の指摘
  • pre-pushフックの推奨理由とその運用ポイント
  • 具体的なフックスクリプトの改良例
  • フック導入時の注意点とベストプラクティス

Rustプロジェクトの初期セットアップとフォーマット管理

  • 新規ディレクトリbest-fizzbuzz-ever作成、main.rsファイル作成
  • Gitリポジトリの初期化とmain.rsのコミット
  • 他言語FizzBuzz集への追加時、コードフォーマット統一の要求
  • rustfmtによるフォーマットチェック用pre-commitフック導入
    • 例:
      #!/bin/sh
      set -eu
      for f in *.rs; do rustfmt --check "$f"; done
      
  • ステージングされていない変更をフックで検出できない問題発生

pre-commitフックの改良と限界

  • インデックス上のファイルを一時ディレクトリに展開しチェックする方式へ改良
    • 例:
      tmpdir=$(mktemp -d --tmpdir "$(basename "$(realpath .)")-pre-commit.XXXX")
      trap 'rm -r "$tmpdir"' EXIT
      git checkout-index --all --prefix="$tmpdir/"
      for f in $tmpdir/*.rs; do rustfmt --check "$f"; done
      
  • 変更ファイルのみチェックするようさらに改良
    • 例:
      files=$(git diff --name-only --cached --no-ext-diff --diff-filter=d)
      ...
      
  • 既存コードが未整形だとコミットできない問題
  • rebase時Rustファイルがないコミットでフックが失敗するケース

pre-commitフックの根本的な問題

  • 他人のブランチ古いフックには適用できない
  • rebasemerge時に意図せずフックが走る
  • コミット単位での品質担保が困難
  • CIでの検証と異なり、ローカルのみで完結してしまう
  • 作業効率低下ワークフロー破壊のリスク
  • 作業ツリー全体のチェックは大規模リポジトリで非現実的

pre-pushフック推奨理由と運用のポイント

  • pre-pushフックなら、push前にまとめてチェック可能
  • インデックス上のファイルを対象にすることで、一貫性確保
  • 高速・信頼性重視のチェックのみ実装推奨
    • ネットワークアクセスやビルド必須のチェックは非推奨
  • 静かな出力でユーザー体験向上
  • 自動セットアップ禁止手動設定手順をドキュメント化
  • コミット前フックは機密情報(例: 認証情報)流出防止用途に限定

pre-pushフック作成時の具体的アドバイス

  • インデックス上のファイルでのみチェック実施
  • 変更ファイル検出は信頼できないため、明示的なファイル指定が望ましい
  • CONTRIBUTING.md等に手動導入手順を明記
  • pre-commitフックは原則使用しない方針

まとめ

  • pre-commitフックは多くの欠点があり、pre-pushフックの使用が推奨
  • 品質担保はCIやpre-pushフックで実施
  • 認証情報漏洩防止以外でpre-commitフックは避けるべき
  • 運用ドキュメントの整備が重要

Hackerたちの意見

これは本当に面白い読み物だった。開発者のためにプレコミットワークフローを設定している人や、すでに維持している人にはぜひおすすめしたい。もう一つ付け加えたいことがあるんだけど、大きな組織では、開発者が予測できない方法でツールを使うことがあるんだよね。Gitもその一つ。特定のワークフローを強制しようとしない方がいいよ、必須のフックや自動的に有効になるフックも含めてね。代わりに、オプションのプレプッシュフックに必要なことを書いて、プルリクエストチェッカーの早いCI/CDステップにも入れておくといいよ。最終的には同じ結果が得られるし、面倒くさい開発者もハッピーになるはず。
それに同意するよ。複数のコミットがある場合は、https://github.com/tummychow/git-absorb が便利で、すでに行われたコミットにフォーマット変更を追加できる。
> これにはGitも含まれる。特定のワークフローを強制しようとしない方がいい、必須のフックや自動的に有効になるフックも含めてね。そして、Gitでは、開発マシンで起こることを必須にすることもできる。必須にしたいことはすべてCIに入れなきゃダメ。プレコミットやプレプッシュフックは、CIの無駄を減らすためにあるだけで、何かを保証するためではないよ。(秘密を誤ってプッシュする人を除いてね。CIでは遅すぎるし、プレプッシュフックは良いアイデアだよ。)
> もう一つ言いたいことがあるんだけど、大きな組織では、開発者が予測できない方法でツールを使うことがある。これにはGitも含まれる。特定のワークフローを強制しようとしない方がいいよ、必須のフックや自動で有効にするフックも含めてね。もし強制すると、組織にとって多くの痛みを避けられるから。フォーマットスタイルを強制するのと同じで、誰かが好きなようにやるのを許す方がいい。うまくいかない部分があれば話し合って変更することはできるけど、一貫性があれば失敗が減るからね。
これがその例だよ: https://www.jj-vcs.dev/
現在のプロジェクトで変なことが起きてる。たまにメインを自分のブランチにマージすると失敗するんだ。失敗するのはマージコミットのpre-commitフック。メインの変更がリントチェックで引っかかるんだけど、なぜかそれがメインに入ってしまう。だから、PRのチェックはpre-commitフックのチェックほど厳しくないみたい。結果的に、多くの開発者が`--no-verify`でコミットすることに慣れてしまった。そうなると、pre-commitフックの意味ってなんなんだろう?進行中の作業をコミットしたいときもあるし、そうすれば変更を戻しやすくなるんだよね。このチェックはpre-pushの方がいいし、PRパイプラインにも絶対に必要だと思う。そうしないと、スキップされちゃうから。とにかく、これを主張するための材料をくれてありがとう。
両方の良いところを取り入れたらどう?クライアントサイドのバリデーションにはpre-commitフックを使って、CIでも同じチェックを実行するっていうの。自分はこの設定を何年も問題なく使ってる。自分の設定の重要な要件は、すべてのフックがヘルメティックで冪等性があること。生産環境でRustは使ってないから、詳しくは言えないけど、他のほとんどの言語—clang-formatからswift-formatまで—では、信頼できるソース(例えば、チームのS3ストレージ)から事前コンパイルされたバイナリをダウンロードするようにしてる。これで、ツールが制御された環境で動作して、一貫して同じ結果を出すことができるんだ。
私はいつも全然違うやり方で仕事するタイプの開発者なんだ。プレコミットフックが大嫌いで、プレプッシュと早めのステップをCIにするのが正しいやり方だと思う。
フックが単なる早いチェック以上のことをし始めると、似たような問題を見たことがある。状態を持ったり外部のコンテキストに依存する瞬間、ガードレールとしての役割を果たさなくなって、摩擦の原因になっちゃうんだよね。実際には、つまらなくて決定論的なものを保つ方が、すべてを早期にキャッチするよりも重要な気がする。
これに関しては、問題のないより良いgitコマンドを見つけた気がする。完璧ではないけど、私には合ってる。プレコミットスクリプト(https://github.com/ThomasHabets/rustradio/blob/main/extra/pr...)が実行されて、プレコミット環境をこうやって設定する: https://github.com/ThomasHabets/rustradio/blob/main/tickbox/... これを毎回のコミットで実行してる。確かにやりすぎかもしれないけど、問題を防いでくれてるし、壊れたHEADがないことにこだわりすぎかも。でも、貢献したいなら、プレコミットを実行する必要はないよ。PRでも同じように動くから。自分でPRを送ることはないから、これでうまくいってる。もちろん、ワークフローを改善するための提案や批評はいつでも歓迎だよ。少なくとも何も状態を持ってないし(ビルドアーティファクトはキャッシュするけど)、"cargo deny"以外は外部依存もない。
小さな提案だけど、git worktreesは最近の追加機能で、あなたのgitアーカイブの設定よりも良いかもしれないよ。
ワークフローのハッピーパスから外れると、フックが単純な環境変数を観察できないの?例えば、`GIT_HOOK_BYEBYE` = フックスクリプト内で早期リターン。この記事はちょっとドラマチックすぎる気がする。自分自身にとって面倒なプレコミットフックを書いたら、それが根本的に壊れてるってどういうこと?
jujutsuを挙げると、`jj fix`(https://docs.jj-vcs.dev/latest/cli-reference/#jj-fix)はコミットのフォーマットを確保するためのより洗練された方法だよ。これは、stdinのdiffを使ってフォーマットコマンドを実行し、stdoutに出力された結果を使うんだ。マージやリベースの履歴を簡素化して、すべてのコミットがフォーマットされた状態を保つことができる(だから、新しいフォーマットオプションを有効にすると、可変セット内で特別なフォーマット/スタイル修正コミットが必要なくなる)。jj fixを使った後はプレコミットフックに戻るのが難しいし(jjを使った後はgitを使うのも難しい;)
jj fixのことを言いたくて来たんだけど、基本的にもっとエレガントなやり方なんだよね。
現在の欠点は(いつか修正されるって言われてるけど)、修正したい各コミットに対して静的解析を実行できないことなんだ。僕のgit rebaseのワークフローでは、よく`git rebase -x "cargo clippy -- --deny=warnings"`を使うんだけど、これにはフルチェックアウトが必要で、単一ファイルの入力じゃダメなんだよね。
いくつかのプロジェクトでローカルでテストを実行すると、自動的にプリコミットフックがインストールされることがあって、それのせいで戦争犯罪を犯したくなったことがある。そんなことしちゃダメ、ほんとに。
前の職場では、すべてのテストやリント、フォーマットをpre-commitフックで実行してたんだ。どうやら、5人の開発者がCIを設定せずにマスターに直接プッシュしたいっていう歴史的な名残らしい。自分も戦争犯罪者になりかけてたよ。
プリコミットフックが使われているプロジェクトでは、まず最初にそれを無効にするんだ。コードが自分のラインの側にあるときに何をするかは、君の知ったことじゃない。
反対側の意見には賛成だよ!使うときはprecommitが結構好きなんだけど、自分のプロジェクトには一度も強制したことがないんだ。たまにコミットする人もいるし、その後にまとめて一つにするのもあるから、他人のワークフローをそこまで壊すのはちょっと気が引ける。自分はほぼ毎回「このCI/CDはマージするために通過しなきゃダメ」っていうジョブを持ってて、リントとかも含めて、マージするときは専らスカッシュコミットを使ってる。
すべてのファイルにtype: ignoreを追加するの?この前、同僚がそれをやってて、どう返事しようか考えてるところ。
コードベースの整合性を維持するためにフックに頼るべきじゃないし、ここで見た限りでは`pre-commit`(もっと言うと、https://pre-commit.com/ツール)を避けたい理由は見当たらないよ。CIがコミットが受け入れられるかどうかの真実の源であるべきなんだ。もし`pre-commit`ツールを使っているなら、単なるフックじゃなくて、https://github.com/andrewaylett/pre-commit-actionみたいなものを使ってCIでツールを実行することもできるよ。これによって、ローカル開発とCIの間でチェック定義を共有できるから、チェックをパイプラインの早い段階に移したことになる。僕は日常的にJujutsuを使ってるけど、プリコミットフックはサポートしてないんだ。でもツールは本当に役立つし、CIでそれを実行することで、すべての開発者がフックを設定していることに頼らなくて済むんだ。JJワークフローでプリコミットを本当に便利にするJJエイリアスも持ってるよ: https://github.com/andrewaylett/dotfiles/blob/7a79cf166d1e7b...
プリコミットは、開発者がCIでのプリフライトチェックが通る自信を得るための便利なものなんだ。自動化しようとすると、非自明なGit機能と相互作用したときに痛い目にあうだけだってわかったよ。フォーマットやリンティングをしたいときに、ちょっとしたプロジェクト特有のスクリプトを実行する方がずっと幸せだね。
投稿にある例は、ちょっとひどいと思う。Lefthookを使ってglob+stage_fixedでフォーマッターを設定すると、指摘されている問題の一つが完全に解決されるんだよね。来週あたりに詳しく書いた記事をアップするかも。趣味のプロジェクトでこれに取り組んでるから、もう1年くらい経つかな。
自分の投稿はここで見つけられるよ: https://news.ycombinator.com/item?id=46409443
自分はpre-commitツールと今はprekを使ってるけど、10年近く使ってて、リベースフローを専ら使っててもこんな問題は一度もなかった。これはオペレーターエラーじゃないかな。