難しいGitマージコンフリクトを段階的に解決する方法

ours、theirs、ベースのバージョンを読み解き、リネーム、リベース、バイナリ、テストを扱いながら、難しいGitマージコンフリクトを解決します。

難しいGitマージコンフリクトを段階的に解決する方法

難しいGitマージコンフリクトが難しい理由は、コンフリクトマーカー自体にあることはほとんどありません。難しいのは、2つの異なる変更の意図を同時に保持しなければならないからです。あるブランチが関数をリネームし、別のブランチがその動作を変更した。あるブランチがファイルを移動し、別のブランチがそれを編集した。あるブランチがデータベースマイグレーションの順序を変更した。Gitは重複部分を表示できますが、ソフトウェアがその後どうあるべきかを決定することはできません。

マージがコンフリクトで停止した場合、すぐにマーカーを削除し始めないでください。まずは状況を把握しましょう。

git status

Gitはマージされていないパスをリスト表示します。both modifieddeleted by usdeleted by themboth addedなどと表示されることがあります。これらのフレーズはコンフリクトの形状を示しています。

マージが間違っていると感じたり、解決する準備ができていない場合は、さらに変更を加える前に中断します:

git merge --abort

リベースコンフリクトの場合、同等のコマンドは:

git rebase --abort

これは失敗ではありません。操作前の状態へのクリーンなリセットであり、より多くのコンテキストが必要だと気付いたときには、しばしば最も賢明な行動です。

コンフリクトを3つのバージョンとして読む

通常のコンフリクトマーカーは次のようになります:

<<<<<<< HEAD
カレントブランチのバージョン
=======
インミングブランチのバージョン
>>>>>>> feature-branch

マージ中、HEADgit mergeを実行したときにチェックアウトしていたブランチです。下側はマージされているブランチです。

難しいコンフリクトには、Gitがインデックスに保持する3つのステージを使用します:

git show :1:path/to/file   # 共通祖先
git show :2:path/to/file   # ours
git show :3:path/to/file   # theirs

共通祖先は、両方のブランチが開始したバージョンです。これは、各ブランチが実際に何を変更したかを示すため便利です。これがないと、2つの最終バージョンを比較して、その背後にある理由を見逃す可能性があります。

次のコマンドも使用できます:

git diff
git diff --ours -- path/to/file
git diff --theirs -- path/to/file
git diff --base -- path/to/file

ここで多くの人が急ぎすぎます。目標は、チームの忠誠心の投票として「ours」または「theirs」を選択することではありません。目標は、正しい最終ファイルを生成することです。

安全な手動ワークフロー

コンフリクトした各ファイルに対してこのルーチンを使用します:

  1. ファイルを開き、すべてのコンフリクトマーカーを見つけます。
  2. マークされた行だけでなく、周囲のコードも読みます。
  3. 両方のブランチでファイルに触れたコミットを確認します。
  4. ファイルを最終的な意図したバージョンに編集します。
  5. すべてのコンフリクトマーカーを削除します。
  6. 最小限の関連テストまたはビルドチェックを実行します。
  7. ファイルをステージングします。

その際に役立つコマンド:

git log --oneline --left-right --merge -- path/to/file
git diff --check
git grep -n '<<<<<<<\|=======\|>>>>>>>'

git diff --checkは、残っている空白の問題をキャッチします。git grepは、CIに到達する前に忘れられたコンフリクトマーカーをキャッチします。

1つのファイルを解決した後:

git add path/to/file

すべてのコンフリクトがステージングされたら:

git status
git commit

リベース中は、次を使用します:

git rebase --continue

両方のブランチが同じ関数を変更した場合

これは一般的なケースです。あるブランチがバリデーションを追加し、別のブランチがパラメータをリネームしたとします:

<<<<<<< HEAD
function createUser(email) {
  return db.users.insert({ email });
}
=======
function createUser(rawEmail) {
  const email = rawEmail.trim().toLowerCase();
  return db.users.insert({ email });
}
>>>>>>> normalize-email

正しい答えは両方を組み合わせたものかもしれません:

function createUser(rawEmail) {
  const email = rawEmail.trim().toLowerCase();
  return db.users.insert({ email });
}

ただし、呼び出し元がrawEmailを渡すように更新され、かつ正規化がまだ望まれている場合に限ります。関数を検索します:

git grep -n 'createUser'

難しいコンフリクトでは、近くのファイルを確認する必要があることがよくあります。あるファイルでの関数シグネチャのコンフリクトは、テスト、ルート、型、モック、またはドキュメントの更新を必要とする場合があります。

リネームと編集のコンフリクト

リネームコンフリクトは、目的のファイルが予想した場所にない可能性があるため厄介です。ステータスから始めます:

git status --short

次に、名前ステータス情報を確認します:

git diff --name-status --diff-filter=R

あるブランチがsrc/user.jssrc/account.jsにリネームし、別のブランチがsrc/user.jsを編集した場合、通常は編集されたコンテンツを新しいパスに適用したいところです。ビジュアルマージツールが役立ちますが、概念はシンプルです:リネームを保持し、意味のある編集を保持します。

最終的なパスを決定したら、必要に応じて古いパスを削除し、最終的なものをステージングします:

git rm old/path.js
git add new/path.js

最終プロジェクトに本当に両方のファイルが含まれるべきでない限り、両方のファイルをステージングしないでください。

Deleted by Us または Deleted by Them

削除/変更コンフリクトは、あるブランチがファイルを削除し、別のブランチがそれを変更したことを意味します。Gitは、削除によって変更が無関係になったかどうかを知ることはできません。

ファイルを削除したままにする場合:

git rm path/to/file

ファイルを残す場合、希望するバージョンを選択してステージングします:

git checkout --theirs path/to/file
git add path/to/file

または:

git checkout --ours path/to/file
git add path/to/file

リベース中は--ours--theirsに注意してください。リベースでは、Gitがコミットを別のベースにリプレイしているため、ラベルが逆に感じられることがあります。不明な場合は、ステージを確認します:

git show :2:path/to/file
git show :3:path/to/file

バイナリファイルのコンフリクト

Gitはほとんどのバイナリファイルをマージできません。2つのブランチが同じ画像、アーカイブ、ドキュメント、またはコンパイル済みアセットを変更した場合、1つのバージョンを選択するか、手動で新しいファイルを作成する必要があります。

自分のバージョンを採用する場合:

git checkout --ours path/to/file.bin
git add path/to/file.bin

相手のバージョンを採用する場合:

git checkout --theirs path/to/file.bin
git add path/to/file.bin

バイナリが生成されたものである場合、最善の答えは、テキストファイルを解決した後、ソースから再生成することです。バイナリがデザインアセットやドキュメントである場合は、反対側を変更した人に相談してください。推測すると作業が台無しになる可能性があります。

ファイルが読みにくすぎる場合はマージツールを使用する

優れたマージツールは、ベースバージョン、自分のバージョン、相手のバージョン、結果の4つを表示します。実際に気に入ったものを設定してください。Visual Studio Codeが一般的です:

git config --global merge.tool vscode
git config --global mergetool.vscode.cmd 'code --wait $MERGED'

次に実行します:

git mergetool

他のチームはMeld、KDiff3、Beyond Compare、またはIDE統合ツールを好みます。ツールよりも、3つのバージョンを理解することの方が重要です。赤いマーカーを消すためだけに、複雑なコンフリクトで「incomingを受け入れる」をクリックしないでください。

mergetoolを使用した後、.origなどのバックアップファイルを確認します:

git status --short

バックアップファイルが不要な場合は、mergetoolのバックアップファイルをグローバルに無効にできます:

git config --global mergetool.keepBackup false

戦略オプションは魔法ではない

次のようなアドバイスを見ることがあります:

git merge -X theirs feature

これは「自分のブランチをfeatureに置き換える」という意味ではありません。Gitのマージ戦略がコンフリクトするハンクを見たときに、それらのハンクについては相手側を優先するという意味です。両方のブランチからの非コンフリクト変更は引き続きマージされます。これは生成されたロックファイルや機械的なフォーマットコンフリクトには便利ですが、ビジネスロジックにはリスクがあります。

-X ours-X theirsは戦略オプションです。oursマージ戦略は異なります:

git merge -s ours old-branch

これは現在のツリーを維持しながらマージを記録します。これは特殊なツールであり、多くの場合、コンテンツを取らずにブランチをマージ済みとしてマークするために使用されます。確信がない限り、通常のコンフリクト解決に使用しないでください。

リベースコンフリクト

リベース中、Gitはコミットを1つずつリプレイします。つまり、1つの大きなマージコンフリクトの代わりに、いくつかの小さなコンフリクトを解決する可能性があります。

ループは次のとおりです:

git status
# ファイルを編集
git add resolved-file
git rebase --continue

リプレイ中のコミットが、新しいベースに既に変更が含まれているため不要になった場合は、次を使用します:

git rebase --skip

スキップは慎重に使用してください。リベースブランチからそのコミットを削除します。最初にコミットを読みます:

git show

繰り返しますが、リベースでは--ours--theirsが混乱を招く可能性があります。疑わしい場合は:2::3:を確認してください。

マージだけでなく解決策をテストする

マージは構文的に解決されていても、間違っている可能性があります。ファイルをステージングした後、変更された領域に触れるテストを実行します。フロントエンドのコンフリクトの場合、型チェックと対象のコンポーネントテストかもしれません。バックエンドのコンフリクトの場合、1つのサービステストやマイグレーションチェックかもしれません。ロックファイルのコンフリクトの場合、依存関係を再インストールし、パッケージマネージャーの検証コマンドを実行します。

最低限:

git diff --check
git grep -n '<<<<<<<\|=======\|>>>>>>>'

次に、悪い組み合わせをキャッチするプロジェクト固有のチェックを実行します。

将来のコンフリクトを減らす

最良のコンフリクトは、決して作成しないものです。ブランチを短命に保ち、メインブランチから定期的にリベースまたはマージし、機械的な変更と機能変更を混在させないようにします。フォーマットのみのPRはロジックも変更すべきではありません。可能であれば、ファイルの移動はファイルの書き換えも伴うべきではありません。

常に問題のあるファイルについては、所有権や構造の変更を検討します。大きな設定ファイル、生成されたスナップショット、ロックファイル、マイグレーションリスト、中央ルートレジストリは、誰もが同じ領域を編集するため、繰り返しコンフリクトを引き起こすことがよくあります。修正はプロセスである場合もあります。修正はファイルを分割したり、小さなソースから生成したりすることである場合もあります。

特別なマージ動作が必要なファイルには.gitattributesを使用します。たとえば、一部の生成されたロックファイルには、パッケージマネージャー固有のマージドライバーがある場合があります。軽率に発明しないでください。ただし、エコシステムに推奨ドライバーがあるかどうかを確認してください。

マージコンフリクトは、技術的な作業とコミュニケーションの両方の部分です。相手のブランチの意図が理解できない場合は、尋ねてください。著者と10分間話すことは、テストに合格しても相手が構築していた機能を削除するコードを黙ってマージするよりも安上がりです。

ロックファイル、マイグレーション、その他の摩擦の大きいファイル

一部のファイルは、多くのブランチが同じ小さな領域を編集するため、より頻繁にコンフリクトします。依存関係ロックファイルは一般的な例です。2つのブランチがパッケージを追加する場合、ロックファイルのコンフリクトは技術的に大きくても概念的には単純です:マニフェストファイルを解決した後、パッケージマネージャーで再生成します。

Nodeプロジェクトの場合、package.jsonを解決し、ロックファイルを所有するパッケージマネージャーを実行することを意味するかもしれません:

npm install
# または pnpm install
# または yarn install

次に、マニフェストとロックファイルの両方をステージングします。複雑なロックファイルを、その形式を理解していない限り手動で編集しないでください。パッケージマネージャーは、微妙な依存関係グラフの間違いを犯す可能性が低くなります。

データベースマイグレーションはより注意が必要です。2つのブランチが順序付けの前提を持つマイグレーションを作成する場合、両方のファイルを受け入れるだけでは不十分な場合があります。マイグレーションのタイムスタンプ、シーケンス番号、依存関係、および両方のマイグレーションが同じテーブルまたはデータを変更するかどうかを確認します。場合によっては、正しい解決策は、2つのブランチを調整する新しいフォローアップマイグレーションです。

生成されたスナップショットとゴールデンファイルも同じパターンです:最初にソースの変更を解決し、出力を再生成し、生成された差分を確認します。生成された差分が膨大な場合、それが同じマージコミットに属するかどうかを尋ねます。巨大な生成された変更は、悪い手動解決を隠す可能性があります。

コンフリクトが複数のファイルにまたがる場合、編集する前に最終的な意図する動作を書き留めてください。「機能Aからの新しいバリデーションを保持、機能Bからのリネームされたサービスを保持、クライアントタイプを再生成」のような短いメモは、各ファイルをローカルで解決しながら全体的な設計を失うことを防ぎます。

特にリスクの高いマージの場合、開始前に一時的なブランチを作成します:

git switch -c merge-test/main-with-feature
git merge feature

解決が乱雑になった場合、元のブランチを乱さずに一時的なブランチを破棄できます。この小さな習慣により、常にクリーンな戻り道があるため、難しいコンフリクトのストレスが軽減されます。

最終マージを独自の変更としてレビューする

コンフリクト解決は新しい作業です。レビューではそのように扱ってください。最終的な差分は、両方のブランチの変更だけでなく、それらを連携させるために記述したグルーコードも示す必要があります。マージコミットが大きい場合は、コミットメッセージまたはプルリクエストコメントで解決策を説明してください。レビュアーは、なぜ一方の側が選択されたかをリバースエンジニアリングする必要はありません。

プッシュする前に、可能であれば最終結果を両方の親と比較します:

git diff HEAD^1..HEAD -- path/to/file
git diff HEAD^2..HEAD -- path/to/file

コミットされていないマージの場合、ステージングされた変更を確認します:

git diff --cached

テストの偶発的な削除、不要になったインポート、重複した設定エントリ、および両方のブランチが異なる名前で同様のロジックを追加したコードパスを探します。これらはGitがあなたのために特定できない間違いです。

コンフリクトが動作に関係する場合は、間違った側を選択した場合に失敗するテストを追加または更新します。そのテストは今日のマージを証明するだけではありません。次のリファクタリングで決定が元に戻されるのを防ぎます。