自工作起就一直使用Git,但一直没去真正了解Git的工作原理,实在惭愧。任何一个项目,团队协作非常重要,在互联网企业级开发中,没有个人英雄主义,一个人几乎不可能独立完成一个项目。因此当很多人共同协作去完成一个项目时,工作成果的维护是一个老大难的问题。Git就是为解决这个问题而生,Git最初是用于 Linux内核开发的版本控制工具。版本控制系统主要就是控制、协调各个版本的文件内容的一致性,这些文件包括文本、图片等等。早期SVN占据了绝大部分市场,而后来随着Git的出现,越来越多的人选择将它作为版本控制工具,社区也越来越强大。相较于SVN,最核心的区别是Git是分布式的VCS,换而言之,每一个你pull下来的Git仓库都是主仓库的一个分布式版本,仓库的内容完全一样,而SVN则不然,它需要一个中央版本库来进行集中控制。采用分布式模式的好处便是你不再依赖于网络,当有更改需要提交的时候而你又无法连接网络时,只需要把更改提交到本地的Git仓库,最后有网络的时候再把本地仓库和远程的主仓库进行同步即可。
1. 基本原理
Git 究竟是怎样的一个系统呢? 若你理解了 Git 的思想和基本工作原理,用起来就会知其所以然,游刃有余。本质上,Git是一套内容寻址(content-addressable)文件系统,而和我们直接接触的Git界面,只不过是封装在其之上的一个应用层。这个关系颇有点类似于计算机网络中应用层和底层的关系。在Git中,那些和应用层相关的命令(也就是我们最常用的命令,如git commit、 git push等),我们称之为porcelain命令(瓷器之意,意为成品、高级命令);而和底层相关的命令(几乎不会在日常中使用,如git hash-object、git update-index等),则称之为plumbing命令(管道之意,是连接git应用界面和git底层实现的一个管道,类似于shell,底层命令)。想要了解Git的底层原理,其实一种高效的学习方式,就是假设自己要设计一套版本管理系统,该怎么设计呢。在思考一定的程度后,再去学习Git的原理,一定会有所收获。在开始学习 Git 之前,我们先来了解一下Git的对象模型
2. Git的对象模型
如果学过或使用过面向对象的语言,那么“对象”这个概念一定不会陌生,通常具有一些能完成指定功能的方法或者标识对象特征的属性。Git也具有对象,当然不能和面向对象中的对象等同,它也有自身的特征下面做一下介绍。在Git中一共有四种对象
- Blob对象:这个对象最为简单,blob对象就是单纯存储数据,通常是文本内容。假如有一个readme.txt文件,那么它就会形成一个blob对象。Git会根据文件内容计算出一个hash值,以hash值作为文件索引存储在Git文件系统中。由于相同的文件内容的hash值是一样的,因此Git将同样内容的文件只会存储一次。
- Tree对象: 树结构最擅长的就是记录层级结构,刚好对应Git需要记录的文件目录结构。Tree对象可以指向其他Tree对象或者Blob对象。树对象可以看做是开发阶段源代码目录树的一次次快照,因此我们可以是用树对象作为源代码版本管理。但是,这里仍然有问题需要解决,即我们需要记住每个树对象的hash值,才能找到个阶段的源代码文件目录树。在源代码版本控制中,我们还需要知道谁提交了代码、什么时候提交的、提交的说明信息等。
- Commit提交对象:对于commit命令大家就比较熟悉了,实际上每进行一次commit提交就生成一个commit对象。在commit对象中就存储了提交人,提交时间以及一些备注信息。一个commit对象指向一个tree对象,提交是一步一步推进的,除了第一个commit,后续的commit都有parent父提交,如果是合并的提交,它有多个父提交。
- Tag标签对象:Git中的标签与分支类似,都是指向某一个commit提交对象的引用或者说指针。
说了这么多,那么在使用master分支,把代码提交到本地仓库后,本地仓库中,git对象是怎么样的拓扑结构呢。我们看下图,会发现最终所有的对象都指向了HEAD。分支master指向的是一个最新提交的commit对象的ID,每个commit代表的是一次提交,这样设计就非常巧妙了,既然可以从任意提交开始建立一条历史追踪链,用一个文件指向这个链条的最新提交,那么这个文件就可以用于追踪整个提交历史了。
3. Git的目录结构
实际上我们Git的本地仓库就是一个文件夹。但是为什么这些文件夹就是Git仓库呢?这是因为Git在初始化的时候会生成一个.git的文件夹,而Git进行版本控制所需要的文件,则都放在这个文件夹中。git init命令相信大家都比较熟悉,使用这个命令就可以把本地文件夹初始化成为一个Git的本地仓库。然后在文件夹中会多出一个.git的文件,默认情况下.git文件夹是隐藏的。在设置中显示隐藏文件夹,就可以到里面一看究竟了。
上图就是.git文件夹中的目录结构。下面来看一下每个目录的作用
- config文件:该文件主要记录针对该项目的一些配置信息,这里将写入远程URL,比如你的邮箱、用户名等,通过git remote add命令增加的远程分支的信息就保存在这里;
- description文件:被gitweb (Github的原型)用来显示对repo的描述。
- hooks文件夹:这个目录存放一些shell脚本,主要定义了客户端或服务端钩子脚本,这些脚本主要用于在特定的命令和操作之前或者之后进行特定的处理。比如:当你把本地仓库push到服务器的远程仓库时,可以在服务器仓库的hooks文件夹下定义post_update脚本,在该脚本中可以通过脚本代码将最新的代码部署到服务器的web服务器上,从而将版本控制和代码发布无缝连接起来
- objects文件夹:所有的Git对象都会存放在这个目录中,对象的SHA1哈希值的前两位是文件夹名称,后38位作为对象文件名;
- HEAD文件:该文件指明了git branch(即当前分支)的结果,比如当前分支是master,则该文件就会指向master,但是并不是存储一个master字符串,而是分支在refs中的表示,例如ref: refs/heads/master。
- index文件:该文件保存了暂存区域的信息。该文件某种程度就是缓冲区(staging area),内容包括它指向的文件的时间戳、文件名、sha1值等;
- Refs文件夹:该文件夹存储指向数据(分支)的提交对象的指针。其中heads文件夹存储本地每一个分支最近一次commit的sha-1值(也就是commit对象的sha-1值),每个分支一个文件;remotes文件夹则记录你最后一次和每一个远程仓库的通信,Git会把你最后一次推送到这个remote的每个分支的值都记录在这个文件夹中;tag文件夹则是分支的别名,这里不需要对其有过多的了解;
除此以外,.git目录下还有很多其他的文件和文件夹,这些文件和文件夹会额外支撑一些其他的功能,但是不是Git的核心部分,因此稍作了解即可。logs则记录了本地仓库和远程仓库的每一个分支的提交记录,即所有的commit对象(包括时间、作者等信息)都会被记录在这个文件夹中,因此这个文件夹中的内容是我们查看最频繁的,不管是Git log命令还是tortoiseGit的show log,都需要从该文件夹中获取提交日志;info文件夹保存了一份不希望在.gitignore 文件中管理的忽略模式的全局可执行文件,基本也用不上;COMMIT_EDITMSG文件则记录了最后一次提交时的注释信息。从以上的描述中我们可以发现,.git文件夹中包含了众多功能不一的文件夹和文件,这些文件夹和文件是描述Git仓库所必不可少的信息,不可以随意更改或删除;尤其需要注意的是,.git文件夹随着项目的演进,可能会变得越来越大,因为任何文件的任何一个变动,都需要Git在objects文件夹下将其重新存储为一个新的对象文件,因此如果一个文件非常大,那么你提交几次改动就会造成.git文件夹容量成倍增长。因此,.git文件夹更像是一本书,每一个版本的每一个变动都存储在这本书中,而且这本书还有一个目录,指明了不同的版本的变动内容存储在这本书的哪一页上,这就是Git的最基本的原理。
4. 常用的术语
(1)暂存区
暂存区是一个文件,路径为: .git/index
。它是一个二进制文件,但是我们可以使用命令来查看其中的内容。暂存区的使用场景是这样的,每当修改了一个或几个文件后,把它加入到暂存区,然后接着修改其他文件,改好后放入暂存区,循环反复。直到修改完毕,最后使用 commit 命令,将暂存区的内容永久保存到本地仓库。这个过程其实就是构建项目快照的过程,当我们提交时,git 会使用暂存区的这些信息生成tree对象,也就是项目快照,永久保存到数据库中。因此也可以说暂存区是用来构建项目快照的区域
(2)分支
分支是一个很重要的概念,当我们有几个人开发人员同一个项目上,会为每个开发人员建立一个分支,这样各开发人员就能开发各自的代码版本而不被别人干扰。但这样做的有个麻烦的地方就是需要一个人做代码合并,这个角色一般会由项目经理一做,顺便可以做一下代码评审。一般小型项目开一个分支就够了,所有都在这个分支上开发。分支的实现其实很简单,我们可以先看一下 .git/HEAD 文件,它保存了当前的分支。
1 | cat .git/HEAD |
其实这个 ref 表示的就是一个分支,它也是一个文件,我们可以继续看一下这个文件的内容:
1 | cat .git/refs/heads/master |
可以看到分支存储了一个 object,我们可以使用 cat-file 命令继续查看该 object 的内容。
1 | git cat-file -p 2b388d2c1c20998b6233ff47596b0c87ed3ed8f8 |
从上面的内容,我们知道了分支指向了一次提交。当我们提交新的commit,这个分支的指向只需要跟着更新就可以了,而创建分支仅仅是创建一个指针。
5. Git常用命令详解
以下对几个git命令进行介绍,重点在于对这些命令的基本使用的普及,包括:git clone、git commit、git reset、git reverse和git add。大多数情况下,我们在开发中小型项目的时候,如果团队成员不是很多,则只需要开一个分支就够了。在这种情况下,只要你操作规范,在push之前注意pull最新的代码,则基本不会出现比较严重的冲突或者问题,这时候以上命令基本都用不上,但是在多分支的情况下,我们可能会使用以上的命令来进行分支合并或者版本回退等,因此,我们有必要对这些命令做一个简单的了解,知道在什么时候去使用它们。
在 git 中分为两种类型的命令,一种是完成底层工作的工具集,称为底层命令,另一种是对用户更友好的高层命令。一条高层命令,往往是由多条底层命令组成的。git常用的高层命令如下
下面分别对一些常用命令加以说明
Git reset
在使用Git的过程中。某些时候,当你不小心改错了内容,或者错误地在本地commit了某些本不该提交的修改,我们就需要进行版本的回退。版本回退最常用的命令包括git reset
和git revert
。这两个命令允许我们在版本的历史之间穿梭。git reset
命令是用来将当前 branch 重置到另外一个 commit 的,这个动作可能同时影响到 index 以及 work directory。先举个例子,来一个感性的认识。现在有一个hotfix分支。
在执行下面这两条命令让 hotfix分支向后回退两个提交。上图就是命令执行前后,分支状态的对比。
1 | git checkout hotfix |
hotfix 分支末端的两个提交现在变成了孤儿提交。下次 Git 执行垃圾回收的时候,这两个提交会被删除。如果你的提交还没有共享给别人,可以用git reset
撤销这些提交。因此git reset
的使用场景是在本地版本需要回退时使用(前提是没有推送到远程库)。回退前,用git log
可以查看提交历史,以便确定要回退到哪个版本;要重返未来,用git reflog
查看命令历史,以便确定要回到未来的哪个版本。
Git revert
Git revert用来撤销某次操作,此次操作之前和之后的commit和history都会保留,并且把这次撤销作为一次最新的提交。git revert
是提交一个新的版本,将需要revert的版本的内容再反向修改回去,版本会递增,不影响之前提交的内容。虽然代码回退了,但是版本依然是向前的,所以,当你用revert回退之后,所有人pull之后,他们的代码也自动的回退了。Git revert和git reset
都可以进行版本的回退,将工作区回退到历史的某个状态,二者有如下的区别:
git revert
是用一次新的commit来回滚之前的commit,而git reset是直接删除指定的commit(并没有真正的删除,通过git reflog
可以找回),这是二者最显著的区别;git reset
是把HEAD向后移动了一下,而git revert
是HEAD继续前进,只是新的commit的内容和要revert的内容正好相反,能够抵消要被revert的内容;
使用revert HEAD是撤销最近的一次提交,如果你最近一次提交是用revert命令产生的,那么你再执行一次,就相当于撤销了上次的撤销操作,换句话说,你连续执行两次revert HEAD命令,就跟没执行是一样的。git revert 命令的好处就是不会丢掉别人的提交,即使你撤销后覆盖了别人的提交,他更新代码后,可以在本地用 reset 向前回滚,找到自己的代码,然后拉一下分支,再回来合并上去就可以找回被你覆盖的提交了。
Git fork
Git fork不是一个Git命令,而是一种工作流。它不是使用单个服务端仓库作为中央代码基线,而让各个开发者都有一个服务端仓库。Fork工作流的主要优点在于贡献可以轻易地整合进项目,而不需要每个人都推送到单一的中央仓库。开发者推送到他们 自己的 服务端仓库,只有项目管理者可以推送到官方仓库。这使得管理者可以接受任何开发者的提交,却不需要给他们中央仓库的权限。结论是,这种分布式的工作流为大型、组织性强的团队(包括不可信的第三方)提供了安全的协作方式。它同时也是开源项目理想的工作流。
Git Add & Commit
add 和 commit 应该可以说是我们使用频率最高的高层命令了。git add
不加参数默认为将修改操作的文件和未跟踪新添加的文件添加到git系统的暂存区,注意不包括删除。每当将修改的文件加入到暂存区,git 都会根据文件的内容计算出 sha-1,并将内容转换成 blob,写入数据库。然后使用 sha-1 值更新该列表中的文件项。在暂存区的文件列表中,每一个文件名,都会对应一个sha-1值,用于指向文件的实际内容。最后提交的那一刻,git会将这个列表信息转换为项目的快照,也就是 tree 对象。写入数据库,并再构建一个commit对象,写入数据库。然后更新分支指向。
Git Conflicts
在使用Git提交文件时,不可避免的会产生冲突。解决冲突成为我们必须要面对的事情,要解决冲突,首先要明白冲突是怎么产生的。下面就来了解一下Git中文件冲突是怎么来的
图上的情况,并不是移动分支指针就能解决问题的,它需要一种合并策略。首先,需要明确的是谁和谁的合并,是 2,3 与 4,5,6的合并吗?说到分支,我们总会联想到线,就会认为是线的合并,这只是一种感性的认知。而事实上并不是这样的,真实合并的版本是 3 和 6。因为每一次提交都包含了项目完整的快照,即合并只是 tree 与 tree 的合并。我们可以先想一个简单的算法。用来比较3和6。但是我们还需要一个比较的标准,如果只是3和6比较,那么3与6相比,添加了一个文件,这无法确切表示当前的冲突状态。因此我们选取他们的两个分支的分歧点(merge base)作为参考点,也就是版本1,进行比较。首先把版本1、版本3、版本6中所有的文件做一个列表,然后依次遍历这个列表中的文件。现在我们拿列表中的一个文件进行举例。当版本3和版本6分别和版本1进行比较时会出现下面三种情况:
- 版本1、版本3、版本6的 sha-1 值完全相同,这种情况表明没有冲突,可以正常合并
- 版本3或6至少一个与版本1状态相同(指的是sha-1值相同或都不存在),这种情况可以自动合并。比如版本1中存在一个文件,在版本3中没有对该文件进行修改,而版本6中删除了这个文件,则以版本6为准就可以了
- 版本3或版本6都与版本1的状态不同,情况复杂一些,自动合并策略很难生效,需要手动解决冲突了。
Merge
在解决完冲突后,我们可以将修改的内容提交为一个新的提交,这就是 merge。git merge
用来做分支合并,将其他分支中的内容合并到当前分支中。运行git-merge
时含有大量的未commit文件很容易让你陷入困境,这将使你在冲突中难以回退。因此非常不鼓励在使用git-merge
时存在未commit的文件,建议使用git-stash
命令将这些未commit文件暂存起来,并在解决冲突以后使用git stash pop
把这些未commit文件还原出来。
可以看到 merge 是一种不修改分支历史提交记录的方式,这也是我们常用的方式。但是这种方式在某些情况下使用 起来不太方便,比如当我们创建了 pr、mr 或者 将修改补丁发送给管理者,管理者在合并操作中产生了冲突,还需要去解决冲突,这无疑增加了他人的负担,使用 rebase 可以解决这种问题。
Rebase
rebase在git中是一个非常有魅力的命令,使用得当会极大提高自己的工作效率;相反,如果乱用,会给团队中其他人带来麻烦。它的作用简要概括为:可以对某一段线性提交历史进行编辑、删除、复制、粘贴;因此,合理使用rebase命令可以使我们的提交历史干净、简洁!假设我们的分支结构如下:
rebase 会把从 Merge Base 以来的所有提交,以补丁的形式一个一个重新达到目标分支上。这使得目标分支合并该分支的时候会直接 Fast Forward,即不会产生任何冲突。提交历史是一条线,这对强迫症患者可谓是一大福音。
Checkout
对于 checkout,我们一般不会陌生。因为使用它的频率非常高,经常用来切换分支、或者切换到某一次提交。这里我们以切换分支为例,从 git 的工作区、暂存区、本地仓库分别来看 checkout 所做的事情。Checkout 前的状态如下:
首先 checkout 找到目标提交(commit),目标提交中的快照也就是 tree 对象就是我们要检出的项目版本。
checkout 首先根据tree生成暂存区的内容,再根据 tree 与其包含的 blob 转换成我们的项目文件。然后修改 HEAD 的指向,表示切换分支。
可以看到 checkout 并没有修改提交的历史记录。只是将对应版本的项目内容提取出来。
stash
有时,我们在一个分支上做了一些工作,修改了很多代码,而这时需要切换到另一个分支干点别的事。但又不想将只做了一半的工作提交。在曾经这样做过,将当前的修改做一次提交,message 填写 half of work,然后切换另一个分支去做工作,完成工作后,切换回来使用 reset —soft 或者是 commit amend。Git 为了帮我们解决这种需求,提供了 stash 命令。stash 将工作区与暂存区中的内容做一个提交,保存起来,然后使用reset hard选项恢复工作区与暂存区内容。我们可以随时使用 stash apply 将修改应用回来。git stash
(git储藏)可用于以下情形:
- 发现有一个类是多余的,想删掉它又担心以后需要查看它的代码,想保存它但又不想增加一个脏的提交。这时就可以考虑
git stash
。 - 使用git的时候,我们往往使用分支(branch)解决任务切换问题,例如,我们往往会建一个自己的分支去修改和调试代码, 如果别人或者自己发现原有的分支上有个不得不修改的bug,我们往往会把完成一半的代码
commit
提交到本地仓库,然后切换分支去修改bug,改好之后再切换回来。这样的话往往log上会有大量不必要的记录。其实如果我们不想提交完成一半或者不完善的代码,但是却不得不去修改一个紧急Bug,那么使用git stash
就可以将你当前未提交到本地(和服务器)的代码推入到Git的栈中,这时候你的工作区间和上一次提交的内容是完全一样的,所以你可以放心的修Bug,等到修完Bug,提交到服务器上后,再使用git stash apply
将以前一半的工作应用回来。 - 经常有这样的事情发生,当你正在进行项目中某一部分的工作,里面的东西处于一个比较杂乱的状态,而你想转到其他分支上进行一些工作。问题是,你不想提交进行了一半的工作,否则以后你无法回到这个工作点。解决这个问题的办法就是
git stash
命令。储藏(stash)可以获取你工作目录的中间状态——也就是你修改过的被追踪的文件和暂存的变更——并将它保存到一个未完结变更的堆栈中,随时可以重新应用。
stash 实现思路将我们的修改提交到本地仓库,使用特殊的分支指针(.git/refs/stash)引用该提交,然后在恢复的时候,将该提交恢复即可。我们可以更进一步,看看 stash 做的提交是什么样的结构。
当你多次使用git stash命令后,你的栈里将充满了未提交的代码,这时候你会对将哪个版本应用回来有些困惑,这时git stash list命令可以将当前的Git栈信息打印出来,你只需要将找到对应的版本号,例如使用 git stash apply stash@{1} 就可以将你指定版本号为stash@{1}的暂存内容取出来,当你将所有的栈都应用回来的时候,可以使用git stash clear来将栈清空。stash是本地的,不会通过git push
命令上传到git server上。
打赏一个呗