Git
章節 ▾ 第二版

7.7 Git 工具 - 重設機制解密

重設機制解密

在繼續討論更專業的工具之前,讓我們先來談談 Git 的 resetcheckout 指令。這兩個指令是初次接觸 Git 時最令人困惑的部分。它們的功能太多,以至於似乎不可能真正理解並正確使用它們。為此,我們建議使用一個簡單的比喻。

三棵樹

思考 resetcheckout 的更簡單方法是透過 Git 作為三個不同樹狀結構的內容管理器的思維框架。這裡的「樹狀結構」實際上指的是「檔案集合」,而不是特定的資料結構。在某些情況下,索引的行為並不完全像樹狀結構,但為了我們的目的,現在這樣思考比較容易。

Git 作為一個系統,在其正常運作中管理和操作三個樹狀結構

樹狀結構 角色

HEAD

最後一次提交的快照,下一個父級

索引

建議的下一次提交快照

工作目錄

沙箱

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 指令是「底層」指令,用於較低階的事情,在日常工作中並非真正使用,但它們有助於我們了解這裡發生的事情。

索引

索引 是您的建議下一次提交。我們也一直將這個概念稱為 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,這是一個更底層的指令,會向您顯示目前索引的樣子。

索引在技術上並非樹狀結構,它實際上是以扁平化的清單來實作,但為了我們的目的,這樣理解已足夠接近。

工作目錄

最後,您會有您的工作目錄 (也常被稱為「工作樹」)。另外兩個樹狀結構將它們的內容以有效率但不方便的方式儲存在 .git 資料夾中。工作目錄會將它們解壓縮成實際的檔案,讓您更容易編輯它們。您可以把工作目錄想像成一個沙箱,您可以在這裡嘗試變更,然後再將它們提交到您的暫存區 (索引),最後提交到歷史紀錄中。

$ tree
.
├── README
├── Rakefile
└── lib
    └── simplegit.rb

1 directory, 3 files

工作流程

Git 的典型工作流程是透過操作這三個樹狀結構,來記錄您的專案在逐漸變好的狀態下的快照。

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

讓我們將這個過程視覺化:假設您進入一個新目錄,其中只有一個檔案。我們將這個檔案稱為 v1,並以藍色表示。現在我們執行 git init,它會建立一個 Git 儲存庫,其中有一個 HEAD 參考,指向尚未誕生的 master 分支。

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,我們會看到該檔案以紅色顯示為「未暫存以供提交的變更」,因為索引和工作目錄之間的該項目有所不同。接下來,我們對它執行 git add,將其暫存到我們的索引中。

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

此時,如果我們執行 git status,我們會看到該檔案以綠色顯示在「要提交的變更」下,因為索引和 HEAD 不同 — 也就是說,我們建議的下一個提交現在與我們上次的提交不同。最後,我們執行 git commit 來完成提交。

The `git commit` step with changed file
圖 143. 已變更檔案的 git commit 步驟

現在 git status 不會給我們任何輸出,因為三個樹狀結構再次相同。

切換分支或複製會經過類似的流程。當您切換分支時,它會變更 HEAD 以指向新的分支參考,用該提交的快照填滿您的索引,然後將索引的內容複製到您的工作目錄中。

重設的角色

在這個背景下檢視 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. 軟重設

無論您以哪種形式呼叫帶有提交的 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 會在此時停止。這也是預設值,因此如果您完全不指定任何選項 (在本例中僅為 git reset HEAD~),則命令會在此處停止。

現在再花點時間看看該圖表,並了解發生了什麼事:它仍然還原了您上次的 commit,但也取消暫存了所有內容。您回滾到執行所有 git addgit commit 命令之前。

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

reset 會做的第三件事是讓工作目錄看起來像索引。如果您使用 --hard 選項,它會繼續到這個階段。

Hard reset
圖 147. 硬重設

所以讓我們思考一下剛剛發生了什麼事。您還原了您上次的提交、git addgit commit 命令,以及您在工作目錄中所做的所有工作。

重要的是要注意,這個旗標 (--hard) 是使 reset 命令變得危險的唯一方法,也是 Git 實際會破壞資料的極少數情況之一。任何其他 reset 的呼叫都可以很容易地還原,但 --hard 選項無法還原,因為它會強制覆寫工作目錄中的檔案。在這個特定的案例中,我們仍然在 Git DB 中的提交中擁有檔案的 v3 版本,我們可以透過查看我們的 reflog 來取回它,但是如果我們沒有提交它,Git 仍然會覆寫該檔案,並且它將無法復原。

重點回顧

reset 命令會以特定順序覆寫這三個樹狀結構,並在您告知它停止時停止

  1. 移動 HEAD 指向的分支 (如果為 --soft 則在此處停止)

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

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

使用路徑重設

這涵蓋了 reset 的基本形式的行為,但您也可以向其提供要作用的路徑。如果您指定路徑,reset 將會跳過步驟 1,並將其餘的動作限制在特定的檔案或一組檔案。這實際上有點道理 — HEAD 只是一個指標,您不能指向一個提交的一部分和另一個提交的一部分。但是索引和工作目錄可以部分更新,因此重設會繼續執行步驟 2 和 3。

因此,假設我們執行 git reset file.txt。這種形式 (因為您沒有指定提交 SHA-1 或分支,而且您沒有指定 --soft--hard) 是 git reset --mixed HEAD file.txt 的簡寫,它會

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

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

因此它基本上只是將 file.txt 從 HEAD 複製到索引。

Mixed reset with a path
圖 148. 使用路徑的混合重設

這具有取消暫存檔案的實際效果。如果我們查看該命令的圖表並思考 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. 使用特定提交的路徑進行軟重設

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

還有趣的是,就像 git add 一樣,reset 命令會接受 --patch 選項,以逐個區塊的方式取消暫存內容。因此您可以選擇性地取消暫存或還原內容。

擠壓

讓我們看看如何使用這種新發現的功能來做一些有趣的事情 — 擠壓提交。

假設您有一系列的提交,其中包含類似「哎呀」、「WIP」和「忘記這個檔案」的訊息。您可以使用 reset 來快速輕鬆地將它們擠壓成一個讓您看起來非常聰明的提交。 擠壓提交 顯示了另一種方法來執行此操作,但在這個範例中,使用 reset 會更簡單。

假設您有一個專案,其中第一個提交有一個檔案,第二個提交新增了一個新檔案並變更了第一個檔案,而第三個提交再次變更了第一個檔案。第二個提交是一個進行中的工作,您想要將其擠壓。

Git repository
圖 151. Git 儲存庫

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

Moving HEAD with soft reset
圖 152. 使用軟重設移動 HEAD

然後只需再次執行 git commit

Git repository with squashed commit
圖 153. 具有擠壓提交的 Git 儲存庫

現在您可以看到,您可存取的歷史記錄 (您將推送的歷史記錄) 現在看起來像是您有一個包含 file-a.txt v1 的提交,然後是第二個將 file-a.txt 修改為 v3 並新增 file-b.txt 的提交。包含檔案 v2 版本的提交不再在歷史記錄中。

檢視看看

最後,你可能會想知道 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 選項,讓你能夠以逐塊的方式選擇性地還原檔案內容。

總結

希望現在你理解了 reset 命令,並且使用起來更自在,但你可能仍然對它與 checkout 的確切差異感到有些困惑,而且不可能記住所有不同調用的規則。

這裡有一個速查表,說明哪些命令會影響哪些樹狀結構。「HEAD」欄位如果該命令會移動 HEAD 指向的參考(分支)則顯示「REF」,如果移動 HEAD 本身則顯示「HEAD」。請特別注意「工作目錄安全?」欄位 — 如果顯示,請在執行該命令之前三思而後行。

HEAD 索引 工作目錄 工作目錄安全?

提交層級

reset --soft [commit]

REF

reset [commit]

REF

reset --hard [commit]

REF

checkout <commit>

HEAD

檔案層級

reset [commit] <paths>

checkout [commit] <paths>

scroll-to-top