# 前言

git reset ,一個之前文章時不時會出現的字詞,但當時文章又一直不認真說明。

看看這篇文章右手邊的滾軸有多小,你大概就能體諒我為什麼當初對 reset 一直要說不說的了 Orz…

在開始介紹 reset 用途之前,有幾個先備知識複習一下,對後面的內容理解可能會有幫助。因為觀念很重要,後續還是會繼續提及。

之前把 「暫存區 (索引)」比喻成「購物車」,接著我們把 「提交版本」 比喻成 「商品下單」,這可能讓讀者以為「提交版本」之後,「索引」就會被清空。

不過在 Git 中,提交版本的行為「不會」清空索引裡面記錄的資料!

提交版本 更貼切的想像,應該是把 commit 想成一個紙箱,即便紙箱中「有東西」,我們也看不到裡面的東西。

所以提交版本 ( git commit ) 的行為,比較像是拿紙箱把商品打包那樣,把「暫存區的資料」封裝起來,讓我們在 「暫存區」看不到資料。

這些被打包的資料依舊存在暫存區中,只是我們不用在意他,但如果有人把這個箱子拆掉,我們將會重新看到這些資料。

會需要有這層先備知識的原因是, git reset 就是一個能對 commit暫存區 動手腳的指令。

整篇文章的內容,我們會持續使用這個觀念,有了這個觀念之後,可能也比較能夠體會 reset 究竟是在玩什麼把戲!

由於 git reset 需要搭配實際情境會比較好理解,所以觀念跟指令會夾雜在一起說明,整個講解完之後,會再帶到 Fork GUI 的操作方式。

請 GUI 派的讀者別擔心, reset 的指令本身並不複雜,只要先搞懂 reset 是怎麼處理資料,在 GUI 中就可以輕鬆操作 reset 了!

話不多說,進入今天的主題:

搭上 reset 時光機,讓資料回到過去

# git reset

git reset 在 Git 中最主要的用途,是將 HEAD 移動到其他 commit 上,以達到「回到過去」的作用。

聽起來可能有點難懂,讓我一步一步來說明 reset 的用意。
reset 照著字面翻譯成中文,可以叫做 重新設定 ,或簡稱 重置,代表著他是用來幫我們把時間重置到某些動作「之前」的狀態。

在操作 Git 時,一般會經歷下面幾個步驟:

  1. 編輯好一些檔案。
  2. 執行 git add 把這些檔案加到暫存區。
  3. 執行 git commit 提交一個版本。
  4. Git 線圖會出現剛剛 commit 的版本點點。

假設今天這個動作執行了五次,也就是讓 Git 紀錄五個版本。
就叫 A => B => C => D => E 好了,所以此時的 HEAD 指著版本 E

使用 reset 指令可以讓 HEAD 倒退回去 ABCD 任何一版。
如果移回 D ,就像搭著時光機回到了 D 的時間點,所以 Git 會把版本 EGit 線圖 中拆掉。

舉一反三,退回 D 會拿掉 E 的話…
那麼退回 B 呢?
沒錯! CDE 三個 commit 箱子都會被拆掉。

這感覺有點像疊疊樂積木,當積木疊到最高點的時候, HEAD 會在最上層的積木堆中。
而把 HEAD 往前移的行為,就代表著要把上面的積木拿掉才行。

拿掉積木示意圖
(再加上箱子的概念… 那應該會變成箱子疊疊樂?!)

在知道 commit 會因為 reset 而被拆掉之後,下一個要擔心的事情應該會是:
資料呢?原本紀錄在 commit 的那些資料會不見嗎…?

可能會,也可能不會。

容我賣個關子,先讓我來介紹 git reset 的語法,至於資料何去何從,後續會慢慢揭曉。

# git reset 語法

git reset commitID 模式

或是

git reset 模式 commitID

這個指令中有兩個參數是可變的:

  1. commitID
    commitID 的預設值是 HEAD ,如果要 reset 回 HEAD ,這個值可以省略。
    如同之前 聊聊 commitID 與 HEAD 文章所說明的觀念, commitID 可以用「哈希值」,也可以使用 「HEAD 操作相對位置」。

  2. 模式
    reset 指令有許多模式能使用,預設模式是 mixed,一樣如果想使用 --mixed 模式,這個值可以省略不寫。

綜合上述的預設值來說,如果我們只寫 git reset 時,實際會執行這個指令:

git reset --mixed HEAD

# git reset 常見三種使用模式

我們使用 reset 回到過去的當下,因為有些 commit 箱子被拆了,所以 Git 手上會拎著原本被這些 commit 包裝的那些資料,Git 需要知道這些內容該被還原,還是直接丟掉。

就像上面講的積木那樣,把上頭積木一層一層拿掉,拎在手中的積木何去何從,會是個大問題。

To be or not to be. That is a question. - 莎士比亞《哈姆雷特》

為了處理資料的去留,Git 提供幾種參數讓我們操作,其中最常見的有三種: --mixed--soft--hard

可以用 git reset -h 指令來看看 reset 的參數說明:
(這裡只擷取常用三種模式)

$ git reset -h
    --mixed               reset HEAD and index
    --soft                reset only HEAD
    --hard                reset HEAD, index and working tree

可以注意到,不管哪一種模式,都會重置 (reset) HEAD,其實就是在講上面那個積木的概念, reset 會移動 HEAD 指向 commit 的意思。

除了一定會做的「移動 HEAD」 之外,依據不同的模式,可能還會有重置索引 (index),以及重置工作目錄 (working tree) 的動作。

接著就來給大家翻譯翻譯,什麼叫做 驚喜 重置索引重置工作目錄

# git reset --mixed

如同在介紹語法的時候說的,這是預設模式,要使用的話參數能省略。

根據定義, --mixed 模式會重置 HEAD 以及 index

  1. 重置 HEAD:表示要上 HEAD 移動到特定 commit,這大家應該都知道了。
  2. 重置 index:意思就是就是把資料從索引中清除。

根據前言的內容,commit 箱子被拆掉之後,資料其實還是留在暫存區的。
預設模式除了把箱子拆掉之外,他額外還會把資料「移出暫存區」。

這就會使資料呈現一個「剛編輯完」的狀態。
(對吧?!剛編輯完的資料,是沒加入暫存區 (購物車、索引) 的狀態,所以跟被移出暫存區的資料是一樣的沒錯吧?!)

要強調的是, --mixed 模式 「不會異動」 工作目錄的資料!
就是說執行完 reset 預設模式之後,工作目錄的檔案 完全不會 被改變!

以實際的例子來看看到底發生了什麼事情:
目前 Git 記錄著兩個 commit ,一個是 initial commit ,另一個是 add style.css

第二個 commit 中有兩個檔案,分別是 readme.txtstyle.css

執行 git reset HEAD^ 之後, git status 會這麼顯示:

紅字的內容就是原本 commit 所記錄的資料,由於少了 commit 當載體,又被移出了索引 (怎麼聽起來很可憐),於是成了 未暫存 的狀態了。

# git reset --soft

根據定義,soft 模式只會重置 HEAD

這個模式就很好理解了,使用 --soft 模式把 HEAD 往前移動之後,多出來的 commit 就只會被拆掉,但資料繼續留在暫存區 (索引)。

我們繼續用剛剛的例子執行 git reset HEAD^ --soft 之後來看狀態吧:

soft 模式執行 reset ,雖然資料也是少了 commit 裝箱,不過 soft 不會去異動索引,所以 git status 會告訴我們「欸!你還有資料在暫存區,是可以 commit 的哦」。

這個情況就很像「已經執行 git add . ,但還沒執行 git commit 」 的時間點了。

一樣也是強調一下,「柔軟模式」因為只移動 HEAD,所以他也不會改變工作目錄的檔案。

# git reset --hard

根據定義,hard 模式同時會重置 HEADindex 還有 Working Tree

這個模式我們就不要講拆箱子或是索引有沒有資料了,最簡單的理解方式是這樣:

使用 hard 模式之後,資料會 完整呈現 指令寫的那個 commit 資料,不會有絲毫差異。
如果你看不懂什麼叫「指令寫的那個 commit」,就是在說這個:

git reset --hard commitID  # <= 就是在說這個 commit

言下之意,–hard 模式就是要把工作目錄狀態直接還原到這個 commit 剛提交完的狀態。

所以不會有任何資料還是 暫存 或是 未暫存 的狀態。
這時候執行 git status 就是會直接告訴你 nothing to commit

實際來看一下,圖中的第一個 commit 只有 readme.txt 一個檔案,之後的 commit 有什麼東西不重要。 反正等等就看不到了

執行 git reset commiID --hard 後 (註 1) ,工作目錄中就只會出現 reset 過去 commit 的資料。

註 1. reset 指令除了操作 HEAD 之外,也可以直接使用 commitID 來指定要重置的 commit,這裡刻意示範使用 commitID 的指令,這個情境因為是退回前一個版本,所以也可以使用 git reset HEAD^ --hard 做到一樣的效果。

# git reset 模式整理

用一個表格來整理操作 reset 時,那些 commit 箱子被拆掉之後,工作目錄與索引會處理資料的方式。

模式 mixed (預設) soft hard
工作目錄 保留 保留 捨棄
索引 (暫存區) 捨棄 保留 捨棄

一下索引一下工作目錄的,可能已經有人看得頭都暈了。

讓我再換種方式說明,這次試著用「回溯動作」的方式來看看三種模式的差異在哪。

假設我們的操作是:

  1. 提交 (commit) 版本 A
  2. 編輯好某些檔案的資料
  3. 將資料加入暫存區
  4. 提交 (commit) 版本 B <= 此時 HEAD 指著他

從版本 B 以不同的模式 reset 回版本 A ,剛好會對應到不同的情境

  • soft 模式:回到第 3 步,剛把資料加入暫存區的狀態。
  • --mixed 模式:回到第 2 步,剛編輯完某些檔案的狀態。
  • hard 模式:回到第 1 步,剛提交完版本 A 的狀態。

# git reset 模式使用情境

說了很多模式,但如果不搭配情境,或許還是有人想像不到 reset 能幹嘛。
這裡簡單說幾個 reset 可能會用到的情境。

# 1. 移除最新的 commit

記得昨天的 –amend 文章就有提到,要移除最近一次 commit 其中一個檔案,使用 reset 反而比較方便。

由於直接使用 reset 預設模式,不會影響到工作目錄的資料,他又能幫我們將資料移出暫存區,如同幫我們把時光回溯到檔案編輯完的狀態。

言下之意,執行完這個模式的 reset 之後,我們可以重新決定 commit 要提交哪些檔案,甚至資料的內容、提交的說明都可以重新來過。

而這個彈性很大的指令,就長這樣:

git reset HEAD^

拆掉 commit 箱子,讓 HEAD 回到上一版。

# 2. 想捨棄所有修改的資料

前天的文章有提到,我們可以用 git restore . 或是 git checkout -- . 捨棄所有未暫存的資料。

不過這個需求使用 hard reset ,可能會更直覺。

git reset HEAD --hard  # HEAD 可以省略不寫

因為 --hard 模式可以直接還原到某個 commit 「剛提交完」的狀態,指令執行完之後,不會有任何資料會是 暫存 或是 未暫存

剛好 reset 預設的位置就是 HAED ,執行上面指令代表位置不動,但直接幫我 捨棄一切 異動,如此一來就達成捨棄所有修改資料的目的了。

# 3. 想在最近一個版本中加一個檔案

這需求用上一篇文章的 --amend 就可以處理。

如果想用 reset 處理的話,除了使用 --mixed 模式,也可以使用 soft 模式。
不過如果只是要追加檔案,用 soft 模式可能會方便一些。

git reset HEAD^ --soft

聰明如你可能會說:「有差嗎?就算使用 --mixed 模式,我直接執行 git add . 就好啦!」

那麼假設,你的工作目錄的資料,還有很多剛新增的「未追蹤」(Untracked) 的檔案,是短時間還沒打算 commit 的呢?
如果直接 git add . ,這堆沒有要 commit 的資料,也會一次加到暫存區哦…

在這種狀況下使用 --mixed 模式,可能就需要一筆一筆執行 git add 把想 commit 的檔案加到暫存區。

不過如果使用 soft 模式,因為不會把原本的資料移出暫存區,就只需要用 git add一個 要追加的檔案,就結束了。

既然資料最後都還是要加回暫存區,就別大費周章去用 mixed 先移除又加入了吧!

# 使用 Fork GUI 來操作 reset

來介紹一下怎麼使用 Fork 操作 reset 的動作。

  1. 對著想 reset 的 commit 點右鍵

  2. 選擇 Reset ‘目前分支名’ to Here

  3. 選擇 reset 模式

  4. 完成

有沒有發現觀念看完,GUI 的操作根本就不會有疑問 XD

# 常見問題

# 1. 已經被移除的 commit ,還回得來嗎?

神隱少女中的湯婆婆說過一句話:「曾經發生的事不可能忘記,只是想不起來而已」

Git 的世界也是一樣 (哪裡一樣!),只要 commit 過的內容不會任意消失,除非你想不起來原本的 commitID…

如果真的不小心「後悔」 reset 的行為,有兩個復原方式:

第一種:用原本的 commitID 執行 hard reset

  1. 尋找原本的 commitID:
    應該沒人會在 reset 之前先去記 ID,用 reflog 找回來吧。
git reflog -5

reflog 是一個可以查看 HEAD 更變過程的特殊 log,執行跟 HEAD 有關的指令時,它就會記錄一筆,而 reset 就是這類的指令。
如果 reset 後忘記原本的 commitID ,用這個指令就可以查到。
指令後面多帶一個 -5 參數,是為了讓 reflog 列出最近五筆即可。

  1. 用這個 ID 執行 reset hard 模式
git reset --hard commitID

第二種:用 ORIG_HEAD 執行 hard reset
ORIG_HEAD 是 Git 的一個特殊指標,當我們執行一些 可能造成資料遺失 的指令時,ORIG_HEAD 就會自動幫我們記錄原先的 commitID,執行 reset 就是一個例子 (註 1)。

如果不慎執行 reset 希望還原回去時,可以直接用 ORIG_HEAD 來執行 resethard 模式:

git reset  --hard  ORIG_HEAD

註 1. 除了 reset 指令之外,其他還有 merge 、 rebase 等操作,ORIG_HEAD 都會幫我們記錄原本的 CommitID

# 2. reset 指令有什麼需要注意的地方?

有,reset 之後又重新提交的 commit,ID 不會相同。

git resetgit commit --amend 一樣都是會更改歷史的指令之一。

在講 --amend 時就有提到一個觀念,但必須再重申一次:

不要隨意修改已經推到遠端儲存庫的 commit

如果 commit 從沒推到遠端,想怎麼操作其實都不會影響到別人。
不過如果是跟他人共用的分支,隨意更改歷史之後,force push 回遠端,極有可能造成團隊成員的不便,不可不慎!!!

# 後記

reset 是個讓我又愛又恨的指令,當初為了搞懂他不知道燒爛了多少腦細胞…
什麼索引,什麼工作目錄,又捨棄又保留的,到底什麼意思…

在實際搞懂之後,發現 reset 除了上面說的那些基礎用途之外,還可以完很多花俏的操作,非常有趣 XD

文章刻意用好幾種角度來解釋 reset 的觀念,很希望能把自己的學習經驗,讓想搞懂 reset 的人能「感受」到 reset 能做到的事情。