あるブランチをmainブランチにマージする時に差分としてcherry-pick済みのコミットが表示される理由

会社で説明したのでブログにもまとめておきます。

問題

あるブランチをマージする際に、意図しないコミットがdiffとして表示されることがあります。 典型的にはcherry-pick済みのcommitの内容が表示され、「すでに反映済みなのにな・・?」という疑問につながることが多いでしょう。 実際それぞれのブランチで該当ファイルを開くと全く同じ内容になっているのにもかかわらずdiffに表示されてしまっており混乱したことがある人も多いのではないでしょうか。

ここでは双方のブランチで反映済み(cherry-pick済み)のcommitが何故diffにでてしまうのかについて解説したいと思います。

ポイント

  • commitをcherry-pickするとcommit hashが変わる
  • GitHubやGitLabは(デフォルトでは)two dot diffを表示する

cherry-pickするとcommit hashが変わる

あるコミットをcherry-pickすると実質的な内容は同じでもcommitが再生成されます。 cherry-pick後にcommit hashを確認すれば違う値になっていることが分かるでしょう。(もし詳細を知りたい場合は コミットはスナップショットであり差分ではない - GitHubブログ が参考になります。)

commit hashが違うからcompare時にはその分のdiffも出るんじゃ!と捉えてしまっても一旦は良いですが今回はもう少し深堀りしてみましょう。

2つのgit diff (two dot diffとthree dot diff)

突然ですがgit diffのブランチ指定方法には以下のようなバリエーションがあります。

git diff a..b
git diff a...b

前者をtwo dot diff, 後者をthree dot diffと呼びます。 ※ ちなみに git diff a b はtwo dot diffと全く同じ意味です。

この2つには git diffコマンドで比較する時のダブルドット(..)とトリプルドット(...)の違いとは? | Yakst で解説されている通り以下のような違いがあります。

  • two dot diff: a,bの先頭同士のdiffを表示
  • three dot diff: 「a,bの共通祖先コミット」とbの先頭についてのdiffを表示

つまり以下のようにcherry-pickされたコミットがあった場合

branch a branch b
commit 1 commit 1
commit 2 commit 2'(cherry-picked)

git diff a..b ではdiffなし(commit 2の状態とcommit 2'の状態を比較), git diff a...b ではdiffあり(commit2は共通祖先ではないので無視されて、共通祖先であるcommit 1の状態とbranch bの先頭であるcommit 2'の状態を比較) *1 という結果になります。

これを踏まえると最初に示した「意図しないコミットが表示される」状況を正確にいうと「two dot diffではdiffがないがthree dot diffではcherry-pick済みのコミットが表示される」状況になっているはずです。

GitHubやGitLabはtwo dot diffを表示する

前節を踏まえるとなんとなく想像がつくかもしれませんが、GitHubのPull Request画面やGitLabのMerge Request画面、あるいはcompare画面ではデフォルトでは three dot diffを表示しています

なんで three dot diff にしてるのかは プルリクエスト中でのブランチの比較について - GitHub Docs を参考にするとよいでしょう。

スリードット比較はマージ ベースと比較するため、"pull request によって何が導入されるか" に焦点を当てています。 ツードット比較を使用したときは、トピック ブランチに変更を加えていない場合でも、ベース ブランチが更新されると差分が変化します。 また、ツードット比較はベース ブランチに焦点を当てます。 つまり、追加したものは、ベース ブランチに存在しないものとして (削除されたかのように) 表示されます。その逆も同様です。 その結果、トピック ブランチが導入する変更があいまいになります。 対照的に、スリードット比較を使用してブランチを比較すると、ベース ブランチが更新された場合、トピック ブランチの変更は常に差分に含まれます。この差分には、ブランチが分岐してからのすべての変更が表示されるためです。

たしかにレビュー中にdiffがちょこまか変わったら面倒なことになりそうですね。three dot diffはwhat you wrote(targetがどう変わるか?ではなく、あなたが書いたものそのもの)を見るためのものと捉えてもいいかもしれません。

GitHubやGitLabで two dot diff を見る方法

three dot diffではなくtwo dot diffを見たい場合はgit diffを手元で実行すればいいのですが面倒なことも多いでしょう。(手元のブランチが古くて不正確なdiffを信じてしまうといったミスも考えられるので正確性が必要な場合は注意が必要な操作でもあります。)

そうなるとGitHubやGitLabでtwo dot diffを見たくなると思うのでその方法を紹介します。(UIは適宜変わると思うのでここの内容は古くなってるかもしれません。)

GitHub

GitHubではcompare画面で比較した後にURLを直接書き換えることでtwo dot diffを見ることができます。

例えば https://github.com/microsoft/TypeScript/compare/v5.5-rc...v5.5.2 の場合は URL欄を直接書き換えて https://github.com/microsoft/TypeScript/compare/v5.5-rc..v5.5.2 にします。

地味にタイトルが Comparing v5.5-rc...v5.5.2 · microsoft/TypeScript から Comparing v5.5-rc..v5.5.2 · microsoft/TypeScript に変わっていたり、UI上も ..... になっていたりと意識してみると変わっている事がわかります。

普通にUI上で操作できないのか気になりましたが公式ドキュメントでURLを編集するように書いてあるので執筆時点では無いのだと思います。

GitHub 上で、ツードット diff を比較する際に 2 つの committish のリファレンスを見たい場合には、リポジトリの [Comparing changes] ページの URL を編集できます。

GitLab

GitLabではcompare画面にいくと straight=false というクエリーパラメータがデフォルトでつくので直接 straight=true に書き換えることでtwo dot diffを表示することができますがUIからも可能です。

公式ドキュメント を見ると compare画面でデフォルトで選択されている Only incoming changes from source (= three dot diff相当) を Include changes to target since source was created (= two dot diff相当)に切り替えることでも実現できることがわかります。

まとめ

  • git diffには two dot diff(最新状態のdiff) と three dot diff(共通祖先とのdiff) という2つの比較方法がある
  • GitHub, Gitlabの画面ででてくるのはデフォルトではthree dot diffになっている。これがcherry-pick済みのコミットを表示するため混乱のもとになることがある。

小ネタだと思っていたけどなんか微妙に込み入っているというか自分をそこまで詳しく理解してたわけではないので改めて勉強になりました。

*1:commitはsnapshotなのでわざわざcommit Nの状態と書かなくても良いかもしれません