-
1. 起步
-
2. Git 基礎
-
3. Git 分支
-
4. 伺服器上的 Git
- 4.1 協議
- 4.2 在伺服器上部署 Git
- 4.3 生成 SSH 公鑰
- 4.4 架設伺服器
- 4.5 Git Daemon
- 4.6 Smart HTTP
- 4.7 GitWeb
- 4.8 GitLab
- 4.9 第三方託管服務
- 4.10 小結
-
5. 分散式 Git
-
A1. 附錄 A: Git 在其他環境
- A1.1 圖形介面
- A1.2 Visual Studio 中的 Git
- A1.3 Visual Studio Code 中的 Git
- A1.4 IntelliJ / PyCharm / WebStorm / PhpStorm / RubyMine 中的 Git
- A1.5 Sublime Text 中的 Git
- A1.6 Bash 中的 Git
- A1.7 Zsh 中的 Git
- A1.8 PowerShell 中的 Git
- A1.9 小結
-
A2. 附錄 B: 在應用程式中嵌入 Git
-
A3. 附錄 C: Git 命令
7.7 Git 工具 - Reset 詳解
Reset 詳解
在深入更專業的工具之前,我們先來討論 Git 的 reset 和 checkout 命令。這應該是 Git 中最容易讓人感到困惑的部分。它們能做的事情太多了,以至於我們似乎很難真正理解並恰當地使用它們。為了幫助你理解,我們推薦一個簡單的比喻。
三棵樹
理解 reset 和 checkout 的一個更簡單的方法是,將 Git 想象成一個管理三種不同“樹”(檔案集合)的內容管理器。在這裡,“樹”指的是“檔案集合”,而不是特指資料結構。在某些情況下,索引(index)的行為並不完全像一棵樹,但為了方便理解,我們暫時可以這樣想。
Git 系統在正常操作中管理和操作著三棵樹
| 樹 | 角色 |
|---|---|
HEAD |
上一次提交的快照,下一個父提交 |
索引 (Index) |
將要提交的下一個快照 |
工作目錄 (Working Directory) |
沙盒 |
HEAD
HEAD 是指向當前分支引用的指標,而分支引用又指向該分支上的最後一次提交。這意味著 HEAD 將是下一次建立的提交的父提交。通常最簡單的理解方式是,HEAD 是**你在此分支上最後一次提交的快照**。
實際上,要檢視這個快照的樣子非常容易。下面是一個示例,展示瞭如何獲取 HEAD 快照中每個檔案的實際目錄列表和 SHA-1 校驗和。
$ git cat-file -p HEAD
tree cfda3bf379e4f8dba8717dee55aab78aef7f4daf
author Scott Chacon 1301511835 -0700
committer Scott Chacon 1301511835 -0700
initial commit
$ git ls-tree -r HEAD
100644 blob a906cb2a4a904a152... README
100644 blob 8f94139338f9404f2... Rakefile
040000 tree 99f1a6d12cb4b6f19... lib
Git 的 cat-file 和 ls-tree 命令是“管道”命令,用於較低級別的操作,在日常工作中並不常用,但它們有助於我們理解這裡發生的事情。
索引 (Index)
索引是你**打算提交的下一個快照**。我們之前也把它稱為 Git 的“暫存區”,因為當你執行 git commit 時,Git 檢視的就是這裡的內容。
Git 會用一份列表來填充這個索引,列表包含所有你上次檢出到工作目錄的檔案內容及其原始狀態。然後,你可以替換其中的一些檔案,用它們的新版本。git commit 會將這些內容轉換為一個新提交的樹。
$ git ls-files -s
100644 a906cb2a4a904a152e80877d4088654daad0c859 0 README
100644 8f94139338f9404f26296befa88755fc2598c289 0 Rakefile
100644 47c6340d6459e05787f644c2447d2595f5d3a54b 0 lib/simplegit.rb
同樣,這裡我們使用了 git ls-files,這是一個更偏向幕後的命令,它展示了你當前索引的樣子。
技術上來說,索引並不是一個樹結構——它實際上被實現為一個扁平的清單(manifest)——但對我們目前的理解來說,已經足夠接近了。
工作目錄 (Working Directory)
最後,你擁有你的工作目錄(也常被稱為“工作樹”)。另外兩棵樹將它們的內容以一種高效但不方便的方式儲存在 .git 資料夾內。工作目錄將它們解壓成實際的檔案,方便你進行編輯。將工作目錄看作一個**沙盒**,你可以在提交到暫存區(索引)然後提交到歷史記錄之前,在這裡嘗試修改。
$ tree
.
├── README
├── Rakefile
└── lib
└── simplegit.rb
1 directory, 3 files
工作流程
Git 的典型工作流程是透過操作這三棵樹,以逐步改進的狀態記錄專案的快照。
讓我們視覺化這個過程:假設你進入一個新目錄,裡面只有一個檔案。我們稱之為檔案的 **v1** 版本,並用藍色表示。現在我們執行 git init,它會建立一個 Git 倉庫,並有一個指向未出生 master 分支的 HEAD 引用。
此時,只有工作目錄樹包含內容。
現在我們想提交這個檔案,所以我們使用 git add 將工作目錄中的內容複製到索引。
git add 操作將檔案複製到索引然後我們執行 git commit,它會將索引的內容儲存為一個永久的快照,建立一個指向該快照的提交物件,並更新 master 指標指向該提交。
git commit 步驟如果我們執行 git status,會看到沒有變化,因為所有三棵樹都是相同的。
現在我們想修改這個檔案並提交它。我們將遵循相同的過程;首先,我們在工作目錄中修改檔案。我們稱之為檔案的 **v2** 版本,並用紅色表示。
如果我們現在執行 git status,會看到該檔案是紅色的,顯示為“Changes not staged for commit”(未暫存的更改),因為索引和工作目錄中的條目不同。接下來,我們對其執行 git add 將其暫存到索引。
此時,如果我們執行 git status,會在“Changes to be committed”(待提交的更改)下看到該檔案是綠色的,因為索引和 HEAD 不同——也就是說,我們提議的下一次提交現在與上次提交不同了。最後,我們執行 git commit 來完成提交。
git commit 步驟現在 git status 將不會有任何輸出,因為所有三棵樹再次相同。
切換分支或克隆也遵循類似的過程。當你檢出一個分支時,它會更新 **HEAD** 指向新的分支引用,用該提交的快照填充你的 **索引**,然後將 **索引** 的內容複製到你的 **工作目錄**。
Reset 的作用
在此背景下,reset 命令會變得更容易理解。
為了演示的方便,我們假設我們再次修改了 file.txt 並進行了第三次提交。現在我們的歷史看起來是這樣的:
現在讓我們一步步分析 reset 呼叫時確切的操作。它以一種簡單且可預測的方式直接操作這三棵樹。它最多執行三個基本操作。
步驟 1: 移動 HEAD
reset 首先會做的第一件事是移動 HEAD 所指向的物件。這與改變 HEAD 本身(checkout 所做的事情)不同;reset 移動的是 HEAD 所指向的分支。這意味著如果 HEAD 指向 master 分支(例如,你當前在 master 分支),執行 git reset 9e5e6a4 將首先使 master 指向 9e5e6a4。
無論你呼叫哪種形式的帶提交的 reset,它總是會先嚐試這個操作。對於 reset --soft,它會在此停止。
現在花點時間看看這個圖,理解發生了什麼:它基本上撤銷了最後一次 git commit 命令。當你執行 git commit 時,Git 建立一個新的提交併將 HEAD 指向的分支向上移動到這個新提交。當你 reset 回到 HEAD~(HEAD 的父提交)時,你將分支移回到原來的位置,而不改變索引或工作目錄。之後,你可以更新索引並再次執行 git commit 來完成 git commit --amend 所能做的事情(參見修改最後一次提交)。
步驟 2: 更新索引 (--mixed)
注意,如果你現在執行 git status,你會看到索引和新的 HEAD 之間的差異(以綠色顯示)。
reset 接下來要做的是用 HEAD 現在指向的快照的內容來更新索引。
如果你指定了 --mixed 選項,reset 將在此停止。這也是預設選項,所以如果你根本不指定任何選項(在本例中就是 git reset HEAD~),命令就會在此停止。
再花點時間看看這個圖,理解發生了什麼:它仍然撤銷了你最後一次 commit,而且還取消了所有暫存。你回滾到了執行所有 git add 和 git commit 命令之前的狀態。
步驟 3: 更新工作目錄 (--hard)
reset 要做的第三件事是使工作目錄看起來像索引。如果你使用了 --hard 選項,它將繼續進行到這一步。
所以,讓我們想想剛才發生了什麼。你撤銷了最後一次提交,git add 和 git commit 命令,以及你在工作目錄中完成的所有工作。
需要注意的是,這個標誌(--hard)是使 reset 命令危險的唯一方式,也是 Git 真正銷燬資料的極少數情況之一。其他任何形式的 reset 呼叫都可以很容易地撤銷,但 --hard 選項不行,因為它會強制覆蓋工作目錄中的檔案。在這種特定情況下,我們的檔案 **v3** 版本仍然在 Git 資料庫的一個提交中,我們可以透過檢視 reflog 來找回它,但如果我們還沒有提交它,Git 仍然會覆蓋檔案,而且將無法恢復。
回顧
reset 命令會按照特定順序覆蓋這三棵樹,並在你告訴它停止的地方停止。
-
移動 HEAD 指向的分支*(如果使用
--soft則在此停止)*。 -
使索引看起來像 HEAD*(除非使用
--hard否則在此停止)*。 -
使工作目錄看起來像索引。
帶路徑的 Reset
以上涵蓋了 reset 的基本行為,但你也可以提供一個路徑來對其進行操作。如果你指定了一個路徑,reset 將跳過步驟 1,並將剩餘操作限制在特定的檔案或一組檔案上。這實際上是有道理的——HEAD 只是一個指標,你不能同時指向一個提交的一部分和另一個提交的一部分。但是索引和工作目錄可以被部分更新,所以 reset 會繼續執行步驟 2 和 3。
所以,假設我們執行 git reset file.txt。這種形式(因為你沒有指定提交 SHA-1 或分支,也沒有指定 --soft 或 --hard)是 git reset --mixed HEAD file.txt 的簡寫,它將:
-
移動 HEAD 指向的分支*(已跳過)*。
-
使索引看起來像 HEAD*(在此停止)*。
所以它基本上只是將 HEAD 中的 file.txt 複製到索引。
這實際上起到了取消暫存檔案的作用。如果我們看看該命令的圖,並思考 git add 的作用,它們是完全相反的。
這就是為什麼 git status 命令的輸出會建議你執行此命令來取消暫存檔案(更多關於此內容請參見取消暫存已暫存的檔案)。
我們也可以透過指定一個特定的提交來讓 Git 不再假定我們指的是“從 HEAD 拉取資料”,而是從那個提交中拉取檔案版本。我們可以執行類似 git reset eb43bf file.txt 的命令。
這實際上與我們之前將檔案的內容還原到工作目錄中的 **v1**,然後執行 git add,再將其還原到 **v3**(而無需實際執行所有這些步驟)的效果相同。如果我們現在執行 git commit,它將記錄一個將該檔案還原到 **v1** 的更改,即使我們實際上從未在工作目錄中再次看到它。
還有一點也很有趣,與 git add 一樣,reset 命令也接受 --patch 選項,允許你逐個 hunks 地取消暫存內容。因此,你可以選擇性地取消暫存或還原內容。
合併提交 (Squashing)
讓我們看看如何利用這個新發現的能力做一些有趣的事情——合併提交。
假設你有一系列提交,提交資訊像是“oops.”、“WIP”和“forgot this file”。你可以使用 reset 將它們快速輕鬆地合併成一個讓你看起來很聰明的提交。 合併提交展示了另一種方法,但在本例中,使用 reset 更簡單。
假設你的專案中的第一次提交只有一個檔案,第二次提交添加了一個新檔案並修改了第一個檔案,第三次提交再次修改了第一個檔案。第二次提交是一個未完成的工作,你想把它合併掉。
你可以執行 git reset --soft HEAD~2 將 HEAD 分支移回一個較舊的提交(你想保留的最新提交)
然後簡單地再次執行 git commit
現在你可以看到,你的可達歷史(也就是你將要推送的歷史)看起來就像你有一個提交包含 file-a.txt **v1**,然後第二個提交同時將 file-a.txt 修改為 **v3** 並添加了 file-b.txt。包含檔案 **v2** 版本的提交已不再歷史記錄中。
檢出 (Check It Out)
最後,你可能想知道 checkout 和 reset 之間的區別。與 reset 類似,checkout 也操作這三棵樹,並且根據你是否為命令提供檔案路徑,其行為略有不同。
沒有路徑時
執行 git checkout [branch] 在很多方面與執行 git reset --hard [branch] 相似,因為它會更新所有三棵樹以匹配 [branch],但有兩個重要區別。
首先,與 reset --hard 不同,checkout 對工作目錄是安全的;它會檢查以確保不會覆蓋有更改的檔案。實際上,它更智慧一些——它會嘗試在工作目錄中進行簡單的合併,因此所有你*沒有*更改的檔案都會被更新。而 reset --hard 會直接替換所有檔案,不做任何檢查。
第二個重要區別是 checkout 如何更新 HEAD。reset 會移動 HEAD 所指向的分支,而 checkout 會移動 HEAD 本身來指向另一個分支。
例如,假設我們有 master 和 develop 分支,它們指向不同的提交,並且我們當前在 develop 分支(所以 HEAD 指向它)。如果我們執行 git reset master,develop 本身將指向與 master 相同的提交。如果我們執行 git checkout master,develop 不會移動,移動的是 HEAD 本身。HEAD 現在將指向 master。
因此,在這兩種情況下,我們都是將 HEAD 指向提交 A,但*方式*卻大不相同。reset 會移動 HEAD 所指向的分支,checkout 移動 HEAD 本身。
git checkout 和 git reset帶路徑時
checkout 的另一種用法是帶上檔案路徑,這與 reset 一樣,不會移動 HEAD。它就像 git reset [branch] file 一樣,它會用那個提交中的檔案更新索引,但它也會覆蓋工作目錄中的檔案。它就像 git reset --hard [branch] file 一樣(如果 reset 允許你那樣執行的話)——它對工作目錄不安全,並且不移動 HEAD。
同樣,與 git reset 和 git add 一樣,checkout 也接受 --patch 選項,允許你逐個 hunks 地選擇性地還原檔案內容。
總結
希望現在你對 reset 命令有了更好的理解和信心,但可能仍然對它與 checkout 的確切區別感到困惑,並且不可能記住所有不同調用的規則。
這裡有一個備忘單,說明哪些命令會影響哪些樹。“HEAD”列如果命令移動 HEAD 所指向的引用(分支),則顯示“REF”,如果命令移動 HEAD 本身,則顯示“HEAD”。請特別注意“WD Safe?”(工作目錄安全?)列——如果顯示為 **NO**,請在執行該命令前三思。
| HEAD | 索引 (Index) | 工作目錄 | WD Safe? | |
|---|---|---|---|---|
提交級別 |
||||
|
REF |
NO |
NO |
YES |
|
REF |
YES |
NO |
YES |
|
REF |
YES |
YES |
NO |
|
HEAD |
YES |
YES |
YES |
檔案級別 |
||||
|
NO |
YES |
NO |
YES |
|
NO |
YES |
YES |
NO |