Hello World
深入理解Git:从原理到实践的全面指南
本文深入剖析Git的核心概念和底层实现原理,包括对象模型、分支管理、合并策略等关键特性。通过理解Git的数据结构和工作机制,帮助开发者更好地处理版本控制中的各种复杂场景,从基础使用者进阶为Git专家。
在日常开发中,很多开发者在使用Git时往往只是停留在基本操作层面,特别是在遇到复杂的合并冲突时,常常感到无所适从。要想真正掌握Git,成为Git专家,关键在于深入理解其底层原理。本文将帮助你:
- 深入理解Git的核心概念和工作原理
- 学会分析和解决各类合并冲突
- 掌握Git的高级特性和最佳实践
通过本文的学习,你将能够在日常工作中更加自如地使用Git,处理各种复杂场景。
1. 基础概念 git的三种基本对象
1.1 Blob
Blob对象是Git中最基础的存储单元。它的工作原理是:
- 将文件内容进行SHA-1哈希计算,得到一个唯一的哈希值作为文件名
- 使用zlib压缩算法压缩文件内容,生成blob对象
这种设计的巧妙之处在于利用了哈希算法的"雪崩效应":即使文件内容只改变一个字符,产生的哈希值也会完全不同。这样,Git只需要比较文件的哈希值,就能立即知道文件是否被修改,而不需要逐字节地比较文件内容,大大提高了效率。
1.2 Tree
Tree对象相当于Git中的目录结构,用于组织和管理文件。它的工作原理是:
- 记录目录下所有内容的信息:
- 文件对应的blob对象
- 子目录对应的tree对象
- 为每个目录生成一个唯一的哈希值,这样任何目录结构的变化都能被追踪
这种层级结构让Git能够高效地管理从单个文件到整个项目的所有内容变化。
1.3 Commit
保存每次提交的信息。
1.4 分支
分支其实就是一个指向commit的指针(文件内容是commit的hash值)。当你提交新的更改时,这个指针会自动指向新的commit。这就是为什么我们能轻松地在不同版本之间切换:本质上就是在移动这个指针。
2. 完整的一次提交过程
在这里我们模拟解释一次git add/ commit的过程,来了解git的工作原理。
2.1 git add 文件
假设我们修改了readme.md文件,执行git add
时会发生以下操作:
- Git读取readme.md的内容
- 将内容用zlib算法压缩,创建一个blob对象
- 用SHA1算法计算这个blob对象的哈希值
- 用这个哈希值作为文件名,将blob对象保存到Git仓库中
这样,每个文件版本都会有一个唯一的标识(哈希值),Git可以精确地追踪文件的每次变化。
2.2 git commit
执行git commit
时,Git会按以下步骤创建新的提交:
-
创建新的Tree对象:
- 读取当前仓库的Tree对象(保存了所有文件的目录结构)
- 将
git add
创建的新Blob对象更新到Tree中 - 为这个新的目录结构创建一个新的Tree对象
-
创建新的Commit对象:
- 记录新Tree对象的哈希值
- 记录父Commit的哈希值
- 保存提交信息(commit message)
- 保存提交者信息和时间戳
-
更新分支指针:
- 将当前分支指向这个新的Commit对象
这样就完成了一次完整的提交,保存了项目的一个新版本。
相当于,我们每次提交,都会完整的把项目中每个文件通过zlib压缩,完整地保存下来。而对于没有修改的文件,git会通过类似于快捷方式,或者软链接等方式,直接指向上一次提交的版本。
Commit对象包含以下重要信息:
- tree:指向项目根目录的Tree对象
- parent:指向一个或多个父Commit
- 常规提交只有一个父Commit
- 合并提交会有多个父Commit,记录了所有被合并的分支
- author/committer:提交者信息
- message:提交信息
这种设计让Git能够完整记录项目的历史,并支持复杂的分支合并操作。
在git commit时,会获取git add创建的blob对象。为了优化设计,git在某处地方存放了一个列表,里面写满了
3. 常见概念以及命令解析
3.1 暂存区(Stage/Index)
暂存区是Git中的一个重要概念,它是一个临时的Tree对象,默认与仓库的HEAD指针指向的Tree对象相同,内容也相同。如果有文件提交,那么临时Tree对象只修改对应的blob,指向新的blob。现在不理解不要紧,下面在使用git底层命令模拟提交的时候会通过例子去详细解释:
-
当执行
git add
时:- 创建新的Blob对象
- 更新暂存区的Tree对象,指向这些新的Blob
-
默认情况下:
- 暂存区的Tree对象与最近一次提交的Tree对象结构相同
- 只有执行
git add
后,相应的文件引用才会更新
-
特殊操作:
- 执行
git reset --soft
时,只移动HEAD指针,暂存区保持不变
- 执行
3.2 使用git底层命令模拟commit
既然git add是创建新的Blob对象,那么我们理论上也可以用git提供的底层命令去完成这个操作。
MS-7B23:~/repo$ git hash-object -w -t blob hello.txt
ce013625030ba8dba906f756967f9e9ca394464a
在这里,我们可以看到git hash-object命令的输出,它生成了一个新的Blob对象,并且返回了这个对象的哈希值。我们直接使用zlib命令,在.git文件夹中找到这个blob文件并解压。
zlib-flate -uncompress < .git/objects/ce/013625030ba8dba906f756967f9e9c
a394464a
blob 6hello
接下来,我们需要将这个新创建的blob对象添加到暂存区的tree对象中。这个过程包含两步:
-
获取当前的tree对象并创建副本
注意- 如果你此时仓库为空,例如刚初始化,git中没有任何东西。那你并不需要获取当前的tree对象。跳到第二步即可
- 但是如果你此时仓库中已经被初始化,那就需要获取当前的tree对象。需要使用
git read-tree HEAD # 读取当前HEAD的树到暂存区
-
在新的tree对象中更新hello.txt的引用,指向我们刚创建的blob对象
git update-index --add --cacheinfo 100644 ce013625030ba8dba906f756967f9e9ca394464a hello.txt
然后再把这个新的tree也保存到文件,我们需要执行git write-tree
,这个命令会返回这个tree的哈希值。
git write-tree
aaa96ced2d9a1c8e72c56b253a0e2fe78393feb7
到这里,我们已经创建了文件的blob对象和目录结构的tree对象。最后一步是创建commit对象,它将包含:
- 这个tree对象的引用
- 提交信息
- 提交者信息
- 时间戳
当然默认git会读取git config中的user.name和user.email,但是也可以在git commit中
自己指定
这个临时的树,就是git中大名鼎鼎的暂存区。你可以使用git ls-tree $tree_sha1来查看它的内容(ls-tree可以查看任意tree对象的内容),也可以使用git diff $tree_sha1来查看它的变化。
我们现在提交这个树到仓库,并包含commit信息。
echo "Project structure" | git commit-tree aaa96ced2d9a1c8e72c56b253a0e2fe78393feb7
f2d4b8622737f02c226f16594f32c423b2c39c5c
我们直接解压这个commit对象,可以看到它的内容。
zlib-flate -uncompress < .git/objects/f2/d4b8622737f02c226f16594f32c423b2c39c5c
commit 212tree aaa96ced2d9a1c8e72c56b253a0e2fe78393feb7
author lll <abc@mail.com> 1735620034 +0800
committer lll <abc@mail.com> 1735620034 +0800Project structure
到这里还不算完,还需要更新HEAD指针,才算一次真正的提交
git update-ref refs/heads/master f2d4b8622737f02c226f16594f32c423b2c39c5c
这样,我们就完成了一次完整的提交,保存了项目的一个新版本。使用git log命令,可以查看本次提交的记录
git log
commit f2d4b8622737f02c226f16594f32c423b2c39c5c (HEAD -> master)
Author: liang_zhibang <liang_zhibang@venusgroup.com.cn>
Date: Tue Dec 31 12:40:34 2024 +0800Project structure
完整图例
3.3 git soft三种模式的区分
通过前面的学习,我们已经了解到HEAD和暂存区实际上都是指向特定Git对象的指针。默认暂存区指针与HEAD指针指向同一Tree对象。
现在,让我们通过一个具体的例子来深入理解git reset
的三种模式(--soft、--mixed、--hard)的区别。
假设我们有一个项目,已经进行了三次提交,我们将以这个场景为例:
3.3.1 git reset --soft
如果我们执行git reset --soft HEAD^1
, 那么Git会做以下操作:
更新HEAD指针,指向上一次提交的commit对象, 暂存区指针不动,依旧指向本次commit提交的tree对象
这种状态下,如果我们执行新的提交,会产生一个有趣的效果:
- 暂存区保留了最新的文件状态(指向最新的Tree对象)
- HEAD指向了上一个commit
- 当我们提交时,会用暂存区的内容创建一个新的commit
这就是为什么它被称为"soft"(软)重置:
- 只移动HEAD指针,不改变任何文件内容
- 保留了暂存区的所有改动
- 常用场景:
- 修改最近一次的提交信息
- 将多个提交合并为一个提交
- 在不丢失任何改动的情况下重新组织提交
我们可以使用Git底层命令来模拟git reset --soft
的效果。首先使用git rev-parse HEAD
获取当前commit的引用,然后通过git rev-parse HEAD^1
获取父commit的hash值,最后使用git update-ref refs/heads/master HEAD^1
来更新分支指针。这样就实现了只移动HEAD指针而保持暂存区不变的效果。
3.3.2 git reset --mixed
如果我们执行git reset --mixed HEAD^1
, 那么Git会做以下操作:
更新HEAD指针,指向上一次提交的commit对象, 暂存区同时也更新,指向上一次提交的tree对象。但是工作区不动。工作区就是你当前在磁盘系统上的项目文件。
这种模式的一个典型应用场景是:当你不小心提交了一些不应该提交的文件,或者不该提交的代码,但又不想丢失工作区的修改时。通过git reset --mixed
,你可以重置暂存区到上一个状态,重新选择要提交的文件,同时保留工作区的所有改动。这就是为什么它被称为"mixed"(混合)重置 —— 它在保留工作区的同时,让你能重新组织暂存区的内容。
通过上面的描述,我们可以使用git read-tree 命令来模拟git reset --soft的效果。核心要点在于修改HEAD和暂存区指针,但不改变工作区的内容。
3.3.3 git reset --hard
如果我们执行git reset --hard HEAD^1
, 那么Git会做以下操作:
更新HEAD指针,指向上一次提交的commit对象, 暂存区同时也更新,指向上一次提交的tree对象,同时把Tree对象中的文件,解压覆盖工作区。
3.4 git 怎么从暂存区复制文件到工作区
要从暂存区复制文件到工作区,需要用到两个底层命令:
3.4.1 git read-tree
这个命令用于读取和更新暂存区的状态。它是git checkout
和git restore
等命令的基础,主要功能是:
- 读取Git对象库中的tree对象,更新暂存区的状态
- 在合并时,可以将多个tree对象的内容合并到暂存区
3.4.2 git checkout-index
这个命令用于从暂存区复制文件到工作区。它的工作过程是:
- 读取暂存区中的文件信息(包含blob对象的引用)
- 从对象库中读取对应的blob对象
- 将blob对象的内容解压到工作区
如图所示,当我们需要从暂存区恢复一个文件到工作区时,Git会先通过read-tree
命令读取暂存区的信息,找到文件对应的blob对象,然后使用checkout-index
命令将blob对象的内容解压到工作区。这就是git checkout -- <file>
或git restore <file>
命令背后的工作原理。
3.5 合并冲突与快速合并
在Git中,一个commit可以有多个父commit,这个特性正是分支合并的基础。当我们合并分支时,本质上是将两个不同分支上的树对象(tree objects)合并成一个新的树对象,并创建一个新的commit来指向这个合并后的树对象。
3.5.1快速合并(Fast-forward)
当一个分支的所有提交都是另一个分支的直接后继时,Git可以执行快速合并。这意味着两个分支的文件树之间没有任何冲突,因为一个分支的历史完全包含了另一个分支的历史。在这种情况下,Git只需要将当前分支的指针"快进"到目标分支的位置即可,无需创建新的合并提交。
3.5.2 合并冲突
当两个分支同时修改了同一个文件的同一个位置时,Git就无法自动决定应该使用哪个版本的内容,这就产生了合并冲突。与快速合并不同,这种情况下Git需要我们手动介入来解决冲突。
比如上图所示的情况:
- 基础版本的文件内容是"Hello World"
- master分支将其修改为"Hello Git World"
- feature分支将其修改为"Hello World"并添加了新行"Welcome!"
- 当我们尝试合并这两个分支时,Git会在文件中标记出冲突的位置:
<<<<<<<
HEAD 标记当前分支(master)的修改内容=======
分隔两个分支的修改>>>>>>>
feature 标记要合并的分支(feature)的修改内容
要解决冲突,我们需要:
- 打开发生冲突的文件
- 找到并分析冲突内容
- 决定最终要保留的内容
- 删除所有的冲突标记(<<<<<<< HEAD,=======,>>>>>>> feature)
- 保存文件
- 使用
git add
标记冲突已解决 - 使用
git commit
完成合并
这样就完成了一次冲突解决,新的commit将会同时包含两个分支的历史。
Git也提供了一些自动解决冲突的策略。通过在git merge
命令中使用-X
参数,我们可以指定合并策略选项:
git merge -X ours
在冲突时优先使用当前分支(我们的)的修改git merge -X theirs
在冲突时优先使用要合并分支(他们的)的修改
这些策略可以帮助我们在某些场景下自动化解决冲突,但要谨慎使用,因为它们可能会忽略掉重要的改动。建议只在确定优先级的情况下使用这些选项。
3.6 撤销commit
有时我们可能需要撤销历史中的某个特定提交,比如要撤销第三个commit。但是git reset
命令有一个局限性:它只能从最新的提交开始,连续地撤销commit。也就是说,如果要撤销第三个提交,就必须先撤销第五个和第四个提交,这显然不是我们想要的结果。这种情况下,我们需要使用其他的Git命令来实现目标。
Git的核心设计理念之一是保持完整的历史记录。因此,撤销一个提交并不是简单地删除它,而是通过创建一个新的提交来"反转"之前的修改。这样做可以确保所有的变更都有迹可循,同时也让其他协作者能够清楚地看到这些变更的来龙去脉。这就是为什么Git提供了git revert
这样的命令,而不是直接删除提交记录。
我们同样可以使用Git提供的底层命令来模拟git revert
:
- 使用
git rev-parse HEAD~2
获取要撤销的commit的引用(假设是倒数第三个提交) - 使用
git rev-parse HEAD~3
获取该commit的父commit引用 - 使用
git diff HEAD~3 HEAD~2
命令获取这两个commit之间的差异,并生成一个patch文件:git diff HEAD~3 HEAD~2 > changes.patch
- 使用
git apply --reverse changes.patch
命令反向应用这个patch,相当于撤销这个commit的修改 - 最后使用
git add .
和git commit -m "Revert commit xxx"
来提交这个撤销操作
通过--reverse
参数,我们告诉Git要反向应用这个patch,也就是撤销而不是应用这些修改。这样就实现了撤销特定commit的效果,同时保留了完整的操作记录。
3.6.1 处理合并提交的撤销
理解了撤销commit的原理后,我们来看一个特殊情况:合并提交(merge commit)的撤销。合并提交的特殊之处在于它有多个父commit。这时,如果直接使用git revert
,Git会遇到一个问题:它不知道应该基于哪个父commit来生成反向修改。
例如,当我们撤销一个合并提交时,我们需要明确告诉Git:
- 是要撤销合并本身
- 还是要保留其中一个父分支的修改
这就是为什么在处理合并提交时,我们需要使用git revert -m <parent-number>
命令,其中parent-number
指定要保留哪个父分支的修改(1表示第一个父分支,2表示第二个父分支)。
如图所示,当我们需要撤销一个合并提交时,必须选择要保留哪个父分支的修改。这是因为合并提交包含了来自两个分支的改动,Git需要知道我们想要撤销哪些改动。
3.7 git rebase
git rebase
是一个强大的命令,它可以帮助我们重新组织提交历史。它的工作原理是通过提取修改并以patch形式重放,这使得它能够:
3.7.1 合并分支
与merge
不同,rebase
通过改变提交的基础(base)来合并分支。从底层实现来看,rebase
的过程是这样的:
- 找到两个分支的共同祖先
- 对比当前分支相对于祖先的历次提交,提取每次提交的差异(patch)
- 将当前分支指向目标分支的最新提交
- 按照提交的顺序,将之前保存的patch依次应用到当前分支
这种方式相当于把我们的修改在目标分支上重新"重放"一遍,使提交历史呈现出一条直线,更加清晰易读。虽然最终的代码改动是一样的,但是提交历史会被重写。
如图所示,rebase
的核心是通过生成patch文件来保存修改,然后将这些修改重新应用到新的基础上。patch文件记录了具体的改动内容(增加、删除或修改的行),这就是为什么即使在新的位置重放,也能保持相同的改动效果。
3.7.2 修改提交历史
正是因为rebase是基于patch的重放机制,而不是直接修改Git对象,所以我们可以在重放过程中对这些patch进行编辑。通过交互模式(git rebase -i
),我们可以在重放之前对提交进行多种操作:
- 重新排序提交
- 合并多个提交
- 修改提交信息
- 删除某些提交
- 将一个提交拆分成多个
这种能力使得我们可以在代码推送到远程仓库之前,整理和优化我们的提交历史,使其更加清晰和有意义。但需要注意的是,一旦提交已经推送到公共仓库,就应该避免使用rebase
来修改历史,因为这会给其他开发者带来麻烦。
如图所示,当我们使用squash
命令合并提交时,Git实际上是将每个提交的改动提取为patch文件,然后将这些patch合并成一个新的patch,最后将合并后的patch应用到目标位置。这就是为什么我们能够保持改动的完整性,同时简化提交历史。
通过本文的学习,你已经深入了解了Git的核心概念和底层实现原理。这些知识将帮助你:
-
理解Git的数据结构和存储模型
- Blob、Tree和Commit对象的作用和关系
- 分支和指针的工作机制
-
掌握复杂操作的原理
- 分支合并和冲突解决的底层逻辑
- Rebase和Reset等高级操作的工作方式
-
培养问题解决思维
- 学会从底层原理分析问题
- 能够处理更复杂的版本控制场景
记住,理解Git的核心概念和数据结构比记住具体命令更重要。这些知识将帮助你以更系统的方式思考和解决版本控制中的各种挑战。