Open Adamwu1992 opened 6 years ago
Git是一个版本控制工具,版本控制在我们的日常工作,尤其是多人协作的场景下非常重要。
最开始当我们意识到有版本控制需求的时候,我们通过复制真个目录,加上不同的命名来表示一个版本。这种方法可以记录我们的工作历史,但是没有办法协作,而且会占用很多的空间。
为了解决这个痛点,出现了很多本地版本控制的软件,大多原理都是基于某种简单的数据库记录每次的更新差异,通过应用这些差异,可以计算出每一个版本的文件内容。
后来,为了解决多人协作的问题,出现了集中化的版本控制系统,比如有Subversion,这类系统用一个集中的服务器来存储所有的版本,参与协作的人都要通过客户端连接到中心服务器,取出最新的版本文件或者提交自己的修改。
再后来,大家发现这样的系统也存在一些问题。最直接的就是中心服务器宕机了,所有的协作者都无法工作;中心服务器磁盘损坏了,项目变更的历史就全部丢失了,你只剩下你机器上保留的单独快照。于是分布式版本控制系统出现了,这种系统,客户端并不是指保存某一版本的快照,而是将整个仓库完成的镜像拷贝下来,每一个客户端都是一个完成的版本控制系统。而且每一个仓库都可以和若干不同的远端仓库交互,以此来实现多人协作。
在以往的版本控制系统中,为了减少体积,每次提交系统只会记录这次版本和上次版本变化的部分。Git选择了另一种方式,每一次提交都会对全部的文件保存一个快照,如果一个文件在某次提交中没有修改,Git会保存一个连接指向上一个版本。
这么做的区别是,以某一个提交版本角度横向来看,以往的系统记录的是一系列文件的变化,而Git记录的是所有文件的快照。从这个角度看Git更像是一个小型的文件系统而非仅仅是一个CVS。
Git的存储核心是一个键值对数据库,在Git的数据库中,记录的是每一个文件的校验和而不是文件名,理论上任何的文件变更都会引起校验和的变化,而且,我们日常执行的绝大部分Git操作都是往数据库中添加记录,所以,只要将数据加入Git,就很难丢失。
在Git中,所有的文件都处于以下三种状态之一:
modified
staged
commited
git clone
我们使用git init命令新建一个Git仓库,这条命令会在文件夹下创建一个.git隐藏文件夹。如果我们是用的git clone命令,也是从对应的远端仓库克隆.git文件夹到我们的目录下,并且把最新版本的文件签出。所以.git这个目录就藏着Git的一切秘密。
git init
.git
.git的目录结构如下所示:
Git所有的对象都存在.git/objects这个目录下,在一个全新的Git仓库目录中运行find .git/objects会发现只有两个空文件夹:
.git/objects
find .git/objects
.git/objects/info
.git/objects/pack
我们可以用底层命令写入一条数据:
echo 'hello world' | git hash-object -w --stdin # -w 执行存储操作,否则只会返回校验和 # --stdin 从标准输入读取文件内容,否则需要指定被存储的文件路径
这条命令会返回一个40位的校验和,这是个SHA-1的哈希值,由存储的数据外加一个header信息计算得出,我们再次检查.git/objects会发现多了一条记录:
find .git/objects -type f # -type f 只显示文件
多了一个文件,以校验和的前两位作为文件夹名,后38位作为文件名。我们还可以查看这个文件的内容,如果直接用cat之类的命令,只能看到一串乱码,因为文件的内容不仅仅是我们写入的内容(包含一个header),而且文件进过了压缩,Git提供了底层命令来查看文件:
cat
git cat-file -p [SHA-1] # -p 自动判断文件内容并显示友好的格式
这样就得到了我们写入的数据。
至此,我们完成了对Git的一次写入以及读取操作,但是我们的工作目录下没有产生任何文件,所有的一切都是基于Git的底层命令来完成的。
我们也可以将Git中的数据应用到我们的文件中:
git cat-file -p [SHA-1] > hello.txt
我们会发现在工作目录下多了一个文件,文件内容就是我们一开始写入Git的内容。
我们同样可以创建一个文件,并将内容写入Git:
echo 'version 1' > test.txt git hash-object -w test.txt echo 'version 2' > text.txt git hash-object -w test.txt
通过find .git/objects我们发现多了两条记录,就是test.txt这个文件的两个版本,而通过
test.txt
git cat-file -p [SHA-1] > test.txt
这个命令,可以将版本1或者版本2的内容应用到文件中。
目前为止我们完成了Git的一小部分功能,但是,现在Git中存储的仅仅是文件内容,没有文件的任何信息(文件名、文件类型等),这种对象被称作数据对象:
数据对象
git cat-file -t [SHA-1] > blob
事实上,对象的类型就是存储在header中的。
树对象解决了文件信息的存储问题。
每个树对象包含一条或者多条树对象记录(tree entry),每一条记录含有一个数据对象或者另一个树对象的SHA-1索引,以及相应的模式,类型,文件名等信息。
Git的文件模式借鉴了常见的UNIX文件模式: 100644表示普通文件,100755表示可执行文件,120000表示一个符号链接,还有其他一些不常用的(表示目录和子模块)
100644
100755
120000
树对象的创建,就是记录下某一时刻暂存区的状态,因此我们需要先创造一个暂存区,我们可以将之前写入的数据对象加入暂存区,也可以创造一个文件加入:
# -add 表示将记录加入index # --cacheinfo 从Git数据库中读取,而不是文件 # 100644 UNIX文件模式,表示普通文件 # [SHA-1] 数据对象记录索引 # test.txt 文件名 git update-index --add --cacheinfo 100644 [SHA-1] test.txt # or echo 'new file' > newfile.txt git update-index --add newfile.txt
这样暂存区中存在了两个文件,现在我们将暂存区的状态作为一个树对象写入Git:
git write-tree
这条命令返回的哈希值就是新增那条树对象对应的索引,我们可以查看这条记录来证明他的确是不同于之前的数据对象的:
git cat-file -p [SHA-1] # or git cat-file -t [SHA-1] > tree
我们也可以将已有的树对象写入暂存区,作为一个新的树对象的子树对象:
# 找到第一条树对象的索引并且读取到index git read-tree --prefix=bak [SHA-1] # 此时index中有三条记录(bak, newfile.txt, test.txt),注意write-tree操作并不会清空index # 写入 git write-tree
此时我们做的离我们日常使用的Git功能又进了一步,但是树对象仅仅储存了文件和文件夹的信息,版本控制中重要的提交信息还是没有。
提交对象是基于一个树对象创建的,如果不是第一次提交的话,还需要上一次提交对象的SHA-1值作为父提交对象,这样多个提交才可以联系在一起:
# 找到刚才的第一条树对象索引 echo 'first commit' | git commit-tree [SHA-1] # 找到第二条树对象索引以及上一个提交对象的索引 echo 'second commit' | git commit-tree [SHA-1] -p [SHA-1]
上面的操作将两次提交联系在一起,两次提交对象都是基于两次树对象创建的,在树对象的额基础上,增加了作者/提交人信息,父提交对象信息以及注释信息,可以通过git cat-file -p [SHA-1]查看提交对象的内容。
git cat-file -p [SHA-1]
既然提交对象是成串的,我们可以查看整个提交历史:
# 找到一条提交对象的SHA-1值,得到这个提交对象的所有历史提交 git log --stat [SHA-1]
这是一个货真价实的提交历史,和我们日常使用git add和 git commit创建的是一样的,而我们只用底层命令就完成了。
git add
git commit
应该注意到,现在为止我们所有的操作都是基于SHA-1,记住这一串数字很难,引用就是用来保存SHA-1的文件。
引用
所有的Git引用都存在于.git/refs下,在一个新的Git目录下运行find .git/refs只能看到两个文件夹而没有任何文件:
.git/refs
find .git/refs
.git/refs/heads
.git/refs/tags
.git/remotes
要创建一个引用很简单,就是向.git/refs目录下的文件里写入想要保存的SHA-1值:
# [SHA-1]是某个提交对象的索引 echo [SHA-1] > .git/refs/heads/master
这样就创建了一个指向某个提交对象的master引用,可以用git log来查看他的提交历史:
master
git log
# 之前 git log [SHA-1] # 现在 git log master
Git提供了更加安全的底层命令来创建或修改引用,比如我们再创建一个dev引用之前前一次提交:
dev
git update-ref refs/heads/dev [SHA-1] # 查看历史,将只包含第二次提交以及之前的记录 git log dev
当我们运行高级命令git branch [branch_name]时,Git底层实际上是调用了update-ref命令将当前分支最新提交的SHA-1值写入指定的(branch_name)文件中。
git branch [branch_name]
update-ref
Git是如何知道当前分支最新提交的SHA-1呢?答案就在.git/HEAD中,这个文件记录了当前分支最新提交的SHA-1。
.git/HEAD
当我们执行类似git checkout dev这样的高级命令时,.git/HEAD会被修改为refs/heads/dev,根据之前的经验,每一个高级命令都会对应着一个或者多个底层命令,此时调用的底层命令是git symbolic-ref,我们当然可以直接修改.git/HEAD文件,但这是一个更加安全的方式,如果你指定修改为一个不存在的引用就会报错:
git checkout dev
refs/heads/dev
git symbolic-ref
# 查看 git symbolic-ref HEAD # 修改为master git symbolic-ref HEAD refs/heads/master
经过上面的探讨,我们得知了分支其实是指向某一个提交对象的指针,本质上就是一个保存了41个字符的文件(40位校验和+1位换行符),可以说是非常的轻量了。
所以Git鼓励我们频繁地通过新建和切换分支来弯沉日常工作,因为这本质上是一个非常便宜的操作。当我们运行git branch dev时,Git做了以下操作:
git branch dev
.git/refs/heads/dev
.git/refs/heads/master
两次读取文件,一次新建&写入文件,这就是全部操作。而切换分支更加的快捷,当我们checkout dev时,Git会将.git/HEAD里的内容改写成refs/heads/dev,这就是全部操作。当然,伴随这些必不可少的操作还有改写我们的工作目录,就是将索引对应的项目快照提取出来放在磁盘上供我们使用。
checkout dev
分支的合并本质上也是指针的操作,分支的合并可以分成两种fast-forward和no-fast-forward。
fast-forward
no-fast-forward
假设你需要修改一个线上问题,从master拉出一个hotfix分支,进行了一些修改验证无误,需要合并到master上,切到master分支执行git merge hotfix命令,如果在你修改bug期间,master上没有产生新的提交,即master上所有的提交都能在hotfix的历史里找到,那么master就是hotfix的直接上游,此时的合并默认就是fast-forward模式,只需要把指针master移动到hotfix指向的那个提交对象就完成了合并。
hotfix
git merge hotfix
还是上面的例子,如果在修改bug期间,你有且回到master分支做了一些提交,当合并hotfix时,由于两条分支有了分叉,Git会将两条分支上最新的提交对象做一次合并,并且生成一个新的提交对象,合并后的master指向这个提交对象,这个特殊的提交对象,有两个父提交,而如果用git log master查看合并后的提交历史时,会发现两个分支上的提交按照时间出现在一条线上。
git log master
Git总是优先使用fast-forward的方式合并,如果你想要分支上每一次合并都有迹可循,可以使用git merge hotfix --no-ff来强制生成一个合并提交对象。
git merge hotfix --no-ff
需要注意的是,合并后,两条分支上的提交对象会被按照时间顺序安排在一起,看上去像是一条分支上的提交,但是对于提交对象本身来说,它的父提交对象不会改变。
.git/refs/tags下存的是标签引用,我们有两种日常使用的创建标签的高级命令,分别称作轻量标签(lightweight)和附注标签(annotated)。
轻量标签(lightweight)
附注标签(annotated)
# 针对最新提交创建一个轻量标签 git tag v1.0 # 对应底层命令 SHA-1从 .git/HEAD 中获取 git update-ref refs/tags/v1.0 [SHA-1]
轻量标签只是简单的将传入的SHA-1写入相应的文件中。
# -a指定创建一个附注标签 git tag -a v1.1 -m 'test tag'
以上的命令创建了一个附注标签,这个命令会生成一个标签对象(tag object),这个与轻量标签是有很大的不同的,在.git/refs/tags/v1.1这个文件里存储的SHA-1,不是提交对象的索引,而是标签对象的索引。
.git/refs/tags/v1.1
对,除了上文提到的数据对象、树对象、提交对象以外,Git中还存在一个标签对象,我们可以通过以下命令查看标签对象的结构:
树对象
提交对象
标签对象
# 取得标签对象的SHA-1 cat .git/refs/tags/v1.1 # 查看标签对象 git cat-file -p [SHA-1]
标签对象的内容包括一个索引,通常指向一个提交对象,创建者信息、创建日期以及注释等。
标签对象不限于指向提交对象,还可以指向树对象甚至数据对象。
远程引用存储的是远程仓库的信息。假设你使用下面的命令添加了一个远程仓库:
git remote add origin [url] git push origin master
那么你的.git/refs/remotes下会多出了一个文件.git/refs/remotes/origin/master,里面存储的SHA-1值就是你执行push命令是master上的最新提交对象的索引。
.git/refs/remotes
.git/refs/remotes/origin/master
push
远程引用和分支的最大区别是,远程引用是只读的,你可以用checkout签出远程分支,但是HEAD并不会指向远程分支,所以你无法通过commit来修改远程分支。
checkout
HEAD
commit
先前说道Git与其他集中式版本控制系统的却别时提到,Git在每一次项目变动时,将所有的文件都储存为一个快照,而不是只储存变化的部分,这样随着提交历史增多,会有大量的相似快照同时存在(如果我们每次提交只修改项目的一小部分),Git是如何处理这种文件体积增长的呢?
概览
Git是一个版本控制工具,版本控制在我们的日常工作,尤其是多人协作的场景下非常重要。
最开始当我们意识到有版本控制需求的时候,我们通过复制真个目录,加上不同的命名来表示一个版本。这种方法可以记录我们的工作历史,但是没有办法协作,而且会占用很多的空间。
为了解决这个痛点,出现了很多本地版本控制的软件,大多原理都是基于某种简单的数据库记录每次的更新差异,通过应用这些差异,可以计算出每一个版本的文件内容。
后来,为了解决多人协作的问题,出现了集中化的版本控制系统,比如有Subversion,这类系统用一个集中的服务器来存储所有的版本,参与协作的人都要通过客户端连接到中心服务器,取出最新的版本文件或者提交自己的修改。
再后来,大家发现这样的系统也存在一些问题。最直接的就是中心服务器宕机了,所有的协作者都无法工作;中心服务器磁盘损坏了,项目变更的历史就全部丢失了,你只剩下你机器上保留的单独快照。于是分布式版本控制系统出现了,这种系统,客户端并不是指保存某一版本的快照,而是将整个仓库完成的镜像拷贝下来,每一个客户端都是一个完成的版本控制系统。而且每一个仓库都可以和若干不同的远端仓库交互,以此来实现多人协作。
Git存储原理
在以往的版本控制系统中,为了减少体积,每次提交系统只会记录这次版本和上次版本变化的部分。Git选择了另一种方式,每一次提交都会对全部的文件保存一个快照,如果一个文件在某次提交中没有修改,Git会保存一个连接指向上一个版本。
这么做的区别是,以某一个提交版本角度横向来看,以往的系统记录的是一系列文件的变化,而Git记录的是所有文件的快照。从这个角度看Git更像是一个小型的文件系统而非仅仅是一个CVS。
Git的存储核心是一个键值对数据库,在Git的数据库中,记录的是每一个文件的校验和而不是文件名,理论上任何的文件变更都会引起校验和的变化,而且,我们日常执行的绝大部分Git操作都是往数据库中添加记录,所以,只要将数据加入Git,就很难丢失。
在Git中,所有的文件都处于以下三种状态之一:
modified
staged
commited
根据这三种状态,引申出三个工作区域的概念:git clone
复制的就是这个目录。Git内部原理
我们使用
git init
命令新建一个Git仓库,这条命令会在文件夹下创建一个.git
隐藏文件夹。如果我们是用的git clone
命令,也是从对应的远端仓库克隆.git
文件夹到我们的目录下,并且把最新版本的文件签出。所以.git
这个目录就藏着Git的一切秘密。.git
的目录结构如下所示:Git对象
Git所有的对象都存在
.git/objects
这个目录下,在一个全新的Git仓库目录中运行find .git/objects
会发现只有两个空文件夹:.git/objects/info
.git/objects/pack
数据对象(blob object)
我们可以用底层命令写入一条数据:
这条命令会返回一个40位的校验和,这是个SHA-1的哈希值,由存储的数据外加一个header信息计算得出,我们再次检查
.git/objects
会发现多了一条记录:多了一个文件,以校验和的前两位作为文件夹名,后38位作为文件名。我们还可以查看这个文件的内容,如果直接用
cat
之类的命令,只能看到一串乱码,因为文件的内容不仅仅是我们写入的内容(包含一个header),而且文件进过了压缩,Git提供了底层命令来查看文件:这样就得到了我们写入的数据。
至此,我们完成了对Git的一次写入以及读取操作,但是我们的工作目录下没有产生任何文件,所有的一切都是基于Git的底层命令来完成的。
我们也可以将Git中的数据应用到我们的文件中:
我们会发现在工作目录下多了一个文件,文件内容就是我们一开始写入Git的内容。
我们同样可以创建一个文件,并将内容写入Git:
通过
find .git/objects
我们发现多了两条记录,就是test.txt
这个文件的两个版本,而通过这个命令,可以将版本1或者版本2的内容应用到文件中。
目前为止我们完成了Git的一小部分功能,但是,现在Git中存储的仅仅是文件内容,没有文件的任何信息(文件名、文件类型等),这种对象被称作
数据对象
:事实上,对象的类型就是存储在header中的。
树对象(tree object)
树对象解决了文件信息的存储问题。
每个树对象包含一条或者多条树对象记录(tree entry),每一条记录含有一个数据对象或者另一个树对象的SHA-1索引,以及相应的模式,类型,文件名等信息。
树对象的创建,就是记录下某一时刻暂存区的状态,因此我们需要先创造一个暂存区,我们可以将之前写入的数据对象加入暂存区,也可以创造一个文件加入:
这样暂存区中存在了两个文件,现在我们将暂存区的状态作为一个树对象写入Git:
这条命令返回的哈希值就是新增那条树对象对应的索引,我们可以查看这条记录来证明他的确是不同于之前的数据对象的:
我们也可以将已有的树对象写入暂存区,作为一个新的树对象的子树对象:
此时我们做的离我们日常使用的Git功能又进了一步,但是树对象仅仅储存了文件和文件夹的信息,版本控制中重要的提交信息还是没有。
提交信息(commit object)
提交对象是基于一个树对象创建的,如果不是第一次提交的话,还需要上一次提交对象的SHA-1值作为父提交对象,这样多个提交才可以联系在一起:
上面的操作将两次提交联系在一起,两次提交对象都是基于两次树对象创建的,在树对象的额基础上,增加了作者/提交人信息,父提交对象信息以及注释信息,可以通过
git cat-file -p [SHA-1]
查看提交对象的内容。既然提交对象是成串的,我们可以查看整个提交历史:
这是一个货真价实的提交历史,和我们日常使用
git add
和git commit
创建的是一样的,而我们只用底层命令就完成了。Git引用
应该注意到,现在为止我们所有的操作都是基于SHA-1,记住这一串数字很难,
引用
就是用来保存SHA-1的文件。所有的Git引用都存在于
.git/refs
下,在一个新的Git目录下运行find .git/refs
只能看到两个文件夹而没有任何文件:.git/refs/heads
.git/refs/tags
(事实上,应该还有一个.git/remotes
表示远程引用,暂时没有添加远程仓库)分支引用(head reference)
要创建一个引用很简单,就是向
.git/refs
目录下的文件里写入想要保存的SHA-1值:这样就创建了一个指向某个提交对象的
master
引用,可以用git log
来查看他的提交历史:Git提供了更加安全的底层命令来创建或修改引用,比如我们再创建一个
dev
引用之前前一次提交:当我们运行高级命令
git branch [branch_name]
时,Git底层实际上是调用了update-ref
命令将当前分支最新提交的SHA-1值写入指定的(branch_name)文件中。Git是如何知道当前分支最新提交的SHA-1呢?答案就在
.git/HEAD
中,这个文件记录了当前分支最新提交的SHA-1。当我们执行类似
git checkout dev
这样的高级命令时,.git/HEAD
会被修改为refs/heads/dev
,根据之前的经验,每一个高级命令都会对应着一个或者多个底层命令,此时调用的底层命令是git symbolic-ref
,我们当然可以直接修改.git/HEAD
文件,但这是一个更加安全的方式,如果你指定修改为一个不存在的引用就会报错:分支的新建&切换&合并
经过上面的探讨,我们得知了分支其实是指向某一个提交对象的指针,本质上就是一个保存了41个字符的文件(40位校验和+1位换行符),可以说是非常的轻量了。
所以Git鼓励我们频繁地通过新建和切换分支来弯沉日常工作,因为这本质上是一个非常便宜的操作。当我们运行
git branch dev
时,Git做了以下操作:.git/refs/heads/dev
文件.git/HEAD
获取当前的分支,假设是master.git/refs/heads/master
获取提交对象的索引,写入.git/refs/heads/dev
两次读取文件,一次新建&写入文件,这就是全部操作。而切换分支更加的快捷,当我们
checkout dev
时,Git会将.git/HEAD
里的内容改写成refs/heads/dev
,这就是全部操作。当然,伴随这些必不可少的操作还有改写我们的工作目录,就是将索引对应的项目快照提取出来放在磁盘上供我们使用。分支的合并本质上也是指针的操作,分支的合并可以分成两种
fast-forward
和no-fast-forward
。假设你需要修改一个线上问题,从
master
拉出一个hotfix
分支,进行了一些修改验证无误,需要合并到master
上,切到master
分支执行git merge hotfix
命令,如果在你修改bug期间,master
上没有产生新的提交,即master
上所有的提交都能在hotfix
的历史里找到,那么master
就是hotfix
的直接上游,此时的合并默认就是fast-forward
模式,只需要把指针master
移动到hotfix
指向的那个提交对象就完成了合并。还是上面的例子,如果在修改bug期间,你有且回到
master
分支做了一些提交,当合并hotfix
时,由于两条分支有了分叉,Git会将两条分支上最新的提交对象做一次合并,并且生成一个新的提交对象,合并后的master
指向这个提交对象,这个特殊的提交对象,有两个父提交,而如果用git log master
查看合并后的提交历史时,会发现两个分支上的提交按照时间出现在一条线上。Git总是优先使用
fast-forward
的方式合并,如果你想要分支上每一次合并都有迹可循,可以使用git merge hotfix --no-ff
来强制生成一个合并提交对象。标签引用(tag reference)
.git/refs/tags
下存的是标签引用,我们有两种日常使用的创建标签的高级命令,分别称作轻量标签(lightweight)
和附注标签(annotated)
。轻量标签只是简单的将传入的SHA-1写入相应的文件中。
以上的命令创建了一个附注标签,这个命令会生成一个标签对象(tag object),这个与轻量标签是有很大的不同的,在
.git/refs/tags/v1.1
这个文件里存储的SHA-1,不是提交对象的索引,而是标签对象的索引。对,除了上文提到的
数据对象
、树对象
、提交对象
以外,Git中还存在一个标签对象
,我们可以通过以下命令查看标签对象的结构:标签对象的内容包括一个索引,通常指向一个提交对象,创建者信息、创建日期以及注释等。
标签对象不限于指向提交对象,还可以指向树对象甚至数据对象。
远程引用(remote reference)
远程引用存储的是远程仓库的信息。假设你使用下面的命令添加了一个远程仓库:
那么你的
.git/refs/remotes
下会多出了一个文件.git/refs/remotes/origin/master
,里面存储的SHA-1值就是你执行push
命令是master上的最新提交对象的索引。远程引用和分支的最大区别是,远程引用是只读的,你可以用
checkout
签出远程分支,但是HEAD
并不会指向远程分支,所以你无法通过commit
来修改远程分支。延伸思考
先前说道Git与其他集中式版本控制系统的却别时提到,Git在每一次项目变动时,将所有的文件都储存为一个快照,而不是只储存变化的部分,这样随着提交历史增多,会有大量的相似快照同时存在(如果我们每次提交只修改项目的一小部分),Git是如何处理这种文件体积增长的呢?