グリーンはマージ後もグリーンのままか
一言でいうと
ブランチでは緑だったのに、マージした途端に赤になることは、珍しくありません。ブランチの検査が見たのは、自分の変更にそのときのトランクを足したものであって、マージされたあとのトランクではないからです。このギャップを縮めるのが、短命なブランチとサーバー側のルールの役割です。
なぜ必要なのか
トランクベース開発は、「開発者がトランクと呼ばれる1つのブランチで協働し、ほかの長命な開発ブランチを作ろうとする圧力に抵抗するソース管理モデル」と定義されます。短命なブランチは、コードレビューとビルドの検査のために使いますが、すぐに消えることを前提にします。リリースブランチも、必要なときにトランクから切って使い、リリースのあと、しばらくして削除します。リリースに付けるバージョン名にも、同じ性質が求められます。セマンティックバージョニングの規約は、一度リリースされたバージョンの内容を変更してはならず、直すべきものがあれば新しいバージョンとして出さなければならないと明記しています。すでに出たものに手を加えないほうが、元に戻す対象を残します。
なぜ長命なブランチを避けるのでしょうか。ブランチが長くなるほど、トランクとの距離が開き、開いた距離は、マージするときにまとめて請求されます。そして、その請求書で怖いのは、テキストのコンフリクトではありません。テキストのコンフリクトは、ツールが教えてくれます。怖いのは意味のコンフリクトです。自分がブランチで使っていた関数の動作を、ほかの人がトランクで変更したなら、2つの変更は別々の行にあるので、きれいにマージされて、結果だけが間違います。バージョン管理ツールは、これを検知できません。
フィードバックの観点でも、答えは同じです。統合が先送りされるほど、問題に気づくのが遅れ、遅く気づくほど、原因の候補が増えます。「どんなコードも、2、3時間を超えて統合されないままにしない」というケント・ベックの文が、目標の線を最も短く示しています。
ブランチ保護は、サーバー側のルールでなければならない
ルールを人の約束にしておくと、守られません。忙しい日、障害対応の最中、新しく来た人の最初の週に、崩れます。そのため、ルールはプッシュする側ではなく、受け取る側に置きます。
git自体が、この区別を持っています。フックは、既定では$GIT_DIR/hooksにあり、クローンでついてきません。git initがテンプレートをコピーすることはできますが、自分が作ったpre-commitフックが、同僚のコピーに自然にできるわけではありません。つまり、ローカルのフックは便宜のための仕組みであり、強制する手段ではありません。強制は、サーバー側のフックが行います。
pre-receiveは、受け取る作業全体に対して1回実行されます。0以外の値で終了すると、どのリファレンスも更新されません。プッシュがまるごと拒否されます。updateは、更新するリファレンスごとに1回ずつ実行されます。0以外なら、そのリファレンスだけが更新されません。post-receiveは、更新が終わったあとに実行されます。通知や後続の作業のためのものです。
ホスティングサービスのブランチ保護も、同じ場所の機能です。GitHubの保護ルールには、マージ前にプルリクエストを必須にする設定、必須ステータスチェック、会話の解決を必須にする設定、署名付きコミットを必須にする設定、直線状の履歴を必須にする設定、マージキューを必須にする設定、デプロイの成功を必須にする設定、ブランチのロック、プッシュできる人の制限、強制プッシュの許可、削除の許可があります。
ここで、必ず知っておくべき既定値が1つあります。保護ルールは、既定では管理者に適用されません。ドキュメントは、リポジトリの管理者権限や保護のバイパス権限を持つ人には、制約が適用されないと書き、「上記の設定のバイパスを許可しない」を有効にすると、管理者にも適用されると案内しています。ルールを有効にして安心していたのに、最も危険な変更がそのルールを素通りしていく事態が、ここで起きます。
緑だったのに、マージ後に赤になる問題
必須ステータスチェックを有効にしても、ギャップは残ります。ブランチAとブランチBが、それぞれトランクを基準に緑だったのに、Aを先にマージすると、Bの検査結果はもう現在のトランクに対するものではありません。そのままマージすると、トランクが壊れます。
解決の方向は2つです。
- マージ直前に、最新のトランクを反映して再検査します。「ブランチを最新の状態に保つ」を必須にする設定が、これです。単純ですが、マージが頻繁だと、再検査している間に別のマージが起きて、ずっと遅れ続けます。
- マージキューを使います。マージするものを列に並べ、マージされた状態を先に作って検査し、通ったものだけをトランクに入れます。複数のものをまとめて一度に検査し、失敗したら原因になったものだけを外して再度試す方式なので、列が長くても検査の回数が爆発しません。GitLabのマージトレインも、同じ問題を解く機能です。
長いブランチの代わりに、フィーチャーフラグ
「この機能はまだ完成していないので、マージできない」が、長命なブランチのよくある理由です。答えは、完成していないものをトランクに入れても、有効にしないことです。フィーチャーフラグと、抽象化によるブランチ(branch by abstraction)が、その方法です。未完成のコードがトランクにありながら、ユーザーには見えないので、統合は毎日行いつつ、公開の時点は別に決められます。
その代わり、フラグにはコストがあります。フラグが増えると、実際に動く組み合わせが増え、テストがカバーできない経路が生まれます。フラグを作るときに、削除する時点を一緒に決めておくことが、唯一通用する管理方法です。
現場での姿
- 「急いでいたので直接プッシュした」という事件の半分は、管理者のアカウントから出ています。バイパスの許可をオフにしていないからです。
- 必須ステータスチェックの名前を変えたのに、保護ルールの名前はそのままで、どんな検査も要求しない状態のまま数週間が過ぎます。ルールが実際に止めるかどうかは、自分で試してみる以外に確認する方法がありません。
- 直線状の履歴を必須にすると、元に戻す作業と二分探索が楽になります。マージコミットが絡み合った履歴で原因のコミットを絞り込むのは、目に見えて面倒です。
- ブランチの寿命を測り始めると、たいてい、数日間のブランチが問題の大部分を作っていることがわかります。
参考
- トランクベース開発: https://trunkbaseddevelopment.com/
- ブランチ保護: https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches
- マージキュー: https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/configuring-pull-request-merges/managing-a-merge-queue
- GitLabのマージトレイン: https://docs.gitlab.com/ci/pipelines/merge_trains/
- githooks: https://git-scm.com/docs/githooks
- 継続的インテグレーション: https://martinfowler.com/articles/continuousIntegration.html
- 抽象化によるブランチ: https://martinfowler.com/bliki/BranchByAbstraction.html
- セマンティックバージョニング: https://semver.org/
次のラボですること
ローカルにベアリポジトリを作って、受け取る側のルールを自分で作ります。pre-receiveとupdateのフックを使って、保護ブランチへの直接プッシュを防ぎ、特定のリファレンスだけを拒否するのと、プッシュ全体を拒否するのとの違いを、終了コードで確認します。続いて、ローカルのフックを作っておいて、クローンしたコピーにそのフックがついてこないことを目で見ます。必須チェックの模倣は、コミットに付けた検査結果をフックが確認する方式で作り、意味のコンフリクトは、2つのブランチがそれぞれ通ったあとにマージしたら壊れる例で再現します。最後に、マージ前にトランクを反映して再検査するキューの模倣をスクリプトで作り、列が長くなるときに検査の回数がどう変わるかを数えます。