Git 優秀實踐,這樣用就對了
作者 | minmingong
很多git的操作,都有多種方法達到目的。但其實往往其中只有一種是最佳的。
Git是個超級強大也非常流行的版本控制系統(VCS)。它的設計理念和其他VCS非常不同。縱觀整個業界,很多人在用舊的思維方式來解決git的使用問題,有svn方式的、p4方式的、奇怪方式的、錯誤方式的,等等,而不是更新成git的思維方式。雖然git非常靈活,確實可以用這些方式來使用,但其實操作起來反而更難,而且效率更低,吃力不討好。這里我打算把二十多年的各種版本控制系統的使用經驗和十多年git的使用經驗,總結出一些git的最佳實踐。其實很多時候,正確的做法比錯誤的更簡單,更不容易出錯。
一、什么是Git
不開玩笑。最常見的Git錯誤使用,正是來自于沒意識到git是什么。大部分git的屬性,可以從定義用邏輯推導出來。邏輯是最重要的,只要邏輯錯了,就一定是錯了。哪怕所有人都這么做,也是錯的。
Git是一個分布式版本控制系統,跟蹤目錄里的修改。它的工作流是非線性的(不同電腦上的平行分支形成了一個graph)。和主從式的系統不一樣的是,每臺電腦上的每個git目錄都是一個完整的repo,包含全部歷史和完整的版本跟蹤能力。(LFS是個例外,后面會提到。)
因為git的本質是一個基于目錄的分布式VCS,這里面并沒有中心服務器的角色。去中心化是未來。同個項目的所有repo都是平等的端點。一個repo可以在服務器、本地目錄、其他人的電腦上。只是為了團隊協作的目的,會認為指定一個或多個端點作為”服務器“。是的,可以同時有多個上游服務器。很多時候這么做很有必要。比如對內開發的repo和對外開源的repo,就是兩個不同的端點。可以有不同的分支和推送頻率。本地只要一個repo就都管理了。
非線性的工作流表示提交和分支操控是一個常規的操作。建立分支、rebase、修訂commit、強制推送、cherry-pick、分支復位,在git都是很正常的使用方式。
二、什么不是Git
很多東西經常和git一起出現,但是并不是git的一部分。
1.Github/Gitlab
這些都不是git,而是提供git服務和社區的網站。Git是個基于目錄的VCS,并不需要網站服務或者網絡訪問才能工作。早期經常有人沒法區分github和git。當要說git的時候,會說github,制造的混亂不是一星半點。
2.Fork
Fork仍然也是git服務網站的功能,用來簡化協作流程。在沒有fork的時候,如果你想往開源項目里修bug或者加feature,會需要這樣的流程:
- 克隆repo
- 修改代碼
- 生成補丁
- 發到論壇或者支持的郵件列表
- 找作者來review,合并補丁
很多項目到現在還是這么做的。如果有了fork,可以簡化成:
- Fork并克隆repo
- 修改代碼
- 發出merge request或者pull request
雖然fork很有用,但這仍然不是git的一部分。它用到的是git的分布式能力。本質上,在fork的時候,它會克隆一份repo,把原來的repo設置成上游。所以其實如果你的目標不是為了繼續把repo放在網絡服務上,那就克隆到本地就是了。太多的人把fork當作like來用,根本就是錯的。如果沒打算改代碼,fork是沒意義的。機器學習界這個問題尤其嚴重。經常放一個README就假開源了,還有幾百個fork,都不知道能fork到什么。
3.Merge request/Pull request
Github上叫Pull Request,gitlab上叫merge request,其實是一個東西的不同視角。這些都是code review和合并的流程,不是git的一部分。
需要注意的是,它們的重點在“request”,而不是merge或者pull。如果你要把一個分支merge到你自己的,沒必要開一個MR然后自己給自己通過。在本地merge就是了,更簡單更快。
4.Import
很多git服務支持“Import”,用來從別的git、svn、cvs、p4等VCS導入一個庫。如果原本的repo已經是git,那直接push到新的地方就是了,比import更簡單。而且這樣絕對不會丟失歷史記錄或者搞錯文件。如果是其他VCS的repo,那也可以用插件或腳本來先轉成一個本地的git repo,然后再push到新的地方。
三、對工具
Git本身是個命令行工具。但是,非線性工作流的本質就讓它沒可能在字符界面顯示出分支的graph。選個好的GUI非常關鍵。不但可以大幅度增加工作效率,更重要的是,減少出錯的機會。第二個常見的git使用錯誤來源,正是因為用錯了工具造成了。
Windows上最好的git GUI是TortoiseGit,沒有之一。它只是個GUI,git命令行需要事先安裝。和其他Tortoise打頭的工具(TortoiseCVS、TortoiseSVN)一樣,它的風格是沒有主UI,而集成到Windows的文件管理器里面。Repo里的文件(也就是目錄里的)圖標上會覆蓋上狀態。右鍵點擊這個目錄,菜單里可以看到TortoiseGit的子菜單,包含git的一些操作。大部分VCS的GUI工具,比如P4V、SourceTree,UGit,都有個主UI顯示映射了的工作空間,而不是目錄本身。對于git來說,這其實是個錯誤,因為git是基于目錄的,不存在工作空間這個概念。而且,這種情況下非常常見的錯誤就是忘記提交新增的文件。在TortoiseGit里,除了蓋在圖標上的狀態之外,提交窗口也可以顯示出哪些文件還沒添加,不會出現遺漏的情況。
另外,TortoiseGit有一個獨特的版本graph查看器,里面可以顯示出repo的整個分支結構。通過這個查看器,可以很方便地看出來repo是怎么成長的,有那些不必要的分支,如何從一個分支跳到另一個,等等。這是TortoiseGit比其他git UI好的一個重要原因。不管是Visual Studio里的、SourceTree、還是UGit,在UI設計上都像用傳統的VCS思路來套用到git上,而不是git的思路。主它們的共同問題就是,基本只關注于當前分支。而有能力同時看所有分支,對git來說非常重要,因為git的工作流是非線性的。
其他高級功能,比如打補丁、處理submodule(非常重要),都可以在TortoiseGit的GUI里完成。但它沒法覆蓋所有的功能。有些很少用的,還是得通過命令行。
四、盡量在本地
所有的git操作都可以在本地repo上完成,因為服務端的并沒有更高優先級。雖然大部分提供git服務的網站都在網頁界面里有cherry-pick、新建分支、合并這些操作,但是在本地執行更容易,而且比在服務端執行了再拉下來要更快。
五、分支策略
Git的工作流是基于分支的。不但每個repo是平等的,每個分支也是。Master/main、develop這些只是為了簡化管理而人工指定的有特殊含義的分支。這里的分支策略是為了更好地協作而產生的習慣規范,不是git的工作流本身必須定義的。分支可以分為幾個層次。
1.Main分支
這是整個項目的穩定分支,里面的內容可能相對較老,但是這個分支里的內容都是經過測試和驗證的。原先都叫master,因為政治正確的要求,最近越來越多新項目開始用main。有些快速開發的項目甚至不采用main分支。
2.Develop分支
開發主要發生在develop分支。新特性先放到這個分支,再去優化和增強穩定性。
3.大項目可選的團隊develop分支
對于跨團隊的大項目,每個團隊都有自己的興趣點和發布周期。很常見的做法是,每個團隊有自己的develop分支。每過一段時間合并到總的develop分支。一般來說,中等大小的團隊,專注于repo的某一部分,可以采取這樣的分支形式。小團隊或者個人沒有必要有自己的develop分支。那樣反而會浪費時間和增加合并過程中的風險。
4.Feature分支
Feature分支是生命期很短的分支,專注于單個特性的開發。和其他VCS不一樣的是,在git里開分支開銷非常低,所以可以高頻地開分支和合并分支。在做一個特性的時候,常規的流程是這樣的:
- 從develop分支上新建一個feature分支
- 提交一些關于這個feature的代碼
- 合并回去
- 刪除這個feature分支
對于本地repo里的feature分支,你可以做任何事。常見的用法是在開發過程中非常頻繁地提交,走一小步就提交一次。在發出MR之前,先合并成一個commit,把這個分支變整潔,方便后續操作。
當feature分支合并之后,絕對不存在任何理由讓這個分支仍然存在于服務器上。WOA現在有自動刪除的選項,可以設置成默認開啟。但有時候仍然會出些問題,這個選項會消失,需要手工刪除分支(其實就是在MR頁面上點一下的事)。記住:服務器上只是一個端點,刪掉那邊的一個分支不會影響你的本地repo。如果你有后續工作需要在那個分支上做,就繼續在你本地的分支上完成就是了。這和服務端有沒有這個分支一點關系都沒有。
因為每個分支都是平等的,可以推出在任何一個分支上都可以新建分支。比如,如果特性B依賴于特性A,你不用等特性A合并了才開始做特性B。只要在特性A的分支上建立一個特性B的分支就可以了,即便特性A不是你的分支也可以。等到特性A合并了,把特性B的分支rebase一下就是了。少了等待環節,效率提高很多,也不必催人做code review。
能建立大量feature分支,對于提高工作效率非常關鍵。每個特性建立一個feature分支,在上面完成特性,發出MR。在code review通過之前,已經可以新建另一個特性專用的feature分支,切換過去,開始做另一個特性。在code review過程中還能來回切換,同時做多個特性。其他VCS是做不到這一點的,效率也自然低很多。
5.Release分支群
Release不只是一個分支,而是一群以“release/”打頭的分支。就好像一個目錄,包含了不同版本給不同產品線的release分支。一般來說他們從main或者develop分支出來。當發現一個bug的時候,在main或者develop分支修好,然后cherry-pick到release分支里。這種單向的處理可以方便管理,并且不用擔心某個commit是不是只有release分支有。Release分支經常在每個sprint的開頭創建,包含這個sprint要發布的東西;或者在每個sprint的結尾創建,包含下一個sprint要發布的東西。
四、Merge還是rebase
雖然在提及把commit從feature分支放到develop分支的時候,我們一直說”合并“,但其實這里存在兩個維度。是的,不是有兩個操作,是有兩個維度。
第一個維度,是merge還是rebase。這是兩種”合并“的方式。第一種是普通的合并,和傳統的VCS一樣。它會把一個分支合并到目標分支,在頂上建立一個commit用來合并,兩個分支里已有的commit不會有變化。
另一個就是rebase。它會從分支分出來的地方切開,嫁接到目標分支的頂端上。(我一直認為rebase應該翻譯成嫁接,而不是“變基”。)
第二個維度是是否squash,也就是選擇一個分支里的一些commit,壓扁成一個commit。這個任何時間都能做,即便不是為了合并也行。在TortoiseGit里,這叫“combine into one commit”。
兩個維度組合之后,我們就得到了4個操作。但是“squash再merge”沒有任何意義,所以就剩下”不squash就merge“, ”不squash就rebase“,以及”squash再rebase“。(微軟的devops文檔曾經有個嚴重的錯誤。里面描述成merge表示不squash就merge、rebase表示squash在rebase,而沒有把它們當作兩個維度來看。是我在2018年左右提出了這個問題,并且要求他們修改,還提供了多個圖片解釋它們到底有什么區別。過了大概半年之后才改成對的。但很多人就是從那里學的git,都被帶壞了。)
其實還可以有第三個維度,修訂與否。但這個更多的是發生在merge之前的過程。修訂,amend,表示當提交的時候,是不是要覆蓋掉上一個commit。打開的話,提交之后還會只有一個commit,而不是兩個。
關閉amend
打開amend
現在的問題就是,什么時候用什么。要是要處理的是長生命周期的分支,比如團隊的develop分支、develop分支、main分支,合乎邏輯的選擇是merge。因為它們的結構需要保留,而且合并后分支也不打算消失。
對于feature分支,不同團隊可以有不同選擇。這里我只說最高效,開銷最低的。一個feature分支里可以有多個commits,但它們只有合在一起的時候才會成為一個feature。中間的commit以后就再也用不到了。留著只會浪費空間和時間。所以邏輯上,這些commit就需要被squash。這時候如果merge一個只包含一個commit的分支,就會出現這樣的graph:
這里有個什么都不做的commit,只是把兩個分支抓在一起,以及一個永遠掛在外面的commit。即便git里開分支和合并的開銷很低,但這會一直積累的。這里用merge,就完全是在浪費時間和空間。對于feature到develop的合并來說,rebase是最佳選擇。
現在,如果早晚需要把多個commit合成一個,那就該用amend。是的,大部分時候,一路amend過去,比最后才來squash更好。首先,rebase一個commit,會比rebase一串來得容易得多,特別是有代碼沖突的時候。其次,如果MR的最后才squash & merge,那commit的消息就是沒有經過review的,增加了犯錯的風險。(是的,非常經常發生)
所有這些操作都可以在本地完成。這比在Web UI上操作遠程的repo要容易而且高效。總結起來,這里的最佳實踐是:
- 在開發過程中可以用commit或者amend commit
- 在發出MR的時候squash成一個commit
- 在MR的迭代內持續用amend commit
- 在MR通過后用rebase進行合并
(其實,p4里面的每一次submit,都是amend + rebase。之前只是因為沒有人告訴你這個事實。而且p4里只有一種submit的方式,沒有思考和選擇的空間,做就是了。但這絕不代表不需要思考“有沒有更好的做法”這個問題,這非常重要。)
更復雜的情況是在跨公司的repo上工作,比如UE。這時候規則需要做一些改變。一般來說,這種情況下你的feature分支是從release分支上建出來的,而不是develop分支。而且這種feature分支其實是作為develop分支來用,有長的生命周期。這時候,如果你要把一個特性從比如UE 5.1移植到5.2,rebase就不是最佳選擇了。因為那樣的話會把5.1 release分支里的所有commit和你的所有feature commit一起rebase。而你真正想要的是只把你的commit給cherry-pick過去。這其實還是因為工具。如果用的是TortoiseGit,就不會有這個疑惑。因為里面rebase默認是交互式的。你可以精確選擇哪些commit需要操作。這就讓rebase和cherry-pick變成一樣的東西。唯一的區別,是rebase是讓git選一個commit的列表,讓你從中選哪個要哪個不要。而cherry-pick是讓你直接選commit的列表。
五、處理合并沖突
當出現合并沖突的時候,最好的方式是先把你的feature分支rebase到目標分支的頂端,這時候解決沖突,然后force push。如果用WOA的沖突解決(可能有些別的基于web的git服務也有),它會每次都做merge。結果經常把簡單的單個commit rebase,變成了復雜的三分支合并。
1.常見錯誤:解決合并沖突后建了個新的MR
因為沖突解決的錯誤行為,有可能在解決之后,修改被提交到了一個新的分支。這時候應該把你的分支reset到新的去,force push,再刪掉新的;而不是關掉原先的MR,在新分支上開個新MR。
2.常見錯誤:把分支搞亂
如果真的遇到了多分支復雜交錯的情況,有兩個方法可以嘗試清理出來。
- 強制rebase。Fetch一下整個repo;把你的分支rebase到目標分支上的時候勾選force;這時候在列表里選要拿去rebase的commit。大部分時候這都能行。但有時候git因為分支太錯綜復雜而搞不清楚commit,在列表里會有遺漏。
- Cherry-pick。在目標分支上新建一個臨時分支;把有用的commit都cherry-pick過去;把你的分支reset到那個臨時分支上;最后刪掉那個臨時分支。
兩個方法最后都需要force push。
六、不要pull,要fetch
很多教程都說push和pull是在本地和遠程repo之間同步的指令。但是其實push是基礎指令,pull不是。它是fetch當前分支->和本地分支合并->reset到合并后的頂端。這里就產生了不必要的合并。你可以打開rebase pull,這就簡化成fetch當前分支->rebase本地分支。
好一些,但是每次pull的時候都會開啟rebase的窗口,即便沒什么好rebase的。其實如果改用手動運行fetch和rebase,同樣的工作量可以獲得更多。因為默認的fetch可以拿到所有分支,而不是只有當前分支。然后你可以決定哪個分支rebase到哪里。整個過程中都可以保證沒有錯誤的merge發生。
七、小而完整的commit
每個commit都該小而完整,有些人把這個叫做”原子性“。不要把多個特性壓到一個commit里,同時不要有一堆必須合起來才能用的commit。
1.常見錯誤:一個commit里做多件事情
這是一個非常常見的錯誤。一個大的commit包含多個任務的代碼。這樣的commit必須要拆成多個才行。在git里,這樣的拆分比較容易。如果一個分支“Feature”包含了特性A和特性B的代碼,那么,
- 在“Feature”的頂端建立“Feature A”和“Feature B”兩個分支
- 切換到“Feature A”分支,刪掉其中特性B的代碼,開amend提交
- 把“Feature B”分支rebase到新的“Feature A”分支
這就行了。現在兩個分支都分別只包含一個特性。如果特性B不依賴于特性A,它還可以繼續rebase到develop分支去。
2.常見錯誤:多個不完整的commit
另一個非常常見的錯誤是不完整的commit,比如不能編譯、不能運行、只包含瑣碎的修改、或者僅僅為了未來的使用而做的修改。這樣的commit只是中間結果,沒法單獨存在,需要和其他commit合起來才變成一個完整的commit。那它們就需要合并之后才發MR。
3.拆分大的commit
是的,有時候是需要把一個大的commit拆分成多個,讓MR更容易看。但是這里的拆分并不能讓commit變得不完整。如果一個大commit中的一部分,本身就能對現在的代碼庫有幫助,拿著就能提出來變成一個獨立的commit。常見的是獨立的bug修復、代碼整理、或者重構。
八、LFS技巧
LFS是git里蠻特殊的一部分。為了讓git更好地支持大(二進制)文件,LFS其實讓git的設計做了一些妥協。LFS比git晚了9年發布,而且花了好多年才讓主流git服務都提供支持。
1.LFS是怎么回事
保存完整歷史的大文件,特別是大的二進制文件超級占空間和處理時間。在LFS里,默認子保存一個版本的大文件,歷史則放在另一個端點,一般是服務器。本地其實也可以這樣拉取完整的歷史:
git lfs fetch --all
當從一個git轉移到另一個的時候,會要求做這件事情。其他時候一個版本就夠了。
另外,LFS有加鎖解鎖的功能。但是和主從式的VCS不同的是,加鎖解鎖不會自動擴散到所有端點。這還是因為并不存在中心服務器的概念。
2.常見錯誤:沒開LFS
非常重要的一件事情是,LFS不負責鑒別哪些文件是大文件。在添加大文件之前,它們路徑需要加到.gitattributes里,可以用通配符。一旦路徑在.gitattributes里了,文件操作就會自動通過LFS過濾,不需要額外的手工操作。
但是,如果一個文件在沒有改.gitattributes之前就添加了,那它會被當作普通文件。要糾正這個,需要把文件路徑放到.gitattributes,然后執行:
git add --renormalize .
才能把當前目錄下的LFS狀態修正過來。但歷史里面的沒法改,一旦提交了,大文件就會永遠在那邊。通過那樣的方法過濾git庫,刪除不小心提交的大文件非常痛苦。過程中會有很多手工操作和確認,但至少這件事情是可做的。在實際項目中,我曾經把一個野蠻生長到1.6GB的git庫,通過去掉沒開LFS的情況下提交的第三方依賴和數據,精簡到了10MB,而且所有歷史記錄都在。其他VCS甚至不會有機會這么做,只能無限增長下去,或者砍掉一段歷史記錄。
3.濫用LFS
另一個極端就是濫用LFS。把所有的文件都當做大文件來添加,這樣git repo就表現成了個svn。當然,git相對svn的大部分優點也沒了,開發效率下降5-10倍。要進一步把效率下降10倍,可以鎖上所有的文件。這樣所有人都需要checkout文件才能編輯。這樣的git repo就退化成了一個p4庫。(要再次把效率下降10倍,就在同個項目上混合使用git和p4。可以肯定,到不了10次commit,就會有人搞錯,把文件同時放到兩邊,造成兩邊都混亂。)
4.封裝LFS鎖
剛提到,LFS鎖所有的東西可以很容易把開發效率下降2個數量級。但是,對于非編程的工作流,比如美術工作,反正是沒有diff的操作。這就會變成加鎖->check out->修改->提交->解鎖,和主從式VCS的工作流一樣。一個常見的解決方法是寫一個腳本來加鎖、擴散鎖的狀態,另一個腳本來做提交、解鎖、擴散鎖的狀態。把LFS鎖封裝之后,工作流既可以符合美術類,也同時保持編程類工作流的效率。從另一個角度想這個問題:git有機會封裝成同時符合編程類和非編程類工作流,保證兩邊的效率;但是svn/p4卻沒可能封裝成提高編程類工作流效率的。
九、Git的缺點
當然,git不是完美的,有些地方仍然比其他VCS有些缺點。解決這些問題的辦法,有,但支持并不廣泛。
1.缺乏分支權限管理
Git沒有內建權限管理(來自于Linus Torvalds的設計理念)。當一個人獲得訪問repo的權限,所有的分支都能訪問到。有些服務通過控制“.git/refs/heads”下的文件訪問,提供了基于分支的權限管理。這就能有基本的權限管理,又不需要修改git。
2.巨型庫(單一庫)
當Linus Torvalds設計git的時候,首要目標是支持Linux內核的開發,需求限于這樣的中等規模。對于一個巨大的項目,git的性能并不好。想想在“git status”的時候,git需要窮舉目錄下的所有文件,比較當前的和repo里的區別。這肯定會花不少時間。
這幾年,git也在這上面做了一些改進。Git 2.25里引入的部分clone和稀疏checkout可以讓你不需要把整個repo都clone或者checkout,只要你需要的一部分子目錄就行。但這些還比較新,不是所有服務提供方都支持。
要解決存放Android源代碼的需求,Google有個工具叫“repo”。它可以管理多個git repo,就好像一個巨大的repo一樣。這個工具支持Linux和macOS,但是Windows上基本沒法用。同時,因為本質上其實還是一堆git庫的集合,把文件從一個git挪到另一個,就會丟失歷史。Google的另一個工作是Git protocol v2。它可以加速repo之間傳輸的速度。
微軟的Windows長期以來一直用的fork的p4,叫做source depot(SD),作為版本控制。在2015年的某個時候,p4已經無法滿足現代的敏捷開發和協作的需求,于是考慮切換到git。即便代價非常大(切換了一個用了20年以上的系統,大量修改bug跟蹤、自動編譯、測試、部署系統,培訓部門里的每個人,配發大容量SSD。),也要堅持去做,因為都知道這才是未來。直接轉的話,單個git庫的大小是270GB,clone一次得花12小時,checkout花3小時,甚至連“git status”都要10分鐘,簡直沒法用。于是有人開始考慮通過引入一些主從的特性來改進git。但因為他們對開源社區的無知,甚至連搜索一下都不,就給這個東西起名叫gvfs(git virtual files ystem),全然不顧已經有叫這個名字的知名項目GNOME virtual file system。被詬病了幾年才改名叫VFSForGit。它不是git的直接替代。首先是引入了一個新的協議,用于虛擬化repo里的文件。
在克隆的時候,不用git clone,而用gvfs clone。在.git和工作目錄下的所有文件都只是個符號鏈接,指向服務器上的真實文件(有了中心服務器的概念),在本地硬盤上不占空間。然后有個后臺駐留程序在監視這個虛擬化。讀文件的時候,它就把文件內容從服務器取到本地的cache,修改文件的時候,它就把符號鏈接替換成硬盤上的普通文件(相當于自動checkout)。同時這個駐留程序還監控文件讀寫的操作。如果文件沒有被寫過,就認為內容不變。這樣就只需要比較被寫過的文件,而不是目錄下所有文件(相當于不按內容判斷是否相同)。然而,這其實破壞了git的很多設計原則,以及放棄了按文件內容決定是否發生改變的規則。顯而易見沒可能被官方的git采納。這些對規則的破壞,這也使得VFSForGit無法和很多git GUI很好地配合使用,包括TortoiseGit。
因此,微軟換了個方向,新做了一個叫做Scalar的系統。這個就不用虛擬化了,也不會改變git的工作流。它是以擴展的形式,優化原有git的部分clone和稀疏checkout,不再修改git的基礎。但它的適用性仍然是個問題。目前只有微軟fork的git和Azure devops支持這個。實際上meta和google也一直在等待著git能更好地支持單一巨型庫,并時不時嘗試從自己開發的系統里切換過去。
但是隨著時間的發展,總會有更多改進被合并到官方的git去。這個問題會慢慢改善。對絕大部分項目來說,這些問題并不會遇到,也不會是問題。
十、總結
像git這樣靈活的系統,達到同個目的往往存在多條路徑。這里提到的這些git最佳實踐,希望能幫助朋友們找到路徑中最優的一條。你越是了解git,越能明白邏輯正確的版本控制應該是什么樣的,越會支持git的使用。而正好相反的是p4。你越是不了解p4,越會支持p4的使用,因為它并沒有給人思考的余地,所以用再久也沒法了解什么是版本控制。