[Git] コンフリクトについて少し調べた

gitのコンフリクト(衝突)について調べてみました。前置きとかもういいよね。
ちなみにこの記事ではrerereについては述べませんし、使われていない前提で書かれています(よくわかんないしね!)

この記事の操作は手元で再現することができます。
試したい方は「git clone https://github.com/righ/gittest.git」としましょう。Gitクライアントがインストールされてることが前提です。各操作毎にブランチを分けているのでチェックアウトして使ってください。気軽にStarつけてね!
どのようなコミットグラフになっているかはGithubで見るか、クローンしてGUIクライアントで見るなりしてください。

コンフリクトの条件

その前にまず3way mergeから(Wikipediaより)

3ウェイマージは、ファイル’A’と’B’の差分、およびそれらの親にあたるファイル’C’(多くの場合、ファイル’A’と’B’は共通の親を持つ)との差分の分析結果を元に行われる。バージョン管理システムでは親にあたるファイルが必ず存在し、またどのファイルが親かもはっきりしているため、3ウェイマージの使用に適している。マージ用ツールは各ファイル間の差分およびその中に現れるパターンを調査し、マージを行うためにファイル’A’,’B’,’C’の間の関係モデルを生成した上で、新しいリビジョン’D’を作り出す。

結論から言うとgitのデフォルトのマージアルゴリズムだと共通祖先との差分範囲が重なっており、重なった差分が異なる場合にコンフリクトとなるようです。
ということで以下のパターンにおいて、それぞれの「共通祖先との差分」と「マージ結果」をみていきましょう。
共通祖先は「conflict-test」というブランチを使います。diffも共通祖先リビジョンで行っています。

まず、マージに失敗するパターンから。

パターン1 パターン2 パターン3 パターン4
差分範囲は 重なっている 重なっている 重なっている 重なっている
重なった範囲は 一致していない 片方が空行 前方一致している 行レベルでは一致しているが片方が1行多い
共通祖先とのマージ元の差分
$ git diff -R fail1-from
diff --git b/a a/a
index da0f8ed..0016606 100644
--- b/a
+++ a/a
@@ -1 +1,2 @@
 a1
+a2
$ git diff -R fail2-from
diff --git b/a a/a
index da0f8ed..2cdcdb0 100644
--- b/a
+++ a/a
@@ -1 +1,3 @@
 a1
+a2
+a3
$ git diff -R fail3-from
diff --git b/a a/a
index da0f8ed..0016606 100644
--- b/a
+++ a/a
@@ -1 +1,2 @@
 a1
+a2
$ git diff -R fail4-from
diff --git b/a a/a
index da0f8ed..0016606 100644
--- b/a
+++ a/a
@@ -1 +1,2 @@
 a1
+a2
共通祖先とのマージ先の差分
$ git diff -R fail1-to
diff --git b/a a/a
index da0f8ed..cd03f2c 100644
--- b/a
+++ a/a
@@ -1 +1,2 @@
 a1
+A2
$ git diff -R fail2-to
diff --git b/a a/a
index da0f8ed..df67604 100644
--- b/a
+++ a/a
@@ -1 +1,3 @@
 a1
+
+a3
$ git diff -R fail3-to
diff --git b/a a/a
index da0f8ed..e39b41b 100644
--- b/a
+++ a/a
@@ -1 +1,2 @@
 a1
+a2.0
$ git diff -R fail4-to
diff --git b/a a/a
index da0f8ed..2cdcdb0 100644
--- b/a
+++ a/a
@@ -1 +1,3 @@
 a1
+a2
+a3
結果
$ git checkout fail1-to
Switched to branch 'fail1-to'

$ git merge fail1-from
Auto-merging a
CONFLICT (content): Merge conflict in a
Automatic merge failed; fix conflicts and then commit the result.

$ cat a
a1
<<<<<<< HEAD
A2
=======
a2
>>>>>>> fail1-from
$ git checkout fail2-to
Switched to branch 'fail2-to'

$ git merge fail2-from
Auto-merging a
CONFLICT (content): Merge conflict in a
Automatic merge failed; fix conflicts and then commit the result.

$ cat a
a1
<<<<<<< HEAD

=======
a2
>>>>>>> fail2-from
a3
$ git checkout fail3-to
Switched to branch 'fail3-to'

$ git merge fail3-from
Auto-merging a
CONFLICT (content): Merge conflict in a
Automatic merge failed; fix conflicts and then commit the result.

$ cat a
a1
<<<<<<< HEAD
a2.0
=======
a2
>>>>>>> fail3-from
$ git checkout fail4-to
Switched to branch 'fail4-to'

$ git merge fail4-from
Auto-merging a
CONFLICT (content): Merge conflict in a
Automatic merge failed; fix conflicts and then commit the result.

$ cat a
a1
a2
<<<<<<< HEAD
a3
=======
>>>>>>> fail4-from

diffの「/」(スラッシュ)左の「a」やら「b」はファイル名とは関係なく差分(diff)対象の区別です。右側はファイル名です。

続いてマージに成功するパターンです。

パターン5 パターン6 パターン7
差分範囲は 重なっていない ファイルは同じだが範囲は重なっていない 重なっている
重なった範囲は - - 全く同じ
共通祖先とのマージ元の差分
$ git diff -R success1-from
diff --git b/a a/a
index da0f8ed..0016606 100644
--- b/a
+++ a/a
@@ -1 +1,2 @@
 a1
+a2
$ git diff -R success2-from
diff --git b/b a/b
index 9b89cd5..5b27bfa 100644
--- b/b
+++ a/b
@@ -1,2 +1,3 @@
 b1
 b2
+b3
$ git diff -R success3-from
diff --git b/a a/a
index da0f8ed..0016606 100644
--- b/a
+++ a/a
@@ -1 +1,2 @@
 a1
+a2
共通祖先とのマージ先の差分
$ git diff -R success1-to
diff --git b/b a/b
index 9b89cd5..5b27bfa 100644
--- b/b
+++ a/b
@@ -1,2 +1,3 @@
 b1
 b2
+b3
$ git diff -R success2-to
diff --git b/b a/b
index 9b89cd5..bbf52d2 100644
--- b/b
+++ a/b
@@ -1,2 +1,5 @@
 b1
+b1.5
+b1.6
+b1.7
 b2
$ git diff -R success3-to
diff --git b/a a/a
index da0f8ed..0016606 100644
--- b/a
+++ a/a
@@ -1 +1,2 @@
 a1
+a2
結果
$ git checkout success1-to
Switched to branch 'success1-to'

$ git merge success1-from
Merge made by the 'recursive' strategy.
 a | 1 +
1 file changed, 1 insertion(+)

$ cat a b
a1
a2
b1
b2
b3
$ git checkout success2-to
Switched to branch 'success2-to'

$ git merge success2-from
Auto-merging b
Merge made by the 'recursive' strategy.
 b | 1 +
 1 file changed, 1 insertion(+)

$ cat b
b1
b1.5
b1.6
b1.7
b2
b3
$ git checkout success3-to
Switched to branch 'success3-to'

$ git merge success3-from
Merge made by the 'recursive' strategy.

$ cat a
a1
a2

空行(パターン2)だったら優先されるとか、前方一致(パターン3)なら補完してくれるとかそんな幻想をぶち壊されますね。

ちょっと引っかかるのは片方の差分で行が多いだけ(パターン4)ならマージできそうだと思ってしまいそうなところです。行末の改行コードの有無とかでぶつかっちゃうのかな。知ってる人は教えてください。

マージとリベースの違い

マージとリベース、差分を統合する操作として何が違うかといえば、「マージコミットができる」とか「履歴が綺麗になる」とかいろいろあると思いますが、コンフリクトという視点で見た時に大事なのは、差分を「一気に適用するか」「一つ一つ適用するか」だと思います。

これによって何が変わるかを以下の例でみていきましょう。
具体的に何をやっているかというと「a2」->「A2」行を書き換えたコミットと「a2」もしくは「A2」という行をもつコミットに統合を試みた時の「マージ」と「リベース」の成功・失敗を比較しています。

マージ リベース
先に一致
$ git checkout merge1-to
Switched to branch 'merge1-to'

$ git merge merge1-from
Auto-merging a
CONFLICT (content): Merge conflict in a
Automatic merge failed; fix conflicts and then commit the result.

$ cat a
a1
<<<<<<< HEAD
a2
=======
A2
>>>>>>> merge1-from
# リベース元ブランチで変更があったケース
$ git checkout rebase1-from
Switched to branch 'rebase1-from'

$ git rebase rebase1-to
First, rewinding head to replay your work on top of it...
Applying: a2->A2

$ cat a
a1
A2

# リベース先ブランチで変更があったケース
$ git checkout rebase3-from
Switched to branch 'rebase3-from'

$ git rebase rebase3-to
First, rewinding head to replay your work on top of it...

$ cat a
a1
A2

後で一致
$ git checkout merge2-to
Switched to branch 'merge2-to'
$ git merge merge2-from
Merge made by the 'recursive' strategy.
$ cat a
a1
A2
$ git checkout rebase2-from
Switched to branch 'rebase2-from'

$ git rebase rebase2-to
First, rewinding head to replay your work on top of it...
Applying: a2
Using index info to reconstruct a base tree...
M	a
Falling back to patching base and 3-way merge...
Auto-merging a
CONFLICT (content): Merge conflict in a
error: Failed to merge in the changes.
Patch failed at 0001 a2
The copy of the patch that failed is found in: .git/rebase-apply/patch

When you have resolved this problem, run "git rebase --continue".
If you prefer to skip this patch, run "git rebase --skip" instead.
To check out the original branch and stop rebasing, run "git rebase --abort".

$ cat a
a1
<<<<<<< 425f1316f7737647622dcc59f9c6b94c12a2ee16
A2
=======
a2
>>>>>>> a2

マージは「結果重視」です。
「マージ先と共通祖先の差分」と「マージ元と共通祖先の差分」のみを比較します。
そのため最終的な差分がなければコンフリクトは発生しませんが、途中まで同じ差分を持っていて、片方のみで変更された場合でもコンフリクトとなります。

リベースは「過程重視」です。
別の記事でも書きましたが、リベースは差分を一つ一つ比較していきます。
途中まで同じ差分を持っていて、その後片方のみで変更が行われた場合、リベースではコンフリクトとはなりません。
逆に最後で帳尻を合わせ同じ差分としても途中で異なる差分がある場合はコンフリクトとなります。

個人的にリベースのほうが少し人間的に感じますが、どちらも一長一短があるということです。

さて、ここでリベースのコンフリクト解決順について少し実験をしてみましょう。

「rebase-order-from」から「rebase-order-to」にリベースしてみましょう。
ちなみにこの2つのブランチは「conflict-test」ブランチを基点として以下のように作られています。今回使うファイルは「test」です。

No rebase-order-from rebase-order-to
1 1行目に「a」を追記 1行目に「e」を追記
2 2行目に「b」を追記 2行目に「f」を追記
3 3,4行目に「c」「d」を追記 3行目に「g」を追記
4 4行目の「d」を「D」に書き換え 4行目に「h」を追記

この状態で、この2つのブランチをリベースしてみましょう。

$ git checkout rebase-order-from
$ git rebase rebase-order-to

First, rewinding head to replay your work on top of it...
Applying: a
Using index info to reconstruct a base tree...
Falling back to patching base and 3-way merge...
Auto-merging test
CONFLICT (add/add): Merge conflict in test
error: Failed to merge in the changes.
Patch failed at 0001 a
The copy of the patch that failed is found in: .git/rebase-apply/patch

When you have resolved this problem, run "git rebase --continue".
If you prefer to skip this patch, run "git rebase --skip" instead.
To check out the original branch and stop rebasing, run "git rebase --abort".
解決前 解決後
e
f
g
h
=======
a
>>>>>>> a
a
f
g
h
$ git add test
$ git rebase --continue
Applying: a
Applying: b
Using index info to reconstruct a base tree...
M	test
Falling back to patching base and 3-way merge...
Auto-merging test
CONFLICT (content): Merge conflict in test
error: Failed to merge in the changes.
Patch failed at 0002 b
The copy of the patch that failed is found in: .git/rebase-apply/patch

When you have resolved this problem, run "git rebase --continue".
If you prefer to skip this patch, run "git rebase --skip" instead.
To check out the original branch and stop rebasing, run "git rebase --abort".
解決前 解決後
a
<<<<<<< 7d3691734fc3f25b089ba136cdf068096dffa17f
f
g
h
=======
b
>>>>>>> b
a
b
g
h
$ git add test

$ git rebase --continue
Applying: b
Applying: c-d
Using index info to reconstruct a base tree...
M	test
Falling back to patching base and 3-way merge...
Auto-merging test
CONFLICT (content): Merge conflict in test
error: Failed to merge in the changes.
Patch failed at 0003 c-d
The copy of the patch that failed is found in: .git/rebase-apply/patch

When you have resolved this problem, run "git rebase --continue".
If you prefer to skip this patch, run "git rebase --skip" instead.
To check out the original branch and stop rebasing, run "git rebase --abort".
解決前 解決後
a
b
<<<<<<< 88676a25037d3982f693982a8279165d4b534e95
g
h
=======
c
d
>>>>>>> c-d
a
b
g
h

「c」「d」ではなく「g」「h」の差分を採用

$ git add test
$ git rebase --continue
Applying: c-d
No changes - did you forget to use 'git add'?
If there is nothing left to stage, chances are that something else
already introduced the same changes; you might want to skip this patch.

When you have resolved this problem, run "git rebase --continue".
If you prefer to skip this patch, run "git rebase --skip" instead.
To check out the original branch and stop rebasing, run "git rebase --abort".

$ git rebase --skip
解決前 解決後
a
b
<<<<<<< 88676a25037d3982f693982a8279165d4b534e95
g
h
=======
c
D
>>>>>>> d->D
a
b
c
h

「g」行ではなく「c」行を採用、「D」行ではなく「h」行を採用。

$ git add test
$ git rebase --continue
Applying: d->D

$ cat test
a
b
c
h

$ git log --oneline
7c18f81 d->D
c8abff3 b
f249868 a
f76dce8 h
2e903ce g
8853eae f
68100ef e
c563014 a1/b1-b2
c9ea552 wrote README

上記の操作から以下がわかります。
①コンフリクトの解消はリベース元ブランチのリビジョン単位で行われる
②コンフリクトの解消でリベース元差分を選択しなくてもログには残る。ログに残さないためにはskipする。

この結果からだとリベース先ブランチ側は最後の差分と比較されているように見えますが、リベースリビジョンもリベースリビジョンも古い順に一つずつ比較されていくようです。

マージツール使ったコンフリクトの解消はいつか。いつか。