ume

conflictの深掘り

#前書き 普段gitを何気なく使っていますがconflictという言葉とざっくりとした意味は知っているのですがどういう時にconflictが起きるのか具体的に理解していなかったので今回は実際に意図的にconflictを起こし挙動を詳しくみていきたいと思います。

目次

①コンフリクトとは.
②なぜコンフリクトは起こるのか?.

コンフリクト

Gitにおいて、コミット履歴を持つ2つ以上のブランチを統合する場合には、競合 = コンフリクト が起こる場合があります。 例えば2つのブランチをマージする際に両方のブランチで同じ行を編集している場合はコンフリクトが発生するため、Gitは自動的にマージをせずに開発者自身にコンフリクトの解消を求めます。 コンフリクトしていない場合はGitが自動的にマージを行い、コミットもされます。コンフリクトしている場合は、競合の解消、解消後のマージの両方を開発者自身が行う必要があります。

下準備

1つのテキストファイルを持つリポジトリを作成します。

% mkdir conflict-sample
% cd conflict-sample
% git init
Initialized empty Git repository in /xxxx/conflict-sample/.git/
% touch test.txt
% cat << EOF > test.txt
Line 1 
Line 2
Line 3
EOF 
% cat test.txt 
Line 1
Line 2
Line 3
% git add test.txt
% git commit -m "first commit"
[main (root-commit) 0049849] first commit
 1 file changed, 3 insertions(+)
 create mode 100644 test.txt
% git log --graph --pretty=format:"%h %s"
* 0049849 first commit
% git status
On branch main
nothing to commit, working tree clean

注意: ターミナルで実行したヒアドキュメントをそのまま貼るとうまく解釈されず、フォーマットがおかしなことになったので一部修正しています。

mainブランチに1つコミットが入りました。

コンフリクトが発生しない場合

まずはコンフリクトが発生しない場合の動きを見てみます。 mainブランチから新しいブランチ no-conflict を作成します。

% git branch
* main
% git switch -c no-conflict
Switched to a new branch 'no-conflict'
% git log --graph --pretty=format:"%h %s %d"
* 0049849 first commit  (HEAD -> no-conflict, main)

mainとno-conflictは同じ状態になっています。

ここでmainブランチに変更を加えます。4行目を追加します。

% git switch main
Switched to branch 'main'
% cat << EOF >> test.txt
Line 4
EOF
% cat test.txt 
Line 1
Line 2
Line 3
Line 4
% git add test.txt 
% git commit -m "Line4を追加(mainブランチ)" 
[main 218ca30] Line4を追加(mainブランチ)
 1 file changed, 1 insertion(+)

続いてno-conflictブランチにも変更を加えます。1行目に修正を加えます。

% git switch no-conflict
Switched to branch 'no-conflict'
% cat << EOF > test.txt
Line 1 no-conflict
Line 2
Line 3
EOF
% cat test.txt 
Line 1 no-conflict
Line 2
Line 3
% git diff
diff --git a/test.txt b/test.txt
index 6ad36e5..6b03856 100644
--- a/test.txt
+++ b/test.txt
@@ -1,3 +1,3 @@
-Line 1
+Line 1 no-conflict
 Line 2
 Line 3
% git add test.txt 
% git commit -m "Line 1の末尾にブランチ名を追加(no-conflictブランチ)"
[no-conflict ee25571] Line 1の末尾にブランチ名を追加(no-conflictブランチ)
 1 file changed, 1 insertion(+), 1 deletion(-)

2つのブランチの履歴は以下のようになっています。

% git log --graph --pretty=format:"%h %s %d" main no-conflict
* ee25571 Line 1の末尾にブランチ名を追加(no-conflictブランチ)  (HEAD -> no-conflict)
| * 218ca30 Line4を追加(mainブランチ)  (main)
|/  
* 0049849 first commit

では、mainブランチにno-conflictブランチをマージしてみます。

% git switch main
Switched to branch 'main'
% git merge no-conflict
Auto-merging test.txt
Merge made by the 'recursive' strategy.
 test.txt | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)
% cat test.txt 
Line 1 no-conflict
Line 2
Line 3
Line 4

コミットメッセージを修正するための画面が開くものの、自動でマージされました。

履歴は以下のようになっています。

% git log --graph --pretty=format:"%h %s %d" main no-conflict
*   2c494a9 Merge branch 'no-conflict'  (HEAD -> main)
|\  
| * ee25571 Line 1の末尾にブランチ名を追加(no-conflictブランチ)  (no-conflict)
* | 218ca30 Line4を追加(mainブランチ) 
|/  
* 0049849 first commit

2つのブランチで、それぞれ違う行に対して変更が行われたため競合は発生しません。 mergeコマンドを実行するだけで、マージ、コミットまでされています。

コンフリクトが発生する場合

本題のコンフリクトが発生する場合です。

mainブランチから conflict ブランチを作成します。

% git switch main
Already on 'main'
% git switch -c conflict
Switched to a new branch 'conflict'
% git log --graph --pretty=format:"%h %s %d"
*   2c494a9 Merge branch 'no-conflict'  (HEAD -> conflict, main)
|\  
| * ee25571 Line 1の末尾にブランチ名を追加(no-conflictブランチ)  (no-conflict)
* | 218ca30 Line4を追加(mainブランチ) 
|/  
* 0049849 first commit

2つのブランチの同じ行に、それぞれ違う修正を加えます。 今回はそれぞれのブランチに5行目を追加します。

まずはmainブランチで5行目を追加します。

% git switch main
Switched to branch 'main'
% cat << EOF >> test.txt
Line 5 main
EOF
% cat test.txt 
Line 1 no-conflict
Line 2
Line 3
Line 4
Line 5 main
% git add test.txt
% git commit -m "5行目を追加(mainブランチ)"
[main 7118de9] 5行目を追加(mainブランチ)
 1 file changed, 1 insertion(+)

続いてconflictブランチで5行目を追加します。

% git switch conflict
Switched to branch 'conflict'
% cat << EOF >> test.txt
Line 5 conflict
EOF
% cat test.txt 
Line 1 no-conflict
Line 2
Line 3
Line 4
Line 5 conflict
% git add test.txt
% git commit -m "5行目を追加(conflictブランチ)"
[conflict 5155112] 5行目を追加(conflictブランチ)
 1 file changed, 1 insertion(+)

2つのブランチの履歴は以下のようになっています。

% git log --graph --pretty=format:"%h %s %d" main conflict 
* 5155112 5行目を追加(conflictブランチ)  (HEAD -> conflict)
| * 7118de9 5行目を追加(mainブランチ)  (main)
|/  
*   2c494a9 Merge branch 'no-conflict' 
|\  
| * ee25571 Line 1の末尾にブランチ名を追加(no-conflictブランチ)  (no-conflict)
* | 218ca30 Line4を追加(mainブランチ) 
|/  
* 0049849 first commit 

マージする

では、mainにconflictブランチをマージしてみます。

% git switch main
Switched to branch 'main'
% git merge conflict
Auto-merging test.txt
CONFLICT (content): Merge conflict in test.txt
Automatic merge failed; fix conflicts and then commit the result.

コンフリクトしました。同じ行が変更されているため、Gitでは2つの変更をどのようにマージすればいいのかがわかりません。よって人間の手で解決する必要があります。メッセージを詳しく確認します。

CONFLICT (content): Merge conflict in test.txt 「test.txtでコンフリクトが起きています。」ということが言われています。

Automatic merge failed; fix conflicts and then commit the result. 「自動マージは失敗しました。コンフリクトを解消し、その結果をコミットしてください。」と言われています。

この時の状態を見て見ましょう。

% git status
On branch main
You have unmerged paths.
  (fix conflicts and run "git commit")
  (use "git merge --abort" to abort the merge)

Unmerged paths:
  (use "git add <file>..." to mark resolution)
    both modified:   test.txt

no changes added to commit (use "git add" and/or "git commit -a")

You have unmerged paths. ということで、未マージのものがあるということが言われています。

test.txtの中身も見てみましょう。

% cat test.txt
Line 1 no-conflict
Line 2
Line 3
Line 4
<<<<<<< HEAD
Line 5 main
=======
Line 5 conflict
>>>>>>> conflict

上記のように、マージしたときにコンフリクトがある場合、ファイルにもコンフリクトしている部分がわかるように変更が入っています。 コフリクトが発生した時の流れは、マージ(コンフリクトが発生、ファイル自体にもコンフリクトである部分がわかるように変更が入る) -> コンフリクトを解消する(ファイルを修正) -> コミットする、となります。

<<<<<<< HEAD から >>>>>>> conflict の間が実際にコンフリクトしている部分です。この部分を実際にファイルを編集することによって正しい状態にすることがコンフリクトの解消になります。

ここだけ抜き出して詳しくみてみましょう。

コンフリクト箇所を詳しくみてみる

<<<<<<< HEAD
Line 5 main
=======
Line 5 conflict
>>>>>>> conflict

<<<<<<<、 =======、 >>>>>>> を3wayマージマーカラインと言ったり、マージマーカと言ったりするようです(オライリー実用Git 』より)。 人が読むことが意図されて装飾されています。

まず、<<<<<<<から =======までの部分が、mainブランチでの変更箇所になります。

<<<<<<< HEAD
Line 5 main
=======

次に、=======から>>>>>>>までの部分が、conflictブランチでの変更箇所になります。

=======
Line 5 conflict
>>>>>>> conflict

HEAD がついている方が、今チェックアウトしているブランチ(=mainブランチ)の変更点となります。GitにおけるHEADは、現在チェックアウトしているブランチの最新コミットを指します。現在チェックアウトしているブランチ(=mainブランチ)の最新コミット(まだマージコミットはされていないので、この段階の最新コミットは7118de9)との差分、という意味ですね。

コンフリクトの解消

とうとうコンフリクトの解消をしてみます。 今回は Line 5 main と Line 5 conflict がコンフリクトしているわけですが、どちらのブランチでも同じ変更を入れているので、末尾のブランチ名を削って Line 5 だけにしてしまいましょう。 ターミナルでやるのが面倒になってきたので、テキストファイルを開いて修正します。

今こうなっているものを

Line 1 no-conflict
Line 2
Line 3
Line 4
<<<<<<< HEAD
Line 5 main
=======
Line 5 conflict
>>>>>>> conflict

以下のようにします。

Line 1 no-conflict
Line 2
Line 3
Line 4
Line 5

<<<<<<< HEAD から >>>>>>> conflict までの間をバッサリ消して、 Line 5 にしました。 コンフリクトを解消できました!

コンフリクトが発生せずに自動マージされた場合はコミットまで行われましたが、コンフリクトが発生した場合は、自分でコミットまで行う必要があります。

まずは今の状態を確認します。

% git status
On branch main
You have unmerged paths.
  (fix conflicts and run "git commit")
  (use "git merge --abort" to abort the merge)

Unmerged paths:
  (use "git add <file>..." to mark resolution)
    both modified:   test.txt

no changes added to commit (use "git add" and/or "git commit -a")

use "git add <file>..." to mark resolution とあります。 「"git add ..." を使って、解決したとマークしてくれ(教えてくれ)」と言われています。addしてみます。

% git add test.txt 
% git status
On branch main
All conflicts fixed but you are still merging.
  (use "git commit" to conclude merge)

Changes to be committed:
    modified:   test.txt

メッセージが変わりました。 All conflicts fixed but you are still merging. 「全てのコンフリクトは解消されました。が、まだマージ作業中です。」 use "git commit" to conclude merge 「"git commit"を使ってマージを完了してください」 コミットしましょう。

% git commit
[main 11cc5d4] Merge branch 'conflict'

コミットメッセージを修正するための画面が開きますが、デフォルトでメッセージが入っていますので、そのまま変更せずにコミットすることができます。

履歴やファイルを見てみます。

% cat test.txt 
Line 1 no-conflict
Line 2
Line 3
Line 4
Line 5

% git log --graph --pretty=format:"%h %s %d" main conflict 
*   11cc5d4 Merge branch 'conflict'  (HEAD -> main)
|\  
| * 5155112 5行目を追加(conflictブランチ)  (conflict)
* | 7118de9 5行目を追加(mainブランチ) 
|/  
*   2c494a9 Merge branch 'no-conflict' 
|\  
| * ee25571 Line 1の末尾にブランチ名を追加(no-conflictブランチ)  (no-conflict)
* | 218ca30 Line4を追加(mainブランチ) 
|/  
* 0049849 first commit

conflictブランチが無事、mainブランチにマージされました!

マージコミットを見てみましょう。

 % git show 11cc5d4
commit 11cc5d41665f44c56bf2de5f95aa783c4c7e7964 (HEAD -> main)
Merge: 7118de9 5155112
Author: chihiro <xxxx>
Date:   Fri Aug 20 08:18:01 2021 +0900

    Merge branch 'conflict'

diff --cc test.txt
index 1ff80e2,7d68217..1ac3a4d
--- a/test.txt
+++ b/test.txt
@@@ -2,4 -2,4 +2,4 @@@ Line 1 no-conflic
  Line 2
  Line 3
  Line 4
- Line 5 main
 -Line 5 conflict
++Line 5

diff 部分を見ると、 Line 5 mainLine 5 conflict が削除され、 Line 5 が追加された、と表現されています。

コンフリクトは良くないもの?

そんなことはありません(と私は思っています)。複数人で並行開発をしていればコンフリクトは普通に起こります。ですが、コンフリクトの解消をミスしてしまう(他の人が書いたコードを消してしまったり、意図しない修正をしてしまう)とバグとなってしまいますし、コンフリクトの内容が複雑であればあるほど解消に時間がかかる、というのも事実ではあると思います。そのため、詳しくは述べませんが以下のようなやり方(他にもいろいろあると思います)を部分的にでも取り入れて、不要なコンフリクトはなくすというのも大事だと思います。

  • feature flag を使うなどして、こまめに中央ブランチにマージする(=ビッグバンマージを避け、コンフリクトの量を減らす)
  • コンフリクトが起こっている部分の開発を担当したメンバーとペアでコンフリクトの解消をする(=コンフリクトの解消ミスを抑制)
  • ペアプロ、モブプロなどを取り入れることで並行作業を減らす(=そもそもコンフリクトが起こる機会を減らす)

最後に

大分長くなってしまいましたが、シンプルなコンフリクトの例を用いてまとめました。知ることによって不安が減ると思うので、よくわからないな〜と思っている方はぜひ自分の環境でやってみてほしいと思います。