Git
章節 ▾ 第二版

7.6 Git 工具 - 重寫歷史

重寫歷史

許多時候,在使用 Git 時,您可能會想要修改您的本機提交歷史。Git 的一大優點是它讓您可以在最後一刻做出決定。您可以在提交前,使用暫存區決定哪些檔案要放入哪個提交中,您可以使用 git stash 決定您不應該現在就開始處理某些內容,而且您可以重寫已經發生的提交,讓它們看起來像是以不同的方式發生。這可能包括變更提交的順序、變更訊息或修改提交中的檔案、將提交合併或分割,或完全移除提交 — 所有這些都是在您與他人分享您的工作之前進行的。

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

注意
在您對工作感到滿意之前,請勿推送

Git 的基本規則之一是,由於許多工作都在您本機的複製中進行,因此您可以非常自由地在本機重寫您的歷史記錄。然而,一旦您推送了您的工作,情況就完全不同了,您應該將推送的工作視為最終版本,除非您有充分的理由進行變更。簡而言之,在您對工作感到滿意並準備好與世界分享之前,您應該避免推送您的工作。

變更最後一次提交

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

如果你只是想修改最後一次的提交訊息,這很簡單

$ git commit --amend

上面的指令會將前一次的提交訊息載入到編輯器中,你可以在其中修改訊息,儲存變更並退出。當你儲存並關閉編輯器時,編輯器會寫入一個包含更新後提交訊息的新提交,並使其成為你新的最後一次提交。

另一方面,如果你想變更最後一次提交的實際內容,流程基本上相同 — 首先進行你認為遺忘的變更,暫存這些變更,然後後續的 git commit --amend 會用你新的、改良後的提交來取代最後一次的提交。

你必須小心使用這種技巧,因為修改會變更提交的 SHA-1 值。這就像一個非常小的變基 — 如果你已經推送了最後一次的提交,請不要修改它。

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

當你修改提交時,你有機會變更提交訊息和提交的內容。如果你大幅修改了提交的內容,你幾乎肯定應該更新提交訊息以反映修改後的內容。

另一方面,如果你的修改相當微不足道(修正一個愚蠢的錯字或新增一個你忘記暫存的檔案),以至於先前的提交訊息仍然適用,你可以簡單地進行變更、暫存它們,並完全避免不必要的編輯器環節,使用

$ git commit --amend --no-edit

變更多個提交訊息

若要修改歷史中較早的提交,你必須使用更複雜的工具。Git 沒有修改歷史的工具,但你可以使用變基工具將一系列提交變基到它們最初所基於的 HEAD,而不是將它們移動到另一個 HEAD。使用互動式變基工具,你可以在每個想要修改的提交之後停止,並變更訊息、新增檔案或執行任何你希望的操作。你可以透過將 -i 選項新增到 git rebase 來互動式執行變基。你必須透過告知指令要變基到哪個提交來指示你想要重寫提交的時間範圍。

例如,如果你想變更最後三個提交訊息,或該群組中的任何提交訊息,你必須提供 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

如果,你指定「squash」而不是「pick」或「edit」,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 中的電子郵件地址。您需要小心僅更改屬於您自己的電子郵件地址,因此您可以使用 --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

這會遍歷並重寫每個 commit 以包含您的新地址。由於 commit 包含其父 commit 的 SHA-1 值,因此此命令會更改您歷史記錄中的每個 commit SHA-1,而不僅僅是那些具有相符電子郵件地址的 commit。

scroll-to-top