当我们git merge的时候到底在merge什么?

更新日期: 2019-11-20 阅读: 2.3k 标签: git

序言

我在上大学的时候并没有接触过VCS(版本控制系统)。虽然曾经在Google Code发布过去项目,但是以压缩包的形式发布的;与室友合作开发计算机网络这门课的课程设计时,也没有用上。直到入职第一家公司后才真正开始使用,当时用的是Git,此后也始终没用过其它的VCS——SVN仅仅耳闻未曾使用——转眼间已经用了六年多的Git了。

尽管日常使用问题不大,但对于Git的内部运行原理我仍然是一知半解——也不是我谦虚,基本就是不懂吧。例如,使用git add、git commit、git branch等命令的时候,Git在背后究竟做了什么,我是答不上来的。好在互联网上有许多这方面的资料可供学习,我硬着头皮看了不少文档和博客后,总算是习得了一些皮毛。

现在,我试着循序渐进地讲解一遍吧。


git add的时候发生了什么?

首先创建出一个仓库并向其中添加一个文件

mkdir git-test
cd git-test
git init
echo 'hello' > a
git add .

到此为止,暂时不要提交改动。现在,我来看看Git到底在背后做了些什么。Git的秘密都藏在叫做.git的目录中,尤其是其中的objects目录。用tree命令查看这个目录的结果如下

.git/objects
├── ce
│   └── 013625030ba8dba906f756967f9e9ca394464a
├── info
└── pack

与运行git add前相比,多出了一个叫ce的目录,以及位于其中的叫013625030ba8dba906f756967f9e9ca394464a的文件。这个文件其实就是a的一个“副本”,其中存储着文件a的内容。但是不能用cat直接查看,因为Git对这个文件做了压缩。可以用pigz来得到压缩前的原文,示例代码如下

pigz -d < .git/objects/ce/013625030ba8dba906f756967f9e9ca394464a

结果为

blob 6hello

Git生成这个文件的规则其实不复杂。首先Git会计算原文件的长度,即6(之所以是6,是因为用echo和重定向写入文件a时,添加了一个换行符)。然后,Git将一个固定的前缀blob (此处有一个空格)、文件长度、一个空字符(ASCII码为0的字符),以及文件内容这四者连接成一个字符串,并计算这个字符串的SHA1摘要。具体到文件a,可以用下面的命令试着计算

printf "blob 6\0hello\n" | shasum

或者用Git内置的hash-object子命令会更简单

git hash-object a

不管是哪一个命令,算出来的摘要都是ce013625030ba8dba906f756967f9e9ca394464a。然后Git会取前两个字符(ce)作为目录名,在.git/objects下创建新的目录。以从第三个字符开始的剩余内容(013625030ba8dba906f756967f9e9ca394464a)为文件名,将方才拼接好的内容压缩后写如文件。这种文件用Git的术语来讲叫做blob对象,稍后还会遇到tree类型和commit类型的对象。


git commit的时候发生了什么?

接下来提交改动

git config user.email 'foobar'
git config user.name 'foobar'
git commit -m 'test'

此时会发现.git/objects下新增了两个文件

.git/objects
├── 09
│   └── 76950c1fdbcb52435a433913017bf044b3a58f # 新的
├── 14
│   └── c77e71bd06df41e1509280cfba045e1db2aa5f # 新的
├── ce
│   └── 013625030ba8dba906f756967f9e9ca394464a
├── info
└── pack

用git cat-file -t可以查看这两个新文件的类型

git cat-file -t 14c77e71bd06df41e1509280cfba045e1db2aa5f # 输出commit
git cat-file -t 0976950c1fdbcb52435a433913017bf044b3a58f # 输出tree

也可以用git cat-file -p以可读的方式输出新文件的内容。例如用git cat-file -p 0976950c1fdbcb52435a433913017bf044b3a58f输出tree类型的对象的内容,结果为

100644 blob ce013625030ba8dba906f756967f9e9ca394464a a

tree类型的对象中记录着Git所追踪的文件的元信息,包括文件的权限、在Git中的对象类型、对象摘要,以及文件名。另一个commit类型的对象中存储着本次提交的信息,用git cat-file -p查看的结果如下

tree 0976950c1fdbcb52435a433913017bf044b3a58f
author foobar <foobar> 1576676836 +0800
committer foobar <foobar> 1576676836 +0800

test

第一行表示这个commit对象指向的是哪一个tree对象,从这个tree对象出发,可以遍历仓库中直到本次提交为止、所有被Git追踪的文件。commit指向tree,tree可以指向blob也可以指向其它的tree,blob就像是树中的叶子节点,不再指向其它的对象,它们之间的关系如下图所示



git branch的时候发生了什么?

Git的branch子命令用于创建新分支——虽然我平时更多地使用git checkout -b。既然add和commit的时候,Git会创建出blob、tree,以及commit类型的对象,那么创建新分支的时候,Git是不是也会创建名为branch的对象呢?答案是否定的。

Git的分支非常简单——它仅仅是指向某个commit对象的引用,就像是*nix系统中的符号链接一样。所有分支都存储在.git/refs/heads之下。例如文件.git/refs/heads/master中便存储着master分支上的最新提交的摘要

cat .git/refs/heads/master # 输出14c77e71bd06df41e1509280cfba045e1db2aa5f

这就是在Git中创建新分支的成本很低的原因——不过是复制一下当前分支在.git/refs/heads下的同名文件而已。我创建一个新分支develop并提交一个新文件b,.git/objects下会多出三个文件

git checkout -b develop
echo 'good' > b
git add b
git commit -m 'new branch'

三个新文件分别存储着文件b的内容(一个blob对象)、文件b的元信息(一个tree对象),以及本次提交(一个commit对象)。这些文件中没有任何关于develop分支的信息,develop分支仅仅是一个存在于.git/refs/heads/目录下的同名文件。


git merge一个子代时发生了什么?

develop分支是从master分叉出来,将develop合并回master时,Git会进行一次fast-forward的合并。虽然名字很唬人但其实Git做的事情非常简单,只需要将.git/refs/heads/master文件的内容修改为与develop相同的摘要即可。

也可以要求Git不使用fast-forward。先用git reset --hard HEAD^1将master分支回退到第一次提交的状态,然后使用下列的命令再次将develop合并进来

git merge --no-ff develop

这一次,Git不再简单地修改.git/refs/heads/master文件了事,而是会创建一个新的commit对象。在我的电脑上,这个新的commit对象的摘要为d1403bb629c7a636c724069b22875ed882b54bcc,使用git cat-file -p看看它的内容

tree e960ed43b8e6b5fe9b4e57b806f70796da820056
parent 14c77e71bd06df41e1509280cfba045e1db2aa5f
parent db891542d3e44448433ba86c7cd636d8aec3da54
author foobar <foobar> 1576679608 +0800
committer foobar <foobar> 1576679608 +0800

Merge branch 'develop'

有趣的是,这个commit对象有两个“父级”的commit,而不像平常所认识的树形数据结构那般只有一个“父节点”。显然,这两个父节点分别是合并前的master分支的最新一次提交,以及develop的最新提交。

虽然创建了一个新的commit对象,但其实develop分支的最新提交持有的便是整个仓库的最新版本,所以不需要创建新的tree,合并所产生的commit直接与develop分支的最新提交共用同一个tree对象便足够了——在上面输出内容的第一行的摘要,就是develop分支的最新commit所指向的tree对象的摘要。

至此,终于解决了我一直以来的一个困惑。我曾天真地以为,Git在合并两个分支的时候,会将待合进来的分支中的所有多出来的改动,复制到要合进去的分支中去。这都是因为我没有理解分支的本质,Git的分支并不是一根水管,没有哪一个提交是只能装在一个特定的分支中的。Git合并的时候,就像是在一个immutable的树上做修改,只需要创建不多的新commit和tree对象,再引用已经存在的旧commit和tree对象即可。否则,哪能快速地完成两个分支的合并呢。


后记

没想到还写了蛮多内容的,经过这么几次试验,我对Git的核心原理也算略知一二了,暂时不打算继续深入。各位读者如果有兴趣,可以试着制造一次有冲突的合并,然后看看冲突解决的前后,.git/objects目录下会有什么变化。

阅读原文  

本文内容仅供个人学习、研究或参考使用,不构成任何形式的决策建议、专业指导或法律依据。未经授权,禁止任何单位或个人以商业售卖、虚假宣传、侵权传播等非学习研究目的使用本文内容。如需分享或转载,请保留原文来源信息,不得篡改、删减内容或侵犯相关权益。感谢您的理解与支持!

链接: https://fly63.com/article/detial/6953

相关推荐

git从远程仓库克隆dev分支到本地的实现

这篇文章主要介绍git从远程仓库拉取dev分支到本地的实现【gitLab】:初始化一个本地仓库、与远程仓库建立连接 、查看本地是否具有dev分支、在本地创建分支dev并切换到该分支 、dev分支上的内容都拉取到本地

解决git/github下载速度缓慢的问题总汇

官网下载Git时,速度几乎是超不过20KB,解决方法有很多,这里介绍几个简单粗暴的方法。这里使用windows系统作为演示,其他系统对号入座即可。

git强制覆盖master分支

在开发中,通常会保持两个分支master分支和develop分支,但是如果因为develop上面迭代太多而没有及时维护master,最后想丢弃master而直接将测试确认过的develop强推到master,该怎么操作呢?因此,做如下总结分享,希望对遇到同样问题的人用帮助。

Git报错:remote: HTTP Basic: Access denied的解决方法

账号密码验证不通过,密码或者权限不对,导致 Git 操作失败。输入:git config --system --unset credential.helper,再次进行 Git 操作,输入正确的用户名,密码即可。

通过git命令,上传本地文件到git服务器

把本地代码上传到git的方法:步骤一:首先进入需要上传的项目文件夹,通过命令git init初始化,步骤二:将本地文件添加到版本库中,使用命令 git add . 将文件提交到本地的暂存区,步骤三:使用命令git commit将文件提交到本地仓库...

vscode git 全局忽略文件和文件夹

windows 中先在当前用户根目录下创建一个全局要忽略的文件列表.gitignore_global,window下 只有扩展名的文件不让保存,可以在 git bash中创建文件;二、 然后在命令行下执行下面git 命令

git合并分支

假如我们现在在dev分支上,刚开发完项目,执行了下列命令:想将dev分支合并到master分支,操作如下:首先切换到master分支上,如果是多人开发的话 需要把远程master上的代码pull下来

Git忽略规则文件.gitignore_关于.gitignore配置

.gitignore 文件的作用就是告诉git, push的时候忽略指定的文件夹或者文件,例如:vue-cli脚手架创建的项目,push到github上时,不会上传node依赖文件夹,这是因为vue-cli脚手架创建的时候,自动为我们创建了 .gitignroe文件,并且为我们写好了规则。

Git 如何撤回某一次提交?

在 master 分支做了一次 commit.之前没有出过这样的错误。就算出现也是直接push了然后再 reset 回滚然后再次 push -f。重置位置的同时,只保留Working Tree工作目录的內容

.gitignore的使用手册

避免将不必要的文件添加到版本管理系统中,比如前端的node_modules目录,该目录包含大量文件,如果将其添加到版本管理系统中,会使仓库变的庞大,不利于仓库的管理。

点击更多...

内容以共享、参考、研究为目的,不存在任何商业目的。其版权属原作者所有,如有侵权或违规,请与小编联系!情况属实本人将予以删除!