學習用 Git 變基來改變歷史!

2020-06-01 17:40:00

Git 核心的附加價值之一就是編輯歷史記錄的能力。與將歷史記錄視為神聖的記錄的版本控制系統不同,在 Git 中,我們可以修改歷史記錄以適應我們的需要。這為我們提供了很多強大的工具,讓我們可以像使用重構來維護良好的軟體設計實踐一樣,編織良好的提交歷史。這些工具對於新手甚至是有經驗的 Git 使用者來說可能會有些令人生畏,但本指南將幫助我們揭開強大的 git-rebase 的神秘面紗。

值得注意的是:一般建議不要修改公共分支、共用分支或穩定分支的歷史記錄。編輯特性分支和個人分支的歷史記錄是可以的,編輯還沒有推播的提交也是可以的。在編輯完提交後,可以使用 git push -f 來強制推播你的修改到個人分支或特性分支。

儘管有這麼可怕的警告,但值得一提的是,本指南中提到的一切都是非破壞性操作。實際上,在 Git 中永久丟失資料是相當困難的。本指南結尾介紹了在犯錯誤時進行糾正的方法。

設定沙盒

我們不想破壞你的任何實際的版本庫,所以在整個指南中,我們將使用一個沙盒版本庫。執行這些命令來開始工作。1

git init /tmp/rebase-sandboxcd /tmp/rebase-sandboxgit commit --allow-empty -m"Initial commit"

如果你遇到麻煩,只需執行 rm -rf /tmp/rebase-sandbox,並重新執行這些步驟即可重新開始。本指南的每一步都可以在新的沙箱上執行,所以沒有必要重做每個任務。

修正最近的提交

讓我們從簡單的事情開始:修復你最近的提交。讓我們向沙盒中新增一個檔案,並犯個錯誤。

echo "Hello wrold!" >greeting.txtgit add greeting.txtgit commit -m"Add greeting.txt"

修復這個錯誤是非常容易的。我們只需要編輯檔案,然後用 --amend 提交就可以了,就像這樣:

echo "Hello world!" >greeting.txtgit commit -a --amend

指定 -a 會自動將所有 Git 已經知道的檔案進行暫存(例如 Git 新增的),而 --amend 會將更改的內容壓扁到最近的提交中。儲存並退出你的編輯器(如果需要,你現在可以修改提交資訊)。你可以通過執行 git show 看到修復的提交。

commit f5f19fbf6d35b2db37dcac3a55289ff9602e4d00 (HEAD -> master)Author: Drew DeVault Date:   Sun Apr 28 11:09:47 2019 -0400    Add greeting.txtdiff --git a/greeting.txt b/greeting.txtnew file mode 100644index 0000000..cd08755--- /dev/null+++ b/greeting.txt@@ -0,0 +1 @@+Hello world!

修復較舊的提交

--amend 僅適用於最近的提交。如果你需要修正一個較舊的提交會怎麼樣?讓我們從相應地設定沙盒開始:

echo "Hello!" >greeting.txtgit add greeting.txtgit commit -m"Add greeting.txt"echo "Goodbye world!" >farewell.txtgit add farewell.txtgit commit -m"Add farewell.txt"

看起來 greeting.txt 像是丟失了 "world"。讓我們正常地寫個提交來解決這個問題:

echo "Hello world!" >greeting.txtgit commit -a -m"fixup greeting.txt"

現在檔案看起來正確,但是我們的歷史記錄可以更好一點 —— 讓我們使用新的提交來“修復”(fixup)最後一個提交。為此,我們需要引入一個新工具:互動式變基。我們將以這種方式編輯最後三個提交,因此我們將執行 git rebase -i HEAD~3-i 代表互動式)。這樣會開啟文字編輯器,如下所示:

pick 8d3fc77 Add greeting.txtpick 2a73a77 Add farewell.txtpick 0b9d0bb fixup greeting.txt# Rebase f5f19fb..0b9d0bb onto f5f19fb (3 commands)## Commands:# p, pick <commit> = use commit# f, fixup <commit> = like "squash", but discard this commit's log message

這是變基計劃,通過編輯此檔案,你可以指導 Git 如何編輯歷史記錄。我已經將該摘要削減為僅與變基計劃這一部分相關的細節,但是你可以在文字編輯器中瀏覽完整的摘要。

當我們儲存並關閉編輯器時,Git 將從其歷史記錄中刪除所有這些提交,然後一次執行一行。預設情況下,它將選取(pick)每個提交,將其從堆中召喚出來並新增到分支中。如果我們對此檔案根本沒有做任何編輯,則將直接回到起點,按原樣選取每個提交。現在,我們將使用我最喜歡的功能之一:修復(fixup)。編輯第三行,將操作從 pick 更改為 fixup,並將其立即移至我們要“修復”的提交之後:

pick 8d3fc77 Add greeting.txtfixup 0b9d0bb fixup greeting.txtpick 2a73a77 Add farewell.txt

技巧:我們也可以只用 f 來縮寫它,以加快下次的速度。

儲存並退出編輯器,Git 將執行這些命令。我們可以檢查紀錄檔以驗證結果:

$ git log -2 --onelinefcff6ae (HEAD -> master) Add farewell.txta479e94 Add greeting.txt

將多個提交壓扁為一個

在工作時,當你達到較小的里程碑或修復以前的提交中的錯誤時,你可能會發現寫很多提交很有用。但是,在將你的工作合併到 master 分支之前,將這些提交“壓扁”(squash)到一起以使歷史記錄更清晰可能很有用。為此,我們將使用“壓扁”(squash)操作。讓我們從編寫一堆提交開始,如果要加快速度,只需複製並貼上這些:

git checkout -b squashfor c in H e l l o , ' ' w o r l d; do    echo "$c" >>squash.txt    git add squash.txt    git commit -m"Add '$c' to squash.txt"done

要製作出一個寫著 “Hello,world” 的檔案,要做很多事情!讓我們開始另一個互動式變基,將它們壓扁在一起。請注意,我們首先簽出了一個分支來進行嘗試。因此,因為我們使用 git rebase -i master 進行的分支,我們可以快速變基所有提交。結果:

pick 1e85199 Add 'H' to squash.txtpick fff6631 Add 'e' to squash.txtpick b354c74 Add 'l' to squash.txtpick 04aaf74 Add 'l' to squash.txtpick 9b0f720 Add 'o' to squash.txtpick 66b114d Add ',' to squash.txtpick dc158cd Add ' ' to squash.txtpick dfcf9d6 Add 'w' to squash.txtpick 7a85f34 Add 'o' to squash.txtpick c275c27 Add 'r' to squash.txtpick a513fd1 Add 'l' to squash.txtpick 6b608ae Add 'd' to squash.txt# Rebase 1af1b46..6b608ae onto 1af1b46 (12 commands)## Commands:# p, pick <commit> = use commit# s, squash <commit> = use commit, but meld into previous commit

技巧:你的本地 master 分支獨立於遠端 master 分支而發展,並且 Git 將遠端分支儲存為 origin/master。結合這種技巧,git rebase -i origin/master 通常是一種非常方便的方法,可以變基所有尚未合併到上游的提交!

我們將把所有這些更改壓扁到第一個提交中。為此,將第一行除外的每個“選取”(pick)操作都更改為“壓扁”(squash),如下所示:

pick 1e85199 Add 'H' to squash.txtsquash fff6631 Add 'e' to squash.txtsquash b354c74 Add 'l' to squash.txtsquash 04aaf74 Add 'l' to squash.txtsquash 9b0f720 Add 'o' to squash.txtsquash 66b114d Add ',' to squash.txtsquash dc158cd Add ' ' to squash.txtsquash dfcf9d6 Add 'w' to squash.txtsquash 7a85f34 Add 'o' to squash.txtsquash c275c27 Add 'r' to squash.txtsquash a513fd1 Add 'l' to squash.txtsquash 6b608ae Add 'd' to squash.txt

儲存並關閉編輯器時,Git 會考慮片刻,然後再次開啟編輯器以修改最終的提交訊息。你會看到以下內容:

# This is a combination of 12 commits.# This is the 1st commit message:Add 'H' to squash.txt# This is the commit message #2:Add 'e' to squash.txt# This is the commit message #3:Add 'l' to squash.txt# This is the commit message #4:Add 'l' to squash.txt# This is the commit message #5:Add 'o' to squash.txt# This is the commit message #6:Add ',' to squash.txt# This is the commit message #7:Add ' ' to squash.txt# This is the commit message #8:Add 'w' to squash.txt# This is the commit message #9:Add 'o' to squash.txt# This is the commit message #10:Add 'r' to squash.txt# This is the commit message #11:Add 'l' to squash.txt# This is the commit message #12:Add 'd' to squash.txt# Please enter the commit message for your changes. Lines starting# with '#' will be ignored, and an empty message aborts the commit.## Date:      Sun Apr 28 14:21:56 2019 -0400## interactive rebase in progress; onto 1af1b46# Last commands done (12 commands done):#    squash a513fd1 Add 'l' to squash.txt#    squash 6b608ae Add 'd' to squash.txt# No commands remaining.# You are currently rebasing branch 'squash' on '1af1b46'.## Changes to be committed:#   new file:   squash.txt#

預設情況下,這是所有要壓扁的提交的訊息的組合,但是像這樣將其保留肯定不是你想要的。不過,舊的提交訊息在編寫新的提交訊息時可能很有用,所以放在這裡以供參考。

提示:你在上一節中了解的“修復”(fixup)命令也可以用於此目的,但它會丟棄壓扁的提交的訊息。

讓我們刪除所有內容,並用更好的提交訊息替換它,如下所示:

Add squash.txt with contents "Hello, world"# Please enter the commit message for your changes. Lines starting# with '#' will be ignored, and an empty message aborts the commit.## Date:      Sun Apr 28 14:21:56 2019 -0400## interactive rebase in progress; onto 1af1b46# Last commands done (12 commands done):#    squash a513fd1 Add 'l' to squash.txt#    squash 6b608ae Add 'd' to squash.txt# No commands remaining.# You are currently rebasing branch 'squash' on '1af1b46'.## Changes to be committed:#   new file:   squash.txt#

儲存並退出編輯器,然後檢查你的 Git 紀錄檔,成功!

commit c785f476c7dff76f21ce2cad7c51cf2af00a44b6 (HEAD -> squash)Author: Drew DeVaultDate:   Sun Apr 28 14:21:56 2019 -0400    Add squash.txt with contents "Hello, world"

在繼續之前,讓我們將所做的更改拉入 master 分支中,並擺脫掉這一草稿。我們可以像使用 git merge 一樣使用 git rebase,但是它避免了建立合併提交:

git checkout mastergit rebase squashgit branch -D squash

除非我們實際上正在合併無關的歷史記錄,否則我們通常希望避免使用 git merge。如果你有兩個不同的分支,則 git merge 對於記錄它們合併的時間非常有用。在正常工作過程中,變基通常更為合適。

將一個提交拆分為多個

有時會發生相反的問題:一個提交太大了。讓我們來看一看拆分它們。這次,讓我們寫一些實際的程式碼。從一個簡單的 C 程式 2 開始(你仍然可以將此程式碼段複製並貼上到你的 shell 中以快速執行此操作):

cat <<EOF >main.cint main(int argc, char *argv[]) {    return 0;}EOF

首先提交它:

git add main.cgit commit -m"Add C program skeleton"

然後把這個程式擴充套件一些:

cat <<EOF >main.c#include &ltstdio.h>const char *get_name() {    static char buf[128];    scanf("%s", buf);    return buf;}int main(int argc, char *argv[]) {    printf("What's your name? ");    const char *name = get_name();    printf("Hello, %s!\n", name);    return 0;}EOF

提交之後,我們就可以準備學習如何將其拆分:

git commit -a -m"Flesh out C program"

第一步是啟動互動式變基。讓我們用 git rebase -i HEAD~2 來變基這兩個提交,給出的變基計劃如下:

pick 237b246 Add C program skeletonpick b3f188b Flesh out C program# Rebase c785f47..b3f188b onto c785f47 (2 commands)## Commands:# p, pick <commit> = use commit# e, edit <commit> = use commit, but stop for amending

將第二個提交的命令從 pick 更改為 edit,然後儲存並關閉編輯器。Git 會考慮一秒鐘,然後向你建議:

Stopped at b3f188b...  Flesh out C programYou can amend the commit now, with  git commit --amendOnce you are satisfied with your changes, run  git rebase --continue

我們可以按照以下說明為提交新增新的更改,但我們可以通過執行 git reset HEAD^ 來進行“軟重置” 3。如果在此之後執行 git status,你將看到它取消了提交最新的提交,並將其更改新增到工作樹中:

Last commands done (2 commands done):   pick 237b246 Add C program skeleton   edit b3f188b Flesh out C programNo commands remaining.You are currently splitting a commit while rebasing branch 'master' on 'c785f47'.  (Once your working directory is clean, run "git rebase --continue")Changes not staged for commit:  (use "git add ..." to update what will be committed)  (use "git checkout -- ..." to discard changes in working directory)  modified:   main.cno changes added to commit (use "git add" and/or "git commit -a")

為了對此進行拆分,我們將進行互動式提交。這使我們能夠選擇性地僅提交工作樹中的特定更改。執行 git commit -p 開始此過程,你將看到以下提示:

diff --git a/main.c b/main.cindex b1d9c2c..3463610 100644--- a/main.c+++ b/main.c@@ -1,3 +1,14 @@+#include &ltstdio.h>++const char *get_name() {+    static char buf[128];+    scanf("%s", buf);+    return buf;+}+ int main(int argc, char *argv[]) {+    printf("What's your name? ");+    const char *name = get_name();+    printf("Hello, %s!\n", name);     return 0; }Stage this hunk [y,n,q,a,d,s,e,?]?

Git 僅向你提供了一個“大塊”(即單個更改)以進行提交。不過,這太大了,讓我們使用 s 命令將這個“大塊”拆分成較小的部分。

Split into 2 hunks.@@ -1 +1,9 @@+#include <stdio.h>++const char *get_name() {+    static char buf[128];+    scanf("%s", buf);+    return buf;+}+ int main(int argc, char *argv[]) {Stage this hunk [y,n,q,a,d,j,J,g,/,e,?]?

提示:如果你對其他選項感到好奇,請按 ? 匯總顯示。

這個大塊看起來更好:單一、獨立的更改。讓我們按 y 來回答問題(並暫存那個“大塊”),然後按 q 以“退出”互動式對談並繼續進行提交。會彈出編輯器,要求輸入合適的提交訊息。

Add get_name function to C program# Please enter the commit message for your changes. Lines starting# with '#' will be ignored, and an empty message aborts the commit.## interactive rebase in progress; onto c785f47# Last commands done (2 commands done):#    pick 237b246 Add C program skeleton#    edit b3f188b Flesh out C program# No commands remaining.# You are currently splitting a commit while rebasing branch 'master' on 'c785f47'.## Changes to be committed:#   modified:   main.c## Changes not staged for commit:#   modified:   main.c#

儲存並關閉編輯器,然後我們進行第二次提交。我們可以執行另一次互動式提交,但是由於我們只想在此提交中包括其餘更改,因此我們將執行以下操作:

git commit -a -m"Prompt user for their name"git rebase --continue

最後一條命令告訴 Git 我們已經完成了此提交的編輯,並繼續執行下一個變基命令。這樣就行了!執行 git log 來檢視你的勞動成果:

$ git log -3 --onelinefe19cc3 (HEAD -> master) Prompt user for their name659a489 Add get_name function to C program237b246 Add C program skeleton

重新排序提交

這很簡單。讓我們從設定沙箱開始:

echo "Goodbye now!" >farewell.txtgit add farewell.txtgit commit -m"Add farewell.txt"echo "Hello there!" >greeting.txtgit add greeting.txtgit commit -m"Add greeting.txt"echo "How're you doing?" >inquiry.txtgit add inquiry.txtgit commit -m"Add inquiry.txt"

現在 git log 看起來應如下所示:

f03baa5 (HEAD -> master) Add inquiry.txta4cebf7 Add greeting.txt90bb015 Add farewell.txt

顯然,這都是亂序。讓我們對過去的 3 個提交進行互動式變基來解決此問題。執行 git rebase -i HEAD~3,這個變基規劃將出現:

pick 90bb015 Add farewell.txtpick a4cebf7 Add greeting.txtpick f03baa5 Add inquiry.txt# Rebase fe19cc3..f03baa5 onto fe19cc3 (3 commands)## Commands:# p, pick <commit> = use commit## These lines can be re-ordered; they are executed from top to bottom.

現在,解決方法很簡單:只需按照你希望提交出現的順序重新排列這些行。應該看起來像這樣:

pick a4cebf7 Add greeting.txtpick f03baa5 Add inquiry.txtpick 90bb015 Add farewell.txt

儲存並關閉你的編輯器,而 Git 將為你完成其餘工作。請注意,在實踐中這樣做可能會導致衝突,參看下面章節以獲取解決衝突的幫助。

git pull –rebase

如果你一直在由上游更新的分支 <branch>(比如說原始遠端)上做一些提交,通常 git pull 會建立一個合併提交。在這方面,git pull 的預設行為等同於:

git fetch origin <branch>git merge origin/<branch>

假設本地分支 <branch> 設定為從原始遠端跟蹤 <branch> 分支,即:

$ git config branch.<branch>.remoteorigin$ git config branch.<branch>.mergerefs/heads/<branch>

還有另一種選擇,它通常更有用,並且會讓歷史記錄更清晰:git pull --rebase。與合併方式不同,這基本上 4 等效於以下內容:

git fetch origingit rebase origin/<branch>

合併方式更簡單易懂,但是如果你了解如何使用 git rebase,那麼變基方式幾乎可以做到你想要做的任何事情。如果願意,可以將其設定為預設行為,如下所示:

git config --global pull.rebase true

當你執行此操作時,從技術上講,你在應用我們在下一節中討論的過程……因此,讓我們也解釋一下故意執行此操作的含義。

使用 git rebase 來變基

具有諷刺意味的是,我最少使用的 Git 變基功能是它以之命名的功能:變基分支。假設你有以下分支:

A--B--C--D--> master   \--E--F--> feature-1      \--G--> feature-2

事實證明,feature-2 不依賴於 feature-1 的任何更改,它依賴於提交 E,因此你可以將其作為基礎脫離 master。因此,解決方法是:

git rebase --onto master feature-1 feature-2

非互動式變基對所有牽連的提交都執行預設操作(pick5 ,它只是簡單地將不在 feature-1 中的 feature-2 中提交重放到 master 上。你的歷史記錄現在看起來像這樣:

A--B--C--D--> master   |     \--G--> feature-2   \--E--F--> feature-1

解決衝突

解決合併衝突的詳細資訊不在本指南的範圍內,將來請你注意另一篇指南。假設你熟悉通常的解決衝突的方法,那麼這裡是專門適用於變基的部分。

有時,在進行變基時會遇到合併衝突,你可以像處理其他任何合併衝突一樣處理該衝突。Git 將在受影響的檔案中設定衝突標記,git status 將顯示你需要解決的問題,並且你可以使用 git addgit rm 將檔案標記為已解決。但是,在 git rebase 的上下文中,你應該注意兩個選項。

首先是如何完成衝突解決。解決由於 git merge 引起的衝突時,與其使用 git commit 那樣的命令,更適當的變基命令是 git rebase --continue。但是,還有一個可用的選項:git rebase --skip。 這將跳過你正在處理的提交,它不會包含在變基中。這在執行非互動性變基時最常見,這時 Git 不會意識到它從“其他”分支中提取的提交是與“我們”分支上衝突的提交的更新版本。

幫幫我! 我把它弄壞了!

毫無疑問,變基有時會很難。如果你犯了一個錯誤,並因此而丟失了所需的提交,那麼可以使用 git reflog 來節省下一天的時間。執行此命令將向你顯示更改一個參照(即分支和標記)的每個操作。每行顯示你的舊參照所指向的內容,你可對你認為丟失的 Git 提交執行 git cherry-pickgit checkoutgit show 或任何其他操作。


  1. 我們新增了一個空的初始提交以簡化本教學的其餘部分,因為要對版本庫的初始提交進行變基需要特殊的命令(即git rebase --root)。  ?

  2. 如果要編譯此程式,請執行 cc -o main main.c,然後執行 ./main 檢視結果。 ?

  3. 實際上,這是“混合重置”。“軟重置”(使用 git reset --soft 完成)將暫存更改,因此你無需再次 git add 新增它們,並且可以一次性提交所有更改。這不是我們想要的。我們希望選擇性地暫存部分更改,以拆分提交。 ?

  4. 實際上,這取決於上游分支本身是否已變基或刪除/壓扁了某些提交。git pull --rebase 嘗試通過在 git rebasegit merge-base 中使用 “復刻點fork-point” 機制來從這種情況中恢復,以避免變基非本地提交。 ?

  5. 實際上,這取決於 Git 的版本。直到 2.26.0 版,預設的非互動行為以前與互動行為稍有不同,這種方式通常並不重要。  ?