同じファイルを 2 人で編集したのに、なぜ git はほとんど自動でマージできるのか?
あなたも昨日、同僚と同じ設定ファイルを触った PR を main に取り込んだはずだ。数十行の変更が衝突せずに混ざり、ある 1 行だけが <<<<<<< に囲まれて止まった。git はどうやって「この行は混ぜてよく、この行は人間に聞くべき」と判断したのだろうか。
- git は 2 つのブランチを比べているのではなく、両者の共通祖先(マージベース)を加えた 3 つの版を比べる「3-way マージ」で、どちらが変えたのかを判定している
- 片側だけが変えた箇所はその変更を採用し、両側が同じ領域を別々に変えた箇所だけをコンフリクトにする。隣接する行の変更も「同じ領域」とみなされる
- コンフリクトが出なかったことは、行単位で衝突しなかったという意味でしかない。関数名の変更と呼び出しの追加のような意味上の衝突は、テストでしか見つからない
2 つの版を見比べるだけでは決められない
マージは「2 つのファイルの差分を取って混ぜる」作業のように思える。しかし 2 つだけでは判定できない場面がすぐに出てくる。main では timeout = 10、feature では timeout = 30 になっているとき、どちらが新しい値なのかは 2 つの版を眺めても分からない。main が 30 から 10 に変えたのかもしれないし、feature が 10 から 30 に変えたのかもしれない。
この曖昧さは、元の値が分かれば消える。元が 30 なら「main だけが変えた」ので 10 を採ればよい。そこで git は、2 つのブランチが分岐した地点のコミットをもう 1 つの比較対象として持ち出す。これがマージベース(2 つのコミットに共通する祖先のうち、最も新しいもの)だ。
以前の記事で見たとおり、git のコミットは差分ではなくスナップショット全体を指している。だからマージのたびに、git はマージベースのスナップショットを起点として「ours(今いるブランチ)がどう変えたか」「theirs(取り込むブランチ)がどう変えたか」という 2 本の差分をその場で計算する。マージベースは git merge-base main feature で確認できる。
マージベース (base)
timeout=30, log=info
┌──────┴──────┐
▼ ▼
main (ours) feature (theirs)
timeout=10 timeout=60
log=debug log=info
└──────┬──────┘
▼
マージ結果: log=debug
timeout は衝突 → 人間へ
3-way マージの判定ルール
3-way マージ(base・ours・theirs の 3 つを突き合わせて結果を決める方式)の規則は、ファイルの領域ごとに次の 4 通りしかない。base と ours が同じなら theirs を採る。base と theirs が同じなら ours を採る。ours と theirs が同じ内容に変わっていれば、それを採る。3 つとも違うときだけ、git は機械的に選べないのでコンフリクトとして人間に返す。
git の公式ドキュメントもこの動作を「共通祖先からの変更のうち、重ならないもの(片側がある領域を変え、もう片側はその領域に触れていない)はそのまま結果に取り込まれる」と説明している。数十行の変更がほぼ自動で混ざるのは、2 人の変更がたいていファイルの別々の場所に散らばっていて、上の最初の 2 規則で片付くからだ。
ここで素朴な予想が 1 つ外れる。「違う行を編集していれば衝突しない」と考えがちだが、git は隣り合う行への変更も同じ領域への変更とみなしてコンフリクトにする。9 行目を ours が、10 行目を theirs が書き換えると、間に無変更の行が 1 行も挟まらないため 2 つの差分が 1 つの塊になる。git のメーリングリストでは、隣接する行はコードでは 1 つの振る舞いを構成していることが多く、人間に確認させる方が安全だという設計判断として説明されている。
共有のレシピ原本を 2 人がそれぞれコピーして書き込んだ場面に似ている。2 枚のコピーだけを並べると「砂糖 10g」と「砂糖 30g」のどちらが書き換えたほうか分からない。手元に原本があれば、原本と同じほうが「触っていない側」だと分かり、もう一方を採用できる。原本から 2 人とも別の数字に書き換えていたときだけ、作った本人たちに相談する。
コンフリクトマーカーの読み方と diff3 / zdiff3
コンフリクトした領域は、作業ツリーのファイルにコンフリクトマーカー(<<<<<<<・=======・>>>>>>> で両側の内容を囲んだ印)付きで書き出される。======= より上が ours、下が theirs だ。ただしデフォルトのスタイル merge は base の内容を表示しないので、2 人がそれぞれ何を変えたのかは推測するしかない。
設定 merge.conflictStyle を diff3 にすると、||||||| の行に続けて base の内容も表示される。zdiff3 は diff3 と同じ情報に加え、領域の先頭と末尾で両側が一致している行をマーカーの外へ出して、衝突範囲を狭く表示する。
git config --global merge.conflictStyle zdiff3
この設定を入れていないと、例えば ours が値を 10 に、theirs が 60 に変えた衝突で「元は 30 だった」ことが画面に出ず、ours が「下げた」のか theirs が「上げた」のかを判断できない。base が見えれば、両者の意図(片方は短くしたい、片方は長くしたい)を読んで第 3 の値に落とす判断ができる。
マージが止まっている間、3 つの版はインデックス(次のコミットの中身を組み立てる領域)にステージ番号付きで保存されている。ステージ 1 が base、2 が ours(HEAD)、3 が theirs(MERGE_HEAD)で、git show :1:app.conf のように取り出せる。設定を後から変えた場合でも、git checkout --conflict=diff3 app.conf でマーカーを base 付きに書き直せる。
コンフリクトが出なかった = 正しい、ではない
3-way マージの判定は行のテキストだけを見ている。そのため、ours が関数 fetch_user を find_user に改名し、theirs が別ファイルに fetch_user の新しい呼び出しを足すと、行としては一切重ならないのでマージは綺麗に成功し、結果のコードは壊れる。こうした意味上の衝突は git には検出できず、マージ後のコードでテストやビルドを走らせて初めて見つかる。
2 つのブランチが互いのマージを何度も取り込み合うと、マージベースの候補が 2 つ以上になることがある(criss-cross マージ)。現在のデフォルト戦略 ort は、この場合に複数の共通祖先をいったん合成した仮想的な base を作り、それを基準に 3-way マージを行う。ort は Git 2.34 からデフォルトで(それ以前の recursive は v2.33.0 までのデフォルト)、v2.50.0 以降は -s recursive を指定しても ort が使われる。
最もよく出会うのは、長く生きたブランチを main に追従させるたびに同じ箇所でコンフリクトが繰り返す場面だ。git config --global rerere.enabled true を入れておくと、git は一度解消したコンフリクトとその解消結果を記録し、同じ衝突が再び現れたときに作業ツリーへ自動で適用する(インデックスには載せないので、git diff で確認してから git add する)。CHANGELOG のように末尾へ 1 行ずつ足すファイルは隣接行ルールで毎回衝突しやすく、.gitattributes に CHANGELOG.md merge=union と書けば両側の行を全部残すようになる。ただし公式ドキュメントが警告するとおり行の順序は保証されないので、コードには使わない。
rebase 中のコンフリクトでは ours と theirs の意味が逆転する点にも注意したい。rebase は取り込み先の上に自分のコミットを 1 つずつ積み直すので、ours は「積み直し中の取り込み先側」、theirs は「自分の作業ブランチ側」になる。git checkout --ours で自分の変更を残したつもりが、相手の変更を採っていたという事故はこれが原因だ。
片側だけが変えた行は自動で混ざり、両側が変えた行だけが衝突することを確かめる。base・ours・theirs の 3 版をインデックスから取り出すところまで見る。
mkdir -p /tmp/merge3-demo && cd /tmp/merge3-demo git init -q -b main printf 'timeout = 30\nretries = 3\nlog = info\nport = 8080\nhost = localhost\n' > app.conf git add app.conf && git commit -q -m base git switch -q -c feature printf 'timeout = 60\nretries = 3\nlog = info\nport = 8080\nhost = 0.0.0.0\n' > app.conf git commit -q -am "feature: timeout 60, host 0.0.0.0" git switch -q main printf 'timeout = 10\nretries = 3\nlog = debug\nport = 8080\nhost = localhost\n' > app.conf git commit -q -am "main: timeout 10, log debug" git merge-base main feature git merge feature cat app.conf git checkout --conflict=diff3 app.conf && cat app.conf git ls-files -u git show :1:app.conf | head -1 git merge --abort
最初の cat では timeout の 1 行だけがマーカーに囲まれ、log = debug(main だけが変更)と host = 0.0.0.0(feature だけが変更)は自動で採用されているはずだ。diff3 で書き直した後は ||||||| の下に base の timeout = 30 が現れ、git ls-files -u は同じ app.conf をステージ 1・2・3 の 3 行で表示する。git merge-base が出力するハッシュは、最初の base コミットのものと一致する。
- 違う行を編集していれば衝突しない — git は隣接する行への変更も同じ領域として扱うため、間に無変更の行が無ければ別々の行の編集でもコンフリクトになる。
- コンフリクトが出なければマージ結果は正しい — git が見ているのは行テキストの重なりだけで、改名と呼び出し追加のような意味上の衝突は素通りする。マージ後のコードでテストを走らせて初めて分かる。
- ours はいつも自分の変更を指す — merge では ours が今いるブランチだが、rebase では取り込み先の側が ours、自分の作業が theirs になる。
- マージベース
- 2 つのコミットの共通祖先のうち最も新しいもの。3-way マージの比較基準になる。
- 3-way マージ
- base・ours・theirs の 3 版を比べ、片側だけの変更は採用し、両側の変更が重なる領域だけをコンフリクトにする方式。
- コンフリクトマーカー
- 衝突した領域を囲む
<<<<<<<・=======・>>>>>>>の印。diff3 系では base を示す|||||||が加わる。 - インデックスのステージ
- 衝突中のファイルについて、base を 1、ours を 2、theirs を 3 の番号で保持する仕組み。
- ort
- Git 2.34 以降のデフォルトのマージ戦略。複数のマージベースを合成した仮想 base を作り、リネームも検出する。
- rerere
- 一度解消したコンフリクトの解き方を記録し、同じ衝突の再発時に自動で適用する機能。
- git-merge Documentation — コンフリクトの表示形式(merge / diff3 / zdiff3 の実例)、インデックスのステージ、ort と recursive の関係を定義する一次資料。
- git-merge-base Documentation — 「最良の共通祖先」の定義と、criss-cross マージでマージベースが複数になる図解。
- Git Tools - Advanced Merging | Pro Git —
git show :1:での版の取り出し、checkout --conflict=diff3、git merge-fileによる手動再マージの実例。 - git-rerere Documentation — 解消結果を記録して再利用する仕組みと、長命ブランチでの使いどころ。