git學習

轉自https://blog.coding.net/blog/principle-of-Git#user-content-1-3-MS4zIOS6uuaJjeaLm+iBmA=

使用原理視角看 Git

1. Git 的玩法

歡迎來到 Coding 技術小館,我叫譚賀賀,目前我在 Coding.net 主要負責 WebIDECodeinsight 的開發。我今天帶來的主要內容是 Git 的原理與使用。

談起 git,大家的第一印象無非是和 svn 一樣的版本控制系統,但其實,他們有着非常大的不同,至少 svn 沒有像 git 一樣這麼多的玩法。下面我舉幾個例子,簡略的說一下。

1.1 搭建博客

阮一峯將寫 blog 的人分成三個階段

使用免費空間,比如 CSDN、博客園。
發現免費空間限制太多,於是自己購買域名和空間,搭建獨立博客。
獨立博客管理太麻煩,最好在保留控制權的前提下,讓別人來管,自己負責寫文章。

其實第三種階段指的就是使用 Pages 服務。很多公司比如 Coding、Github 等代碼託管平臺都推出了 Pages 服務,可以用來搭建個人博客。Pages 服務不需要複雜的配置,就可以完成博客的搭建。

在使用 Pages 的過程中,通過使用標記語言(Markdown)完成博客的編寫,推送到服務器上,就可以看到新發布的博客了。

不需要管理服務器,降低了搭建博客的門檻,同時又保持了用戶對博客的高度定製權。

1.2 寫書

很多牛人喜歡寫博客,博客寫多了,然後彙集起來就出了本書。比如 Matrix67《思考的樂趣》、阮一峯《如何變得有思想》就是這樣的例子。

其實出書距離我們也並不遙遠,爲什麼?因爲有 gitbook 這類服務。

對於 git + Pages 服務的用戶,gitbook 很容易上手,因爲使用 gitbook 就是使用 git 與 markdown。
你完全可以將你 markdown 的博客 copy,彙集起來,形成一本書籍。內容的排版 gitbook 會幫你做,我們只負責內容就可以了。編寫好內容,我們就能立刻獲得 html、pdf、epub、mobi 四個版本的電子書。這是 html 版的預覽:

圖片

在 gitbook 上有 explore 頻道,上面列出了所有公開的書籍(當然也可以直接搜索)。

圖片

實際上,除了寫書,還可以連同其他人一起進行外文資料的翻譯,舉個例子《The Swift Programming Language》中文版,將英文版分成幾個部分,然後在開源項目中由參與者認領翻譯,每個人貢獻一份自己的力量,完成了這樣以非常快的相應速度跟隨官方文檔更新的操作。如果你喜歡的一門語言,或者技術,中文資料缺乏,大家可以發起這樣的活動,完成外文資料的翻譯。

1.3 人才招聘

人才招聘這一塊,至今還並沒有形成一定的規模。但仍舊有很多的公司選擇在代碼託管平臺上(比如 Coding、Github)上尋找中意的開發者。

有一些開發者看準了這一塊,專門開發了這樣的網站,比如 githuber.cn、github-awards.com。

拿 githuber 舉例,該網站主要提供兩個功能,第一個是星榜,說白了將所有所有用戶按照語言分類,然後根據粉絲數(star)排序。

圖片

我們可以很容易的看到排行榜上前幾位的用戶,他們的開源項目,這在一定程度上能代表這門語言的發展趨勢。比如我對java比較感興趣,然後我看了一下前十名,發現大部分都是 android 開發,由此可見android開發的火爆程度。

當然你也可以看到你的排名,會讓你有打怪升級的快感。

第二個功能是搜索,輸入篩選條件,搜搜程序員!

圖片

1.4 WebIDE

Coding WebIDE 是 Coding 自主研發的在線集成開發環境 (IDE)。只要你的項目在代碼託管平臺存放,就可以導入到 WebIDE。之後就可以在線開發。

圖片

WebIDE 還提供 WebTerminal 功能,用戶可以遠程操作Docker容器,自由安裝偏好的軟件包、方便折騰。

看起來是不是還挺好玩的,如果想把這些都玩轉,git 是肯定要好好學的。接下來,我們就看一下 git 的基本原理。

2. Git 原理

我們可以現在想一下,如果我們自己來設計,應該怎麼設計。

傳統的設計方案我們可以簡單的分成兩塊:工作目錄,遠程倉庫。

圖片

但是作爲一個目標明確的分佈式版本控制系統,首先要做的就是添加一個本地倉庫。

圖片

接着我們選擇在工作目錄與遠程倉庫中間加一個緩衝區域,叫做暫存區。

圖片

加入暫存區的原因有以下幾點:

  1. 爲了能夠實現部分提交
  2. 爲了不再工作區創建狀態文件、會污染工作區。
  3. 暫存區記錄文件的修改時間等信息,提高文件比較的效率。

至此就我們本地而言有三個重要的區域:工作區、暫存區、本地倉庫。

接下來我們想一下本地倉庫是如何存放項目歷史版本。

2.1 快照

圖片

這是項目的三個版本,版本1中有兩個文件A和B,然後修改了A,變成了A1,形成了版本2,接着又修改了B變爲B1,形成了版本3。

如果我們把項目的每個版本都保存到本地倉庫,需要保存至少6個文件,而實際上,只有4個不同的文件,A、A1、B、B1。爲了節省存儲的空間,我們要像一個方法將同樣的文件只需要保存一份。這就引入了Sha-1算法。

可以使用git命令計算文件的 sha-1 值。

echo 'test content' | git hash-object --stdin
d670460b4b4aece5915caf5c68d12f560a9fe3e4

SHA-1將文件中的內容通過通過計算生成一個 40 位長度的hash值。

Sha-1的非常有特點:

  • 由文件內容計算出的hash值
  • hash值相同,文件內容相同

對於上圖中的內容,無論我們執行多少次,都會得到相同的結果。因此,文件的sha-1值是可以作爲文件的唯一 id 。同時,它還有一個額外的功能,校驗文件完整性。

有了 sha-1 的幫助,我們可以對項目版本的存儲方式做一下調整。

圖片

2.1.1 數據庫中存儲的數據內容

實際上,現在就與git實際存儲的結構一致了。我們可以預覽一下實際存儲在 .git 下的文件。

圖片

我們可以看到,在 objects 目錄下,存放了很多文件,他們都使用 sha-1 的前兩位創建了文件夾,剩下的38位爲文件名。我們先稱呼這些文件爲 obj 文件。

對於這麼多的 obj 文件,就保存了我們代碼提交的所有記錄。對於這些 obj 文件,其實分爲四種類型,分別是 blob、tree、commit、tag。接下來,我們分別來看一下。

  1. blob

    首先 A、A1、B、B1 就是 blob 類型的 obj。

    blob: 用來存放項目文件的內容,但是不包括文件的路徑、名字、格式等其它描述信息。項目的任意文件的任意版本都是以blob的形式存放的。

  2. tree

    tree 用來表示目錄。我們知道項目就是一個目錄,目錄中有文件、有子目錄。因此 tree 中有 blob、子tree,且都是使用 sha-1值引用的。這是與目錄對應的。從頂層的 tree 縱覽整個樹狀的結構,葉子結點就是blob,表示文件的內容,非葉子結點表示項目的目錄,頂層的 tree 對象就代表了當前項目的快照。

  3. commit

    commit: 表示一次提交,有parent字段,用來引用父提交。指向了一個頂層 tree,表示了項目的快照,還有一些其它的信息,比如上一個提交,committer、author、message 等信息。

2.2 暫存區

暫存區是一個文件,路徑爲: .git/index

圖片

它是一個二進制文件,但是我們可以使用命令來查看其中的內容。
這裏我們關注第二列和第四列就可以了,第四列是文件名,第二列指的是文件的blob。這個blob存放了文件暫存時的內容。

第二列就是sha-1 hash值,相當於內容的外鍵,指向了實際存儲文件內容的blob。第三列是文件的衝突狀態,這個後面會講,第四列是文件的路徑名。

我們操作暫存區的場景是這樣的,每當編輯好一個或幾個文件後,把它加入到暫存區,然後接着修改其他文件,改好後放入暫存區,循環反覆。直到修改完畢,最後使用 commit 命令,將暫存區的內容永久保存到本地倉庫。

這個過程其實就是構建項目快照的過程,當我們提交時,git 會使用暫存區的這些信息生成tree對象,也就是項目快照,永久保存到數據庫中。因此也可以說暫存區是用來構建項目快照的區域。

2.3 文件狀態

有了工作區、暫存區、本地倉庫,就可以來定義文件的狀態了。

圖片

文件的狀態可以分爲兩類。一類是暫存區與本地倉庫比較得出的狀態,另一類是工作區與暫存區比較得出的狀態。爲什麼要分成兩類的願意也很簡單,因爲第一類狀態在提交時,會直接寫入本地倉庫。而第二種則不會。一個文件可以同時擁有兩種狀態。

比如一個文件可能既有上面的 modified 狀態,又有下面 modified 狀態,但其實他們表示了不同的狀態,git 會使用綠色和紅色把這兩中 modified 狀態區分開來。

2.4 分支

接下來,看一個很重要的概念,分支。

圖片

分支的目的是讓我們可以並行的進行開發。比如我們當前正在開發功能,但是需要修復一個緊急bug,我們不可能在這個項目正在修改的狀態下修復 bug,因爲這樣會引入更多的bug。

有了分支的概念,我們就可以新建一個分支,修復 bug,使新功能與 bug 修復同步進行。

分支的實現其實很簡單,我們可以先看一下 .git/HEAD 文件,它保存了當前的分支。

cat .git/HEAD
=>ref: refs/heads/master

其實這個 ref 表示的就是一個分支,它也是一個文件,我們可以繼續看一下這個文件的內容:

cat .git/refs/heads/master
=> 2b388d2c1c20998b6233ff47596b0c87ed3ed8f8

可以看到分支存儲了一個 object,我們可以使用 cat-file 命令繼續查看該 object 的內容。

git cat-file -p 2b388d2c1c20998b6233ff47596b0c87ed3ed8f8
=> tree 15f880be0567a8844291459f90e9d0004743c8d9
=> parent 3d885a272478d0080f6d22018480b2e83ec2c591
=> author Hehe Tan <[email protected]> 1460971725 +0800
=> committer Hehe Tan <[email protected]> 1460971725 +0800
=> 
=> add branch paramter for rebase

從上面的內容,我們知道了分支指向了一次提交。爲什麼分支指向一個提交的原因,其實也是git中的分支爲什麼這麼輕量的答案。

因爲分支就是指向了一個 commit 的指針,當我們提交新的commit,這個分支的指向只需要跟着更新就可以了,而創建分支僅僅是創建一個指針。

3. 高層命令

在 git 中分爲兩種類型的命令,一種是完成底層工作的工具集,稱爲底層命令,另一種是對用戶更友好的高層命令。一條高層命令,往往是由多條底層命令組成的。

不知道的人可能一聽高層感覺很厲害的樣子,其實就是指的是那些我們最常使用的git命令。

3.1 Add & Commit

add 和 commit 應該可以說是我們使用頻率最高的高層命令了。

touch README.md
git add README.md
git commit -m "add readme”

touch 指的是創建一個文件,代表了我們對項目文件內容的修改,add 操作是將修改保存到暫存區,commit 是將暫存區的內容永久保存到本地倉庫。

每當將修改的文件加入到暫存區,git 都會根據文件的內容計算出 sha-1,並將內容轉換成 blob,寫入數據庫。然後使用 sha-1 值更新該列表中的文件項。在暫存區的文件列表中,每一個文件名,都會對應一個sha-1值,用於指向文件的實際內容。最後提交的那一刻,git會將這個列表信息轉換爲項目的快照,也就是 tree 對象。寫入數據庫,並再構建一個commit對象,寫入數據庫。然後更新分支指向。

3.2 Conflicts & Merge & Rebase

3.2.1 Conflicts

git 中的分支十分輕量,因此我們在使用git的時候會頻繁的用到分支。不可不免的需要將新創建的分支合併。

在 git 中合併分支有兩種選擇:merge 和 rebase。但是,無論哪一種,都有可能產生衝突。因此我們先來看一下衝突的產生。

圖片

圖上的情況,並不是移動分支指針就能解決問題的,它需要一種合併策略。首先,我們需要明確的是誰和誰的合併,是 2,3 與 4,5,6的合併嗎?說到分支,我們總會聯想到線,就會認爲是線的合併。其實不是的,真實合併的是 3 和 6。因爲每一次提交都包含了項目完整的快照,即合併只是 tree 與 tree 的合併。

我們可以先想一個簡單的算法。用來比較3和6。但是我們還需要一個比較的標準,如果只是3和6比較,那麼3與6相比,添加了一個文件,也可以說成是6與3比刪除了一個文件,這無法確切表示當前的衝突狀態。因此我們選取他們的兩個分支的分歧點(merge base)作爲參考點,進行比較。

比較時,相對於 merge base(提交1)進行比較。

首先把1、3、6中所有的文件做一個列表,然後依次遍歷這個列表中的文件。現在我們拿列表中的一個文件進行舉例,把在提交1、3、6中的該文件分別稱爲版本1、版本3、版本6。

  1. 版本1、版本3、版本6的 sha-1 值完全相同,這種情況表明沒有衝突
  2. 版本3或6至少一個與版本1狀態相同(指的是sha-1值相同或都不存在),這種情況可以自動合併。比如1中存在一個文件,在3中沒有對該文件進行修改,而6中刪除了這個文件,則以6爲準就可以了
  3. 版本3或版本6都與版本1的狀態不同,情況複雜一些,自動合併策略很難生效,需要手動解決。我們來看一下這種狀態的定義。

衝突狀態定義:

  • 1 and 3: DELETED_BY_THEM;
  • 1 and 6: DELETED_BY_US;
  • 3 and 6: BOTH_ADDED;
  • 1 and 3 and 6: BOTH_MODIFIED

我們拿第一種情況舉例,文件有兩種狀態 1 和 3,1 表示該文件存在於 commit 1(也就是MERGE_BASE),3 表示該文件在 commit 3 (master 分支)中被修改了,沒有 6,也就是該文件在 commit 6(feature 分支)被刪除了,總結來說這種狀態就是 DELETED_BY_THEM。

可以再看一下第四種情況,文件有三種狀態 1、3、6,1 表示 commit 1(MERGE_BASE)中存在,3 表示 commit 3(master 分支)進行了修改,6 表示(feature 分支)也進行了修改,總結來說就是 BOTH_MODIFIED(雙方修改)。

遇到不可自動合併衝突時,git會將這些狀態寫入到暫存區。與我們討論不同的是,git使用1,2,3標記文件,1表示文件的base版本,2表示當前的分支的版本,3表示要合併分支的版本。

3.2.2 Merge

在解決完衝突後,我們可以將修改的內容提交爲一個新的提交。這就是 merge。

圖片

merge 之後仍可以做出新的提交。

圖片

可以看到 merge 是一種不修改分支歷史提交記錄的方式,這也是我們常用的方式。但是這種方式在某些情況下使用 起來不太方便,比如當我們創建了 pr、mr 或者 將修改補丁發送給管理者,管理者在合併操作中產生了衝突,還需要去解決衝突,這無疑增加了他人的負擔。

使用 rebase 可以解決這種問題。

3.2.3 Rebase

假設我們的分支結構如下:

圖片

rebase 會把從 Merge Base 以來的所有提交,以補丁的形式一個一個重新達到目標分支上。這使得目標分支合併該分支的時候會直接 Fast Forward,即不會產生任何衝突。提交歷史是一條線,這對強迫症患者可謂是一大福音。

圖片

如果我們想要看 rebase 實際上做了什麼,有一個方法,那就是用“慢鏡頭”來看rebase的整個操作過程。rebase 提供了交互式選項(參數 -i),我們可以針對每一個patch,選擇你要進行的操作。

通過這個交互式選項,我們可以”單步調試”rebase操作。

經過測試,其實 rebase 主要在 .git/rebase-merge 下生成了兩個文件,分別爲 git-rebase-todo 和 done 文件,這兩個文件的作用光看名字就可以看得出來。git-rebase-todo 存放了 rebase 將要操作的 commit。而 done 存放正在操作或已經操作完畢的 commit。比如我們這裏,git-rebase-todo 存放了 4、5、6,三個提交。

圖片

首先 git 將 sha-1 爲 4 的 commit 放入 done。表示正在操作 4,然後將 4 以補丁的形式打到 3 上,形成了新的提交 4’。這一步是可能產生衝突的,如果有衝突,需要解決完衝突之後才能繼續操作。

圖片

接着講 sha-1 爲 5 的提交放入 done 文件,然後將 5 以補丁的形式打到 4’ 上,形成 5’。

圖片

再接着將 sha-1 爲 6 的提交放入 done 文件,然後將 6 以補丁的形式打到 5’ 上,形成 6’。最後移動分支指針,使其指向最新的提交 6’ 上。這就完成了 rebase 的操作。

圖片

我們看一下真實的 rebase 文件。

pick e0f56d9 update gitignore
pick e370289 add a

# Commands:
# p, pick = use commit
# r, reword = use commit, but edit the commit message
# e, edit = use commit, but stop for amending
# s, squash = use commit, but meld into previous commit
# f, fixup = like "squash", but discard this commit's log message
# x, exec = run command (the rest of the line) using shell
# d, drop = remove commit

該文件一共有三列,第一列表示要進行的操作,所有可以進行的操作,在下面註釋裏都列了出來,比如 pick 表示使用該提交,reword 表示使用該提交,但修改其提交的 message,edit 表示使用該提交,但是要對該提交進行一些修改,其它的就不一一說了。

而 done 文件的形式如下,和 git-rebase-todo 是一樣的:

pick e0f56d9 update gitignore
pick e370289 add a

從剛纔的圖中,我們就可以看到 rebase 的一個缺點,那就是修改了分支的歷史提交。如果已經將分支推送到了遠程倉庫,會導致無法將修改後的分支推送上去,必須使用 -f 參數(force)強行推送。

所以使用 rebase 最好不要在公共分支上進行操作。

3.3 Checkout、Revert、Reset

3.3.1 Checkout

對於 checkout,我們一般不會陌生。因爲使用它的頻率非常高,經常用來切換分支、或者切換到某一次提交。

這裏我們以切換分支爲例,從 git 的工作區、暫存區、本地倉庫分別來看 checkout 所做的事情。Checkout 前的狀態如下:

圖片

首先 checkout 找到目標提交(commit),目標提交中的快照也就是 tree 對象就是我們要檢出的項目版本。
checkout 首先根據tree生成暫存區的內容,再根據 tree 與其包含的 blob 轉換成我們的項目文件。然後修改 HEAD 的指向,表示切換分支。

圖片

可以看到 checkout 並沒有修改提交的歷史記錄。只是將對應版本的項目內容提取出來。

3.3.2 Revert

如果我們想要用一個反向提交恢復項目的某個版本,那就需要 revert 來協助我們完成了。什麼是反向提交呢,就是舊版本添加了的內容,要在新版本中刪除,舊版本中刪除了的內容,要在新版本中添加。這在分支已經推送到遠程倉庫的情境下非常有用。

Revert 之前:

圖片

revert 也不會修改歷史提交記錄,實際的操作相當於是檢出目標提交的項目快照到工作區與暫存區,然後用一個新的提交完成版本的“回退”。

Revert 之後:

圖片

Reset

reset 操作與 revert 很像,用來在當前分支進行版本的“回退”,不同的是,reset 是會修改歷史提交記錄的。

reset 常用的選項有三個,分別是 —soft, —mixed, —hard。他們的作用域依次增大。

我們分別來看。

soft 會僅僅修改分支指向。而不修改工作區與暫存區的內容,我們可以接着做一次提交,形成一個新的 commit。這在我們撤銷臨時提交的場景下顯得比較有用。

使用 reset --soft 前:

圖片

使用 reset --soft 後:

圖片

mixed 比 soft 的作用域多了一個 暫存區。實際上 mixed 選項與 soft 只差了一個 add 操作。

使用 reset --mixed 前:

圖片

使用 reset --mixed 後:

圖片

hard 會作用域又比 mixed 多了一個 工作區。

使用 reset --hard 前:

圖片

使用 reset --hard 後:

圖片

hard 選項會導致工作區內容“丟失”。

在使用 hard 選項時,一定要確保知道自己在做什麼,不要在迷糊的時候使用這條選項。如果真的誤操作了,也不要慌,因爲只要 git 一般不會主動刪除本地倉庫中的內容,根據你丟失的情況,可以進行找回,比如在丟失後可以使用 git reset --hard ORIG_HEAD 立即恢復,或者使用 reflog 命令查看之前分支的引用。

3.4 stash

有時,我們在一個分支上做了一些工作,修改了很多代碼,而這時需要切換到另一個分支幹點別的事。但又不想將只做了一半的工作提交。在曾經這樣做過,將當前的修改做一次提交,message 填寫 half of work,然後切換另一個分支去做工作,完成工作後,切換回來使用 reset —soft 或者是 commit amend。

git 爲了幫我們解決這種需求,提供了 stash 命令。

stash 將工作區與暫存區中的內容做一個提交,保存起來,然後使用reset hard選項恢復工作區與暫存區內容。我們可以隨時使用 stash apply 將修改應用回來。

stash 實現思路將我們的修改提交到本地倉庫,使用特殊的分支指針(.git/refs/stash)引用該提交,然後在恢復的時候,將該提交恢復即可。我們可以更進一步,看看 stash 做的提交是什麼樣的結構。

圖片

如圖所示,如果我們提供了 —include-untracked 選項,git 會將 untracked 文件做一個提交,但是該提交是一個遊離的狀態,接着將暫存區的內容做一個提交。最後將工作區的修改做一個提交,並以untracked 的提交、暫存區 的提交、基礎提交爲父提交。

搞這麼複雜,是爲了提供更靈活地選項,我們可以選擇性的恢復其中的內容。比如恢復 stash 時,可以選擇是否重建 index,即與 stash 操作時完全一致的狀態。

3.5 bisect

最後要講到一個曾經把我從“火坑”中救出來的功能。

項目發佈到線上的項目出現了bug,而經過排查,卻找不到問 bug 的源頭。我們還有一種方法,那就是先找到上一次好的版本,從上一次到本次之間的所有提交依次嘗試,一一排查。直到找到出現問題的那一次提交,然後分析 bug 原因。

git 爲我們想到了這樣的場景,同樣是剛纔的思路,但是使用二分法進行查找。這就是 bisect 命令。

使用該命令很簡單,

git bisect start
git bisect bad HEAD
git bisect good v4.1

git 會計算中間的一個提交,然後我們進行測試。

圖片

根據測試結果,使用 git bisect good or bad 進行標記,git 會自動切換到下一個提交。不斷的重複這個步驟,直到找到最初引入 bug 的那一次提交。

圖片

我們知道二分法的效率是很高的,2的10次方就已經1024了,因此我們測試一般最多是10次,再多就是11次、12次。其實這就要求我們優化測試的方法,使得簡單的操作就能使 bug 重現。如果重新的操作非常簡單,簡單到我們可以使用腳本就能測試,那就更輕鬆了,可以使用 git bisect run ./test.sh,一步到位。

如果某一個提交代碼跑不起來,可以使用 git bisect skip 跳過當前提交或者使用 visualize 在 git 給出的列表中手動指定一個提交進行測試。

Happy Coding ; )

(完)

你可能會感興趣的文章:

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