你好,我是猿java。
Git 是如何工作的?暂停 10秒:你能在脑海里构思出一幅 Git工作的全景图吗?
最近,有小伙伴私信我,说他因为这个问题,倒在了美团一面的路上。借此机会,特来聊聊 Git是如何工作的?整体大纲如下图:
Git是什么
老规矩,在讲解一个技术点之前,先弄清楚它的概念,看看官方是怎么推销自己的,如下图:
大致意思是:Git 是一个免费的开源分布式版本控制系统,旨在快速高效地处理从小型到超大型项目的所有内容。
Git 易于学习,占用空间小,性能快如闪电。它超越了 Subversion、CVS、Perforce 和 ClearCase 等 SCM 工具,具有廉价的本地分支、方便的暂存区域和多个工作流程等功能。
开源、分布式、快如闪电、易学习、优于其他SCM 工具,官方的描述是不是太诱惑了?不过,作为技术人员,我们显然不会为这种宣传式的介绍买单,有没有干货呢?
当然有,直接上菜!
Git对比其它SCM
Git 对比其他 SCM工具,最大的差异点有 3点:架构,数据存储,完整性保证。
架构
Git采用的是分布式设计,每个克隆都是完整的版本库,因此能更好地支持离线操作和本地提交;其它 SCM是集中式管理,重度依赖网络,离线模式工作困难,另外,集中式还会让我们直接想到单点故障。
数据存储
其它类型的SCM(CVS、Subversion、Perforce ),数据保存都是基于 delta差异存储,如下图:
而 Git是全量快照存储,另外,Git为了效率,如果文件没有被修改,Git不再重新存储该文件,而只保留一个链接指向之前存储的文件。如下图,虚线是对实线的链接指向:
关于 Git在数据存储上的优势,在下面 git branch 部分就能很好地体现。
保证完整性
Git 中所有的数据在存储前都需要通过 SHA-1散列(一种 hash算法),计算出一个由 40 个十六进制字符(0-9 和 a-f)组成的字符串校验和(哈希码),然后以该哈希码作为引用。因此,当任何文件内容或目录内容被修改,通过这个哈希码就能很快被发现。
3种状态
在 Git中,文件有三种状态:modified(已修改)、staged(已暂存)、committed(已提交)。
-
已修改:表示文件在工作区被修改了,但还未进入缓存区;
-
已暂存:表示已修改的文件进入了缓存区,即将被提交;
-
已提交:表示缓存区的数据被提交到本地仓库中;
4个核心区域
Git 包含 4个核心区域:
-
工作区: Working Directory
-
暂存区: Staging Area
-
本地仓库: Local Repository
-
远程仓库: Remote Repository
工作区
工作区,顾名思义,就是我们干活的区域,一般是电脑上肉眼可见的目录。如下图:磁盘上 yuanjava/git-project 目录就是一个工作区。
暂存区
暂存区,有点类似于草稿箱,用 Git 的术语叫做"index(索引)",本质是一个二进制文件,即 {工作目录}/.git/index,如下图:
****
本地仓库
本地仓库,就是 .git目录,它完整地保存了 git 项目的历史记录,包含了项目的所有文件、提交历史、分支、标签等信息。通过执行 git init
命令就能在工作区下生成一个 .git目录。如下图:
远程仓库
远程仓库,其实就是代码的远程服务器。比如:GitHub、GitLab 或自建的服务器。远程仓库可以被多个开发者访问和操作,允许团队成员协同工作,共享代码并进行版本控制。
最后,用一张图来形象地描述 3 种状态和 4个核心区域:
为了更好理解上图的核心内容,我们以代码提交这个过程为例:
-
在工作区中进行文件操作,被修改后的文件,状态就会变成 modified;
-
执行 git add 命令,Git的文件状态变成 staged,更改的部分会被添加到暂存区;
-
执行 git commit 命令,已暂存的文件会被提交到本地仓库,状态变为committed;
-
执行 git push,本地仓库的代码会被同步到远程仓库;
上述描述的 4个核心区域,其实都是 Git的一些逻辑结构,为了更深入地了解这些逻辑结构对应的物理结构,也就是磁盘目录,我们就不得不扒一扒 .git 这个神秘的隐藏目录。
.git目录详解
.git 是一个隐藏目录,位于工作目录下,它是通过执行 'git init ' 或者 'git clone ' 命令生成的。如下图,执行 ll -a
指令,可以看到 .git目录的所有信息:
.git 目录下的子目录和文件比较多,这里重点讲解:HEAD,refs,index,objects, logs:
HEAD
HEAD 文件是一个特殊的指针,指向当前所在的本地分支,HEAD 文件的内容是:"ref:refs/heads/{分支名}"。
如下图:当分支切换为 master时,HEAD文件的内容为"ref:refs/heads/master",当分支切换为 test时,HEAD文件的内容为"ref:refs/heads/test"
refs
refs是一个目录,包含 3个子目录:
- heads:存储本地分支引用
- remote:存储远程分支引用
- tags:存储标签的引用
如下图:
目录 refs/heads/ 下面存放的是一些以分支名命名的文件, refs/heads/ {分支名}的结构和上文 HEAD 文件中的内容是对应的,每个分支名对应一个文件,记录了该分支最后一个commit的校验和,而这个校验和就是用来定位 .git/object/{校验和前2位}/{校验和剩余38位} 这个文件,如下图:
objects
objects 是一个相当重要的目录,存储了所有分支所有版本的数据,包括文件内容、目录结构、提交历史等。
objects的目录设计也是相当的精巧,结构如下:
-
子目录:Git 使用 SHA-1哈希算法对对象内容进行哈希计算,然后将哈希值的前两个字符作为目录名,将后 38 个字符作为文件名。这样的目录结构有助于将大量的对象分散存储,提高效率。
例如,如果文件哈希值是 ab123ds...,那么文件路径就是 .git/objects/ab/123ds...
-
文件:每个文件的内容存储在与其哈希值对应的文件中。文件可以是 blob 对象(存储文件内容)、tree 对象(存储目录结构)、commit 对象(存储提交信息)等。
-
- Blob 对象:存储文件的真实内容,对应于工作目录中的文件;
- Tree 对象:存储目录结构,包括文件和子目录;
- Commit 对象:存储提交的元数据,包括作者、提交时间、指向树对象的引用等;
-
信息压缩:对象文件是经过 zlib 压缩的二进制数据。这种压缩有助于减小存储空间,并提高传输效率。
index
index 是一个二进制文件,也就是上文说的暂存区,它记录当前目录结构和文件的索引信息,用于构建下一次提交的快照;初始状态下不存在该文件,需要执行第一个init add
命令后生成该文件;如下图:
logs
logs 是一个目录,存储引用的更新历史,例如分支的移动、合并等操作记录,初始状态下该目录不存在,在执行第一个git commit文件时生成,logs/HEAD 记录的是commit的log,可以通过 git log 命令查看;
总结,通过上面对.git目录的分析,我们能看出每个逻辑结构后面对应的真实磁盘目录是什么,同时,还可以梳理出在 Git 查找当前分支代码的整套流程:
- 通过 .git/HEAD 文件找出当前指向的分支名A;
- 查看 .git/refs/heads/A 文件,文件的内容就是分支A最后一次提交的哈希值;
- 定位 .git/object/{哈希值前2位}/{哈希值剩余38位} 二进制文件,该文件就是分支A 最后提交对应的数据仓库;
- 对于 .git/index文件,当分支切换后,会用步骤 3中状态为 staged 文件信息进行数据恢复;
-
- Git分支
有了上文内容的铺垫,我们再来分析 Git分支就会轻松很多,这里挑选日常开发工使用频率最高的几个功能,另外,在图形描绘时,我会标注磁盘目录,方便大家更好地对照逻辑变更与磁盘目录变更的关系。
分支创建****
git 创建分支最常用的命令是:
css
git branch 分支名git checkout -b 分支名
在默认情况下,.git/HEAD 指向的是工程创建时最原始的分支,这里以 master为例。如下图:
当我们基于 master创建一个新分支 test时,其实就是创建了一个可移动的新指针,如下图:
在 test新分支上做修改,并且产生了一个新的提交 c3,test指针会移动 c3,如下图:
分支合并
分支合并最常用的命令
bash
# 将两个或更多的开发历史合并在一起git merge
# 将提交重新应用到另一个基准提交的顶部git rebase
快进合并(fast-forward)****
如下图:假如把 test 分支合并到 master分支,由于 test 所指向的提交 c3 是 c2 的直接后继, 因此 Git 会直接将 master指针向前移动。换句话说,当合并两个分支时, 如果顺着一个分支走下去能够到达另一个分支,那么 Git 在合并的时候, 只会简单地将指针向前推进(指针右移),再加上合并不存在冲突,因此,这种合并叫做 "快进(fast-forward)"。
三方合并(three-way merge)
如下图:假如线上出现紧急bug,需要从 master 切出一个新分支进行修改,并且该分支最终被合并到 master,即 c2 移动到 c4,此时 mater指向了 c4。
在处理完 bug后,重新切到 test分支继续工作,在一切准备 ok后,需要把 test 合并到 master分支。
因为 master不能顺着某个分支到达 test,Git 会使用两个分支所指的快照(c3 和 c4)以及这两个分支的公共祖先(c2),做一个简单的三方合并,这种方法叫做三方合并。此时合并会出现两种情况:
- c3 和 c4 不存在冲突,Git会自动合并;
- c3 和 c4 存在冲突,Git不会自动合并,而是停下来,需要我们手动解决冲突,然后重新提交;
rebase 合并
rebase:中文翻译 变基 或者 重新设置基线;
git rebase:将提交重新应用到另一个基线提交的顶部。这句话看起来很晦涩,但是如果把句子补全成"把当前分支的基线应用到另外一个分支基线的顶部",这样是不是就更容易理解?这里的基是指两个分支最近的公共祖先。
如下图:master 和 test 两个分支最近的公共祖先是 c2, 因此基线就是 c2。
git rebase master,把 test的基线 c2变成 master 基线c2的顶部 c4,也就是把 c3 指向 c2 变成 c3指向 c4。因为 test分支在 rebase之后内容变更了,所以 c4对应的磁盘目录也就变成了 .git/refs/c4'。
反之,git rebase test,那就把 master的基线 c2变成 test 基线c2的顶部 c3,也就是把 c4 指向 c2 变成 c4指向 c3。因为 master在 rebase之后内容变更了,所以 c3对应的磁盘目录也就变成了 .git/refs/c3'。
最后,更直白地描述 git rebase:把另一个分支线性地包含到当前分支。
git merge 和 git rebase对比
git merge 保留了每个分支上的提交,合并会导致提交历史中可能出现较多的分叉和合并点;git rebase 会保持线性的提交历史;
git merge 对于冲突,Git 会自动创建合并提交,并在冲突解决后完成合并;git rebase 当冲突发生时,Git 会在重新应用提交的过程中停止,让用户解决冲突后继续,这意味着在 rebase 过程中可能会多次冲突处理。
- Git常用命令
git init
git init 命令的功能如下:
- 初始化仓库:将当前工作区初始化成一个新的 git仓库;
- 生成隐藏的 .git目录:创建一个名为.git的子目录,其中包含了Git用于跟踪项目历史、版本控制等所需的全部内容。
- 默认配置:初始化 git仓库时,会生成默认的配置文件(如 .git/config),用于设置 git的行为和属性。
git init 命令执行后,在磁盘上的表现是工作区添加一个.git隐藏目录,该目录包含了一些默认的子目录和文件:
git add
'git add' 命令是将工作区文件添加到暂存区,实质上是在创建/更新 .git/index 文件,主要的过程为:
- 更新暂存区状态:git add 会将工作区中指定的文件更改或新文件的当前状态记录到 .git/index 文件中;
- 创建/更新索引记录:给被操作的文件生成一个哈希值,并将该文件的元数据(如文件名、文件类型、权限等)保存到 .git/index 文件中;
- 暂存文件快照: .git/index 文件会记录每个被暂存的文件的快照信息,用于创建提交时的快照;
如下图,在 test分支的工作区添加一个 test.txt文件,执行'git add . ' 命令后会在.git目录下生成一个 index文件,这个就是暂存区的核心。
将上述过程抽象成下面的模型:
git commit
'git commit' 命令是将暂存区的内容提交到本地仓库。对.git的修改为:
- 更新 .git/objects
在 .git/objects 目录中创建新的对象,这些对象会使用 SHA-1 哈希值作为标识,用于在仓库中唯一标识每个提交对象、树对象和文件对象。
因此,每一次 commit都会在 .git/objects下创建一个目录,目录名为此次提交到哈希值的前两位,如下图:
- 更新索引文件(index)
在 .git/index 中更新索引文件,记录新提交的快照和元数据信息;
- 更新 .git/refs
在 .git/refs/heads 中增加一个以分支名命名的文件(.git/refs/heads/test),文件中记录了该分支最后一次commit的hash值,如下图;
再次修改 test.txt并且commit,.git/refs/heads/test内容为:
- 更新HEAD指针:
HEAD 指向的分支(当前为 test 分支)指向新的提交对象,表示当前分支已经包含了这个新提交;抽象成下面的模型:
git pull
****'git pull' 命令用于从远程仓库中获取最新的提交,并将它们合并到当前所在的本地分支。它实际上是 'git fetch' 和 'git merge' 两个命令的组合。
- 执行 git fetch:
git pull 首先会执行 git fetch 命令,从远程仓库中拉取最新的提交、分支信息和其他更新;
git fetch 不会修改本地工作区的内容,而是将远程仓库的内容下载到本地仓库,更新远程跟踪分支(如 origin/master)指向最新提交;
- 执行 git merge:
git pull 接着会执行 git merge 命令,将远程仓库拉取的内容与当前分支进行合并;
如果没有冲突,git 会尝试自动合并更新到当前分支中。如果有冲突,则需要手动解决冲突后再提交;
git fetch
'git pull' 命令用于从远程仓库中获取最新的提交,并且将代码拉取到本地仓库。 'git pull'对 .git目录的影响如下:
- 更新 .git/refs/remotes/origin 目录,在该目录下创建一个远程分支名的目录,更新或创建远程分支的引用;
- 更新.git/FETCH_HEAD 文件中记录最新的 fetch 操作信息;
git merge
'git merge' 命令是指要将哪个分支的内容合并到当前分支。它对 .git目录的影响如下:修改 .git/refs/heads 目录
git push
git push
命令用于将本地分支的提交推送(同步)到远程仓库中。它对 .git目录的影响如下:
- 更新 .git/refs/remotes 或 .git/refs/heads
git push 会在 .git/refs/remotes 或 .git/refs/heads 目录下更新或创建远程引用;
- 更新 .git/config 文件
执行 git push 也会修改 .git/config 文件中关于远程仓库的配置信息,比如远程仓库的URL、远程分支的映射关系等;
git reset
``'git reset'**** 命令用于移动分支的指向以及更改暂存区和工作目录的状态。对 .git目录的影响如下:
-
更新 .git/refs/heads 目录下的分支引用文件;
-
更新暂存区;
-
修改本地工作区;
使用 git reset --hard ,回退暂存区,修改工作目录的状态,删除未提交的更改;如下图,执行 git reset --hard 命令,会把暂存区的修改回退,使得工作区是干净的。
- 总结
本文重点分析了 git 的3种状态,4个核心区域,.git目录,分支以及常见指令。因为 Git的知识点太多,无法一一讲解,只要抓住 Git的核心,也就是下面这张图,那么我们再去分析 Git其它的问题就会轻松很多。因此,回到文章标题:Git如何工作?其实这个问题的答案比较宽泛自由,只要围绕下图核心灵活作答即可。
参考资料
Git官方文档英文版:git-scm.com/book/en/v2G...
官方文档英文版:git-scm.com/book/en/v2