SKIP TO CONTENT

重寫歷史

很多時候,在 Git 上工作的時候,你也許會由於某種原因想要修訂你的提交歷史。Git 的一個卓越之處就是它允許你在最後可能的時刻再作決定。你可以在你即將提交暫存區時決定什麼檔歸入哪一次提交,你可以使用 stash 命令來決定你暫時擱置的工作,你可以重寫已經發生的提交以使它們看起來是另外一種樣子。這個包括改變提交的次序、改變說明或者修改提交中包含的檔,將提交歸併(squash)、拆分或者完全刪除——這一切在你尚未開始將你的工作和別人共用前都是可以的。

在這一節中,你會學到如何完成這些很有用的任務,使得你的提交歷史在你將其共用給別人之前變成你想要的樣子。

改變最後一次提交

改變最後一次提交也許是最常見的重寫歷史的行為。對於你的最近一次提交,你經常想做兩件基本事情:改變提交說明,或者經由增加、改變、移除檔案而改變你剛記錄的快照。

如果你只想修改最近一次提交說明,這非常簡單:

$ git commit --amend

這會把你帶入文字編輯器,裡面包含了你最近一次提交的說明訊息,供你修改。當你保存並退出編輯器,這個編輯器會寫入一個新的提交,裡面包含了那個說明,並且讓它成為你的新的最後提交。

如果你完成提交後又想修改被提交的快照,增加或者修改其中的檔案,可能因為你最初提交時,忘了添加一個新建的檔,這個過程基本上一樣。你通過修改檔案然後對其執行 git add 或對一個已被記錄的檔執行 git rm,隨後的 git commit --amend 會獲取你當前的暫存區並將它作為新提交對應的快照。

使用這項技術的時候你必須小心,因為修正會改變提交的SHA-1值。這個很像是一次非常小的 rebase——不要在你最近一次提交被推送後還去修正它。

修改多個提交訊息

要修改歷史中更早的提交,你必須採用更複雜的工具。Git 沒有一個修改歷史的工具,但是你可以使用 rebase 工具來衍合一系列的提交到它們原來所在的 HEAD 上而不是移到新的上。依靠這個互動式的 rebase 工具,你就可以停留在每一次提交後,如果你想修改或改變說明、增加檔案或做任何事情。在 git rebase 增加 -i 選項可以對話模式執行 rebase。你必須告訴 rebase 命令要衍合到哪次提交,來指明你想要重寫的提交要回溯到多遠。

例如,你想修改最近三次的提交說明,或者其中任意一次,你必須給 git rebase -i 提供一個參數,指明你想要修改的提交的父提交,例如 HEAD~2^ 或者 HEAD~3。可能記住 ~3 更加容易,因為你想修改最近三次提交;但是請記住你事實上所指的是四次提交之前,即你想修改的提交的父提交。

$ git rebase -i HEAD~3

再次提醒這是一個衍合命令—— HEAD~3..HEAD 範圍內的每一次提交都會被重寫,無論你是否修改說明。不要涵蓋你已經推送到中心伺服器的提交——這麼做會使其他開發者產生混亂,因為你提供了同樣變更的不同版本。

執行這個命令會在你的文字編輯器提供一個提交列表,看起來像下面這樣:

pick f7f3f6d changed my name a bit
pick 310154e updated README formatting and added blame
pick a5f4a0d added cat-file

# Rebase 710f0f8..a5f4a0d onto 710f0f8
#
# Commands:
#  p, pick = use commit
#  r, reword = use commit, but edit the commit message
#  e, edit = use commit, but stop for amending
#  s, squash = use commit, but meld into previous commit
#  f, fixup = like "squash", but discard this commit's log message
#  x, exec = run command (the rest of the line) using shell
#
# 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.
#
# Note that empty commits are commented out

很重要的一點是你得注意這些提交的順序與你通常通過 log 命令看到的是相反的。如果你執行 log,你會看到下面這樣的結果:

$ git log --pretty=format:"%h %s" HEAD~3..HEAD
a5f4a0d added cat-file
310154e updated README formatting and added blame
f7f3f6d changed my name a bit

請注意這裡的順序是相反的。互動式的 rebase 給了你一個即將執行的腳本。它會從你在命令列上指明的提交開始(HEAD~3)然後自上至下重播每次提交裡引入的變更。它將最早的列在頂上而不是最近的,因為這是第一個需要重播的。

你需要修改這個腳本來讓它停留在你想修改的變更上。要做到這一點,你只要將你想修改的每一次提交前面的 pick 改為 edit。例如,只想修改第三次提交說明的話,你就像下面這樣修改文件:

edit f7f3f6d changed my name a bit
pick 310154e updated README formatting and added blame
pick a5f4a0d added cat-file

當你存檔並退出編輯器,Git 會倒回至列表中的最後一次提交,然後把你送到命令列中,同時顯示以下資訊:

$ git rebase -i HEAD~3
Stopped at 7482e0d... updated the gemspec to hopefully work better
You can amend the commit now, with

       git commit --amend

Once you’re satisfied with your changes, run

       git rebase --continue

這些指示很明確地告訴了你該幹什麼。輸入

$ git commit --amend

修改提交說明,退出編輯器。然後,執行

$ git rebase --continue

這個命令會自動應用其他兩次提交,你就完成任務了。如果你將更多行的 pick 改為 edit ,你就能對你想修改的提交重複這些步驟。Git 每次都會停下,讓你修正提交,完成後繼續執行。

重排(Reordering) 提交

你也可以使用互動式的衍合來徹底重排或刪除提交。如果你想刪除 ”added cat-file” 這個提交並且修改其他兩次提交引入的順序,你將 rebase 腳本從這個

pick f7f3f6d changed my name a bit
pick 310154e updated README formatting and added blame
pick a5f4a0d added cat-file

改為這個:

pick 310154e updated README formatting and added blame
pick f7f3f6d changed my name a bit

當你存檔並退出編輯器,Git 將分支倒回至這些提交的父提交,應用 310154e,然後 f7f3f6d,接著停止。你有效地修改了這些提交的順序並且徹底刪除了 ”added cat-file” 這次提交。

擠壓(Squashing) 提交

互動式的衍合工具還可以將一系列提交擠壓為單一提交。腳本在 rebase 的資訊裡放了一些有用的指示:

#
# Commands:
#  p, pick = use commit
#  r, reword = use commit, but edit the commit message
#  e, edit = use commit, but stop for amending
#  s, squash = use commit, but meld into previous commit
#  f, fixup = like "squash", but discard this commit's log message
#  x, exec = run command (the rest of the line) using shell
#
# 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.
#
# Note that empty commits are commented out

如果不用 ”pick” 或者 ”edit”,而是指定 ”squash”,Git 會同時應用那個變更和它之前的變更並將提交說明歸併。因此,如果你想將這三個提交合併為單一提交,你可以將腳本修改成這樣:

pick f7f3f6d changed my name a bit
squash 310154e updated README formatting and added blame
squash a5f4a0d added cat-file

當你儲存並退出編輯器,Git 會應用全部三次變更然後將你送回編輯器來歸併三次提交說明。

# This is a combination of 3 commits.
# The first commit's message is:
changed my name a bit

# This is the 2nd commit message:

updated README formatting and added blame

# This is the 3rd commit message:

added cat-file

當你儲存之後,你就擁有了一個包含前三次提交的全部變更的單一提交。

拆分(Splitting) 提交

拆分提交就是撤銷一次提交,然後多次部分地暫存或提交直到結束。例如,假設你想將三次提交中的中間一次拆分。將「updated README formatting and added blame」拆分成兩次提交:第一次為「updated README formatting」,第二次為「added blame」。你可以在 rebase -i 腳本中修改你想拆分的提交前的指令為 ”edit”:

pick f7f3f6d changed my name a bit
edit 310154e updated README formatting and added blame
pick a5f4a0d added cat-file

然後,這個腳本就將你帶入命令列,你重置那次提交,提取被重置的變更,從中創建多次提交。當你儲存並退出編輯器,Git 倒回到列表中第一次提交的父提交,應用第一次提交(f7f3f6d),應用第二次提交(310154e),然後將你帶到控制台。那裡你可以用 git reset HEAD^ 對那次提交進行一次混合的重置,這將撤銷那次提交並且將修改的檔從暫存區撤回。此時你可以暫存並提交檔案,直到你擁有多次提交,結束後,執行 git rebase --continue

$ git reset HEAD^
$ git add README
$ git commit -m 'updated README formatting'
$ git add lib/simplegit.rb
$ git commit -m 'added blame'
$ git rebase --continue

Git 在腳本中應用了最後一次提交(a5f4a0d),你的歷史看起來就像這樣了:

$ git log -4 --pretty=format:"%h %s"
1c002dd added cat-file
9b29157 added blame
35cfb2b updated README formatting
f3cc40e changed my name a bit

再次提醒,這會修改你列表中的提交的 SHA 值,所以請確保這個列表裡不包含你已經推送到共用倉庫的提交。

核彈級選項: filter-branch

如果你想用腳本的方式修改大量的提交,還有一個重寫歷史的選項可以用——例如,全域性地修改電子郵寄地址或者將一個檔從所有提交中刪除。這個命令是 filter-branch,這會大面積地修改你的歷史,所以你很有可能不該去用它,除非你的專案尚未公開,沒有其他人在你準備修改的提交的基礎上工作。儘管如此,這個可以非常有用。你會學習一些常見用法,借此對它的能力有所認識。

從所有提交中刪除一個檔

這個經常發生。有些人不經思考使用 git add .,意外地提交了一個巨大的二進位檔案,你想將它從所有地方刪除。也許你不小心提交了一個包含密碼的檔,而你想讓你的專案成為 open source。filter-branch 大概會是你用來清理整個歷史的工具。要從整個歷史中刪除一個名叫 password.txt 的檔,你可以在 filter-branch 上使用 --tree-filter 選項:

$ git filter-branch --tree-filter 'rm -f passwords.txt' HEAD
Rewrite 6b9b3cf04e7c5686a9cb838c3f36a8cb6a0fc2bd (21/21)
Ref 'refs/heads/master' was rewritten

--tree-filter 選項會在每次 checkout 專案時先執行指定的命令然後重新提交結果。在這個例子中,你會在所有快照中刪除一個名叫 password.txt 的檔,無論它是否存在。如果你想刪除所有不小心提交上去的編輯器備份檔案,你可以執行類似 git filter-branch --tree-filter "find * -type f -name '*~' -delete" HEAD 的命令。

你可以觀察到 Git 重寫目錄樹並且提交,然後將分支指標移到末尾。一個比較好的辦法是在一個測試分支上做這件事,然後在你確定結果真的是你所要的之後,再 hard-reset 你的主分支。要在你所有的分支上運行 filter-branch 的話,你可以傳遞一個 --all 參數給該命令。

將一個子目錄設置為新的根目錄

假設你完成了從另外一個代碼控制系統的導入工作,得到了一些沒有意義的子目錄(trunk, tags 等等)。如果你想讓 trunk 子目錄成為每一次提交的新的專案根目錄,filter-branch 也可以幫你做到:

$ git filter-branch --subdirectory-filter trunk HEAD
Rewrite 856f0bf61e41a27326cdae8f09fe708d679f596f (12/12)
Ref 'refs/heads/master' was rewritten

現在你的專案根目錄就是 trunk 子目錄了。Git 會自動地刪除不對這個子目錄產生影響的提交。

全域性地更換電子郵寄地址

另一個常見的案例是你在開始時忘了執行 git config 來設置你的姓名和電子郵寄地址,也許你想開源一個專案,把你所有的工作電子郵寄地址修改為個人位址。無論哪種情況你都可以用 filter-branch 來更換多次提交裡的電子郵寄地址。你必須小心一些,只改變屬於你的電子郵寄地址,所以你使用 --commit-filter

$ git filter-branch --commit-filter '
        if [ "$GIT_AUTHOR_EMAIL" = "schacon@localhost" ];
        then
                GIT_AUTHOR_NAME="Scott Chacon";
                GIT_AUTHOR_EMAIL="schacon@example.com";
                git commit-tree "$@";
        else
                git commit-tree "$@";
        fi' HEAD

這樣會巡迴並重寫所有提交使之擁有你的新地址。因為提交裡包含了它們的父提交的 SHA-1 值,這個命令會修改你的歷史中的所有提交,而不僅僅是包含了匹配的電子郵寄地址的那些。