章節 ▾ 第二版

7.6 Git 工具 - 重寫歷史

重寫歷史

在使用 Git 時,你可能經常會想要修改本地的提交歷史。Git 的一個強大之處在於它允許你把決定推遲到最後一刻做出。你可以使用暫存區在提交前決定哪些檔案進入哪些提交,你也可以使用 git stash 決定暫時不處理某些工作,還可以重寫已經發生的提交,讓它們看起來是以不同的方式發生的。這可能涉及到改變提交的順序、更改提交中的訊息或修改檔案、合併或拆分提交,或者完全刪除提交——所有這些都可以在你與他人分享你的工作之前完成。

在本節中,你將看到如何完成這些任務,以便在與他人分享你的提交歷史之前,讓它看起來符合你的期望。

注意
在你滿意之前不要推送你的工作

Git 的一個基本規則是,由於你的大部分工作都在本地克隆中進行,你有很大的自由在本地重寫你的歷史。然而,一旦你推送了你的工作,情況就完全不同了,除非你有充分的理由,否則應該將已推送的工作視為最終版本。簡而言之,你應該避免推送你的工作,直到你對此感到滿意並準備好與世界分享。

修改上次提交

修改最近一次提交可能是最常見的歷史重寫操作。你通常會想要對最後一次提交做兩件基本的事情:簡單地修改提交訊息,或者透過新增、刪除和修改檔案來改變提交的實際內容。

如果你只是想修改上次提交的訊息,那很簡單

$ git commit --amend

上面的命令將上次提交的訊息載入到一個編輯器會話中,你可以在其中對訊息進行更改,儲存這些更改並退出。當你儲存並關閉編輯器時,編輯器會寫入一個新的提交,其中包含更新後的提交訊息,並將其作為你的新最近提交。

另一方面,如果你想更改上次提交的實際內容,過程基本上是一樣的——首先進行你認為遺漏的更改,暫存這些更改,然後後續的 git commit --amend 將用你新的、改進的提交替換掉上次提交。

你需要小心這種技術,因為修改提交會改變提交的 SHA-1 值。這就像一次非常小的變基操作——如果你已經推送了上次提交,就不要修改它。

提示
修改過的提交可能(或可能不需要)修改提交訊息

當你修改一個提交時,你有機會同時更改提交訊息和提交內容。如果你大幅修改了提交內容,那麼你幾乎肯定應該更新提交訊息以反映這些修改過的內容。

另一方面,如果你的修改微不足道(修復一個愚蠢的錯別字或新增一個你忘記暫存的檔案),以至於之前的提交訊息仍然適用,那麼你只需進行更改,暫存它們,然後完全避免不必要的編輯器會話,透過

$ git commit --amend --no-edit

修改多個提交訊息

要修改歷史記錄中更早的提交,你必須使用更復雜的工具。Git 沒有一個專門的“修改歷史”工具,但你可以使用 rebase 工具將一系列提交變基到它們最初基於的 HEAD 上,而不是將它們移動到另一個分支。使用互動式變基工具,你可以在每次你想要修改的提交之後停止,然後修改訊息、新增檔案或做任何你希望的事情。你可以透過給 git rebase 命令新增 -i 選項來互動式地執行變基。你必須透過告訴命令要變基到哪個提交來指明你想要重寫多遠歷史的提交。

例如,如果你想修改最近三次提交的訊息,或者該組中的任何提交訊息,你需要將你想要編輯的最後一個提交的父提交作為引數提供給 git rebase -i,即 HEAD~2^HEAD~3。記住 ~3 可能更容易,因為你正試圖編輯最近三次提交,但請記住,你實際上指定的是四次提交之前的位置,也就是你想要編輯的最後一次提交的父提交

$ git rebase -i HEAD~3

再次提醒,這是一個變基命令——範圍 HEAD~3..HEAD 內的每一個訊息被修改的提交,以及它的所有後代提交,都將被重寫。不要包含任何你已經推送到中央伺服器的提交——這樣做會透過提供相同更改的替代版本來混淆其他開發者。

執行此命令會在你的文字編輯器中顯示一個提交列表,看起來像這樣

pick f7f3f6d Change my name a bit
pick 310154e Update README formatting and add blame
pick a5f4a0d Add cat-file

# Rebase 710f0f8..a5f4a0d onto 710f0f8
#
# 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 <commit> = like "squash", but discard this commit's log message
# 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.
#
# Note that empty commits are commented out

需要注意的是,這些提交的順序與你通常使用 log 命令看到的順序是相反的。如果你執行 log 命令,你會看到類似這樣的內容

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

注意反向順序。互動式變基會給你一個即將執行的指令碼。它將從你在命令列中指定的提交(HEAD~3)開始,並從上到下重放每個提交中引入的更改。它將最舊的列在頂部,而不是最新的,因為這是它將首先重放的提交。

你需要編輯指令碼,使其在你想要編輯的提交處停止。為此,對於每個你希望指令碼在其後停止的提交,將單詞“pick”更改為“edit”。例如,要僅修改第三個提交訊息,你將檔案更改為如下所示

edit f7f3f6d Change my name a bit
pick 310154e Update README formatting and add blame
pick a5f4a0d Add cat-file

當你儲存並退出編輯器時,Git 會將你回退到該列表中的最後一個提交,並在命令列中顯示以下訊息

$ git rebase -i HEAD~3
Stopped at f7f3f6d... Change my name a bit
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,則可以為每個更改為 edit 的提交重複這些步驟。每次,Git 都會停止,讓你修改提交,並在你完成後繼續。

重排提交

你還可以使用互動式變基來完全重排或刪除提交。如果你想刪除“Add cat-file”提交併更改另外兩個提交的引入順序,你可以將變基指令碼從這樣更改

pick f7f3f6d Change my name a bit
pick 310154e Update README formatting and add blame
pick a5f4a0d Add cat-file

到這樣

pick 310154e Update README formatting and add blame
pick f7f3f6d Change my name a bit

當你儲存並退出編輯器時,Git 會將你的分支回退到這些提交的父提交,然後應用 310154e 再應用 f7f3f6d,然後停止。你實際上改變了這些提交的順序,並完全刪除了“Add cat-file”提交。

合併提交

你還可以使用互動式變基工具將一系列提交合併為一個單獨的提交。指令碼會在變基訊息中提供有用的說明

#
# 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 <commit> = like "squash", but discard this commit's log message
# 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.
#
# Note that empty commits are commented out

如果除了“pick”或“edit”之外,你指定“squash”,Git 會應用該更改以及它之前的直接更改,並讓你將提交訊息合併在一起。因此,如果你想將這三個提交合併為一個提交,你可以讓指令碼看起來像這樣

pick f7f3f6d Change my name a bit
squash 310154e Update README formatting and add blame
squash a5f4a0d Add cat-file

當你儲存並退出編輯器時,Git 會應用所有這三處更改,然後讓你回到編輯器中合併這三條提交訊息

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

# This is the 2nd commit message:

Update README formatting and add blame

# This is the 3rd commit message:

Add cat-file

當你儲存後,你就得到了一個單獨的提交,它包含了之前所有三個提交的更改。

拆分提交

拆分提交會撤銷一個提交,然後根據你最終想要的提交數量,多次部分暫存和提交。例如,假設你想拆分你三個提交中的中間那個提交。你不想保留“Update README formatting and add blame”,而是想將其拆分成兩個提交:第一個是“Update README formatting”,第二個是“Add blame”。你可以在 rebase -i 指令碼中透過將你想要拆分的提交上的指令更改為“edit”來做到這一點

pick f7f3f6d Change my name a bit
edit 310154e Update README formatting and add blame
pick a5f4a0d Add cat-file

然後,當指令碼將你帶到命令列時,你重置該提交,獲取已重置的更改,並從這些更改中建立多個提交。當你儲存並退出編輯器時,Git 會回退到列表中第一個提交的父提交,應用第一個提交(f7f3f6d),再應用第二個(310154e),然後將你帶到控制檯。在那裡,你可以使用 git reset HEAD^ 對該提交進行混合重置,這實際上撤銷了該提交併使修改後的檔案處於未暫存狀態。現在你可以暫存和提交檔案,直到你有了幾個提交,並在完成後執行 git rebase --continue

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

Git 會應用指令碼中的最後一個提交(a5f4a0d),你的歷史記錄看起來像這樣

$ git log -4 --pretty=format:"%h %s"
1c002dd Add cat-file
9b29157 Add blame
35cfb2b Update README formatting
f7f3f6d Change my name a bit

這會改變你列表中最近三個提交的 SHA-1 值,因此請確保列表中沒有你已推送到共享倉庫的更改過的提交。請注意,列表中的最後一個提交(f7f3f6d)未發生變化。儘管這個提交在指令碼中顯示,但因為它被標記為“pick”並在任何變基更改之前應用,Git 會讓這個提交保持原樣。

刪除提交

如果你想刪除一個提交,你可以使用 rebase -i 指令碼來刪除它。在提交列表中,在你想要刪除的提交前加上單詞“drop”(或者直接從變基指令碼中刪除該行)

pick 461cb2a This commit is OK
drop 5aecc10 This commit is broken

由於 Git 構建提交物件的方式,刪除或更改一個提交將導致其後所有提交的重寫。你在倉庫歷史中回溯得越遠,需要重新建立的提交就越多。如果序列中後面有許多提交依賴於你剛剛刪除的提交,這可能會導致大量的合併衝突。

如果你在這樣的變基過程中途決定這不是一個好主意,你可以隨時停止。輸入 git rebase --abort,你的倉庫將恢復到開始變基之前的狀態。

如果你完成變基後發現這不是你想要的,你可以使用 git reflog 來恢復你的分支的早期版本。有關 reflog 命令的更多資訊,請參閱 資料恢復

注意

Drew DeVault 製作了一份包含練習的實用動手指南,教你如何使用 git rebase。你可以在這裡找到它:https://git-rebase.io/

終極選項:filter-branch

還有另一種重寫歷史的選項,如果你需要以某種可指令碼化的方式重寫大量提交——例如,全域性更改你的電子郵件地址或從每個提交中刪除檔案,你可以使用它。這個命令是 filter-branch,它可以重寫你歷史記錄中的大片內容,所以你最好不要使用它,除非你的專案尚未公開,並且其他人也沒有基於你即將重寫的提交進行工作。然而,它非常有用。你將學習一些常見用法,以便了解它的一些功能。

注意

git filter-branch 有許多陷阱,並且不再是重寫歷史的推薦方式。相反,請考慮使用 git-filter-repo,這是一個 Python 指令碼,對於大多數通常會使用 filter-branch 的應用場景來說,它能做得更好。其文件和原始碼可在 https://github.com/newren/git-filter-repo 找到。

從每個提交中刪除檔案

這種情況相當常見。有人不小心用不假思索的 git add . 提交了一個巨大的二進位制檔案,而你希望在所有地方刪除它。也許你不小心提交了一個包含密碼的檔案,而你想要開源你的專案。filter-branch 是你可能想用來清理整個歷史的工具。要從你的整個歷史記錄中刪除名為 passwords.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 選項在每次檢出專案後執行指定的命令,然後重新提交結果。在這種情況下,你從每個快照中刪除名為 passwords.txt 的檔案,無論它是否存在。如果你想刪除所有不小心提交的編輯器備份檔案,你可以執行類似 git filter-branch --tree-filter 'rm -f *~' HEAD 的命令。

你將能夠看到 Git 重寫樹和提交,然後在最後移動分支指標。通常,最好在一個測試分支中執行此操作,並在確定結果是你真正想要的之後,再對你的 master 分支進行硬重置。要在所有分支上執行 filter-branch,你可以向命令傳遞 --all

將子目錄設為新的根目錄

假設你已經從另一個原始碼控制系統匯入,並且有一些沒有意義的子目錄(如 trunktags 等)。如果你想讓 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 值,所以此命令會更改你歷史記錄中的每個提交 SHA-1 值,而不僅僅是那些擁有匹配電子郵件地址的提交。

scroll-to-top