git mergetoolでvimdiffを使う コンフリクト解消の4画面と:diffget
git mergeでコンフリクトが出たあと、git mergetool --tool=vimdiffを打つとVimが4分割で開きます。上に3つ、下に1つ。下の窓でとりあえずdoを押すと、E101: More than two buffers in diff mode, don't know which one to useと返されて何も起きません。ふだん2ファイルの差分で使っている操作が、ここでは効きません。
4つの窓が何を表示しているのか、下の窓で何を打てば片付くのかを、小さなリポジトリでコンフリクトを起こして確かめました。終わり方を間違えると、マーカーが残ったまま「解消済み」になる落とし穴もありました。
検証環境:git 2.42.0、Vim 9.1.1752、NVIM v0.12.4(macOS)。一時ディレクトリに作ったリポジトリで2つのブランチが同じ行を書き換え、git mergeでコンフリクトを2か所起こしてから、tmuxの中でgit mergetoolを起動して操作しました。設定ファイル(~/.gitconfigや~/.vimrc)は読ませていません。掲載している画面とメッセージはすべて実行結果です。
目次(10項目)
| 操作 | これだけ覚える |
|---|---|
:diffget LO | 自分のブランチ側(LOCAL)を取り込む |
:diffget REMOTE | マージしてきた側(REMOTE)を取り込む |
| ]c [c | 次・前のコンフリクトへ移る |
:wqa | 保存して解消済みにする |
:cq | やめる。未解決のまま残る |
git mergetoolでvimdiffを使う設定
一度だけ試すなら、ツール名を引数で渡します。
git mergetool --tool=vimdiff
毎回使うならmerge.toolに登録しておくと、git mergetoolだけで同じものが開きます。
git config --global merge.tool vimdiff
どちらも設定していないと、gitは手元のツールから候補を選び、確認を挟んでから起動します。
'git mergetool' will now attempt to use one of the following tools:
tortoisemerge emerge vimdiff nvimdiff
Merging:
app.conf
Normal merge conflict for 'app.conf':
{local}: modified file
{remote}: modified file
Hit return to start merge resolution tool (vimdiff):
--toolかmerge.toolで明示したときは、この確認を挟まずにすぐVimが開きました。使えるツール名の一覧はgit mergetool --tool-helpで出せます。Vim系だけ抜き出すと次の12個で、末尾の数字は画面の並びの違いです(後の節で扱います)。
nvimdiff Use Neovim with a custom layout (see `git help mergetool`'s `BACKEND SPECIFIC HINTS` section)
nvimdiff1 Use Neovim with a 2 panes layout (LOCAL and REMOTE)
nvimdiff2 Use Neovim with a 3 panes layout (LOCAL, MERGED and REMOTE)
nvimdiff3 Use Neovim where only the MERGED file is shown
vimdiff Use Vim with a custom layout (see `git help mergetool`'s `BACKEND SPECIFIC HINTS` section)
vimdiff1 Use Vim with a 2 panes layout (LOCAL and REMOTE)
vimdiff2 Use Vim with a 3 panes layout (LOCAL, MERGED and REMOTE)
vimdiff3 Use Vim where only the MERGED file is shown
gvimdiff Use gVim (requires a graphical session) with a custom layout (...)
gvimdiff1 Use gVim (requires a graphical session) with a 2 panes layout (LOCAL and REMOTE)
gvimdiff2 Use gVim (requires a graphical session) with a 3 panes layout (LOCAL, MERGED and REMOTE)
gvimdiff3 Use gVim (requires a graphical session) where only the MERGED file is shown
開く4画面の意味 LOCAL・BASE・REMOTE・MERGED
検証に使ったのは、mainでcolorを"red"・sizeを12に、featureブランチでcolorを"green"・sizeを14に変えた設定ファイルです。mainにいる状態でgit merge featureすると2か所がぶつかります。この状態でgit mergetool --tool=vimdiffを打ったときの画面がこれです(幅を78桁にして撮っています)。
title = "draft" | title = "draft" | title = "draft"
color = "red" | color = "blue" | color = "green"
-----------------------| ------------------------| -----------------------
-----------------------| ------------------------| -----------------------
font = "mono" | font = "mono" | font = "mono"
lang = "ja" | lang = "ja" | lang = "ja"
wrap = true | wrap = true | wrap = true
theme = "dark" | theme = "dark" | theme = "dark"
size = 12 | size = 10 | size = 14
-----------------------| ------------------------| -----------------------
./app_LOCAL_29858.conf ./app_BASE_29858.conf ./app_REMOTE_29858.conf
title = "draft"
<<<<<<< HEAD
color = "red"
=======
color = "green"
>>>>>>> feature
font = "mono"
lang = "ja"
wrap = true
theme = "dark"
<<<<<<< HEAD
size = 12
=======
size = 14
>>>>>>> feature
app.conf
上段の区切り線(----の行)は一部省いています。4つの窓の役割は次のとおりです。
| 窓 | 中身 | この例では |
|---|---|---|
| LOCAL(左上) | マージを受け入れる側。いまいるブランチ | mainの"red" |
| BASE(中上) | 2つのブランチが分かれる前の共通の祖先 | 元の"blue" |
| REMOTE(右上) | マージしてくる側 | featureの"green" |
| MERGED(下) | 作業ツリーの本物のファイル。ここを直して保存する | マーカー入りのapp.conf |
上段の3つはgitが一時的に書き出したファイルで、名前は元の名前_LOCAL_数字.拡張子の形になります。数字は起動のたびに変わりました。置き場所は作業ツリーの同じディレクトリで、mergetoolを抜けると消えます。カーソルは最初から下のMERGEDにあります。窓の移動はウインドウ間をカーソル移動するCtrl-wの操作がそのまま使えます。
BASEは判断の材料になります。この例ではBASEが"blue"なので、両方のブランチが別々に色を変えたと分かります。片方だけが変えた行ならgitが自動で取り込むので、コンフリクトになるのは両方が触った行だけです。
MERGEDでdoが効かない理由(E101)と:diffget LO / RE
MERGEDの最初のコンフリクトにカーソルを置いて、素の:diffgetを打った結果です。doでも同じでした。
E101: More than two buffers in diff mode, don't know which one to use
:diffgetは「差分の相手から取ってくる」コマンドで、相手が1つしかないときは引数を省けます。2ファイルの差分を:diffget / :diffputでマージするときに引数なしで済むのはこのためです。4画面ではdiffモードのバッファが4つあり、MERGEDから見た相手はLOCAL・BASE・REMOTEの3つです。どれから取るかをVimが決められないので、E101で止まります。
引数にはバッファの名前の一部を渡せます。上段の3つは名前にLOCAL・BASE・REMOTEを含むので、そこを切り出して指定します。
:diffget LO
:diffget REMOTE
:diffget BA
最初のコンフリクトで:diffget LOを打つと、マーカーを含む5行がcolor = "red"の1行に置き換わりました。マーカーの消し忘れを手で拾う必要はありません。:lsに出るバッファ番号でも指定でき、この例では:diffget 3がREMOTEを指しました。
ここで1つ引っかかりました。:diffget REと2文字で打ったところ、E93: More than one match for REで失敗したのです。原因は名前の照合のしかたにあります。:diffgetの引数はバッファ名に対するパターンとして扱われ、フルパスにも当てられます。検証用のリポジトリをrepoというディレクトリに作っていたので、どのバッファのパスにもreが含まれていました。macOSのVimは'fileignorecase'がオンなので大文字小文字も区別しません。ディレクトリ名をprojに変えて作り直すと、同じ:diffget REがREMOTEを取り込みました。
パスしだいで当たり外れが出るので、手元では:diffget REMOTEのように省略せずに打つことにしました。
コンフリクト間の移動と、両方残したいときの手直し
次のコンフリクトへは]c、前へは[cで移ります。この例では1行目から]cで2行目(1つ目のマーカー)、もう一度で11行目(2つ目のマーカー)に飛び、[cで2行目に戻りました。:diffgetで片付けた箇所は差分でなくなるので、]cと:diffgetを交互に打てば上から順に処理できます。
迷うのは「両方の変更を残したい」ときです。:diffgetはどちらか一方で丸ごと置き換えるので、両方を並べる用途には使えません。この場合はMERGEDのマーカー行を消すのがいちばん早く、<<<<<<<・=======・>>>>>>>の3行をddで落とせば両側の行が残ります。範囲を決めて一度に消すなら次の形です(2〜6行目が1つ目のコンフリクトの場合)。
:2,6g/^\(<<<<<<<\|=======\|>>>>>>>\)/d
これでcolor = "red"とcolor = "green"の2行が残りました。設定ファイルのように同じキーが2回あると壊れるものでは、残したあとにどちらかを手で直します。
画面を減らす vimdiff1〜3 と mergetool.vimdiff.layout
4画面が広すぎるなら、並びを変えられます。--tool=vimdiff1〜vimdiff3は決まった並びの省略形で、git help mergetoolの「BACKEND SPECIFIC HINTS」節によると、mergetool.vimdiff.layoutに次の値を入れたのと同じです。
| ツール名 | layout の値 | 実際に開いた画面 |
|---|---|---|
vimdiff1 | @LOCAL, REMOTE | LOCALとREMOTEの2つ。カーソルはREMOTE |
vimdiff2 | LOCAL, MERGED, REMOTE | 3列。カーソルはREMOTE |
vimdiff3 | MERGED | MERGEDだけ。ほかは隠れバッファ |
画面を減らすとdoがそのまま効くか、という点は並びによって違いました。vimdiff2は窓が3つでdiffモードのバッファも3つなので、真ん中のMERGEDに移ってdoを押してもE101のままです。しかも開いた直後のカーソルは右のREMOTEにありました。
vimdiff1は事情が違います。@LOCALの@は「保存したものを結果として使う窓」を指す印で、MERGEDの代わりにLOCALを直して保存します。カーソルは右のREMOTEにあるので、Ctrl-w hでLOCALへ移り、]cとdoを2回ずつ打って:wqaで抜けたところ、app.confはREMOTE側の"green"と14になりました。相手が1つなので引数なしのdoで済みます。REMOTEの窓のまま押すと逆向きに取り込むので、移動を忘れないようにします。
並びは自分で書くこともできます。BASEを外してMERGEDとLOCALだけにする例です。
git config --global mergetool.vimdiff.layout "LOCAL,MERGED"
,が縦分割、/が横分割、+がタブの区切りです。この2画面ではMERGEDでdoがそのまま効きました。ただし、マーカー付きのファイルとLOCALの比較になるので、差分の単位が細かくなります。1つ目のコンフリクトは<<<<<<< HEADの行と、=======から>>>>>>> featureまでの3行という2つの差分に分かれ、do1回では片方しか消えませんでした。コンフリクト2つを片付けるのに]cとdoを4回ずつ、または:%diffgetの1回が必要でした。
終わり方 :wqaで解消、:cqで中断
MERGEDを直したら:wqaで全部の窓を閉じます。gitはVimの終了コードを見て、0なら解消済みとしてgit addまで済ませます。
$ git status --short
M app.conf
?? app.conf.orig
途中でやめたいときは:cqです。Vimが終了コード1で抜けるので、gitは失敗として扱い、ファイルは未解決のまま残ります。
merge of app.conf failed
$ git status --short
UU app.conf
注意が要るのはここからです。gitがVimの終了コードを信用するということは、何も直さずに:wqaや:qaで抜けても、終了コードは0なので解消済みになります。実際にマーカーを1つも消さずに:qaで抜けたところ、app.confはM(ステージ済み)になり、中身はマーカーが入ったままでした。直さずに抜けるときは必ず:cqを使います。
うっかり解消済みにしてしまった場合は、コミットする前なら取り返せます。git diff --cached --checkで残ったマーカーを見つけ、git checkout -mでコンフリクトの状態に戻します。
$ git diff --cached --check
app.conf:2: leftover conflict marker
app.conf:4: leftover conflict marker
app.conf:6: leftover conflict marker
app.conf:11: leftover conflict marker
app.conf:13: leftover conflict marker
app.conf:15: leftover conflict marker
$ git checkout -m -- app.conf
$ git status --short
UU app.conf
diff3 / zdiff3 と .origを残さない設定
MERGEDのマーカーには、既定ではLOCALとREMOTEの2段しか入りません。merge.conflictstyleをdiff3にすると、|||||||の段にBASEの内容が加わります。
git config --global merge.conflictstyle diff3
<<<<<<< HEAD
color = "red"
||||||| 653d62b
color = "blue"
=======
color = "green"
>>>>>>> feature
上段のBASEの窓と同じ情報がMERGEDの中にも並ぶので、2画面やvimdiff3のようにBASEを表示しない並びと組み合わせると効きます。git 2.35以降のzdiff3は、両側に共通する行をマーカーの外へ出す点が違います。両方のブランチがmode = 1という同じ行を足した例で比べると、diff3ではmode = 1がLOCALとREMOTEの段の両方に入ったのに対し、zdiff3ではマーカーの手前に1回だけ出ました。この記事の主な例のように共通の行が無い場合は、どちらも同じ出力です。
もう1つ、:wqaで抜けるたびにapp.conf.origが残ります。マーカー入りの状態を控えたバックアップで、コミットのときに混ざりやすいので、要らなければ止めます。
git config --global mergetool.keepBackup false
この設定を入れて(試したのは次の節のnvimdiffです)同じ手順を繰り返すと、解消後の作業ツリーにはapp.confだけが残りました。なお:cqで中断した回は、設定に関係なく.origは作られていませんでした。
Neovimで使う nvimdiff
Neovimなら--tool=nvimdiffです。並びの設定名はmergetool.nvimdiff.layoutに変わりますが、4画面の構成、素の:diffgetでE101が出ること、:wqaと:cqの扱いはVimと同じでした。
1つだけ挙動が違いました。NVIM v0.12.4で1つ目のコンフリクトに:diffget LOを打つと、5行が1行に置き換わらず、<<<<<<< HEADの行だけが消えて残りの4行はそのまま残りました。原因は'diffopt'の既定値の違いです。
Vim 9.1.1752: diffopt=filler,closeoff,inline:simple
NVIM v0.12.4: diffopt=internal,filler,closeoff,indent-heuristic,inline:char,linematch:40
Neovimの既定にはlinematch:40が入っていて、差分のかたまりを行どうしで対応づけて細かく分けます。上段の窓でも、Neovimでは"red"と"green"が別々の行に並んで見えました。:diffgetが一度に取る範囲もそのぶん細かくなります。手元で効いたのは次の2つです。
" マージのあいだだけ linematch を外す
:set diffopt-=linematch:40
" 範囲を明示して取り込む(2〜6行目が1つ目のコンフリクト)
:2,6diffget LO
前者を打ってから]cと:diffget LOを使うと、Vimと同じく1回でコンフリクト1つが片付きました。Neovim標準の:DiffToolなどふだんの差分表示ではlinematchが見やすいので、外すのはマージのときだけにしています。
以前はgit mergetoolでVimが4分割になった時点で:qaして、エディタで直接マーカーを消していました。終了コードの扱いを調べて、その:qaで「解消済み」にしていた可能性に気づき、冷や汗が出ました。以来git diff --cached --checkをコミット前に打っています。もう1つ予想外だったのが:diffget REのE93で、最初は自分の打ち間違いを疑い、:lsとbufname()を何度も見比べてやっとrepoというディレクトリ名のせいだと分かりました。
まとめ
git mergetool --tool=vimdiffの4画面では、下のMERGEDが本物のファイルで、上の3つは参照用です。相手が3つあるので素のdoはE101で止まり、:diffget LOCALや:diffget REMOTEで取り込み元を名前で指定します。
引数なしのdoで済ませたいなら、vimdiff1かmergetool.vimdiff.layoutで窓を2つにします。終わるときは、直したら:wqa、やめるなら:cq。直さずに:qaで抜けても解消済みになってしまう点だけは覚えておくと、マーカー入りのコミットを防げます。2ファイルの差分の見方そのものはvimdiffで差分を表示する記事で扱っています。