版本管理三國志 (CVS, Subversion, git)

ii_chengzi發表於2019-11-07

最近有一則和git有關的新聞很火:

 (GitHub基於git)

git是一款版本控制軟體(VCS,Version Control System)。VCS通常用於管理開發過程中的原始碼檔案。VCS是軟體開發的好幫手。當軟體本身在釋出時獲取大量關注時,VCS躲在幕後默默管理和記錄軟體的開發和釋出程式。git頗有戲劇性的借春運搶票火了一把,也讓許多人好奇什麼是git,什麼是VCS。我複習了一下VCS的歷史,忽然有些讀三國時的你方唱罷我登場的感覺,就想寫一個VCS版本的三國志。

現在最常見的VCS軟體(同時也是開源的VCS軟體)有CVS, Subversion和git。CVS曾經雄霸一時,至今還管理著大量的開發專案。Subversion青出於藍,對CVS進行改進,大有取而代之的勢頭。git另闢蹊徑,依仗Linux的名號,並借GitHub的推廣攻城略地。VCS領域激烈的爭鬥正反映了軟體開發專案的紅火勢頭。

 

斬白蛇而起

早期(1970年到1980年代)的軟體開發大部分是愉快的個人創作。比如UNIX下的sed是L. E. McMahon寫的,Python的第一個編譯器是Guido寫的,Linux最初的核心是Linus寫的 (好吧,awk是個例外,它的名字是三位作者的首字母,但也只是三個人)。這些程式設計師可以用手工的方式進行備份,並以註釋或者新建文字檔案來記錄變動。

正如現在普通使用者常做的,當時的程式設計師常用cp備份:

$cp dev.c dev.bak

更有條理一些的程式設計師會加上一個時間標記,比如:

$cp dev.c dev.bak.19890908

程式設計師很可能會用vi建立一個LOG檔案來做日誌:

1989-09-08 02:00:00
Old input method is stupid
Add command-line input function

在一個版本釋出的時候,程式設計師可能做一個tar歸檔,將所有的檔案歸為同一個.tar檔案。

$tar -cf project_v1.0.tar project

上面的工具構成了一套人工VCS。上面的這套組合也非常符合UNIX的模組化理念:讓每個應用專注於一個小的功能,使用者根據需要,將這些功能連線起來。你還可以寫一個shell指令碼,將上面的功能都寫在裡面。當需要的時候,呼叫該指令碼就可以了。

(這樣一個shell指令碼並不複雜,而且挺有用的,可以作為學習shell程式設計的小練習)

 

再說一下早期的合作開發模式。如在 Python簡史中看到的,Guido透過電子郵件接收補丁(patch),並將補丁應用到原來的程式碼檔案。實際上,一個補丁(patch)的主要功能是描述兩個檔案的 改變(change, or file delta)。 假設我們有兩個檔案a.c和b.c內容分別為:
a.c (有bug的程式碼)

int sum(int a, int b)
{  int c;
  c = a + 1;  return c;
}

b.c (修正後的程式碼)

int sum(int a, int b)
{  int c;
  c = a + b;  return c;
}

 

在UNIX系統下,執行

$diff a b > iss01.patch

iss01.patch就是一個補丁檔案,它看起來如下:

4c4<   c = a + 1;---
>   c = a + b;

這個補丁表示,更改原檔案第四行的 c = a + 1; ,改為 c = a + b;,更改後的這一行位於新的檔案的第四行。

 

使用 patch命令將iss01.patch應用到a.c上,相當於將 b.c-a.c 的改變作用在a上,a.c將和b.c有一樣的內容:

$patch a.c < iss01.patch

當我發現a.c的程式碼有錯誤時,可以將我修改後的b.c與原來的a.c做diff獲得補丁檔案,並將補丁發給Guido,並告訴他該補丁是為了修正a.c程式碼中的加法錯誤。Guido確認之後,就可以使用 patch應用該補丁了。在後面我們將看到,這種diff-patch的工作方式被VCS不同程度的採用。

 

東漢末年

早在70年代末80年代初,VCS的概念已經存在,比如UNIX平臺的RCS (Revision Control System)。RCS是由Walter F. Tichy使用C開發。RCS對檔案進行集中式管理,主要目的是避免多人合作情況下可能出現的衝突。如果多使用者同時寫入同一個檔案,其寫入結果可能相互混合和覆蓋,從而造成結果的混亂。你可以將檔案交給RCS管理。RCS允許多個使用者同時讀取檔案,但只允許一個使用者 鎖定(locking)並寫入檔案 (類似於 多執行緒的mutex)。這樣,當一個程式設計師登出(check-out,見RCS的 co命令)某個檔案並對檔案進行修改的時候。只有在這個程式完成修改,並登入(check-in,見RCS的 ci命令)檔案時,其他程式設計師才能登出檔案。基本上RCS使用者所需要的,就是co和ci兩個命令。在co和ci之間,使用者可以對原檔案進行許多改變(change, or file delta)。一旦重新登入檔案,這些改變將儲存到RCS系統中。透過check-in將改變永久化的過程叫做提交(commit)。

RCS互斥寫入

RCS的互斥寫入機制避免了多人同時修改同一個檔案的可能,但代價是程式設計師長時間的等待,給團隊合作帶來不便。如果某個程式設計師登出了某個檔案,而忘記登入,那他就要面對隊友的怒火了。(從這個角度上來說,RCS造成的問題甚至大於它所解決的問題……)

檔案每次commit都會創造一個新的版本(revision)。RCS給每個檔案建立了一個追蹤文件來記錄版本的歷史。這個文件的名字通常是原檔名加字尾 ,v (比如main.c的追蹤文件為main.c,v)。追蹤文件中包括:最新版本的檔案內容,每次check-in的發生時間和使用者,每次check-in發生的改變。在最新文件內容的基礎上,減去歷史上發生的改變,就可以恢復到之前的歷史版本。這樣,RCS就實現了備份歷史和記錄改變的功能。

 

RCS歷史版本追蹤

 

相對與後來的版本管理軟體,RCS純粹線性的開發方式非常不利於團隊合作。但RCS為多使用者寫入衝突提供了一種有效的解決方案。RCS的版本管理功能逐漸被其他軟體(比如CVS)取代,但時至今日,它依然是常用的系統管理工具。RCS就像是東漢王室,飄搖多年而不倒。

 

挾天子,令諸侯

1986年,Dick Grune寫了一系列的shell指令碼用於版本管理,並最終以這些指令碼為基礎,構成了CVS (Concurrent Versions System)。CVS後來用C語言重寫。CVS是開源軟體。在當時,Stallman剛剛舉起GNU的大旗,掀起開源允許的序幕。CVS被包含在GNU的軟體包中,並因此得到廣泛的推廣,最終擊敗諸多商業版本的VCS,呈一統天下之勢。

CVS繼承了RCS的集中管理的理念。在CVS管理下的檔案構成一個庫(repository)。與RCS的鎖定檔案模式不同,CVS採用 複製-修改-合併(copy-modify-merge)的模式,來實現多線開發。CVS引進了 分支(branch)的概念。多個使用者可以從主幹(也就是中心庫)建立分支。分支是主幹檔案在本地複製的副本。使用者對本地副本進行修改。使用者可以在分支提交(commit)多次修改。使用者在分支的工作結束之後,需要將分支合併到主幹中,以便讓其他人看到自己的改動。所謂的合併,就是CVS將分支上發生的變化應用到主幹的原檔案上。比如下面的過程中,我們從r1.1分支出rb1.1.2.*,並最終合併回主幹,構成r1.2

 copy-modify-merge

 

CVS與RCS類似,使用,v檔案記錄改變,以便追蹤歷史。在合併的過程中,CVS將兩個change應用於r1.1,就得到了r1.2:

r1.2 = r1.1 + change(rb1.1.2.2 - rb1.1.2.1) + change(rb1.1.2.1-r1.1)

上面的兩個改變都記錄在,v檔案中,所以很容易提取。

 

在多使用者情況下,可以建立多個分支進行開發,比如:

在這樣的多分支合併的情況下,有可能出現 衝突(colliding)。比如上圖中,第一次合併和第二次合併都對r1.1檔案的同一行進行了修改,那麼r1.3將不知道如何去修改這一行 (第二次合併比圖示的要更復雜一些,分支需要先將主幹拉到本地,合併過之後傳回主幹,但這一細節並不影響我們這裡的討論)。CVS要求衝突發生時的使用者手動解決衝突。使用者可以呼叫編輯器,對檔案發生合併衝突的地方進行修改,以決定最終版本(r1.3)的內容。

 

CVS管理下的每個檔案都有一系列獨立的版本號(比如上面的r1.1,r1.2,r1.3)。但每個專案中往往包含有許多檔案。CVS用標籤(tag)來記錄一個集合,這個集合中的元素是一對(檔名:版本號)。比如我們的專案中有三個檔案(file1, file2, file3),我們建立一個v1.0的標籤:

tag v1.0  (file1:r1.3) (file2:r1.1) (file3:r1.5)

v1.0的tag中包括了r1.3版本的檔案file1,r1.1版本的file2…… 一個專案在釋出(release)的時候,往往要釋出多個檔案。標籤可以用來記錄該次釋出的時候,是哪些版本的檔案被髮布。

 

CVS應用在許多重要的開源專案上。在90年代和00年代初,CVS在開源世界幾乎不二選擇 (RCS也是開源的,但正如我們已經提到的,RCS無法與CVS媲美)。CVS就像是官渡之戰後的曹魏,挾開源運動,號令天下。時至今天,儘管CVS已經長達數年沒有釋出新版本,我們依然可以在許多專案中看到CVS的身影。

 

青出於藍

正如曹操的統治富有爭議一樣(比如非漢祚,以臣欺君等等),CVS也有許多常常被人詬病的地方,比如下面幾條:

  • 合併不是原子操作(atomic operation):如果有兩個使用者同時合併,那麼合併結果將是某種錯亂的混合體。如果合併的過程中取消合併,不能撤銷已經應用的改變。
  • 檔案的附加資訊沒有被追蹤:一旦納入CVS的管理,檔案的附加資訊(比如上次讀取時間)就被固定了。CVS不追蹤它所管理檔案的附加資訊的變化。
  • 主要用於管理ASCII檔案:不能方便的管理Binary檔案和Unicode檔案
  • 分支與合併需要耗費大量的時間:CVS的分支和合並非常昂貴。分支需要複製,合併需要計算所有的改變並應用到主幹。因此,CVS鼓勵儘早合併分支。

CVS還有其它一些富有爭議的地方。隨著時間,人們對CVS的一些問題越來越感到不滿 (而且程式設計師喜歡新鮮的東西),Subversion應運而生。Subversion的開發者Karl Fogel和Jim Blandy是長期的CVS使用者。贊助開發的CollabNet, Inc.希望他們寫一個CVS的替代VCS。這個VCS應該有類似於CVS的工作方式,但對CVS的缺陷進行改進,並提供一些CVS缺失的功能。這就好像劉備從曹營拉出來單幹的劉備一樣。

總體上說,Subversion在許多方面沿襲CVS,也是集中管理庫,透過記錄改變來追蹤歷史,允許分支和合並,但並不鼓勵過多分支。Subversion在一些方面得到改善。Subversion的合併是原子操作。它可以追蹤檔案的附加資訊,並能夠同樣的管理Binary和Unicode檔案。但CVS和Subversion又有許多不同:

  • 與CVS的,v檔案儲存模式不同,Subversion採用關係型資料庫來儲存改變集。VCS相關資料變得不透明。
  • CVS中的版本是針對某個檔案的,CVS中每次commit生成一個檔案的新版本。 Subversion中的版本是針對整個檔案系統 (包含多個檔案以及檔案組織方式),每次commit生成一個整個專案檔案系統樹的新版本。

Subversion依賴類似於硬連線(hard link)的方式來提高效率,避免過多的複製檔案本身。Subversion不會從庫下載整個主幹到本地,而只是下載主幹的最新版本。

 

在Subversion剛剛誕生的時候,來自CVS使用者的抱怨不斷。他們覺得在Subversion中有太多的改動,有些改動甚至是相對於CVS的倒退。比如CVS中的tag,在Subversion中被改為直接複製版本的檔案系統樹到一個特殊的資料夾。然而,隨著時間的推移,Subversion逐漸推廣 (Subversion已經是Apache中自帶的一個模組了,Subversion應用於GCC、SourceForge,新浪APP Engine等專案),並依然有活躍的開發,而CVS則逐漸沉寂。事實上,許多UNIX的參考書的新版本中,都縮減甚至刪除了CVS的內容。

 

別開生面

CVS和Subversion有很多不同的地方。但如果將這兩者和git比較,那麼git看起來就像孫權的碧眼,有一些怪異。

git的作者是Linus Torvald。對,就是寫Linux Kernel的那個Linus Torvald。Linus在貢獻了最初的Linux Kernel原始碼之後,一直領導著Linux Kernel的開發。Linus Torvald本人相當厭惡CVS(以及Subversion)。然而,作業系統核心是複雜而龐大的程式碼“怪獸” (2012年的Linux Kernel有1500萬行程式碼,Windows的程式碼不公開,估計遠遠超過這一數目)。Linux核心小組最初使用.tar檔案來管理核心程式碼,但這遠遠無法匹配Linux核心程式碼的增長速度。Linus轉而使用BitKeeper作為開發的VCS工具。BitKeeper是一款分散式的VCS工具,它可以快速的進行分支和合並。然而由於使用證照方面的爭議(BitKeeper是閉源軟體,但給Linux核心開發人員發放免費的使用證照),Linus最終決定寫一款開源的分散式VCS軟體:git。

git在英文中比喻一個愚蠢或者不愉快的人(a stupid or unpleasant person)。Linus說這個比喻是在說自己:

I'm an egotistical bastard, and I name all my projects after myself. First "Linux", now "git".

(這裡,Linus似乎並不是在貶低自己,見Linus和Eric S. Raymond的爭論:  The curse of the gifted)

 

對於一個開發專案,git會儲存blob, tree, commit和tag四種物件。

  • 檔案被儲存為blob物件。
  • 資料夾被儲存為tree物件。tree物件儲存有指向檔案或者其他tree物件指標。

上面兩個物件類似於一個UNIX的檔案系統,構成了一個檔案系統樹。

  • 一個commit物件代表了某次提交,它儲存有修改人,修改時間和附加資訊,並指向一個檔案樹。這一點與Subversion類似,即每次提交為一個檔案系統樹。
  • 一個tag物件包含有tag的名字,並指向一個commit物件。

虛線下面的物件構成了一個檔案系統樹。在git中,一次commit實際上就是一次對檔案系統樹的 快照(snapshot)。

 

每個物件的內容的checksum校驗(checksum校驗可參閱 IP頭部的checksum)都經過SHA1演算法的HASH轉換。每個物件都對應一個40個字元的HASH值。每個物件對應一個HASH值。兩個內容不同的物件不會有相同的 HASH值(SHA1有可能發生碰撞,但機率非常非常非常低)。這樣,git可以隨時識別各個物件。透過HASH值,我們可以知道這個物件是否發生改變。

比如一個檔案LOG,它包含一下內容:

aaa

這個檔案的HASH碼為 72943a16fb2c8f38f9dde202b7a70ccc19c52f34

如果我們修改這個檔案,成為

aaa

bbb

這個檔案的HASH碼變成 dbee0265d31298531773537e6e37e4fd1ee71d62

所以,git只需看物件的HASH碼,就可以知道該物件是否發生改變。

 

在整個開發過程中,可能會有許多次提交(commit)。每次commit的時候,git並不總是複製所有的物件。git會檢驗所有物件的HASH值。如果該物件的HASH值已經存在,說明該物件已經儲存過,並且沒有發生改變,所以git只需要調整新建tree或者commit中的指標,讓它們指向已經儲存過的物件就可以了。git在commit的時候,只會新建HASH值發生改變的物件。如下圖所示,我們建立新的commit的時候,只需要新建一個commit物件,一個tree物件和一個blob物件就足夠了,而不需要新建整個檔案系統樹。

 

可以看到,與CVS,Subversion儲存改變(file delta)的方式形成對照,git儲存的不是改變,而是此時的檔案本身。由於不需要遵循改變路徑來計算歷史版本,所以git可以快速的查閱歷史版本。git可以直接提取兩個commit所儲存的檔案系統樹,並迅速的算出兩個commit之間的改變。

 

同樣由於上面的資料結構,git可以很方便的建立分支(branch)。實際上, git的一個分支是一個指向某個commit的指標。合併時,git檢查兩個分支所指的兩個commit,並找到它們共同的祖先commit。git會分別計算每個commit與祖先發生的改變,然後將兩個改變合併(同樣,針對同一行的兩個改變可能發生衝突,需要手工解決衝突)。整個過程中,不需要複製和遵循路徑計算總的改變,所以效率提高很多。

比如下面的圖1中有兩個分支,一個master和一個develop。我們先沿著develop分支工作,並進行了兩次提交(比如修正bug1),而master分支保持不變。隨後沿著master分支,進行了兩次提交(比如增加輸入功能),develop保持不變。在最終進行圖4中的合併時,我們只需要將C4-C2和C6-C2的兩個改變合併,並作用在C2上,就可以得到合併後的C7。合併之後,兩個分支都指向C7。我們此時可以刪除不需要的分支develop。

由於git建立、合併和刪除分支的成本極為低廉,所以git鼓勵根據需要建立多個分支。實際上,如果分支位於不同的站點(site),屬於不同的開發者,那麼就構成了分散式的多分支開發模式。每個開發者都在本地複製有自己的庫,並可以基於本地庫建立多個本地分支工作。開發者可以在需要的時候,選取某個本地分支與遠端分支合併。git可以方便的建立一個分散式的小型開發團隊。比如我和朋友兩人各有一個庫,各自開發,並相互拉對方的庫到本地庫合併(如果上面master,develop代表了兩個屬於不同使用者的分支,就代表了這一情況)。當然,git也允許集中式的公共倉庫存在,或者多層的公共倉庫,每個倉庫享有不同的優先順序。git的優勢不在於引進了某種開發模式,而是給了你設計開發模式的自由。

正如東吳門閥合作的政治模式,git非集中式的開發模式讓git成為了後起之秀。生子當如孫仲謀,生子當如Git Torvald。

(需要注意的是,GitHub儘管以git為核心,但並不是Linus建立的。事實上, 。Linus本人將此歸罪於GitHub糟糕的Web UI。但有些搞笑的是,正是GitHub的Web頁面讓許多新手熟悉並開始使用git。好吧,Linus大嬸是在鞭策GitHub。)

來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/31543790/viewspace-2663159/,如需轉載,請註明出處,否則將追究法律責任。

相關文章