🐙

チームでのアプリ開発におけるブランチ戦略とプルリクエストのマージ方法

2023/08/27に公開
1

弊社では、主に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通りが考えられると思います。

  1. 新機能の開発がデプロイ可能状態になるまで、 feature ブランチを入れ子にする
  2. フィーチャーフラグを使って新機能の公開を制御する

前者は親の 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コミット例
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 ブランチ単位のコミット粒度は手癖もあると思うので、弊社ではルール化はしていません。


では、コンフリクト(競合)の解消や、ベースブランチである mainfeatureブランチに取り込んで追従するときはどうでしょう?

GitHubでは、マージ先である main ブランチに変更があった場合に以下のようなメッセージが表示されます。

This branch is out-of-date with the base branch\nMerge the latest changes from main into this branch.\nThis merge commit will be associated with ...
"このブランチはベースブランチと古くなっています..."

ベースブランチである main ブランチが更新されて、プルリクエストのブランチが古くなっていることを知らせてくれています。

このような場合、プルリクエストのブランチに main ブランチの変更を取り込むことによって2つの利点があります。

  1. 最新の main ブランチでテストを行うことができる
  2. main ブランチの履歴が一直線になる

また、プルリクエストで実装・修正している箇所と同じ場所の変更が main ブランチにマージされるとコンフリクト(競合)が発生する可能性があります。
この場合、ベースブランチである main ブランチの取り込みは必須となります。

プロジェクトやチームにより様々なルールがあると思いますが、弊社ではマージ前に main ブランチを取り込むことをルールとしています。

みなさんは、コンフリクトの解消やベースブランチの取り込みは、どう行なっていますか?

feature ブランチに main ブランチをマージする、あるいは rebase する方が多いのではないでしょうか?

https://twitter.com/riscait/status/1386122014440656898

↑以前とったアンケートです。母数が少ないですがこの時の結果では merge が多いですね。

ベースブランチを取り込むにはRebaseかMergeか

Mergeと比べてRebaseするメリットとしては以下が挙げられるかと思います。

  1. コミット履歴がきれいになる
  2. マージコミットが残らないため、コミット履歴が汚れない・見やすくなる

しかし、 Squash and Merge を使用している弊社では、1,2どちらもメリットにはなりません。
綺麗さよりも、実際の時間軸・作業内容を残すメリットを優先したいです。

そして、Rebaseには以下のようなデメリットもあります。

  1. コンフリクトが発生した場合、コミットごとにコンフリクトの解消を行わなければならない
  2. コミットが書き換えられ、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 ブランチのコミット履歴がプルリクエスト単位に見通しやすくなりました。

また、プルリクエストのコミット履歴にこだわる必要性が薄まりました。
その分、プルリクエストは開発者の裁量に任せることができ、開発スピードが上がりました。
開発者は慣れ親しんだ好みの方法でコミットやベースブランチの取り込み・コンフリクトの解消を行うことができます。

弊社では複数のプロジェクトでチーム開発を行なっており、日々改善を心掛けています。
今後もより良い方法を模索しながら取り入れていきたいと思います。

参考リンク

https://altive.notion.site/Git-cc693f29e62346f487f7110b9a8ec0bc?pvs=4
https://altive.notion.site/Git-Flow-5b110b2d204c4f998543db45869a5897?pvs=4

GitHubで編集を提案
Altiveエンジニアリングブログ

Discussion