天天看點

Git 工具 - 重寫曆史

作者:超越永無止境技有所長

許多時候,在使用 Git 時,你可能想要修訂送出曆史。 Git 很棒的一點是它允許你在最後時刻做決定。 你可以在将暫存區内容送出前決定哪些檔案進入送出,可以通過 git stash 來決定不與某些内容工作, 也可以重寫已經發生的送出就像它們以另一種方式發生的一樣。 這可能涉及改變送出的順序,改變送出中的資訊或修改檔案,将送出壓縮或是拆分, 或完全地移除送出——在将你的工作成果與他人共享之前。

在本節中,你可以學到如何完成這些工作,這樣在與他人分享你的工作成果時你的送出曆史将如你所願地展示出來。

Note

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

Git 的基本原則之一是,由于克隆中有很多工作是本地的,是以你可以 在本地 随便重寫曆史記錄。 然而一旦推送了你的工作,那就完全是另一回事了,除非你有充分的理由進行更改,否則應該将推送的工作視為最終結果。 簡而言之,在對它感到滿意并準備與他人分享之前,應當避免推送你的工作。

修改最後一次送出

修改你最近一次送出可能是所有修改曆史送出的操作中最常見的一個。 對于你的最近一次送出,你往往想做兩件事情:簡單地修改送出資訊, 或者通過添加、移除或修改檔案來更改送出實際的内容。

如果,你隻是想修改最近一次送出的送出資訊,那麼很簡單:

$ git commit --amend           

上面這條指令會将最後一次的送出資訊載入到編輯器中供你修改。 當儲存并關閉編輯器後,編輯器會将更新後的送出資訊寫入新送出中,它會成為新的最後一次送出。

另一方面,如果你想要修改最後一次送出的實際内容,那麼流程很相似:首先作出你想要補上的修改, 暫存它們,然後用 git commit --amend 以新的改進後的送出來 替換 掉舊有的最後一次送出,

使用這個技巧的時候需要小心,因為修正會改變送出的 SHA-1 校驗和。 它類似于一個小的變基——如果已經推送了最後一次送出就不要修正它。

Tip

修補後的送出可能需要修補送出資訊

當你在修補一次送出時,可以同時修改送出資訊和送出内容。 如果你修補了送出的内容,那麼幾乎肯定要更新送出消息以反映修改後的内容。

另一方面,如果你的修補是瑣碎的(如修改了一個筆誤或添加了一個忘記暫存的檔案), 那麼之前的送出資訊不必修改,你隻需作出更改,暫存它們,然後通過以下指令避免不必要的編輯器環節即可:

$ git commit --amend --no-edit           

修改多個送出資訊

為了修改在送出曆史中較遠的送出,必須使用更複雜的工具。 Git 沒有一個改變曆史工具,但是可以使用變基工具來變基一系列送出,基于它們原來的 HEAD 而不是将其移動到另一個新的上面。 通過互動式變基工具,可以在任何想要修改的送出後停止,然後修改資訊、添加檔案或做任何想做的事情。 可以通過給 git rebase 增加 -i 選項來互動式地運作變基。 必須指定想要重寫多久遠的曆史,這可以通過告訴指令将要變基到的送出來做到。

例如,如果想要修改最近三次送出資訊,或者那組送出中的任意一個送出資訊, 将想要修改的最近一次送出的父送出作為參數傳遞給 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 <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 added cat-file
310154e updated README formatting and added blame
f7f3f6d changed my name a bit           

注意其中的反序顯示。 互動式變基給你一個它将會運作的腳本。 它将會從你在指令行中指定的送出(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 f7f3f6d... changed 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 将會停止,讓你修正送出,然後繼續直到完成。

重新排序送出

也可以使用互動式變基來重新排序或完全移除送出。 如果想要移除 “added cat-file” 送出然後修改另外兩個送出引入的順序,可以将變基腳本從這樣:

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” 送出。

壓縮送出

通過互動式變基工具,也可以将一連串送出壓縮成一個單獨的送出。 在變基資訊中腳本給出了有用的指令:

#
# 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 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           

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

拆分送出

拆分一個送出會撤消這個送出,然後多次地部分地暫存與送出直到完成你所需次數的送出。 例如,假設想要拆分三次送出的中間那次送出。 想要将它拆分為兩次送出:第一個 “updated README formatting”,第二個 “added blame” 來代替原來的 “updated README formatting and 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-1 校驗和,是以要確定清單中的送出還沒有推送到共享倉庫中。

核武器級選項:filter-branch

有另一個曆史改寫的選項,如果想要通過腳本的方式改寫大量送出的話可以使用它——例如,全局修改你的郵箱位址或從每一個送出中移除一個檔案。 這個指令是 filter-branch,它可以改寫曆史中大量的送出,除非你的項目還沒有公開并且其他人沒有基于要改寫的工作的送出做的工作,否則你不應當使用它。 然而,它可以很有用。 你将會學習到幾個常用的用途,這樣就得到了它适合使用地方的想法。

Caution git filter-branch 有很多陷阱,不再推薦使用它來重寫曆史。 請考慮使用 git-filter-repo,它是一個 Python 腳本,相比大多數使用 filter-branch 的應用來說,它做得要更好。它的文檔和源碼可通路 https://github.com/newren/git-filter-repo 擷取。

從每一個送出中移除一個檔案

這經常發生。 有人粗心地通過 git add . 送出了一個巨大的二進制檔案,你想要從所有地方删除。 可能偶然地送出了一個包括一個密碼的檔案,然而你想要開源項目。 filter-branch 是一個可能會用來擦洗整個送出曆史的工具。 為了從整個送出曆史中移除一個叫做 passwords.txt 的檔案,可以使用 --tree-filter 選項給 filter-branch:

$ 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 選項。

使一個子目錄做為新的根目錄

假設已經從另一個源代碼控制系統中導入,并且有幾個沒意義的子目錄(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="[email protected]";
                git commit-tree "$@";
        else
                git commit-tree "$@";
        fi' HEAD           

這會周遊并重寫每一個送出來包含你的新郵箱位址。 因為送出包含了它們父送出的 SHA-1 校驗和,這個指令會修改你的曆史中的每一個送出的 SHA-1 校驗和, 而不僅僅隻是那些比對郵箱位址的送出。