Git 全景課 / 第二部分 分支與歷史 · M6

M6 撤銷與救援

「我搞砸了」的時候,先問一個問題:改動走到哪一步了?還沒提交、已經提交、還是已經和別人分享?答案決定你該用哪一條命令,以及會不會丟資料。
約 75 分鐘沙盒 7 個任務 · 互動 ×2 · 小測 4 題先修:M5
學完本單元,你能夠:
  1. 按改動所處的階段,選出正確的撤銷命令;
  2. 說出 reset 三種模式各自改變了哪幾個區域;
  3. 分清 reset 和 revert,知道哪一個可以用在已分享的歷史上;
  4. 用 reflog 找回「消失」的 commit 和被刪除的分支。

1先判斷:改動走到哪一步? Where is the change?

情況想做的事命令安全嗎
未暫存放棄工作目錄的修改git restore 檔案不可復原
已暫存退出暫存,保留修改git restore --staged 檔案安全
剛提交、未分享改說明或補一個漏掉的檔案git commit --amend安全(舊 commit 留在 reflog)
已提交、未分享把分支搬回較早的 commitgit reset(三種模式)--hard 會丟掉未提交的修改
已分享(已 push)撤銷某個 commit 的效果git revert安全:只新增 commit
「commit 不見了」找回git reflog救援工具
一條總原則 已提交的東西幾乎不會真的消失:commit 是物件,物件不會被修改,只會變成沒有分支指向(M3、M4)。真正會永久丟失的,是從未提交過的修改。所以有疑慮時,先提交,再處理。

2還沒提交:restore Before committing

這是 M1 學過的兩條命令:

3剛提交:--amend Fixing the last commit

說明打錯字、漏了一個檔案?修好後執行:

git commit --amend -m "正確的說明"     # 只改說明
git add 漏掉的檔案 && git commit --amend  # 補檔案,沿用原說明

--amend 並沒有「修改」上一個 commit(M3 說過,物件不能被修改)。它是建立一個新 commit,parent 和原來的一樣,再把分支移過去。舊 commit 仍在,只是沒有分支指向它。

已經 push 的 commit 不要 amend 別人手上的歷史仍有舊 commit,你的分支卻換成了新 commit,兩邊從此分岔。推送時會被拒絕,強行推送(push --force)則會覆蓋別人的工作(M9、M10)。已分享的錯誤,用 revert 修正。

4reset:把分支標籤搬回去 git reset

git reset <commit> 首先做一件事:把目前的分支(連同 HEAD)搬到 <commit>。之後被跳過的 commit 不再出現在 git log。三種模式的分別,只在於它還要不要順便改寫暫存區和工作目錄:

模式分支/HEAD暫存區工作目錄結果
--soft搬回去不變不變被跳過的改動變成「已暫存」,可以立即重新提交
--mixed(預設)搬回去改成目標的內容不變改動變成「未暫存」,可以重新挑選再提交
--hard搬回去改成目標的內容改成目標的內容改動從檔案裏消失;未提交的修改永久丟失

互動 1 reset 三種模式

起點:main 有 A、B、C 三個 commit;你還在工作目錄裏改了一處(D),尚未提交。按一種模式執行 git reset HEAD~1,看三個區域各剩下甚麼。藍色=比右邊一欄多出來的改動;紅色=永久丟失。

工作目錄

暫存區

HEAD(main 所指的 commit)

5revert:用新 commit 抵銷 git revert

git revert <commit> 不改動任何舊歷史。它計算出「把那個 commit 的改動倒過來」的修改,然後提交成一個新 commit,說明自動寫成:

Revert "更新摘要"

This reverts commit 9f3c2e1a….

因為只是新增 commit,它可以安全地用在已經分享的分支上:別人 pull 下來,只是多了一個 commit。如果之後的改動和要撤銷的部分重疊或相鄰,revert 也會像合併一樣產生衝突,處理方法相同(解決 → git add → git revert --continue)。

6reflog:Git 的黑盒子 The reflog

HEAD 每移動一次(提交、切換、合併、reset、amend、revert),Git 都在 .git/logs/HEAD 記一筆。git reflog 把它們列出來,最新的在最上面:

1c44d8d (HEAD -> main) HEAD@{0}: reset: moving to HEAD~2
7635382 HEAD@{1}: commit (amend): 改寫摘要
e997d13 HEAD@{2}: revert: Revert "two"
19a7a2b HEAD@{3}: commit: three

HEAD@{1} 的意思是「HEAD 上一步在哪裏」。所以剛剛 reset --hard 闖了禍,git reset --hard HEAD@{1} 就能回到闖禍之前。被刪除的分支、amend 前的舊 commit、detached HEAD 下的提交,都能在這裏找到編號,再用 git branch 名字 編號 救回。

reflog 的限度 ① 它只記錄已提交的東西;restore 或 reset --hard 丟掉的未提交修改,不在任何地方。② 它是本機記錄,不會 push,也不會隨 clone 複製。③ 舊記錄會過期:沒有分支能到達的記錄預設保留 30 天,其他預設 90 天。

7沙盒:撤銷與救援 Sandbox: undo and rescue

互動 2 救援沙盒

按「改寫」修改某一行,用命令提交、reset、revert、查 reflog。只靠 reflog 才找得到的 commit 會標示「只在 reflog」。編號與 reflog 的格式都和真實 Git 相同。

應用線:

8容易混淆的對照 Easily confused

reset 與 revert

git reset HEAD~1git revert HEAD
做法把分支搬回去,改寫「目前的歷史」新增一個抵銷改動的 commit
舊 commit不再出現在 log(仍在 reflog)保留,歷史完整
已 push 的分支不適用:會與別人的歷史分岔適用:別人只會多收到一個 commit
可以撤銷較早的某一個 commit 嗎不行,只能把之後的全部一起搬掉可以,只抵銷那一個
常見誤解
  • 「git revert abc123 會把專案回到 abc123 的版本。」不是。它只撤銷 abc123 那一個 commit 的改動,之後的改動都保留。要回到某個版本的檔案內容,用 git restore --source=abc123 檔案,或 reset。
  • 「reset --hard 之後 commit 就沒了。」commit 仍在,reflog 找得回;真正沒了的是當時未提交的修改。
  • 「--amend 修改了原來的 commit。」它建立了一個新 commit,編號不同(M3:物件不能被修改)。
  • 「git reset 檔案 和 git reset --hard 差不多。」完全不同。前者等於 git restore --staged 檔案(只退出暫存);後者會改寫工作目錄。這也是新命令 restore 出現的原因:讓意思一看就懂。

9救援速查:「我搞砸了」 Rescue cheat sheet

我……這樣救
commit 說明打錯了(還沒 push)git commit --amend -m "新說明"
漏了一個檔案在上一個 commit(還沒 push)git add 檔案 → git commit --amend --no-edit
把幾個零碎的 commit 合成一個(還沒 push)git reset --soft HEAD~3 → git commit
提交到錯的分支(還沒 push)git branch 正確分支(先保住)→ git reset --hard HEAD~1 → git switch 正確分支
reset --hard 搬過頭了git reflog → git reset --hard HEAD@{1}
刪錯了分支git reflog 找編號 → git branch 名字 編號
已 push 的 commit 有錯git revert 編號 → git push
改壞了檔案,還沒 addgit restore 檔案(先 git diff 確認要丟掉甚麼)

10小測 Quiz

1. 你想撤銷最近兩個 commit,但保留它們的改動、重新整理後再提交,而且暫存區也保留。應該用:
2. 一個錯誤的 commit 已經 push 到 main,同事也已 pull。最合適的做法是:
3. 你執行了 git reset --hard HEAD~3,才發現搬錯了。下一步:
4. 以下哪一樣,無法用 reflog 找回?

本單元重點 Summary

延伸閱讀