git push --force
は最悪他の人のコミットを上書きして消してしまうので危険です。
git push --force-with-lease
を使いましょうと言われていますが、これも危険な場合があります。
また、push.default = matching
だと被害が広範囲に及ぶこともあります。
実際、どういう挙動になるか確かめてみました。
リポジトリの準備
- repo というベアリポジトリを作成する。
- repo を clone_a に clone し、master ブランチと branch ブランチにそれぞれ commit し、push もする。
- clone_a で master ブランチと branch ブランチにそれぞれ追加 commit するが、push はしない。
- repo を clone_b に clone し、master ブランチと branch ブランチにそれぞれ commit 、push もする。
# repo リポジトリを作成
$ mkdir repo
$ cd repo
$ git init --bare
hint: Using 'master' as the name for the initial branch. This default branch name
hint: is subject to change. To configure the initial branch name to use in all
hint: of your new repositories, which will suppress this warning, call:
hint:
hint: git config --global init.defaultBranch <name>
hint:
hint: Names commonly chosen instead of 'master' are 'main', 'trunk' and
hint: 'development'. The just-created branch can be renamed via this command:
hint:
hint: git branch -m <name>
Initialized empty Git repository in /tmp/git_test/repo/
$ cd ..
# repo を clone_a に clone
$ git clone repo clone_a
Cloning into 'clone_a'...
warning: You appear to have cloned an empty repository.
done.
$ cd clone_a
# clone_a で master と branch に commit、push
$ git commit -m "first commit" --allow-empty
[master (root-commit) 68f4e27] first commit
$ git push
Enumerating objects: 2, done.
Counting objects: 100% (2/2), done.
Writing objects: 100% (2/2), 168 bytes | 168.00 KiB/s, done.
Total 2 (delta 0), reused 0 (delta 0), pack-reused 0
To /tmp/git_test/repo/
* [new branch] master -> master
$ git checkout -b branch
Switched to a new branch 'branch'
$ git commit -m "branch first commit" --allow-empty
[branch 4183267] branch first commit
$ git push -u origin branch
Enumerating objects: 1, done.
Counting objects: 100% (1/1), done.
Writing objects: 100% (1/1), 194 bytes | 194.00 KiB/s, done.
Total 1 (delta 0), reused 0 (delta 0), pack-reused 0
To /tmp/git_test/repo/
* [new branch] branch -> branch
Branch 'branch' set up to track remote branch 'branch' from 'origin'.
# clone_a で master と branch に追加 commit。push はしない
$ git checkout master
Switched to branch 'master'
Your branch is up to date with 'origin/master'.
$ git commit -m "second commit" --allow-empty
[master 62a06fb] second commit
$ git checkout branch
Switched to branch 'branch'
Your branch is up to date with 'origin/branch'.
$ git commit -m "branch second commit" --allow-empty
[branch 75b67d7] branch second commit
$ cd ..
# repo を clone_b に clone
$ git clone repo clone_b
Cloning into 'clone_b'...
done.
$ cd clone_b
# clone_b で master と branch に commit、push
$ git checkout master
Already on 'master'
Your branch is up to date with 'origin/master'.
$ git commit -m "clone_b commit" --allow-empty
[master 9dd33f6] clone_b commit
$ git push
Enumerating objects: 1, done.
Counting objects: 100% (1/1), done.
Writing objects: 100% (1/1), 192 bytes | 192.00 KiB/s, done.
Total 1 (delta 0), reused 0 (delta 0), pack-reused 0
To /tmp/git_test/repo
68f4e27..9dd33f6 master -> master
$ git checkout branch
Branch 'branch' set up to track remote branch 'branch' from 'origin'.
Switched to a new branch 'branch'
$ git commit -m "clone_b branch commit" --allow-empty
[branch 8298060] clone_b branch commit
$ git push
Enumerating objects: 1, done.
Counting objects: 100% (1/1), done.
Writing objects: 100% (1/1), 197 bytes | 197.00 KiB/s, done.
Total 1 (delta 0), reused 0 (delta 0), pack-reused 0
To /tmp/git_test/repo
4183267..8298060 branch -> branch
全体として以下のような状態になっています。
# repo
68f4e27 - 9dd33f6(master)
+- 4183267 - 8298060(branch)
# clone_a
68f4e27(origin/master) - 62a06fb(master)
+- 4183267(origin/branch) - 75b67d7(branch)
# clone_b
68f4e27 - 9dd33f6(master, origin/master)
+- 4183267 - 8298060(branch, origin/branch)
clone_a が取得したときより repo は master, branch ともに進んでしまっているけれど、clone_a はそれを知らない。そして clone_a でも master, branch にそれぞれ独自に commit しています。
ここから clone_a から repo に branch ブランチを push する方向で実験を行います。
また、今の状態でディレクトリ丸ごとのバックアップを取っておきます。
push は出来ない
$ cd clone_a
$ git checkout branch
Already on 'branch'
このブランチは 'origin/branch' よりも1コミット進んでいます。
(use "git push" to publish your local commits)
$ git push
To /tmp/git_test/repo/
! [rejected] branch -> branch (fetch first)
error: failed to push some refs to '/tmp/git_test/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.
push は出来ないですね。これは当たり前。
push --force は出来る
$ git push --force
Enumerating objects: 4, done.
Counting objects: 100% (4/4), done.
Delta compression using up to 8 threads
Compressing objects: 100% (3/3), done.
Writing objects: 100% (4/4), 353 bytes | 353.00 KiB/s, done.
Total 4 (delta 2), reused 0 (delta 0), pack-reused 0
To /tmp/git_test/repo/
+ 8298060...75b67d7 branch -> branch (forced update)
--force オプションを付ければ push 出来ます。
repo と clone_a, clone_b の状態は以下のようになっています。repo の branch ブランチは clone に上書きされてしまいましたが、master ブランチはそのままです。
# repo
68f4e27 - 9dd33f6(master)
+- 4183267 - 75b67d7(branch)
# clone_a
68f4e27(origin/master) - 62a06fb(master)
+- 4183267 - 75b67d7(branch, origin/branch)
# clone_b
68f4e27 - 9dd33f6(master, origin/master)
+- 4183267 - 8298060(branch, origin/branch)
push --force-with-lease は出来ない
バックアップから repo と clone_a, clone_b 復元してから以下を行います。
$ cd clone_a
$ git checkout branch
Already on 'branch'
このブランチは 'origin/branch' よりも1コミット進んでいます。
(use "git push" to publish your local commits)
$ git push --force-with-lease
To /tmp/git_test/repo/
! [rejected] branch -> branch (stale info)
error: failed to push some refs to '/tmp/git_test/repo/'
push --force-with-lease は出来ません。これなら安心かというと、そうではない場合もあります。
fetch すると push --force-with-lease は出来てしまう
$ git fetch
remote: Enumerating objects: 2, done.
remote: Counting objects: 100% (2/2), done.
remote: Compressing objects: 100% (2/2), done.
remote: Total 2 (delta 1), reused 0 (delta 0), pack-reused 0
Unpacking objects: 100% (2/2), 271 bytes | 271.00 KiB/s, done.
From /tmp/git_test/repo
4183267..8298060 branch -> origin/branch
68f4e27..9dd33f6 master -> origin/master
$ git push --force-with-lease
Enumerating objects: 1, done.
Counting objects: 100% (1/1), done.
Writing objects: 100% (1/1), 195 bytes | 195.00 KiB/s, done.
Total 1 (delta 0), reused 0 (delta 0), pack-reused 0
To /tmp/git_test/repo/
+ 8298060...75b67d7 branch -> branch (forced update)
repo と clone_a の状態は以下のようになっています。見事にリモートの branch ブランチの内容が吹き飛んでしまいました。私はこれで同僚の作業を実際に吹き飛ばしたことがあります。I さん、あの時は本当にごめんなさい。
# repo
68f4e27 - 9dd33f6(master)
+- 4183267 - 75b67d7(branch)
# clone_a
68f4e27(origin/master) - 62a06fb(master)
+- 4183267 - 75b67d7(branch, origin/branch)
+- 9dd33f6(origin/master)
# clone_b
68f4e27 - 9dd33f6(master, origin/master)
+- 4183267 - 8298060(branch, origin/branch)
なぜ fetch してからだと push --force-with-lease 出来てしまうかというと、リモートとの比較を refs で行っているからです。リモートブランチの方が先に進んでいたらローカルに保存している refs とリモートの refs が一致しないので push --force-with-lease は拒否されます。しかし fetch してしまうと、ローカルに保存している refs が更新されてしまうので、push --force-with-lease は成功してしまうということだそうです。
実際のところどうなっているか、バックアップからリストアして観察してみましょう。
fetch する前は、clone したときのコミットハッシュが記録されています。
$ cd clone_a
$ ls -R .git/refs/remotes/
.git/refs/remotes/:
./ ../ origin/
.git/refs/remotes/origin:
./ ../ branch master
$ more .git/refs/remotes/origin/*
::::::::::::::
.git/refs/remotes/origin/branch
::::::::::::::
4183267f0e686b2c59d2eb111fb90ab4c8c7412a
::::::::::::::
.git/refs/remotes/origin/master
::::::::::::::
68f4e278663ffe08e4f9f94fb741481b7c450854
fetch すると repo の最新の状態にコミットハッシュが更新されました。
$ git fetch
remote: Enumerating objects: 2, done.
remote: Counting objects: 100% (2/2), done.
remote: Compressing objects: 100% (2/2), done.
remote: Total 2 (delta 1), reused 0 (delta 0), pack-reused 0
Unpacking objects: 100% (2/2), 271 bytes | 271.00 KiB/s, done.
From /tmp/git_test/repo
4183267..8298060 branch -> origin/branch
68f4e27..9dd33f6 master -> origin/master
$ more .git/refs/remotes/origin/*
::::::::::::::
.git/refs/remotes/origin/branch
::::::::::::::
82980608a5aa384cb9a2892c124d43bec9ebd612
::::::::::::::
.git/refs/remotes/origin/master
::::::::::::::
9dd33f6c0084194f8a5cc5b26e5d59c517aba903
.git/refs/remotes/ の各コミットハッシュの値は ls-remote の値と一致しています。
$ git ls-remote
From /tmp/git_test/repo/
9dd33f6c0084194f8a5cc5b26e5d59c517aba903 HEAD
82980608a5aa384cb9a2892c124d43bec9ebd612 refs/heads/branch
9dd33f6c0084194f8a5cc5b26e5d59c517aba903 refs/heads/master
push --force-with-lease では .git/refs/remotes/ と ls-remote のコミットハッシュを比較し、一致していればリモートが先に進んでいないと判断して push してしまうのですね。
コミットハッシュが一致していても、ローカルブランチにマージされていないならそれは push しちゃいけないんじゃないかと思うんですが、なんらか理由があってこのような仕様になっているんですかね。もしくは fetch と push --force-with-lease を組み合わせて行うのがそもそも Git の仕様上おかしな操作であるというか。
どうしてこうなっているかはわかりませんが、とにかく Git の実装としてこのようになっているので、push --force-with-lease が絶対安全とは限らないということは覚えておかないといけませんね。
push.default = matching だとカレントブランチ以外も push される
これまでの実験は実は push.default = simple の状態で行っていました。これは Git 2.0 以降のデフォルトです。Git 1.X でのデフォルトは push.default = matching だったそうですが、Git 2.0 が出たのが 2013年頃なので、そんな古い環境で使ってる人もそうそういないとは思いますが。
push.default についてはこちらの記事で詳しく解説されています。
push.default = matching だとどうなるか、バックアップからリストアして実際に試してみましょう。
$ cd clone_a
$ git config push.default
simple
$ git config push.default matching
$ git config push.default
matching
$ git branch
* branch
master
$ git fetch
remote: Enumerating objects: 2, done.
remote: Counting objects: 100% (2/2), done.
remote: Compressing objects: 100% (2/2), done.
remote: Total 2 (delta 1), reused 0 (delta 0), pack-reused 0
Unpacking objects: 100% (2/2), 271 bytes | 271.00 KiB/s, done.
From /tmp/git_test/repo
4183267..8298060 branch -> origin/branch
68f4e27..9dd33f6 master -> origin/master
$ git push --force-with-lease
Enumerating objects: 2, done.
Counting objects: 100% (2/2), done.
Delta compression using up to 8 threads
Compressing objects: 100% (2/2), done.
Writing objects: 100% (2/2), 287 bytes | 287.00 KiB/s, done.
Total 2 (delta 1), reused 0 (delta 0), pack-reused 0
To /tmp/git_test/repo/
+ 8298060...75b67d7 branch -> branch (forced update)
+ 9dd33f6...62a06fb master -> master (forced update)
リポジトリの状態はこちらです。
# repo
68f4e27 - 62a06fb(master)
+- 4183267 - 75b67d7(branch)
# clone_a
68f4e27 - 62a06fb(master, origin/master)
+- 4183267 - 75b67d7(branch, origin/branch)
# clone_b
68f4e27 - 9dd33f6(master, origin/master)
+- 4183267 - 8298060(branch, origin/branch)
fetch してから push --force-with-lease ですから origin/branch が上書きされます。それに加えて、カレントブランチとは関係のない origin/master まで上書きされてしまいました。
これは push.default = matching では同名のブランチは全てが push 対象になるためです。リポジトリ全体を分散させるという当初の Git の思想としては、この挙動は適切だったのでしょう。でも、現代ではブランチ単位で操作するのが一般的ですから、期待と違った挙動になってしまうわけですね。Git 2.0 以降では push.default = simple がデフォルトになったのも、さもありなんというところです。
ところでこれ、リモートの master ブランチを吹き飛ばしているわけですから、もしもやらかしたらごめんなさいどころではない事態ですよね。誰かがたまたま最新の origin/master を pull していればそちらから push --force してもらえますが、そうでなければ gc が走る前に上書き前のコミットIDから復元するしかありません。
ちなみに私はこれで同僚のトピックブランチを吹き飛ばしたことがあります(上述の I さんとは別件)。O さん、本当にごめんなさい。origin/master は push プロテクトされていたのでそちらは被害がありませんでしたが、もしもプロテクトされてなかったらと思うと今でも冷や汗が出ます。
私がなんで push.default = matching にしていたかというと、.gitconfig の設定参考例をどこかの記事で見て、よく理解せずに自分の設定に反映させてしまっていたからなんですね。どこでそんな危険な設定例を見たのかももう覚えていませんが。やっぱり、ちゃんと理解しておかないとダメですね。
まとめ
- push --force は危険。
- git push --force-with-lease は --force よりは安全だけど、それでも危険な場合もある。
- push.default = matching は危険。
とはいえ、rebase すれば push --force はせざるを得ません。これらの挙動を理解したうえで、慎重に操作するしかないですね。
管理者の方は master ブランチには push プロテクトをかけておきましょう。私のような粗忽なスタッフがよく理解せずに吹き飛ばしてしまう可能性があります。定期的にバックアップを取っておくのもいいですね。
そして何よりもですが、こちらが結論になるかと思います。
- 何事もちゃんと理解せずに使うのは危険。
(追記)--force-if-includes でさらに安全に
(2023/11/15追記)
コメントで --force-if-includes というオプションを教えていただきました。恥ずかしながらこのようなオプションが追加されたのを知りませんでした。教えていただきましてありがとうございます。
このオプション、Git 2.30.0 で追加されたそうで、リリースは 2020 年12月28日と比較的新しいオプションなんですね。それでも3年も前ですが。ググったところ詳細に解説した記事がいくつもありましたので、代表してこちらにリンクしておきます。
--force-if-includes がどのような挙動をするか、確認してみましょう。まずこれまでと同じリポジトリとクローンを作成しました。バックアップを消してしまって再作成したので、コミットIDが変わってしまってますのはご容赦ください。
# repo
01ba936 - 051b981(master)
+- f6d8e55 - e07a8f9(branch)
# clone_a
01ba936(origin/master) - 4e1f123(master)
+- f6d8e55(origin/branch) - 06967cb(branch)
# clone_b
01ba936 - 051b981(master, origin/master)
+- f6d8e55 - e07a8f9(branch, origin/branch)
早速実験してみましょう。
$ cd clone_a
$ git fetch
remote: Enumerating objects: 2, done.
remote: Counting objects: 100% (2/2), done.
remote: Compressing objects: 100% (2/2), done.
remote: Total 2 (delta 1), reused 0 (delta 0), pack-reused 0
Unpacking objects: 100% (2/2), 269 bytes | 269.00 KiB/s, done.
From /tmp/git_test/repo
f6d8e55..e07a8f9 branch -> origin/branch
01ba936..051b981 master -> origin/master
$ git push --force-with-lease --force-if-includes
To /tmp/git_test/repo
! [rejected] branch -> branch (remote ref updated since checkout)
error: failed to push some refs to '/tmp/git_test/repo'
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.
ちゃんとリジェクトされましたね。よかったよかった。
--force-if-includes で 100% 安心かどうかは分かりません。見落としてるケースはあるかもしれませんから。それでも従来よりははるかに安全になりますね。git alias に登録しておこうと思います。