Git 是如何工作的

Git 是如何工作的

http://zoo.zhengcaiyun.cn/blog/article/git-work

前言

Git 是一個分佈式的版本控制系統,這意味着它使用多個本地存儲庫,包括一個集中式存儲庫和服務器,它在從前端工作中抽象出底層機制方面做得非常出色。雖然 Git 已經演變成一個成熟的版本控制管理系統,但這並不是作者最初的意圖,但並不影響它成爲最爲世界上最爲出色、優雅的工具之一。Git 的好處在於,你可以在整個職業生涯中都不知道 Git 內部是如何工作的,但你依然可以和它相處得很好。但當你瞭解了 Git 如何管理您的存儲庫將有助於打開你的思維方式,並讓您更深入地瞭解 Git 。

Git 的特性

區別

優勢

Git 實際上是如何工作的

當我們要去探究 Git 是如何工作的時候我們該從何處下手呢?因爲上文說過 Git 近乎所有操作都是本地執行的,在本地的文件中我們能找到他執行的記錄,這就需要我們聚焦本地文件的 Git 文件 —— .git 文件,那麼接下來就來看看 Git 的本地文件都有些什麼。

Git 對象

.git 文件作爲一個隱藏文件並不經常出現在我們的目錄中,現在我們打開一個從代碼倉庫拉取的項目,打開終端程序並導航到存儲庫的主目錄,再導航到存儲庫的 .git 目錄:

cd .git

拉出 .git 的目錄列表,那麼你至少能看到以下幾個目錄:

FETCH_HEAD/             
HEAD/
config/
objects/
refs/

現階段我們需要聚焦的是objects目錄,在 objects 中,我們最常見的對象是以下三種(具體的下文會詳細說明這三者):

Commits 對象

直接進入 object 對象:

cd objects
ls

控制檯展示:

// 每個人的項目都不同,文件自然也不同,此處以筆者的一個項目爲例
0c      57      85      b3       
1b      60      94      c4      
2a      67      98      cb      
2c      6c      9a      info      
3c      73      a9      pack      
49      82      af      
52      83      b1

小朋友你是否有很多問號?在第一眼看到這麼多兩位字符的文件夾名時完全不知道這些是啥。那麼我們就需要轉頭來解釋一下 Git 的數據存儲結構 了。

當 Git 存儲對象(也就是我們提交的記錄)時,它不會將它們全部轉儲到一個目錄中,因爲這樣會使得目錄在不斷的迭代提交後變得笨拙,所以它會將它們整齊地構造成一棵樹—— Git 將對象哈希的前 2 個字符用作目錄名稱,然後將剩餘的 38 個字符用作對象標識符。當我們將以上的二位字符命名的文件夾展開時,我們就會得到這樣一個樹形結構的目錄:

objects
├── 0c
│   ├── 8867d7e175f46d4bcd66698ac13f4ca00cf592
│   └── c8002da0403724dfaa6792885eaa97faa71bcf
├── 1b
│   └── 716fafdd3aeb3636222a0026d1d4971078db05
├── 2a
│   └── 14f7db6a6748cc98862960ff5d0e9b1d4a2f17
├── 2c
│   ├── 14f7db6a6748cc98862960ff5d0e9b1d4a2f17
├── 3c
│   ├── 121291ffc25ce6792f9350883b77cea2633048
.
.
.

爲了驗證上述 Git 存儲對象的結構,我們可以查看當前最新的 4 次提交,並取第一條記錄去提交記錄的結構樹中匹配:

command: git log -4 --oneline

9a5bf36 (HEAD -> master) feat: third commit
2c5331f feat: second commit
60814e1 feat: first commit
49942f3 Initial commit

我們能看到最近的 4 次提交,並且每次提交都會有一個 7 位長的哈希值以及提交時的描述。以 9a5bf36  這次提交爲例,我們可能會有個疑問:這隻有 7 位似乎跟我們說的不太一樣呀。別急!我們需要轉換一下,將他轉換成完整的長哈希值,因爲在樹結構中是以長哈希值構建生成的。

git rev-parse 9a5bf36

Git 以等效的長哈希值響應:9a5bf367f10390c64a3f7b3e738b78bd78a3d781.

將其分解爲目錄名稱和對象標識符:

我們很容易就能看到找到:

objects
├── 0c
│   ├── 8867d7e175f46d4bcd66698ac13f4ca00cf592
│   └── c8002da0403724dfaa6792885eaa97faa71bcf
├── 1b
│   └── 716fafdd3aeb3636222a0026d1d4971078db05
.
.
.
├── 98
│   ├── ed6b3f02409778bc864d8897bc230c90cae445
├── 9a
│   ├── 5bf367f10390c64a3f7b3e738b78bd78a3d781   //====>在這
.
.

既然我們知道了它的存儲結構,那麼我們自然就應該打開這個文件查看文件的內容,但是我們不能直接查看此對象,因爲 Git 中的對象是經過壓縮的。如果您嘗試使用 cat 5bf367f10390c64a3f7b3e738b78bd78a3d781 或類似方式查看它,您可能會看到一堆像這樣的亂碼,以及計算機嘗試從二進制對象讀取控制字符時發出的咔呲聲:

6?$?(?E9?z??nUmV?Em]?p??3?`??????q?Ţqjw????VR?O? q?.r???e|lN?p??Gq?)?????#???85V?W6?????
)|Wc*??8?1a?b?=?f*??pSvx3??;??3??^??O?S}??Z4?/?%J?
xu?Ko?0??̯?51??Ԯ
yB
    ??f?y?cBɯo?{ݝ?|ҌFL?:?@??_?0Td5?D2Br?D$??f?B??b?5W?HÁ?H*?&??(fbꒉdC!DV%?????D@?(???u0??8{?w
    ????0?IULC1????@(<?s '
mO????????ƶe?S????>?K8                  89_vxm(#?jxOs?u?b?5m????=w\l?
%?O??[V?t]?^??????G6.n?Mu?%
                           ?̉?X??֖X
                          v??x?EX???:sys???G2?y??={X?Ռe?X?4u???????4o'G??^"qݠ???$?Ccu?ml???vB_)?I?
`??*ގF?of??O

我們可以使用命令:

git cat-file -p 9a5bf36
sanqius-MacBook-Pro:3c zcy$ git cat-file -p 9a5bf36
tree 85b9416a23f8fb018181f96e5c01ba4bd923b965
parent 2c5331fd7046e561aad8fdde3e3f21375a17549c
author 三秋 <sanqiu@***.com> 1665729807 +0800
committer 三秋 <sanqiu@***.com> 1665729807 +0800

feat: third commit

我們看到的這個文件內部的這些內容其實就是一個對象,一個包含了 tree、parent、author... 等數據的對象,這個對象就是 Commits 了。

Commits 對象是以鍵值對的形式展示的,這個 Commits 指向一個 Hash 值爲 2c5331fd7046e561aad8fdde3e3f21375a17549c  的 parent ,其實這個 parent 同樣是一個 Commits 對象,這很好理解。但是這個 Commits 還有一個 Hash 值爲 85b9416a23f8fb018181f96e5c01ba4bd923b965  的 tree 屬性,也就是我們上面所說的第二個常用對象 Tree 。接下來我們需要聚焦的是 Commits 對象中的 Tree。

Tree 對象

這個提交的文章目錄裏面有什麼?我們使用相同的命令打開這個哈希值指向的文件:

git cat-file -p 85b9416a23f8fb018181f96e5c01ba4bd923b965
100644 blob 0cc8002da0403724dfaa6792885eaa97faa71bcf    README.md
040000 tree 3c121291ffc25ce6792f9350883b77cea2633048    src

我們發現這個 Tree 對象下有兩個,一個是 Blob 類型的 README.md 文件和 Tree 類型的 src 的文件夾,可以看出 Tree 是可以嵌套的,並且這個結構似乎有點眼熟,沒錯這就是我們項目的目錄結構,這也就能解釋爲什麼說 Commits 對象下的 Tree 就是對應着這個代碼版本的文件快照了。(100644 代表它是一個普通的文件,100755 表示一個可執行文件,120000 僅僅是一符號鏈接)

Blobs 對象

接上文,現在這個 Tree 文件類型已經出現我們的第三類對象 Blob 了,打破砂鍋問到底,繼續看看這個 Blob 是啥:

git cat-file -p 0cc8002da0403724dfaa6792885eaa97faa71bcf
MIT License

Copyright (c) 2019

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell...
<snip>

我們可以看到其實這就是我們在這個代碼版本下的文件內容,這也就意味着 Blob 其實就是存放文件的內容

總結

放一張圖用來總結一下 Commits、Tree、Blob 三者之間的關係:

分支創建與合併

在上文中,我們不難知道每一次提交記錄其實就是向代碼倉庫提交一次 Commits 對象,還記得 Commits 對象中的 Parent 屬性嗎, Parent 屬性指向的是當前基變的原型版本。那麼當有多個 Commits 提交後,我們能得到這樣一個結構的 Commits 流:

用過圖形化 Git 工具的同學有沒有覺得這個很眼熟,沒錯,圖形化工具就是將 Commits 關係視圖化,就得到我們常用的 SourceTree 、GitKraken 這些常用的圖像化 Git 工具,當然這些軟件肯定沒這麼簡單,但基本原理還是一樣的。

現在來談分支,Git 中的分支,其實本質上僅僅是個指向 Commit 對象的可變指針。Git 會使用 Master 作爲分支的默認名字。在若干次提交後,你其實已經有了一個指向最後一次提交對象的 Master 分支,它在每次提交的時候都會自動向前移動。

當我們創建一個新的分支時,其實就是在當前 Commit 對象上新建一個分支指針。這也就是爲什麼當我們新建一個分支的時候會如此迅速。

那麼 Git 是如何知道你當前在哪個分支上工作的呢?其實答案也很簡單,它保存着一個名爲 HEAD 的特別指針。在 Git 中,它是一個指向你正在工作中的本地分支的指針。所以當我們切換分支的時候就是切換 HEAD 指針的指向,這和大多數版本控制系統形成了鮮明對比,它們管理分支大多采取備份所有項目文件到特定目錄的方式,所以根據項目文件數量和大小不同,花費的時間也會有很大的差別,快則幾秒,慢則數分鐘。而  Git 的實現與項目複雜度無關,它永遠可以在幾毫秒的時間內完成分支的創建和切換。

當我們分別在 Master、testing 分支分別進行了一些修改,並將代碼提交,那麼我們就會得到這樣結構的分支關係,當前 Master、testing 分支最新的代碼的父級記錄指向的都是同一個。

讀到這我們可以總結出分支的本質:

  1. 當我們切換到一個命名分支,其實只是切換一個引用提交哈希的標籤。

  2. Git 是通過哈希值來找到該提交對象,然後從提交對象中獲取樹哈希。

  3. 然後 Git 沿樹對象遞歸,找到哈希對應的快照文件對象,然後解壓縮文件對象。

  4. 您的工作目錄現在代表該分支的狀態,因爲它存儲在存儲庫中。

代碼合併與衝突

當我們繼續在 testing 分支進行開發,且 Master 與 testing 分支的開發是在兩個不同文件中,那麼當我們要將 testing 分支合併到 Master 分支中去時,Git 實際上會將兩個分支的末端(A5 和 A7)以及它們的共同祖先(A3)進行一次簡單的三方合併計算。

這次,Git 沒有簡單地把分支指針右移,而是對三方合併後的結果重新做一個新的快照,並自動創建一個指向它的提交對象( A8 )。這個提交對象比較特殊,它有兩個祖先( A5 和 A7 )。

此時我們知道了代碼的合併是如何進行的,但當我們在兩個分支都同時修改了同一處代碼時,那麼當你合併代碼的時候碰到這樣的提示時,就意味着我們在進行代碼合併時出現了代碼衝突。

// 代碼合併衝突提示
Auto-merging index.html
CONFLICT (content): Merge conflict in index.html
Automatic merge failed; fix conflicts and then commit the result.

當我們打開衝突的文件,你會看到類似於這種

<<<<<<< HEAD
<div id="footer">contact : email.support@github.com</div>
=======
<div id="footer">
  please contact us at support@github.com
</div>
>>>>>>> iss53

可以看到 ======= 隔開的上半部分,是 HEAD(即 Master 分支,在運行 Merge 命令時所切換到的分支)中的內容,下半部分是在 testing 分支中的內容。解決衝突的辦法無非是二者選其一或者由你手動整合到一起。但是 Git 是如何進行 Diff 的呢?

代碼合併算法(Myers)

Git 的 Diff 是基於 Myers 算法進行的,那麼先來了解一下 Myers 算法。Myers 算法由 Eugene W.Myers 在 1986 年發表的一篇論文中提出,是一個能在大部分情況產生” 最短的直觀的 “diff 的一個算法。

differ

Diff 就是尋找目標文本和源文本之間的區別,也就是將源文本變成目標文本所需要的操作。舉一個 Myers 算法中最常用的例子,A1 = ABCABBA,A2 = CBABAC,那麼通過怎樣的操作才能使得由 A1 轉變成 A2 呢。

例如:
1.  - A       2.  - A       3.  + C
    - B           + C           - A
      C             B             B
    - A           - C           - C
      B             A             A
    + A             B             B
      B           - B           - B
      A             A             A
    + C           + C           + C

這三種都是有效的變動方式,其實這種轉化過程有很多種,那麼那種轉換過程纔是最高效的呢?我們在變動時有這麼一個共識:

面對這個問題我們可以將這個問題抽象成一個數學問題,生成 “直觀” 的 Diff 算法。抽象的結果是:尋找 Diff 的過程可以被表示爲圖搜索

圖搜索

還是以兩個字符串,A1 = ABCABBA ,A2 = CBABAC  爲例,根據這兩個字符串我們可以構造下面一張圖,橫軸是 A1 內容,縱軸是 A2 內容,要想從 A1 變換成爲 A2 抽象的數學問題就是求一條從左上角到右下角的路徑。圖中每一條從左上角到右下角的路徑,都表示一個 Diff。向右表示 “刪除”,向下表示” 新增“,對角線則表示“原內容保持不動“。

將上述的共識再次進行數學抽象化就對應爲:

就像走迷宮一樣,我們就可以摸索得到這麼一條路徑:

①.  (0, 0) -> (1, 0) -> (2, 0) 
②.  (2, 0) -> (3, 1)
③.  (3, 1) -> (3, 2)
④.  (3, 2) -> (4, 3) -> (5, 4)
⑤.  (5, 4) -> (6, 4)
⑥.  (6, 4) -> (7, 5)
⑦.  (7, 5) -> (7, 6)

這條路徑代表的 diff 的操作爲:

- A
- B
  C
+ B
  A
  B
- B
  A
+ C

代碼 diff

我們以上文中的幾次提交中的任意兩次 2c5331f 和   60814e1 提交進行 diff:

command: git diff 2c5331f 60814e1

2c5331f 和  60814e1 表示兩個文件的 Hash,相當於它們的 HashID,這個 HashID 就代表了一個文件對象的特定版本,最後的一串數字代表了一個文件的模式。

Git 會告訴你哪些行存在差異,它們被顯示在兩個 “@@” 符號之前,以上圖示例中所表示的含義爲:

@@ -1,15 +1,5 @@
-  console.log('watch')
-
-  const add = (a,c) ={
-    return a+c
-  }
-  const reduce=(a)=>{
-    if (a<0){
-      return  "第一位不能爲負數"
-    }else {
-      return a-b
-    }
+  const add = (a,b) ={
+    return a+b
   }
   add(4,8)
-  console.log(reduce(-2,-9))
-  console.log(new Date().getDate(),'第二次提交')

而”@@” 後面的緊跟着的部分就是其上下文信息,在每一個被改動過的代碼行之前都會前置一個 “+” 或是 “-” 符號。這些符號可以幫助你準確瞭解版本 a 和版本 b ,例如前置了 “-” 符號的行就代表來自版本 a ,反之帶有符號 “+” 的行就代表來自於版本 b 。

結尾

上述已經粗淺的爲大家介紹了 Git 的一些簡單原理,但這只是 Git 的冰山一角,如果大家還有興趣可以繼續深入學習,相信大家能夠爲自己開拓出一塊新的知識領域。

參考資料

《Pro Git》

《Advanced Git》

The Myers diff algorithm: part 1(https://blog.jcoglan.com/2017/02/12/the-myers-diff-algorithm-part-1/)

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/DBnbDjY_oXuM-X6m2K5oeQ