マージのしくみ
前章ではコンフリクトの解決方法について表面的に説明しましたが、なぜその方法でよいのかを説明するために Git が採用しているマージアルゴリズムについて紹介します。発展的な内容になるので全てを理解できなくても大丈夫です。
3-way merge
main ブランチの状態 O から sub ブランチを生やし、main ブランチと sub ブランチのそれぞれにコミットをしていったとします。その後、状態 Y まで進んだ sub ブランチを状態 X まで進んだ main ブランチにマージすることになりました。
Git はマージの実装に 3-way merge と呼ばれる方式を採用しています。簡単に説明すると、状態 O, X, Y の 3 つだけを用いてマージ後の状態 Z を生成するという方式です。
具体的なマージの手順を以下に示します。
- 状態 O と 状態 X から差分 A' を導く。同様に、状態 O と 状態 Y から差分 B' を導く
- 差分 A' と 差分 B' を合成して合成差分を得る(コンフリクトの発生源)
- 状態 O に合成差分の変更を施した結果の状態 Z を得る
- sub → main 方向のマージなので、main の先端に Z をつけてマージコミットを作成する
- マージコミットの「変更内容」は X と Z の差分を導くことで逆算する
コミットグラフを構成する要素
コミットグラフにおいて、点(Node)はリポジトリのある状態を表し、矢印(有向辺、Directed Edge)は状態同士の親子関係を表します。単一ブランチ内でのコミットをはじめとして、ほとんどの場合で矢印は「(根本の状態から先端の状態への)変更」と同義です。しかし、ブランチ同士が合流するマージコミットのように、矢印が単に「変更」を意味するとは言えない場合もあります。
先ほどの図で Y → Z 方向の矢印を実線ではなく点線で示したのは、この矢印が「変更」を意味しないからです。Z は確かに状態 X と Y 両方の子の状態ですが、マージコミットにおける sub の寄与としては「main の新しいコミットに sub の情報が用いられた」に過ぎず、sub → main のマージの後も依然として sub の先端は Y のままになっています。
3-way merge における重要な点をいくつか挙げます。
- O から X, Y に至るまでのコミット群 A, B の途中経過はマージの結果に影響しません。Z を決めるのは専ら O, X, Y の 3 つの状態であり、それ以外は参照されません。
- 合成差分を得るとき、差分 A' と B' は同列に扱われます。すなわち、main → sub のマージと sub → main のマージでは全く同じ状態 Z が得られることになります。ただし、Z がつく部分が main の先端になるか、sub の先端になるかはマージの方向によって異なります。
マージ競合の解決
前章で紹介したマージ競合の解決方法は大まかに以下の流れを辿りました。
- main → sub のマージ(コンフリクトの解決を伴う)
- sub → main のマージ(PR を立てる)
この説明を聞いて「main → sub 方向のマージのコンフリクトを解決することで、なぜ直ちに sub → main 方向のマージのコンフリクトも解決できるのか?」と疑問に思った方はとても鋭いです。その理由はここで説明した 3-way merge の仕組みに基づいて述べることができます。
3-way merge においてマージ後の状態の生成に用いられるのは「それぞれのブランチの最新の状態」そして「それらの最も近い共通の祖先」です。main → sub 方向のマージによって、main の最新状態は X、sub の最新状態は Z になります。これらの最も近い共通の祖先は X 自身であり、Z はその子にあたる状態です。従って、続けて sub → main のマージをする際には X → Z の差分をそのまま合成差分として用いることができ、差分の合成においてコンフリクトが生じることがありません。
Fast-Forward merge
前編 Git の操作の実体 の章で触れた通り、pull とは fetch + merge です。VSCode 上で「変更の同期」ボタンを押すたびに 3-way merge の処理が走るということになります。
しかし、それにしてはコミットグラフにマージの形跡が見当たりません。これは、最後の同期以後ローカルにコミットが一つもない場合の pull のマージでは Fast-Forward merge と呼ばれる特殊なマージ処理がはたらく条件が整っているからです。
Fast-Forward merge は
- マージ先のブランチが共通の根から一度もコミットされていない場合に限り
- マージ元のブランチにおける全ての変更をマージ先のブランチで行っていたことにできる
マージ処理です。上の図では origin/main(マージ元のリモートブランチ)におけるすべての変更が main(マージ先のローカルブランチ)でも行っていたことにされています。その結果コミットグラフには枝分かれやマージの形跡が残らず、ずっと手元の main の上で作業していましたと言わんばかりの 1 本のコミット列が続いていくことになります。
ただし、この Fast-Forward merge を条件によって発動させない設定にすることもできます。たとえば、プルリクエストを立てて行った main へのマージでは、たとえ main からブランチを生やしてから一度も main に動きがなかったとしても「ブランチを生やしてコミットしてからマージした」という履歴を残したい場合があります。このようなニーズに対応し、Gitea などホスティングサービスの側でプルリクエストをマージするにあたり「Fast-Forward merge を適用する」か「マージコミットを作成する」かを選択できることが多いです。
pull でマージコミットが生じる場合
最後の同期以後 origin/main と main の双方にコミットが存在する状態で pull を実行した場合、Fast-Forward merge は発動せずマージコミットが作成されることになります。この場合コミットグラフ上には「origin/main から ローカルの main に枝分かれしてコミットがなされ、そしてマージされた」という形跡が残ります。普段はお目にかかれない「origin/main と main の本質的相違」の証左です。
ところで、もしコンフリクトがあれば前章で紹介した通りの方法で解決してから手動で origin/main → main のマージを実行することになりますが、仮にコンフリクトを生じない変更だったとしても VSCode 上で「変更の同期」ボタンを押した瞬間にはダイアログが表示されます。Git の操作としては可能でも「普通はやらない異常な操作」であることには変わりないからでしょうか。
その他の Git の機能
ここまでのテキストでまだ登場していない Git の機能をいくつか紹介します。基本的にはすでに紹介したことだけ知っていれば問題なく開発を進めていくことができますが、より整然とした開発を実現するために以下のような機能が役に立つことがあります。
stash
作業ディレクトリにおける変更内容をコミットせず一時的に控えておくことができる機能です。間違えて別のブランチで作業をしてしまい、途中でそれに気付いたときにはそれまでの変更を stash して適切なブランチに移動してから適用し直すことができます。
おすすめは、控えておきたい変更をすべてステージして以下のように選択することです。ステージされている変更が全て 1 つのスタッシュに入ります。スタッシュ名は空で問題ありません。
適切なブランチに移動したのち上図に見つかる『最新のスタッシュをポップ』を選択すると、先ほど控えた変更を反映させることができます。
cherry-pick
現在のブランチに別の場所から持ってきたコミットを適用できる機能です。名前は「さくらんぼ狩り」という意味です。1 つのコミットだけとってくるからですね。
cherry-pick には(普通)2-way patch と呼ばれるアルゴリズムが用いられます。コミットにおける「変更」は元の状態を前提とするため、現在のブランチの先端 Y と持ってくるコミットの根元の状態 O がかけ離れているとコンフリクトが生じることがあります。
rebase
現在のブランチが「枝分かれ元のブランチの最新状態から始まった」ことにできます。名の通り「根を変える」機能です。
基本的な仕組みは cherry-pick の繰り返しです。状態 O から始まる sub ブランチの過去のコミットそれぞれを状態 X から順に生やしていきます。
cherry-pick と異なるのは、rebase を実行しても main には影響せず sub ブランチの内容が書き換えられるという点です。ただし、その結果 sub は main への Fast-Forward merge が可能な形になるので、合わせて実行すれば main にコミットが増えることと同じになります。Fast-Forward merge の項で紹介した Gitea のキャプチャに含まれるブランチ統合の選択肢「リベース後にファストフォワード」はまさにその操作を表しています。
インタラクティブリベース
Git の rebase コマンドにオプション -i
をつけることでインタラクティブリベースを開始することができます。インタラクティブリベースは原義 rebase を大幅に拡張した汎用的な歴史改変ツールであり、たとえば以下のような操作を可能にします。
- コミットの順序を変更する
- 複数のコミットを一つにまとめる / 一つのコミットを分割する
- コミットメッセージを修正する
コミットグラフの見やすさに気を遣う開発者は、インタラクティブリベースを活用して一連のコミットを綺麗にまとめてからリモートにプッシュすることがあります。一旦コミットしてからその順序や内容を整理することで、他者から見てそれぞれのコミットの意図がより明瞭になるからです。
ただし、その性質上インタラクティブリベースの使用はリスクを伴います。上で述べた使用例ではローカルだけで操作を済ませているのでよいのですが、すでにリモートブランチにプッシュされたコミットに対して歴史改変を試みると他の開発者が所有するコミットグラフとの間に矛盾が発生し、開発作業が正常に進行しなくなるおそれがあります。
コミットはその粒度が細かければ細かいほどよいとも言われるので、そもそもインタラクティブリベースを活用してコミットをまとめたりすること自体が野暮なことなのかも知れません。少なくとも Git 初学者がインタラクティブリベースに手を出すのはやめておいた方がよさそうです。