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 が拒否される

  1. 「パンを追加」は push 済みです。(HEAD -> main, origin/main, origin/HEAD) が同じ行に並んでいるので、GitHub の main も同じコミットにあります。ここで、レシピのファイルを入れ忘れたことに気づきました。

    $ git log --oneline
    e5c0a62 (HEAD -> main, origin/main, origin/HEAD) パンを追加
    8b41d07 卵を追加
    3f2a9c1 牛乳を追加
  2. 1-5 で見た git reset --soft HEAD~1 で、main の付箋だけを「卵を追加」に戻します。「パンを買う」の変更はステージに残ったままです。GitHub 側の main と手元の origin/main は「パンを追加」から動きません。

    $ git reset --soft HEAD~1
  3. 入れ忘れたファイルを作ります。

    $ echo "フレンチトーストの作り方" > recipe.txt
  4. ステージに載せます。ステージには「パンを買う」の変更と recipe.txt の2つが入りました。

    $ git add recipe.txt
  5. d6d1536 という新しいコミットができました。親は「卵を追加」で、GitHub にある「パンを追加」とは別のコミットです。手元の図では「卵を追加」から2本に分かれています。

    $ git commit -m "パンとレシピを追加"
    [main d6d1536] パンとレシピを追加
     2 files changed, 2 insertions(+)
     create mode 100644 recipe.txt
  6. have 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
  7. (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.
  8. --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)
  9. 「パンを追加」は、手元でも 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 から消える

  1. 前の区間で「パンを追加」をやり直した直後で、まだ 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
  2. そのあいだに、Mac で「メモを追加」をコミットして push しました。Mac は GitHub の「パンを追加」の上に積んでいます。こちらのターミナルには何も出ません。

  3. 今度の拒否は (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.
  4. 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)
  5. 手元の履歴に「メモを追加」は一度も出てきません。手元に届く前に上書きしたので、何を消したのかもこちらからは見えません。エラーも警告も出ずに終わるのが、force push の怖いところです。

    $ git log --oneline --all
    d6d1536 (HEAD -> main, origin/main, origin/HEAD) パンとレシピを追加
    8b41d07 卵を追加
    3f2a9c1 牛乳を追加
  6. Mac の手元には「メモを追加」が残っています。Mac で fetch して新しい main の上に積み直し(3-2 の rebase)、push し直せば GitHub に戻せます。図では、作り直した 5b0e7f3 が「パンとレシピを追加」の上に載りました。

  7. こちらでも 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 があれば止まる

  1. 同じ場面をやり直します。「パンを追加」をやり直した直後に、Mac が「メモを追加」を push しました。

  2. --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'
  3. 止まったら、まず fetch で GitHub の様子を取ってきます。e5c0a62..9ae3d51 main -> origin/main で、Mac の「メモを追加」が手元に届きました。

    $ git fetch
    From https://github.com/you/memo-app
       e5c0a62..9ae3d51  main       -> origin/main
  4. 1 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
  5. 上書きはやめて、d6d1536 を origin/main の先へ積み直すことにします。積み直す先は (origin/main, origin/HEAD) の付いた「メモを追加」です。

    $ git log --oneline --all
    9ae3d51 (origin/main, origin/HEAD) メモを追加
    d6d1536 (HEAD -> main) パンとレシピを追加
    e5c0a62 パンを追加
    8b41d07 卵を追加
    3f2a9c1 牛乳を追加
  6. git rebase origin/main で、d6d1536 を「メモを追加」の先へ作り直します(3-2)。「パンを買う」は「パンを追加」で入っているので、作り直したコミットで新しく加わるのは recipe.txt だけです。

    $ git rebase origin/main
    Successfully rebased and updated refs/heads/main.
  7. 履歴が一直線になりました。作り直したコミットは 942fa15 で、d6d1536 とは別のコミットです。

    $ git log --oneline
    942fa15 (HEAD -> main) パンとレシピを追加
    9ae3d51 (origin/main, origin/HEAD) メモを追加
    e5c0a62 パンを追加
    8b41d07 卵を追加
    3f2a9c1 牛乳を追加
  8. 9ae3d51..942fa15 の早送りなので、普通の git push で通ります。+ も (forced update) も付かず、誰のコミットも消えていません。

    $ git push
    To https://github.com/you/memo-app.git
       9ae3d51..942fa15  main -> main

(stale info) で止まったら、拒否された普通の push と同じ手順に戻ります。

  1. git fetch で GitHub の様子を取ってくる
  2. git status と git log --oneline --all で、何が増えていたかを見る
  3. 増えていたコミットを取り込む(3-2 の rebase か、3-1 の git merge origin/main)
  4. 普通の 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 は止まらない

  1. もう一度、同じ場面です。「パンを追加」をやり直した d6d1536 を、まだ push していません。

  2. そのあいだに、Mac が「メモを追加」を push しました。

  3. Claude Code がよく打つ git fetch -q で、GitHub の様子を取ってきます。何も表示されませんが、手元の origin/main は「メモを追加」へ進みました。

    $ git fetch -q
  4. 1 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
  5. 今度は止まりません。手元の 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)
  6. 「メモを追加」は 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 が通った場所の記録から呼び戻せます。