Git実務ワークフロー #4 コンフリクト解決 — 構造とmergetool・rerere

コンフリクトマーカーを読んで解決する基本手順はGit基礎 #3 ブランチとマージ — fast-forwardと3-wayで扱いました。ところが実務で出会うコンフリクトはmergeだけで発生するのではありません。rebaseの途中、cherry-pickの途中、stashを再適用する途中にも同じ形で現れます。そして、マーカーを消してコードを選ぶ手作業だけでは足りない瞬間が来ます。どちらがoursなのか分からなくなり、元のコードが何だったのかマーカーに見えず、昨日解いたコンフリクトを今日また解いている、という状況です。今回はコンフリクトを構造で理解し、道具で扱う方法をまとめます。

全7回で構成します。

  • #1 ブランチ戦略 — GitHub Flowとtrunk-based
  • #2 rebase vs merge — 判断基準と禁則
  • #3 interactive rebase — squash・fixupでコミット整理
  • #4 コンフリクト解決 — 構造とmergetool・rerere ← この記事
  • #5 PR運用 — レビュー単位・コミットメッセージ・draft PR
  • #6 stash・cherry-pick・bisect — 日常ツールセット
  • #7 モノレポとGit — sparse-checkout・サブモジュール・LFS

今回はコンフリクトの3つの材料であるbase、ours、theirsから確認し、rebaseで方向が逆に見える理由、マーカーを読みやすくするzdiff3設定、エディタ統合とrerereまで進めます。

コンフリクトの構造 — base・ours・theirs #

Git基礎で見た3-wayマージを思い出してみましょう。Gitはコンフリクトを判定するとき、3つのバージョンを材料に使います。

  • base — 2つのブランチが分岐する前の共通祖先のバージョンです。
  • ours — いま自分が立っている側(HEAD)のバージョンです。
  • theirs — 合流してくる相手側のバージョンです。

baseと比べて片側だけが変わっていれば、Gitは変わった側を自動的に採用します。両側がbaseから別々に変わっていたときだけ、人間に決定が渡されます。それがコンフリクトです。つまりコンフリクト解決とは、baseから出発した2つの変更のどちらを残すか決める作業です。この観点が立つと、以下のすべての道具が同じ絵の上に載ります。

rebaseでoursとtheirsが逆になる理由 #

実務で混乱の元になる第1位を先に整理します。mergeのコンフリクトでは、oursは自分のブランチ、theirsは相手のブランチです。直感と一致します。ところがrebase中のコンフリクトでは逆に見えます

rebase中のコンフリクト — oursがmain側
$ git switch feature-retry
$ git rebase main
CONFLICT (content): Merge conflict in config.py
config.py内のマーカー
<<<<<<< HEAD (ours)
retry_count = 5        ← main側のコード
=======
retry_count = 10       ← 自分のブランチのコード
>>>>>>> 9f8e7d6 (theirs)

自分のコードがtheirs側にあります。理由はrebaseの動作方式にあります。rebaseはmainの上に移動して自分のコミットをひとつずつ再適用します。再適用が行われている間、HEADはmain側の履歴の上にあるので、oursはmainになり、いま載せようとしている自分のコミットがtheirsになります。方向が反転したのではなく、基準点が移動したのです。rebaseのコンフリクトで「自分のコードを残そう」とoursを選んで正反対の結果になる事故がここから生まれます。マーカーのラベルをブランチ名で確認する習慣が最も確実な予防です。

zdiff3 — マーカーにbaseを一緒に表示する #

デフォルトのコンフリクトマーカーの弱点は、元のコードが何だったのかを見せてくれない点です。両側の最終状態しか見えないので、それぞれがbaseから何を変えたのかを推測しなければなりません。merge.conflictStyleをzdiff3に変えると、マーカーにbaseブロックが一緒に表示されます。

zdiff3の設定
git config --global merge.conflictStyle zdiff3
設定前 — デフォルトのマーカー
<<<<<<< HEAD
retry_count = 5
=======
retry_count = 10
>>>>>>> feature-retry
設定後 — zdiff3のマーカー
<<<<<<< HEAD
retry_count = 5
||||||| 1a2b3c4 (base)
retry_count = 3
=======
retry_count = 10
>>>>>>> feature-retry

真ん中の|||||||ブロックが共通祖先のコードです。元は3で、片側は5に、もう片側は10に変えたという事実がマーカーの中でそのまま読み取れます。2つの変更の意図を比べて決められるので、判断の質が変わります。設定1行のコストで得られる効果としては最大級の部類です。

エディタで解く — VS Codeのmerge editor #

マーカーを直接編集する代わりに、3-wayビューを提供する道具を使うこともできます。VS Codeはコンフリクトしたファイルを開くとResolve in Merge Editorボタンを表示し、ours、theirs、base、結果を4つのパネルで見せてくれます。ターミナル中心で作業するなら、git mergetoolがコンフリクトしたファイルを順に設定済みの道具で開いてくれます。

VS Codeをmergetoolに設定
git config --global merge.tool vscode
git config --global mergetool.vscode.cmd 'code --wait --merge $REMOTE $LOCAL $BASE $MERGED'

コンフリクト全体ではなくファイル単位で片側を丸ごと採用するケースもよくあります。自動生成ファイルやlockファイルが代表例です。ファイルを開く必要はなく、1行で終わります。

ファイル単位で片側を採用
git checkout --ours package-lock.json     # 自分側を採用
git checkout --theirs package-lock.json   # 相手側を採用
git add package-lock.json

ブランチ切り替えがswitchに移った後も、この用途のcheckout --ours/--theirsは今も慣用的に使われています。rebase中なら、前の節で整理したとおりoursとtheirsの方向が逆になっている点だけ、もう一度確認してください。

rerere — 同じコンフリクトを二度解かない #

長く維持されるブランチを定期的にrebaseしたり、同じ変更を複数のブランチに運んだりしていると、完全に同じコンフリクトに繰り返し出会うことになります。rerere(reuse recorded resolution)を有効にしておくと、Gitがコンフリクトの解決内容を記録しておき、同じコンフリクトに再び出会ったとき、以前の解決を自動的に適用します。

rerereを有効化
git config --global rerere.enabled true

その後の動作は自動です。コンフリクトを解決してコミットすると記録が残り、次に同じコンフリクトが出ると出力に案内が現れます。

同じコンフリクトに再び出会ったとき
CONFLICT (content): Merge conflict in config.py
Resolved 'config.py' using previous resolution.

ファイルはすでに以前の解決どおりに埋められているので、内容を確認してgit addするだけです。

注記
rerereは記録された解決をそのまま再適用するので、間違って解いた解決もそのまま繰り返されます。以前の記録が疑わしいときはgit rerere forget <ファイル>でそのファイルの記録を消し、解き直せば済みます。

迷い込んだとき — 中断コマンドの整理 #

コンフリクト解決の途中で状況がこじれたら、無理にやり切るより、開始前の状態に戻ってやり直すほうが速いです。3つの操作すべてに中断コマンドがあります。

状況中断コマンド戻る地点
mergeのコンフリクト中git merge --abortmerge開始前
rebaseのコンフリクト中git rebase --abortrebase開始前
cherry-pickのコンフリクト中git cherry-pick --abortcherry-pick開始前

中断は失敗ではありません。zdiff3でマーカーを読み直し、必要なら対象をさらに細かく分けて再挑戦するほうが、コンフリクトを大きくするよりも常に良い選択です。

まとめ #

今回の要点は4つです。

  • コンフリクトはbaseから分岐した2つの変更のどちらを残すか決める作業で、材料はbase、ours、theirsの3つです。
  • rebaseのコンフリクトでは基準点が移動し、oursがmain側になります。マーカーのラベルを確認する習慣が事故を防ぎます。
  • merge.conflictStyle zdiff3はマーカーにbaseを一緒に見せて判断の質を高め、繰り返すコンフリクトはrerereが代わりに解いてくれます。
  • こじれたときは--abortで開始前に戻ってやり直します。

コンフリクトまで扱ったので、次はブランチを合流させる関門であるPRに進みます。次回の「Git実務ワークフロー #5 PR運用 — レビュー単位・コミットメッセージ・draft PR」では、レビューされやすいPRの大きさ、コミットメッセージの規約、draft PRを使った進行共有まで、チームでPRを運用する基準をまとめます。

X