はじめに — 同じコード、違う歴史
mergeとrebaseの論争は、たいてい好みの争いとして消費されます。きれいな一直線がいいという側と、起きたことはそのまま残すべきだという側が、それぞれのスクリーンショットを持ち出してきます。ところが2つの命令が実際に何を作るのかをコミットハッシュのレベルで見ると、論争の半分は自然に片づきます。
核心から述べます。マージはコミットを1つ増やすだけで、既存のコミットには手を触れません。リベースは既存のコミットを移動させるのではなく、内容は同じで親だけが異なる新しいコミットを作り出します。だからハッシュが変わります。この一文が「共有されたブランチはリベースしない」というルールの唯一の根拠であり、そのルールの例外がどこまでなのかもここから出てきます。
コミットハッシュが語ること — リベースはコミットを移動しない
分岐したブランチを1つ用意しました。
$ git log --oneline --graph --all
* 8c377e2 (HEAD -> feature/checkout) 결제 실패 재시도 추가
* 6d466fd 결제 요청 타임아웃 조정
| * c76f2e6 (main) 로깅 포맷 통일
|/
* a2338b7 주문 API 스키마 정리
ここにリベースをかけます。
$ git rebase main
Rebasing (1/2)
Rebasing (2/2)
Successfully rebased and updated refs/heads/feature/checkout.
$ git log --oneline --graph --all
* 4a17a14 (HEAD -> feature/checkout) 결제 실패 재시도 추가
* 7040fea 결제 요청 타임아웃 조정
* c76f2e6 (main) 로깅 포맷 통일
* a2338b7 주문 API 스키마 정리
コミットメッセージも同じで変更内容も同じなのに、ハッシュがすべて変わりました。8c377e2が4a17a14になり、6d466fdが7040feaになっています。理由は、コミットオブジェクトの姿を見れば即座に納得できます。
$ git cat-file -p HEAD
tree c9cff0bf1786d3c413655ad220869022721584a4
parent 7040fea9c7bc15cd1d77bb26d843c8ba81140fec
author Demo <d@e.com> 1785081971 +0900
committer Demo <d@e.com> 1785081971 +0900
결제 실패 재시도 추가
コミットオブジェクトの中には親コミットのハッシュが入っており、コミットのハッシュはこの内容全体をハッシュした値です。親が変わればコミットハッシュは必ず変わります。つまりリベースはコミットを移動させる命令ではなく書き換える命令であり、書き換えという言葉は正確に「新しいオブジェクトを作ってブランチのリファレンスをそちらへ移した」という意味です。
では元のコミットはどこへ行ったのでしょうか。消えてはいません。誰からも指されなくなっただけです。
$ git reflog show feature/checkout
4a17a14 feature/checkout@{0}: rebase (finish): refs/heads/feature/checkout onto c76f2e6
8c377e2 feature/checkout@{1}: commit: 결제 실패 재시도 추가
6d466fd feature/checkout@{2}: commit: 결제 요청 타임아웃 조정
a2338b7 feature/checkout@{3}: branch: Created from HEAD
リベースの結果をレビューしたいなら、2つのバージョンを並べて比較する専用の命令があります。強制プッシュされた他人のブランチを見直すときに特に便利です。
$ git range-diff main feature/checkout@{2} feature/checkout
1: 6d466fd = 1: 7040fea 결제 요청 타임아웃 조정
-: ------- > 2: 4a17a14 결제 실패 재시도 추가
黄金律の本当の境界線
「すでにプッシュしたコミットはリベースするな」という文はよく引用されますが、少し不正確です。問題の本質はプッシュしたかどうかではなく、そのコミットオブジェクトが他人のリポジトリにあるか、そしてその上に誰かの作業が積まれているかです。
同僚のクローンには8c377e2がそのまま残っています。自分が4a17a14に差し替えたブランチを強制プッシュすると、同僚が次に取得したときにローカルの古いコミットと新しいコミットの両方を持つ状態になります。ここで同僚が何気なくマージすると、同じ変更が二度入った履歴ができあがり、コンフリクトはこの地点から始まります。
ですからルールはこう書き直したほうが正確です。他人の作業がその上に積まれているコミットは書き換えない。この基準で見ると、よく言われる例外が例外ではなくルールの正常な適用であることがわかります。
- まだ誰もチェックアウトしていない自分のレビューブランチはリベースしてかまいません。多くのチームがレビュー中のリベースを慣行として許している理由です。
- 自分のローカルにしかないコミットを整理するリベースは常に安全です。以下の設定はそれを既定の動作にします。
$ git config --global pull.rebase true
$ git config --global rebase.autoStash true
問題はその次です。リベースしたブランチを上げるには強制プッシュが必要ですが、ここでよく見かける助言が危険です。
# よくある助言 — 他人のコミットまで一緒に消します
$ git push -f origin feature/checkout
# 最低限このように
$ git push --force-with-lease origin feature/checkout
To github.com:example/shop.git
+ 8c377e2...4a17a14 feature/checkout -> feature/checkout (forced update)
ここでもう一歩踏み込む必要があります。--force-with-leaseはリモート追跡リファレンスが自分の最後に見た値と同じかどうかを確認しますが、IDEやバックグラウンドのジョブがたった今フェッチしていたら、そのリファレンスはすでに最新へ更新されています。自分が見ていないコミットが入ってきていても検査は通過します。この穴をふさぐオプションが別にあります。
$ git config --global push.useForceIfIncludes true
$ git push --force-with-lease origin feature/checkout
スタックのように積み上げたブランチが複数あるなら、以下のオプションが下側のブランチのリファレンスまで一緒に動かしてくれます。これを知らないと、リベースのたびに下流のブランチを手で付け直すことになります。
$ git rebase --update-refs main
$ git config --global rebase.updateRefs true
マージコミットは情報かノイズか
マージコミットをなくすべきだという主張の根拠は、たいてい「ログが汚い」です。この不満のほとんどはツールの使い方で解決します。
マージコミットは親が2つあります。1番目の親はマージを受けた側、つまりmainの直前の状態であり、2番目の親が入ってきたブランチです。1番目の親だけをたどれば、main視点での統合順序だけが残ります。
$ git log --oneline --graph --first-parent main
* 40600e3 Merge branch 'feature/checkout'
* c76f2e6 로깅 포맷 통일
* a2338b7 주문 API 스키마 정리
リリースノートを作ったり「いつ何が入ったか」を見たりするときに欲しい絵は、まさにこれです。bisectも同じ方法でブランチ内部を飛ばし、統合単位だけに絞れます。
$ git bisect start --first-parent
$ git bisect bad main
$ git bisect good v2.14.0
ですからマージコミットそのものはノイズではありません。本当のノイズは統合の意図がないマージコミットです。リベースせずに習慣的に取得していると生まれる Merge branch 'main' into feature のようなコミットがそれです。これは何の情報も持たず、グラフをこじらせるだけです。以下の設定を入れれば、その種類のマージコミットはそもそも生まれません。
$ git config --global pull.ff only
まとめると、対立の構図が間違っています。選択肢はマージコミットを許すかどうかではなく、マージコミットに人の判断を記録させるのか、それともツールに好きなときに打たせるのかです。
スカッシュマージが与えるものと奪うもの
スカッシュマージはブランチのコミットをすべて1つにまとめて載せます。得られるものは明確です。mainのコミット1つがPR1つに正確に対応し、そのコミットはCIを通った状態であり、巻き戻しはコミット1つをrevertすれば終わります。レビュー単位と履歴単位が一致することは、思ったより大きな利点です。
奪うものも明確ですが、あまり語られません。
第一に、bisectの解像度がPRの大きさに固定されます。800行のPRがスカッシュされると、bisectはその800行までしか絞ってくれず、その内側は手で読むことになります。逆に個別のコミットが残っていればもっと絞れますが、ブランチ途中のコミットはCIを通ったことがないのでビルドが壊れている場合があります。そのときはスキップするよう伝えれば済みます。
$ git bisect skip
第二に、cherry-pickの精度を失います。ホットフィックスのブランチにそのPRのログ改善1行だけを移したくても、残っているコミットは800行の塊だけです。
第三に、これが最もよくかかる罠ですが、Gitから見ればそのブランチはマージされたことがありません。スカッシュコミットの親が元のブランチではないからです。その結果、以下の命令はすでに反映済みのブランチを一覧に見せません。
$ git branch --merged main
hotfix/coupon-null
* main
同じ理由で、スカッシュマージしたブランチを削除せずに使い続けて再びマージすると、Gitは共通祖先以降の変更をすべて新しいものとして扱い、すでに反映済みの内容までコンフリクトとして突きつけてきます。スカッシュマージを使うチームは、マージ直後にソースブランチを削除するというルールを一緒に持つ必要があります。
リベース中に同じコンフリクトが繰り返される理由
マージはコンフリクトを一度だけ解決するのに、リベースはコミットごとに同じ場所をまた聞いてきます。いらだちの原因ですが、動作としては正確です。
マージは両端と共通祖先という3点だけを比較します。一方リベースはコミットを1つずつ再適用し、そのたびに三方向マージを新たに実行します。同じファイルの同じ区画を自分のコミット3つが続けて触っていたなら、その区画のコンフリクトに3回出会うことになります。リベースは1回のマージではなくN回のマージです。
解決策は、同じ解決を覚えさせることです。
$ git config --global rerere.enabled true
$ git rebase main
Auto-merging src/checkout/retry.ts
CONFLICT (content): Merge conflict in src/checkout/retry.ts
Recorded preimage for 'src/checkout/retry.ts'
error: could not apply 6d466fd... 결제 요청 타임아웃 조정
# コンフリクトを解決して継続
$ git add src/checkout/retry.ts
Recorded resolution for 'src/checkout/retry.ts'
$ git rebase --continue
Auto-merging src/checkout/retry.ts
CONFLICT (content): Merge conflict in src/checkout/retry.ts
Resolved 'src/checkout/retry.ts' using previous resolution.
最後の行がrerereの働く音です。コンフリクト前の状態と自分が作った結果を組にして保存しておき、同じコンフリクト状態が再び現れたら保存済みの結果をそのまま適用します。リベースを中断して再挑戦したときもそのまま生きています。
コンフリクト解決そのものを楽にする設定も一緒に入れておくとよいでしょう。既定のコンフリクト表示は両側の結果しか見せませんが、以下の設定は共通祖先の原文まで一緒に見せてくれます。相手が何を変えたかではなく、両者が何から出発したかを知って初めて正しく統合できます。
$ git config --global merge.conflictStyle zdiff3
ファストフォワード、マージコミットの強制、そしてチーム方針の選択
ブランチが分岐していなければ、マージは新しいオブジェクトをまったく作らず、リファレンスを前に動かすだけです。
$ git merge feature/checkout
Updating c76f2e6..4a17a14
Fast-forward
retry.txt | 2 ++
1 file changed, 2 insertions(+)
逆に統合した事実を必ず記録したいなら、強制的にマージコミットを作ります。
$ git merge --no-ff feature/checkout
Merge made by the 'ort' strategy.
retry.txt | 2 ++
1 file changed, 2 insertions(+)
ここまで来ると、方針の選択は好みではなくチームが何を使っているかの問題になります。bisectを実際に回すのか、リリース追跡が必要なのか、レビュー単位はコミットかPRか。
| 方針 | main履歴の形 | PR境界の記録 | bisectの解像度 | 巻き戻し | 向いているチーム |
|---|---|---|---|---|---|
| マージコミット強制 | 分岐が見えるグラフ | マージコミットに残る | コミット単位、1番目の親で絞れる | マージコミットを1番目の親基準でrevert | リリース追跡と監査履歴が重要なチーム |
| リベース後にファストフォワード | 完全な一直線 | 残らない | コミット単位で最も細かい | コミットを1つずつrevert | コミット1つ1つをレビュー単位として扱うチーム |
| スカッシュマージ | 一直線、PRごとにコミット1つ | コミット1つがそのままPR | PR単位に固定 | コミット1つのrevertで完了 | PR単位でレビューし、ブランチをすぐ削除するチーム |
| リベース後にマージコミット | 一直線上の統合地点 | マージコミットに残る | コミット単位 | マージコミットをrevert | 履歴の細かさと境界の記録を両方ほしいチーム |
基準を1つだけ選ぶなら、こう問うのが実用的です。障害が起きたとき、このリポジトリで原因コミットをどうやって見つけるつもりか。bisectを実際に使うチームはコミットの細かさが資産であり、PRの説明とissueリンクで追跡するチームはコミット1つがPR1つのほうがはるかに楽です。答えが決まれば残りはついてきます。
おわりに — ルールを1つだけ覚えるなら
mergeとrebaseについての助言は多いのですが、根拠は1つです。リベースはコミットを移動せず新しく作り、したがってハッシュが変わります。ここから共有ブランチのルールが出てきて、強制プッシュが必要な理由が出てきて、同じコンフリクトが繰り返される理由が出てきて、スカッシュされたブランチがマージ済みと認識されない理由も出てきます。
Gitのオブジェクトモデルが命令の動作をどう決めるのかはGitの内部構造で理解する命令の回で続けて扱いました。巻き戻すほうが急ぐなら状況別の巻き戻しの回が近道です。
현재 단락 (1/117)
mergeとrebaseの論争は、たいてい好みの争いとして消費されます。きれいな一直線がいいという側と、起きたことはそのまま残すべきだという側が、それぞれのスクリーンショットを持ち出してきます。ところ...