你可能不知道的 Git

你可能不知道的 Git

PPT: ​你可能不知道的 Git​ 

前言

首先,本文假設你已經對 Git 有了初步的認知:

  • 理解 Git 在本地的三種工作區域:Git 倉庫、工作目錄和暫存區域;
  • 熟練操作 pull / push / branch / checkout / commit / ... 常見指令;

但是:

  • 希望能夠進一步瞭解 Git 的一些基本原理;
  • 偶爾會遇到與預期不一致的現象,掉入坑中。

 

如果你已經熟讀 Pro Git 等相關書籍、熟練使用各種底層命令,甚至有 Git 相關工具鏈的開發經驗,那本文可能不太適合你。

 

接下來將分爲兩大主題,文件系統與命令操作:

  • 文件系統主題將帶領大家去理解 Git 的存儲模型,我們的文件、commit 等信息是如何存儲的;
  • 命令操作主題將針對我們最常用的兩個命令 merge 和 rebase 去解釋它們是如何工作的。

文件系統

Snapshot-based, not delta-based

不知道大家有沒有經歷過不使用版本控制的項目,在大學期間我做的第一個外包項目的時候,雖然已經瞭解了 Git 的基本使用方式,但是完全沒有工程經驗,不知道源代碼版本控制的意義所在,就沒有選擇使用版本控制,然後就吃了大虧。在開發初期還好,但在聯調和交付階段,細節變更頻繁發生,而我應對變更區分版本的方式是複製文件夾,給文件夾命名版本號來區分版本,緩解了問題。

 

在和隊友覆盤的時候,我們深刻的意識到了版本控制的重要性,從那之後不管大小項目,都會使用 Git 來管理源代碼版本。

 

當然我講這個故事的目的並不是想說明版本控制的意義,而是想由此引出一個問題,如果你來實現一個版本控制工具,你會如何設計最核心的部分:存儲?

 

有同學的第一反應可能會是和上面我的做法類似,把整個項目存一份,打上版本號;想的更深一點的同學,可能會想,我只存每次的變更,每次切換版本的時候將變更串起來,得到文件,這樣是不是也行?

 

這其實是版本控制工具文件系統實現的兩種思路:基於快照(snapshot-based)和基於差異(delta-based)。那麼,已經知道答案的同學不要說出答案,大家再猜一猜,Git 是基於哪一種呢?

 

基於差異(delta-based)

 

基於快照(snapshot-based)

 

可能在平時提交 commit、merge request 等變更行爲之前,大家都會習慣性的去看一下 diff,也會對照 diff 進行 Code Review,這很容易給人一種錯覺:Git 是基於差異的,但實際 Git 是基於快照的。

 

基於快照的優勢在於,因爲每個版本的文件都是直接保存的,可以更簡單的實現快速切換版本,更易於實現分佈式的特性(例如 patch、bundle),而帶來的劣勢在於存儲空間會明顯佔用更多,一個文件即使改動一個字符也需要再單獨存一份,當然 Git 也適度的做了一些優化,包括主動的和被動的(zlib 壓縮、pack);

相比之下,基於差異的優勢則是存儲空間佔用極低,幾乎接近極限,而劣勢則是如果想要做到和基於快照一樣的切換版本速度,需要設計更復雜算法和邏輯(例如藉助索引將版本切換時間降到常數級別)。

 

這裏不去過多探討兩種方式的差異,我們需要了解的是 Git 使用了基於快照的方式存儲信息,這樣便於理解接下來的內容。

Objects

接下來我們一起來對 Git 文件系統的具體實現探個究竟。接着剛剛如何實現一個版本控制工具的問題,現在我們已經確定了用基於快照的思路,像我那樣每個版本把整個項目打包一份肯定是不太合適的,每個版本之間一般只會變更一小部分文件,那麼如何儘可能的減少存儲?大家應該會想到:沒有發生變更的文件只存儲一份,在各個版本之間只保存對這個文件的引用,使用過對象存儲服務的同學應該會想到對象存儲,例如 TOS,使用 k-v 來存儲文件,key 使用一個唯一值,value 則是文件。

 

Git 的核心部分正是這樣一個簡單的鍵值對存儲,key 是存儲對象加一些元數據一起做 SHA-1 校驗運算得到的哈希值,value 是壓縮過的數據對象。注意我這裏的說法是對象而非文件,因爲在 Git 的鍵值對存儲系統中,數據對象(blob object)是對象(Objects)類型的一種,爲什麼只是一種呢?

 

在上述的存儲系統中,我們存儲一個文件後,得到的只有一個哈希值作爲 key,這隻解決了存儲文件的問題,並沒有解決文件名存儲的問題,要將文件和文件夾組合起來,我們還需要一個用樹對象(tree object)來存儲這種關係,所以在 Git 中,項目文件的存儲形式可以簡化理解爲下圖:

 

簡化版的 Git 數據模型

 

現在有一些樹對象,分別代表了我們整個項目不同版本的快照。然而問題依舊:若想重用這些快照,你必須記住所有的 SHA-1 哈希值。 並且,你也完全不知道是誰保存了這些快照,在什麼時刻保存的,以及爲什麼保存這些快照。 而以上這些,正是提交對象(commit object)能爲你保存的基本信息。

 

上述這三種主要的 Git 對象——數據對象、樹對象、提交對象——最初均以單獨文件的形式保存在 ​.git/objects​ 目錄下。

 

SHA-1 哈希值的長度是 40 字符,​.git/objects​ 目錄下可以看到又做了一層以 2 字符命名的子目錄,子目錄下的文件名長度都是 38 字符,前面的 2 字符加上文件名的 38 字符就是 SHA-1 哈希值。

 

多提一嘴,Git 這樣做的意義主要在於優化查找速度,Git 可能跑在各種各樣的操作系統文件系統之上,有的文件系統同目錄下文件過多會導致查找緩慢(基於鏈表的文件查找時間複雜度爲線性,查找速度隨文件數量增長),哈希的均衡性相當於把文件打散到 16*16=256 個桶中,降低了線性的係數,也便於分散的去觸發 gc 優化存儲佔用,感興趣的同學可以稍後自行查找相關資料。

 

可能大家會想,如何驗證你說的是真的呢?在驗證之前,我們需要先了解一些不太常用的 Git 命令,他們將會幫助我們去做驗證。

 

由於 Git 是一套完整的版本控制系統,而不是純粹的面向用戶,所以它還包含了一部分用於完成底層工作的命令。 這些命令被設計成能以 UNIX 命令行的風格連接在一起,可以與腳本配合完成一些工作。 這部分命令一般被稱作「底層(plumbing)命令」,而那些更友好的命令則被稱作「高層(porcelain)」命令。

 

以 Vue3 的倉庫爲例,​g​ 是 ​git​ 的 alias,​cat-file​ 可以打印出 SHA-1 值對應的對象信息, ​master^{tree}​ 語法表示 ​master​ 分支上最新的提交所指向的樹對象,可以看到文件夾的類型是 tree,文件的類型是 blob:

➜  vue-next git:(master) g cat-file -p master^{tree}

040000 tree ddbd1f4f93be7551349641c6fd7b13853a5c3430        .circleci

040000 tree b907db0cd33f5eee41fc6a9264eac855b93f4585        .github

100644 blob 0a663edeb54caf4c87bce21c0ed3840b92c7c48b        .gitignore

100644 blob f5a1bdcdd2daf271a87314a475c36ca723c0b499        .prettierrc

040000 tree f9a363bc62a7b277b968a2ee39d0b65f3ca28e87        .vscode

100644 blob 4ab470e7d5667b51c02671940a3b2a89890a27b4        README.md

100644 blob 5d26120d66a6991086c11ce9e7ae15e573baaa73        api-extractor.json

100644 blob 343a47e7808ab4a11e161afb02e83a1779cbaa59        jest.config.js

100644 blob 95684f95724550d4ac35ad8473ab04fd19060d13        lerna.json

100644 blob edf9f47d9289da363411f0c052de09a9c9806a21        package.json

040000 tree aab9fcef4b707da87170027ffc23610fc36e8106        packages

100644 blob 56d34528a7a37f796c28739cbc989c988e5d0862        rollup.config.js

040000 tree 536d6046a5c3e6e3b0c3ef8aed1fbc5a6e4f25b7        scripts

100644 blob 3784a8e29d1809a48e14f3893e431d6f80570f19        tsconfig.json

100644 blob 9fb28cdba4257bb1b9888fb733c97a90ff606cd8        yarn.lock

 

我們進一步的去查看 tree,會發現它是如上圖所示的樹狀結構:

➜  vue-next git:(master) g cat-file -p aab9fcef4b707da87170027ffc23610fc36e8106

040000 tree 0d025022b6f8360b3d80ee5819b112885f340404        compiler-core

040000 tree a65012f14f650d41b47bcd7669a943367bdbbb8a        compiler-dom

100644 blob d84d24ac08b34a020d790460bd38b73753c982a9        global.d.ts

040000 tree 51a1a6f4477d767fcde58bc2420106ee40c99776        reactivity

040000 tree 25b42154a55f594c0b45bc5eb154a480d843e9a8        runtime-core

040000 tree a8de900be6577a766ff6bca3cec0fa6231fcb95a        runtime-dom

040000 tree 9d881bdd4f349df7460339225e5ac40a8995c047        runtime-test

040000 tree 368c477ccc2e47d1e407994fcf243afb556ad9ae        server-renderer

040000 tree e0a3ebe69e1aff614077ab4bbf2bbd4b419c4f05        shared

040000 tree 7d5be9ece62127b727bb6e08d20360a235709e64        template-explorer

040000 tree 0de10b0cd3e2d94cb927ad0267af63e7131c2bb3        vue

 

而查看 blob 則會直接打印出文件內容

➜  vue-next git:(master) g cat-file -p f5a1bdcdd2daf271a87314a475c36ca723c0b499

semi: false

singleQuote: true

printWidth: 80

 

這是文件系統讀的方式,同樣我們還可以藉助 ​hash-object​ 寫入 blob 對象,​write-tree​ 將暫存區寫入一個 tree 對象等等,通過底層命令實現 commit,這裏不去深入講解,上述內容已經足夠我們對 Git 文件系統有一個大致的瞭解,如果不是做 Git 相關工具的開發沒有太大深入必要。

 

Git 中的第四種對象:標籤對象(tag object)。Git tag 分爲兩種,輕量(lightweight)標籤和附註(annotated)標籤。輕量標籤和分支相似,下面會分析輕量標籤的存儲形式;而附註標籤和 commit 更相似,標籤對象包含標籤創建者信息、日期、註釋信息和一個指針,主要的區別在於,標籤對象通常指向一個提交對象,而不是一個樹對象。

 

當然爲了解決快照佔用空間過大的問題,Git 設計了自動垃圾回收策略,一般 .git/objects/ 目錄下的對象是以鬆散的方式存放的,但當這些鬆散對象的個數超過 7000 時,Git 會自動進行壓縮,形成 pack 文件,當 pack 文件多於 50 個時,Git 會把多個 pack 文件再壓縮成爲一個 pack 文件。

Git 也可以手動觸發垃圾回收:git gc,還可以設置自動垃圾回收策略的參數,例如剛剛的 7000 限制是個魔術數字,可以通過 gc.autoPackLimit 修改。

Refs: HEAD / branch / tag / remotes 的真相

我們可以通過一次 commit 的 SHA-1 值來查看它的內容,但是記住這個哈希值顯然是不現實的,應該把這個哈希值存起來,用一種更簡單的方式記住它,這種方式就是「引用(refs)」。我們可以在 ​.git/refs​ 文件夾中看到這些存儲了哈希值的引用文件,隨意打開一個一個倉庫的 refs 目錄,HEAD / branch / tag / remotes 在這裏一覽無餘。

branch

➜  vue-next git:(master) cat .git/refs/heads/master

4e91b1328dda38e342b9dd0794ee1483ad2a7002

➜  vue-next git:(master) git log

commit 4e91b1328dda38e342b9dd0794ee1483ad2a7002 (HEAD -> master, upstream/master)

Author: Evan You <[email protected]>

Date:   Thu Dec 12 21:22:29 2019 -0500

 

    chore: add package dependency graph

 

可以看到分支 refs 文件存儲了其對應的最新的 commit SHA-1,我們完全可以修改這個 SHA-1 來改變所指向的 commit,但是不建議這麼做,使用 git update-ref 是更安全的做法。

HEAD

但 Git 還需要一個記錄當前在哪個分支的文件,這就是 HEAD,正常情況下它是一個「符號引用(symbolic ref)」,是一個指向了引用的指針:

➜  vue-next git:(master) cat .git/HEAD

ref: refs/heads/master

 

和 ref 文件相似,我們可以修改這個指針來改變當前所在的分支,但也有一個更安全的命令 git symbolic-ref 可以使用。

 

注意在 Git 中,如果你切到一個指定的 commit,也就是「detached HEAD」狀態下,HEAD 文件的內容就變成了 ref,存儲這個 commit 的 SHA-1 值:

➜  vue-next git:(master) g checkout c36941c4987c38c5a9

Note: checking out 'c36941c4987c38c5a9'.

 

You are in 'detached HEAD' state. You can look around, make experimental

changes and commit them, and you can discard any commits you make in this

state without impacting any branches by performing another checkout.

 

If you want to create a new branch to retain commits you create, you may

do so (now or later) by using -b with the checkout command again. Example:

 

  git checkout -b <new-branch-name>

 

HEAD is now at c36941c fix(compiler-core): should apply text transform to <template v-for> children

➜  vue-next git:(c36941c) cat .git/HEAD

c36941c4987c38c5a92a1ae0d554dbf746177e71

 

當然一般很少會進入這種狀態,這種狀態下提交的 commit 除非記住 SHA-1 值,否則很難找回這些 commit 記錄,這些 commit 會變成 dangling commit,沒有分支指向它們。

 

tag

tag 分爲兩種,輕量(lightweight)標籤和附註(annotated)標籤,輕量標籤和分支相似,ref 文件中存儲着對應 commit SHA-1,而附註標籤和 commit 更相似,指向了 Git 中的第四種對象:標籤對象(tag object)。

 

標籤對象包含標籤創建者信息、日期、註釋信息,以及一個指針。 主要的區別在於,標籤對象通常指向一個提交對象,而不是一個樹對象。 它像是一個永不移動的分支引用——永遠指向同一個提交對象,只不過給這個提交對象加上一個更友好的名字。

 

remotes

遠程引用(remote ref)與分支引用基本相似,其指向了最近一次與服務端通信時所知曉的遠端分支對應的最新 commit SHA-1:

➜  vue-next git:(master) cat .git/refs/remotes/upstream/master

4e91b1328dda38e342b9dd0794ee1483ad2a7002

與分支引用的差異在於,遠端引用是隻讀的,雖然我們可以 checkout 到遠程引用,但 HEAD 不會指向這個引用,而是進入 detached HEAD 狀態,指向這個引用所對應的 commit,在之後所做的 commit 都不會去更新遠端引用。

 

綜上,一個 Git 倉庫內部的引用和文件關係大致如下圖所示,圖中的 index 是暫存區域,這裏不去展開講了:

包含引用的 Git 目錄

 

 

命令操作

在準備分享內容的時候,一開始我計劃設計一個章節來講 Git 工作流,但發現常見的工作流涉及到的大多是基本操作,與其講工作流,不如講講最常見的操作:merge 和 rebase。

merge

merge 是一個比較常用的命令,很多時候使用起來比較簡單,但有的時候卻會有一些不符合直覺的現象。

 

Fast-Forward Merge:快進式合併

當合並的兩個分支滿足祖孫/父子關係時,Git 默認會使用快進式合併,最常見的場景是 pull,pull 的本質是 fetch + merge,由於一般在執行 pull 的時候遠端分支相對本地分支是快進的,Git 可以直接合並,默認情況下不會產生 merge commit,但也可以通過參數 --no-ff 來禁止快進式合併。

非快進式 vs 快進式

Three-Way Diff Merge:三路合併

上面我們講到 Git 是基於快照的,每一個 commit 中都有全量的項目文件,所以在 merge 時,Git 不會使用那些中間的 commit,只去關注最新的 commit,但這還不夠:

如上圖所示,我們要把 feature 分支合入 master 分支,假如 master 分支只修改了文件 X,feature 分支只修改了文件 Y,在合併的時候兩者是不應該產生衝突、應該將修改合在一起;但是如果我們使用兩路合併算法,只去對比當前分支與目標分支的最新 commit 即 B3 與 F3,X 和 Y 都是不一致的,需要用戶介入選擇處理,體驗會非常糟糕。

 

所以 Git 使用了更智能的三路合併算法,選取當前分支節點 B3 與目標分支節點 F3 的公共祖先節點 B1 作爲基準(base),將這三個節點的文件依次進行比較:

  • 如果 B3、F3 的某個文件和 B1 中的相同,那麼不產生衝突;
  • 如果 B3 或 F3 只有一個和 B1 相比發生變化,那麼該文件將會採用該變化了的版本;
  • 如果 B3、F3 和 B1 相比都發生了變化,且變化不相同,那麼則產生衝突,需要手動去合併;
  • 如果 B3、F3 都發生了變化,且變化相同,那麼不產生衝突,自動採用該變化的版本。

 

也許有同學會想,爲啥解衝突時,我看到的只有當前節點與目標節點的差異,看不到公共祖先節點,這看起來像是兩路合併?這是因爲 Git 存在這一個配置項 ​merge.conflictStyle​ ,且該配置項默認值爲 merge,可以將其設置爲 diff3,這樣之後看到的解決衝突界面就會出現祖先節點的文件內容。

 

<<<<<<< HEAD

const a = 'master'

=======

const a = 'feat'

>>>>>>> feat-1

merge 風格

 

<<<<<<< HEAD

const a = 'master'

||||||| merged common ancestors

const a = 1

=======

const a = 'feat'

>>>>>>> feat-1

diff3 風格

 

Recursive Three-Way Diff Merge:遞歸三路合併

有時候我們的場景可能沒有上面那樣簡單,當前分支節點與目標分支節點之間可能存在多個公共祖先,先舉一個有兩個公共祖先的例子:

存在兩個公共祖先節點 B2 和 F1

 

如圖所示,B3 與 F3 之間存在着 B2 和 F1 兩個公共祖先,這時候 Git 會先將 B2 與 F1 進行合併,產生一個虛擬的節點 V,將節點 V 作爲公共祖先節點:

合併兩個祖先節點產生虛擬祖先節點

 

兩個公共祖先節點的情況這樣處理,超出兩個的情況也就很類似了,Git 會遞歸進行上述的行爲,直到只有一個祖先節點,這也是爲什麼叫「遞歸三路合併算法」。

 

Git 提供了一個底層命令 git merge-base,可以用它來查找兩個 commit 的公共祖先節點:git merge-base <commit1> <commit2>;另外在 UNIX 上還有一個 diff3 的命令,可以對三個文件進行三路 diff,這裏就不再去展開講了,大家可以自己進行嘗試。

 

三路合併來帶的陷阱

如果兩個分支有同樣的改動,並後續在一個分支上做了 revert 的操作,在將這兩個分支進行合併的時候,revert 的行爲會體現在最終合併的結果裏,也就是最初的改動不會保留到合併的結果中。

最常見的場景是,當一個特性分支在開發過程中被誤合入主分支後,在主分支上 revert 了這個 merge commit,在特性開發完成再次合入主分支時,會發現上次合入的代碼都丟失了。

M2 中不包含 F1 和 F2 所引入的修改

 

在我們團隊的使用 Git 的過程中遇到過這樣的問題,而在 ByteKM 上也有團隊遇到過這樣的問題並釀成了線上事故(https://bytekm.bytedance.net/kmf/articleDetail/11404),但 KM 的這篇文章實際並沒有解釋清楚爲什麼會陷入這樣的問題。

 

通過 git merge-base R F3 我們會發現第二次 merge 的最近公共祖先節點是 F2,而已經不再是 B1,第一次 merge 改變了特性分支與主分支最新節點之間的最近公共祖先節點,根據三路合併算法的原理,F1 F2 的代碼在公共祖先節點 F2 以及 F3 上是一致的,那麼在主分支上所做的 revert 修改將會被帶入最終的 merge 2。

 

針對這種情況如何解決?KM 那篇文章中複製粘貼的做法是強烈不建議的,複製粘貼很可能會再一次搞丟代碼,我想到的安全的做法有兩種:

  1. 在主分支上再次 revert R(revert revert 套娃操作);
  1. 通過 cherry-pick 將 F1 和 F2 pick 到主分支上。

 

rebase

很多時候大家對 rebase 的認知是可以幫忙引入其他分支的改動並解除衝突,也會和 merge 進行對比,因爲這種場景下往往 merge 也可以達到類似的效果,但實際 rebase 的使用場景更廣泛一些。

基本用法

在 Git 中並不存在一個專用的修改歷史的命令,但我們可以藉助 rebase 來修改歷史。

剛剛說的常見場景是將當前的分支基於另一個 base 重放(reapply)歷史的 commits,以 git 文檔中的例子進行解釋,當前我們有兩個分支,master 與 feature,並且當前我們在 feature 分支上,

在執行 git rebase master 後,將變成:

 

通常我們會使用它來將上游分支(一般是 master)上的特性引入到目標的分支上(當前工作的特性分支 / bugfix 分支),已保持當前的工作分支包含了想要的特性,並且與目標分支不存在衝突。

 

rebase 的原理

Git 先將所有在目標分支但不在上游分支的 commit 保存到一個臨時區域,這些 commit 和 git log <upstream>..HEAD 的結果是一樣的;接着將目標分支的指針設爲和上游分支一致,再從剛剛的臨時區域將 commit 按照順序一一的進行重放。

每一次的 commit 重放都是一次三路合併:重放 F1 時,選擇 F1 和 B2 的公共祖先節點 B1 作爲 base;重放 F2 時,選擇 F2 和 F1’ 的公共祖先節點 B1 作爲 base;在 commit 的數量更多的情況下依次類推。

修改當前分支的歷史記錄

這裏 rebase 的上游可以不只是其他的分支,也可以是當前分支的歷史記錄節點,配合 rebase 的交互模式,可以實現重寫當前分支的歷史:修改 commit、合併 commit、拆分 commit、調整 commit 順序等等。

實際使用也比較簡單,可以看下 Git 文檔,很容易上手,相關的文章也非常多,這裏就不去贅述。

如何判斷我是否可以重寫歷史?

  • 已經進入多人協作的公共分支,絕對禁止重寫歷史:
    • 如果遠端分支禁用了 push --force,無法修改遠端的記錄,會導致自己與遠端不一致;
    • 如果 push --force 強制修改遠端的記錄,則會導致其他人的本地與遠端的不一致;
  • 想要修改的歷史記錄只在本地,或者對應的分支只有自己在使用,遠端允許 push --force,可以重寫歷史。

 

如何判斷我是否應該重寫歷史?

實際上社區存在兩種觀念:歷史記錄需要被尊重不應該被修改 vs 歷史應該更清晰便於查閱,在實際的工程中,甚至存在着兩種極端:

  • 禁止 rebase,遠端完全禁止 force push,雖然無法禁止對於未提交的 commits 進行重寫,但是一旦提交的 commits 就不再可以被變更了;
  • 除了使用 GitLab 的 MR 做 Code Review 的代碼合入之外,禁止手動 merge,如果需要引入特性提前排除衝突,必須使用 rebase / cherry-pick。

 

這裏不去探討應該認可哪種觀念,但客觀來講,正確、適度的使用 rebase,可以幫助我們得到更清晰的 commit log 和 branch graph,便於之後的查閱 log / revert / cherry-pick。

個人習慣

在我個人使用 Git 時有一些小的習慣,幫助我從源頭上避免掉入坑中:

  • 藉助 rebase squash / split,儘量保證 commit 的原子性,便於 revert / cherry-pick;
  • 如果 feature 分支只有我一個人在使用,且沒有禁用 force push,則經常 rebase master;
  • 儘量不拿子分支去 merge 祖先分支(feature merge master),保證分支線清晰;
  • 除非很明確目的,否則在 merge 時不做 squash;
  • 配置 alias,少打一些字母,減少出錯率
  • 善用 stash,保存未完成的工作

參考資料

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章