Git 版本控制教學 - 用範例學 rebase

Posted on  Nov 1, 2022  in  Git 版本控制  by  Amo Chen  ‐ 10 min read

常言道「不會 rebase, 等於沒學過 Git」,由此可見 rebase在 Git 內有多重要。

在開始本文之前,請大家牢記千萬不要對已經 push 到遠端儲存庫(remote repository) 而且已經有人正在使用的提交進行 rebase ,這是很危險的!詳情請見 The Perils of Rebasing

Do not rebase commits that exist outside your repository and that people may have based work on.

改變歷史是很危險的!請牢記在心!

接下來,本文會說明什麼是 rebase,並且介紹 rebase 的基本用法。

什麼是 rebase

Rebase 對很多人來說是很抽象的概念,因此它的學習門檻在於如何了解這個抽象的概念。

對於 rebase 比較恰當的比喻應該是「移花接木」,簡單來講把你的分支接到別的分支上,稍後我們會用圖說明 merge 與 rebase 的差異。

了解 rebase 之前,我們必須了解什麼是 base 。

對 Git 的使用者而言,在分支進行開發是稀鬆平常的事情,因此在合併管理分支時,也就需要了解分支是在哪個提交分出來的旁支,而長出旁支來的提交點,對於旁支來說就是 base commit, 也就是 base 。

所以簡單來說, rebase 其實就是改變分支的 base 的功能。

下圖是在 merge 所產生的版本演進的示意圖,可以看到在新的分支 New branch 中所做的變更,在合併之後,一併成為一個新的提交(commit 6)。而 commit 1 就是 New Branch 的 base , 因為是從這個 commit 開始分出新的分支。

下圖則是 rebase 所產生的版本演進的示意圖。我們同樣是在分支中進行開發的動作,但是在 rebase 時,與 merge 不同的是,Git 會將分支上所做的變更先暫存起來,接著把 new base (或稱新基準點)合併進來,最後直接將剛剛暫存起來的變更在分支上重演,這邊用「重演」這個字眼是表示 rebase 不是將提交(commit)複製到分支上,而是將整個變更過程一個一個重新套用到分支上 ,也就因為如此 commit 2'commit 3',才會特別以另外的 ' 符號表示,帶表與原本的 commit 2, commit 3 不同,這點可以從 commit 的 commit id 不同看出來,雖然變更的內容相同,但是 commit Id 不同。本文會在稍後實際演練一遍。

因為如此,所以 rebase 的行為很像「移花接木」,以上圖來說,就是把 New Branch 的變更整個接到 Master 分支上。

接下來的文章中,將會傳授 rebase 的 2 大主軸:

  • 基礎用法
  • 進階互動模式

在基礎用法中,我們將學習 rebase 分支剪接的部分。

而進階互動模式中將會說明如何交換提交次序、修改提交內容、合併提交內容,甚至將一個提交拆解成多個提交。

Rebase 基本用法

以下我們用一個情境示範 rebase 的基礎用法:

你是一位 team lead,你的其中一項職務就是負責進行程式碼審查(code review),並且將不同程式分支進行合併管理。 現在有 2 位程式設計師以 main 分支為基礎,分別開了新的分支 feature/login 與 feature/signup, 也都已經完工了。 你希望利用 rebase 的方式將這 2 個分支併入 main 分支中。

首先, main 的日誌如下所示:

$ git log --oneline
36abac1 (HEAD -> main) 2nd commit
3695af2 1st commit

接著,feature/login 的日誌如下所示:

$ git log --oneline
54a5341 (HEAD -> feature/login) feature/login 1st commit
36abac1 (main) 2nd commit
3695af2 1st commit

最後是 feature/signup 的日誌:

$ git log --oneline
e94ab5e (HEAD -> feature/signup) feature/signup 2nd commit
a71bc5b feature/signup 1st commit
36abac1 (main) 2nd commit
3695af2 1st commit

可以看到 feature/login 與 feature/signup 分別比 main多出了 1 至 2 個提交。

假設這 2 個分支運作的很好下,我們開始進行 rebase 。

切換到 main 分支:

$ git checkout main

rebase feature/login 分支:

$ git rebase feature/login
Successfully rebased and updated refs/heads/main.

在上述指令中,我們先切換到 main 分支中,接著我們利用指令 git rebase feature/login 進行 rebase 。

此時, main 分支的 commit log 已經變成:

$ git log --oneline
54a5341 (HEAD -> main, feature/login) feature/login 1st commit
36abac1 2nd commit
3695af2 1st commit

在上述指令執行結果中,可以看到 feature/login 的 commit id 並沒有產生變化,與本文先前提到的「重演」顯然有所出入。

這是由於, git 在進行 rebase 時採用 fast-forwarded 的做法進行合併的緣故,那麼 fast-forwarded 是什麼意思呢?

Fast-forwarded 指的就是當 2 個分支的頭尾相接時,代表 2 者之間不會有 conflict ,因此只要改 HEAD 的指向就能夠迅速合併了。

以本情境為例, main 的最後一個提交正好是 feature/login 的頭,所以這 2 者的 rebase 適用 fast-forwarded 模式,所以 main 的 HEAD 改為 54a5341 就完成合併了。

最後, rebase feature/signup:

$ git rebase feature/signup
Successfully rebased and updated refs/heads/main.

接下來,可以用 git log 看看 main 分支的日誌,我們可以從日誌中發現feature/login 與 feature/signup 的 commit id 都不一樣了:

$ git log --oneline
188e157 (HEAD -> main) feature/login 1st commit
e94ab5e (feature/signup) feature/signup 2nd commit
a71bc5b feature/signup 1st commit
36abac1 2nd commit
3695af2 1st commit

以上就是簡單的 rebase 過程。

但是在這過程中,有些人可能產生了幾個疑問:

「為什麼先 rebase feature/login 再 rebase feature/signup 後,會是feature/login 的日誌在最上方呢?」

這是由於 rebase 會先找出與新分支之間最近的一個共同 base,然後先保留 HEAD 所在分支(也就是當前分支,以上述範例為例就是 main 分支)從共同 base 開始的所有變更,接著從共同 base 開始,將新分支的變更重新套用到 HEAD 的所在分支後,再將方才所保留的當前分支變更一個一個套用進來,也因此 feature/login 會是最後的一個 commit 。

我們一樣以圖示進行說明。

下圖 是 rebase feature/login 之後的樣子,可以看到 rebase feature/login 之後的 main 與 feature/signup 的共同 base 是 commit 36abac1:

因此要 rebase feature/signup 的話, commit 54a5341 會先被暫存起來,先進行 rebase feature/signup 之後,再將剛剛暫存的 commit 54a5341 重演一次,所以下圖 rebase feature/signup 之後, feature/login 的 commit id 就從 54a5341 變成 188e157 ,這就是 rebase 的過程。

p.s. rebase feature/signup 也適用 fast-forwarded 的合併,所以最後只有 feature/login 的 commit 因爲重演而改變。

Rebase onto

問題又來了,剛剛學的 rebase 會將整個分支都接上去,有時候我們不需要整個分支都接上去,只要接到分支上的某個提交的點即可,這種情況下可以使用 rebase —onto 進行。

假設要將 feature/signup 的 commit a71bc5b 合併到 main 就好,就可以用以下指令進行 rebase:

$ git checkout main
$ git rebase feature/signup --onto a71bc5b

git rebase feature/signup --onto a71bc5b 執行前:

經過 rebase 之後,可以發現只有 a71bc5b 被合併到 main 分支:

$ git log --oneline
a71bc5b (HEAD -> main) feature/signup 1st commit
36abac1 2nd commit
3695af2 1st commit

這時候 feature/signup 與 main 的 base 就變成 a71bc5b:

又或者,想要把我們現在分支的 base 改成另一個點,可以使用以下指令:

$ git rebase --onto <new base-commit> <current base-commit>

例如,我們在 feature/signup 分支上時,想把整個分支接到 commit 3695af2 時,可以輸入以下指令:

$ git co feature/signup # 先切換到 feature/signup
$ git rebase --onto 3695af2 36abac1

上述指令執行成功之後,可以發現 feature/signup 的 base 變了之外,也可以發現 commit id 變了,這是因為 rebase 改變 base 會重演分支上的 commit 造成的變化:

$ git log --oneline
c035377 (HEAD -> feature/signup) feature/signup 2nd commit
ff88fc8 feature/signup 1st commit
3695af2 1st commit

下面 2 張圖就是執行上述指令的前後對照。

Rebase 之前:

Rebase 之後:

Rebase - 進階互動(interactive)模式

Rebase 的互動模式十分強大,可以允許我們交換提交的次序、修改提交內容、合併提交內容,甚至將一個提交拆解成多個提交。

要進入互動模式的基本指令如下, commit 可以是分支上的任意一點:

$ git rebase -i <commit id>

例如,我們想整理 feature/signup 上的所有 commit 時:

$ git checkout feature/signup # 切換到 feature/signup 分支
$ git rebase -i 36abac1

上述指令的意思就是我們希望將 feature/signup 從 commit 36abac1 之後的所有提交進行整理(注意,不含 commit 36abac1)。

執行成功後,會出現類似以下的訊息,從結果可以注意到所有 commit id 與 commit message 從上到下,是從最早到最新的方式進行排列

pick a71bc5b feature/signup 1st commit
pick e94ab5e feature/signup 2nd commit

# Rebase 36abac1..e94ab5e onto 36abac1 (2 commands)
#
# Commands:
# p, pick <commit> = use commit
# r, reword <commit> = use commit, but edit the commit message
# e, edit <commit> = use commit, but stop for amending
# s, squash <commit> = use commit, but meld into previous commit
# f, fixup [-C | -c] <commit> = like "squash" but keep only the previous
#                    commit's log message, unless -C is used, in which case
#                    keep only this commit's message; -c is same as -C but
#                    opens the editor
# x, exec <command> = run command (the rest of the line) using shell
# b, break = stop here (continue rebase later with 'git rebase --continue')
# d, drop <commit> = remove commit
# l, label <label> = label current HEAD with a name
# t, reset <label> = reset HEAD to a label
# m, merge [-C <commit> | -c <commit>] <label> [# <oneline>]
# .       create a merge commit using the original merge commit's
# .       message (or the oneline, if no original merge commit was
# .       specified); use -c <commit> to reword the commit message
#
# These lines can be re-ordered; they are executed from top to bottom.
#
# If you remove a line here THAT COMMIT WILL BE LOST.
#
# However, if you remove everything, the rebase will be aborted.

在進一步操作前,我們必須對上述訊息提到的幾個指令(commands)進行說明:

  • pick : 保留此提交
  • reword : 修改提交的訊息(只改提交訊息)
  • edit : 保留此提交,但是需要做一些修改(例如在程式裡面多加些註解)
  • squash : 保留此提交,但是將上面的提交(也就是上一個 commit)併入此提交,此動作會顯示提交訊息供人編輯
  • fixup : 與 squash 相似,但是此 commit 的訊息會被丟掉
  • exec : 執行 shell 指令,例如 exec make test 進行一些測試,可以隨意穿插在提交點之間

接下來示範幾種操作。

變換 commit 順序

如果想變換提交的順序,我們只要進入 rebase 互動模式後,將 commit 的順序進行變動,就能夠變換順序了!

# 調換順序 ↓
pick e94ab5e feature/signup 2nd commit
pick a71bc5b feature/signup 1st commit

# Rebase 36abac1..e94ab5e onto 36abac1 (2 commands)
#
# Commands:
# p, pick <commit> = use commit
# r, reword <commit> = use commit, but edit the commit message
# e, edit <commit> = use commit, but stop for amending
# s, squash <commit> = use commit, but meld into previous commit
# f, fixup [-C | -c] <commit> = like "squash" but keep only the previous
#                    commit's log message, unless -C is used, in which case
#                    keep only this commit's message; -c is same as -C but
#                    opens the editor
# x, exec <command> = run command (the rest of the line) using shell
# b, break = stop here (continue rebase later with 'git rebase --continue')
# d, drop <commit> = remove commit
# l, label <label> = label current HEAD with a name
# t, reset <label> = reset HEAD to a label
# m, merge [-C <commit> | -c <commit>] <label> [# <oneline>]
# .       create a merge commit using the original merge commit's
# .       message (or the oneline, if no original merge commit was
# .       specified); use -c <commit> to reword the commit message
#
# These lines can be re-ordered; they are executed from top to bottom.
#
# If you remove a line here THAT COMMIT WILL BE LOST.
#
# However, if you remove everything, the rebase will be aborted.

修改提交內容

有些時候,我們提交之後,不免會註解忘了加或是程式內還有測試的 code 忘記清掉。這時候除了用 git reset --soft HEAD^ 之外,也可以用 rebase 編輯那些需要修正的提交。

例如,我們希望用 rebase 在 commit a71bc5b 中添加幾個提交,就可以將 pick 改成 edit 進入編輯狀態。

# ↓ 改為 edit
edit a71bc5b feature/signup 1st commit
pick e94ab5e feature/signup 2nd commit

# Rebase 36abac1..e94ab5e onto 36abac1 (2 commands)
#
# Commands:
# p, pick <commit> = use commit
# r, reword <commit> = use commit, but edit the commit message
# e, edit <commit> = use commit, but stop for amending
# s, squash <commit> = use commit, but meld into previous commit
# f, fixup [-C | -c] <commit> = like "squash" but keep only the previous
#                    commit's log message, unless -C is used, in which case
#                    keep only this commit's message; -c is same as -C but
#                    opens the editor
# x, exec <command> = run command (the rest of the line) using shell
# b, break = stop here (continue rebase later with 'git rebase --continue')
# d, drop <commit> = remove commit
# l, label <label> = label current HEAD with a name
# t, reset <label> = reset HEAD to a label
# m, merge [-C <commit> | -c <commit>] <label> [# <oneline>]
# .       create a merge commit using the original merge commit's
# .       message (or the oneline, if no original merge commit was
# .       specified); use -c <commit> to reword the commit message
#
# These lines can be re-ordered; they are executed from top to bottom.
#
# If you remove a line here THAT COMMIT WILL BE LOST.
#
# However, if you remove everything, the rebase will be aborted.

儲存上述檔案之後,如果用 git status 就可以看到我們正在 rebase的訊息:

$ git status
interactive rebase in progress; onto 36abac1
Last command done (1 command done):
   edit a71bc5b feature/signup 1st commit
Next command to do (1 remaining command):
   pick e94ab5e feature/signup 2nd commit
  (use "git rebase --edit-todo" to view and edit)
You are currently editing a commit while rebasing branch 'feature/signup' on '36abac1'.
  (use "git commit --amend" to amend the current commit)
  (use "git rebase --continue" once you are satisfied with your changes)

nothing to commit, working tree clean

在這個狀態下,如果你只是想修正提交訊息,可以用以下指令 :

$ git commit --amend

在這個狀態下,如果你需要多增加幾個提交,直接編輯吧** ,接著用 git add <file> , git commit -m <message> 等一般操作進行。

最後利用以下指令完成 rebase:

$ git rebase --continue

又或者,我們現在編輯的提交實在是太大了,可能對程式碼審查的人造成困擾,例如同時修正太多個檔案,我們希望拆成比較明確的多個提交 ,就可以用以下指令回到未提交前的狀態:

$ git reset --soft HEAD^

然後可以用 git status 列出這個提交中變更了多少檔案,然後依照需求一個一個用 git add 加進去後提交,多提交個幾次,就等於是將一個提交拆成多個提交囉!

不過別忘了,要用以下指令結束 rebase:

$ git rebase --continue

合併 commits

如果想合併多個 commit 則可以使用 squash, squash 時會讓我們編輯 commit 訊息:

pick a71bc5b feature/signup 1st commit
# ↓ 改為 squash
squash e94ab5e feature/signup 2nd commit

# Rebase 36abac1..e94ab5e onto 36abac1 (2 commands)
#
# Commands:
# p, pick <commit> = use commit
# r, reword <commit> = use commit, but edit the commit message
# e, edit <commit> = use commit, but stop for amending
# s, squash <commit> = use commit, but meld into previous commit
# f, fixup [-C | -c] <commit> = like "squash" but keep only the previous
#                    commit's log message, unless -C is used, in which case
#                    keep only this commit's message; -c is same as -C but
#                    opens the editor
# x, exec <command> = run command (the rest of the line) using shell
# b, break = stop here (continue rebase later with 'git rebase --continue')
# d, drop <commit> = remove commit
# l, label <label> = label current HEAD with a name
# t, reset <label> = reset HEAD to a label
# m, merge [-C <commit> | -c <commit>] <label> [# <oneline>]
# .       create a merge commit using the original merge commit's
# .       message (or the oneline, if no original merge commit was
# .       specified); use -c <commit> to reword the commit message
#
# These lines can be re-ordered; they are executed from top to bottom.
#
# If you remove a line here THAT COMMIT WILL BE LOST.
#
# However, if you remove everything, the rebase will be aborted.

注意:第一行 commit 無法使用 squash, 如果在第一行使用 squash 會出現類似下的錯誤訊息:

error: cannot 'squash' without a previous commit
You can fix this with 'git rebase --edit-todo' and then run 'git rebase --continue'.
Or you can abort the rebase with 'git rebase --abort'.

直接合併 commits

除了 squash 之外,也可以使用 fixup 合併 commits, 與 squash 不同的是, fixup 會直接採用上一個 commit 的 message 作為合併後的 commit message, 不需重新輸入 commit message:

pick a71bc5b feature/signup 1st commit
# ↓ 改為 fixup
fixup e94ab5e feature/signup 2nd commit

# Rebase 36abac1..e94ab5e onto 36abac1 (2 commands)
#
# Commands:
# p, pick <commit> = use commit
# r, reword <commit> = use commit, but edit the commit message
# e, edit <commit> = use commit, but stop for amending
# s, squash <commit> = use commit, but meld into previous commit
# f, fixup [-C | -c] <commit> = like "squash" but keep only the previous
#                    commit's log message, unless -C is used, in which case
#                    keep only this commit's message; -c is same as -C but
#                    opens the editor
# x, exec <command> = run command (the rest of the line) using shell
# b, break = stop here (continue rebase later with 'git rebase --continue')
# d, drop <commit> = remove commit
# l, label <label> = label current HEAD with a name
# t, reset <label> = reset HEAD to a label
# m, merge [-C <commit> | -c <commit>] <label> [# <oneline>]
# .       create a merge commit using the original merge commit's
# .       message (or the oneline, if no original merge commit was
# .       specified); use -c <commit> to reword the commit message
#
# These lines can be re-ordered; they are executed from top to bottom.
#
# If you remove a line here THAT COMMIT WILL BE LOST.
#
# However, if you remove everything, the rebase will be aborted.

以上就是 rebase 的幾個簡單說明與操作,至於 exec 就留給各位去體驗了!

Rebase 出現問題時的處理方法

Rebase 與 merge 一樣都可能會產生 conflict ,這時候除了修正 conflict 之後再用 git add <file> , git commit -m <message> , git rebase --continue 完成 rebase 之外,也可以用 git rebase --abort 直接放棄 rebase 。

$ git rebase (--continue | --abort | --skip)

此外,對於 rebase 使用不慎時,我們會希望能夠直接回復到 rebase 之前的狀態,以下就是幾個指令可以用來回復到 rebase之前的狀態(參考自 StackOverFlow )。

回復方法 1 (最簡單的用法):

$ git reset --hard ORIG_HEAD

回復方法 2. rebase 之前先上 tag :

$ git tag BACKUP
$ ... # rebase 過程
$ ... # rebase 過程
$ git reset --hard BACKUP # 失敗的話可以直接回復到 tag BACKUP

回復方法 3 :

$ git reflog # 尋找要回復的 HEAD ,以下假設是 HEAD@{3}
$ git reset --hard HEAD@{3} # 回復

總結

實務上,如果想維持 commit 的整潔, rebase 幾乎是必要的,只是在使用 rebase 時一定要了解其特性,否則就容易造成其他人的困擾。

以上, Happy Coding!

對抗久坐職業傷害

研究指出每天增加 2 小時坐著的時間,會增加大腸癌、心臟疾病、肺癌的風險,也造成肩頸、腰背疼痛等常見問題。

然而對抗這些問題,卻只需要工作時定期休息跟伸展身體即可!

你想輕鬆改變現狀嗎?試試看我們的 PomodoRoll 番茄鐘吧! PomodoRoll 番茄鐘會根據你所設定的專注時間,定期建議你 1 項辦公族適用的伸展運動,幫助你打敗久坐所帶來的傷害!

贊助我們的創作

看完這篇文章了嗎? 休息一下,喝杯咖啡吧!

如果你覺得 MyApollo 有讓你獲得實用的資訊,希望能看到更多的技術分享,邀請你贊助我們一杯咖啡,讓我們有更多的動力與精力繼續提供高品質的文章,感謝你的支持!