-
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 |
上次提交的快照,下一個父提交 |
索引 |
建議的下一次提交快照 |
工作目錄 |
沙盒 |
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
命令是用於底層操作的“管道(plumbing)”命令,在日常工作中並不常用,但它們能幫助我們理解這裡發生了什麼。
索引
索引 是你建議的下一次提交。我們也將這個概念稱為 Git 的“暫存區”(Staging Area),因為當你執行 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 的典型工作流程是透過操作這三棵樹,以連續遞進的良好狀態記錄專案的快照。

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

此時,只有工作目錄樹包含內容。
現在我們想要提交這個檔案,因此我們使用 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 (在此停止)。
所以它實質上只是將 file.txt
從 HEAD 複製到索引。

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

這就是為什麼 git status
命令的輸出會建議你執行此命令來取消暫存檔案(更多資訊請參見取消暫存已暫存的檔案)。
我們也可以透過指定一個特定的提交來拉取檔案版本,而不讓 Git 假設我們指的是“從 HEAD 拉取資料”。我們只需執行類似 git reset eb43bf file.txt
的命令。

這實際上與我們在工作目錄中將檔案內容恢復到 v1,然後對其執行 git add
,接著又將其恢復到 v3 相同(而無需實際執行所有這些步驟)。如果我們現在執行 git commit
,它將記錄一個將該檔案恢復到 v1 的更改,即使我們從未在工作目錄中再次擁有過它。
另外值得注意的是,與 git add
類似,reset
命令也接受 --patch
選項,以逐個塊地取消暫存內容。因此,你可以選擇性地取消暫存或恢復內容。
合併提交
讓我們來看看如何利用這種新獲得的能力做些有趣的事情——合併提交(squashing commits)。
假設你有一系列提交,其訊息類似於“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 版本的提交不再存在於歷史記錄中。
檢出
最後,你可能會想 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
選項,允許你逐個塊地選擇性地恢復檔案內容。
總結
希望現在你已經理解並對 reset
命令感到更自在,但可能仍然對它與 checkout
的確切區別感到有些困惑,並且可能無法記住所有不同調用規則。
這裡有一份備忘錄,說明哪些命令影響哪些樹。“HEAD”列顯示“REF”表示該命令移動了 HEAD 所指向的引用(分支),顯示“HEAD”表示它移動了 HEAD 本身。請特別注意“WD Safe?”(工作目錄安全?)列——如果顯示否,請在執行該命令前三思。
HEAD | 索引 | 工作目錄 | 工作目錄安全? | |
---|---|---|---|---|
提交級別 |
||||
|
引用 |
否 |
否 |
是 |
|
引用 |
是 |
否 |
是 |
|
引用 |
是 |
是 |
否 |
|
HEAD |
是 |
是 |
是 |
檔案級別 |
||||
|
否 |
是 |
否 |
是 |
|
否 |
是 |
是 |
否 |