三方向マージ
三方向マージは、共通の過去のスナップショットを使って二つのブランチ先端を比較する。チェックアウト側が ours、指定した取り込み元が theirs、最良共通祖先が merge base。基点が変更前を示すので、どちらが変わり、どちらがそのままかを判断できる。
コミットグラフは基点と先端の関係を決め、三つのスナップショットはパスごとの内容を決める。全パスが競合なく決まってから、結果ツリー、二親コミットを書き、現在のブランチを進める。競合時にはこれらの最終書き込みを行わない。
四つのパス規則
一パスの base、ours、theirs を次のように比較する。
| 条件 | 結果 |
|---|---|
ours == theirs | 両側が同じなので、どちらかを採る |
ours == base | 元側だけが変わったので theirs |
theirs == base | 現在側だけが変わったので ours |
| 三つとも異なる | 競合して停止 |
両側が独立して同じ値に変わったなら、base と違っても最初の規則で採用できる。本レッスンは二つの平坦なテキストファイルの値全体を比較する。実 Git はディレクトリ、モード、削除、改名、バイナリ、ドライバー、戦略も扱うが、base が共通の変更前を与える役割は変わらない。
三方向マージのステップ
既定の操作は次のとおり。
commit snapshot "init": app.py=print("v1"), README.md=hello
git branch feature
commit snapshot "main-api": app.py=print("v2"), README.md=hello
git switch feature
commit snapshot "feature-docs": app.py=print("v1"), README.md=quickstart
git switch main
git merge feature
固定の識別情報と時刻を持つ Git 形式の SHA-1 オブジェクトを使い、ツリーと親の変化を見せる。
- 根のスナップショットからツリー
a7c4b8a...とコミット6bb158e...を作り、main が初めてそこを指す。 - feature を
6bb158e...に作る。コミットをコピーせず、HEAD は main に残る。 main-apiは app.py だけを変え、親6bb158e...のコミットfb177c6...を作り、main だけを進める。- 基点に残る feature へ切り替える。
feature-docsは README を変え、app.py を base と同じに保ち、兄弟コミット87fb417...を作る。 - main へ戻り、
ours = fb177c6...とtheirs = 87fb417...を固定する。比較中は動かさない。 - ours から到達できるコミットを記録し、元の履歴をたどって、唯一の最良共通祖先
6bb158e...を得る。 - base は両先端と異なり、既に取り込み済みでも Fast-forward でもない。三スナップショットを読み込む。
- app.py は theirs と base が
print("v1")なので、第三規則で ours のprint("v2")を採る。 - README は ours と base が
helloなので、第二規則で theirs のquickstartを採る。 - app.py=
print("v2")、README=quickstartの結果をツリー2bcb0a8...にする。 - コミット本文は結果ツリー、第一親の旧 HEAD
fb177c6...、第二親の元先端87fb417...を含み、73bb817...になる。 - 最後に main を
73bb817...へ進める。feature は87fb417...に残る。コミットを一つ追加し、現在の参照を一回、元の参照をゼロ回書く。
親順はコマンドの視点を記録する。同じ結果ツリーでも parent 行を入れ替えると、順序付きヘッダーがハッシュ対象なので ID が変わる。first-parent の履歴走査で選ぶ主線も変わる。
最良共通祖先が必要な理由
最初に見つかった共通祖先が最良とは限らない。より新しい共通の子孫があれば、そちらを使う必要がある。本エンジンは共通祖先を集め、他の共通候補の祖先でもある古い候補を除く。
通常の分岐なら一候補になるが、交差するマージ履歴では複数残ることがある。可視化はモデル境界として停止し、履歴が不正だとは扱わない。実 Git の ort などは、複数基点を仮想基点へ統合してから先端をマージできる。古すぎる基点はパス規則に誤った変更前状態を渡してしまう。
境界ケース
- base が ours:現在は元の祖先なので、参照を動かす Fast-forward で足りる。三スナップショットの調整やコミットは不要で、作業台を開かない。
- base が theirs:現在は元を含むので最新状態と報告する。先端が異なってもオブジェクト・参照の書き込みはゼロ。
- 両側の同じ編集:ours と theirs が一致すれば第一規則で採り、base は違ってよい。
- 一パスで三値が違う:結果ツリーの前に停止する。実 Git はそのパスをインデックス stage 1、2、3 に保存し、
MERGE_HEADなどを設定する。作業ファイルには競合マーカーが入りうる。解決してコミットするまでマージコミットはできない。 - 最良基点が複数:交差する履歴で起こりうる。本モデルは停止し、実戦略では仮想基点を作れる。
- 八コミット上限:レイアウト制限。範囲外を報告して予定コミットを保存しないが、実 Git が大きな履歴を拒むという意味ではない。
よくある間違い
- 「三方向は三ブランチ。」 三入力は base、ours、theirs のスナップショットで、先端は二つ。
- 「最初の共通祖先が基点。」 より新しい共通子孫を持つ古い候補は除く必要がある。
- 「ours が常に勝つ。」 theirs が未変更なら ours、ours が未変更なら theirs、両側が同じなら共通値を採る。
- 「競合コミットができる。」 未解決インデックスで停止し、全パスの解決後に通常の結果コミットを作る。
- 「両ブランチが進む。」 チェックアウトした取り込み先だけが進む。
- 「マージコミットはパッチを持つ。」 完全な結果ツリーと二親を指し、差分は後から比較して作る。
- 「親順は表示だけ。」 オブジェクトのバイト列に含まれ、ID と第一親の履歴を変える。
モデルの範囲
クリーンなローカルリポジトリ、接続 HEAD、二つの 100644 ファイル、決定的な SHA-1 オブジェクト、最大三ブランチ、一つの最良基点を扱う。パスは文字列全体で比較する。クリーンマージ中の作業ツリーとインデックス、改名、削除、モード、バイナリドライバー、属性、フック、署名、タグ、リモート、サブモジュール、octopus マージ、戦略オプション、reflog、参照の並行更新、再帰的な仮想基点構築は省く。
実際の動作は git merge、git merge-base、git read-tree を参照できる。
続けて学ぶ
同じ手法につながるレッスンや、次の概念への入口です。
誤りや気になる点を見つけましたか? フィードバックを送る。