Gitでブランチをマージ後、バグ発見などでマージをキャンセルしたいことがあります。その場合、マージキャンセル後にバグ修正を行い再度マージを行なう必要があります。
但し、マージのキャンセルは状況に応じて適切な方法が変わりますし、キャンセル方法によって再マージの方法も変わります。そこで、本記事ではマージをキャンセルする複数の方法と、キャンセル後の再マージ方法についてコミットグラフを使って視覚的に解説します。
目次
- プッシュ前のキャンセル
git reset --hard後の再マージ
- プッシュ後のキャンセル
git revert後の再マージ- リバートしたコミットをさらにリバートする
- リベースする
プッシュ前のキャンセル
マージコミットがまだリモートにプッシュされておらず、ローカルでのみ存在する場合、git reset --hardを使って履歴を書き換えるのが最もシンプルでクリーンです。
git reset --hard HEAD^
または、マージコミットの1つ前のコミットID(ハッシュ値)が分かっている場合は、以下のコマンドでも同様の結果を得られます。
git reset --hard <マージコミットの1つ前のコミットID>
動作
HEAD^(現在のコミット、つまりマージコミットの1つ前のコミット)にブランチの参照を強制的に移動します。- これにより、マージコミット自体が履歴から消去されます。
- 作業ディレクトリとステージングエリア(インデックス)も、マージ前の状態に完全にリセットされます。
マージ後の状態図(Mはマージコミット)

git reset --hardでマージキャンセル後の状態図

git reset --hard後の再マージ
git reset --hardでマージをキャンセルした場合、通常のマージのみで再マージが可能です。
git merge branch_name
例えば、マージキャンセル後、developブランチでバグ修正Cをコミットし、再マージするとします。この場合は下図のように、通常のマージでマージコミットMを作成するだけの簡単な操作となります。

プッシュ後のキャンセル
マージコミットが既にプッシュされ、他の開発者と共有している場合は、履歴の書き換え(git reset)を行うと他の人のリポジトリと整合性が取れなくなり、push -f(強制プッシュ)が必要になるなど、トラブルの原因となります。
そのため、履歴を書き換えずに変更を打ち消すgit revertを使用します。
マージコミットに対する git revert の特殊性
通常、git revertはコミットIDを指定するだけですが、マージコミットは親コミットを2つ持つため、-m <親番号> オプション(mはmainlineの略)で、どちらの親の履歴を残すかを指定する必要があります。
git revert -m 1 <マージコミットのID>
親番号(Parent Number)とは: マージコミットをリバートする場合、マージコミットの親は通常以下のようになっています。
- 親1 (-m 1): マージした側のブランチ(マージ先のブランチ、例:
main)の先端。 - 親2 (-m 2): マージされたブランチ(取り込まれたブランチ、例:
develop)の先端。
ほとんどの場合、「developブランチによってもたらされた変更だけを取り消したい(mainの状態に戻したい)」という意味でリバートを行うため、-m 1 を指定します。
動作
- マージコミットによって導入された変更をちょうど取り消す(逆の変更を行う)新しいコミット(リバートコミット)が作成されます。Gitコマンド風に言い換えると、
git diff R^..Rの差分はgit diff -R M^..M(マージコミットの反転)と同一です。 - マージコミット自体は履歴に残るため、履歴の整合性は保たれ、安全にプッシュできます。
マージ後の状態(Mはマージコミット)

git revertでマージキャンセル後の状態(Rはリバートコミット)

注) この方法のデメリットは、マージのリバートが単一コミットで大量のコード変更をもたらすため、git blameやgit bisectでのデバッグが困難になることです。そのため、可能であるならばマージのリバートではなく、バグ修正開発をして修正コミットを通常の方法でマージする方が好ましいでしょう。もちろん、本番リリース用ブランチなどでそのような時間余裕がない場合は、マージをリバートします。
git revert後の再マージ
マージコミットをrevertするということは、そのマージで取り込んだ差分を将来的に取り込みたくないと宣言するのと同義です。
取り込む予定だったブランチの機能が開発中止になったのであれば、これで何の問題もありません。但し、一時的にマージをキャンセルしたいだけで、将来のどこかで変更を再度取り込む場合、revertの意味を理解していないと思わぬトラブルが発生します。
例えば、マージのrevert後、バグ修正 C をdevelopブランチでコミットし、再度developブランチをmainブランチにマージしてマージコミットNを作成したとします。
この場合、マージコミットNで取り込まれる変更はBとCの差分(つまりコミットC)のみです。コミットAとBのコードは、mainブランチ上では「リバートコミットR」によって打ち消された状態が最新であるため、再度取り込まれません。結果として、mainブランチにバグ発生源のコードは存在しないにも関わらずバグ修正コードだけが存在するという、ちぐはぐな状態になってしまいます。
間違った再マージ方法の図

git revert によるマージキャンセル後の、正しい再マージの方法は2通りあります。
1. リバートしたコミットをさらにリバートする
最初の方法は、リバートコミットをさらにリバートすることです。マージの変更を打ち消したリバートコミット R に対して、さらにgit revertを適用し、打ち消しの打ち消しを行う新しいコミット R’ を作成します。
# リバートコミットのID(例: Rのハッシュ値)を指定
git revert <リバートコミットのID>
この操作はマージキャンセルを打ち消すので、結果として最初のマージを行ったことと同じことになります。
手順
git revertで、指定されたコミット(この場合はリバートコミット R)によって行われた変更を取り消す新しいコミット R’ を作成します。これにより、コミット A と B のコンテンツがmainブランチに復活します。git mergeで修正コミットC を取り込むマージコミット N を作成します。

2. リベースする
もう一つの正しい再マージの方法は、git rebaseを使います。 現状は、コミットBにバグが含まれていたため、マージコミットMをRによってリバートし、修正コミットCをdevelopブランチに追加した状態だと仮定します。

手順
1. developブランチをrebase後もそのままの状態で残すため、新しいfixブランチを作成します。(もしdevelopブランチの履歴を保存する必要がないなら、このステップは不要です。)
git switch -c fix develop
2. fixブランチをコミットP(developが最初にmainから枝分かれした地点)を起点としてリベースします。
git rebase --no-ff P fix
これにより、fixブランチに A’、B’、C’ のコミットが作成されます。これらのコミットは元のA、B、Cのコミットと参照するコンテンツツリーは同じですが、違うコミットIDを持ちます。

注) --no-ff(または -f / --force-rebase)オプションを付けないと、内容に変更がないコミットはスキップ(fast-forwardのような挙動)されてしまうため、違うコミットIDが作成されず、fixブランチはdevelopブランチと同一のコミット履歴となってしまいます。再マージを成功させるためには、コミットIDを一新してGitに「新しい変更」と認識させる必要があります。
3. fixブランチをmainにマージします。
git switch main
git merge fix
fixブランチ上の全コミットはコミットIDが異なるため、mainに取り込まれていない新しい変更とGitが判断し、元々開発していた機能(A’, B’)とバグ修正(C’)がすべてmainブランチに取り込まれ、マージコミットNが作成されます。

まとめ
マージのキャンセル方法と再マージの方法をまとめると、以下の表の通りです。
| 状況 | コマンド | データ変更 | 履歴 | 再マージ方法 |
| プッシュ前 | git reset --hard | 取り消し | 書き換えされる | 通常のマージ |
| プッシュ後 | git revert | 取り消し | 書き換えされない | 1. revertのrevert後にマージ 2. rebase後にマージ |
- プッシュ前であれば履歴を書き換えても他の開発者に迷惑をかけることはないので、
git reset --hardを使ってマージをキャンセルし、再マージの際は通常のマージを行います。 - プッシュ後であれば、履歴を書き換えない
git revertを使ってマージをキャンセルします。その後の再マージは、git revertでリバートのリバートを行なうか、git rebase --no-ff等で異なるIDを持つコミット群を作成してからマージします。どちらの方法が優れているということはないので、開発チームの方針などによって決定します。
