Git 的原理

Posted zhuo木鸟

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Git 的原理相关的知识,希望对你有一定的参考价值。

文章目录

什么是 Git?

Git 是一个分布式的版本控制系统。他的工作原理类似于区块链,严格来说,他是一个对象数据库。

Git 通过版本库来管理数据(代码)。这里的版本库,指的是开发者、或者服务器中托管的项目文件夹。在后文中,亦用版本库指由一系列历史项目文件、当前项目文件组成的文件夹。

对于一个开发者来说,他电脑上的项目文件,就叫本地版本库。而其他开发者的项目文件(有可能一样),就叫远程版本库。一般的,虽然分布式版本控制系统中,各个版本库都是同等地位的。但是为了开发方便,一般都会有一个公认的服务器,维护一个“官方”版本库。当然,假若这个官方版本库丢失了,那么可以用任何一个开发者的版本库顶上。这个官方版本库,也叫项目版本库。

不过,一个开发者,为了调试的方便,都有多个本地版本库。其中一个作为主版本库。另外的几个,一般作为调试之用。这些调试的版本库,一般称为克隆版本库。

版本库是由什么构成的

我们知道,一个项目文件,肯定包含源代码、说明文档、日志等等。由 Git 维护的版本控制系统中,一个项目文件也叫版本库。并且,区分与一般的文件管理系统,版本库除了有当前项目文件外,还包含了历史的所有项目文件,也即项目文件的历史版本(以下简称历史版本)和当前版本(以下简称当前版本)。

当然,当前版本、历史版本,并不是通过直接存储里面的项目文件,而是将它们经过处理后,转换成哈希数值存储在一个对象数据库中。这个数据库,相当于文件管理系统管理项目时,所用到的日志。只不过,可以通过这个“日志”,将项目文件恢复到历史状态。我们后面也会叫这个**“日志”为对象数据库**。

这究竟是怎么做到的呢?我们将在下一节说明。目前的主要任务,还是了解一下什么是版本库吧。

不过,在此之前,必须说明的一点是,在诸多介绍和书籍中,也将这个日志数据库,称为版本库。晕了?没关系,大家只要记住,版本库有时候是指项目文件夹的“日志数据库”(对象数据库)、也可以指项目文件夹。

注意,这里的术语,除了“日志数据库” 是笔者为了方便提出的之外,其余术语都来自官方资料

版本库(项目文件夹)的构成

版本库由:版本库(对象数据库)、工作区、缓存区构成。

其中工作区存储了实质的文件、以及文件目录。对象数据库,则存储了版本库的历史信息(历史版本等)。当然,他们都有相同的根目录,就是我们的版本库(项目文件夹)啦。

这样可能有点抽象,让我们看一个实际的例子:

其中, .git 就是我们的版本库(对象数据库)。而 folder1、folder2、ReadMe.md 就是我们的工作区了。

咦?缓存区呢?怎么没画出来。

缓存区又叫 index、暂存区。工作区中所做的每一次修改,并不会实时地、同步地更新给版本库。而是需要开发者手动提交,将修改弄成一个版本,存到缓存区中。然后,缓存区再正式地,将该版本加上适当的注释,添加到版本库中。

为什么要多此一举?我们先在此打住。在后续章节,我们将会仔细涉及。

版本库(对象数据库)的结构

前面提到:

工作区中所做的每一次修改,并不会实时地、同步地更新给版本库。而是需要开发者手动提交,将修改弄成一个版本,存到缓存区中。然后,缓存区再正式地,将该版本加上适当的注释,添加到版本库中。

这里有一个重要的概念,提交。但我们创建一个项目、并首次添加一些项目文件时,就会形成工作区。然后,我们需要用 git init 命令,生成一个空的版本库。

如果我们要将工作区所做的修改,包括文件的增、删、改、差等记录添加到版本库中,就要用到 git add xx [ xx 可以是所有文件、或指定的某些文件、文件夹],将 xx 作为一个版本,上传到缓冲区。然后,再用 git commit -m '注释' 将版本以及相应的注释,提交到版本库中。

一般的,我们修改了工作区,都必须以版本的形式,提交给数据库管理。

当然,版本不是工作区的简单复制。而是将工作区的文件、文件目录进行处理后,以哈希数值的形式,追加给版本库。

至此,大家应该能够理解版本库的构成了。版本库是由一系列版本构成的、以哈希值为内容的对象数据库

图中,每一个节点就是一个版本。每一个节点,是由工作区(历史工作区,若版本不是最新版本),处理成哈希散列构成的。当然,每一个版本,都是一次提交。因此,有时候也称版本为提交

版本库就是由提交构成的。这些提交,可能是基于上一次提交的提交,因此具有某些序列关系。

如何查看版本库的所有提交呢?可以用 git log [--oneline] \\[--graph] [-n number] 实现。

提交的组成

我们重复了三遍,提交由工作区(历史工作区,若版本不是最新版本),处理成哈希散列构成的。具体怎么做呢?

对于当前提交对应的工作区的所有文件、目录(文件夹),我们给它们取个别名叫 blob、tree:

  • 对每一个 blob,我们将其内容根据 SHA1 算法,计算出一个 40 位的哈希值,用来代替其原始内容,存放在版本中。
  • 对每一个 tree,我们将其内容(下属的 blob、tree 的文件名),通过 SHA1 算法,计算一个 40 位的哈希值,存储在版本中。

然后,将版本中的哈希值,以及提交时的元数据通过 SHA1 算法,计算出一个 Hash 值,作为该版本的标题。我们称为版本号。有时,也会称为提交哈希。其中,提交时的元数据,是指提交作者、提交时间、修改内容的作者、以及注释。


如何查看版本中的内容呢?
git cat-file -p 版本号,会输出:

$ git cat-file -p HEAD
tree dcdef0feae480a65a74e82bfc84faf0dbdbe0bc0
parent b510c6998b678742b7e872edff4b7dc0dd638b55
author xxxxxxx <xxxxxx@qq.com> 1597471230 +0800
committer xxxxxxx <xxxxxxxx@qq.com> 1597471230 +0800

change to master branch

HEAD 的当前版本的版本号的快捷名字。

  • 包括根目录:tree dcdef0feae480a65a74e82bfc84faf0dbdbe0bc0
  • 上一个版本的根目录:parent b510c6998b678742b7e872edff4b7dc0dd638b55
  • 修改者信息:author xxxxxxx xxxxxx@qq.com 1597471230 +0800
  • 提交者信息(有可能不是同一个人):committer xxxxxxx xxxxxxxx@qq.com 1597471230 +0800
  • 注释:change to master branch

为了查看某版本中的某个文件,存储了什么内容。我们可以用如下命令:
git cat-file -p 根目录的版本号

$ git cat-file -pdcdef0feae480a65a74e82bfc84faf0dbdbe0bc0
100644 blob e69de29bb2d1d6434b8b29ae775ad8c2e48c5391    ReadMe.md
040000 tree 49b4dea4392ce90ecd5a1bc9e7b44362aac69d0e    folder1
040000 tree a9f3cdba16734bb778a8203a9cb8c4754f3b0310    folder2

其中 100644 代表文件的权限。

我们也可以继续用 git cat-file -p 子目录哈希 来查看子目录的内容。(所需查询的文件的版本号可以逐级查询到)

当然,在逻辑实现上,虽然每一个版本看似存储了当前工作区的所有文件以及目录。但是,在物理上,版本库存储的版本,并非是相互独立、毫不相干的。也就是说,假设文件 f 在提交 A 中存储了,并在之后的提交中, f 并没有被更改(这点可以通过检测哈希值有无变化来判断),则之后的所有提交中,并不会实际存储 f,而单单存储一个引用

SHA1 算法

SHA1 算法是一个加密算法。他可以将任意长度的输入,输出成固定长度的随机数(哈希值)。不过,同样的输入,可以输出同样的哈希值。这个算法的输出,总共有 2160 种。因此,虽然可能不同的输入,有相同的哈希值。但是这个概率微乎其微,因此认为不同的输入,都有一个独一无二的哈希值

实际上,区块链也是一样。区块链用的是一个叫 SHA256 的算法。

现在,请思考,用哈希值来替代实际文件,有什么好处呢?为什么要这样做呢?

  1. 由于哈希值可以大致认为:不同输入,得到的哈希值绝对是独一无二的。因此,我们不用当心哈希值重复。这样有什么好处呢?此时,我们在本地版本库中,修改文件时,就不用去对照远端的项目版本库,有无重复的哈希。也就是说,我们可以离线工作
  2. Git 本质上讲是数据库。谈到数据库,就必须知道搜索速度的问题。如果采用哈希值,就可以用哈希表来存储数据。从而,将数据的访问时间降为常数时间
  3. 若修改了文件内容,那么相应的哈希值也会变化。也就是说,我们可以每隔一段时间,就算一下文件的哈希值,并与原先的哈希值对比,从而判断文件是否被恶意篡改。
  4. 用哈希值代替文件进行存储,很明显,能够降低大小,节省存储空间。
  5. 避免冗余存储。假设有一个文件,文件名不同,但内容相同。那么其算出的哈希值,肯定也是相同的。而系统只会去存储一个哈希值。这样做,可以大大降低冗余导致的存储浪费。
  6. 重命名检测算法,实现文件的移动、重命名等操作。

这里具体解释一下重命名检测算法:首先 Git 所存储的文件名,不是单个文件的名字,而是以版本库为根目录,描述的整个相对路径。如果一个文件进行了移动,并将操作提交到版本库后。Git 首先会认为原文件被删除了,然后在检查提交中有没有与其内容相同(可以通过判断哈希值来鉴别)的文件。若有,则直接认为是重命名操作。当然,这里的重命名,不是传统意义上的更改文件名,而是更改带有文件名的那个相对路径

总结

版本库(项目文件夹)的构成如下:

版本库的节点(即一个提交)的构成如下:

在逻辑上,一个节点(版本)存储了相应工作区的所有文件、文件目录、以及元数据的哈希值。在物理上,有些从未被修改过的文件,只保存一次。

每一个版本,都有一个哈希值,称为版本号。这个版本号用作为版本的引用。

每一个版本中的 blob、tree 都有独一无二的哈希。该哈希值与其内容有关。可以实现快速访问、压缩内存、避免重复存储、以及自动检错等优点。

版本库的最新的节点,一般都落后或者同步于工作区中的文件,且版本的建立,需要手动进行提交。提交的过程是,先提交给缓存区、再提交给版本库。

版本库相当于存储了项目文件的“历史”。

暂存区

缓存区也叫暂存区、索引(Index),后文统一称之为暂存区。建立一个提交,首先要将工作区中的文件,上传到暂存区中。再用 git commit 命令,将文件提交,形成一个新版本追加到版本库中。

什么样的文件,不应该上传到版本库中?

  1. 调试用的实验修改
  2. 意外修改
  3. 尚未准备好的修改
  4. 自动生成的文件

因此,在提交文件是,不能直接用 git add .git add -all 将工作区中的文件都上传到暂存区中。而是要有选择的挑选文件上传,即 git add 所要上传的文件或文件夹

查看暂存区

我们可以用 git status [--short] 查看工作区中文件的工作状态,以查看那些文件已经被添加到存储区中。例如:

$ git status
On branch master
Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
        modified:   my_code.txt

Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
        modified:   test_text.txt

Untracked files:
  (use "git add <file>..." to include in what will be committed)
        his_code.txt

可以看到,工作区的文件有如下三种状态:

  • 已经在暂存区中的(Changes to be commited)
  • 被修改了的,但没在暂存区中的(Changeds not staged for commit)
  • 没有被追踪的。这类文件是指,在工作区中,但不受 Git 管理的文件。

除此之外,我们也可以用 git diff --staged 来查看假若暂存区被提交,那么新版本与当前版本的差别。以此来判断暂存区的内容。

清空暂存区

如题,方法如下:

  1. git rm --cached 文件名
  2. git stash;这条命令比较危险。
  3. 删除 .git 文件中 index 文件夹的所有内容

让我们重点看一下第二条命令,这个命令是将工作区、暂存区中,与当前版本库中的版本相比,增加的操作全部存到一个栈中保存起来。可以通过 git stash listgit stash pop 分别查看栈内容,和还原栈内容。

选择性提交

什么是选择性提交?由于在 add 的过程中,我们可以指定特定的文件,上传到暂存区中。于是,当我们在开发时,假若一次性修改了文件 A 和 B,那么可以单独将文件 A 的修改上传到暂存区、提交到版本库。

但是,由于这些修改是一次性的。也就是说,单独提交文件 A 的修改,作为一个新的版本,则该新版本实际上在硬件是从未存在过的。那么,就有可能造成一些错误。例如,假设修改文件 B 是文件 A 得以运行的前提,那么只提交文件 A 的修改,会导致新版本实际上是不可运行的。

选择性提交的好处在于,能够使得版本泾渭分明。比如我们在文件 B 改了一个 bug,但在文件 A 中开发了新的内容。那么,分开来提交,就可以让人们清晰地认出该 Bug 的那个版本。

版本库管理

这里的版本库,是指对象数据库。

版本的回滚、复原

前面提到,版本库存储了项目文件的历史。因此,人们可以根据版本库,将文件还原到历史状态。

首先,需要用 git log 命令来查看版本库中所有的版本、版本号以及提交时补充的注释。

然后,根据版本号,既可以还原到所需要的版本,具体代码为:git reset [--hard] 版本号。但要注意,还原后,工作区的内容保持不变。如果需要重新提交,则需要再次 add 后 commit。

此时,输入 git log 不会出现当前版本号之后的版本号。

如果要还原版本,则可以用 git reflog 查询所有的提交,包括之前回滚的那部分提交。查询所需要还原的版本的版本号,再次使用 reset 命令即可。

当然,如果不愿意删除 reset 后版本之后的所有“新”版本,也可以使用 revert 命令。这个命令在回滚提交的同时,也创建了一个新的提交,用来保存“新”版本。然后,修改当前版本并提交后,可以和“新”版本合并,从而避免丢失。

分支

在同一版本库中,可以有不同的分支。也就是说,版本库中的版本,可以不是依次提交的。如下图所示:

此时,由于分支的存在,就构成了两条并行的“泳道”。

为什么有分支的存在?

1、并行开发
2、修复旧版本中的 Bug

分支管理:创建、合并、变基

创建与删除分支

创建一个分支,可以使用 git branch 分支名 的方式,以当前版本为起点,创建一个新的分支。

也可以用 git branch 分支名 版本号 ,来以任意版本为起点,创建一个新的分支:

分支是相互独立的,并且一个开发者在同一时间内,只能操作一个分支。该处于操作状态的分支叫做活跃分支。在未创建任何分支的情况下,节点库只有一条叫 master 的分支。这条分支也是默认的活跃分子。

可以通过 git checkout 已存在的分支名 来设置当前的活跃分子。(其原理是将 HEAD 指针指向该分支的最后一个版本)在切换活跃分子后,工作区的内容也随之变化。

在分支上的提交,都会在版本库中创建一个版本。并且,分支中的版本,具体来说,分支的新版本,是相互独立的。

若要删除某个分支,则需要用 git branch -d 分支名

合并分支

若要使得所有的版本都合为一处,就要进行版本的合并(分支的合并)。以便将两个节点修改的内容,全部并在一起。当然,这个过程中,可能存在合并冲突(conflict)。此时,就需要手工合并了。这里不再介绍。

合并可以用如下命令实现:git merge 分支1。运行后,即可将分支 1 合并到当前的活跃分支中。

变基

假设开发者 1 已经走了好长一段路了,但开发者 2 还没有完成 Bug 的修改,如下图:

开发者 2 为了保持新版本,避免落后太多,导致修改的 Bug “过时”,就需要进行变基。

如下版本库:

为了整合历史,我们可以将版本 E 变基,移动到 F 中。具体做法和合并差不多。其做法是保持版本 E 中的修改不变,然后以 F 为基版本,再将修改还原出来:

实现为——首先以分支 2 为活跃分支,然后输入: git rebase master 即可。

当然,变基和合并类似,也有可能出现冲突。此时,就需要手动变基。

标签(tags)

大多数项目中,都会采用 1.2.3.4 后者 “gingerbread” 等数字来表示软件的版本。这就是标签!当然,标签应是永久性的。修然可以强制修改,但大多数情况下,不建议这样做。因为,这很可能会导致版本的混乱。

创建标签

git tag 1.2.3.4 master -m 'My New Tags‘’
这句话将在 master 分支中的当前版本,创建一个标签。这里的标签也带有注释,并作为元数据,保存在版本库中。

标签的管理与作用

  1. 可用 git tag -l 显示版本库中所有的标签。
  2. 可用 git log --decorate 在日志中显示标签。
  3. 可用 git tag --contains 哈希值 可以查询包含哈希值的所有标签。

版本库的交互

创建克隆版本库

对于一个开发者来说,可不能直接在本地版本库中嘿嘿嘿。而是想要在一个克隆版本库进行调试后,再用本地版本库拉回修改。

创建克隆版本库可以用:git clone 克隆源;不过,在克隆版本库之前,需要创建一个空文件夹,之后在用 cd 进入到文件夹中国,再输入上述命令。

当然,这里的克隆源可以是文件路径,也可以是 URL(如 Github 地址)

裸版本库

裸版本库是指不带有工作区的版本库。也就是说,裸版本库中的项目文件=版本库。这也是为什么,很多时候,我们用版本库代指项目文件夹的原因。

裸版本库一般作为项目版本库使用。用于维护一个“官方”版本库或者作为备用的版本库。其可用 git clone --bare 克隆源 产生。

不过,裸版本库的文件夹一般命名为 xxx.git 。尽管这个后缀并不改变文件夹的性质…

版本库之间的版本分享

有了多个版本库,就需要进行版本库之间的分享。分享的命令有 pull、fetch、push。在此之前,由于使用 URL 或者文件路径来代指其他版本库有点麻烦,因此经常给版本库取一个“昵称”。命令如下:
git remote add 昵称 URL/文件路径
如果昵称太多,记不住,可以用 git remote --verbose 显示所有命令了的昵称;用 git remote rm 昵称 来删除昵称。

抓取 Fetch 操作

Fetch 用于跟踪目标版本库的所有分支。代码为:git fetch 远程版本库的昵称或URL

结果如下:

可见,创建的只是分支。我们可以用 git branch -r 命令来显示远程分支。并用 git diff 分支d clone\\分支e 来查看本地分支、以及远程分支的区别。

也可以用 git log 分支d .. clone\\分支e 来查看远端分支新增加的提交(版本)。

Pull = Fetch + Merge

若用 git pull 命令,则可以直接将远端分支追加后,在用 Merge 合并分支。当然,如果里面有冲突,还需要手工合并。

也可以用 git pull --rebase 在 Merge 之前变基。

在 Pull 和 Fetch 时,标签都会自动追加。如果不需要,可用 --no-tags 参数。

Push

将本地版本库推送到远程版本库。在进行 Push 时,值得注意的几点是:

  • 需要有远端版本库的访问权限
  • 是一种 fast-foreword updated,也就是说,需要远程版本库,属于本地版本库的一个子集,才能进行 Push
  • push 并不会让远端版本库跟踪我们的分支
  • push 不会将标签也塞给远程版本库。若需要,要加上 --tags 参数。

Github

Github 是一个为 Git 版本控制系统提供托管服务的网络供应商,我们可以使用 SSH 和 HTTPS 协议访问 Github 上的版本库(URL 啦)。

在 Github 中,我们可以直接在服务器上 clone 版本库,这种在服务器端的版本库也叫 fork 版本库。之后,通过 push 命令进修改提交到这个 fork 版本库中。

如果我们希望将自己的提交,传递给源版本库,可以先该 Github 上的所有者发送一个 pull 请求,拉取源版本库。之后,在用 push 命令将提交发给所有者。

有时在 Github 或 Gitee 上创建一个仓库后,因为带有 ReadMe.md,所以可能会出现下面的错误:

如何解决呢?

只需要:

git pull origin master --allow-unrelated-histories

思考题

  1. 在同一个本地版本库中,修改者、提交者可能是同一个人吗?为什么有可能是不同的人?如何在修改、提交的时候,表明自己的身份?
  2. 版本库的某一个版本信息中(用 git cat-file -p 版本号获取),上一个版本的根目录有变化吗?为什么它的哈希,与当前根目录的哈希不同呢?
  3. 选择性提交是什么?依次修改并提交属于选择性提交吗?选择性提交有什么坏处和好处?
  4. 为什么需要缓存区?git stash 有毛用?
  5. 如果我们需要的是一个可移动的标志,可以使用标签吗?为什么?
  6. 如何给 Github 中的某个版本库取一个昵称?
  7. 在 Github 中,将本地版本库推送到 Github 中的源版本库时,需要用到 git push origin master。请问,这里的 origin 是什么?master 又是什么?
  8. 解释下述操作:git stash、git log [–oneline] [–graph] [–decorate]、git diff [–staged]、git branch、git checkout、git branch [-r]、git merge、git cat-file -p、git pull、git push、git tag

以上是关于Git 的原理的主要内容,如果未能解决你的问题,请参考以下文章

Git内部原理探索

Git内部原理之Git引用

GIT 原理简介

GIT 原理简介

5分钟学习git内部原理

Git 内部原理