以下は社内向け勉強会のLT枠で話した内容をベースにして編集増補したものである。増補しただけでなくそもそも1回分を記事にしたものではなかったりもするので、この内容を5分で話したわけではない。

git mergeコマンドの概要

git mergeはブランチの履歴にないコミットのファイル変更を取り込むためのコマンドである。

git mergeはgit pullによっても暗黙的に実行される。つまり、git pullは内部的にはgit fetchを行い、続けてgit mergeを行っている。ただしgit pullに–rebaseオプションを指定していればgit mergeの代わりにgit rebaseが実行される。

git mergeコマンドの形

git mergeコマンドには、これからマージを開始する時に実行するコマンドと、競合によって等でマージが中断している時に実行するコマンドの2種類がある。

これからマージを開始するためのgit mergeコマンドはgit merge [オプション] <commit-ish>...である。取り込まれる先はHEADの参照先、つまりdetached HEAD状態でなければ現在作業中のブランチである。以降、HEADの参照先をdetached HEADの状態である時もまとめて「現在作業中のブランチ」と表現する。

commit-ishは複数指定可能であり、その場合指定したすべてを取り込むことになる。この複数指定の中に現在作業中のブランチを指定することもできるが、その指定は無視されて指定していない時と同じ挙動を示す。つまり、現在作業中のブランチが指定されていれば取り込まれるのは指定した他のすべてのcommit-ishであるし、現在作業中のブランチが指定されていなければ取り込まれるのは指定したすべてのcommit-ishである。

2つ以上のcommit-ishを取り込むマージにはoctopus mergeという通称があり、1つだけを取り込む場合だったならば取り込めるような差分であっても取り込めない、という制限が発生することがある。

競合によって等でマージが中断している時のためのコマンドはgit merge <オプション>でオプション以外の引数、つまりcommit-ishを指定できない。ここで指定できるオプションは–continue, –abort, –quitの3種類のうち1つであり、他のオプションも指定できない。

fast-forward mergeとtrue merge

マージには新たにコミットが作成される場合とされない場合とがある。コミットが作成されない方のマージをfast-forward merge、される方のマージをtrue mergeと呼ぶ。ただし、true mergeという表現はman git-mergeのセクションタイトルの1箇所でしか見たことがない(本文にもない)。Web検索した限りではnon-fast-forward mergeと書かれていることが多いが、ここではmanに従いtrue mergeと書くことにする。

fast-forward mergeが可能なのは、次のように取り込まれる側のコミット履歴が現在作業中のブランチのコミット履歴の子孫である場合である。

      C---D 取り込まれる側
     /
A---B       現在作業中のブランチ

この取り込まれる側がコミットDを、現在作業中のブランチがコミットBを参照している状態でfast-forward mergeを行うと、現在作業中のブランチもコミットDを参照するようになる。この時、新たにコミットは作成されていない。

A---B---C---D 現在作業中のブランチ & 取り込まれた側

取り込まれる側のコミット履歴が現在作業中のブランチのコミット履歴の子孫であってもオプション–no-ffを指定すればtrue mergeを行うこともできる。true mergeではコミットが作成される。上記の図でtrue mergeを行った場合次のようになる。

      C---D      取り込まれる側
     /     \
A---B-------E    現在作業中のブランチ

コミットEが新たに作られ、現在作業中のブランチの参照するコミットがBからこのEに変更される。このコミットのことを特に「マージコミット」と呼ぶ。

マージコミットと通常のコミットとの違いは親を2つ以上持つことだけで、このマージコミットEの親はBとDの両方である。Gitはコミットの親を使って履歴を辿っているので、マージコミットから見てそれぞれを履歴として扱うには複数の親を設定する必要があるわけである。各種ツールやgit log --graph等を使ってコミットグラフを見ればいくらか流れがわかりやすい。

余談だが、コミットが簡単に親を複数設定できるのは、コミットがファイルの差分ではなくスナップショットを持っているからである。もし差分を持っているとしたらすべての親からの差分を保持しなければならないが、スナップショットなら親が1つだろうと複数だろうと一緒である。

これまでは取り込まれる側が現在作業中のブランチの子孫である例を示していたが、true mergeは取り込まれる側が現在作業中のブランチの子孫でなくても可能である。

      C---D 取り込まれる側
     /
A---B---E   現在作業中のブランチ

この状態でマージすると、

      C---D     取り込まれる側
     /     \
A---B---E---F   現在作業中のブランチ

親DとEを持つマージコミットFを作ってマージされ、現在作業中のブランチの参照するコミットがFに変更されるわけである。

ちなみに現在作業中のブランチが取り込まれる側の子孫である場合や、両方が同じコミットを指している場合はマージしようとしても「Already up to date.」と言われて何も起こらない。現在作業中のブランチと取り込まれる側に共通祖先がなく–allow-unrelated-historiesオプションが指定されていなければ「fatal: refusing to merge unrelated histories」と言われてエラーになる。

取り込まれる側が1つだけの場合、各ファイルをマージするのにtrue mergeのデフォルトでは「取り込まれる側コミットと共通祖先コミットとの差分」と「現在作業中のブランチのコミットと共通祖先コミットとの差分」とを比較して行われる。「取り込まれる側コミット」「現在作業中のブランチのコミット」「共通祖先コミット」という3箇所が比較元となっていることから、このようなアルゴリズムを「3-way merge algorithm」と呼ぶ。各ファイルについておおよそdiff3 -m <作業中側> <共通祖先> <取り込まれる側>が行われているわけである。diff3の-mオプションは–mergeの短縮形である。

3-way merge algorithmを使わない設定も-s(あるいは–strategy)オプションで可能である。-sで指定するものをマージストラテジと呼ぶ。「-s octopus」と指定すると複雑なマージがあればエラーにし、「-s ours」とすると自分以外の変更は無視する。2つ以上を取り込む場合はこのどちらかしか選べない(デフォルトはoctopus)、というのが前述の「1つだけを取り込む場合だったならば取り込めるような差分であっても取り込めない、という制限」である。1つだけ取り込む場合のデフォルトはGit v2.33以降3-way merge algorithmを使用するortマージストラテジである。それ以前のデフォルトだった同じく3-way merge algorithmを使用するrecursiveマージストラテジを高速化したものである。その他にも細かく挙動の違ういくつかのマージストラテジがある。

fast-forward mergeかtrue mergeかを選択するgit mergeのオプションは以下の通りである。これらのオプションがどれも指定されていない場合は–ffが指定されている時と同じになる。

  • –ff

可能ならfast-forward mergeを行い、可能でなければtrue mergeを行う。

  • –ff-only

可能ならfast-forward mergeを行い、可能でなければエラーとする。

  • –no-ff

fast-forward mergeが可能かどうかに関わらずtrue mergeを行う。

fast-forward mergeが可能な状況でもtrue mergeを選択するかどうかは好みの問題であると思うが、利点としては思い付く辺りで以下のようなものがある。

  • マージを常にtrue mergeにするという一貫性を持たせられる
  • マージしたという印を残せる
  • 取り込んだ側と取り込まれた側との双方からの履歴を残せる
  • revertが容易である

もっとも3つ目については利点であると同時にログ、特にコミットグラフが見た目複雑になる欠点もある。

4つ目については、fast-forward mergeを選択できないこともあることを考えれば追加の欠点にはならないのだが、マージコミットをrevertした後に再度同じ内容を取り込む作業は手順が単純ではなく、間違った行動を取るとその間違いに気付きにくいことがあるのに留意する必要がある。つまりrevertした後でも取り込んだ時のコミットは履歴に残っているので再度同じブランチを普通にマージすると、先に取り込んだコミットはすでに取り込み先に存在するコミットなので取り込み対象にならずエラーにもならず、結果として取り込み漏れが発生する上にそれに気付きづらいのである。再度取り込むにはいくつか手順が考えられるが、revertコミットをrevertして、しかる後にrevertされる原因を修正したものを取り込むのが安全だろうか。

競合の解決

共通の祖先から、現在作業中のブランチも取り込まれる側も新たなコミットが行われている場合、同じファイルの同じような箇所が修正されていることもある。

そのような場合、git mergeを実行した時に一例として以下のようなメッセージが出力されてマージが中断される。

$ git merge b7
Auto-merging b
CONFLICT (add/add): Merge conflict in b
Automatic merge failed; fix conflicts and then commit the result.

これは、bというファイルが現在作業中のブランチでも取り込まれる側でも追加されているのでどちらを採用するかを自動的に決定することはできないから競合している部分を解決してからその結果をコミットしろ、という旨のメッセージである。

この状態でgit statusを実行すると以下のようになる。

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

Changes to be committed:
        new file:   a

Unmerged paths:
  (use "git add <file>..." to mark resolution)
        both added:      b

競合しているファイルはUnmerged pathsの後にリストアップされる。

ここで採ることができる選択肢は2つある。競合を解決してマージコミットを作るところまで持っていくか、マージを諦めてしまうか、である。

git merge --abortあるいはgit merge --quitを実行すればマージを諦めて終了することができる。–abortオプションと–quitオプションとの違いは、–abortの場合はワークツリーやインデックスもマージ前の状態に戻すが、–quitの場合はワークツリーやインデックスは元に戻さないことである。git merge --abortの代わりにgit reset --mergegit reset --hardでも同じ挙動となる。

競合を解決する場合、以下のようにすれば解決できる。

  • 競合しているファイルをエディタ等で開く。「<<<<<<< HEAD」という行、「=======」という行、「>>>>>>> (指定した取り込まれる側)」というマーカー行ができている。「<<<<<<< HEAD」という行と「=======」という行の間が取り込む側にあった内容、「=======」という行と「>>>>>>> (指定した取り込まれる側)」という行の間が取り込まれる側にあった内容である。
  • 適切な内容に修正し、マーカー行「<<<<<<< HEAD」「=======」「>>>>>>> (指定した取り込まれる側)」も消す。
  • 競合箇所はファイル内に複数あるかもしれないのであれば全部解決する。
  • ファイルの中身があるべき内容になったらそのファイルをgit addしてインデックスに配置する。
  • すべての競合ファイルを解決してインデックスに配置したら、git commitあるいはgit merge --continueでマージコミットを作成する。ここまで終わるとマージの中断が終了して通常の状態に戻る。

マージ中断中のgit commitgit merge --continueは同じ挙動を示す。より正確にはgit merge --continueは2つのチェックをした後に内部的にgit commitを実行する。ただし、git merge --continueは他のオプションを指定できないので(これがgit merge --continueのチェックの片方。もう1つはこの後に書く.git/MERGE_HEADの存在を確認してマージ中断中かどうかチェックする)、コミットメッセージの指定などのオプションが必要な場合はgit commitにオプションを付けて使う。その場合でも親を2つ持つマージコミットになる。

git merge --abortgit reset --mergeとの関係はgit merge --continuegit commitとの関係とほとんど同じであり、git merge --abortは2つチェックをした後に内部的にgit reset --mergeを実行する。微妙な違いもあるがこれはautostash項に記述する。

なお、「マージ中断中」という状態になるのは競合の解決を行わなければならない場合以外にも、マージコミットのコミットメッセージを空にしたせいでエラーになった時、とかもある。強制的にマージコミットができる前のマージ中断中の状態で止める–no-commitオプションもある(ちなみに–no-commitオプションはfast-forward mergeの時は無意味なので、–no-ffと一緒に指定するのが安全である)。そのような状態に新しくマージを開始しようとするとエラーとなり、fatal: You have not concluded your merge (MERGE_HEAD exists).といったメッセージが出ることがあるが、「MERGE_HEAD exists」とはリポジトリのルートディレクトリからの相対で.git/MERGE_HEADというファイルが存在する、という意味である。このファイルがあるかどうかでマージ中断中かどうかが判断されているわけである。なお、.git/MERGE_HEADに書かれているのは取り込まれる側のコミットの40桁のオブジェクトIDである。

squash merge

マージを行う際に、取り込まれる側の分岐した後のコミット履歴は不要なので1つにまとめたい、ということは良くある。その場合に使うのがsquash mergeである。なお、squash mergeという名称は公式に呼ばれている名前ではないがWeb検索で見る限りこれで通じると思われる。

squash mergeを行うにはgit merge --squash <commit-ish>...を実行すれば良い。ただ、これまでのマージと違うのは、ファイルはマージされてワークツリーやインデックスに配置されるが、マージコミットが作られるわけでも、現在作業中のブランチが参照するコミットが変更されるわけでもないことである。

つまり、squash mergeがやっていることの実態はtrue mergeの工程の内、「ファイルをマージする」ところまでやって、後工程の「マージコミットを作る」ところと「HEADの移動、つまり現在作業中のブランチが参照するコミットを新しく作られたマージコミットに変更する」ところはやらずにマージを終了する、ということである。

なのでこの後コミットは別途行う必要がある。この別途行うコミットはマージコミットではなく普通のコミットになる。つまり、親は現在作業中のブランチが先程まで参照していたコミットだけで、取り込まれた側のコミットは親にはならない。–squashも–no-commitも「ファイルをマージする」ところまで行うオプションで良く似ているのだが違いはここで、–no-commitはまだマージが完了していないマージ中断中の状態なので別途行ったコミットはマージコミットになる。

squash mergeでもファイルの競合は普通に発生する。bというファイルで競合が発生した時は以下のようなメッセージが出力される。

Auto-merging b
CONFLICT (content): Merge conflict in b
Squash commit -- not updating HEAD
Automatic merge failed; fix conflicts and then commit the result.

git statusを実行すればtrue mergeと同じように競合しているファイルがUnmerged pathsの後にリストアップされている。

競合はコミット前に解決する必要があるが、マージ中断中という扱いにはならないのでマージを諦める場合にgit merge --abortを実行してもエラーになるだけであるし、git merge --quitはすでにマージは完了している扱いなので無意味であるが、git reset --mergegit reset --hardで諦めて元に戻すgit merge --abortと同じ効果を出すことができる。同様に競合を解決してコミットする場合にgit merge --continueは使えないのでgit commitでコミットする。

各種マージの比較まとめ

各種マージで発生する事象をまとめると以下のようになる。–no-commitオプションは「true merge(競合あり)」と同等である。

true merge(競合なし) true merge(競合あり) squash merge fast-forward merge
ファイルのマージ o o o x
マージコミットの作成 o x x x
HEADの移動 o x x o
マージの終了 o x o o

「true merge(競合あり)」のようにマージが終了していない場合、その後コミットするとxになっている箇所はすべて行われる。git merge --quitはマージを終了させるだけなので、「true merge(競合あり)」の状態で実行すると「squash merge」と同じ状態になる。

競合はどのように管理されているのか

squash mergeやgit merge --quitによってマージは終了しているがファイルの競合は発生している状態を作り出せるということは、マージの状態管理と競合の状態管理は別々に行われているということである。前述の通りマージは.git/MERGE_HEADがあるかないかでマージ中断中なのかそうでないのかが管理されている。

競合時にはワークツリーのファイルに「<<<<<<< HEAD」などのマーカー行ができているが、これで競合を管理しているかと言えばそうではない。ワークツリーのファイルを編集してマーカー行をなくしただけではgit status等ではまだ競合状態と認識されていることでそれがわかる。

ワークツリーのファイルを編集してgit addしてインデックスに配置した後にgit statusを打つと、その時点で競合状態が解決していることがわかる。インデックスの変化で競合状態が変わったということはつまり競合の状態管理はインデックスによって行われているということである。

では競合中にインデックスはどのようになっているだろうか。インデックスの中にあるオブジェクトの一覧を表示するコマンドはgit ls-files -sである。以下はまずマージ実行前の、ワークツリーにコミット済みのbというファイルがあるが何も変更していない状態での実行結果である。

$ git ls-files -s
100644 a9eb3bbf4e21a1823eb23fe497069343d76b44c5 0       b

左からファイルモード、オブジェクトID、ステージ、ファイルのパスである。インデックスの中にbというファイルがあるとわかる。ステージは0となっている。これが競合状態ではないということである。余談だが、インデックスに変更を登録していない状態でインデックスにファイルがあるということはインデックスが差分ではなくスナップショットであることがわかる。

では競合を起こしてgit ls-files -sを実行する。

$ git ls-files -s
100644 78981922613b2afb6025042ff6bd878ac1994e85 1       b
100644 a9eb3bbf4e21a1823eb23fe497069343d76b44c5 2       b
100644 81bf396956110ad81c14860af1bbcc9dfbe4df20 3       b

このようにファイルパスが同じでオブジェクトIDとステージが違う3つのオブジェクトが含まれるようになった。

git show <オブジェクトID>でそれぞれのファイルの中身を見ることができる。そうするとステージ1のファイルが共通祖先から、ステージ2のファイルが現在作業中のブランチから、ステージ3のファイルが取り込まれる側からそれぞれ来ていることが(それぞれの中身を把握していれば)わかる。この状態になっていることをもって「競合している」と判定されているわけである。

編集してgit addするとgit ls-files -sは以下のようになる。

$ git ls-files -s
100644 8baef1b4abc478178b004d62031cf7fe6db6f903 0       b

ステージが0になってファイルが1つだけになったので競合状態ではなくなったことを示している。

ちなみにコミットの中にあるファイルはgit cat-file -p <コミットのオブジェクトID>で1つ存在するtreeのオブジェクトIDを取得して、git cat-file -p <treeのオブジェクトID>で含まれるファイルとディレクトリの一覧を取得することができる。ディレクトリはtreeオブジェクトであり、ディレクトリの中にあるファイルとディレクトリの一覧はさらにgit cat-file -p <ディレクトリのtreeのオブジェクトID>で取得できる。

マージコミットのコミットメッセージの指定

git mergeもコミットを作るコマンドであるため、git commitと同じようなコミットメッセージを指定するオプションがある。

-m(あるいは–message)オプションはオプションの後に指定した文字列をコミットメッセージとするし、-F(あるいは–file)オプションはオプションの後に指定したファイルの内容をコミットメッセージとする。ただし、「-F -」で標準入力からコミットメッセージを読み取る機能はない。-cや-Cもない。

-mや-Fを使用した場合はコミットメッセージの再編集のためにエディタは立ち上がらないが、-e(あるいは–edit)オプションも指定すれば再編集のためにエディタが立ち上がるようにすることができる。コミットメッセージはデフォルトで「Merge branch ‘(取り込まれるブランチ)’ into HEAD」のようなものがセットされているので、編集するつもりがなければ–no-editオプションでエディタが立ち上がらないようにすることもできる。ただし、競合が発生した等コミットがすぐには作られない場合は-eや–no-editは無意味となる。

-mや-Fで指定したコミットメッセージは競合が発生した時も持ち越して使ってくれる。この場合はコミットメッセージの再編集のためにエディタは立ち上がる。競合を解決する時にgit commitにオプションを付けて再度コミットメッセージを指定した場合は、その新しく指定した方に上書きされる。

autostash

通常、マージはインデックスに変更が登録されている時や、取り込もうとする変更ファイルがワークツリーで更新されている場合には実行に失敗しエラーとなる。

しかしgit mergeにはこのエラーを回避する–autostashオプションがある。その名の通り、git stashが行うワークツリーの変更の一時的な退避と復元を自動的に行う。

マージ開始時にgit stash save、マージ終了時にgit stash popが行われる。つまり「マージ開始時にワークツリーから退避」→「内部的にgit reset --hardを実行してワークツリーとインデックスをコミットと同じ内容にする」→「マージする」→「マージ終了時にワークツリーに戻す」、という処理なのでインデックスの変更は戻って来ない。

また、git statusで”Untracked files”に表示されるファイルは退避の対象外なので、マージで取り込まれる中に同じファイルがあればマージ自体がエラーになる。その場合でもUntrackedではない変更ファイルは退避が行われるのでgit stash pop等で手動で戻してやる必要がある。

ワークツリーに戻すのはマージ終了時でありgit merge --continuegit merge --abortgit merge --quitgit merge --squash <commit-ish>...のような終了の仕方でも戻してくれる。git merge --continueの代わりのgit commitでも戻してくれるが、git merge --abortの代わりのgit reset --mergegit reset --hardでは戻してくれないので手動でgit stash pop等で戻す必要がある。

git merge --abortでなければマージ後のワークツリーに対して復元するので競合が発生することがある。squash mergeの場合マージ側の競合も同時に発生している場合があり、その場合はマージ側の競合を解決した後から手動で退避されたものとの競合を解決する必要がある。いずれにせよ競合が発生すればまだ退避した内容が残っているので、squash mergeならgit stash pop等で退避側を取り込み競合させた後に解決するべきであるし、squash merge以外ならgit stash drop等で退避した内容を掃除した方が良い。

TOP