チームでのアプリ開発におけるブランチ戦略とプルリクエストのマージ方法
弊社では、主にFlutterでアプリ開発を行なっていますが、そのほとんどは数名からなるチーム開発です。
3人前後のチームで開発を行う場合、どのようなブランチ戦略を取るのが良いのか、また、どのようなマージの仕方をするのが良いのか、悩むところです。
今回は、弊社で採用しているチーム開発でのブランチ戦略とマージの仕方を、運用してみて感じたメリットとともに紹介します。
TL;DR
-
develop
ブランチは不要だった - フィーチャーフラグを使用して、
main
ブランチをリリース可能状態に保っている - プルリクエストのマージには
Squash and Merge
を使用している- プルリクエストのコミットにはこだわらずにすむ
-
main
ブランチのコミット履歴がPR単位になり見やすい
ブランチ戦略
弊社では以前、ほぼGit-Flowを使用し、 main (master)
, develop
, feature/*
, release/*
, hotfix/*
ブランチで運用していました。
しかし、少人数の開発チームとしては過剰に複雑でした。
現在は GitHub Flow に近い、main
, feature/*
, release/*
の3つのブランチのみを使用した比較的シンプルなワークフローを採用しています。
以下に各ブランチの用途を記載します。
feature
ブランチ
最新の main
ブランチから派生させるブランチです。
対応するIssueが分かりやすいように、ブランチ名にはIssue番号を含めるようにしています。
例: 100-add-chat-message
プルリクエストを経て、 main
ブランチにマージされます。
新機能の追加や、不具合の修正やリファクタリングなどは全てfeatureブランチで行われます。
機能追加・不具合修正・リファクタリングなどの実装種類によって細かくブランチを分けることはしていません。
分けるメリットが、都度考える手間を上回れないと判断したためです。
release/*
ブランチ
最新の main
ブランチから派生させるブランチです。
例: release/1.0.0
プルリクエストを経て、 main
ブランチにマージされます。
アプリの新バージョンや新ビルドを配信するためにバージョン・ビルド番号を更新するためのブランチです。
弊社では、GitHub Actionsを使用し、リリースブランチの作成は自動化しています。
このリリースブランチがマージされると、GitHub Actionsによって自動的に main
ブランチに Tag
が打たれるようにしています。
例: v1.0.0
main
ブランチ
デプロイ(リリース)可能状態を保つ、主となり基準となるブランチです。
チーム各員の feature
ブランチの成果や release
ブランチがマージされ、集約されます。
main
ブランチのコミット履歴は、プルリクエスト単位のため、ざっと見てもどのような変更があったのかがとても分かりやすいです。
複数のプルリクエストにまたがる新機能の開発はどうするのか?
main
ブランチはデプロイ可能状態を保ちます。
では、複数のプルリクエストにまたがるような、小さくない新機能の開発はどのように行うのでしょうか?
以下の2通りが考えられると思います。
- 新機能の開発がデプロイ可能状態になるまで、
feature
ブランチを入れ子にする - フィーチャーフラグを使って新機能の公開を制御する
前者は親の feature
ブランチを作って、そのブランチに対して複数のブランチをマージし、最終的に親ブランチを main
ブランチにマージするという方法です。
この方法は、以下のつらみがありました。
- 最後の、親featureブランチから
main
ブランチへの差分が大きくなってしまう - 親featureブランチに、定期的に
main
ブランチをマージする等しなければ古い状態になってコンフリクトの発生が大きくなる可能性がある -
main
ブランチにマージされるまで、新機能をテスト配信アプリで確認できない(featureブランチでビルドしてアップロードすれば可能)
そこで、弊社では後者のフィーチャーフラグを使用する方法を採用しています。
弊社では、必ず本番環境・ステージング環境(+開発環境)を分けています。
開発段階では、ステージング環境やデバッグ環境ではフィーチャーフラグがONになるようにし、本番環境ではOFFになるようにしています。
こうすることで、新機能の開発は、 main
ブランチにマージされても、本番アプリに反映されず、いつでもリリース可能な状態を保てています。
開発環境やステージングアプリでは、実装途中ではあるものの、マージされた新機能を確認することができます。
また、フィーチャーフラグには以下のメリットもあります。
- 段階的リリース:新機能を一部のユーザーにだけ公開したり、徐々に対象を増やすことができる
- ロールバック:新機能の不具合が発覚した場合、フィーチャーフラグをOFFにすることで、公開アプリから新機能を無効化できる
プルリクエストのマージ方法
GitHubでは、プルリクエストのマージ方法として、以下の3つがあります。
Create a merge commit
Squash and merge
Rebase and merge
弊社では、プルリクエストのマージは Squash and merge
を使用しています。
(プルリクエストの向き: main
<- feature/*
, main
<- release/*
)
Squash and merge
を使用すると、マージコミットが main
に作成されません。
また、デフォルトでは、プルリクエストのタイトルがコミットメッセージになります。
そのため、 main
ブランチのコミット履歴は、プルリクエスト単位になります。
プルリクエストに含まれる複数コミットのメッセージは、コミットメッセージに残り、 Co-authored-by
もきちんと残るので心配ありません。
mainブランチの1コミット例
上記の画像は、プルリクエスト(コミット5つ)が main
ブランチに Squash and merge
されたものです。
プルリクエストのタイトル「build: Flutter 3.13」がコミットメッセージの1行目になっています。
(#234)はプルリクエスト番号とリンクです。
5つのコミットメッセージも「*」から始まる行で残っています。
また、 Co-authored-by
も残っています。
この例では、追加コミットを行なってくれた naipaka
さんが共同作者として、きちんと含まれていることが分かります。
プルリクエスト作成中のコミット
みなさん、プルリクエストを作成するまでの、あるいは作成した後のコミットはどうしていますか?
コミット1つですます人、細かくコミットする人、様々だと思います。
前述の通り、弊社では Squash and Merge
を使用しているため、最終的に main
ブランチにマージされるコミットは1つです。
そのため、プルリクエスト作成中のコミットは重要視していません。
もちろん、コミットを意味のある程度に細かく刻んだ方が、レビュワーにとっては多少意図が汲み取りやすくレビューしやすくなる利点はあります。
ただ、コミットや差分ファイルが多すぎる場合は、そもそもプルリクエストを分けた方が良さそうです。
feature
ブランチ単位のコミット粒度は手癖もあると思うので、弊社ではルール化はしていません。
では、コンフリクト(競合)の解消や、ベースブランチである main
を feature
ブランチに取り込んで追従するときはどうでしょう?
GitHubでは、マージ先である main
ブランチに変更があった場合に以下のようなメッセージが表示されます。
"このブランチはベースブランチと古くなっています..."
ベースブランチである main
ブランチが更新されて、プルリクエストのブランチが古くなっていることを知らせてくれています。
このような場合、プルリクエストのブランチに main
ブランチの変更を取り込むことによって2つの利点があります。
- 最新の
main
ブランチでテストを行うことができる -
main
ブランチの履歴が一直線になる
また、プルリクエストで実装・修正している箇所と同じ場所の変更が main
ブランチにマージされるとコンフリクト(競合)が発生する可能性があります。
この場合、ベースブランチである main
ブランチの取り込みは必須となります。
プロジェクトやチームにより様々なルールがあると思いますが、弊社ではマージ前に main
ブランチを取り込むことをルールとしています。
みなさんは、コンフリクトの解消やベースブランチの取り込みは、どう行なっていますか?
feature
ブランチに main
ブランチをマージする、あるいは rebase
する方が多いのではないでしょうか?
↑以前とったアンケートです。母数が少ないですがこの時の結果では merge
が多いですね。
ベースブランチを取り込むにはRebaseかMergeか
Mergeと比べてRebaseするメリットとしては以下が挙げられるかと思います。
- コミット履歴がきれいになる
- マージコミットが残らないため、コミット履歴が汚れない・見やすくなる
しかし、 Squash and Merge
を使用している弊社では、1,2どちらもメリットにはなりません。
綺麗さよりも、実際の時間軸・作業内容を残すメリットを優先したいです。
そして、Rebaseには以下のようなデメリットもあります。
- コンフリクトが発生した場合、コミットごとにコンフリクトの解消を行わなければならない
- コミットが書き換えられ、force pushが必要になってしまう(歴史の改変)
1, コンフリクトが発生した場合、merge
では1つのコミットでコンフリクトを解消することができます。
しかし、rebase
ではコミットごとにコンフリクトを解消する必要があります。
人によって感じ方の違いはあるかもしれませんが、コンフリクトの解消は merge
の方が楽だと感じます。
merge
の場合は最終的な成果物を意識して1ファイルずつコンフリクトを解消していけば良いのですが、
rebase
の場合は複数コミットがあった場合、そのコミット時点での期待値を意識しなければならないからです。
なので、個人的にはコンフリクトの解消には merge
を使用しています。
2, Rebaseを行うと、自身が行ったコミットの前に変更が起こるため、コミットが書き換えられ、コミットハッシュが変更されてしまいます。
当然コミット日時も更新されます。
例えば、2日後にRebaseした場合、内容(差分)は同じなのに8月1日のコミットが8月3日のコミットとして更新されてしまいます。
git push
ではコミットできず、 git push --force
または git push --force-with-lease
が必要です。
また、元々のオリジナルのコミッターではなく、レビュワー等の第三者がRebaseを行った場合、
全てのコミットに2人分の名前が記載されます。
riscaitがコミット作成したブランチをnaipakaがRebaseした場合の例:
riscait authored and naipaka committed 2 days ago
まとめ
Squash and Merge
を使用することで、 main
ブランチのコミット履歴がプルリクエスト単位に見通しやすくなりました。
また、プルリクエストのコミット履歴にこだわる必要性が薄まりました。
その分、プルリクエストは開発者の裁量に任せることができ、開発スピードが上がりました。
開発者は慣れ親しんだ好みの方法でコミットやベースブランチの取り込み・コンフリクトの解消を行うことができます。
弊社では複数のプロジェクトでチーム開発を行なっており、日々改善を心掛けています。
今後もより良い方法を模索しながら取り入れていきたいと思います。
参考リンク
Discussion
良記事ありがとうございます!!