2021-09-13

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

gitのコンフリクト(衝突)について調べてみました。前置きとかもういいよね。

ちなみにこの記事ではrerereについては述べませんし、使われていない前提で書かれています(よくわかんないしね!)

info

コンフリクトの条件

その前にまず 3way merge から。出典: マージ(バージョン管理システム - Wikipedia)

3ウェイマージは、ファイル'A'と'B'の差分、およびそれらの親にあたるファイル'C'(多くの場合、ファイル'A'と'B'は共通の親を持つ)との差分の分析結果を元に行われる。

バージョン管理システムでは親にあたるファイルが必ず存在し、またどのファイルが親かもはっきりしているため、3ウェイマージの使用に適している。

マージ用ツールは各ファイル間の差分およびその中に現れるパターンを調査し、マージを行うためにファイル'A','B','C'の間の関係モデルを生成した上で、新しいリビジョン'D'を作り出す。

結論から言うとgitのデフォルトのマージアルゴリズムだと共通祖先との差分範囲が重なっており、重なった差分が異なる場合にコンフリクトとなるようです。

ということで以下のパターンにおいて、それぞれの「共通祖先との差分」と「マージ結果」をみていきましょう。

共通祖先は conflict-test というブランチを使います。diffも共通祖先リビジョンで行っています。

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

差分範囲は
    • 重なっている
    • 重なっている
    • 重なっている
    • 重なっている
重なった範囲は
    • 一致していない
    • 片方が空行
    • 前方一致している
    • 行レベルでは一致しているが片方が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)対象の区別です。 右側はファイル名です。

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

差分範囲は
    • 重なって いない
    • ファイルは同じだが範囲は重なって いない
    • 重なっている
重なった範囲は
    • ない
    • ない
    • 全く同じ
共通祖先とのマージ元の差分
    • $ 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 にリベースしてみましょう。

今回使うファイルは test です。

この2つのブランチは「conflict-test」ブランチを基点として以下のような手順で作られています。

ブランチ
    • 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」を追記
5
    • 何もしない
    • 4行目の「h」を「H」に書き換え

この状態でリベースを行うと当然のように各行がぶつかります。 その解決内容を以下に示します。

1行目の衝突(aを採用)

1回目の衝突ではそれぞれのブランチで1行目の ae がぶつかるので、 rebase-order-froma を採用します。

リベース先の衝突部分がHEADの差分に出てきます。

  • 解決前
  • 解決後
  • <<<<<<< HEAD e f g H ======= a >>>>>>> a
  • a f g H

この状態で $ git rebase --continue を行うと次の衝突が起こります。

2行目の衝突(bを採用)

2回目の衝突ではそれぞれのブランチで2行目の bf がぶつかるので、 rebase-order-fromb を採用します。

  • 解決前
  • 解決後
  • a <<<<<<< HEAD f g H ======= b >>>>>>> b
  • a b g H

この状態で $ git rebase --continue を行うと次の衝突が起こります。

3-4行目の衝突(gHを採用)

3回目の衝突ではそれぞれのブランチで3-4行目の cdgH がぶつかるので、 今回は rebase-order-togH を採用します。

  • 解決前
  • 解決後
  • a b <<<<<<< HEAD g H ======= c d >>>>>>> c-d
  • a b g H

この状態で $ git rebase --continue を行うと次の衝突が起こります。

3-4行目の衝突(cHを採用)

4回目の衝突ではそれぞれのブランチで3-4行目の cDgH がぶつかるので、 rebase-order-fromcrebase-order-toH を採用します。

  • 解決前
  • 解決後
  • a b <<<<<<< HEAD g H ======= c D >>>>>>> d->D
  • a b c H

リベース元では 4行目の「d」を「D」に書き換えただけですが、 gH の2行と衝突しています。これは前の衝突で gH を採用した結果、リベース元の c が反映されていないからです。

この状態で $ git rebase --continue を行うとリベースが完了します。

リベース完了後

最終的なリビジョングラフは以下のようになります。

上記の結果から以下のことがわかります。

  • コンフリクトの解消はリベース元ブランチのリビジョン単位で行われる

    • 4行目の「h」を「H」に書き換えたものが1度目の衝突の時点で現れていることから、 リベース操作はリベース先のリビジョンのみに対して行われるのであり、リベース先の古いリビジョン(共通親以降の各リビジョン)までは見ていない
  • リベース先の変更をそのまま採用するときはコミットログに残らない

    • 3回目の衝突で gH を採用したときに 3,4行目に「c」「d」を追記 したリビジョンがリベース後のログから消えている
  • 衝突する行数は必ずしもリベース元リビジョンと同じとは限らない

    • 衝突が複数回起こった場合、前の衝突でリベース先の差分を採用すると変更前の行も合わせて(何度も)衝突する可能性がある

image0

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