使用原理视角看 Git

1. Git 的玩法

欢迎来到 Coding 技术小馆,我叫谭贺贺,目前我在 Coding.net 主要负责 WebIDE 与 Codeinsight 的开发。我今天带来的主要内容是 Git 的原理与使用。html

谈起 git,你们的第一印象无非是和 svn 同样的版本控制系统,但其实,他们有着很是大的不一样,至少 svn 没有像 git 同样这么多的玩法。下面我举几个例子,简略的说一下。java

1.1 搭建博客

阮一峰将写 blog 的人分红三个阶段android

使用免费空间,好比 CSDN、博客园。
发现免费空间限制太多,因而本身购买域名和空间,搭建独立博客。
独立博客管理太麻烦,最好在保留控制权的前提下,让别人来管,本身负责写文章。git

其实第三种阶段指的就是使用 Pages 服务。不少公司好比 Coding、Github 等代码托管平台都推出了 Pages 服务,能够用来搭建我的博客。Pages 服务不须要复杂的配置,就能够完成博客的搭建。程序员

在使用 Pages 的过程当中,经过使用标记语言(Markdown)完成博客的编写,推送到服务器上,就能够看到新发布的博客了。github

不须要管理服务器,下降了搭建博客的门槛,同时又保持了用户对博客的高度定制权。web

1.2 写书

不少牛人喜欢写博客,博客写多了,而后聚集起来就出了本书。好比 Matrix67《思考的乐趣》、阮一峰《如何变得有思想》就是这样的例子。算法

其实出书距离咱们也并不遥远,为何?由于有 gitbook 这类服务。shell

对于 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、A一、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、A一、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 <xiayule148@gmail.com> 1460971725 +0800 => committer Hehe Tan <xiayule148@gmail.com> 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)进行比较。

首先把一、三、6中全部的文件作一个列表,而后依次遍历这个列表中的文件。如今咱们拿列表中的一个文件进行举例,把在提交一、三、6中的该文件分别称为版本一、版本三、版本6。

  1. 版本一、版本三、版本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。

能够再看一下第四种状况,文件有三种状态 一、三、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 存放了 四、五、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 ; )

相关文章
相关标签/搜索