git reset、revert、cherry-pick 差在哪:撤銷一個 commit 之前,先問它出去了沒
那顆你想撤掉的 commit,push 出去了沒?
先回答這個,三選一就結束了。出去了,用 revert。還在自己機器上,reset 比較快。至於 cherry-pick,它根本不在這場討論裡,只是常常被放在同一頁教學所以一起背了。
判準聽起來太簡化,但我把三個指令的行為實跑了一遍(git 2.52.0),跑完反而覺得它還不夠強硬。下面凡是有輸出區塊的段落都是實跑結果,包括我第一輪跑錯的那次;沒有輸出的地方是機制說明或實務建議,我會講清楚哪些是我沒測的。
reset 動的是指標,不是內容
git reset 做的事只有一件:把分支的指標往回搬。至於工作目錄跟暫存區要不要跟著搬,才是那三個參數在管的。
拿一個乾淨的 repo 來試,三顆 commit,C3 的內容是「新增 f3.txt」。從 C3 退回 C2,三種模式的結果是這樣:
| 模式 | HEAD | 暫存區 | f3.txt 的實體檔案 |
|---|---|---|---|
--soft |
C2 | f3.txt 還在暫存區裡 | 還在硬碟上 |
--mixed(預設) |
C2 | 空的 | 還在硬碟上,變成「未追蹤」 |
--hard |
C2 | 空的 | 從硬碟消失 |
三種模式的 HEAD 一模一樣,差別全在後面兩欄。--soft 等於「這顆 commit 我想重寫 message 或再補點東西」,改動原封不動躺在暫存區等你重新 commit。--mixed 把暫存區也清掉,改動退回工作目錄,你得重新 git add。--hard 連工作目錄一起洗掉。
會出事的只有 --hard,而且出事的方式很直接:檔案不見了。不過它沒有大家講的那麼恐怖。我把 HEAD 往回砍兩顆再去翻 reflog,原本那顆 commit 的 SHA 還好好躺在裡面,git reset --hard <原SHA> 一下就回來了。commit 物件不會因為沒有分支指到它就立刻蒸發,Git 的垃圾回收沒那麼勤勞。
真正救不回來的是「還沒 commit 過的改動」。那種東西不在 Git 的資料庫裡,--hard 洗掉就是洗掉了,reflog 幫不上忙。所以動 --hard 之前的動作不是深呼吸,是 git stash。
出去了就別想 reset
把 reset 跟 revert 分開的那條線,不在功能,在你有沒有推出去。
我開了一個 bare repo 當遠端來測。先 push 一顆 C3 上去,然後在本機 reset --hard HEAD~1 把它砍掉,再 push:
1 | push exit code = 1 |
被擋下來了。你的分支比遠端「短」,Git 拒絕讓歷史倒退。這時候多數人的反射動作是加 --force。
接下來這段我沒有實測,手邊沒有第二台機器可以扮演同事,但機制不難推:已經 pull 過那顆 commit 的人,本機歷史跟遠端對不上,下次 pull 會生出莫名其妙的衝突,或是把你砍掉的東西又推回來。
同一個情境改用 revert,push 直接過:
1 | push exit code = 0 |
原本三顆變四顆。這就是 revert 的本質,它不刪東西,它多疊一顆「把剛才那顆的改動反過來做一遍」的新 commit。C3 還在歷史裡,git cat-file -t 查得到,只是它的效果被後面那顆抵銷了。
歷史變長、變醜,但沒有人的本機需要重寫。這筆交易在多人協作裡幾乎永遠划算。
revert 一個 merge commit,會先賞你一個 128
單顆 commit 的 revert 沒什麼好講,git revert <sha> 就結束了。merge commit 不一樣,它有兩個 parent,Git 不知道你說的「撤銷」是要退回哪一邊:
1 | error: commit f1e8240... is a merge but no -m option was given. |
-m 要填的是「保留哪個 parent 當主線」。-m 1 是第一個 parent,也就是你當初執行 merge 時所在的那條分支。在 main 上合併 feature,-m 1 就是「回到 main 原本的樣子」,絕大多數情況要的都是這個。
加上去之後乾淨收工:feature 帶進來的 fd.txt 消失,main 自己的 md.txt 原封不動。到這裡都還算好懂。
真正的坑在後面
fd.txt 沒有回來。
我在 revert 掉那個 merge 之後,回到 feature 分支補了一顆新 commit(fd2.txt),再合一次進 main。exit code 是 0,看起來完全成功。結果:
1 | fd.txt(第一次帶進來、被 revert 掉的): 沒有回來 |
Git 沒有出錯,它只是照規矩辦事。merge 是看兩邊的共同祖先算差異的,第一次合併之後,feature 上那些 commit 對 main 來說「已經合過了」。你後來用 revert 把內容抵銷掉,那是 main 上另一顆新 commit 的事,不影響「合過了」這個事實。所以第二次合併時,Git 只補從那之後的新東西。
這個坑陰險的地方在於它不報錯。功能默默少一半,測試如果沒覆蓋到就直接上線了。
要救有兩條路。乾淨的做法是把當初那顆 revert commit 再 revert 一次(revert 的 revert,聽起來很蠢但完全合法,Git 自己那份 revert-a-faulty-merge how-to 講的就是這招),把內容先還原回來再合。或者一開始就別 revert 整顆 merge,改成把 feature 上那幾顆 commit 個別 revert 掉,這樣「合過了」這件事沒被破壞。
如果團隊的規則是每個功能都用 --no-ff 留一顆合併 commit,那更省事的選擇是整包 git revert -m 1 <merge> 之後就把那條分支當作報廢,要重來就開新分支,別回頭去合舊的。
cherry-pick 解的是另一個問題
它會跟前面兩個混在一起講,只是因為它也會動歷史。實際上它要回答的是另一種需求:這個修正,我要它同時出現在另一條線上。
hotfix 分支上修好一顆 bug,release 分支已經往前走了兩顆 commit,把修正挑過去:
1 | 來源 commit SHA=54efb60 parent=72171e6 tree=ca03b47 |
SHA 不一樣,tree 也不一樣,只有 patch-id 一樣。這三行值得多看一眼,因為它解釋了 cherry-pick 所有奇怪的行為。
commit 的 SHA 是內容加上下文一起算出來的,parent 是誰、整棵檔案樹長怎樣都算進去。同一個修正疊在不同的基礎上,算出來就是不同的 commit。patch-id 則只看「這次改了哪幾行」,所以兩邊相同。
換句話說,cherry-pick 產生的是一顆長得很像、但身分證不同的 commit。它跟原本那顆之間沒有任何血緣關係,Git 只知道它們改的東西一樣。
實務上這會咬人的地方,是同一個修正在兩條線上各有一顆 commit,日後那兩條線要合併時,Git 得靠內容比對去發現「這兩個其實是同一件事」。多數時候它認得出來,遇到中間有人動過相鄰幾行就未必。我試著把同一顆 commit 挑第二次,Git 的回應是:
1 | The previous cherry-pick is now empty, possibly due to conflict resolution. |
它知道這個改動已經在了。這裡有個細節值得記住:它沒有幫你留下一顆空 commit,commit 數不會增加,而是停在半路,repo 進入未完成的 cherry-pick 狀態(.git/CHERRY_PICK_HEAD 還在),exit code 1,等你自己決定是 git cherry-pick --skip 跳過,還是 git commit --allow-empty 硬留一顆空的。沒處理掉它,下一個 git 操作就會撞牆。
想事先知道對面有沒有等價的改動,git cherry release hotfix 會列出來,開頭是 - 代表對面已經有了,+ 代表還沒過去。這個指令沒什麼存在感,但在維護多條 release 線的時候很好用。
我第一輪把自己騙了
這篇的實測跑了三輪,前兩輪都有問題,值得記一下。
第一輪我在 revert 跟 cherry-pick 後面加了 -q 想讓輸出安靜點。這兩個指令沒有 -q 這個選項,所以它們印了一段 usage 就結束,根本沒執行。而我腳本裡後面那些「檢查歷史長什麼樣」的指令照樣跑,照樣印出漂亮的結果——歷史沒變、檔案還在。如果我沒去看上面那段 usage,這輪的結論會是「revert 好像沒作用」。
第二輪 cherry-pick 又給了我一個假答案。當時來源分支跟目標分支的基礎完全一樣,挑過去的 commit 連 parent、tree、message、時間戳都相同,SHA 算出來一模一樣。我差點寫成「cherry-pick 會保留原本的 SHA」,那是完全錯的結論,只是我剛好造了一個最不像真實情況的場景。第三輪讓目標分支先走遠兩顆,SHA 立刻就不一樣了。
兩次都是同一種錯:實驗設計得太乾淨,乾淨到把要觀察的差異給抹掉了。這比程式寫錯難抓,因為輸出看起來一切正常。
所以到底選哪個
還沒推出去,reset 就好,選 --soft 還是 --hard 看你想不想留改動。動 --hard 之前先 stash,事後真的後悔就去翻 reflog。
已經推出去了,revert,沒有第二個選項。歷史醜一點沒關係,--force 省下的那點整潔換來的是同事重建本機分支的下午。撤的是 merge commit 就記得 -m 1,撤完那條分支當報廢處理,別回頭再合。
cherry-pick 留給「這個修正得同時出現在兩條線上」的場合,挑之前用 git cherry 確認對面沒有,挑完在 commit message 裡註明來源 SHA。
三個指令沒有一個是危險的。危險的是在不確定那顆 commit 有沒有出去的情況下動手,然後用 --force 收尾。










