快轉到主要內容
  1. 教學文章/

Git reset、restore、revert 實戰:選對復原工具

·9 分鐘· loading · loading · ·
Git Reset Restore Revert Undo Version-Control Developer-Tools
每日拍拍
作者
每日拍拍
科學家 X 科技宅宅
目錄
版本控制: Git - 本文屬於一個選集。
§ 15: 本文

featured

一. 前言:Git 最難的不是撤銷,而是先回答「你要撤銷哪一層?」
#

你剛改壞一個檔案,想回到上一版。 你不小心把五個檔案全都 git add,其實只想提交其中兩個。 你剛 commit 才發現內容分錯包;更刺激的是,那顆 commit 已經推到大家共用的 main。 這四種情況都可以口語化地說成「我想復原」,但適合的 Git 指令完全不同。

  • git restore 主要處理檔案內容與 staging area;
  • git reset 主要移動目前 branch 的 HEAD,也能重設 index;
  • git revert 不刪歷史,而是新增一顆反向 commit。 真正的選擇條件不是「哪個名字聽起來最像 undo」,而是兩個問題:
  1. 變更目前位於 working tree、index,還是 commit history?
  2. 那段歷史只有你自己看過,還是已經分享給其他人? 今天拍拍君會先建立三層心智模型,再用一個可重做的實驗 repo,分別演練 restoreresetrevert。 本文不會重講如何從消失的 commit 中救資料;那是 Git reflog 救援實戰 的範圍。

二. 先看懂三層狀態:Working Tree、Index、Commit
#

Git 的檔案不是只有「有存檔」和「沒存檔」兩種狀態。 日常操作至少要同時看三個位置:

位置 裡面是什麼 常用觀察指令
Working tree 你磁碟上正在編輯的內容 git diff
Index / staging area 下一顆 commit 預計收進去的 snapshot git diff --staged
HEAD commit 目前 branch 指向的已提交 snapshot git show HEAD
假設 app.pyHEAD 裡是 A 版。
你改成 B 版後執行 git add app.py,index 會保存 B;接著又在編輯器改成 C,但還沒再 add。
此時同一個路徑同時存在三個版本:
HEAD commit     A
index           B
working tree    C

所以:

git diff

比較的是 C 與 B;而:

git diff --staged

比較的是 B 與 A。 如果你沒先弄清楚三層的來源和目的地,就很容易在想「取消 staging」時順便把 working tree 也蓋掉。

三. 一張選擇表:先決定要保留什麼
#

先把最常見的需求濃縮成表格:

你的需求 建議指令 會不會新增 commit? 主要風險
取消某檔案的 staging,但保留修改 git restore --staged <path>
丟掉尚未 staged 的檔案修改 git restore <path> 會覆寫未提交內容
從某個 commit 取回一個檔案 git restore --source=<rev> <path> 會改 working tree
重做最近一顆本機 commit,保留 staged 內容 git reset --soft HEAD^ 改寫本機歷史
撤回本機 commit,保留未 staged 修改 git reset HEAD^ 改寫本機歷史
讓本機 branch 與指定 commit 完全一致 git reset --hard <rev> 可能永久丟掉未提交修改
抵銷已推送的壞 commit git revert <rev> 可能有衝突,但歷史可追蹤
一個很好記的版本是:

檔案內容先想 restore,私人 branch 的指標才想 reset,共享歷史優先用 revert。 這不是所有情況的完整規格,但作為日常安全預設非常實用。

四. git restore:精準處理檔案與 Staging Area
#

git restore 在 Git 2.23 加入,目的之一就是把原本塞在 checkoutreset 裡的檔案復原行為拆得更清楚。 先建立練習環境:

mkdir git-undo-lab
cd git-undo-lab
git init
git config user.name "拍拍君"
git config user.email "pypy@example.invalid"
printf "port=8000\nmode=dev\n" > app.conf
git add app.conf
git commit -m "Add app config"

4.1 取消 Staging,但保留檔案修改
#

修改並 add:

printf "port=9000\nmode=dev\n" > app.conf
git add app.conf
git status --short

如果只是太早 add,執行:

git restore --staged app.conf
git status --short

app.conf 的內容仍是 port=9000,只是從 index 退回 working tree。 這個動作的預設來源是 HEAD:Git 把 index 裡的 app.conf 恢復成 HEAD 版本,working tree 不動。

4.2 丟掉 Working Tree 裡的修改
#

確認剛才的修改真的不要後,才執行:

git diff -- app.conf
git restore app.conf

沒有 --staged 時,預設來源是 index,所以 working tree 會被 index 的版本覆蓋。 這一步通常無法靠 Git 找回未曾 commit 或 stash 的內容。 不確定時,先複製檔案到 repo 外、建立臨時 commit,或使用 Git stash 實戰 保存現場。

4.3 同時恢復 Index 與 Working Tree
#

如果一個修改已 staged,後來又在 working tree 繼續改,可以明確指定兩個目的地:

git restore --source=HEAD --staged --worktree app.conf

這會讓 index 與 working tree 都回到 HEADapp.conf。 因為它會覆寫兩層,請先檢查:

git diff -- app.conf
git diff --staged -- app.conf

4.4 從舊 Commit 取回單一檔案
#

restore 不只能回到 HEAD,也可以指定來源:

git restore --source=HEAD~2 -- app.conf
git diff -- app.conf

它不會移動 branch,也不會自動 commit,只會把指定版本放進 working tree。 確認後照一般流程提交:

git add app.conf
git commit -m "Restore previous app config"

如果只想取回其中幾個 hunk,可以使用互動模式:

git restore --patch --source=HEAD~2 -- app.conf

4.5 restore 不會替你刪掉一般 Untracked Files
#

git restore . 會處理 tracked paths,不代表「把整個資料夾清乾淨」。 新建但從未被 Git 追蹤的檔案,通常仍會留下。 先預覽清理結果:

git clean -nd

git clean 是另一個具有刪除能力的工具,不要把 -n 預覽省掉,也不要在不清楚範圍時直接套用。

五. git reset:移動 HEAD,並選擇要不要連動其他層
#

git reset <commit> 的核心是讓目前 branch 的 HEAD 指向另一顆 commit。 模式決定 index 與 working tree 要不要跟著改:

模式 移動 HEAD 重設 Index 重設 Working Tree
--soft
--mixed(預設)
--hard

5.1 --soft:拆掉 Commit 外殼,保留 Staged 內容
#

新增一顆故意包太大的 commit:

printf "timeout=30\n" >> app.conf
printf "temporary note\n" > notes.txt
git add app.conf notes.txt
git commit -m "Change config and add notes"

若這顆 commit 還只在本機,可以退回一格:

git reset --soft HEAD^
git status
git diff --staged

branch 回到前一顆 commit,但兩個檔案仍保持 staged。 你可以重新分組:

git restore --staged notes.txt
git commit -m "Configure request timeout"
git add notes.txt
git commit -m "Add maintenance notes"

這是 --soft 很實用的場景:內容要留,只是 commit 邊界切得不好。

5.2 預設 --mixed:保留內容,但全部變回 Unstaged
#

如果想撤回最近一顆本機 commit,並重新挑選要 add 的 hunk:

git reset HEAD^
git status
git diff

--mixed 可以省略。 它移動 HEAD、把 index 對齊新的 HEAD,但不碰 working tree,所以修改仍在磁碟上。 接著可以使用:

git add --patch
git commit -m "Create a focused change"

5.3 Pathspec 模式:不移動 HEAD,只重設 Index
#

這兩條在常見情況下效果等價:

git reset HEAD -- app.conf
git restore --staged app.conf

reset 後面是 pathspec,它只更新 index,不移動 branch。 新文章與團隊文件通常優先寫 restore --staged,因為意圖更直接;閱讀舊教學時,仍要看得懂 pathspec 形式的 reset

5.4 --hard:不是「更徹底」,而是「允許覆寫」
#

git reset --hard HEAD~2

這會同時移動 branch、重設 index,並讓 tracked working-tree files 對齊目標 commit。 Git 官方文件也提醒,必要時它可能覆寫阻擋操作的 untracked files。 在執行前至少檢查:

git status --short
git diff
git diff --staged
git log --oneline --decorate -5

如果有一絲不確定,先建立保護 branch:

git branch backup/before-reset

保護 branch 能保住已 commit 的歷史,卻保不住從未 commit 的 working-tree 修改。

5.5 reset 適合私人歷史,不適合偷偷改掉共享歷史
#

如果 commit 已經推送,而且同事可能從它建立新工作,reset 後再 force push 會讓大家的歷史分岔。 即使用 --force-with-lease 比裸 --force 安全,仍然是在改寫遠端 reference;它不是「可以不溝通」的通行證。 共享 branch 上要撤銷一顆壞 commit,通常改用 revert

六. git revert:用新 Commit 抵銷舊 Commit
#

git revert <commit> 會計算指定 commit 引入的 patch,再建立一顆反向 patch 的新 commit。 原本的 commit 仍留在歷史中,因此其他人不用重新對齊被改寫的 branch。 先建立一個壞 commit:

printf "debug=true\n" >> app.conf
git add app.conf
git commit -m "Enable debug mode"

假設它已經進入共享歷史,執行:

git revert HEAD

Git 預設會開啟編輯器,讓你說明撤銷原因。 完成後的圖形會像:

* Revert "Enable debug mode"
* Enable debug mode
* Create a focused change

兩顆 commit 都看得到:一顆記錄錯誤變更,一顆記錄決策與修正。

6.1 遇到衝突:解完後 Continue,或完整 Abort
#

如果後續 commit 已修改同一段內容,反向 patch 可能無法乾淨套用。 流程是:

git status
# 編輯衝突檔案並測試
git add app.conf
git revert --continue

如果判斷不該繼續:

git revert --abort

不要在 sequencer 進行中又疊一個 reset --hard;先用 status 看 Git 正在等待什麼。

6.2 一次整理多顆 Revert:先不 Commit
#

要把幾顆相關 commit 的反向變更合成一顆修復,可以使用 --no-commit

git revert --no-commit <commit-a>
git revert --no-commit <commit-b>
git diff --staged
git commit -m "Rollback unstable export changes"

順序很重要,而且變更彼此相依時可能發生衝突。 不要只看 commit message 就批次撤銷;先用 git show 檢查每顆 patch。

七. Revert Merge Commit:-m 不是訊息,是 Mainline Parent
#

merge commit 有兩個或更多 parent,Git 不知道你想保留哪一側作為主線。 因此 revert merge 時需要指定 mainline parent:

git show --no-patch --pretty=raw <merge-commit>
git revert -m 1 <merge-commit>

-m 1 的意思是「以第一個 parent 為 mainline」,不是「加一段 message」。 更重要的是,revert merge 不只拿掉當時帶入的 tree changes,也會留下「這次合併不要」的歷史語意。 未來再 merge 同一條 branch 時,Git 不一定會重新帶回已被那次 merge 引入的舊變更。 所以在正式 branch 上 revert merge 前,應先畫出關係:

git log --graph --oneline --decorate --all
git show --stat <merge-commit>

不確定 parent 或未來整合效果時,先在臨時 branch 演練並跑完整測試。

八. 一套安全復原流程:先觀察,再改動
#

真的出事時,拍拍君建議固定照這個順序:

8.1 先盤點狀態
#

git status --short --branch
git diff
git diff --staged
git log --graph --oneline --decorate -8

回答三題:修改有沒有 commit、有沒有 push、working tree 還有沒有其他珍貴內容。

8.2 驗證你要撤銷的來源
#

git show --stat <commit>
git show <commit> -- path/to/file

不要只因為 HEAD~2 看起來差不多就直接操作。

8.3 優先選可稽核、範圍小的工具
#

  • 只動一個檔案,不要重設整條 branch;
  • 只取消 staging,不要碰 working tree;
  • 已共享的 commit,用 revert 留下記錄;
  • 要改寫私人歷史,也先留 backup branch。

8.4 操作後立刻驗證
#

git status
git diff
git diff --staged
git log --graph --oneline --decorate -8

然後執行專案測試,而不是只看 Git 顯示「成功」。 Git 能證明 patch 套上了,不能證明產品語意恢復正確。

九. 常見誤區:復原失敗通常是範圍判斷錯了
#

9.1 把 restore 當成可逆操作
#

git restore app.conf 可能直接覆寫未提交內容。 「restore」這個名字很溫和,效果不一定溫和。

9.2 把 reset --hard 當成萬用清潔鍵
#

它會同時改 HEAD、index 與 working tree。 如果只想取消 staging,這個範圍大得荒謬。

9.3 對已共享 Commit 使用 Reset + Force Push
#

技術上做得到,不等於協作上合理。 除非團隊明確同意改寫歷史,否則用 revert 讓所有 clone 都沿著同一條歷史往前走。

9.4 以為 Revert 會讓舊 Commit 消失
#

revert 保存舊 commit,再增加一顆反向 commit。 稽核紀錄正是它的優點,不是缺陷。

9.5 Revert 之後又想重新套用原 Commit
#

原 patch 可能因為「已經是祖先」而不會被普通 merge 再帶回。 常見做法是 revert 那顆 revert,或建立新的修正版 commit;先根據 branch 圖和實際差異判斷。

9.6 把 Reflog 當成所有資料的保險
#

reflog 很適合找回曾被 commit 指向的歷史,但未 commit 的 working-tree 內容可能根本沒有 Git object。 若你已經 reset 錯了,請停止繼續試指令,改看 Git reflog 救援實戰 建立 rescue branch。

十. 指令速查:從問題反推工具
#

# 取消 staging,保留修改
git restore --staged path/to/file
# 丟掉 tracked file 的未 staged 修改
git restore path/to/file
# 從指定 commit 取回檔案到 working tree
git restore --source=<commit> -- path/to/file
# 重做最近一顆本機 commit,保留 staged 狀態
git reset --soft HEAD^
# 撤回最近一顆本機 commit,保留 unstaged 修改
git reset HEAD^
# 讓三層都對齊指定 commit:高風險
git reset --hard <commit>
# 用新 commit 抵銷已共享 commit
git revert <commit>
# 中止發生衝突的 revert
git revert --abort

再濃縮成四句:

  1. 先跑 statusdiffdiff --staged
  2. 檔案層級優先 restore
  3. reset 留給你確定可以改寫的私人歷史;
  4. 共享歷史優先 revert

結語:安全復原靠的是邊界,不是背咒語
#

resetrestorerevert 看起來像三個近義詞,其實分工很清楚。 restore 搬動檔案內容,reset 移動 reference 並視模式調整其他層,revert 則沿著歷史向前建立反向變更。 真正重要的不是一次背完所有旗標,而是每次操作前先說清楚:來源是哪一層、目的地是哪一層、哪些內容必須保留、歷史是否已經共享。 只要先把邊界畫清楚,Git 復原就不再是按下去祈禱,而是一個可以觀察、演練、驗證的工程流程。

延伸閱讀
#

版本控制: Git - 本文屬於一個選集。
§ 15: 本文

相關文章

Git rerere 實戰:記住衝突解法,讓 Rebase 與 Merge 不再重做
·8 分鐘· loading · loading
Git Rerere Merge-Conflict Rebase Merge Version-Control Developer-Tools
Git reflog 救援實戰:找回 reset、rebase 後消失的 Commit
·9 分鐘· loading · loading
Git Reflog Recovery Reset Rebase Commit Version-Control
Git tag 與 Release 實戰:版本標記、SemVer 與安全發佈流程
·8 分鐘· loading · loading
Git Tag Release SemVer Versioning GitHub Developer-Tools
Git worktree 實戰:同時開多個分支、平行測試與安全清理完全攻略
·11 分鐘· loading · loading
Git Worktree Branch Workflow Version-Control
Git bisect 實戰:快速定位壞 commit 與除錯流程完全攻略
·9 分鐘· loading · loading
Git Bisect Debugging Regression Version-Control
Git cherry-pick 實戰:精準搬運 commit、修補 hotfix 與分支同步
·11 分鐘· loading · loading
Git Cherry-Pick Hotfix Commit Version-Control