Git rebase と cherry-pick 入門:履歴を安全に書き換える
rebase と merge の違いを実際のグラフで比較。エディタなしの対話的 rebase、cherry-pick、force push で同僚のコミットが消えた再現まで実出力で解説。
シリーズ:Git 講座 4/6(中級)。 ブランチ・マージ・push(第1〜3回)はできるようになり、レビューで「main に rebase して、fixup コミットはまとめてください」と言われて手が止まった人向けです。この回を読み終えると、rebase、エディタを開かない履歴の整理、リリースブランチへの修正1件だけの移植、そして「履歴の書き換えが同僚を困らせる場面」の見分けができるようになります。
プルリクエストに「WIP」「typo修正」が8個並んでいる、ブランチが main から30コミット遅れている、git push --force は危ないと聞いたが理由はよく知らない。そんな状況の人のための記事です。rebase も cherry-pick も「コミットを別の場所にコピーする」道具で、危険があるとすれば「元のコミットをすでに誰が持っているか」だけです。後半では、エラーが一切出ないまま共有ブランチから同僚のコミットが消える様子を実出力で見せます。
実行環境は Git 2.54.0(Apple Git-157)、Apple Silicon Mac(M1 Pro)。作業用リポジトリでデモ用の名前とメールアドレス、固定したコミット日時を使っているので、同じ手順なら同じハッシュになります。「サーバー」はローカルの bare リポジトリで、パスは ~/demo/… と短縮しています。
同じブランチを merge と rebase で比べる
準備:main に2コミット、feature/coupon に2コミット。その間に誰かが main に README を追加しました。main の変更を feature ブランチへ取り込む方法を2通り試します。
git switch -c try-merge feature/coupon
git merge --no-edit main
git log --graph --oneline --all
* a514a17 Merge branch 'main' into try-merge
|\
| * 2f373eb Add README
* | e6de6d4 Validate coupon codes
* | d7647be Add coupon model
|/
* 90e23df Add price list
* 7f32e75 Initial app
merge は2本の履歴をそのまま残し、親を2つ持つコミットを1つ足します。次に rebase です。
git switch feature/coupon
git rebase main
git log --graph --oneline feature/coupon
Successfully rebased and updated refs/heads/feature/coupon.
* de4f46d Validate coupon codes
* 7839332 Add coupon model
* 2f373eb Add README
* 90e23df Add price list
* 7f32e75 Initial app
README の後から作業を始めたかのような一直線の履歴になりました。ここで ID に注目してください。rebase 前の2つのコミットは d7647be と e6de6d4、rebase 後は 7839332 と de4f46d です。rebase はコミットを「移動」しているのではなく、新しく作り直しています。 変更内容もメッセージも同じですが、親が違うのでハッシュが変わります(親がハッシュに含まれる理由は第6回で中身を見ます)。古いコミットも消えずに残っていて、ブランチ名がコピーの方を指すようになっただけです。
rebase のコンフリクト:ours と theirs が逆になる
feature ブランチは価格を 90 に下げ、main は同じ行を 120 に上げていました。rebase は feature のコミットを1つずつ積み直すので、最初のコミットでぶつかります。
Rebasing (1/2)Auto-merging config.txt
CONFLICT (content): Merge conflict in config.txt
error: could not apply 8286dc4... Discount price to 90
hint: Resolve all conflicts manually, mark them as resolved with
hint: "git add/rm <conflicted_files>", then run "git rebase --continue".
hint: You can instead skip this commit: run "git rebase --skip".
hint: To abort and get back to the state before "git rebase", run "git rebase --abort".
hint: Disable this message with "git config set advice.mergeConflict false"
Could not apply 8286dc4... # Discount price to 90
ファイルの中身はこうなっていて、ここに落とし穴があります。
<<<<<<< HEAD
price=120
=======
price=90
>>>>>>> 8286dc4 (Discount price to 90)
tax=10
rebase 中の HEAD は移動先(main)であって、自分のブランチではありません。ショートカットのフラグで確かめました。
$ git checkout --ours config.txt -> price=120 (main 側)
$ git checkout --theirs config.txt -> price=90 (自分のコミット)
つまり rebase で「ours を採用」は「自分の変更を捨てる」という意味です。merge のときの感覚で --ours を選ぶと、自分の作業が黙って消えます。解決せずに続行しようとするとこう言われます。
config.txt: needs merge
You must edit all merge conflicts and then
mark them as resolved using git add
合意した値(price=108)を書き、git add config.txt → git rebase --continue で2つ目のコミットは問題なく適用されました。途中でわからなくなったら git rebase --abort で rebase 前の状態に戻れます。
エディタを開かない対話的 rebase
git rebase -i はエディタで todo リスト を開きます。サーバーや CI、スクリプトの中ではエディタが使えないことがあり、記事ではキー操作を見せられないので、ここでは環境変数2つで操作します。
GIT_SEQUENCE_EDITOR:todo リストを編集するプログラムGIT_EDITOR:コミットメッセージを編集するプログラム(rewordやsquashで使われる)
整理前のブランチ:
fe6b7fb fix typo in comment
b2fb93e remove debug
6656888 WIP
c8673f4 debug print
87fd1dd Add search
まず cat を「エディタ」にして、Git が作る todo を表示だけさせます(何も変わりません)。
$ GIT_SEQUENCE_EDITOR=cat git rebase -i main
pick 87fd1dd # Add search
pick c8673f4 # debug print
pick 6656888 # WIP
pick b2fb93e # remove debug
pick fe6b7fb # fix typo in comment
次に望む todo を書きます。fixup は直前のコミットに溶かし込んでメッセージを捨て、reword は変更を残してメッセージだけ書き換えます。行の順番も入れ替えられます。
cat > todo.txt <<'EOF'
pick 87fd1dd Add search
fixup c8673f4 debug print
fixup b2fb93e remove debug
fixup fe6b7fb fix typo in comment
reword 6656888 WIP
EOF
cat > msg.sh <<'EOF'
#!/bin/sh
# git がコミットメッセージのファイルを $1 で渡してくる
printf 'Add search tests\n' > "$1"
EOF
chmod +x msg.sh
GIT_SEQUENCE_EDITOR="cp $PWD/todo.txt" GIT_EDITOR="$PWD/msg.sh" git rebase -i main
Rebasing (2/5)Rebasing (3/5)Rebasing (4/5)Rebasing (5/5)[detached HEAD 43afe11] Add search tests
Date: …
1 file changed, 1 insertion(+)
create mode 100644 test_search.py
Successfully rebased and updated refs/heads/feature/search.
$ git log --oneline main..
43afe11 Add search tests
11d1870 Add search
5コミットが2つになりました。git diff before-tidy HEAD の出力は空で、最終的なファイルは1バイトも変わっていません。変わったのは履歴だけです。整理の前にバックアップのブランチ(git branch before-tidy)を作り、整理の後にこの「空の diff」を確認する。この2つを習慣にしてください。
「エディタ」が失敗すると、Git は何も触らずに止まります。
$ GIT_SEQUENCE_EDITOR=false git rebase -i main
error: there was a problem with the editor 'false'
fixup コミットと --autosquash
後からまとめて整理するのは面倒です。古いコミットへの修正は、作った時点で印を付けておくのがおすすめです。
# 11d1870 "Add search" にバグを見つけた
git add search.py
git commit --fixup=11d1870
GIT_SEQUENCE_EDITOR=true git rebase -i --autosquash main
86ccfa8 fixup! Add search <- 前
9655543 Test search_all
...
Successfully rebased and updated refs/heads/feature/search.
67a71fb Test search_all <- 後:fixup! コミットは消え、
a31a276 Add search_all 変更は "Add search" の中に入った
3bc14a8 Add search tests
91dd3d7 Add search
GIT_SEQUENCE_EDITOR=true は Git が並べた todo をそのまま受け入れるという意味です。--autosquash がすでに fixup! の行を対象の下へ移動してくれています。修正対象だけでなく、その後ろのコミットすべての ID が変わる点にも注意してください。
cherry-pick:修正1件だけをリリースブランチへ
develop にはダークモード機能、タイムアウト修正、リトライ回数変更の3コミットがあります。リリースブランチに必要なのは修正だけです。
$ git switch release/1.0
$ git cherry-pick -x 42758df
[release/1.0 16a5673] Fix: lower timeout to 10s
Date: …
1 file changed, 1 insertion(+), 1 deletion(-)
$ git log -1 --format='%h %s%n%n%b'
16a5673 Fix: lower timeout to 10s
(cherry picked from commit 42758dfeb88d68ecc9d2915678f9377241e34360)
-x を付けるとメッセージに元の ID が追記され、後で「同じ修正だ」とたどれます。Git 自身もパッチを比べて判定でき、git cherry は適用済みの変更に - を付けます。
$ git cherry -v release/1.0 develop
+ 7226ea7b... Add dark mode flag
- 42758dfe... Fix: lower timeout to 10s
+ 2acd9f81... Retry 3 times
現場でよく見るメッセージは次の3つです。
# 同じコミットを2回 pick した
The previous cherry-pick is now empty, possibly due to conflict resolution.
If you wish to commit it anyway, use:
git commit --allow-empty
Otherwise, please use 'git cherry-pick --skip'
# マージコミットを pick した
error: commit a8e75a5f... is a merge but no -m option was given.
fatal: cherry-pick failed
# 本物のコンフリクト(リリース側で同じ行を変えていた)
CONFLICT (content): Merge conflict in net.conf
error: could not apply 2acd9f8... Retry 3 times
hint: After resolving the conflicts, mark them with
hint: "git add/rm <pathspec>", then run
hint: "git cherry-pick --continue".
git cherry-pick -m 1 <マージ> は通ります。ただし「第1親から見てそのマージが持ち込んだ変更すべて」を普通のコミット1つとして適用するので、リリースブランチではたいてい望んだ結果になりません。個々のコミットを pick しましょう。
共有された履歴を書き換えてはいけない:実際に消えたもの
「公開済みブランチを rebase するな」は誰でも言えます。破るとどうなるかを見ましょう。2つのクローン me と mate が bare リモート上の feature/login を共有しています。同僚(mate)がテストのコミットを push。私は fetch しないまま feature/login を新しい main に rebase して push しました。
$ git push origin feature/login
! [rejected] feature/login -> feature/login (fetch first)
error: failed to push some refs to '~/demo/remote.git'
hint: Updates were rejected because the remote contains work that you do not
hint: have locally.
ここまでは Git が守ってくれます。リモート追跡ブランチが古いので --force-with-lease も拒否されます。
$ git push --force-with-lease origin feature/login
! [rejected] feature/login -> feature/login (stale info)
そしてよくある流れ:「stale info?じゃあ fetch してからもう一度」。
$ git fetch
$ git push --force-with-lease origin feature/login
+ 0c266b9...114c254 feature/login -> feature/login (forced update)
$ git --git-dir=../remote.git log --oneline feature/login
114c254 Add login
2c0213a Main moves on
98574c9 Initial
同僚の 0c266b9 Teammate: add login test が共有ブランチから消えました。エラーも警告もありません。lease が確認するのは「リモートが自分の最後に fetch した状態と同じか」だけで、直前に fetch したので一致したのです。--force-if-includes を足すと、リモートの先端が自分のローカル履歴に含まれているかも確認するので防げました。
$ git push --force-with-lease --force-if-includes origin feature/login
! [rejected] feature/login -> feature/login (remote ref updated since checkout)
同僚の側はさらに厄介です。素の git pull は fatal: Need to specify how to reconcile divergent branches. とヒントを出して止まります。「今どき」の答えである git pull --rebase は成功を表示しました。
$ git pull --rebase
Successfully rebased and updated refs/heads/feature/login.
$ git log --oneline -3
114c254 Add login
2c0213a Main moves on
98574c9 Initial
同僚自身のブランチからも、同僚のコミットが消えました。 原因は fork-point の仕組みです。pull --rebase は origin/feature/login の reflog を見て、0c266b9 がかつて「上流」にあった(本人が push したので当然)ことから、上流が意図的に削除したと判断します。
$ git reflog show origin/feature/login
114c254 refs/remotes/origin/feature/login@{0}: pull: forced-update
0c266b9 refs/remotes/origin/feature/login@{1}: update by push
復旧(今回はバックアップブランチがありました。なければ第5回の reflog が使えます):
$ git reset --hard mate-backup
$ git rebase --no-fork-point origin/feature/login
warning: skipped previously applied commit 2b5dd1d
Rebasing (1/1)Successfully rebased and updated refs/heads/feature/login.
94fb347 Teammate: add login test
114c254 Add login
教訓は「force push 禁止」ではありません。「誰も fetch していないブランチだけを force push する。共有ブランチを force push したら使っている人に必ず伝える。相手の道具が黙って作業を落とすことがあるから」です。
判断表:rebase・merge・cherry-pick
| 状況 | 使うもの | 理由 |
|---|---|---|
| ローカルのブランチが main より遅れていて、まだ push していない | git rebase main |
一直線の履歴。古い ID を誰も持っていない |
| push 済みだが自分しか使っていない | rebase 後に push --force-with-lease --force-if-includes |
2つのチェックが通れば安全 |
| 他の人が pull したり、その上で作業したりしている | git merge main |
書き換えると相手の作業が消えるか重複する |
| develop の修正1件だけをリリースブランチへ | git cherry-pick -x <id> |
変更1つだけをコピーし、元を記録する |
| レビュー前に「typo修正」が大量にある | commit --fixup + rebase -i --autosquash |
todo を手で編集せずに整理できる |
| 完成した機能を丸ごと main へ | merge(またはチームの squash merge) | 機能を1単位で残せて revert しやすい |
| 迷ったら | merge | merge は何も壊さない |
実測:100 コミットと 1,000 コミットの rebase
rebase はコミットを1つずつ再適用するので、ブランチが長いほど時間がかかります。N 個のコミット(毎回ファイルに1行追記)を持つ feature ブランチを、新しいコミットが1つ増えた main に追従させました。毎回同じ状態から始め(git reset --hard feature-orig)、自動 gc は無効化しています(理由は後述のよくある失敗で)。Apple Silicon Mac(M1 Pro)、Git 2.54.0、各3回。
| N コミット | rebase 1 / 2 / 3 回目 | merge 1 / 2 / 3 回目 |
|---|---|---|
| 100 | 0.855 / 0.665 / 0.657 秒 | 0.075 / 0.069 / 0.067 秒 |
| 1,000 | 7.901 / 7.271 / 8.669 秒 | 0.104 / 0.133 / 0.140 秒 |
rebase はほぼ線形(1コミットあたり約 7 ミリ秒)に伸び、merge は 0.1 秒前後のままでした。rebase した 100(1,000)個のコミットはすべて新しい ID になっています。5〜30 コミットの普段のブランチなら気にならない差ですが、数百コミットの長命ブランチでは、コンフリクトするコミットごとに作業が止まることの方が本当のコストです。
よくある失敗
rebase のコンフリクトを --ours で解決する。 起きること:main 側が残り、自分の変更が消えたまま rebase は「成功」する。対策:ours=移動先と覚え、git diff before-rebase HEAD で確認する。
rebase 後に push が拒否されたので git pull する。 起きること:古いコミットと新しいコピーがマージされ、ログに同じコミットが2つずつ並ぶ。対策:自分専用のブランチなら push --force-with-lease --force-if-includes。共有ブランチなら最初から rebase しない。
ベンチマークやスクリプトがバックグラウンド gc で壊れる。 最初の計測では error: Could not read 3f472ec… / fatal: revision walk setup failed が出ました。前の実行が残した自動 git gc がバックグラウンドで動き続け、削除して作り直した同名ディレクトリの中で作業していたのが原因です。大量のコミットを短時間で作るスクリプトでは、作業用リポジトリで git config gc.auto 0 を設定します。
正直な限界・バージョンメモ
- 確認したのは Git 2.54.0(Apple Git-157)だけです。ヒントの
git config set advice…という書き方は新しい Git のもので、古い版ではgit config advice…と表示されます。 git switchは Git 2.23、--force-if-includesは Git 2.30 で追加。「Merge made by the 'ort' strategy」の ort が既定になったのは 2.34 からです。pull.rebase未設定のpullがfatal: Need to specify how to reconcile divergent branches.で止まるかはバージョン次第で、2.54 では止まりました。- 上で見た fork-point の挙動は仕様どおりで、
git rebase --helpの--fork-pointの項に書かれています。 - 時間は小さなファイルを使った1台での値です。大きなファイルやコンフリクトが多い場合、rebase はもっと遅くなります。
練習問題
- ブランチにコミット A(最古)、B「A の typo 修正」、C があります。B を A に含めて C は残す todo を書き、スクリプトから適用するときに設定する環境変数を答えてください。
git rebase mainの途中、コンフリクトしたコミットについてconfig.txtは自分の版を残したい。git checkoutに付けるフラグは?
解答
pick A/fixup B/pick C。適用はGIT_SEQUENCE_EDITOR="cp todo.txt"(Git が引数で渡すファイルを書き換えるプログラムなら何でもよい)。もっと楽なのは、B をgit commit --fixup=Aで作っておき、GIT_SEQUENCE_EDITOR=true git rebase -i --autosquash mainを実行する方法です。git checkout --theirs config.txt。rebase では「theirs」が再適用中のコミット、つまり自分の変更です。
前回: Git 講座 3:リモート・push・pull・fetch · 次回: Git 講座 5:reset・revert・reflog で元に戻す · 関連:ブランチとマージコンフリクト · 新人向け学習ロードマップに戻る
よくある質問
rebase と merge はどちらを使うべき?
まだ誰とも共有していない自分のブランチなら main に rebase して一直線の履歴にし、すでに push して他の人が使っているブランチなら merge します。rebase は再適用したコミットすべてに新しい ID を付けます。今回の実行では 100 個のコミットすべてのハッシュが変わり、merge では元のコミットはそのままでマージコミットが1つ増えただけでした。
rebase のコンフリクトで ours と theirs が逆になるのはなぜ?
rebase は移動先ブランチの上に自分のコミットを1つずつ積み直すため、HEAD(ours)は移動先の main、適用中のコミット(theirs)が自分の変更になります。実際に git checkout --ours では main 側の price=120、--theirs では自分の price=90 が取り出されました。
git push --force-with-lease なら常に安全?
安全ではありません。リモートのブランチが自分のリモート追跡ブランチと一致するかしか確認しないため、同僚の push 後に git fetch していると一致してしまい、同僚のコミットを上書きします。今回それを再現しました。Git 2.30 以降の --force-if-includes を併用すると、同じ push が 'remote ref updated since checkout' で拒否されました。