為什麼 Mercurial 沒有比 Git 更好

林雪凡寫了一篇《為什麼比 GIT 更好--理解 Mercurial 版本管理系統》,主張 Mercurial (Hg) 有很多 Git 沒有的優點。

兩款軟體我都實際用於專案管理超過 3 年,也研究及比較過不少還算進階的功能,竊以為該文不少描述不甚符合 Git 及 Hg 的一般使用情形,故撰此文稍做平衡報導。

1. Hg 擁有更優秀的分支模型?

1.1. Git 無法確定特定版本的分支,糟透了?

曰:Hg 的具名分支可以把和特定主題相關的提交集分離出來,很容易就能回答像是「約 250 次提交前,為了開發 X 功能而在某分支中進行的 N 個提交」之類的問題,而 Git 很難做到。(原文 1.1.、1.4.、註一)

答:首先要理解,Hg 具名分支和 Git 分支雖然都叫分支,但本質功能是不同的。Hg 具名分支是標示提交,相當於嵌在提交版本中的屬性或元資料,和「日期」「作者」「提交訊息」這些資訊類似;Git 的分支則是標示開發線的意義。

如果要類比,Git 的分支相似於 Hg 的書籤。而 Hg 的具名分支相當於在提交訊息加上規格化的訊息,例如提交到「feature」分支就像是把提交訊息寫成「feature: ...」, Linux 核心的版本庫就是這麼做的:

Linux 核心的 Git 版本庫的提交訊息大量使用「mtd:」「IB/mlx4:」等前綴

Hg 特化出這種功能,確實有其方便之處,例如提交到同一分支不必重複輸入分支名稱、想查詢「屬於某分支的所有提交」有簡單指令可用、圖形界面可為不同分支上色等等。相較之下,Git 要達成同等功能就比較依賴開發團隊的記律,且需要較複雜的指令技巧。(註一)

儘管如此,Hg 這種做法有其嚴重缺陷──靈活性不足。一個提交往往可以屬於一個以上的分類,但 Hg 的具名分支系統無法表達,比如一個和 UI 相關的功能在核心加了幾個公用函數和選項,理應歸類於 ui 及 core 分支,但 Hg 只能強迫歸類為相對重要的分支,假如歸類在 ui,未來查詢「屬於 core 分支的所有提交」就會漏掉這個和 core 有關的提交。雪凡說 Hg 要查與 X 功能相關的提交很容易,但這種搜尋方法其實並不可靠。同樣情況用 Git 的作法則毫無問題──在提交訊息標示「ui: core: ...」,未來查詢「ui:」或「core:」都能找到。

另一個缺點恰好就是因為方便──如果每個提交都要親自輸入分類,便不易出錯;用 Hg 具名分支反而容易忘記在提交前切換分支,往往就把應該提交到 core 的東西提交到 ui 去了。我自己在使用 Hg 時就經常犯下這種錯。

以上談了 Hg 具名分支較常見的用法──標示提交,但如果想用 Hg 具名分支取代 Git 的分支,那還得考慮 Git 分支的主流用法──標示開發線。

Git 最常用的分支流程之一是為特定主題開特性分支(feature branch),該分支上的提交原則上只與該功能相關,完成後再合併至開發分支(development branch)或主分支(master branch)。如下:

典型的 Git 流程會在完成階段任務後把特性分支合併至主分支

如果 Git 使用者想找出「屬於某分支的所有提交」,一般並不依賴前面談的搜尋方法,而是直接從線圖找,例如想找屬於 ui 分支的提交,就切換到「UI」分支看,如果「UI」分支已併入主線,就找「Merge branch 'UI'」提交再往前追。

Hg 若用具名分支模擬 Git 分支,大概會像這樣:

Hg 用具名分支模擬 Git 流程示意圖

接著 Hg 可能會遇到幾個難題。首先,Hg 的繪圖機制與 Git 不同,上圖 Git 的 master、ui 及 data 分支的主幹皆分別呈一直線,而 Hg 會把 default、ui 及 data 分支的主幹混合在同一條直線上,這導致從 Hg 的線圖難以分辨一條開發線上有哪些提交,因而 Hg 更依賴對每個提交的分類標示。

其次,Hg 建立 tag 也會產生新提交,讓線圖更為雜亂,不像 Git 能直接貼上 1.0.0、1.0.1、1.0.2 等標籤而完全不影響線圖。

有些 Hg 專案會為不同的維護版本設立不同的具名分支,比方為 2.*、3.*、……,例如 Python。如果有一個 Bug 同時存在於 2.0 及 3.0 版,修復此 Bug 的相關提交理應同時屬於兩個分支,但 Hg 具名分支無法表達,通常最後會歸於其中一個,比如 2.0 版,因而搜尋「屬於 3.0 分支的所有提交」會漏掉這些與 3.0 版相關的提交。若是用 Git 分支,則不須刻意標示,以線圖呈現即可:某 fix-issue-xxx 分支從 maint-2.0 分支分出,之後合併入 maint-2.0 分支及 maint-3.0 分支。

還有,分支往往會事後修改,Git 可直接更名,Hg 則必須修改歷史。比如我本來在開發 ui 分支,後來發現做得不好。使用 Git 分支我可以在目前的 HEAD 加上 test-ui-xxx 分支,然後把 ui 分支 reset 回之前版本重做。若是使用 Hg 我就得改寫歷史才能把這幾個本來屬於 ui 分支的失敗品改成 test-ui-xxx 分支;當然我也可以選擇不改,但代價是未來查詢「屬於 ui 分支的所有提交」時會列出這些不該計入的失敗品提交。Hg 具名分支綁死在版本庫中難以修改,任何錯誤及不適當的命名都會永遠留在版本庫中,因此常被形容為「很重」而不受開發者偏好。

雪凡抱怨 Git 做法能成立的前提是「整個團隊得付出苦勞」,比如得正確使用 no-ff merge 及 rebase 把線圖整理好,得保留分支不刪除等等……。我則認為團隊本來就有義務審核及確保開發者能維護好線圖。或許雪凡提出的 Hg 具名分支策略在某些情境──開發團隊能把提交放到正確分支、開發團隊無法把線圖維護好、各提交無複雜的多層相依關係、缺乏足夠資源訓練好開發團隊等等──能運作得比 Git 分支策略更好,但這恐怕只是特例。

總體而言,由於提交可能有多重歸類、開發線可能有多重合併及事後修改的情形,Hg 具名分支無論是用於標示提交或標示開發線,表達力皆不如線圖維護輔以提交訊息加註。

1.2. Git 無法隱藏分支,保留分支成了難題?

曰:Hg 可關閉不使用的分支讓它們不在 log 上出現,而 Git 無法隱藏分支,等到有幾百幾千個分支就難以維護,只能忍痛犧牲歷史。(原文 1.2.、1.4.、註二)

答:讓我們再複習一次:Git 分支和 Hg 分支的本質功能不同。

如果開發者是採用 Hg 具名分支策略,那麼在 Git 就是如 1.1. 所述在每個提交訊息中以特定格式標示其所屬分類。這些分類在 Git 本來就不會自動產生列表,也就不需要「關閉」。

如果開發者是採用 Git 分支策略,且如雪凡所說有上千個分支,那麼在 Hg 就是有上千個書籤。Hg 的書籤沒有關閉功能,如果 Git 會因此無法管理,Hg 也不會有辦法。

因此,雪凡的類比要成立,就得是 Hg 使用者把具名分支當 Git 特性分支使用。

一般的 Git 分支使用方法很難造成數量爆炸。首先,Git 絕大多數分支是為了開發某功能而建立的特性分支,這類分支完成後便會併入上游線,併入後即可毫無懸念刪除。由於 Git 可以把已合併的分支釋放,即使版本庫源遠流長,分支總數也可以極少。相較之下,Hg 具名分支會永遠存在,要是大小主題都開具名分支,成長的速度將是 Git 的無數倍。

舉例來說,Linux 專案有 16451 個分支,Git 專案有 5707 個分支(註二),這些分支都已合併入主線,因此在 Git 分支策略中不過是一個 master 分支;換成 Hg 具名分支則變成了幾千幾萬個,自然壓力山大囉。

不需要的分支太多,Git 選擇直接刪除,但 Hg 認為這不符合其「歷史是神聖的」宗旨而另行發展出關閉分支的功能,但也因此 Hg 關閉分支必須建立新提交,把線圖搞得違和。比如可能像這樣:

Hg 合併分支後關閉分支的線圖

或這樣:

Hg 關閉分支後合併分支的線圖

這只是兩條開發線,要是有幾千幾萬個「關閉分支」,這些提交放在線圖裡會是什麼樣貌,請自行想像囉。(某方面這是價值觀的差異:如果您認為「關閉分支」的動作值得永久記錄在版本庫,就會更偏好 Hg。我個人經驗是大部分開發者不傾向這麼認為。)

除了已併入主線的分支,其他分支可能是開發中的、未併入上游線的特性分支,這些本來就需要命名及管理,不會關閉。還有一些情況如「懸置分支」(本來打算開發某功能,但因種種因素長期沒做、打算封存或棄置)、「失敗品分支」(開發某功能做一做發現行不通留下來的分支)、「改寫備份分支」(為避免失誤而在改寫歷史前建立於原位置的分支)、「壓縮前分支」(以 git merge --squash 等方式壓縮歷史時,壓縮前的原版分支)。這些情況就可能有「隱藏分支」的需求,Hg 可以關閉分支,Git 可以怎麼做呢?

如果有分支想封存備查,但平時不想看到它們,Git 有很多種策略能處理:如果分支不打算留太久也不預期會用到,可立即刪除或觀察一段時間後刪除(刪除後 30 天內還能救回,參考本文第 2 節);如果該分支可以公開,可做成標籤後刪除分支,或以「反轉後合併法」將分支併入主線後刪除;如果該分支不適合公開,可加上前綴管理,或進一步移到其他命名空間或其他版本庫。(註三)

雪凡對這些做法的批評是「大量依賴人工」「光看就讓人想哭了」,我覺得主要還是熟悉與習慣的問題,細看註三的實做細節我認為不會太複雜,而換成 Hg 關閉分支也沒有輕鬆多少:首先,關閉分支需要輸入指令 hg ci --close-branch -m '...';其次,關閉的分支雖然在 hg branches 會自動隱藏,但在更常用的 hg log 仍會冒出來礙眼,得用 hg log -r 'not closed()' 才能濾掉。這些指令說長不長、說短卻也不短,要常規使用恐怕還是得設別名;而 Git 當然也可以設別名,你別名我也別名之下,Hg 在方便性上也佔不到優勢。

總結來說,幾乎不會有人把 Hg 具名分支當特性分支使用,果真那樣使用,具名分支會遇到的麻煩還是比較多的。

1.3. Hg 有實用的超輕量匿名分支?

曰:Hg 允許多頭分支,可作為超輕量且可追蹤的匿名分支。Git 雖有匿名分支但基本不具可追蹤性,也不可用。(原文 1.5.、註三)

答:匿名分支及多頭是很有創意的設計,但它真有帶來實務上的便利嗎?

雪凡提到匿名分支可以「臨時修些小 bug」:主線進行時突然發現某舊版有些小 bug,於是 update 至該 commit 改一改提交個匿名分支存著,然後回到主線去處理主要問題免得靈感飛了,等有空再對該匿名分支做妥善處理。

如果是 Git 使用者,他得在該舊版建立及切換至新分支,修 bug,提交,然後 checkout 回主線。另一個可能更順手的做法是 checkout 至該舊版(成為 detached HEAD,即匿名分支),修 bug 後提交,建個分支,然後 checkout 回主線。(這可能是 Git 匿名分支較合理的用法)

細觀 Git 與匿名分支做法的差異,匿名分支帶來了什麼好處呢?可能有人說能精簡分支列表,但這些分支既然短時間內會處理掉,本來就不必擔心它們長期佔用名稱或造成分支列表爆炸。況且這些臨時任務本來就應該要有個標示提醒自己尚有待辦事項,而不是讓它們無名而容易被忽略或遺忘

比較能算是好處的大概是省下了命名的腦力吧。但分支必定是基於某種目的而建立,只要大略寫出目的即可,即使一時沒靈感,也有很多偷懶方法:比如編號,比如使用圖形界面提供的產生罐頭名稱的功能,再不然隨便敲幾個鍵也行。Hg 具名分支改名是改歷史,因此命名不得馬虎,但 Git 分支改名輕而易舉,暫時使用的分支自然可以隨意命名,有必要再改就好。由 Hg 轉換到 Git 之初我也曾經覺得開分支要命名很麻煩,但習慣以後也不過是敲幾個鍵而已。

匿名分支好處不大,帶來的麻煩卻不少:匿名分支會造成多頭分支,導致協作上的困擾及在推送時被 Hg 拒絕執行等問題。雪凡在原文註三提了兩個解決方案:「先合併再推送」和「標書籤再強制推送」,而這兩者一旦用上,與 Git 做法相比就毫無優勢:前者換作 Git 也是建立分支、合併至上游、推送、刪除分支,最終兩者皆未使用分支;後者 Git 用一個分支 Hg 用一個書籤,並無差異,而 Hg 還得當心強制推送可能造成的問題。

另一種常見情形是分支有多頭而我只想推送一個。在 Hg 我有幾種選擇:(1) 每次都用指定版本號的方式推送,缺點是推送前要先查目前所在的版本號,比較麻煩;(2) 把想留在本地的其他頭設成 secret phase,這招好一點,不過如果情況是某個頭想推到 A 遠端而不推到 B 遠端,就不管用了。

總的來說,匿名分支只是省下敲幾個鍵的力氣,卻有缺乏語義的不足及需要另下工夫處理的多頭問題,實在很難說有實質優勢。

1.4. Hg 的分支可以和 Git 一樣輕量?

曰:Hg 可使用 bookmark 達到和 Git 一樣靈活的本地分支功能。(原文 1.3.)

答:Hg 書籤並無任何勝過 Git 本地分支之處;而受限於 Hg 的基本架構,Hg 書籤有很多方面不如 Git 分支。

本質上的不足是 Hg 書籤不會和歷史連動,無法藉由賦予書籤保護相關歷史免於破壞,也無法藉由刪除書籤把相關歷史清除。我若要嘗試某些功能而開了幾個 test-1, test-2, ..., test-n 分支,在 Git 無論我提交了多少版本,彼此互相做了多少分支合併,只要把這幾個分支刪除就能完全回復原狀;在 Hg 我若要刪除這些歷史,得一一把新增的提交版本從根源刪除,少刪就是留下懸置垃圾,多刪就是破壞歷史,只要新提交多些複雜些,根本不可能記得原來是怎樣,遑論還原。總之,相較於以開發線的概念掌握歷史,這種針對提交點的管理方式非常沒效率。

Hg 移動書籤的方式也不友善。在 Git 只要一個 git reset <commit> 就可以把目前分支、工作狀態等通通移過去,在 Hg 則得分成 hg update <commit>hg bookmark -f <bookmark> 兩道手續,後者還得自行輸入目前書籤名稱,打錯就變成新增,而且系統不會提示是新增或移動,必須自己檢查。

還有,由於 Hg 書籤通常會搭配匿名分支使用,幾乎總是多頭狀態,hg push 會被拒絕。雖然可以用 hg push --force,但這樣很容易造成多頭及混亂而不被建議。基本上總是得用 hg push -B my_bookmark1 -B my_bookmark2 ... 的方式操作。

Hg 想用書籤模擬 Git 特性分支還會在合併時遇到個困難。具名分支相同時無法 no-ff 合併,因此 Hg 使用者若想把 A 書籤合併到同一具名分支的 B 書籤就只能選擇:(1) 放棄 no-ff 合併,代價是線圖難讀;(2) 特性分支一開始就切換到與主幹不同名的具名分支,以確保可以 no-ff 合併,但使用書籤的主要目的不就是要避免建立具名分支嗎;(3) 用繞道手法,比如在需要 no-ff 合併時先切換到暫用具名分支再合併(註四),這做法總算有解決問題,但手續還是比較麻煩,線圖也難看了些。此外,Hg 合併分支的預設提交訊息是根據具名分支產生,想在提交訊息記錄是哪個書籤合併入哪個書籤,就得手動輸入……。

書籤確實相當靈活好用,也是 Hg 使用者常用的管理方式,但書籤在 Hg 只是外掛的地位,限於整體架構,要大量使用仍有許多不便。喜歡書籤的話還是直接用正版的 Git 分支吧!

1.5. 共用命名空間才利於共享?

曰:Hg 使用盡可能完全同步的方式取代遠端版本庫的命名空間,把事情簡化許多,也是更利於共享的設計。(原文 2.4.、註四)

答:拿掉命名空間確實讓架構簡潔清爽許多,但實務上它給我帶來的困擾多不勝數,請容我發發牢騷:

  1. 在 Git 如果加了很多遠端版本庫,我可以把他們的版本通通 fetch 下來,透過遠端追蹤分支清楚看出每個遠端版本庫分別有哪些分支、處於哪個進度、與本地分支差異為何,並且可以逐一審查它們多出的部分,我要的就 merge 或 rebase ,不要的就無視;萬一有某些版本庫內容很爛,只要把那些遠端版本庫刪掉就能把雜訊通通去除。在 Hg 我若按預設方式全部 pull,往往會直接被雜訊淹沒,那些我還沒檢查的版本通通變成我的版本,很難把不要的部分清掉;如果我逐個版本庫 hg incoming ,一來效率差,二來它只呈現有差異的部分,不是附著在現有線圖上,不容易搞懂那些片段是接到哪裡,三來那些版本不在我的版本庫中,我無法比對具體修改內容,光從作者、時間、提交訊息等資訊往往不足以做出適當的取捨決定。
  2. 遠端版本庫若有同名書籤,pull 時書籤可能因自動同步而前移,但我不見得總是想要自己的書籤前進到對方書籤的位置,此時自動同步便成了困擾。即使我可以手動把書籤移回,下次 pull 時同樣的事又會重演。總之在 Hg 我不能讓我的書籤乖乖停在我要它待的位置。
  3. Git 可用 config 配置針對遠端版本庫的推送內容,比如把 master 和 features/* 和 tags 推出去而保留其他。這些配置好了,日後只要一個 git push 就能完成任務。相較之下 Hg 缺乏這種靈活,每次推送到其他版本庫都得為各分支或書籤逐一下指令,除非您的使用情境總是適合 hg push --force

在 DVCS 的世界中,一個專案有成千上百個 fork 是司空見慣的,從 GitHub 的協作模式便可看出。若只和一個遠端版本庫交流,Hg 的架構是能行,若交流對象是很多個遠端版本庫,共用命名空間只會是場災難。某方面來看,Hg 像個加了 local commit 功能的 CVCS,Git 才做到了真正的 DVCS ,單是如此也許就註定了前者擦不出像 GitHub 那麼大的社群火花。

為了處理複雜問題,Git 的架構確實比較複雜,但這些複雜之所以存在,是因為簡單的做法有太多不足。所幸一般操作只要瞭解 git remote add origin http://path.to.remote.repogit fetch --allgit branch -t topic origin/topicgit branch -vvgit push origin topic 就足夠辦好很多事了,就稍微擔待擔待吧。

1.6. clone 作為分支策略?

除了雪凡介紹過的具名分支、書籤、匿名分支,Hg 還有一種常見且被建議的分支策略是 clone 版本庫(參考 A Guide to Branching in MercurialBranching and merging in Mercurial (and Git) explained)。

您沒看錯,clone 真的是一種分支策略,還是官方推薦過的,因為它可以有效解決其他分支策略的許多麻煩。比如我怕 pull 某人的版本庫後被垃圾灌爆,於是 clone 一個版本庫 pull 遠端歷史檢查,然後把能接受的部分 push 回主版本庫。比如我怕嘗試新功能失敗留下滿地零散提交,或怕改寫重整歷史失敗難以還原,於是 clone 一個版本庫測試。比如我是堅守不改寫歷史信條的人,為了避免開發過程搞出差勁的提交歷史,於是 clone 一個版本庫開發,搞爛了就 update 回舊版重來,直到有成果再把成功的部分 push 回去(匿名分支在這種本地 clone 版本庫就比較好用)。

還有某些堅持不啟用改寫歷史相關擴充套件的人,當他們發現主版本庫有東西做爛了,他們可以建個新版本庫,把原版本庫可用的部分推過去,重建爛掉的部分(可參照主版本庫的資料改,比如主版本庫 update 至某爛版本,檔案複製到 clone 版本庫,改到正確再提交;提交訊息之類的當然也可以從主版本庫複製),成功了再用 clone 版本庫取代主版本庫。此謂不改之改,很有禪意吧?

當然 clone 的缺點也不少,比如速度慢,吃空間,專案若要求路徑正確則 clone 版不易調試,未版控的檔案得另外與主版本庫同步等等,還有這種做法也不好管理,仔細想想,不覺得 clone 做法像極了舊時代的「my_project」、「my_project - 複製」、「my_project - 複製 (2)」、……?

相對的,在 Git 的架構下極少有使用 clone 作為分支策略的需要,想試改任何分支,建個新分支當副本操作就行,切換、套用、還原都輕而易舉。以上幾種 clone 做法我當初都實際玩過,現在回想起來,當初果然是醉了吧。

1.7. 分支模型分析與總結

總體而言,Git 雖只參照式分支一招,但它極為靈活且在各種情況都能運作良好;Hg 分支花樣繁多、令人眼花繚亂,但沒有一種足夠全面好用。在分支模型的功能性與易用性方面,Git 大勝 Hg。

2. Hg 進行危險操作的安全性高?

曰:Git 一但改寫歷史出錯,想還原需要複雜的 shell 魔法,而 Hg 會在所有破壞性操作前建立 bundle 儲存,事後可輕鬆復原。(原文 3.3.)

答:雪凡找的文章其實不是在談回復 Git 改寫歷史失誤的方法,而 Git 修復錯誤改寫其實只要兩個步驟:先用 git reflog(或更詳細的 git log -g)查出目前分支(如 my_branch)在改寫前的 hash 或代號(如 my_branch@{4}),再用 git reset --hard my_branch my_branch@{4} 將 my_branch 移過去即可收工。

被剝離的提交在 reflog 會留存 30 天,這段時間內任何修改失誤都能挽回。如果您不能接受任何失去歷史的可能性,可調整 config 把 gc.reflogExpire 及 gc.reflogExpireUnreachable 時間加長或乾脆設為 never,那就絕不會發生救不回的慘劇。

比較有經驗的 Git 使用者應該都知道做危險的改寫最好先備份,備份方法也很簡單──在目前分支的位置建立新分支即可,比如修改 my_branch 前先在 my_branch 的位置建立 my_branch_bak。有做到這步,改寫 my_branch 歷史出錯只要 git reset --hard my_branch my_branch_bak 就能回復,連查閱 reflog 的工夫都省下了。

Git 的情形講完了,換成 Hg 呢?雪凡對 Hg 用 bundle 備份被改寫歷史的方式讚譽有加,但我的經驗是裡面埋著一大堆地雷。

首先,bundle 檔是以被剝離分支的最早提交的 hash 命名,缺乏語義,數量一多,想找到正確的 bundle 便是一大挑戰,我得針對所有可能的 bundle 檔,逐一下指令查看。相較之下,Git 的 reflog 有時間、有操作者、有操作摘要、有前後脈絡,而且只要下一次指令就可以邊喝茶邊看,輕鬆得多。

其次,bundle 只有在其 parent 提交存在於版本庫時才能還原,只改寫一次還好,如果是改寫了很多次,那個 parent 提交可能已被刪除放到另一個 bundle,那還得費一番工夫找出 parent 再依序 unbundle。當然這個 parent 的 parent 也有可能已被刪除放到另一個 bundle……,於是我可能得經歷漫長的尋親之旅才能救回被誤刪的歷史。更糟的是,我可能千查萬找才發現這「另一個 bundle」已被刪除,屆時只好宣告我的歷史沒救!

換言之,我若要刪除任何一個備份 bundle,便得先確認不會讓其他想保留的 bundle 作廢,這可不是件容易的事。我當然可以選擇絕不刪除任何備份 bundle 以防意外,但我同時也得付出版本庫肥大及 bundle 數量龐大難以查找的代價。

漫長尋親之旅救回歷史以後,工程還沒結束,我得把那些不需要的歷史刪除,但這些改寫歷史的相依性往往極為複雜,我可能得費一番工夫檢查才知道要刪掉哪些提交,一個不小心就會再次誤刪。

Hg 改寫歷史直接把舊歷史移除的行為也讓改寫歷史後的檢查變得很費事,在 Git 我改寫完 my_branch 以後可以立刻和 my_branch_bak 比對檢查,但在 Hg 沒有辦法,我得先找 bundle 匯入才能比對、比對完還得刪掉,同時得小心刪錯,為此我常常懶得在改寫歷史後做檢查,這就讓出錯的風險提高且讓錯誤更晚被發現,晚發現又讓還原操作變得更複雜……這簡直就是惡夢般的循環。

絕大多數修改不需要永久備份,比如修改提交訊息的錯字,比如一些簡單無衝突的 rebase 操作,這些 bundle 大多是垃圾,不刪嘛……未免浪費空間和增加無謂的檔案數,刪嘛……檢查及確認的程序又極麻煩。

最後,如果非常堅持要有個像 hg bundle 一樣把舊歷史弄到版本庫外封存的方法,在 Git 也有 git bundlegit fast-export 可以把舊歷史備份存出去,這方面功能完全不缺,儘管我用 Git 以來從未有此需求。

Hg 不啟用改寫歷史功能的確非常安全,然而一旦要改寫歷史,Git 建個分支就能備份,1~2 個指令就能還原;Hg 還原則要一連串複雜操作,想像 Git 一樣事前備份、無腦還原大概只有 clone 版本庫一途。何者方便、何者安全?您自己判斷吧。

3. Hg 的 MQ 比 Git 的 Stage 強大且易用多了?

曰:Hg 的 MQ 套件比 Git 的 Stage 強大得多,而如果不需要這些功能,Hg 也不會像 Git 一樣常有忘記將檔案加入 Stage 的困擾。(原文 3.1.)

答:拿 stage 和 MQ 比較是很奇怪的事,因為雪凡列的所有「MQ 可以而 stage 不能」,幾乎都不是 stage 的存在目的。MQ 能做的所有事在 Git 都有更方便、更簡單且功能更強大的做法──就是直接開本地分支和改寫歷史!

MQ 的功能是建立一連串與提交相似但不是提交的補綴線,如要計較細節,MQ 的補綴版本線當然不同於一般提交版本線,背後運作方式也大有不同,但如果您只關注如何完成工作,MQ 一切操作完全可以視為本地分支和改寫歷史的操作

MQ 的架構限制很多:只能在一個提交上依序貼出一條補綴線,補綴線上不能有分支,不能同時貼兩條以上的補綴線,切換補綴線時必須把本來存在的那條 qpop 下來,用 qqueue 切換到另一條補綴線,然後 qpush 上去,操作相當繁瑣。相較之下,Git 分支沒有這些限制,完全自由。

Git 改寫分支總是有 reflog 可救援,MQ 改寫則沒有備份,出錯就毀了。雖說可建立 MQ repo 管理那些補綴,但如此一來修改 MQ 還得三不五時進 MQ repo 再提交一次,操作只有更繁瑣;如果 qqueue 弄了很多補綴佇列,還得維護多個 MQ repo……一個版本庫搞成 n 個,不累嗎?

最後,Git 這些指令都是原生功能,要玩 MQ 卻得學一整套截然不同的指令。

我以前也常玩 MQ,還丟過幾次資料,改用 Git 開本地分支的心得是它總是比 MQ 更簡單、更方便、更快速、也更安全。只要區隔好可改與不可改的分支,只要克服「不要改寫歷史」這個心理障礙,就能得到真正的自由。

或曰:果真本地分支那麼強大,為何還要 stage?

答曰:其實 Hg 也有 stage,當您執行 hg add 時,背後會有個系統記錄著「下次提交要加入這些檔案」,當您 merge 遇上衝突時,也有個系統記錄著「下次提交時,這些檔案還是衝突的,得拒絕」。概念上來說,記錄著「下次提交要包括什麼東西、處於什麼狀態」的系統就是 stage,又名 index 或 cache。

就此觀之,Git、Hg、乃至 SVN 及其他版控系統,多半是有 stage 的,唯一的差別是 Git 把 stage 層做得很明確,也提供豐富的指令讓使用者操作,而 Hg 和多數版控系統並沒有完整的 stage,指令上也內隱地略過這層,比如 hg commit 總是相當於 git commit -a,而 hg commit -A 總是相當於 git add . --all && git commit

層次分明是 Git 的哲學,為此雖讓複雜度增加不少,但由於提供了直接操作 stage 的功能,有些操作便能更簡潔且直指核心地完成,比如:

  1. 工作分階段暫存:即在工作目錄的東西改到一個階段(但還不適合作為一個提交)時,先放進 stage,然後進行下一部分,如果成功就再加入 stage,失敗則 checkout 復原再來。
  2. 工作目錄之修改分批提交:有時候我們不免會在處理某件事時順手改了其他不相干的東西,但理想的歷史應是一個提交只包括與一個功能相關的變更,此時我們可以把其中一部分修改加入 stage 提交,再把另一些修改加入 stage 作為另一提交,如此便能做到以語義為單位切割提交。(MQ 無法做到「一檔案多處修改的分批提交」,至於「只提交某些更動的檔案」可用 hg commit -I/-X 處理,但這會直接把檔案提交出去,無法在提交前分批 diff,也不能像 Git 那樣逐個檔案 diff → 檢查、修正 → add → status 確認全部正確 → commit)
  3. 工作目錄之修改提交到另一分支:有時會發現某些修改的提交到另一個分支比較恰當,此時可先 stage,然後 checkout 至另一分支,提交,再 checkout 回原來分支繼續幹活。
  4. 略過工作目錄直接寫入版本庫:比如可以把工作目錄外的檔案寫入到 stage,直接提交進版本庫,如此不必動到工作目錄就能直接把東西寫入版本庫。

所謂 stage 的標準用法(可參考這篇介紹)大致是上面的 1~3(4 算進階應用,通常用於程式或其他自動化工具操作 Git 時),這些和本地分支的應用場合是不同的,若要拿 MQ 比較,MQ 僅能應付第 1 種情況而已。(2 有 hg shelve 可用,唯較不直覺;至於其他項目前並未看到 Hg 有相應做法)

4. Hg 沒有缺乏功能?

曰:Hg 的設計邏輯是核心只提供基本功能,額外功能可透過擴充件達成,有很多是官方提供的,加個設定值即可啟用。將擴充套件納入考慮,沒有什麼是 Git 能做而 Hg 做不到的。(原文 3.1.)

答:我完全同意比較 Git 和 Hg 不該忘了官方擴充套件,但儘管如此,Hg 是否已提供了所有 Git 能做到的功能,恐怕還是要全面檢視才能下定論。至少就我的經驗,以下 Git 功能是 Hg 加上官方擴充套件也做不到的:

  1. git merge (hg merge)、git rebase (hg histedit, hg rebase)、git cherry-pick (hg graft, hg transplant)、git filter-branch (hg convert) 等分支合併及歷史改寫功能皆比對應的 Hg 工具功能更為強大,細節可查閱說明文件比較雙方支援的參數。我注意過的如 git rebase -p 能夠把包含 merge commit 的分支完全稼接,Hg 只能接成一直線;Git 的 merge 和 rebase 支援多種解決衝突的策略,而 Hg 沒有。
  2. git subtree 可以把子版本庫的所有內容映射到主版本庫的特定子目錄下,能保留所有子版本庫的修訂歷史,還能與主版本庫的歷史互相更新。是 git submodule 以外管理子專案的實用策略。去 Google 看看就能發現有不少人主張用 subtree 取代麻煩的 submodule。
  3. git replace 可以把某個提交取代為另一提交,把分開的版本線模擬成連在一起,讓查閱與比對更輕鬆。
  4. git notes 可事後在提交上追加注釋內容,且這些注釋是在版本控制之下。如要在舊提交上補充資訊,卻不想改寫歷史,這功能就能派上用場。此外巧妙運用 notes 分支,還能做出獨特且便利的關聯式資料結構,GitHub 的註解及評論便是用 notes 儲存的。
  5. Git 支援章魚合併(octopus merge),可在一次提交中合併多個分支。這有時能讓版本線圖精簡許多,尤其在相關開發線皆無衝突的時候。
  6. Git 支援 shallow clone,可只 clone 遠端版本庫某些分支的最新幾個版本,便能修改及提交,而不必下載全部的歷史記錄。

當然,如果認真去找,一定也會有 Hg 有而 Git 沒有的功能,不過大體而言 Git 之所以指令龐雜就是因為提供了比 Hg 更多更豐富的功能。當然功能不是看多寡而是看實用,這點您得自行評估。

5. Hg 更易於操作?

曰:Hg 提供了數序版號、revset 定位法、清晰的指令、簡潔的說明文件、指令預設縮寫、防呆等等,使 Hg 比 Git 易用得多。(原文 Point2.)

答:Hg 比 Git 易學易用,基本上是編程界的共識了,無可反對。然而即使 Git 指令語意混亂、參數龐雜、說明文件難懂,主要也是入門辛苦,對熟悉的人影響小得多,反而軟體本身的功能更能決定工作效率。Hg 有些易用特性對老手也很有幫助(我個人就很欣賞 revsets 和 hg serve),但就我的經驗,Git 優異架構及強大功能帶來的效益還是多些、困擾還是少些。

雖說 Git 易用性不佳,但偶爾還是有勝過 Hg 的佳作,有些已在前面零散談過,這裡再介紹點不一樣的:

  1. Git 會記錄 comitter 和 commit date,這對後續追蹤很有用,尤其在涉及修改歷史時。相較之下,Hg 只記錄了 author 和 date,使得 Hg 即使可以修改歷史,也不易區分而容易混亂。
  2. git log 一般依 commit date 排序,同樣的提交集在任何 clone 版本庫都會呈現同樣順序,hg log 以版本編號排序,但版本編號不可攜,換到另一個 clone 版本庫也會換一套版號及排序。
  3. Git 指令的自動補完功能做得較好,比如輸入 git log -- 再按下 tab 鍵就會列出所有可用參數;而輸入 hg log -- 再按下 tab 鍵則什麼都沒有。其他許多 Git 指令也能正確補上 branch 名稱、ref 名稱或檔案路徑(若要填已版控的檔案,不會列出未版控的),而在 Hg 我常常要自己輸入完整內容。這使得 Hg 指令雖普遍較短,實際使用時卻未必較容易輸入。
  4. git loggit diff 等指令在內容較多時會自動分頁輸出,而在 Hg 經常得看它們淹沒螢幕,或必須手工加入 | less(新版的 Hg 會用 more 分頁,但仍沒有 less 方便)。
  5. Git 在 merge 時會智能寫上提交訊息說明是哪些分支合併到哪個分支,相較之下 Hg 的合併訊息總要自己寫。
  6. Git 線圖有不少強大的呈現模式,比如 git log--first-parent--merges--no-merges--simplify-merges--ancestry-path--simplify-by-decoration--boundary 等參數。Hg 雖有強大的 revsets,篩選符合條件的提交集合較容易,但很難組織它們。個人經驗是呈現節點關聯的場合比篩選零散提交集的場合更多,這點 Git 做得比 Hg 好。
  7. Hg 只支援最上層資料夾的 .hgignore,Git 可設定 core.excludesfile 供所有版本庫共用,每層資料夾都能放 .gitignore,還可用 .git/info/exclude 設定版本庫專屬的忽略規則。其中版本庫專屬忽略規則我很常用,因為它不像 .gitignore 會納入提交影響協作者。
  8. Git 有 --assume-unchanged 和 --skip-worktree 設定,可在工作目錄對某些版控的檔案做本地端修改而不會被 git status 或 git add 納入。

還有,雖然 Git 指令列不好學,但 Git 和 Hg 圖形界面的上手性與易用性並無明顯差別,我本身是從 TortoiseHg 和 TortoiseGit 入門,上手時並不覺得後者比前者難,甚至由於 Git 內建強大的基本功能,TortoiseGit 往往還比 TortoiseHg 更好操作。我目前大多數工作是在 TortoiseGit 進行,local branch、no-ff merge、cherry-pick、squash 都能做得非常開心,指令列不過是偶爾需要複雜功能才開來用用。

由於圖形界面的學習曲線不陡,即使是版控麻瓜,由 Git 圖形界面循序漸進至精通 Git 指令操作也不失為好選擇。如上手和管理有難度,還有個可考慮的做法是架設 SVN,然後大家自由選用 SVN 或 Git 去操作,因為 SVN 比 Hg 和 Git 易學且易管理,而 git-svn 也有很棒的相容性。

早期 Git 在 Windows 一直是次等公民,但隨著 Unix 移植的改良,Git 在 Windows 已能發揮相當好的效能,加上圖形界面工具日漸發達,Hg 易上手的優勢大概會越來越不明顯,反而功能不夠會深深限制發展性,我個人就是原本以為用不到太多功能選了 Hg 後來不得不跳槽的血淋淋例子XD

結語

儘管本文對 Hg 多所批評,但請務必理解,以上多是我的經驗之談,所有說法都有其預設前提及適用情境,您可能處在不同的大環境,您可能把版控用於不同的場合,您的操作習慣可能與我不同,您的喜惡可能與我有別,您的開發團隊可能已經有習慣的版控軟體及常規,因此本文說的未必適合您或您的團隊,那都沒有關係,只要您理解並考慮清楚即可。我曾被推入看似美好的坑,遇過不少意料之外的驚喜,故撰此文希望後人能免於重蹈覆轍罷了。

(當然,若是我提供的資訊有誤,則歡迎回饋吐槽)

附註

註一:Git 如何模擬 Hg 具名分支的功能

(1) 提交到同一分支:複製貼上,或寫個 pre-commit hook 自動從目前版本 commit message 中擷取「分支名稱」插入提交訊息。

(2) 查詢「屬於某分支的所有提交」:例如 git log --grep '^watchdog: ' 可找出提交訊息開頭為「watchdog: 」的所有提交,相當於查詢 Hg 具名分支「watchdog」。

(3) 查詢所有分支名稱:可用諸如 git log --format=%B | egrep '^[A-Za-z]+: ' | sed 's|:.*$||' | sort -u | less 之類的指令。

(4) 建立新分支時確保名稱不與既有分支重複:建立新分支前後執行 2. 或 3. 的指令檢查即可。

話雖如此,這些多半是浮雲,一般 Git 使用者其實幾乎不會有這些需求XD

註二: 幾個大專案的分支數

本文的算法是 clone 版本庫後以指令計算 "Merge" 行的分支次數,不分命名空間,重複名稱只計一次。程式碼如下,您可自行試試:

git log --oneline --grep "^Merge" -i |
perl -pe 's/^.*'\''([^'\'']*)'\''.*$/$1/g' |
sort -u |
wc -l
註三: Git 如何封存不常用的分支
(1) 標籤法

如果要封存的開發線是打算公開的,可以做成標籤,比如在分支 X 的位置建立「archive/X」標籤再把分支 X 刪除。由於標籤和分支的命名空間不同,它們不會在 git branchgit log --branches=* 時跑出來煩人,推送時也可以加上 --tags 全部送出去;而且標籤不會動,比較有封存的感覺,還可以在標籤上加註訊息作為註記。

(2) 反轉後合併法

在想封存的分支上建立一個提交將該分支的所有修改反轉(revert;但注意這裡是使用 git revert 的概念,卻不直接使用該指令),把此分支合併入上游線(一般會用 no-ff),然後刪除分支,如此一來這條開發線就會永遠成為上游線的一部分。如以下二圖所示:

Git 用反轉後合併法封存棄用分支示意圖1
Git 用反轉後合併法封存棄用分支示意圖2

此法可保存相關歷史又永遠省去一個分支,但也會讓主線的歷史更龐雜,且這些備存歷史無法與主線分離,您得好好評估是否值得。

參考指令如下:

git checkout topic && 
git reset --hard master && 
git reset --soft HEAD@{1} && 
git commit -m "revert to $(git rev-parse master)" && 
git checkout master && 
git branch -m topic archive/topic-test-1 && 
git merge --no-ff --no-edit archive/topic-test-1 && 
git branch -D archive/topic-test-1
(3) 前綴法

前綴是一種簡單易行的管理策略,比如把分支 X 更名成「archive/X」,之後只要用 git branch | egrep -v '^  archive/'glt log --not --branches=archive/* 就能在分支列表及日誌中把它們排除。如果覺得麻煩,可設定別名處理。

(4) 命名空間法

算是 (3) 的進階版,用 update-ref 把要封存的分支移到 refs/heads/ 以外的命名空間,這可以讓 git branchgit log --branches=* 不會列出它們,更為乾淨。想看它們時用 git show-ref 列出即可。

但要注意,用此法封存的參照項在 push --all 時不會推出去,fetch 時也不會抓下來。您得在 push、fetch 等指令明確指定其名稱,或在 config 設定好對應,或用 push --mirror、clone --mirror 處理。

參考指令如下:

git update-ref refs/archive/<my_branch> <my_branch> &&
git branch -D <my_branch>
(5) 移到其他版本庫

把分支改成特定名稱後推送到備份的版本庫,然後從本地版本庫中移除,這算是 (3)、(4) 的終極版。

註四:Mercurial 匿名分支無法 no-ff 合併的問題

如圖,由於版本 3 是版本 2 的 fast-forward,且兩者屬同一分支,Hg 會禁止版本 3 和版本 2 以 no-ff 方式合併,必須先切換至另一具名分支(版本 4)才能合併(版本 5)。同理,版本 5 要和版本 1 合併,也得用同樣方式。

Hg 切換到 no-ff 分支再合併,以繞過無法 no-ff merge 的問題

參考資料

後記

本文初版撰寫後,林雪凡留言指出有些評論是基於誤解。我們後來透過私訊討論釐清了一些爭論點,本文也做了幾次較大改版,力求避免基於誤解的評論。至於本文是否客觀公正,請讀者自行判斷。

(2020/08/06 四版 5 修)

留言

  1. 親愛的丹尼,有些東西您誤解了。不過我最近很忙,實在沒精力在網路上長篇大論地和人留言論證,特別是上次您留言討論筆記的經驗,單我這邊就整整花了三四十小時以上在碼字,而且一個問題接一個問題沒完沒了,影響到生活了我有點撐不住。如果您真想知道我的想法,我們可以僅限一次地約個時間出來面聊,我想只要一個小時就能把問題釐清,這樣反而省時間。如果你人在台北的話。

    回覆刪除

張貼留言

1.本格歡迎任何留言,只有廣告和垃圾留言會刪。
2.希望您盡量留下代稱,以方便大家討論、回覆。
3.如果您打算長篇大論,建議在您自己的部落格貼文,然後留下連結和摘要。

這個網誌中的熱門文章

Windows 批次檔令人崩潰的特殊字元處理

中文與英文的比較

用 git replace 改善 Git 本地版本庫的開發線圖