Git 全景課 / 第二部分 分支與歷史 · M7

M7 改寫歷史:rebase 與 cherry-pick

歷史裏的 commit 從不會被修改;所謂「改寫歷史」,其實是複製出一批新 commit,再把標籤搬過去。看懂這一點,rebase、互動式 rebase、cherry-pick、squash merge 就只是同一件事的不同用法,而「不要改寫已分享的歷史」這條規則也就不言自明。
約 75 分鐘沙盒 5 個任務 · 互動 ×2 · 小測 4 題先修:M5、M6
學完本單元,你能夠:
  1. 解釋 rebase 是「複製 commit 再搬標籤」,並預測 rebase 之後的 commit 圖;
  2. 比較 merge 和 rebase 產生的歷史,按情況選擇;
  3. 用互動式 rebase 整理 commit(squash、fixup、reword、drop);
  4. 用 cherry-pick 複製單一 commit;理解 squash merge 之後分支為甚麼「看起來沒合併」;
  5. 遵守「不改寫已分享的歷史」,並說出違反時會發生甚麼。

1「改寫」其實是複製 Rewriting means copying

M3 說過:commit 的編號由內容計算,內容包括 parent 的編號。所以只要 parent 換了,即使檔案改動完全一樣,也必然是一個新的 commit、新的編號。M6 的 --amend 已經是這樣:它沒有修改舊 commit,而是建立一個新的,再把分支搬過去。

本單元的所有命令都依循同一個模式:

  1. 把某些 commit 的「改動」重新套用到另一個起點上,產生新 commit;
  2. 把分支標籤搬到新 commit;
  3. 舊 commit 沒有被刪除,只是不再有分支指向它,仍可從 reflog 找回。

2rebase:把分支搬到新的起點 Rebase

你在 feature 上做了兩個 commit,期間 main 也前進了。git rebase main(在 feature 上執行)會:

  1. 找出 feature 上、但不在 main 歷史裏的 commit(F1、F2);
  2. 暫時把 HEAD 移到 main 的頂端;
  3. 按次序把 F1、F2 的改動重新套用,產生新 commit F1′、F2′(保留原作者和作者時間,提交時間是現在);
  4. 把 feature 搬到 F2′。
rebase 前後 rebase 前:A 之後分岔,main 有 M,feature 有 F1、F2。rebase 後:F1′、F2′ 接在 M 之後成一直線,feature 指向 F2′;舊的 F1、F2 變成虛線,只在 reflog。 rebase 前 A M main F1 F2 feature git rebase main 之後 A M main F1′ F2′ feature F1 F2 只在 reflog
圖 1. rebase 之後歷史變成一直線。F1′、F2′ 的改動和 F1、F2 相同,但 parent 不同,所以是新的 commit(藍色)。

如果重放某個 commit 時,它的改動和 main 上的改動重疊,就會像合併一樣停下來等你解決衝突:解決 → git add → git rebase --continue;想放棄就 git rebase --abort,一切回到 rebase 之前。

3merge 與 rebase:同一段歷史,兩種形狀 Merge vs rebase

互動 1 並排比較

兩邊是完全相同的歷史:main 前進了一步,feature 有兩個 commit。左邊在 main 上 git merge feature;右邊先在 feature 上 git rebase main,再回到 main git merge feature。比較兩邊的圖和 git log --oneline。

merge

rebase,再 fast-forward

mergerebase
歷史形狀保留分岔,多一個合併 commit一直線,像是按順序寫成的
舊 commit原封不動被複製成新 commit,舊的只在 reflog
真實性如實記錄「曾經並行開發」經過整理的版本,較易閱讀和追查
衝突一次解決全部可能每個 commit 各解決一次
能否用在已分享的分支可以不可以(見第 7 節)

常見的折衷做法:自己的分支,合併前先 rebase 到最新的 main 上整理好;共用的分支,一律 merge。

4互動式 rebase:整理自己的 commit Interactive rebase

開 PR 之前,你的分支上可能有「加入摘要」「修正錯字」「wip」這樣零碎的 commit。git rebase -i main 會打開一份待辦清單(todo),每行一個 commit,由舊到新;你改寫每行開頭的動作,存檔後 Git 依次執行:

動作效果
pick保留這個 commit
reword保留,但改寫說明
squash併入上一個 commit,兩者的說明合在一起
fixup併入上一個 commit,丟棄自己的說明(適合「修正錯字」這類)
drop刪除這個 commit(它的改動也會消失)

調換行的次序,就能調換 commit 的次序。沙盒用表單代替文字編輯器,但動作和結果相同;squash 的結果經過真實 Git 驗證,編號一致。

5cherry-pick:只複製一個 commit Cherry-pick

git cherry-pick <commit> 把某一個 commit 的改動,套用到目前分支,產生一個新 commit(說明、作者、作者時間沿用原來的)。典型用法:main 上修好了一個錯誤,正式版的維護分支也需要這個修正,但不能把 main 上其他未完成的功能一起帶過去。

git switch release-1.0
git cherry-pick 9f3c2e1     # 只把那個修正複製過來

6squash merge:分支為甚麼「看起來沒合併」 Squash merge

GitHub 的「Squash and merge」按鈕,等於 git merge --squash feature 再 git commit:把整條分支的改動壓成一個普通的新 commit放到 main 上。main 的歷史乾淨了,但這個新 commit 和 feature 上原來的 commit 沒有任何 parent 關係。

結果是:從 Git 的角度,feature 上的 commit 從未被合併。git branch -d feature 會拒絕(not fully merged),git log main..feature 也會把它們全部列出來。

刪除分支前,比較「內容」而不是「commit」 squash merge 之後,所有以 commit 為準的檢查(branch -d、git log main..feature、git cherry)都會誤報「未合併」。可靠的做法是比較內容:git diff main feature(或只比較該分支改過的檔案)沒有輸出,代表 feature 的內容已全部在 main 裏,這時才用 git branch -D 刪除。

7黃金規則:不改寫已分享的歷史 The golden rule

rebase、amend、reset、互動式 rebase 都會用新 commit 取代舊 commit。如果舊 commit 已經 push、別人也已經 pull 下來,就會出現兩份「同樣改動、不同編號」的歷史:

判斷準則 只改寫只有你一個人用過的 commit:還沒 push 的,或者 push 到只有你在用的個人分支上的。已經有人基於它工作的,就用 merge 和 revert。

8沙盒:改寫歷史 Sandbox: rewriting history

互動 2 改寫歷史沙盒

按「改寫」修改某一行,用命令完成任務。執行 git rebase -i 時,圖的上方會出現 todo 表單。藍色是這一步新產生的 commit;被取代的舊 commit 標示「只在 reflog」。

應用線:

9容易混淆的對照 Easily confused

命令新增甚麼搬哪張標籤用在已分享的歷史
merge一個合併 commit(或不新增:fast-forward)目前分支往前可以
rebase每個 commit 的複本目前分支搬到複本頂端不可以
cherry-pick一個 commit 的複本目前分支往前可以(只是新增)
merge --squash一個普通 commit(要再 commit)目前分支往前可以,但原分支會「看起來未合併」
reset(M6)不新增目前分支搬到任意 commit不可以
revert(M6)一個抵銷用的 commit目前分支往前可以
常見誤解
  • 「rebase 會移動 commit。」嚴格來說不會:它建立新的 commit,舊的仍在,只是沒有分支指向。
  • 「git rebase main 會改動 main。」不會。被搬動的是你所在的分支;main 只是新的起點。
  • 「rebase 比 merge 好(或相反)。」兩者各有用途:rebase 讓個人分支的歷史清楚,merge 讓共用分支的歷史真實可靠。
  • 「squash merge 之後就可以放心 branch -d。」-d 會拒絕,因為以 commit 判斷它從未合併;用內容比較確認後再 -D。

10命令速查 Cheat sheet

命令作用
git rebase main把目前分支的 commit 搬到 main 頂端重放
git rebase -i main整理目前分支上不在 main 裏的 commit
git rebase -i HEAD~3整理最近三個 commit
git rebase --continue / --skip / --abort解決衝突後繼續/略過這個 commit/放棄並回到原狀
git cherry-pick 9f3c2e1把那一個 commit 的改動複製到目前分支
git merge --squash feature把 feature 的全部改動放進暫存區,再自己 git commit
git diff main feature比較兩者的內容(沒有輸出=內容相同)
git push --force-with-lease改寫自己個人分支後推送;若遠端有別人的新 commit 會拒絕(M9)

11小測 Quiz

1. 在 feature 上執行 git rebase main 之後,feature 上原來的兩個 commit:
2. 你想把「修正錯字」這個 commit 併入上一個 commit,並丟棄它的說明。todo 裏應把它改成:
3. feature 經 GitHub「Squash and merge」併入 main 後,git branch -d feature 回報「not fully merged」。原因是:
4. 以下哪一種情況不應該 rebase?

本單元重點 Summary

延伸閱讀