結城です。
1つ前の記事では、フリーソフトウェア・OSS開発プロジェクトで採用されていることの多いブランチ運用スタイルである「トランクベース開発」の概要を紹介しました。
どのようなブランチ運用を取っていても、チームでの開発で複数人で並行して作業していると、変更同士が競合1しがちです。 しかし、競合を解消する方法をインターネットで検索して、見つけた記事に書かれたコマンドをやたらめったらコピペ実行しても、期待した結果を得られなかったり、ともすれば作業の成果を失ってしまったりもします。 競合を解消するためには、それぞれのブランチがどのような関係にあるかを正確に把握した上で、的確に操作しなくてはなりません。
この記事では、トランクベース開発で運用されているGitリポジトリで起こりやすい競合の場面のそれぞれについて、競合を解消するための考え方と具体的な手順を解説します。
なお、この記事では1つ前の記事の例と同様、デフォルトブランチ名はmain
であると仮定します。
リポジトリ同士の関係の整理
Gitを使ったソフトウェア開発プロジェクトでは、大抵、全員が共通で参照して作業の成果を共有するための中央リポジトリを設けます。 例えばGitLabやGitHubなどのWebサービスに「プロジェクト公式のリポジトリ」がある場合、それが中央リポジトリです2。
あなたがそのプロジェクトのオーナーやメンバーなのであれば、中央リポジトリへのコミット権を持っていて、作業の成果は中央リポジトリへ直接プッシュできる状態にあるはずです。
Gitコマンドで中央リポジトリをクローンした時点で、リモートリポジトリorigin
が中央リポジトリに設定されているため、git pull
やgit push
の操作は既定で中央リポジトリが対象となります。
他方、コミット権が無い外部協力者の立場で関わろうとしている場合、「作業の成果をプッシュする先のリポジトリ=origin
」を中央リポジトリとは別に持つ必要があります。
GitLabやGitHub上で「フォーク」の操作をして作られるリポジトリがそれに該当し、この記事ではこれを「作業用リモートリポジトリ」と呼ぶことにします。
作業の成果が競合した場合には、これらの各リポジトリの関係を把握した上で、適切な順番で変更を適用していく必要があります。 複雑なケースでは適切なやり方自体も複雑になってくるため、一度で理解するのは難しいです。 そこでここからは、単純なケースから複雑なケースへと段階的に説明していくことにします。
中央リポジトリへのコミット権がある場合の競合の解消
まず、個人でフリーソフトウェアを開発している場面や、社内プロダクトの開発などのような、自分が中央リポジトリへのコミット権を持っている場合から説明します。 前述の「作業用リモートリポジトリ」が必要になる場合については、この記事では扱わず、次の記事で説明したいと思います。
デフォルトブランチへの直接の変更で生じる競合
最も単純な場面として、トピックブランチを作らずにデフォルトブランチに変更を直接コミットする場合から考えてみます。
デフォルトブランチへ変更を直接コミットすることは、現代の「きちんとした」開発ではタブーとされることが多いです。 とはいえ、プロトタイプの実装段階のようにデフォルトブランチの動作が壊れても失う物がほぼ無い場合や、個人の趣味で細々と開発しているプロジェクトなど、デフォルトブランチへ直接コミットする場面は今でも無いわけでもありません。 では、この運用で競合が発生せずプッシュが成功する場合の流れを見てみましょう。
まず作業者(自分)が中央リポジトリを自分の手元の環境にクローンし、ローカルリポジトリを用意します。
$ git clone git@gitlab.com:redmine-plugin-testcase-management/redmine-plugin-testcase-management.git
$ cd redmine-plugin-testcase-management
次に、デフォルトブランチにいる状態で変更をコミットします。
$ git checkout main # デフォルトブランチ(main)をチェックアウトする。
... # 何か変更の作業を行う。
$ git diff # 変更内容を再確認する。
$ git add path/to/added/file # 追加したファイルをバージョン管理対象に含める。
$ git commit -p # 差分のパートごとにコミットする。git diffの代わりにこちらだけ行うことも多い。
そして、この変更をクローン元のリモートリポジトリ=中央リポジトリにプッシュします。
$ git push # 「git push origin main」の略。
この一連の流れの過程で、誰か他の人が中央リポジトリのデフォルトブランチに変更をプッシュしていると、競合が発生する可能性が出てきます。
手元で変更をコミットする前の時点での競合の解消
手元で作業をしてコミットしようとした時に、自分がコミットする前の時点で中央リポジトリへ他の人が変更をプッシュしていた、という場合を考えます。
この場合、自分の手元の変更をコミットする前に、中央リポジトリのデフォルトブランチの変更を手元に反映するのが最善です。
まず、手元の変更をgit stash
で一時的に退避します。
$ git stash
次に、中央リポジトリのデフォルトブランチとローカルリポジトリのデフォルトブランチを同期させます。
$ git pull # 「git pull origin main」の略。
その後、退避した変更をgit stash pop
で再適用します。
$ git stash pop
変更が競合していた場合、この時点で競合を解消することになります。
なお、「片方では関数やモジュールの設計を大きく変更し、もう片方では元々の設計を前提にした変更をしていた」という場合、git stash pop
の時点では何も競合が検出されなくても、自動テストが失敗したりプログラムとして動作しなくなっていたりといったことは起こり得ます。
そのようなことを防ぐためにも、Gitが競合を検出したかどうかに関わらず、自分が関知していなかった変更がデフォルトブランチで行われていた場合は、その変更の内容を把握しておくのが望ましいです(プロジェクトの規模が大きくなると、それも難しくなってしまうのですが……)。
ともあれ、競合がなかったか、競合を無事に解消できたら、あとは変更をローカルリポジトリにコミットして中央リポジトリにプッシュします。
$ git add path/to/added/file # 追加したファイルをバージョン管理対象に含める。
$ git commit -p # 差分のパートごとにコミットする。
$ git push # 「git push origin main」の略。
git stash
とgit stash pop
は、このように「今やりかけでまだコミットしていない変更」があって別の作業を割り込ませる必要が生じた場面で、非常に便利な機能です。
まだ使ったことがない人は、ぜひ覚えておきましょう。
手元で変更をコミットした後の時点での競合の解消
次は、中央リポジトリのデフォルトブランチに他の人が変更を加えていたことに、自分がローカルリポジトリに変更をコミットした後で気付いた、という場合を考えます。
この状況で単純に中央リポジトリのデフォルトブランチの内容をローカルリポジトリのデフォルトブランチにプルしようとすると、競合の恐れがあるということで、最近のバージョンのGitでは以下のようなメッセージが表示されて処理が中断されます。
hint: You have divergent branches and need to specify how to reconcile them.
hint: You can do so by running one of the following commands sometime before
hint: your next pull:
hint:
hint: git config pull.rebase false # merge
hint: git config pull.rebase true # rebase
hint: git config pull.ff only # fast-forward only
hint:
hint: You can replace "git config" with "git config --global" to set a default
hint: preference for all repositories. You can also pass --rebase, --no-rebase,
hint: or --ff-only on the command line to override the configured default per
hint: invocation.
fatal: Need to specify how to reconcile divergent branches.
ここで git config pull.rebase false
(あるいは git config --global pull.rebase false
)を実行してから再度プルしたり、あるいはこのメッセージが表示されない古いバージョンのGitを使っていたりすると、両者の変更が矛盾無く統合できる場合でも、「2つのブランチをマージした」扱いとなりマージコミットができます。
実のところ、この状態でもローカルリポジトリから中央リポジトリへプッシュできる状態にはなっており、実際に、それを許容する運用を取っているプロジェクトもあります。
ただ、このような枝分かれした変更履歴は、開発者の混乱を招くリスクがあります。 例えば、ある変更の経緯を知りたくて「この変更の前のコミット、その1つ前のコミット……」と履歴を遡ってみたら、デフォルトブランチ側ではその変更のコミットがどうしても見つからず、実はトピックブランチの方で変更が行われていた、といった事が起こり得ます。 そのため、このような合流のマージコミットはプッシュしないよう求めるプロジェクトは多いです。
このような場面では、合流のマージコミットが発生しないように、マージではなくリベース(rebase)するのがお薦めです。
リベースは、ブランチを作成したときの基準となるリビジョンを別のリビジョンだったことに改める操作で、一種の歴史改変にあたります。 今回のケースであれば、「ローカルリポジトリのデフォルトブランチを、フォーク時点の中央リポジトリのデフォルトブランチからの派生ではなく、最新の中央リポジトリのデフォルトブランチからの派生だったことにする」ということになります。
この図の例であれば、aからcになるように変更したのではなく、aからbを経由してc'になるように変更したことにする、という具合です。 a→cの差分がそのままbに適用できる場合なら、競合せずリベースが完了します。
リベースを行う方法としては、すでに手元に必要な情報がすべて揃っている場合向けのgit rebase
と、今回のケースのように情報がまだローカルリポジトリにはない場合向けのgit pull --rebase
3の2つがあります。
よって、今回はgit pull --rebase
の方を使います。
$ git pull --rebase # 「git pull --rebase origin main」の略。
このようにすると、ローカルリポジトリの変更と中央リポジトリの変更に矛盾がなければ、そのまま先の図のように歴史が改変され4、ローカルリポジトリのデフォルトブランチの変更履歴には枝分かれが残りません。 すべてのコミットの親コミットが1つだけになるため、変更の経緯を辿りやすい状態になるわけです。
なお、git config --global pull.rebase true
と設定しておくと、--rebase
オプションを付けなくてもgit pull
の動作がgit pull --rebase
と同じになります。
多くの場合、git pull
での競合はリベースで解決するのが安全なので、ぜひ設定しておきましょう。
また、この状態であればもはやローカルリポジトリのデフォルトブランチと中央リポジトリのデフォルトブランチの間に履歴の矛盾はないため、中央リポジトリのデフォルトブランチへ変更をプッシュすれば、リベースの事実自体を他の人に特に意識させることなく作業の成果を共有できます。
$ git push # 「git push origin main」の略。
ローカルリポジトリの変更と中央リポジトリの変更内容が矛盾していた場合には、リベースの途中で競合を解消するよう求められます。 先の図の例だと、a→cの差分がbに適用できない場合は、b→c'になるようなコミットを手動で行う(競合を解消する) ことになります。
通常のマージでの競合の解消は、マージ対象の2つのブランチのHEAD
同士の競合状態を解消する作業を1回だけ行うものです。
それに対し、リベースでは競合の解消作業が複数回発生する場合があります。
これは、リベースという操作が「ローカルリポジトリのデフォルトブランチに対して行っていたコミットを、フォーク時点のHEAD
に対してではなく、現在の中央リポジトリのデフォルトブランチのHEAD
に対して行ったことにする」というものだからです。
ローカルリポジトリでの未プッシュのコミットが複数回に及んでいると、競合の仕方によっては、すべてのコミットで都度競合を解消する羽目になる場合もあります。
この図の例であれば、最大で「a→cに相当するb→c'のコミット」と「c→dに相当するc'→d'のコミット」の2回競合が生じる可能性があります。
競合の回数が増えれば増えるほど、得られるメリットに対してかかるコストが大きくなるので、一旦ローカルでスカッシュ(squash)の操作をしてそれらのコミットを1つにまとめてからリベースしたり、あるいは、後々履歴を辿るのが大変になることを承知で通常のマージにしてHEAD
同士の競合の解消だけに努めたり5、といった方法をとる判断もあり得ます。
この場合も、リベースが完了したら中央リポジトリのデフォルトブランチへ変更をプッシュして作業の成果を共有できます。
$ git push # 「git push origin main」の略。
トピックブランチを使ったデフォルトブランチの変更で生じる競合
複数人が開発者として関わるプロジェクトでは、デフォルトブランチへの直接の変更をリポジトリの設定もしくは運用ルールで禁じていることがあります。 また、デフォルトブランチへの直接の変更が可能な場合でも、他の人の作業を邪魔しないように、離れた所で、大規模な変更を少しずつ進めたい場合もあります。
このようなケースで使うのが、トピックブランチや機能ブランチと呼ばれる種類のブランチです。 これは、「機能追加(や変更、削除)」の単位でブランチを作って作業を行い、完了時点でブランチをデフォルトブランチにマージする、という形で短命なブランチを使い捨てていく運用です。 では、この運用で競合が発生せずプッシュが成功する場合の流れを見てみましょう。
まず、ローカルリポジトリのデフォルトブランチから分岐する形で、予定している変更内容に則した名前のブランチを作ります。
ここでは仮に、使い方を説明するドキュメント群を追加するつもりでadd-usage-documents
という名前のブランチを作ると仮定します。
$ git checkout main
$ git checkout -b add-usage-documents
git checkout -b
は、現在のブランチから新しいブランチを作って、作業コピーを用意する操作です。
ブランチができたら、これまでのケースと同様に開発作業を行い、変更を随時コミットします。
今度は前のケースと異なり、作業をデフォルトブランチ以外で行っているため、(このトピックブランチ自体を複数人で手がけているのでなければ)好きなタイミングで変更を中央リポジトリにプッシュできます。
$ git push --set-upstream origin add-usage-documents # 最初の1回目のみ。
$ git push # 2回目以降。--set-upstream した後であれば、「git push origin add-usage-documents」の略となる。
次は、このトピックブランチをデフォルトブランチにマージします。
デフォルトブランチへの直接の変更を禁止しているリポジトリでは、マージ操作はWebのUIで行います。 トピックブランチに変更をプッシュした直後にGitLab(あるいはGitHub)の中央リポジトリのページを開くと、そのトピックブランチからマージリクエスト(GitHubではプルリクエスト)を作成するためのボタンがページ上部に表示されます。
そこからページを遷移し、適切な説明を添えてマージリクエスト(プルリクエスト)を作成します。 レビューで得たフィードバックを踏まえて適宜変更を当該ブランチにプッシュしていき、問題点がなくなったら、責任者がマージを実行します。
トピックブランチのマージでは、リベースを行った場合でも履歴が完全には一本化されず、必ず「デフォルトブランチの最後のコミット」と「トピックブランチの最後のコミット」の2つを親に持つマージコミットが作成されます。 2つの履歴の流れが合流する形のマージコミットと比べると、リベース後のマージの場合は、トピックブランチ側の履歴を辿っていくとデフォルトブランチの最後に辿り着くため、実質的には履歴が一本化されたと見なしても問題のない、ほぼ無害な存在だと筆者は考えています。
ただ、このようなマージコミットが多くなってくると、少々厄介なことになります。
冗長なマージコミットを残さない、スカッシュを伴うマージ
すべての変更で必ずトピックブランチを作成する方針を取っている場合、マージを重ねていくと、「変更履歴の一覧の半分がマージコミットで埋まっている」といった状態が発生します。
これでは、重要な変更が埋もれて見えにくくなってしまいます。 そのため、マージの際にスカッシュ(squash)を行って、冗長なマージコミットを残さないようにしているプロジェクトは少なくありません。
スカッシュとは、先にも少し触れましたが、複数のコミットで行われた変更を1つのコミットにまとめる操作です。 トピックブランチを使う運用であれば、トピックブランチをスカッシュした結果のコミットは「トピックブランチの趣旨として行いたかったことを一発で行う変更」となります。 一般的にスカッシュは「変更の詳細な経緯を辿れなくなる」というデメリットがある操作ですが、トピックブランチの趣旨以上の細かさで変更の経緯を深掘りするつもりがなければ、このデメリットは問題になりません。
GitLabのマージリクエストやGitHubのプルリクエストは、マージの際にサービス上でスカッシュを同時に行うことができます(いわゆる「スカッシュマージ」)6。 これにより、トピックブランチを多用していても、変更履歴が冗長なマージコミットで埋まる事態に陥らずに済みます。
GitLabの場合、マージリクエストのマージ操作を行うボタンの上に「Squash commits」というチェックボックスがあり、ここにチェックが入っていれば、スカッシュとマージが同時に行われます。 プロジェクトの設定7で、このチェックボックスを常にONにするよう強制することもできます。
GitHubの場合、マージ操作を行う「Merge pull request」ボタンの右の「▼」をクリックして、ドロップダウンメニューから「Squash and merge」を選択すると、スカッシュとマージが同時に行われます。
マージの後始末
レビューが終わってマージされたら、そのトピックブランチはもう不要です。 GitLabやGitHubでは、マージ時にトピックブランチを削除できるようになっていますので、その機能を使うのがおすすめです。
もしマージ時にトピックブランチを削除しそびれてしまった場合は、以下のように操作して中央リポジトリに残留したトピックブランチを削除できます。
$ git push origin --delete add-usage-documents
また、不要になったブランチが多くあると、git checkout
などの操作を行う際にも混乱の元となります。
無事にマージが完了したら、ローカルリポジトリのトピックブランチも併せて削除しておきましょう。
$ git checkout main # 現在のブランチは削除できないため、一旦、削除対象でないブランチに切り替える。
$ git branch --delete add-usage-documents
以上で変更の操作は終わりです。
トピックブランチで作業していて、中央リポジトリに他の人が変更を行っていた場合
「トピックブランチで開発を進めている間や、開発が一段落してマージのためのレビューを受けている最中に、他の人の作業が先に完了して中央リポジトリのデフォルトブランチにマージされる」という場面は、チーム開発では頻発します。
そのような場面でも、中央リポジトリのデフォルトブランチに行われた変更と、作業中のトピックブランチでの変更が互いに競合していなければ、そのままマージリクエスト(プルリクエスト)をマージできます。
問題は、双方の変更が競合してしまっている場合です。 このような場面では、先の「中央リポジトリに他の人が変更を行っていた場合(コミット済み)」と似た要領で競合を解消する必要があります。
まず、ローカルリポジトリのデフォルトブランチを中央リポジトリのデフォルトブランチと同期します。
$ git checkout main # 一旦デフォルトブランチに切り替える。
$ git pull # 「git pull origin main」の略。
次に、トピックブランチにデフォルトブランチの変更を反映します。 この時には、変更の経緯を追いやすい状態を保つために、可能であればリベースを行うのが望ましいです。
$ git checkout add-usage-documents # トピックブランチに戻す。
$ git rebase main # 最新のデフォルトブランチからの派生だったことにする。この過程で競合が発生する場合がある。
競合の仕方によっては、トピックブランチの複数コミットに対して何度も修正を行わなくてはならない場合があります。 場合によっては、一旦スカッシュの操作をしてトピックブランチのコミットを1つにまとめてからリベースしたり、あるいはリベースを諦めて通常のマージにしてしまう方がよいかもしれません。 それらの選択肢を採れるかどうかは、プロジェクトの運営方針次第です。
$ git checkout add-usage-documents # トピックブランチに戻す。
$ git merge main # デフォルトブランチの変更をまとめてマージする。この過程で競合が発生する場合がある。
リベースでの競合の解消、またはマージが無事に完了したら、中央リポジトリのトピックブランチを更新します。
$ git push # 「git push origin add-usage-documents」の略。
競合を解消した状態のトピックブランチを中央リポジトリにすんなり持って行ければ、後は先述のケースでのマージと同様の流れとなります。
とはいえ、すんなりいく場合ばかりでもありません。 トピックブランチを中央リポジトリにプッシュし待っていた後で、中央リポジトリのデフォルトブランチの変更をローカルリポジトリに持ってきてリベースやスカッシュした場合、話は少しややこしくなります。
ローカルリポジトリのデフォルトブランチを中央リポジトリと同期してトピックブランチをリベースすると、今度はローカルのトピックブランチと中央リポジトリのトピックブランチが競合する状態になります。 当然、このままではプッシュできません。
こういう時に一番簡単な解決方法は、強制的にプッシュ(force push)してしまうというものです。
$ git push --force-with-lease # 「git push --force-with-lease origin add-usage-documents」の略。
-f
(--force
)ではなく--force-with-lease
を指定しているのがポイントです。
--force-with-lease
はプッシュ先のブランチに他の人の作業の成果が加わっていた場合に強制プッシュを中断する仕様のため、-f
でよくある「他の人の作業の成果を吹き飛ばしてしまう」リスクが小さく、よりお薦めです。
この後でマージリクエスト(プルリクエスト)を作成すれば、こちらも先述のケースでのマージと同様の流れとなります。 もし古いトピックブランチからマージリクエストを既に作成済みだった場合、ブランチ名自体が変わっていなければ、マージリクエストの内容も自動的に追従して更新されます。
なお、リポジトリの設定で強制プッシュが禁止されている場合や、心情的に強制プッシュに不安がある場合は、中央リポジトリのトピックブランチを一旦削除してからトピックブランチをプッシュし直す方法もあります。
$ git push origin --delete add-usage-documents # 中央リポジトリのトピックブランチを削除。
$ git push origin add-usage-documents # 改めて、トピックブランチを中央リポジトリに作り直す。
GitLabの場合、マージリクエストを作成した後でマージ前に対象ブランチを削除すると、マージリクエストが連動して自動的にクローズされます。 同名のトピックブランチをプッシュし直した時点で、マージリクエストの内容も追従して更新されますが、この場合には自動で再開まではされません。 Webブラウザー上で手動操作して、マージリクエストを再開する必要があります。
まとめ
以上、Gitのブランチ運用スタイルの1つであるトランクベース開発における、コミット権を持つプロジェクトメンバーとして開発に関わる際のブランチ運用と、競合が発生した際の解消の流れを紹介しました。
競合はしないに越したことはなく、競合の発生自体をなるべく防ぐように考えることが肝要です。
具体的には、Gitの運用としては中央リポジトリから離れた状態での作業期間を短くすることが大切です。 分かりやすい所では、「コミットやトピックブランチの作成など何らかの操作をする前には、必ずローカルリポジトリのデフォルトブランチを中央リポジトリのデフォルトブランチと同期すること」を習慣付けたり、作業の単位を細かくして個々のトピックブランチの寿命を短くしたり、といったことに気をつけるとよいでしょう。
また筆者の経験上は、「関数同士やモジュール同士の結合度を下げる」「1つの行の中での処理を少なくする(1行の中で複数の処理をやらない)」など、設計面でコードの風通しを良くしておくことも効果的と感じています。 実際に、適切に設計されたコードは変更の影響範囲が小さくなるため変更同士が競合しにくいですし、コードの密度が低いと、変更箇所同士が1行の中で重なりにくいです。
この記事では「中央リポジトリ」と「ローカルリポジトリ」の2つの間で発生した競合を解消する様子を説明しましたが、2者間だけでも、競合が生じてしまうと解消には注意を要することを実感して頂けたでしょうか。 次の記事では、コミット権を持たない外部協力者として開発に関わる場面で、ここにさらにもう1つを加えた3つのリポジトリ間で競合が発生するケースと、その解消法を紹介していきます。
-
例えば双方が同じファイルの同じ行を変更していた場合、行単位で変更を管理するGitではどちらの変更を先に適用しても支障が生じるため、何らかの解決が必要です。また、変更した行が極めて近接している場合も、Gitは双方の変更をうまく1つにまとめられず、解決が必要になることがあります。このような状況は「衝突」や「コンフリクト」とも言い、ここでは「競合」に統一することにします。 ↩
-
たまに「外部のリポジトリが本当の中央リポジトリで、GitHubのリポジトリは利便性のためのミラーである」のような場合もあります。例えば、Redmineは2023年2月時点で自組織ホスティングのSubversionが中央リポジトリとなっています。 ↩
-
git pull --rebase
は、git rebase
を含む複数のGitのコマンド操作をまとめて一気に行う物です。 ↩ -
なお、このときリベース後のコミットは、仮にローカルリポジトリの元々のコミットと同内容であっても、それらとは別の新しいコミットとして扱われ、新たに別のリビジョンIDが割り振られます。 ↩
-
筆者は、自身が管理するリポジトリの場合、早々に諦めて通常のマージにしてしまうことが多いです。 ↩
-
正確な言い方をすると、「スカッシュ結果として生成された単一のコミットを、トピックブランチのマージコミットの代わりに使う」形となります。そのため、トピックブランチのコミットそのものは、デフォルトブランチと紐付かないまま放置される形となります。なお、こうして作られたスカッシュ結果のコミットは、GitLabやGitHubなどのサービスのUI上ではマージリクエスト(プルリクエスト)と紐付けられるため、サービス利用者の視点では通常のマージとあまり区別が付かない状態となります。 ↩
-
左のサイドバーの「設定」から「マージリクエスト」を選択し、「Squash commits when merging」で「Require」を選択すると、そのようになります。なお、変更履歴の一本化を強制するのであれば、併せて「マージ方法」で「早送りマージ」を選択しておくのがおすすめです。 ↩