歴史改変、してますか?
私は歴史改変が大好きで、毎日 rebase しています。なので割と毎日 git push -f することになっています。
口で -f と言っても、実際には --force-with-lease --force-if-includes をしているので、これらのオプションのご紹介。
この記事は はてなエンジニア Advent Calendar 2022 の 18 日目です。昨日は
id:rokoucha さんで 壊れたデータベースとの向きあいかた - rokoucha でした。
-f の危険性
...--F--G--H <-- main
という状態で push した後、H をコミットし直したとしよう。
...--F--G--H' <-- main
\
H <-- origin/main
このまま H' (main) を origin/main に push したいが、改変しているので「fast-forward ではない」と怒られます。
$ git push origin HEAD To github.com:onk/foo.git ! [rejected] HEAD -> main (non-fast-forward) error: failed to push some refs to 'github.com:onk/foo.git' hint: Updates were rejected because the tip of your current branch is behind hint: its remote counterpart. Integrate the remote changes (e.g. hint: 'git pull ...') before pushing again. hint: See the 'Note about fast-forwards' in 'git push --help' for details.
ここで push -f をするんですが、もし origin 側に他人のコミット I が更に積まれていたら。
...--F--G--H' <-- main
\
H--I <-- origin/main
この状態で push -f すると I は消えて無くなります。H を書き換えるつもりはあったが、I を闇に葬るつもりは無かった。
...--F--G--H' <-- main, origin/main
\
H--I <-- ???
このように、-f はローカルを強制的にリモートに同期するので、安易に使うと共用リポジトリを頻繁に破壊できます。
少し安全になる --force-with-lease
自分の知らないコミットがあると失敗するようになるオプションです。
先ほどと同じように、origin/main 側に気づかないうちに I が積まれている場合。
...--F--G--H' <-- main
\
H--I <-- origin/main
この状態で push --force-with-lease すると、以下のように怒ってくれます。
$ git push --force-with-lease origin HEAD To github.com:onk/foo.git ! [rejected] HEAD -> main (stale info) error: failed to push some refs to 'github.com:onk/foo.git'
stale info、古くて使えないと言われています。何を比べて古いのかというと、実はローカルブランチ main、リモートブランチ origin/main だけではなく、ローカルの .git の中に remotes/origin/main というブランチがあり、この remotes/origin/main が origin/main より古いと怒られているのです。
...--F--G--H' <-- main
\
H <-- remotes/origin/main
\
I <-- origin/main
- ローカルブランチ:
H' - ローカルのリポジトリが知っているリモートブランチ:
H - リモートブランチ:
I
この「ローカルのリポジトリが知っているリモートブランチ」は .git/refs/remotes/origin/ に並んでいるので、眺めてみてください。オススメは .git の中で git init して、 git fetch や git push したときに何が変わるのかを diff の形で眺めてみることです。コミットハッシュへの参照なんだな、というのがよく分かると思います。*1
ローカルのリポジトリが知っているリモートブランチは git fetch で origin と同期されます。
$ git fetch remote: Enumerating objects: 4, done. remote: Counting objects: 100% (4/4), done. remote: Compressing objects: 100% (2/2), done. remote: Total 3 (delta 0), reused 3 (delta 0), pack-reused 0 Unpacking objects: 100% (3/3), 283 bytes | 283.00 KiB/s, done. From github.com:onk/foo 8f51241..f953a86 main -> origin/main
fetch したことで、remotes/origin/main は origin/main と同じく I を指すようになりました。
...--F--G--H' <-- main
\
H--I <-- remotes/origin/main, origin/main
ローカルのリポジトリが知っているリモートブランチと実際のリモートブランチとが揃っていると、push --force-with-lease は成功し、 I は闇に消えていきます。
...--F--G--H' <-- main, remotes/origin/main, origin/main
\
H--I <-- ???
このように、手癖で push -f 前に git fetch してしまった場合等では、--force-with-lease は他人のコミットを守ってくれません。
更に安全になる --force-if-includes
Git v2.30.0 で増えたオプションです。更に安全になります。
...--F--G--H' <-- main
\
H--I <-- remotes/origin/main, origin/main
--force-if-includes の場合、--force-with-lease では push できてしまっていた remotes/origin/main と origin/main が揃っている場合でも、reflog に remotes/origin/main (I) が含まれていること、をチェックして撥ねてくれます。面白い条件だと思う。
$ git push --force-with-lease --force-if-includes origin HEAD To github.com:onk/foo.git ! [rejected] HEAD -> main (remote ref updated since checkout) error: failed to push some refs to 'github.com:onk/foo.git' hint: Updates were rejected because the tip of the remote-tracking hint: branch has been updated since the last checkout. You may want hint: to integrate those changes locally (e.g., 'git pull ...') hint: before forcing an update.
ここまでやっておくと、日常的に rebase && push -f していても闇に飲まれる他人のコミットは無くなり、安全になります。
というわけで、口では push -f と言っているけど、安全なチーム開発を意識している人は --force-with-lease --force-if-includes のことを指していて、本当に push -f していることは無いんだよ、という説明でした。
はてなエンジニア Advent Calendar 2022、明日は
id:tokizuoh さんです。
参考 URL
*1:この技はドリコムの社内勉強会で
id:sue445 に教えて貰いました https://sue445.hatenablog.com/entry/2013/06/26/012409