force push と --force-with-lease — GitHub の歴史を書き換える
push 済みのコミットを手元でやり直すと、次の push は拒否されます。拒否を押し通すのが git push --force で、GitHub 側のブランチの付箋を手元の位置へ付け替えます。そのあいだにもう1台の PC が push していたら、そのコミットは GitHub から消えます。この回では、拒否される理由、--force で Mac の作業が消える場面、--force-with-lease が止める場面と止めない場面(fetch の直後)を、2つのグラフで見ます。
- 上のターミナルは、Windows のノート PC で実際に打ったときと同じ出力です(Git は英語で答えます)
- 下の中で起きていることには、グラフが2つ並びます。上が GitHub 側、下が手元で、同じコミットは上下で同じ横位置に来ます。丸がコミット、黒い札がブランチ、ピンクの札が HEAD、手元の点線の札
origin/mainは GitHub の写し(追跡ブランチ)です - もう1台の Mac での操作は、ターミナルには何も出ず、GitHub 側の図だけが変わります
- GitHub 側の図は、どのブランチからも辿れなくなったコミットを出しません。手元の図は、HEAD が一度でも乗ったコミットなら薄く残します
- 右の説明をスクロールして読み進めると、左のターミナルでコマンドが1つずつ打たれます。
→キーでも1つずつ進みます(←で1つ戻ります。スマホでは「次へ」ボタン)。最後まで進んでから→を押すと次の区間へ移ります。黄色く光っているのが、そのコマンドで変わった場所です
1. push 済みのコミットをやり直すと、push は拒否される
コミットを「書き換える」操作は、どれも元のコミットを直すのではなく、新しいコミットを作って付箋を付け替えます。1-5 reset の git reset --soft HEAD~1 でコミットし直すと、親が同じで中身の違う別のコミットができます。3-2 rebase も、自分のコミットを相手の先へ作り直すので ID が変わります。
まだ push していないコミットなら、何度やり直しても困りません。問題はすでに GitHub に送ったコミットをやり直したときです。GitHub 側には元のコミットが残っていて、手元の新しいコミットはその子孫ではないので、早送り(fast-forward)で付箋を進められません。
push 済みのコミットをやり直すと、push が拒否される
「パンを追加」は push 済みです。
(HEAD -> main, origin/main, origin/HEAD)が同じ行に並んでいるので、GitHub のmainも同じコミットにあります。ここで、レシピのファイルを入れ忘れたことに気づきました。$ git log --oneline e5c0a62 (HEAD -> main, origin/main, origin/HEAD) パンを追加 8b41d07 卵を追加 3f2a9c1 牛乳を追加1-5 で見た
git reset --soft HEAD~1で、mainの付箋だけを「卵を追加」に戻します。「パンを買う」の変更はステージに残ったままです。GitHub 側のmainと手元のorigin/mainは「パンを追加」から動きません。$ git reset --soft HEAD~1入れ忘れたファイルを作ります。
$ echo "フレンチトーストの作り方" > recipe.txtステージに載せます。ステージには「パンを買う」の変更と
recipe.txtの2つが入りました。$ git add recipe.txtd6d1536という新しいコミットができました。親は「卵を追加」で、GitHub にある「パンを追加」とは別のコミットです。手元の図では「卵を追加」から2本に分かれています。$ git commit -m "パンとレシピを追加" [main d6d1536] パンとレシピを追加 2 files changed, 2 insertions(+) create mode 100644 recipe.txthave diverged(枝分かれした)と出ます。1 and 1 different commitsは、手元にだけある「パンとレシピを追加」と、origin/main にだけある「パンを追加」のことです。誰とも一緒に作業していないのに、自分の書き換えだけで枝分かれしました。$ git status On branch main Your branch and 'origin/main' have diverged, and have 1 and 1 different commits each, respectively. (use "git pull" if you want to integrate the remote branch with yours) nothing to commit, working tree clean(non-fast-forward)で拒否されます。GitHub のmainをd6d1536へ動かすと、「パンを追加」がどのブランチからも辿れなくなるからです。GitHub 側の先端(「パンを追加」)は手元も知っているので、2-2 で見た(fetch first)ではなくこちらの理由になります。$ git push To https://github.com/you/memo-app.git ! [rejected] main -> main (non-fast-forward) error: failed to push some refs to 'https://github.com/you/memo-app.git' hint: Updates were rejected because the tip of your current branch is behind hint: its remote counterpart. If you want to integrate the remote changes, hint: use 'git pull' before pushing again. hint: See the 'Note about fast-forwards' in 'git push --help' for details.--force-with-leaseを付けると、GitHub のmainを手元の位置へ付け替えます。+ e5c0a62...d6d1536の+と(forced update)が、早送りではなく付け替えたしるしです。GitHub 側の図から「パンを追加」が消えました。$ git push --force-with-lease To https://github.com/you/memo-app.git + e5c0a62...d6d1536 main -> main (forced update)「パンを追加」は、手元でも GitHub でもどのブランチからも辿れなくなりました(手元の図には薄く残り、3-4 の reflog で扱います)。このコミットを持っているのがこの PC だけなら、困る人はいません。困るのは、もう1台の PC がその上に積んでいたときです。
$ git log --oneline --all d6d1536 (HEAD -> main, origin/main, origin/HEAD) パンとレシピを追加 8b41d07 卵を追加 3f2a9c1 牛乳を追加
push の出力は、次の4通りを見分けられれば十分です。
| 出力の行 | 意味 |
|---|---|
! [rejected] main -> main (non-fast-forward) | 拒否。GitHub 側の先端は手元にもあるが、手元の main はその子孫ではない(自分で書き換えたときに出る) |
! [rejected] main -> main (fetch first) | 拒否。GitHub 側に、手元がまだ受け取っていないコミットがある(誰かが push したときに出る。2-2) |
e5c0a62..d6d1536 main -> main | 早送り。点が2つ。付箋を前へ進めただけで、何も消えていない |
+ e5c0a62...d6d1536 main -> main (forced update) | 付け替え。先頭の +、3つの点、(forced update) が目印。左側が、付け替えで外れたコミット |
拒否の理由は、force する前に必ず読んでください。(fetch first) は「自分の知らないコミットが GitHub にある」という意味で、force すればそれが消えます。この区間で付け替えても困らなかったのは、「パンを追加」を持っていたのがこの PC だけだったからです。
2. git push --force — Mac の push が GitHub から消える
git push --force(短く -f)は、GitHub 側のブランチの付箋を、何も確かめずに手元のブランチと同じコミットへ付け替えます。1節と同じく「パンを追加」をやり直した直後、まだ push していないあいだに、Mac が「パンを追加」の上に「メモを追加」を積んで push した場面です。
git push --force — Mac の push が GitHub から消える
前の区間で「パンを追加」をやり直した直後で、まだ push していません。
have divergedは、自分の書き換えによる枝分かれです。$ git status On branch main Your branch and 'origin/main' have diverged, and have 1 and 1 different commits each, respectively. (use "git pull" if you want to integrate the remote branch with yours) nothing to commit, working tree cleanそのあいだに、Mac で「メモを追加」をコミットして push しました。Mac は GitHub の「パンを追加」の上に積んでいます。こちらのターミナルには何も出ません。
今度の拒否は
(fetch first)です。GitHub に手元の知らないコミットがある、という意味で、前の区間の(non-fast-forward)とは理由が違います。誰かが push したことに気づける、最後の合図です。$ git push To https://github.com/you/memo-app.git ! [rejected] main -> main (fetch first) error: failed to push some refs to 'https://github.com/you/memo-app.git' hint: Updates were rejected because the remote contains work that you do not hint: have locally. This is usually caused by another repository pushing to hint: the same ref. If you want to integrate the remote changes, use hint: 'git pull' before pushing again. hint: See the 'Note about fast-forwards' in 'git push --help' for details.git push --forceは、何も確かめずに GitHub のmainをd6d1536へ付け替えます。+ 9ae3d51...d6d1536の左側が、付け替える前の GitHub のmain、つまり Mac の「メモを追加」です。GitHub 側の図から「パンを追加」と「メモを追加」が消えました。$ git push --force To https://github.com/you/memo-app.git + 9ae3d51...d6d1536 main -> main (forced update)手元の履歴に「メモを追加」は一度も出てきません。手元に届く前に上書きしたので、何を消したのかもこちらからは見えません。エラーも警告も出ずに終わるのが、force push の怖いところです。
$ git log --oneline --all d6d1536 (HEAD -> main, origin/main, origin/HEAD) パンとレシピを追加 8b41d07 卵を追加 3f2a9c1 牛乳を追加Mac の手元には「メモを追加」が残っています。Mac で fetch して新しい
mainの上に積み直し(3-2 の rebase)、push し直せば GitHub に戻せます。図では、作り直した5b0e7f3が「パンとレシピを追加」の上に載りました。こちらでも
git pull --ff-onlyで受け取れました。今回は Mac に残っていたので戻せましたが、Mac がその後に手元を作り直していたら、取り戻すのはずっと難しくなります。$ git pull --ff-only From https://github.com/you/memo-app d6d1536..5b0e7f3 main -> origin/main Updating d6d1536..5b0e7f3 Fast-forward notes.txt | 1 + 1 file changed, 1 insertion(+) create mode 100644 notes.txt
+ 9ae3d51...d6d1536 の左側が、GitHub から外したコミットの ID ですが、それが誰の何というコミットだったかは、手元からは見えません。
「メモを追加」が戻ってきたのは、Mac の手元に同じコミットが残っていたからです(6節)。Claude Code が rebase のあと、拒否された push を --force で通すと、前の晩に Mac で push したコミットがあれば消えます。
3. --force-with-lease — 知らない push があれば止まる
--force-with-lease は、付け替える前に1つだけ確かめる force push です。確かめるのは、手元の origin/main(最後に見たときの GitHub の main)が、GitHub の今の main と同じかどうかです。同じなら付け替え、違えば (stale info)(手元の情報が古い)で止まります。「最後に見たときから誰も動かしていなければ、上書きさせてもらう」という条件付きの force で、自分が見ていない push があるときだけ止まります。
git push --force-with-lease — 知らない push があれば止まる
同じ場面をやり直します。「パンを追加」をやり直した直後に、Mac が「メモを追加」を push しました。
--force-with-leaseは(stale info)(手元の情報が古い)で止まりました。手元のorigin/mainは「パンを追加」なのに、GitHub のmainは「メモを追加」に進んでいたからです。拒否された push と同じく、GitHub にも手元にも何も書き込みません。$ git push --force-with-lease To https://github.com/you/memo-app.git ! [rejected] main -> main (stale info) error: failed to push some refs to 'https://github.com/you/memo-app.git'止まったら、まず fetch で GitHub の様子を取ってきます。
e5c0a62..9ae3d51 main -> origin/mainで、Mac の「メモを追加」が手元に届きました。$ git fetch From https://github.com/you/memo-app e5c0a62..9ae3d51 main -> origin/main1 and 2 different commitsになりました。origin/main にだけあるのは、Mac の「メモを追加」と、その下の「パンを追加」の2つです。Mac がその上に積んだので、もう「パンを追加」を無かったことにはできません。$ git status On branch main Your branch and 'origin/main' have diverged, and have 1 and 2 different commits each, respectively. (use "git pull" if you want to integrate the remote branch with yours) nothing to commit, working tree clean上書きはやめて、
d6d1536を origin/main の先へ積み直すことにします。積み直す先は(origin/main, origin/HEAD)の付いた「メモを追加」です。$ git log --oneline --all 9ae3d51 (origin/main, origin/HEAD) メモを追加 d6d1536 (HEAD -> main) パンとレシピを追加 e5c0a62 パンを追加 8b41d07 卵を追加 3f2a9c1 牛乳を追加git rebase origin/mainで、d6d1536を「メモを追加」の先へ作り直します(3-2)。「パンを買う」は「パンを追加」で入っているので、作り直したコミットで新しく加わるのはrecipe.txtだけです。$ git rebase origin/main Successfully rebased and updated refs/heads/main.履歴が一直線になりました。作り直したコミットは
942fa15で、d6d1536とは別のコミットです。$ git log --oneline 942fa15 (HEAD -> main) パンとレシピを追加 9ae3d51 (origin/main, origin/HEAD) メモを追加 e5c0a62 パンを追加 8b41d07 卵を追加 3f2a9c1 牛乳を追加9ae3d51..942fa15の早送りなので、普通のgit pushで通ります。+も(forced update)も付かず、誰のコミットも消えていません。$ git push To https://github.com/you/memo-app.git 9ae3d51..942fa15 main -> main
(stale info) で止まったら、拒否された普通の push と同じ手順に戻ります。
git fetchで GitHub の様子を取ってくるgit statusとgit log --oneline --allで、何が増えていたかを見る- 増えていたコミットを取り込む(3-2 の rebase か、3-1 の
git merge origin/main) - 普通の
git pushで送る
大事なのは、止まったあとで、もう一度 force しなかったことです。Mac がその上に積んだ時点で、「パンを追加」は2台が共有する歴史になりました。共有された歴史は書き換えずに、その上に積みます。
4. 落とし穴 — fetch したあとの --force-with-lease は止まらない
lease が比べるのは、手元の origin/main と GitHub の main です。ところが git fetch は、手元の origin/main を GitHub の今の位置へ動かします。fetch した直後は、2つが必ず一致します。そこで --force-with-lease を打つと、知らない push も「もう知っている」扱いになり、止まりません。
fetch したあとの --force-with-lease は止まらない
もう一度、同じ場面です。「パンを追加」をやり直した
d6d1536を、まだ push していません。そのあいだに、Mac が「メモを追加」を push しました。
Claude Code がよく打つ
git fetch -qで、GitHub の様子を取ってきます。何も表示されませんが、手元のorigin/mainは「メモを追加」へ進みました。$ git fetch -q1 and 2 different commitsと出ています。ここで止まれば、前の区間と同じように積み直せます。ただし lease は、この答えを見てくれません。$ git status On branch main Your branch and 'origin/main' have diverged, and have 1 and 2 different commits each, respectively. (use "git pull" if you want to integrate the remote branch with yours) nothing to commit, working tree clean今度は止まりません。手元の
origin/mainと GitHub のmainが、どちらも「メモを追加」で一致しているからです。lease が確かめるのは「手元の写しが GitHub の今と同じか」だけで、「その中身を取り込んだか」ではありません。$ git push --force-with-lease To https://github.com/you/memo-app.git + 9ae3d51...d6d1536 main -> main (forced update)「メモを追加」は GitHub からも手元の図からも消えました。fetch で取ってきたのに、取り込まないまま上書きしたからです。fetch したら、取り込んでから push し直す、が決まりです。
$ git log --oneline --all d6d1536 (HEAD -> main, origin/main, origin/HEAD) パンとレシピを追加 8b41d07 卵を追加 3f2a9c1 牛乳を追加
2-3 で見たとおり、Claude Code は作業の前後に git fetch -q をよく打ちます。-q は出力を消すだけなので、Mac の push を取ってきたことに誰も気づかないまま、lease が「確かめ済み」になってしまいます。
決まりは1つです。fetch したら、push の前に git status を見て、diverged や behind なら取り込んでから push し直す。lease が防ぐのは「fetch していないときの事故」で、「fetch したけれど見ていないときの事故」は防げません。
本物の Git(2.30 以降)には、この穴をふさぐ --force-if-includes もあります。--force-with-lease と一緒に付けると、手元の origin/main を取り込んだことがあるかまで確かめます(画面では再現していません)。
5. force してよい場面・してはいけない場面
force push そのものが悪いわけではありません。危ないのは、ほかの人や別の PC が同じブランチに push しうるときです。
| 場面 | force してよいか | 理由 |
|---|---|---|
| 自分しか使っていない作業ブランチ | よい(--force-with-lease で) | 消えるのは自分がやり直した古いコミットだけ |
| プルリクエストの作業ブランチを rebase して更新する | よい(--force-with-lease で) | よくある使い方。ほかの人がコミットを足していないかは先に確かめる |
| 2台の PC で同じ作業ブランチを使っている | 注意 | もう1台の push を消しうる。fetch → status → 取り込む、を先にする |
main など、みんなが使うブランチ | しない | 誰かの push が消えるうえ、ほかの PC の main がすべて枝分かれする |
main には force しない、と決めておけば、迷う場面は作業ブランチに限られます。GitHub のリポジトリの設定で main を保護すれば、force push を GitHub 側で断らせることもできます。
6. 消えた側を取り戻すには
force push で GitHub から外れたコミットを取り戻せるかは、そのコミットをまだ持っている PC があるかで決まります。2節では、push した Mac の手元に「メモを追加」が残っていたので、Mac で git fetch → git status(diverged と出る)→ 新しい origin/main の先へ積み直す → git push の順で戻せました。
消された側の PC で diverged と出たとき、「GitHub が正しいはず」と git reset --hard origin/main で合わせると、最後に残っていた1つを自分の手で消すことになります。捨ててしまったら、その PC の reflog から探します(次の回)。
7. Claude Code の許可画面で見かけたら
「何が消えうるか」で並べるとこうなります。
| コマンド | 何が起きるか | 消えうるもの |
|---|---|---|
拒否された push((non-fast-forward) / (fetch first) / (stale info)) | 何も書き込まずに止まる | なし |
push 済みのコミットへの git reset --soft HEAD~1 や git rebase | 手元のコミットだけを作り直す。GitHub 側は変わらない | なし(ただし次の push が拒否される) |
git push(オプション無し) | 早送りでしか GitHub 側の付箋を進めない | 注意 消えるものは無い。GitHub 側が変わり、ほかの PC からも見える |
git push --force-with-lease | 手元の origin/main が GitHub の今と同じときだけ、GitHub 側の付箋を手元の位置へ付け替える | 注意 直前に fetch していると止まらず、GitHub 側にだけあるコミットが消える |
git push --force / git push -f | 何も確かめずに、GitHub 側の付箋を手元の位置へ付け替える | GitHub 側にだけあるコミット(ほかの人や別の PC が push したもの) |
許可画面に --force や -f が出たら、「誰かのコミットを消してよいか」を聞かれていると読み替えてください。送り先が main なら許可せず、作業ブランチなら --force-with-lease に変えたうえで、直前の fetch のあとに git status を見たかを確かめます。
まとめ
- reset でのやり直しや rebase は、新しいコミットを作って付箋を付け替える。push 済みのコミットを書き換えると、次の push は
(non-fast-forward)で拒否される git push --forceは GitHub 側の付箋を手元の位置へ付け替える(+ 古い...新しい (forced update))。別の PC が push していたコミットも GitHub から消える。(fetch first)はその合図--force-with-leaseは、手元のorigin/mainが GitHub の今と同じときだけ付け替える。知らない push があれば(stale info)で止まる- fetch した直後は lease が止まらない。fetch したら
git statusを見て、取り込んでから push し直す - force してよいのは自分しか使っていないブランチだけ。
mainには force しない。消えたコミットは、持っている PC から積み直せば戻せる
次の回では、手元から「消えたように見える」コミットを取り戻す git reflog を扱います。この回で手元の図に薄く残っていた「パンを追加」も、reset --hard や rebase のあとに見えなくなったコミットも、HEAD が通った場所の記録から呼び戻せます。