我使用 Git 已經很多年了。提交、推播、拉取,偶爾遇到問題也會感到恐慌。但如果你問我執行git commit
時到底發生了什麼,我會給你一個模糊的答案,例如“保存更改”,並希望你不要再問更多問題。
這讓我很困擾。所以我用 Rust 建構了自己的版本控制系統Veridian 。不是因為世界需要另一個 Git,而是因為我需要理解我們已經擁有的 Git。
事實證明,Git 比我想像的要簡單得多。
我們學習 Git 的方式是倒著學的。我們記住命令,卻不理解它們的作用。 git git add
暫存檔案。好吧,但暫存到底是什麼意思呢? git commit
儲存你的工作。很酷,但保存在哪裡,又是如何保存的呢?
我花了好幾年時間只是遵循命令。我會用 Git,但不懂它。建立 Veridian 改變了這一切。
撇開所有指令和功能,Git 只是一個內容可尋址的儲存系統。聽起來很複雜,但其實不然。
你有一個儲存系統,它根據內容而不是名稱來保存資料。輸入兩次相同的內容?儲存位置相同。更改一個字元?儲存位置不同。
這就是 Git。 “位置”是 SHA-1 雜湊值。 「儲存」是.git/objects
資料夾。
Blob是文件內容。取出你的文件,加入類似blob <size>\0
頭,對其進行哈希處理、壓縮、儲存。搞定。 Git 不關心檔名,只關心內容。
樹是目錄列表。它們透過列出檔案和資料夾及其雜湊值來表示「這個資料夾是這樣的」。樹指向 blob 和其他樹。
提交是帶有上下文的快照。每個提交都指向一棵樹(專案的樣子),指向父親提交(先前的提交),並包含作者、時間和訊息等元資料。
三種物件類型。這就是整個系統。
同一個文件在多個提交中?只保存一次。在一個大文件中更改了一行?只儲存新版本。想檢查兩個檔案是否相同?比較哈希值,立即獲得答案。
Git 不會一遍又一遍地複製你的專案。它只會儲存獨特的片段,並根據這些片段建立快照。這就是為什麼包含數百個提交的倉庫並不會變得龐大。
分支實際上是一個帶有提交哈希的文件。
檔案.git/refs/heads/main
包含 40 個字符,這是你最新提交的哈希值。當你建立分支時,Git 會使用目前提交的雜湊值寫入一個新檔案。當你提交時,Git 會使用新的雜湊值更新該檔案。
無需複製。只需更新一個小文字檔案。這就是分支“輕量級”的原因。
Git 使用 zlib 在儲存所有內容之前進行壓縮。因此,你的目標檔案不僅僅是原始內容,而是經過壓縮的。當我建立 Veridian 時,每次讀寫都必須處理這種壓縮和解壓縮。
具體過程如下:Git 取得你的 blob(包含檔案頭),壓縮後儲存在.git/objects/ab/cdef123...
中,其中ab
是哈希值的前兩個字符, cdef123...
是剩餘部分。拆分兩個字元只是為了避免在一個目錄中存放數千個文件,因為這會降低檔案系統的執行速度。
讀取文件意味著找到文件,用 zlib 解壓縮,解析文件頭檢查物件類型和大小,然後返回內容。 Rust 的標準函式庫沒有內建 zlib,所以我使用了flate2
crate 來實作。大概用了 5 行程式碼。
我以為建置版本控制系統會很難。但事實並非如此。
使用 Rust 進行建置非常有趣,因為 Rust 會讓你思考記憶體和所有權。當你對檔案進行哈希處理並建立樹時,你需要妥善處理錯誤(如果檔案不存在怎麼辦?)並謹慎管理緩衝區(你不能直接將一個 5GB 的檔案載入到記憶體中)。
但說實話,版本控制邏輯本身就很簡單。我的大部分程式碼只是讀取檔案、計算 SHA-1 雜湊值以及寫入壓縮資料。難點不在於演算法,而是理解 Git 到底在做什麼。
建立一個.veridian
資料夾。新增用於存放物件和引用的子資料夾。建立一個 HEAD 檔案。完成,你就有了一個倉庫。
讀取文件,加入文件頭,用 SHA-1 演算法計算雜湊值,用 zlib 壓縮,然後寫入.veridian/objects/
。返回哈希值。這就是檔案進入系統的過程。
遍歷一個目錄。對每個檔案進行哈希處理(產生 blob)。將所有檔案的名稱和哈希值放入一個樹形物件中。對這棵樹進行哈希處理。現在你就得到了目錄的快照。
我學到了一件事:樹狀結構條目需要按檔案名稱排序。如果不排序,相同的目錄結構會根據檔案處理順序產生不同的雜湊值。 Git 會對其進行排序以保持雜湊值的一致性。雖然細節不多,但很重要。
取一個樹形哈希。如果有父提交哈希,則加入。新增作者資訊和時間。新增訊息。對所有內容進行哈希處理。寫入物件。更新分支指針。更新 HEAD。這就是一次提交。
該實現出奇的小。它的運作方式與 Git 類似,因為 Git 實際上就是這麼簡單。
有趣的是:Git 將時間戳記儲存為 Unix 時間戳記(自 1970 年以來的秒數),並附帶時區資訊。因此,提交物件包含類似1760211794 +0530
的內容,這是時間戳記和時區偏移。當你git commit
,它會取得你的系統時間和時區。我使用了 Rust 的chrono
crate 來實現這一點,但你可以使用任何語言來實現。
Git 速度快的原因:它比較的是雜湊值,而不是檔案內容。 40 個字元的字串。超快。
為什麼會出現 HEAD 分離的情況: HEAD 通常指向一個分支文件,而該文件又指向一個提交。直接檢出提交? HEAD 會跳過分支,直接指向提交。 HEAD 分離是因為你不在分支上,而是在特定的提交上。
為什麼可以恢復已刪除的提交:它們仍然在.git/objects
中,只是沒有被引用而已。使用git reflog
,找到哈希值,然後恢復。只有垃圾回收才能真正刪除它們。
合併衝突的原因:兩個提交具有相同的父級,但對同一文件進行了不同的更改。 Git 無法決定哪一個提交勝出。它需要您來決定。
Git 之所以難,不是因為它複雜,而是因為我們學錯了。
一旦你明白了 Git 是一個鍵值儲存系統,其中鍵是內容哈希,值有三種類型(blob、tree、commit),那麼一切都說得通了。分支是指針。合併是將樹合併在一起。變基是將提交重播到不同的父級。
我使用 Git 很多年,卻一直搞不懂。後來我花了一週時間搭建了 Veridian,突然間 Git 就變得有意義了。不是因為搭建過程很神奇,而是因為它迫使你去理解正在發生的事情。
你不需要打造完美的東西。只要開始就好。
建立一個倉庫。將檔案儲存為 blob。建構一棵樹。進行一次提交。做好這四件事,你會比大多數開發人員更了解 Git。
建造 Veridian 大概花了一周時間。現在我用 Git 的時候,我其實知道發生了什麼事。只是一些資料結構和文件操作。沒什麼複雜的。
Veridian 並不完美。它缺少一些功能,可能還有一些 bug。但它教會了我 Git 的工作原理,這才是重點。
如果你想真正學習 Git,而不僅僅是使用它,那就去建立一些東西。即使它很小。即使它出了問題。你花一周時間建立它,比花幾個月閱讀文件學到的更多。
在 GitHub 上查看 Veridian 。打破它,修復它,從中學習。這就是它的工作原理。