一. 前言:Git 最難的不是撤銷,而是先回答「你要撤銷哪一層?」 #
你剛改壞一個檔案,想回到上一版。
你不小心把五個檔案全都 git add,其實只想提交其中兩個。
你剛 commit 才發現內容分錯包;更刺激的是,那顆 commit 已經推到大家共用的 main。
這四種情況都可以口語化地說成「我想復原」,但適合的 Git 指令完全不同。
git restore主要處理檔案內容與 staging area;git reset主要移動目前 branch 的HEAD,也能重設 index;git revert不刪歷史,而是新增一顆反向 commit。 真正的選擇條件不是「哪個名字聽起來最像 undo」,而是兩個問題:
- 變更目前位於 working tree、index,還是 commit history?
- 那段歷史只有你自己看過,還是已經分享給其他人?
今天拍拍君會先建立三層心智模型,再用一個可重做的實驗 repo,分別演練
restore、reset與revert。 本文不會重講如何從消失的 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.py 在 HEAD 裡是 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 加入,目的之一就是把原本塞在 checkout 和 reset 裡的檔案復原行為拆得更清楚。
先建立練習環境:
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 都回到 HEAD 的 app.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
再濃縮成四句:
- 先跑
status、diff、diff --staged; - 檔案層級優先
restore; reset留給你確定可以改寫的私人歷史;- 共享歷史優先
revert。
結語:安全復原靠的是邊界,不是背咒語 #
reset、restore、revert 看起來像三個近義詞,其實分工很清楚。
restore 搬動檔案內容,reset 移動 reference 並視模式調整其他層,revert 則沿著歷史向前建立反向變更。
真正重要的不是一次背完所有旗標,而是每次操作前先說清楚:來源是哪一層、目的地是哪一層、哪些內容必須保留、歷史是否已經共享。
只要先把邊界畫清楚,Git 復原就不再是按下去祈禱,而是一個可以觀察、演練、驗證的工程流程。