那顆你想撤掉的 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
2
3
4
push exit code = 1
! [rejected] expG -> expG (non-fast-forward)
hint: Updates were rejected because the tip of your current branch is behind
hint: its remote counterpart.

被擋下來了。你的分支比遠端「短」,Git 拒絕讓歷史倒退。這時候多數人的反射動作是加 --force

接下來這段我沒有實測,手邊沒有第二台機器可以扮演同事,但機制不難推:已經 pull 過那顆 commit 的人,本機歷史跟遠端對不上,下次 pull 會生出莫名其妙的衝突,或是把你砍掉的東西又推回來。

同一個情境改用 revert,push 直接過:

1
2
3
push exit code = 0
遠端現在 HEAD = Revert "C3"
遠端 commit 數 = 4

原本三顆變四顆。這就是 revert 的本質,它不刪東西,它多疊一顆「把剛才那顆的改動反過來做一遍」的新 commit。C3 還在歷史裡,git cat-file -t 查得到,只是它的效果被後面那顆抵銷了。

歷史變長、變醜,但沒有人的本機需要重寫。這筆交易在多人協作裡幾乎永遠划算。

revert 一個 merge commit,會先賞你一個 128

單顆 commit 的 revert 沒什麼好講,git revert <sha> 就結束了。merge commit 不一樣,它有兩個 parent,Git 不知道你說的「撤銷」是要退回哪一邊:

1
2
3
error: commit f1e8240... is a merge but no -m option was given.
fatal: revert failed
(exit code 128)

-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
2
fd.txt(第一次帶進來、被 revert 掉的): 沒有回來
fd2.txt(第二次的新檔案): 有進來

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
2
3
來源 commit  SHA=54efb60  parent=72171e6  tree=ca03b47
產生 commit SHA=ff924a2 parent=5cde510 tree=9d54dc7
patch-id 兩邊都是 a8178d9fc971

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 收尾。