--force は有害だという考え; git の --force-with-lease を理解する

Git の push --force は有害です。何故ならローカルの内容を無条件にリモートレポジトリを上書きしてしまい、チームメンバーがその間にプッシュしていた変更を上書きてしまうからです。しかし、これには改善策があります。強制プッシュがどうしても必要ではあるけれど、他人の作業を上書きしないようにしたいときは --force-with-lease というオプションを利用します。

force-with-lease
Git の push --force は共有レポジトリにプッシュされた他の変更を破壊する可能性があるので、利用すべきではないことは良く知られています。常に完全に失われることにならなくても (もし変更が他人のワーキングツリーに存在していればマージすることは可能です)、これは無分別な対処であり、最悪の場合は大きな損害を招きます。何故なら --force というオプションはブランチの先頭をローカルの履歴に設定し、これまでの全ての変更を無視することになるからです。

強制プッシュは、ブランチをリベース(rebase)しなければいけない時によく発生します。これを例示するために簡潔な例を見てみましょう。あるプロジェクトでアリスとボブが一緒に作業をしようとしているフィーチャーブランチがあります。彼ら二人はこのレポジトリをクローン(clone)し作業を始めます。

アリスは最初にこのフィーチャーに対して、彼女の担当部分を完了させました。そしてこれをメインのレポジトリにまでプッシュしました。ここまでは何の問題もありません。

ボブも彼の作業を終えました、しかし、それをプッシュする前に彼はいくつかの変更がマスターブランチ上にマージされたことに気付きました。ボブはきれいなツリーを維持したかったので、マスターブランチに対してリベースを実行しました。そうすると、このリベースされたブランチにソースのプッシュは拒否されます。しかし、アリスがすでに彼女の作業をプッシュしていたことを気付かずに、彼は push --force を行いました。不幸なことに、これはオリジナルのリポジトリ内のアリスの変更の全ての記録を削除してしまうことになります。

ここでの問題は、ボブが強制プッシュをする際に、なぜ自分の変更が拒否されたのかを知らなかったことです。その結果、彼はこれがリベースのせいだと推測し、アリスの変更によるものだとは考えませんでした。これが共有ブランチ内において --force は絶対に行っては行けない理由です。共有する可能性があるブランチの中央リポジトリ型ワークフローでは利用するべきではありません。

しかし、--force には余り知られていない類似のオプションがあります。これを利用すると強制更新によるリポジトリの破壊を部分的に保護してくれます。これが --force-with-lease です。

この --force-with-lease は誰もブランチのアップストリームを変更していないなど、期待された状況にならない限りはブランチへの更新を拒否します。実際には、アップストリームへの参照が期待されているものであるかチェックすることによって動作します。git のリファレンスはハッシュ値となっており、暗にこれまでの値をチェーンとしたものをエンコードして格納しているからです。

--force-with-lease が実際に何をチェックするかはわかったかと思いますが、デフォルトでは現在のリモートのリファレンスをチェックします。これが実際のところ何を意味するかと言うと、アリスが自分のブランチを更新しそれをリモートリポジトリにプッシュすると、このブランチの HEAD を示すリファレンス が更新されるということです。これによって、ボブがリモートからプルしない限り、彼のリモートへのローカルのリファレンスは期限切れになります。彼が --force-with-lease を使ってプッシュをする際、 git は新しいリモートに対してローカルのリファレンスをチェックし、プッシュの強制を拒否します。--force-with-lease はその間誰も変更をリモートにプッシュしていない場合のみ、効率的に強制プッシュを許可します。これは --force にシートベルトを装着させるようなものです。簡潔なデモンストレーションがこれをわかりやすくさせる助けとなるかもしれません:

アリスがブランチにいくつかの変更を加え、メインのリポジトリにプッシュしました。しかしここでボブがマスターに対してブランチをリベースします:

ssmith$ git rebase master

First, rewinding head to replay your work on top of it…

Applying: Dev commit #1

Applying: Dev commit #2

Applying: Dev commit #3

リベースされたので、彼はプッシュしようと試みます。しかしこれがアリスの作業を上書きしてしまうのでサーバーが拒否します。

ssmith$ git push

To /tmp/repo

! [rejected] dev -> dev (fetch first)

error: failed to push some refs to ‘/tmp/repo’

hint: Updates were rejected because the remote contains work that you do

hint: not have locally. This is usually caused by another repository pushing

hint: to the same ref. You may want to first integrate the remote changes

hint: (e.g., ‘git pull …’) before pushing again.

hint: See the ‘Note about fast-forwards’ in ‘git push –help’ for details.

しかしボブはこれがリベースのせいだと推測するので、とにかくプッシュしようとします。

ssmith$ git push –force

To /tmp/repo

+ f82f59e…c27aff1 dev -> dev (forced update)

しかし、もし彼が --force-with-lease を使っていた場合、これとは異なる結果を得ていたことでしょう。 ボブが最後にフェッチしてからリモートブランチが実際にはアップデートされてはいなかったことが git によってチェックされていたからです。

ssmith$ git push -n –force-with-lease

To /tmp/repo

! [rejected] dev -> dev (stale info)

error: failed to push some refs to ‘/tmp/repo’

もちろん、 git は 警告を表示します。基本的にはアリスが既に彼女の変更をリモートリポジトリにプッシュしていた場合にのみ機能します。これは別に深刻な問題ではありません。しかし、彼女がリベースされたブランチをプルするとき、自分の変更をマージするように促されます。もし望むなら、彼女が自分の作業をリベースできます。

それよりも目立たない問題ではありますが、ブランチが修正されているにも関わらず、修正されていないと git が認識することがあります。通常の使用法においてこのようなことが起きてしまう方法とは、ボブが彼のローカルコピーをアップデートするときに git pull よりも git fetch をむしろ使用したような場合です。このフェッチはオブジェクトと参照をリモートからプルしてくれますが、マッチングが無ければ マージはワーキングツリーをアップデートしてはくれません。これは、リモートのワーキングコピーがアップデートされているように錯覚させます。そしてこのリモートには新しいネットワークを実際には含まれず、そして --force-with-lease にリモートブランチを上書きさせるように錯覚させるものです。これは以下の例に見られます:

ssmith$ git push –force-with-lease

To /tmp/repo

! [rejected] dev -> dev (stale info)

error: failed to push some refs to ‘/tmp/repo’

ssmith$ git fetch

remote: Counting objects: 3, done.

remote: Compressing objects: 100% (3/3), done.

remote: Total 3 (delta 0), reused 0 (delta 0)

Unpacking objects: 100% (3/3), done.

From /tmp/repo

1a3a03f..d7cda55 dev -> origin/dev

ssmith$ git push –force-with-lease

Counting objects: 9, done.

Delta compression using up to 8 threads.

Compressing objects: 100% (6/6), done.

Writing objects: 100% (9/9), 845 bytes | 0 bytes/s, done.

Total 9 (delta 0), reused 0 (delta 0)

To /tmp/repo

d7cda55..b57fc84 dev -> dev

この問題に対する最もシンプルな解決策は単純に「マージ (merge) なしに フェッチ (fetch) はするな」(あるいはより一般的には両方を行ってくれるプルをしろということになります)、しかしいろいろな状況によって --force-with-lease を伴うプッシュの前にフェッチをする必要があるような場合があるかもしれません。そのような場合のためにそれを安全に行う方法があります。git にはたくさんありますが、リファレンスはオブジェクトにとってのただの恣意的なリファレンスに過ぎません。そういうことであれば私たちは自分たちでそれを作ってやればいいのです。このケースではリモートのリファレンスの “save-point” コピーをフェッチを実行する前に作ってやればいいのです。それから私たちは --force-with-lease にこのリファレンスを更新されたリモートのリファレンスとしてというよりも期待して利用するように命令すればいいのです。

これをするために私たちは git の update-ref 機能を使って、新しいリファレンスを作り、どんなリベースやフェッチのオペレーションの前でもリモートの状態を保存するようにします。これで効率よく私たちが強制プッシュをリモートにする作業をはじめるポイントをブックマークすることができます。これよって私たちはリモートブランチ dev の状態を dev-pre-rebase と呼ばれる新しい ref に保存することができるのです:

ssmith$ git update-ref refs/dev-pre-rebase refs/remotes/origin/dev

この時点で私たちはリベースとフェッチをすることができ、それから保存されたリファレンスを使用して、私たちが作業をしている時に誰かが変更をプッシュしているような場合に、リモートリポジトリを守ることができるようになります。

ssmith$ git rebase master

First, rewinding head to replay your work on top of it…

Applying: Dev commit #1

Applying: Dev commit #2

Applying: Dev commit #3

ssmith$ git fetch

remote: Counting objects: 3, done.

remote: Compressing objects: 100% (3/3), done.

remote: Total 3 (delta 0), reused 0 (delta 0)

Unpacking objects: 100% (3/3), done.

From /tmp/repo

2203121..a9a35b3 dev -> origin/dev

ssmith$ git push –force-with-lease=dev:refs/dev-pre-rebase

To /tmp/repo

! [rejected] dev -> dev (stale info)

error: failed to push some refs to ‘/tmp/repo’

これでわかったように、--force-with-lease は強制プッシュを使う機会のある git ユーザーにとっては便利なオプションです。しかしこれが --force の全てのリスクを解決する万能薬であるとは言い難く、最初にこれがどのように内部で機能するのか、またこの 警告メッセージを理解しないうちは使わない方が良いでしょう。

しかし、デベロッパーらが時折のリベースを伴う、単にプッシュとプルをするようなノーマルで最も一般的な使用例の場合、これはダメージを与えるような強制プッシュに対して必要な保護を提供するものです。このような理由により、私は将来の git (おそらく 3.0 まではないと思いますが) のバージョンにおいて、これが --force のデフォルトの動作になっているといいなと願っています。そして現在の動作が --force-replace-remote のような実際の動作を示すようなオプションに移管されれば良いとも願っています。


*本ブログは Atlassian Developers の翻訳です。本文中の日時などは投稿当時のものですのでご了承ください。
*原文 : 2015 年 4 月 29 日投稿 "--force considered harmful; understanding git's --force-with-lease"