開発プロジェクトの作業を陰で支えるのがGitです。しかし、ダンスフロアで多くの人が踊りに興じるのと同じように、複数人が介入すると「足の踏み合い」になりかねません。二人の開発者が同じコードを編集し、それをコミットしてしまうこともあるでしょう。このような場合には、Gitのマージを駆使してコンフリクトを解決することができます。

Gitのマージそのものはシンプルですが、高度なアプローチが必要になることもあります。再帰的マージ(recursive)や3ウェイマージ(three-way)などを使うことになるでしょう。Gitマージの取り消しが必要になることもあるかもしれません。

今回の記事では、複雑なGitマージテクニックについてご説明します。実際に、まずはどんなメリットがあるのかから見ていきましょう。

Gitマージ戦略入門

マージの基本的な考え方は単純です。複数のコミットをひとつにまとめ、ふたつのブランチを結合するというものです。しかし、正しくコードをコミットしてマージするためには、さまざまなテクニックが必要になります。

今回の記事では、大事な手法をいくつか順不同で扱います。開発キャリアの中で、どれも必要になるかもしれません。さらに、ポインタやブランチ、コミットといったGitの基本的な概念についてもしっかり理解しておきましょう。

2ウェイマージと3ウェイマージの違い

2ウェイマージとその仲間である3ウェイマージの違いを理解しておくと便利です。次に説明するマージ戦略のほとんどは、三方向の状況を扱います。実際、三方向のマージが何であるかについて話す方がよりわかりやすいです。次の例を考えてみましょう。

  • いくつかのコミットがあるmainブランチと、同じく複数のコミットがあるfeatureブランチがあるとします。
  • しかし、mainブランチがさらにコミットを行うと、両方のブランチが分岐してしまいます。
  • 平たく言えば、mainブランチとfeatureブランチの両方に固有のコミットがあるということです。これらを2ウェイでマージすると、いずれかのコミットを失うことになります(おそらくmainのコミットでしょう)。
  • その代わり、Gitでは現在のmainブランチとfeatureブランチの両方から新しいマージコミットを作成することができます。

簡単に説明すると、Gitは変更をマージするために三つのスナップショットを見ることになります。mainブランチの先頭、featureブランチの先頭、そして共通の先祖です。これは、mainブランチとfeatureブランチの両方に共通する最終コミットとなります。

実際には、あるマージ戦略が二方向か三方向かを気にする必要はありません。多くの場合、その戦略に関係なく使わなければならないためです。いずれにせよ、ブランチやリポジトリのマージに関してGitがどのように「考えている」のかを知っておくのは有益でしょう。

fast-forwardマージ

最初の戦略は、何もしなくても実行できるかもしれません。fast-forwardマージは、(混乱を招く可能性のある)余計なコミットを作成することなくmainの最新コミットにポインタを移動させます。これはクリーンなアプローチで、多くの開発者が標準的に使うものです。

このテクニックは、コミットがある場合もない場合も、mainブランチから始めます。例えば、新しいブランチを開きコードを作成し、コミットを行います。この時点で、変更をmainブランチにマージする必要があります。fast-forwardマージには、それを実行するための条件がひとつあります。

  • 新しいブランチで作業している間、mainでは他のコミットが行われないようにすること

とは言え、これは常にできることではありません。大所帯のチームであればなおさらです。それでも、コミットを最新でありそれ独自のコミットのないmainブランチにマージすると、fast-forwardマージが実行されます。 これには、以下に示すようにいくつか方法があります。

git merge <branch>

git merge --ff-only

多くの場合、fast-forwardマージを実行することを指定する必要はありません。このタイプのマージは、単独プロジェクトや小規模チームで利用されます。変更のペースの速い環境では稀なマージだと言えるでしょう。他のマージを利用するほうが一般的です。

再帰的マージ

再帰的マージは、他のタイプのマージよりも一般的な状況で発生するため、デフォルトになることがよくあります。再帰的マージとは、あるブランチでコミットを行い、さらにmainブランチでもコミットを行うことです。

マージするとき、Gitはブランチを再帰して最終的なコミットを行います。つまり、マージコミットを完了させると、そのコミットにはふたつの親が存在することになります。

fast-forwardマージと同様、通常は再帰を指定する必要はありません。しかし、次のコマンドとフラグを使うことで、fast-forwardマージを選択しないようにすることができます。

git merge --no-ff

git merge -s recursive <branch1> <branch2>

二行目では、-sオプションと明示的な命名を使ってマージを行っています。fast-forwardマージとは異なり、再帰的マージでは専用のマージコミットが作成されます。2ウェイマージでは、再帰的戦略は堅実でうまく機能します。

oursとtheirs

開発中によくある状況として、プロジェクト内で新機能を作成したものの最終的に許可が下りないというものがあります。多くの場合、多くのコードをマージすることになりますが、そのコードもまた共依存です。oursマージは、このようなコンフリクトを解決するのに有効です。

このタイプのマージは、必要なだけのブランチを扱うことができ、他のブランチの変更をすべて無視します。古い機能や不要な開発内容を一掃したい場合に便利です。必要なコマンドは以下のとおりです。

git merge -s ours <branch1> <branch2>

oursマージは、基本的に現在のブランチにそのコードが含まれることを意味します。これとは対照的に、他のブランチを正しいものとして扱うtheirsマージもあります。これには別のオプションを渡すことになります。

git merge -X theirs <branch2>

oursマージとtheirsマージを使い分けると混乱する可能性がありますが、一般的には典型的な使用例(最新のブランチにすべてを残し、残りを破棄する)にこだわるのが安全です。

octopus

複数のブランチを別のブランチにマージするのは、Gitマージにとって厄介なシナリオです。コンフリクト解決に「二人以上の手」が必要だと感じたら、octopusマージが利用できます。

octopusマージは、oursや theirsマージの正反対のようなものです。典型的な用途として、似たような機能の複数のコミットをひとつにまとめてマージすることができます。どのように渡すかは次のとおりです。

git merge -s octopus <branch1> <branch2>

しかし、その後に手動での解決が必要な場合には、octopusマージが拒否されます。自動解決では、複数のブランチをひとつにマージする際にデフォルトでoctopusマージが行われます。

resolve

コミットをマージするもっとも安全な方法のひとつであるresolveマージは、複数のブランチをまたいでマージするのに便利です。また、すぐに実行できる解決方法でもあります。より複雑なマージ履歴の場合にも(HEADが2つであれば)この方法を使うのが効果的です。

git merge -s resolve <branch1> <branch2>

resolveマージは、現在のブランチとプル元のブランチの両方で動作する3方向のアルゴリズムを使用するため、他のマージ方法ほど柔軟ではないかもしれません。しかし、必要な作業を行うことに関して言えば、resolveマージはほぼ完璧な選択肢です。

subtree

これは再帰的マージの仲間で、少しわかりにくいことがあります。簡単例を使って説明します。

  • まず、2つのツリー(XとY)を考えます。
  • 両方のツリーを1つにマージしたいとします。
  • ツリーYがXのサブツリーの1つに対応する場合、ツリーYはXの構造に合わせて変更されます。

つまり、subtreeマージは、複数のリポジトリを1つにまとめたい場合に有用です。また、両方のブランチの共通の「祖先」ツリーに必要な変更を加えることができます。

git merge -s subtree <branch1> <branch2>

要するに、subtreeマージは2つのリポジトリを結合する必要がある場合に使えます。とはいえ、どのマージ戦略が正しいのか理解するのに苦労するかもしれません。後で、役立つツールをいくつかご紹介します。

その前に、高度なマージのコンフリクトを解決する方法を知っておきましょう。

複雑なGitマージコンフリクトの対処法

Gitでのブランチのマージは、実際にはコンフリクトの管理とその解決に費やされます。チームやプロジェクトの規模が大きくなればなるほど、コンフリクトが発生する可能性は高くなります。中には複雑で解決が難しいものもあるでしょう。

コンフリクトが時間やお金、リソースを食いつぶしてしまうことを考えると、コンフリクトの芽を早く摘む方法を考えなければなりません。たいていの場合、2人の開発者が同じコードに取り組み、2人ともコミットを決定するものです。

そのため、保留中の変更のためにマージをまったく開始できなかったり、マージ中に手動での介入が必要な障害が発生したりする可能性があります。ワーキングディレクトリが「クリーン」になったら、作業を開始しましょう。マージを始めると、多くの場合Gitのコンフリクトが通知されます。

Gitでコンフリクトが発生していることを示すターミナルウィンドウ
Gitでコンフリクトが発生していることを示すターミナルウィンドウ

より詳しい情報を確認するには、git statusを実行して詳細を見ることができます。

git statusコマンドの結果を示すターミナルウィンドウ
git statusコマンドの結果を示すターミナルウィンドウ

ここから、コンフリクトの原因となっているさまざまなファイルに着手することができます。この後で説明するツールやテクニックも役に立つはずです。

マージの中止とリセット

時には、マージを完全に中止してまっさらな状態から始める必要があるかもしれません。実際、ここで紹介するコマンドはどれも、コンフリクトが発生したときにどうすればいいのかまだわからないような状況で使うのが有効です。

以下のコマンドで、進行中のマージを中止またはリセットすることができます。

git merge --abort

git reset

この2つのコマンドは似ていますが、使用する状況は異なります。たとえば、マージをabortで中止するとブランチはマージ前の状態に戻ります。しかし、これはうまくいかない場合もあります。たとえば、ワーキングディレクトリにコミットされていない/stashされていない変更内容があると、abortは実行できません。

そして、マージのリセットは、ファイルを既知の「良い」状態に戻すことを意味します。後者は、Gitでマージ開始が失敗したときに考慮すべきです。このコマンドはコミットしていない変更もすべて削除してしまうので、注意が必要です。

コンフリクトのチェックアウト

マージコンフリクトの大部分の確認と解決は簡単です。しかし場合によっては、コンフリクトが発生する理由と、それを修正する方法を知るために、より深く掘り下げる必要があります。

git mergeの後にチェックアウトを使って、詳しい情報を確認できます。

git checkout --conflict=diff3 <filename>

これによりチェックアウトの典型的なナビゲーションを使用して、マージの競合を示す2つのファイルの比較を行うことができます。

特定のプロジェクトファイル内のコンフリクトをチェック
特定のプロジェクトファイル内のコンフリクトをチェック

技術的に言えば、これはファイルを再度チェックアウトし、コンフリクトマーカーを置き換えることになります。この操作は、解決を通して何度か行うかもしれません。ここで、diff3 引数を渡すと、基本バージョンと代替バージョンをoursとtheirsで表示できます。

デフォルトの引数オプションはmergeであることに注意してください。マージコンフリクトのスタイルをデフォルトから変更しない限り、指定する必要はありません。

ネガティブスペースの無視

ネガティブスペースとその使い方はよく議論されるところです。プログラミング言語によって異なる種類のスペースを使用しますし、個々の開発者でも書式が異なります。

スペース対タブについて意見を述べるつもりはありません。しかし、ファイルやコーディングのやり方によって書式がころころ変わるような状況では、このGitマージの問題にぶつかる可能性があります。

コンフリクトを見てみると、削除された行と追加された行があるため、これがマージの失敗の原因だとわかります。

コンフリクトの差分をエディタで表示
コンフリクトの差分をエディタで表示

このような問題が起きる原因として、Gitでは負のスペースが変更点とみなされます。

しかし、git mergeコマンドに特定の引数を追加することで、関連するファイルのネガティブスペースを無視するように設定できます。

git merge -Xignore-all-space

git merge -Xignore-space-change

この二つの引数は似ていますが、中身には違いがあります。前者はすべてのスペースを無視します。対照的に、-Xignore-space-changeは、スペースの連続や行末の単一のスペースを無視します。

さらに安全性を高めるために、--no-commitコマンドを使用して、正しい方法でスペースを無視できているか確認することもできます。

ログのマージ

データをやりとりするソフトウェアのほとんどすべてにおいて、ログの記録は非常に重要です。Gitでは、マージの衝突の詳細を確認するためにログを使うことができます。この情報を見るにはgit logを使います。

ターミナルでのGitログの実行と表示
ターミナルでのGitログの実行と表示

基本的には、リポジトリ内のすべてのアクションをダンプしたテキストファイルです。引数を追加して表示を絞り込み、見たいコミットだけを表示させることもできます。

git log --oneline --left-right <branch1>...<branch2>

トリプルドットを使用して、マージ時に2つのブランチに含まれたコミットの一覧を表示することができます。両方のブランチが共有しているコミットをすべて絞り込み、その中からさらに確認したいコミットを選択します。

また、git log --oneline --left-right --mergeを使用すると、コンフリクトしているファイルに「触れた」マージ側のコミットだけを表示することもできます。-pオプションを使用すると、特定の「差分」に対する正確な変更点を表示可能です。ただし、これはマージ以外のコミットに対するものであることに注意してください。これには回避策があり、次に説明します。

Gitマージのコンフリクトを調べるための結合Diff形式の使用

git logの表示をさらに発展させて、マージのコンフリクトを調べることができます。典型的な状況では、Gitはコードをマージして成功したものすべてをステージングに追加します。すると、コンフリクトしている行だけが残り、git diffコマンドでそれを見ることができます。

ターミナルでgit diffコマンドを実行する
ターミナルでgit diffコマンドを実行する

この「combined diff」形式では、二列の情報が追加されます。最初の列は、あなた自身(ours)のブランチとワーキングコピーの間で行が異なっているかどうかを示すもので、2番目の列はtheirsブランチについて同じ情報を示すものです。

プラス記号は、その行がワーキングコピーに追加されたものの、特定のマージ側には追加されていないことを示し、マイナス記号は、その行が削除されたことを示します。

Gitのログでも、いくつかのコマンドを使えばこのdiffの結合形式を見ることができます。

git show

git log --cc -p

一つはマージコミットの履歴を見るコマンドです。二つ目のコマンドは-pを使ったもので、マージコミット以外のコミットに対する変更点をdiff形式で表示します。

Gitマージの取り消し方

間違いは起こりうるものです。マージした後でやり直したいと思うこともあるでしょう。場合によっては、git commit --amendを使って直近のコミットを修正することもできます。エディタが開き、直近のコミットメッセージを修正できるようになります。

より複雑なマージコンフリクトやその結果の変更を取り消すことは可能ではありますが、コミットが永続的なものであることが多いので、難しいかもしれません。

そのため、多くの手順を踏む必要があります。

  • まず、コミットを見直して必要なマージへの参照を見つける
  • 次に、ブランチをチェックアウトしてコミット履歴を確認
  • 必要なブランチとコミットについての情報が得られたら、目的のアクションに応じて特定のGitコマンドを実行する

これらのコマンドを詳しく見てみましょう。また、Gitのマージを取り消す簡単な方法、さらに高度な使い方をするためのコマンドをご紹介します。

コミットのレビュー

git log --onelineコマンドは、現在のブランチに関連するリビジョンIDやコミットメッセージを見たいときに便利です。

ターミナルでgit diffコマンドを実行する(一行)
ターミナルでgit diffコマンドを実行する(一行)

git log --branches=*コマンドも同じ情報を表示しますが、すべてのブランチが対象となります。ただし、git checkoutと一緒に参照IDを使用すれば、「切り離されたHEAD」(HEADがコミットIDを直接指す)状態を確立することができます。これは、技術的な観点からどのブランチでも作業しないことを意味し、いったん確立されたブランチに戻ると変更を「放棄」することになります。

そのため、チェックアウトをほとんどリスクのないサンドボックスとして使うことができます。しかし、変更を保持したい場合は、git checkout -b <branch-name>を使ってブランチをチェックアウトし、新しい名前をつけることができます。これはGitのマージを取り消す堅実な方法ですが、複雑な状況では他の手法も使えます。

git resetの使用

マージのコンフリクトの多くはローカルリポジトリで発生する可能性があります。そのような場合は、git resetが使えます。このコマンドにはさらに多くのパラメータや引数があります。実際のコマンドの使い方は以下のとおりです。

git reset --hard <reference>

この最初の部分(git reset --hard)に3つのステップがあります。

  • 参照ブランチをマージコミット前の位置に移動する
  • ハードリセットにより、「インデックス」(つまり、次に提案されるコミットスナップショット)を参照ブランチのようにする
  • ワーキングディレクトリをインデックスのようにする

このコマンドを実行すると、コミット履歴からそれ以降のコミットが削除され、参照されたIDにリセットされます。Gitのマージを取り消すすっきりとした方法ですが、すべての状況で有効とは言えません。

たとえば、ローカルのリセットコミットをそのコミットを含むリモートリポジトリにプッシュしようとするとエラーになります。そのような場合には、別のコマンドを使いましょう。

git revertの使用

git resetgit revertはどちらも似ているように見えますが、いくつか重要な違いがあります。これまでの例では、元に戻す処理では参照ポインタとHEADを特定のコミットに移動させていました。これは、トランプをシャッフルして新しい順番を作るようなものです。

対照的に、git revertでは、バックトラックの変更に基づいて新しいコミットを作成し、参照ポインタを更新してブランチを新たな「最新」にします。このような性質から、リモートリポジトリのマージコンフリクトにこのコマンドを使うのが便利です。

git revert <reference>を使ってGitのマージを取り消すこともできます。コミット参照を指定しないとコマンドが実行されませんのでご注意ください。コマンドにHEADを渡すと、最新のコミットに戻すこともできます。

さらに、実行内容をより明確に指定することもできます。

git revert -m 1 <reference>

マージを実行すると、新しいコミットは2つの「親」を持つことになります。1つは指定した参照に関するもので、もう1つはマージしたいブランチの最新のコミットです。この場合、-m 1は最初の親、つまり指定した参照を「メインライン」として保持するよう指示することになります。

git revertのデフォルトのオプションは-eあるいは--editです。これにより、エディタを開きコミットメッセージを編集できます。--no-editを渡すとエディタは開きません。

-nまたは--no-commitを渡すこともできます。すると、git revertにより新しいコミットは作成されず、変更を「反転」することで、ステージングインデックスとワーキングディレクトリに追加されます。

Gitにおけるマージとrebaseの違い

git mergeコマンドを使う代わりに、git rebaseを使うこともできます。これも変更を1のディレクトリに統合する方法ですが、以下の違いがあります。

  • git mergeを使った場合のデフォルトは3ウェイマージです。最新の2つのブランチのスナップショットを結合し、両方の共通の先祖とマージして新しいコミットを作成します。
  • rebaseは、分岐したブランチからパッチを適用した変更を別のブランチに適用するもので、先祖は必要ありません。つまり、新しいコミットは発生しません。

このコマンドを使うには、rebaseしたいブランチにチェックアウトします。そこから次のコマンドを使います。

git rebase -i <reference>

多くの場合、参照はメインブランチになります。-iオプションで「対話的rebase」が実行されます。これによって、コミット移動に際してそのコミットを変更できるようになります。git rebaseを使う大きなメリットのひとつです。

このコマンドを実行すると、移動するコミットの候補がエディタに表示されます。これにより、コミット履歴の見た目を自由に変更できるようになります。pickコマンドをfixupに変更すれば、コミットをマージすることもできます。変更を保存すると、Gitによりrebaseが実行されます。

全体として、Gitのマージの方が多くのコンフリクトに使われます。しかし、rebaseにも多くの利点があります。たとえば、マージはシンプルな操作でマージ履歴のコンテキストを保持できますが、リベースはコミット履歴をひとつにまとめられるのですっきりします。

とはいえ、リベースはエラーの可能性が大きいので、より注意が必要です。さらに、リベースは自分のリポジトリにしか影響を与えないので、パブリックブランチではこのテクニックを使うべきではありません。結果として発生した問題を修正するにはさらに多くのマージが必要になり、複数のコミットが発生することになります。

Gitマージの管理を助けるツール

Gitのマージにまつわるコンフリクトの複雑さを考えると、手助けが欲しくなることでしょう。Intellij IDEAには、Branchesメニューを使った手法が組み込まれています。

Intellij IDEAでブランチをチェックアウト
Intellij IDEAでブランチをチェックアウト

VSCodeのユーザーインターフェース(UI)にも、同様の機能が含まれています。古くからのAtomユーザーなら、Microsoftが見事にGit統合を行ったことがわかるはずです(拡張機能やアドオンを追加することなくGitHubに接続することが可能)。

また、Command Palette」を使うことで、さらなるオプションも利用できます。これは、Onivim2のようなVSCodeのオープンソースフレームワーク上に構築されたエディタでも同様です。

コマンドパレットから「Git: Merge Branch」コマンドを使用する(Onivim2)
コマンドパレットから「Git: Merge Branch」コマンドを使用する(Onivim2)

この利点は(ここで紹介しているすべてのツールに言えることですが)マージにコマンドラインを必要としないことです。通常、ソースブランチとターゲットブランチをドロップダウンメニューから選択し、エディタからマージを実行する必要があります。とは言え、実際には手動での介入を行うことも可能です。マージ後に変更を確認し、必要なコミットを行うことができます。

他にも、Sublime Textのグラフィカルユーザーインターフェース(GUI)を使って、Gitの作業を行うことも可能です。このエディタを使うなら、Sublime Mergeはワークフローに理想的な追加のツールとなることでしょう。

Sublime Merge
Sublime Merge

どのコードエディタを選んでも、コマンドラインを使わずにGitを操作できる機能がたくさんあることに気付くはずです。VimやNeovimでも、Tim Pope氏のGit Fugitiveプラグインを使えば、簡単に素晴らしい機能が追加できます。

また、この作業だけに特化した専用のサードパーティ製マージツールもいくつかあります。

専用のGitマージアプリ

たとえばMergifyは、継続的インテグレーション/継続的デリバリー(CI/CD)パイプラインとワークフローに統合された、エンタープライズレベルのコードマージツールです。

Mergifyのウェブサイト
Mergifyのウェブサイト

マージ前のプルリクエストの更新を自動化したり、優先度に基づいてプルリクエストを並べ替えたり、バッチ処理したりすることができます。また、オープンソースのソリューションとしては、Meldを検討してみる価値はあるかもしれません。

Meldアプリのインターフェース
Meldアプリのインターフェース

Meldの安定版リリースは、WindowsとLinuxをサポートし、GPLライセンスの下で機能します。ブランチの比較やマージの編集など、基本的な機能を備えており、2方向または3方向の比較や、Subversionのような他のバージョン管理システムのサポートもあります。

まとめ

Gitは、共同作業やコード変更の効率的な管理に欠かせないツールです。しかし、複数の開発者が同じコードで作業をすると、コンフリクトが発生することがあります。Gitのマージ戦略は、このようなコンフリクトを解決するのに有効です。複雑な状況下では、高度なGitのマージ戦略が必要になります。

もちろん、ネガティブスペースを無視したり検索ログを漁ったりするようなシンプルな作業になることもあります。しかし、必ずしもコマンドラインを使う必要はありません。手助けしてくれるアプリはたくさんありますし、コードエディタに便利な機能が搭載されていることもあります。

質を重視したアプリケーションホスティングをお探しであれば、Kinstaにお任せください。クラウドベースのアプリケーションホスティングサービスが、お客様のフルスタックアプリケーションの日々の稼働と成長を後押しします。

今回ご紹介した数々のGitマージ戦略のうち、どれを使えば窮地を脱することができるでしょうか?特に気になるものがございましたら、以下のコメント欄でお聞かせください。

Jeremy Holcombe Kinsta

Content & Marketing Editor at Kinsta, WordPress Web Developer, and Content Writer. Outside of all things WordPress, I enjoy the beach, golf, and movies. I also have tall people problems ;).