任何版本控制系統的一個最有的用特性就是“撤銷 (undo)”你的錯誤操作的能力。在 Git 裡,“撤銷” 蘊含了不少略有差別的功能。
當你進行一次新的提交的時候,Git 會儲存你程式碼庫在那個特定時間點的快照;之後,你可以利用 Git 返回到你的專案的一個早期版本。
在本篇博文裡,我會講解某些你需要“撤銷”已做出的修改的常見場景,以及利用 Git 進行這些操作的最佳方法。
撤銷一個“已公開”的改變
場景: 你已經執行了 git push
, 把你的修改傳送到了 GitHub,現在你意識到這些 commit 的其中一個是有問題的,你需要撤銷那一個 commit.
方法: git revert <SHA>
原理: git revert
會產生一個新的 commit,它和指定 SHA 對應的 commit 是相反的(或者說是反轉的)。如果原先的 commit 是“物質”,新的 commit 就是“反物質” — 任何從原先的 commit 裡刪除的內容會在新的 commit 裡被加回去,任何在原先的 commit 里加入的內容會在新的 commit 裡被刪除。
這是 Git 最安全、最基本的撤銷場景,因為它並不會改變歷史 — 所以你現在可以 git push
新的“反轉” commit 來抵消你錯誤提交的 commit。
修正最後一個 commit 訊息
場景: 你在最後一條 commit 訊息裡有個筆誤,已經執行了 git commit -m "Fxies bug #42",但在
git push
之前你意識到訊息應該是 “Fixes bug #42″。
方法: git commit --amend
或 git commit --amend -m "Fixes bug #42"
原理: git commit --amend
會用一個新的 commit 更新並替換最近的 commit ,這個新的 commit 會把任何修改內容和上一個 commit 的內容結合起來。如果當前沒有提出任何修改,這個操作就只會把上次的 commit 訊息重寫一遍。
撤銷“本地的”修改
場景: 一隻貓從鍵盤上走過,無意中儲存了修改,然後破壞了編輯器。不過,你還沒有 commit 這些修改。你想要恢復被修改檔案裡的所有內容 — 就像上次 commit 的時候一模一樣。
方法: git checkout -- <bad filename>
原理: git checkout
會把工作目錄裡的檔案修改到 Git 之前記錄的某個狀態。你可以提供一個你想返回的分支名或特定 SHA ,或者在預設情況下,Git 會認為你希望 checkout 的是 HEAD
,當前 checkout 分支的最後一次 commit。
記住:你用這種方法“撤銷”的任何修改真的會完全消失。因為它們從來沒有被提交過,所以之後 Git 也無法幫助我們恢復它們。你要確保自己瞭解你在這個操作裡扔掉的東西是什麼!(也許可以先利用 git diff
確認一下)
重置“本地的”修改
場景: 你在本地提交了一些東西(還沒有 push),但是所有這些東西都很糟糕,你希望撤銷前面的三次提交 — 就像它們從來沒有發生過一樣。
方法: git reset <last good SHA>
或 git reset --hard <last good SHA>
原理: git reset
會把你的程式碼庫歷史返回到指定的 SHA 狀態。 這樣就像是這些提交從來沒有發生過。預設情況下, git reset
會保留工作目錄。這樣,提交是沒有了,但是修改內容還在磁碟上。這是一種安全的選擇,但通常我們會希望一步就“撤銷”提交以及修改內容 — 這就是 --hard
選項的功能。
在撤銷“本地修改”之後再恢復
場景: 你提交了幾個 commit,然後用 git reset --hard
撤銷了這些修改(見上一段),接著你又意識到:你希望還原這些修改!
方法: git reflog
和 git reset
或 git checkout
原理: git reflog
對於恢復專案歷史是一個超棒的資源。你可以恢復幾乎 任何東西 — 任何你 commit 過的東西 — 只要通過 reflog。
你可能已經熟悉了 git log
命令,它會顯示 commit 的列表。 git reflog
也是類似的,不過它顯示的是一個 HEAD
發生改變的時間列表.
一些注意事項:
它涉及的只是 HEAD
的改變。在你切換分支、用git commit
進行提交、以及用git reset
撤銷 commit 時,HEAD
會改變,但當你用git checkout -- <bad filename>
撤銷時(正如我們在前面講到的情況),HEAD 並不會改變 — 如前所述,這些修改從來沒有被提交過,因此 reflog 也無法幫助我們恢復它們。git reflog
不會永遠保持。Git 會定期清理那些 “用不到的” 物件。不要指望幾個月前的提交還一直躺在那裡。- 你的
reflog
就是你的,只是你的。你不能用git reflog
來恢復另一個開發者沒有 push 過的 commit。
那麼…你怎麼利用 reflog 來“恢復”之前“撤銷”的 commit 呢?它取決於你想做到的到底是什麼:
- 如果你希望準確地恢復專案的歷史到某個時間點,用
git reset --hard <SHA>
- 如果你希望重建工作目錄裡的一個或多個檔案,讓它們恢復到某個時間點的狀態,用
git checkout <SHA> -- <filename>
- 如果你希望把這些 commit 裡的某一個重新提交到你的程式碼庫裡,用
git cherry-pick <SHA>
利用分支的另一種做法
場景: 你進行了一些提交,然後意識到你開始 check out 的是 master 分支。你希望這些提交進到另一個特性(feature)分支裡。
方法: git branch feature
, git reset --hard origin/master
, and git checkout feature
原理: 你可能習慣了用 git checkout -b <name> 建立新的分支
— 這是建立新分支並馬上 check out 的流行捷徑 — 但是你不希望馬上切換分支。這裡, git branch feature
建立一個叫做 feature
的新分支並指向你最近的 commit,但還是讓你 check out 在 master 分支上。
下一步,在提交任何新的 commit 之前,用 git reset --hard
把 master
分支倒回 origin/master 。不過別擔心,那些 commit 還在
feature 分支裡。
最後,用 git checkout
切換到新的 feature
分支,並且讓你最近所有的工作成果都完好無損。
及時分支,省去繁瑣
場景: 你在 master 分支的基礎上建立了 feature
分支,但 master
分支已經滯後於 origin/master 很多。現在
master
分支已經和 origin/master 同步,你希望
在 feature
上的提交是從現在開始,而不是也從滯後很多的地方開始。
方法: git checkout feature
和 git rebase master
原理: 要達到這個效果,你本來可以通過 git reset
(不加 --hard
, 這樣可以在磁碟上保留修改) 和 git checkout -b <new branch name>
然後再重新提交修改,不過這樣做的話,你就會失去提交歷史。我們有更好的辦法。
git rebase master
會做如下的事情:
- 首先它會找到你當前 check out 的分支和
master 分支的共同祖先。
- 然後它 reset 當前 check out 的分支到那個共同祖先,在一個臨時儲存區存放所有之前的提交。
- 然後它把當前 check out 的分支提到
master
的末尾部分,並從臨時儲存區重新把存放的 commit 提交到master
分支的最後一個 commit 之後。
大量的撤銷/恢復
場景: 你向某個方向開始實現一個特性,但是半路你意識到另一個方案更好。你已經進行了十幾次提交,但你現在只需要其中的一部分。你希望其他不需要的提交統統消失。
方法: git rebase -i <earlier SHA>
原理: -i
引數讓 rebase
進入“互動模式”。它開始類似於前面討論的 rebase,但在重新進行任何提交之前,它會暫停下來並允許你詳細地修改每個提交。
rebase -i
會開啟你的預設文字編輯器,裡面列出候選的提交。如下所示:
前面兩列是鍵:第一個是選定的命令,對應第二列裡的 SHA 確定的 commit。預設情況下, rebase -i
假定每個 commit 都要通過 pick
命令被運用。
要丟棄一個 commit,只要在編輯器裡刪除那一行就行了。如果你不再需要專案裡的那幾個錯誤的提交,你可以刪除上例中的1、3、4行。
如果你需要保留 commit 的內容,而是對 commit 訊息進行編輯,你可以使用 reword
命令。 把第一列裡的 pick
替換為 reword
(或者直接用 r
)。有人會覺得在這裡直接重寫 commit 訊息就行了,但是這樣不管用 —rebase -i
會忽略 SHA 列前面的任何東西。它後面的文字只是用來幫助我們記住 0835fe2
是幹啥的。當你完成 rebase -i
的操作之後,你會被提示輸入需要編寫的任何 commit 訊息。
如果你需要把兩個 commit 合併到一起,你可以使用 squash
或 fixup
命令,如下所示:
squash
和 fixup
會“向上”合併 — 帶有這兩個命令的 commit 會被合併到它的前一個 commit 裡。在這個例子裡, 0835fe2
和 6943e85
會被合併成一個 commit, 38f5e4e
和 af67f82
會被合併成另一個。
如果你選擇了 squash,
Git 會提示我們給新合併的 commit 一個新的 commit 訊息; fixup
則會把合併清單裡第一個 commit 的訊息直接給新合併的 commit 。 這裡,你知道 af67f82
是一個“完了完了….” 的 commit,所以你會留著 38f5e4e
as的 commit 訊息,但你會給合併了 0835fe2
和 6943e85
的新 commit 編寫一個新的訊息。
在你儲存並退出編輯器的時候,Git 會按從頂部到底部的順序運用你的 commit。你可以通過在儲存前修改 commit 順序來改變運用的順序。如果你願意,你也可以通過如下安排把 af67f82
和 0835fe2
合併到一起:
修復更早期的 commit
場景: 你在一個更早期的 commit 裡忘記了加入一個檔案,如果更早的 commit 能包含這個忘記的檔案就太棒了。你還沒有 push,但這個 commit 不是最近的,所以你沒法用 commit --amend
.
方法: git commit --squash <SHA of the earlier commit>
和 git rebase --autosquash -i <even earlier SHA>
原理: git commit --squash
會建立一個新的 commit ,它帶有一個 commit 訊息,類似於 squash! Earlier commit
。 (你也可以手工建立一個帶有類似 commit 訊息的 commit,但是 commit --squash
可以幫你省下輸入的工作。)
如果你不想被提示為新合併的 commit 輸入一條新的 commit 訊息,你也可以利用 git commit --fixup
。在這個情況下,你很可能會用commit --fixup
,因為你只是希望在 rebase
的時候使用早期 commit 的 commit 訊息。
rebase --autosquash -i
會啟用一個互動式的 rebase
編輯器,但是編輯器開啟的時候,在 commit 清單裡任何 squash!
和 fixup!
的 commit 都已經配對到目標 commit 上了,如下所示:
在使用 --squash
和 --fixup
的時候,你可能不記得想要修正的 commit 的 SHA 了— 只記得它是前面第 1 個或第 5 個 commit。你會發現 Git 的 ^
和 ~ 操作符特別好用。
HEAD^
是 HEAD
的前一個 commit。 HEAD~4
是 HEAD
往前第 4 個 – 或者一起算,倒數第 5 個 commit。
停止追蹤一個檔案
場景: 你偶然把 application.log
加到程式碼庫裡了,現在每次你執行應用,Git 都會報告在 application.log
裡有未提交的修改。你把 *.log
in 放到了 .gitignore
檔案裡,可檔案還是在程式碼庫裡 — 你怎麼才能告訴 Git “撤銷” 對這個檔案的追蹤呢?
方法: git rm --cached application.log
原理: 雖然 .gitignore
會阻止 Git 追蹤檔案的修改,甚至不關注檔案是否存在,但這只是針對那些以前從來沒有追蹤過的檔案。一旦有個檔案被加入並提交了,Git 就會持續關注該檔案的改變。類似地,如果你利用 git add -f
來強制或覆蓋了 .gitignore
, Git 還會持續追蹤改變的情況。之後你就不必用-f
來新增這個檔案了。
如果你希望從 Git 的追蹤物件中刪除那個本應忽略的檔案, git rm --cached
會從追蹤物件中刪除它,但讓檔案在磁碟上保持原封不動。因為現在它已經被忽略了,你在 git status
裡就不會再看見這個檔案,也不會再偶然提交該檔案的修改了。
這就是如何在 Git 裡撤銷任何操作的方法。要了解更多關於本文中用到的 Git 命令,請檢視下面的有關文件:
打賞支援我翻譯更多好文章,謝謝!
打賞譯者
打賞支援我翻譯更多好文章,謝謝!
任選一種支付方式