取り消した機能は、もう一度 merge しても戻らない
一言でいうと
revertは、変更を打ち消す新しいコミットを作ります。そのため、マージコミットを元に戻すには、どちらの側を基準に打ち消すか(-m)を指定する必要があり、元に戻した機能ブランチをあとで再びmergeすると、gitは「すでにマージされている」と答えます。履歴はそのまま残っていて、内容だけが消えた状態だからです。
なぜ必要なのか
リリースの翌日に障害が起きます。原因は昨日マージした機能ブランチで、すでに複数の人がその履歴を取得しています。resetは使えません。他の人に渡ったコミットを消すことになり、全員の履歴がずれるからです。そのため、revertを使います。
ところが、そのマージコミットを元に戻そうとすると、gitが止まります。
$ git revert 685e75f
error: commit 685e75f is a merge but no -m option was given.
fatal: revert failed
マージコミットは、親が2つあります。「元に戻す」というのは、ある状態に戻るという意味ですが、親が2つあると、どちらの親の状態に戻るかが決まりません。-m 1は、1番目の親(たいてい、マージを受け入れた側、つまりmain)を基準にして、2番目の親が持ち込んだ変更を打ち消すという意味です。-m 2は、その逆です。
ここまでは、たいていの人が知っていて通り過ぎます。本当の落とし穴は、その次です。
どう動くのか
マージをrevert -m 1で元に戻したあと、機能を直して再び入れようとgit merge featureを実行すると、次のように表示されます。
Already up to date.
バグのように見えますが、正確な答えです。mergeは、履歴の到達可能性で判断します。そのマージコミットが今も履歴にあり、featureのすべてのコミットがそこから到達可能なので、mergeの立場では、マージするものが残っていません。内容がない理由は、そのあとに来たrevertコミットが消したからであり、マージされていないからではありません。
そのため、元に戻した機能を復活させる方法は、revertをrevertすることです。
git revert <되돌림 커밋>
git 2.xは、このコミットのデフォルトのタイトルをReapply "..."にしてくれます。履歴に、取り消しと復活の両方が残るため、あとで「この機能はなぜ2回入ったのか」を、履歴を読むだけでわかります。Linuxカーネルのドキュメントがこのパターンを勧める理由も同じです。履歴は、実際に起きたことを語る必要があります。
cherry-pickに出典を残す
ホットフィックスをリリースブランチで作り、mainにも入れることはよくあります。ただのcherry-pickでは、新しいコミットに、元がどこだったかが残りません。-xを付けると、メッセージの末尾に1行が付きます。
(cherry picked from commit fb42e70c24e9bab398dcef64c3b2bd316de8ca4e)
あとで、「この修正はリリースにも入ったのか」を調査するとき、この1行があるかないかで、大きく変わります。ただし、非公開リポジトリから公開リポジトリへ移すときは、存在しないハッシュを指すことになるため、そのときは使いません。
同じ変更がすでに入っているか: patch-id
「このコミットは、あのブランチにすでに反映されているか」は、ハッシュではわかりません。cherry-pickは、新しいコミットを作るからです。gitは、代わりにpatch-idを使います。diffから空白と行番号を取り除いてハッシュした値なので、同じ変更であれば、コミットのハッシュが違っても、同じ値が出ます。
$ git show <원본> | git patch-id --stable
37d09d56... fb42e70c...
$ git show <가져온 것> | git patch-id --stable
37d09d56... 075fc52e...
git cherry -v <upstream> <head>が、この計算を代わりに行ってくれます。先頭の-は、その変更がupstreamにすでにあるという意味で、+は、まだないという意味です。
ここに、重要な限界があります。衝突を解決して取り込んだコミットは、patch-idが変わります。文脈が変わったので、diffも違うからです。そのため、git cherryは、そのコミットを+(まだない)と表示します。自動判定をそのまま信じて再び取り込むと、同じ修正が2回入ります。このツールが答える問いは、「同じdiffがあるか」であり、「同じ意図が反映されたか」ではありません。
現場での姿
- リリースブランチのホットフィックスを
mainに取り込むときは、-xをルールとして決めておきます。 - 問題になった機能を元に戻してから、直して再び入れるとき、復活のコミットのタイトルだけを見て、「なぜ2回入ったのか」と聞かれることがなくなります。
- 長期サポートブランチにバックポートするとき、
git cherry -vで残りを先に数え、衝突で解決した項目は、リストに手で印を付けておきます。 - 衝突の最中に、
git cherry-pick --abortできれいに引き返せることを知っていれば、半分だけ解決した状態でコミットしてしまう事故が減ります。
次のラボですること
マージコミットを作り、-mなしで元に戻そうとして拒否されるところから見ます。-m 1で元に戻したあと、git mergeが「Already up to date」を出すことを確認し、revertのrevertで復活させます。そのあと、ホットフィックスを-xで取り込み、衝突するコミット1つは--abortで引き返してから、再び--continueで完了させます。最後に、git cherryとgit patch-idで、何がすでに入っているかを数えてみると、衝突で解決したコミットがなぜ+のまま残るのかが、目に見えます。
公式ドキュメントは、git-revert、git-cherry-pick、git-cherry、git-patch-idです。