我曾介紹過 Git 如何使用有向無環圖 (DAG) 來組織存儲庫的提交對象。此外,我還研究過提交對象可以指代的 blob、樹和標記對象。在這篇文章的最後,我還介紹了分支,包括 HEAD 和 head 的區別。閱讀本文前必須先閱讀這篇文章,因為本文將介紹 Git「三樹」體系結構及其索引文件的重要性。進一步了解這些 Git 內部結構將有助於積累基礎知識,提高 Git 用戶的工作效率,並有助於用戶在研究各種基於 Visual Studio IDE 圖形 Git 工具的 Git 操作時獲取新見解。
回顧一下,之前有介紹過,Visual Studio 使用 Git API 與 Git 進行通信,以及 Visual Studio IDE Git 工具簡化並取代了基礎 Git 引擎的功能。對於要實現版本控制工作流而不依賴 Git 命令行接口 (CLI) 的開發者來說,這是一大福音。哎,但 IDE 的實用 Git 抽象有時可能會引起混亂。以下面的基本工作流為例:將項目添加到 Git 源控制項,修改並暫存項目文件,再提交暫存文件。為此,需要打開「團隊資源管理器 - 更改」窗格來查看更改後文件列表,再選擇要暫存的文件。請注意圖 1 中的最左側圖像,其中顯示了我在工作目錄中更改的兩個文件(標記 1)。
圖 1:「團隊資源管理器 - 更改」窗格可以在「更改」和「暫存更改」部分中顯示相同的文件
在下一張靠右的圖像中,我暫存了其中一個更改後文件:Program.cs(標記 2)。當我這樣做時,Program.cs 似乎已從「更改」列表中「移動」到了「暫存更改」列表中。如果我進一步修改,並在工作目錄中保存 Program.cs 副本,那麼此文件會繼續出現在「暫存更改」部分中(標記 3),但同時也會出現在「更改」部分中(標記 4)! 如果不了解 Git 的幕後運行機制,可能會感到困惑,解疑釋惑的關鍵在於有兩個 Program.cs「副本」:一個位於工作文件夾中,另一個位於對象的 Git 內部資料庫中。即使發現這一點,可能也無法知道在取消暫存副本、嘗試暫存 Program.cs 的第二個更改副本、撤消對工作副本的更改或切換分支時會發生些什麼。
若要真正了解 Git 在暫存、取消暫存、撤消、提交和籤出文件時所做的工作,必須先了解 Git 的體系結構。
Git 實現的是三樹體系結構(在此上下文中,「樹」指的是目錄結構和文件)。在圖 2(Git 三樹體系結構利用非常重要的索引文件實現智能化和高效性能)中從左向右看,第一棵樹是工作目錄(包含隱藏 .git 文件夾的 OS 目錄)中的文件和文件夾集合;第二棵樹通常存儲在.git 文件夾根目錄中一個名為 index 的二進位文件中;第三棵樹由代表 DAG 的 Git 對象組成(回顧一下,名為 SHA-1 的 Git 對象位於 .git\objects 中用兩個十六進位數命名的文件夾內,也可以存儲在 .git\objects\pack 的「包」文件中,以及 .git\objects\info\alternates 文件定義的文件路徑中)。請注意,Git 存儲庫是由 .git 文件夾中的所有文件進行定義。人們通常將 DAG 稱為「Git 存儲庫」,但這並不太準確:因為索引和 DAG 都包含在 Git 存儲庫中。
圖 2:Git 三樹體系結構利用非常重要的索引文件實現智能化和高效性能
請注意,雖然每顆樹都存儲目錄結構和文件,但它們利用的數據結構不同,以便可以保留樹專屬元數據,並優化存儲和檢索。第一棵樹(工作目錄樹,也稱為「工作樹」)顯然是 OS 文件和文件夾(除了 OS 級數據結構外,沒有其他任何特殊數據結構),滿足軟體開發者和 Visual Studio 的需求;第二顆樹(Git 索引)跨越工作目錄和組成 DAG 的提交對象,從而幫助 Git 快速執行工作目錄文件內容比較和快速提交;第三棵樹 (DAG) 讓 Git 能夠跟蹤可靠歷史版本控制系統,同時 Git 還能向其存儲在索引和提交對象中的項添加有用的元數據。例如,在索引中存儲的元數據有助於檢測工作目錄中的文件更改,而在提交對象中存儲的元數據則有助於跟蹤提交籤發者和籤發原因。
本段內容是為了回顧三樹體系結構中的三棵樹,並引出本文剩餘部分重點介紹的主題:已了解工作目錄樹的運行方式,因為實際上就是已精通使用的 OS 文件系統。如果閱讀過我的上一篇文章,應該已非常了解 DAG 的運行方式。那麼,此時,缺少的環節就是跨工作目錄和 DAG 的索引樹(下稱「索引」)。實際上,索引的作用非常重要,將是本文剩餘部分的唯一主題。
可能已聽說過下面善意的意見:索引是「暫存區域」的代名詞。 雖然這樣的表述多少有些準確,但卻掩蓋了索引的真正作用:不僅支持暫存區域,還便於 Git 檢測工作目錄中的文件更改;協調分支合併過程,以便能夠逐個文件解決衝突,並能隨時安全地中止合併;將暫存文件和文件夾轉換為樹對象,這些對象的引用會被寫入下一個提交對象。Git 還使用索引來保留工作樹中文件的相關信息,以及從 DAG 中檢索到的對象的相關信息,從而進一步將索引用作一種緩存。我們將更為全面地研究一下索引。
索引實現自己的獨立式文件系統,從而能夠存儲對文件夾和文件的引用,以及關於文件夾和文件的元數據。Git 如何以及何時更新此索引取決於所發出的 Git 命令類型和指定的命令選項(若要試一試,可以使用 Git 更新索引底層命令來自行管理索引),因此這裡無法詳盡無遺地進行介紹。不過,使用 Visual Studio Git 工具時,不妨留意一下 Git 更新索引以及使用索引中存儲信息的主要方式。圖 3 展示了 Git 如何在用戶暫存文件時在索引中更新工作目錄數據,以及如何在用戶啟動合併(若有合併衝突)、執行克隆/拉取或切換分支時在索引中更新 DAG 數據。另一方面,Git 依賴索引中存儲的信息,在用戶籤發提交後更新 DAG,並在用戶執行克隆/拉取或切換分支後更新工作目錄。意識到 Git 依賴索引,並且索引跨越多個 Git 操作後,便會開始重視用於修改索引的高級 Git 命令,從而能夠有效地巧妙處理 Git 操作。
圖 3:更新索引的主要 Git 操作(綠色)和依賴索引所含信息的 Git 操作(紅色)
我們將在工作目錄中新建一個文件,看看此文件被寫入索引時會發生些什麼。在用戶暫存此文件後,Git 會立即使用以下字符串串聯公式創建標頭:
blob{space}{file-length in bytes}{null-termination character}
然後,Git 將標頭串聯到文件內容開頭。因此,對於包含字符串「Hello」的文本文件,將標頭與文件內容串聯後生成如下字符串(請注意,字母「H」前面有一個空字符):
blob 5Hello
為了更加明確化,下面展示了此字符串的十六進位版本:
62 6C 6F 62 20 35 00 48 65 6C 6C 6F
然後,Git 計算此字符串的 SHA-1:
5ab2f8a4323abafb10abb68657d9d39f1a775057
接下來,Git 檢查現有索引,以確定此文件夾\文件名的條目是否已存在且包含相同的 SHA-1。如果有,Git 會在 .git\objects 文件夾中找到此 blob 對象,並更新它的修改日期時間(Git 絕不會覆蓋存儲庫中的現有對象;而是更新上次修改日期,以便延遲這一新添加的對象被視為垃圾遭到回收的時間)。如果沒有,Git 會使用 SHA-1 字符串的前兩個字符作為 .git\objects 中的目錄名稱,並使用剩下的 38 個字符命名 blob 文件,再對此文件進行 zlib 壓縮並編寫其內容。在我的示例中,Git 會在 .git\objects 中創建名為 5a 的文件夾,再將 blob 對象作為文件 b2f8a4323abafb10abb68657d9d39f1a775057 寫入此文件夾。
當 Git 以這種方式創建 blob 對象時,大家可能會感到驚訝,因為 blob 對象中明顯缺少一個預期的文件屬性:文件名! 然而,這是有意而為之。回顧一下,Git 是內容可尋址文件系統,因此它管理的是名為 SHA-1 的 blob 對象,而不是文件。每個 blob 對象通常是由至少一個樹對象引用,反過來樹對象通常是由提交對象引用。最終,Git 樹對象表示暫存的文件的文件夾結構。不過,在用戶籤發提交之前,Git 不會創建這些樹對象。因此,可以得出下列結論:如果 Git 僅使用索引來準備提交對象,還必須為索引中的每個 blob 捕獲文件路徑引用,而它正是這樣做的。其實,即使兩個 blob 的 SHA-1 值相同,只要每個映射到不同的文件名或不同的路徑/文件值,都將顯示為索引中的單獨條目。
Git 還將文件元數據(如文件的創建日期和修改日期)與寫入索引的每個 blob 對象一起保存。Git 利用此類信息通過比較文件日期和使用啟發,有效地檢測工作目錄中的文件更改,而不是採用重新計算工作目錄中每個文件的 SHA-1 值這種蠻力做法。此類策略可以加快「團隊資源管理器 - 更改」窗格中顯示信息的速度,或發出高層 Git 狀態命令後顯示信息的速度。
有了工作目錄文件對應的索引條目及其相關元數據後,Git 據說就可以「跟蹤」文件了,因為它可以很容易地將文件副本與工作目錄中保留的副本進行比較。從技術角度來講,被跟蹤的文件也存在於工作目錄中,並包含在下一次提交中。這與未受跟蹤的文件相反,未受跟蹤的文件分為以下兩種類型:位於工作目錄中但不位於索引中的文件,以及顯式指定為不受跟蹤的文件(見「索引擴展」部分)。總而言之,藉助索引,Git 可以確定跟蹤、不跟蹤以及不得跟蹤哪些文件。
為了更好地理解索引的具體內容,讓我們來看看一個具體示例,從新的 Visual Studio 項目入手。此項目是否複雜並不太重要,只需幾個文件就可以充分說明正在發生什麼。新建名為 MSDNConsoleApp 的控制臺應用程式,並選中「創建解決方案的目錄」和「新建 Git 存儲庫」複選框。單擊「確定」,創建解決方案。
稍後我將發出一些 Git 命令。因此,若要在系統上運行這些命令,請在工作目錄中打開命令提示符窗口,並確保可以在繼續操作時使用此窗口。一種為特定 Git 存儲庫快速打開 Git 命令窗口的方法是,訪問「Visual Studio Team」菜單,並選擇「管理連接」。此時,將看到本地 Git 存儲庫列表,以及相應存儲庫的工作目錄路徑。右鍵單擊存儲庫名稱,並選擇「打開命令提示符」,以啟動可用於輸入 Git CLI 命令的窗口。
創建解決方案後,立即打開「團隊資源管理器 - 分支」窗格(圖 4 中的標記 1),以確定 Git 是否創建了名為「主分支」的默認分支(標記 2)。右鍵單擊「主分支」(標記 2),並選擇「查看歷史記錄」(標記 3),以查看 Visual Studio 代表用戶創建的兩個提交對象(標記 4)。第一個對象包含提交消息「添加 .gitignore 和 .gitattributes」;第二個對象包含提交消息「添加項目文件」。
圖 4:查看歷史記錄以確定 Visual Studio 在用戶新建項目時所做的工作
打開「團隊資源管理器 - 更改」窗格。Visual Studio 依賴 Git API 在此窗口中填充項,因為它是 Visual Studio 版 Git 狀態命令。當前,此窗口指明工作目錄中沒有取消暫存的更改。為了做出此決定,Git 將每個索引條目與各個工作目錄文件進行比較。有了索引的文件條目和相關的文件元數據後,Git 就有了所需的全部信息,可以確定用戶是否進行了任何更改、添加、刪除,或是否重命名了工作目錄中的任何文件(不包括 .gitignore 文件中提到的任何文件)。
因此,在方便 Git 智能化確定工作目錄樹與 HEAD 指向的提交對象的差異方面,索引起到了關鍵作用。若要詳細了解索引提供給 Git 引擎的信息種類,請轉到前面打開的命令行窗口,並發出以下底層命令:
git ls-files --stage
可以隨時發出此命令,從而生成索引中當前包含的文件的完整列表。在我的系統上,此命令的輸出如下:
100644 1ff0c423042b46cb1d617b81efb715defbe8054d 0 .gitattributes100644 3c4efe206bd0e7230ad0ae8396a3c883c8207906 0 .gitignore100644 f18cc2fac0bc0e4aa9c5e8655ed63fa33563ab1d 0 MSDNConsoleApp.sln100644 88fa4027bda397de6bf19f0940e5dd6026c877f9 0 MSDNConsoleApp/App.config100644 d837dc8996b727d6f6d2c4e788dc9857b840148a 0 MSDNConsoleApp/MSDNConsoleApp.csproj100644 27e0d58c613432852eab6b9e693d67e5c6d7aba7 0 MSDNConsoleApp/Program.cs100644 785cfad3244d5e16842f4cf8313c8a75e64adc38 0 MSDNConsoleApp/Properties/AssemblyInfo.cs
輸出的第一列是八進位的 Unix OS 文件模式。不過,Git 並不支持全部文件模式值。可能只會看到 100644(對於非 EXE 文件)和 100755(對於基於 Unix 的 EXE 文件,適用於 Windows 的 Git 也對可執行文件類型使用 100644)。第二列是文件的 SHA-1 值。第三列是文件的合併暫存值,0 表示沒有衝突,1、2 或 3 表示有合併衝突。最後,請注意,七個 blob 對象的路徑和文件名全都存儲在索引中。Git 使用路徑值在下一次提交之前生成樹對象(稍後將詳細介紹)。
現在,讓我們來研究一下索引文件本身。由於它是二進位文件,因此我將使用 HexEdit 4(hexedit.com 提供的免費軟體十六進位編輯器)查看文件內容(圖 5 摘錄了部分內容)。
圖 5:項目的 Git 索引文件的十六進位轉儲
圖 6:Git 索引標頭數據格式
索引文件 - 標頭條目00 - 03
索引的前 12 個字節包含標頭(見圖 6)。前 4 個字節始終包含字符 DIRC(「目錄緩存」的縮寫),這是 Git 索引通常被稱為「緩存」的原因之一。接下來的 4 個字節包含索引版本號,默認為版本 2,除非要使用 Git 的特定功能(如稀疏籤出)。在這種情況下,可以設置為版本 3 或 4。最後 4 個字節包含索引進一步包含的文件條目數。
12 字節標頭後面是 n 個索引項的列表,其中 n 與索引標頭描述的條目數一致。圖 7 展示了每個索引條目的格式。Git 根據路徑/文件名欄位按升序排列索引條目。
圖 7:Git 索引文件 - 索引條目數據格式
索引文件 - 索引條目4 字節32 位創建時間(以秒為單位)與 1970 年 1 月 1 日 00:00:00 之間相隔的秒數。4 字節32 位創建時間 - 納秒組成部分創建時間的納秒組成部分(以秒為單位)。4 字節32 位修改時間(以秒為單位)與 1970 年 1 月 1 日 00:00:00 之間相隔的秒數。4 字節32 位修改時間 - 納秒組成部分修改時間的納秒組成部分(以秒為單位)。4 字節設備與文件相關的元數據,源自 Unix OS 上使用的文件屬性。4 字節Inode4 字節mode4 字節用戶 ID4 字節組 ID4 字節文件內容長度文件內容字節數。20 字節SHA-1相應 blob 對象的 SHA-1 值。2 個字節標記(從高到低位)1 位:假設有效/假設未更改標誌;1 位:擴展標誌(如果版本低於 3,必須為 0;如果為 1,在路徑\文件名前面附加 2 個字節);2 位:合併暫存;12 位:路徑\文件名長度(如果小於 0xFFF)2 個字節
第一個 8 字節表示文件創建時間(與 1970 年 1 月 1 日 00:00:00 之間相隔的秒數)。第二個 8 字節表示文件修改時間(與 1970 年 1 月 1 日 00:00:00 之間相隔的秒數)。接下來是五個與主機 OS 相關的文件屬性元數據的 4 字節值(設備、inode、模式、用戶 ID 和組 ID)。唯一僅限 Windows 的值是模式,值通常是八進位的 100644,我在前面介紹 ls-files 命令輸出時提到過(這會轉換為 4 字節 814AH 值,如圖 5 中的 26H 位置所示)。
元數據後面是 4 字節的文件內容長度。在圖 5 中,此值從 030 行的 00 00 0A 15(十進位為 2,581)開始,我的系統上的 .gitattributes 文件長度為:
05/08/2017 09:24 PM <DIR> .05/08/2017 09:24 PM <DIR> ..05/08/2017 09:24 PM 2,581 .gitattributes05/08/2017 09:24 PM 4,565 .gitignore05/08/2017 09:24 PM <DIR> MSDNConsoleApp05/08/2017 09:24 PM 1,009 MSDNConsoleApp.sln 3 File(s) 8,155 bytes 3 Dir(s) 92,069,982,208 bytes free
偏移 034H 是 blob 對象的 20 字節 SHA-1 值:
1ff0c423042b46cb1d617b81efb715defbe8054d.
請注意,此 SHA-1 指向 blob 對象,其中包含相關文件 (.gitattributes) 的文件內容。
048H 是 2 字節值,包含兩個 1 位標誌、2 位合併暫存值,以及當前索引條目的路徑/文件名的 12 位長度。在這兩個 1 位標誌中,高位指定索引條目是否設置了假設未更改標誌(通常使用 Git 更新索引底層命令完成);低位指定是否在路徑\文件名條目之前附加兩字節的數據(只有當索引版本不小於 3 時,此位才會是 1)。接下來的 2 位保留介於 0 到 3 之間的合併暫存值,如前所述。12 位值包含路徑\文件名字符串的長度。
如果設置了擴展標誌,那麼 2 字節值包含跳過工作樹標誌和有意添加位標誌,以及填充佔位符。
最後,長度不固定的字節序列包含路徑\文件名。此值以一個或多個空字符結尾。空字符結尾後面是索引中的下一個 blob 對象,或一個或多個索引擴展條目(我很快將會介紹)。
我在前面提到過,在用戶提交暫存內容前,Git 不會生成樹對象。也就是說,索引最初只包含路徑/文件名,以及對 blob 對象的引用。不過,只要用戶籤發提交,Git 就會將索引更新為包含對在上次提交期間創建的樹對象的引用。如果在下一次提交期間這些目錄引用仍位於工作目錄中,那麼可以使用緩存的樹對象引用,以減少 Git 需要在下一次提交期間完成的工作量。可以看到,索引的作用涉及許多方面,正因為此,它被描述為索引、暫存區域和緩存。
圖 7 展示的索引條目只支持 blob 對象引用。Git 使用擴展,以便可以存儲樹對象。
索引可以包含擴展條目,這些條目存儲特殊化數據流,為 Git 引擎提供其他信息,以供它在監視工作目錄中的文件和準備下一次提交時參考。為了緩存在上次提交期間創建的樹對象,Git 將樹擴展對象添加到工作目錄的根目錄的索引,以及每個子目錄的索引。
圖 5 中的標記 2 展示了索引的最終字節,並捕獲索引中存儲的樹對象。圖 8 展示了樹擴展數據的格式。
圖 8:Git 索引文件樹擴展對象數據格式
索引文件 - 緩存的樹擴展標頭4 字節TREE用於緩存的樹擴展條目的固定籤名。4 字節表示 TREE 擴展數據長度的 32 位數字緩存的樹擴展條目變量Path以空字符結尾的路徑字符串(只有是根樹,才為 NULL)。ASCII 數字條目數ASCII 數字,表示此樹條目覆蓋的索引中的條目數。1 個字節20H(空格字符)
在偏移 284H 處出現的樹擴展數據標頭包含字符串「TREE」(標記了緩存的樹擴展數據的開頭),後跟 32 位值(表示後面的擴展數據的長度)。接下來是各個樹條目:第一個條目是樹路徑的以 NULL 結尾的長度不固定字符串值(或直接為 NUL,如果是根樹的話)。後跟 ASCII 值,因此十六進位編輯器中顯示「7」,即當前樹覆蓋的 blob 條目數(因為這是根樹,條目數與先前在籤發 Git ls-files 暫存命令時看到的條目數相同)。下一個字符是空格,後又跟一個 ASCII 數字,表示當前樹的子樹數量。
我們項目的根樹只有 1 個子樹:MSDNConsoleApp。此值後面是換行符和樹的 SHA-1 值。SHA-1 從偏移 291 處的 0d21e2 開始。
我們將確認 0d21e2 是否就是根樹的 SHA-1。為此,請轉到命令窗口並輸入:
git log
這將顯示與最近提交相關的詳細信息:
commit 5192391e9f907eeb47aa38d1c6a3a4ea78e33564Author: Jonathan Waldman <jonathan.waldman@live.com>Date: Mon May 8 21:24:15 2017 -0500 Add project files.commit dc0d3343fa24e912f08bc18aaa6f664a4a020079Author: Jonathan Waldman <jonathan.waldman@live.com>Date: Mon May 8 21:24:07 2017 -0500 Add .gitignore and .gitattributes.
最近一次提交的時間戳為 21:24:15,所以就是上次更新索引的提交。我可以使用此提交的 SHA-1 查找根樹的 SHA-1 值:
git cat-file -p 51923
輸出如下:
tree 0d21e2f7f760f77ead2cb85cc128efb13f56401dparent dc0d3343fa24e912f08bc18aaa6f664a4a020079author Jonathan Waldman <jonathan.waldman@live.com> 1494296655 -0500committer Jonathan Waldman <jonathan.waldman@live.com> 1494296655 -0500
以上樹條目就是根樹對象。據此可以確認,索引轉儲中偏移 291H 處的 0d21e2 值實際上就是根樹對象的 SHA-1 值。
SHA-1 值後面為其他樹條目(從偏移 2A5H 開始)。若要確認根樹下緩存的樹對象的 SHA-1 值,請運行以下命令:
git ls-tree -r -d master
這僅以遞歸方式顯示當前分支上的樹對象:
040000 tree c7c367f2d5688dddc25e59525cc6b8efd0df914d MSDNConsoleApp040000 tree 2723ceb04eda3051abf913782fadeebc97e0123c MSDNConsoleApp/Properties
第一列中的模式值 040000 表明此對象是目錄,而不是文件。
最後,索引的最後 20 個字節包含表示索引本身的 SHA-1 哈希值:就跟預期一樣,Git 使用此 SHA-1 值來驗證索引的數據完整性。
雖然我介紹了本文中示例索引文件的所有條目,但更大、更複雜的索引文件成為一種規範。索引文件格式支持附加的擴展數據流,例如:
支持合併操作和合併衝突解決方法的數據流。它的籤名為「REUC」(用於解決撤消衝突)。
用於維護未受跟蹤的文件(這些是未包含在跟蹤範圍內的文件,在 .gitignore 和 .git\info\exclude 中指定,並由 core.excludesfile 指向的文件指定)的緩存的數據流。它的籤名為「UNTR」。
支持拆分索引模式的數據流,以便加快非常大的索引文件的索引更新速度。它的籤名為「link」。
藉助索引的擴展功能,可以繼續添加索引功能。
本文回顧了 Git 三樹體系結構,並深入詳細地探討了索引文件的幕後運行機制。我介紹了 Git 為響應特定操作而更新索引,並依賴索引包含的信息,以便執行其他操作。
可以在不太考慮索引的情況下使用 Git。然而,了解索引可以獲取對 Git 核心功能的寶貴見解,同時了解 Git 如何檢測工作目錄中的文件更改、什麼是暫存區域及其非常有用的原因、Git 如何管理合併以及 Git 執行特定操作如此快速的原因。此外,還可以輕鬆了解籤出命令和變基命令的命令行變體,以及軟重置、混合重置與硬重置的區別。通過此類功能,可以指定應在發出特定命令時更新索引、工作目錄還是兩者都更新。了解 Git 工作流、策略和高級操作時,將會看到此類選項。本文旨在讓讀者認識到索引起到的重要作用,從而可以更好地了解具體利用方式。
Jonathan Waldman 是一名 Microsoft 認證專家,專攻軟體工效學,從 Microsoft 技術誕生之際便一直研究這些技術。Waldman 是 Pluralsight 技術團隊的成員,目前負責機構和私營部分的軟體開發項目。可以通過 jonathan.waldman@live.com 與他聯繫。
衷心感謝以下 Microsoft 技術專家對本文的審閱:Kraig Brockschmidt、Saeed Noursalehi、Ralph Squillace 和 Edward Thomson