章節 ▾ 第二版

7.7 Git 工具 - Reset 詳解

Reset 詳解

在深入更專業的工具之前,我們先來討論 Git 的 resetcheckout 命令。這應該是 Git 中最容易讓人感到困惑的部分。它們能做的事情太多了,以至於我們似乎很難真正理解並恰當地使用它們。為了幫助你理解,我們推薦一個簡單的比喻。

三棵樹

理解 resetcheckout 的一個更簡單的方法是,將 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-filels-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 的典型工作流程是透過操作這三棵樹,以逐步改進的狀態記錄專案的快照。

Git’s typical workflow
圖 137. Git 的典型工作流程

讓我們視覺化這個過程:假設你進入一個新目錄,裡面只有一個檔案。我們稱之為檔案的 **v1** 版本,並用藍色表示。現在我們執行 git init,它會建立一個 Git 倉庫,並有一個指向未出生 master 分支的 HEAD 引用。

Newly-initialized Git repository with unstaged file in the working directory
圖 138. 新初始化的 Git 倉庫,工作目錄中有一個未暫存的檔案

此時,只有工作目錄樹包含內容。

現在我們想提交這個檔案,所以我們使用 git add 將工作目錄中的內容複製到索引。

File is copied to index on `git add`
圖 139. git add 操作將檔案複製到索引

然後我們執行 git commit,它會將索引的內容儲存為一個永久的快照,建立一個指向該快照的提交物件,並更新 master 指標指向該提交。

The `git commit` step
圖 140. git commit 步驟

如果我們執行 git status,會看到沒有變化,因為所有三棵樹都是相同的。

現在我們想修改這個檔案並提交它。我們將遵循相同的過程;首先,我們在工作目錄中修改檔案。我們稱之為檔案的 **v2** 版本,並用紅色表示。

Git repository with changed file in the working directory
圖 141. Git 倉庫,工作目錄中有一個已修改的檔案

如果我們現在執行 git status,會看到該檔案是紅色的,顯示為“Changes not staged for commit”(未暫存的更改),因為索引和工作目錄中的條目不同。接下來,我們對其執行 git add 將其暫存到索引。

Staging change to index
圖 142. 暫存更改到索引

此時,如果我們執行 git status,會在“Changes to be committed”(待提交的更改)下看到該檔案是綠色的,因為索引和 HEAD 不同——也就是說,我們提議的下一次提交現在與上次提交不同了。最後,我們執行 git commit 來完成提交。

The `git commit` step with changed file
圖 143. 帶有已修改檔案的 git commit 步驟

現在 git status 將不會有任何輸出,因為所有三棵樹再次相同。

切換分支或克隆也遵循類似的過程。當你檢出一個分支時,它會更新 **HEAD** 指向新的分支引用,用該提交的快照填充你的 **索引**,然後將 **索引** 的內容複製到你的 **工作目錄**。

Reset 的作用

在此背景下,reset 命令會變得更容易理解。

為了演示的方便,我們假設我們再次修改了 file.txt 並進行了第三次提交。現在我們的歷史看起來是這樣的:

Git repository with three commits
圖 144. 包含三次提交的 Git 倉庫

現在讓我們一步步分析 reset 呼叫時確切的操作。它以一種簡單且可預測的方式直接操作這三棵樹。它最多執行三個基本操作。

步驟 1: 移動 HEAD

reset 首先會做的第一件事是移動 HEAD 所指向的物件。這與改變 HEAD 本身(checkout 所做的事情)不同;reset 移動的是 HEAD 所指向的分支。這意味著如果 HEAD 指向 master 分支(例如,你當前在 master 分支),執行 git reset 9e5e6a4 將首先使 master 指向 9e5e6a4

Soft reset
圖 145. Soft reset (軟重置)

無論你呼叫哪種形式的帶提交的 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
圖 146. Mixed reset (混合重置)

如果你指定了 --mixed 選項,reset 將在此停止。這也是預設選項,所以如果你根本不指定任何選項(在本例中就是 git reset HEAD~),命令就會在此停止。

再花點時間看看這個圖,理解發生了什麼:它仍然撤銷了你最後一次 commit,而且還取消了所有暫存。你回滾到了執行所有 git addgit commit 命令之前的狀態。

步驟 3: 更新工作目錄 (--hard)

reset 要做的第三件事是使工作目錄看起來像索引。如果你使用了 --hard 選項,它將繼續進行到這一步。

Hard reset
圖 147. Hard reset (硬重置)

所以,讓我們想想剛才發生了什麼。你撤銷了最後一次提交,git addgit commit 命令,以及你在工作目錄中完成的所有工作。

需要注意的是,這個標誌(--hard)是使 reset 命令危險的唯一方式,也是 Git 真正銷燬資料的極少數情況之一。其他任何形式的 reset 呼叫都可以很容易地撤銷,但 --hard 選項不行,因為它會強制覆蓋工作目錄中的檔案。在這種特定情況下,我們的檔案 **v3** 版本仍然在 Git 資料庫的一個提交中,我們可以透過檢視 reflog 來找回它,但如果我們還沒有提交它,Git 仍然會覆蓋檔案,而且將無法恢復。

回顧

reset 命令會按照特定順序覆蓋這三棵樹,並在你告訴它停止的地方停止。

  1. 移動 HEAD 指向的分支*(如果使用 --soft 則在此停止)*。

  2. 使索引看起來像 HEAD*(除非使用 --hard 否則在此停止)*。

  3. 使工作目錄看起來像索引。

帶路徑的 Reset

以上涵蓋了 reset 的基本行為,但你也可以提供一個路徑來對其進行操作。如果你指定了一個路徑,reset 將跳過步驟 1,並將剩餘操作限制在特定的檔案或一組檔案上。這實際上是有道理的——HEAD 只是一個指標,你不能同時指向一個提交的一部分和另一個提交的一部分。但是索引和工作目錄可以被部分更新,所以 reset 會繼續執行步驟 2 和 3。

所以,假設我們執行 git reset file.txt。這種形式(因為你沒有指定提交 SHA-1 或分支,也沒有指定 --soft--hard)是 git reset --mixed HEAD file.txt 的簡寫,它將:

  1. 移動 HEAD 指向的分支*(已跳過)*。

  2. 使索引看起來像 HEAD*(在此停止)*。

所以它基本上只是將 HEAD 中的 file.txt 複製到索引。

Mixed reset with a path
圖 148. 帶路徑的 Mixed reset

這實際上起到了取消暫存檔案的作用。如果我們看看該命令的圖,並思考 git add 的作用,它們是完全相反的。

Staging file to index
圖 149. 將檔案暫存到索引

這就是為什麼 git status 命令的輸出會建議你執行此命令來取消暫存檔案(更多關於此內容請參見取消暫存已暫存的檔案)。

我們也可以透過指定一個特定的提交來讓 Git 不再假定我們指的是“從 HEAD 拉取資料”,而是從那個提交中拉取檔案版本。我們可以執行類似 git reset eb43bf file.txt 的命令。

Soft reset with a path to a specific commit
圖 150. 帶路徑到特定提交的 Soft reset

這實際上與我們之前將檔案的內容還原到工作目錄中的 **v1**,然後執行 git add,再將其還原到 **v3**(而無需實際執行所有這些步驟)的效果相同。如果我們現在執行 git commit,它將記錄一個將該檔案還原到 **v1** 的更改,即使我們實際上從未在工作目錄中再次看到它。

還有一點也很有趣,與 git add 一樣,reset 命令也接受 --patch 選項,允許你逐個 hunks 地取消暫存內容。因此,你可以選擇性地取消暫存或還原內容。

合併提交 (Squashing)

讓我們看看如何利用這個新發現的能力做一些有趣的事情——合併提交。

假設你有一系列提交,提交資訊像是“oops.”、“WIP”和“forgot this file”。你可以使用 reset 將它們快速輕鬆地合併成一個讓你看起來很聰明的提交。 合併提交展示了另一種方法,但在本例中,使用 reset 更簡單。

假設你的專案中的第一次提交只有一個檔案,第二次提交添加了一個新檔案並修改了第一個檔案,第三次提交再次修改了第一個檔案。第二次提交是一個未完成的工作,你想把它合併掉。

Git repository
圖 151. Git 倉庫

你可以執行 git reset --soft HEAD~2 將 HEAD 分支移回一個較舊的提交(你想保留的最新提交)

Moving HEAD with soft reset
圖 152. Soft reset 移動 HEAD

然後簡單地再次執行 git commit

Git repository with squashed commit
圖 153. Git 倉庫,合併後的提交

現在你可以看到,你的可達歷史(也就是你將要推送的歷史)看起來就像你有一個提交包含 file-a.txt **v1**,然後第二個提交同時將 file-a.txt 修改為 **v3** 並添加了 file-b.txt。包含檔案 **v2** 版本的提交已不再歷史記錄中。

檢出 (Check It Out)

最後,你可能想知道 checkoutreset 之間的區別。與 reset 類似,checkout 也操作這三棵樹,並且根據你是否為命令提供檔案路徑,其行為略有不同。

沒有路徑時

執行 git checkout [branch] 在很多方面與執行 git reset --hard [branch] 相似,因為它會更新所有三棵樹以匹配 [branch],但有兩個重要區別。

首先,與 reset --hard 不同,checkout 對工作目錄是安全的;它會檢查以確保不會覆蓋有更改的檔案。實際上,它更智慧一些——它會嘗試在工作目錄中進行簡單的合併,因此所有你*沒有*更改的檔案都會被更新。而 reset --hard 會直接替換所有檔案,不做任何檢查。

第二個重要區別是 checkout 如何更新 HEAD。reset 會移動 HEAD 所指向的分支,而 checkout 會移動 HEAD 本身來指向另一個分支。

例如,假設我們有 masterdevelop 分支,它們指向不同的提交,並且我們當前在 develop 分支(所以 HEAD 指向它)。如果我們執行 git reset masterdevelop 本身將指向與 master 相同的提交。如果我們執行 git checkout masterdevelop 不會移動,移動的是 HEAD 本身。HEAD 現在將指向 master

因此,在這兩種情況下,我們都是將 HEAD 指向提交 A,但*方式*卻大不相同。reset 會移動 HEAD 所指向的分支,checkout 移動 HEAD 本身。

`git checkout` and `git reset`
圖 154. git checkoutgit reset

帶路徑時

checkout 的另一種用法是帶上檔案路徑,這與 reset 一樣,不會移動 HEAD。它就像 git reset [branch] file 一樣,它會用那個提交中的檔案更新索引,但它也會覆蓋工作目錄中的檔案。它就像 git reset --hard [branch] file 一樣(如果 reset 允許你那樣執行的話)——它對工作目錄不安全,並且不移動 HEAD。

同樣,與 git resetgit add 一樣,checkout 也接受 --patch 選項,允許你逐個 hunks 地選擇性地還原檔案內容。

總結

希望現在你對 reset 命令有了更好的理解和信心,但可能仍然對它與 checkout 的確切區別感到困惑,並且不可能記住所有不同調用的規則。

這裡有一個備忘單,說明哪些命令會影響哪些樹。“HEAD”列如果命令移動 HEAD 所指向的引用(分支),則顯示“REF”,如果命令移動 HEAD 本身,則顯示“HEAD”。請特別注意“WD Safe?”(工作目錄安全?)列——如果顯示為 **NO**,請在執行該命令前三思。

HEAD 索引 (Index) 工作目錄 WD Safe?

提交級別

reset --soft [commit]

REF

NO

NO

YES

reset [commit]

REF

YES

NO

YES

reset --hard [commit]

REF

YES

YES

NO

checkout <commit>

HEAD

YES

YES

YES

檔案級別

reset [commit] <paths>

NO

YES

NO

YES

checkout [commit] <paths>

NO

YES

YES

NO