Git底层原理与分析模型
https://www.cnblogs.com/liqinglucky/p/git.html
一、git版本管理
1.1 背景--从写毕业论文讲文档备份
让我们从写毕业论文的经历讲起。通常开始写论文之前,先在一个开阔的空间创建了一个文件夹用于保存将来的论文稿。然后就开始了我们的 “毕业论文版本管理”。
这样管理存在的问题:
- 看不出每一个版本都更改了什么东西
- 合并多个文档版本的不同段落需要逐个打开手动复制。
- 文档副本很多的时候,和容易忘记那个才是自己的最终版本。
- 文档手滑删除了,只能再写一遍。
当然毕业论文至多就多复制几个文件备份就好了,但如果是上万个代码文件的项目工程如何管理呢?
1.2 版本控制系统
备份策略通常包括版本控制,或者叫“对变更进行追踪管理”。不管是集中式的 CVS、SVN 还是分布式的 Git 工具,实际上都是一种版本控制系统,我们可以通过他们很方便的管理我们的文件、代码等。
1.2.1 集中式与分布式
集中式版本控制系统[1]。版本库是集中存放在中央服务器的,开发者都是在自己本地电脑,先从中央服务器下载最新的版本,然后开始干活。干完活了,再把自己的活推送给中央服务器。集中式版本控制系统最大的毛病就是必须联网才能工作,中央服务器要是出了问题,所有人都没法干活了。
布式版本控制系统。分布式版本控制系统根本没有 “中央服务器”,每个人的电脑上都是一个完整的版本库。 开发者之间可以把各自的修改直接推送给对方。分布式版本控制系统的安全性要高很多,因为每个人电脑里都有完整的版本库,某一个人的电脑坏掉了不要紧,随便从其他人那里复制一个就可以了。
在实际使用分布式版本控制系统的时候,其实很少在两人之间的电脑上推送版本库的修改。因此,分布式版本控制系统通常也有一台充当 “中央服务器” 的电脑,但这个服务器的作用仅仅是用来方便 “交换” 大家的修改,没有它大家也一样干活,只是交换修改不方便而已。
1.3 git历史
Git 是当前流行的分布式版本控制管理工具,最初由 Linus Torvalds (Linux 创始人) 创造,于 2005 年发布[2]
[3]。
[!quote] Git版本控制管理, Jon Loeliger, Mattbew McCullougb
Git诞生之前,Linux内核开发过程中使用BitKeeper来作为版本控制系统(VCS),然而在2005年春天,BitKeeper对免费版加入额外限制时,Linux社区意识到BitKeeper不再是长期可行的方案。
Linux 创始人Linus Torvalds也开始寻找替代方案。首先他回避再次使用商业解决方案。但当时现有的开源方案中的一些限制和缺陷也使得他放弃。
1.4 git特性
- 分布式开发。允许开发人员在自己本地离线并行开发,不需要与中心版本库时刻同步。
- 能够胜任上千开发人员协同。
- 性能优异。网络传输操作,需要使用“压缩”和“差异比较”技术。
- 保持完整性和可靠性。一个分布式版本控制系统,要能绝对保证数据的完整性和不被篡改。通过安全散列函数(SHA1)命名和识别数据库中的对象。
- 强化责任。能够定位谁改动了文件与改动说明。
- 原子事务。相关操作要么全部执行要么都不执行。
- 支持并鼓励基于分支的开发。
- 完整的版本库。每个人的版本库中都有一份完整历史修订记录。
- 清晰的内部设计。
- 免费自由。
二、git底层原理--数据结构
其实只要我们掌握几个基本的git命令其实就够了,但还是很有必要理解git的实现逻辑。[4]
[!tip]
工科很多东西都有一套底层逻辑,得出结论更多靠的是“推导”而非“记忆”。
2.1 仓库-- git初始化一个仓库
如果我们打算对该项目进行版本管理,第一件事就是使用 git init
命令,进行初始化。
$ git init
git init
命令只会做一件事,就是在项目的根目录下创建一个 .git
的子目录,用来保存当前项目的一些版本信息。
2.2 三大分区--追踪文件管理状态
- 工作区(Working Directory):能直接编辑文件。这个区位置最简单,就是我们的所有源代码目录。新增的文件和修改的文件修改状态为红色。
- 暂存区(stage,index):暂时存放的文件数据。可以理解为数据进入本地代码仓库之前存放的区域。
git add
操作将文件副本加入暂存区。文件修改状态为绿色。 - 仓库区(commit History):纳入版本管理的文件数据。可以理解为一个本地的代码仓库。暂存区
git commit
的文件会被放入仓库区。文件修改状态清除。[5][6]
2.3 对象类型(objects)--commit,tree,blob
Git 可以通过一种算法可以得到任意文件的 “指纹”(40 位 16 进制数字),然后通过文件指纹存取数据,存取的数据都位于 objects 目录。
块(blob: binary large object):文件内容本身,不包含文件名。[7]
目录树(tree):记录blob标识、路径名和目录里的所有文件。
提交(commit): 保存版本库中提交时刻的快照。
一个commit表示了什么?
A: 每个commit索引出此刻的完整工作区源文件
B: 每个commit索引出此次的所有工作区新增文件内容
[!quote] Git版本控制管理 4.1.5节, Jon Loeliger, Mattbew McCullougb
git内部数据库存储文件的每个版本,而不是差异
git用blob之间的区别计算历史,
git需要创建一个工作目录时,对文件系统说“我有这样大的一个blob数据,应该放在路径tree下,你能重新构造?”
直接存储每个版本的完整内容是否效率太低了?如果只添加一行到文件,git是否要存两个版本的全部内容?不是,不完全是。git的打包文件存储机制,定位内容相似的全部文件,然后只存储一份全部内容。之后计算文件间差异并只存差异。如果你只更改一行内容,git会存储新版本的全部内容,然后记录一行的差异,存储在包里。
打包文件跟对象中其他对象存储在一起。也用于网络版本库中的高效数据传输。
梳理 git管理文件版本的原理:
1 每个源文件内容本身映射成blob对象,而源文件名与文件路径映射成tree。指纹(SHA1)算法保证了对象的全局唯一性。
2 用commit索引tree, blob来追踪还原工作区的所有文件。一个commit就是一个完整的版本副本。
3 commit会自动追加成commit链还原了版本的全部历史。
三、git分析模型--有向无环图
理解了commit的实质基本原理后,建立commit的基本分析模型[8][9]:有向无环图(DAG)。有向无环图是不会回到起点的图。一个commit表示一个节点,一个节点表示了所有文件的一次版本。节点有父链接(历史改动生成的指纹)指向上一个commit,只要根据git的抽象模型分析git的操作就够了。只需关注commit id的变化分析。
3.1 commit与reset操作
一次commit就是整个仓库所有代码的改动。
git add a.txt
git commit -m "add a.txt"
git log看commit链。
# git log
commit dea03e51887ee93dbe862d8c6a4e5f64ca586d60 (HEAD -> master)
Author: Your Name <you@example.com>
Date: Tue Jun 4 03:17:26 2024 +0000
add b.txt
commit 966737211d560207bc6d7e01be267707adc22ca6
Author: Your Name <you@example.com>
Date: Tue Jun 4 03:12:04 2024 +0000
add a.txt
通过git diff
可以看到改动了哪些内容。
比较两次commit id的diff
git diff commit_id1 commit_id2
3.2 分支与merge操作
分支就是在某个commit发生分叉的commit链。
merge就是将两个commit链合并。
查看该 commit object 后可以看到,执行 merge 操作之后,会将原本版本链基础上衍生出一个新的 commit,并且该 commit 拥有两个 parent 父指针:
1 两个分支合并,不冲突的时候,git计算合并结果,并创建一个新提交
2 有冲突是,通常出现在同一个文件,同一行两个分支都各自进行了改动。git自己不解决冲突,而是留给开发人员处理,然后开发人员做一次commit。
如何避免 git 冲突?
“各位同仁们,接下来的四五个小时内我即将提交一笔代码,恳请大家在这段时间不要merge任何代码,求求大家了,晚上给你们买奶茶!”
只要是多人协作开发,文件的冲突是不可避免的,剩下的问题只是根据git提示手动解决冲突。
3.3 push与pull操作
对于处在远端的中央仓库,我们每次尝试通过 push 向远端推送一个 commit 时,远端仓库都会对提交版本的正确性进行校验,校验方式是沿拟提交 commit object 的 parent 指针向前遍历,倘若能找到某个 parent commit object 和远端分支上最后一个 commit object 的 key 值相同,才可能允许这次 push 行为,以此保证版本链的连贯性.
追踪远端分支
git checkout -b dev --track origin/dev
3.4 子模块(submodule)
子模块是一个链接,就像文件夹的快捷方式。
git submodule可以看到记录子模块的文件.gitmodule
,文件的内容就是记录的子模块的那个仓库的最新commit id。
四、操作演示
4.1 分区与对象
// 实时观察.git目录的变化
$ watch -n .5 tree .git
// .git目录
.git
├── branches
├── COMMIT_EDITMSG
├── config
├── description
├── HEAD <<< 指向当前commit
├── index <<< 暂存区文件
├── info
│ └── exclude
├── logs
│ ├── HEAD
│ └── refs
│ └── heads
│ └── master
├── objects
│ ├── 96
│ │ └── 6737211d560207bc6d7e01be267707adc22ca6
│ ├── bc
│ │ └── 9a8c7d02d20e99c0481003176a906d4c6e0cf3
│ ├── c9
│ │ └── b2240e42509686a034104179629ad74f72d3f4
│ ├── cc
│ │ └── 147aadf9175a075ea6f2c455692d074b45c329
│ ├── de
│ │ └── a03e51887ee93dbe862d8c6a4e5f64ca586d60
│ ├── info
│ └── pack
└── refs
├── heads
│ └── master <<< 指向当前分支commit
└── tags
objects
// 对象类型:blob
# git cat-file -t c9b224
blob
// 对象内容: 文件内容
# git cat-file -p c9b224
1 hello
2 git
// 对象类型:tree
# git cat-file -t cc147a
tree
// 对象内容: 文件内容
# git cat-file -p cc147a
100644 blob c9b2240e42509686a034104179629ad74f72d3f4 a.txt
100644 blob c9b2240e42509686a034104179629ad74f72d3f4 b.txt
// 对象类型:commit
# git cat-file -t dea03e
commit
// 对象内容: 文件内容
# git cat-file -p dea03e
tree cc147aadf9175a075ea6f2c455692d074b45c329
parent 966737211d560207bc6d7e01be267707adc22ca6
author Your Name <you@example.com> 1717471046 +0000
committer Your Name <you@example.com> 1717471046 +0000
add b.txt
index
# git ls-files --stage
100644 c9b2240e42509686a034104179629ad74f72d3f4 0 a.txt
100644 c9b2240e42509686a034104179629ad74f72d3f4 0 b.txt
HEAD
// 当前分支
.git# cat HEAD
ref: refs/heads/master
# git cat-file -t HEAD
commit
root@ubuntu:/home/git# git cat-file -p HEAD
tree cc147aadf9175a075ea6f2c455692d074b45c329
parent 966737211d560207bc6d7e01be267707adc22ca6
author Your Name <you@example.com> 1717471046 +0000
committer Your Name <you@example.com> 1717471046 +0000
add b.txt
# git cat-file -t refs/heads/master
commit
# git cat-file -p refs/heads/master
tree cc147aadf9175a075ea6f2c455692d074b45c329
parent 966737211d560207bc6d7e01be267707adc22ca6
author Your Name <you@example.com> 1717471046 +0000
committer Your Name <you@example.com> 1717471046 +0000
add b.txt
logs
# cat .git/logs/HEAD
0000000000000000000000000000000000000000 966737211d560207bc6d7e01be267707adc22ca6 Your Name <you@example.com> 1717470724 +0000 commit (initial): add a.txt
966737211d560207bc6d7e01be267707adc22ca6 dea03e51887ee93dbe862d8c6a4e5f64ca586d60 Your Name <you@example.com> 1717471046 +0000 commit: add b.txt
4.2 远端仓库
git clone ssh://root@server/home/git
遇到的问题:push到远端失败
Writing objects: 100% (3/3), 308 bytes | 154.00 KiB/s, done.
Total 3 (delta 0), reused 0 (delta 0), pack-reused 0
remote: You can set the 'receive.denyCurrentBranch' configuration variable
remote: to 'ignore' or 'warn' in the remote repository to allow pushing into
remote: its current branch; however, this is not recommended unless you
remote: arranged to update its work tree to match what you pushed in some
remote: other way.
解决:
root@ubuntu:/home/git/.git# cat config
[core]
repositoryformatversion = 0
filemode = true
bare = false
logallrefupdates = true
[receive] <<< 增加策略
denyCurrentBranch = ignore
4.3 子模块
git submodule add ssh://root@server/home/plugin
子模块
git/plugin# git log
commit cbb1dd676b4d269e6cf02a4eadb4946391adbfa1 (HEAD -> master, origin/master, origin/HEAD)
Author: Your Name <you@example.com>
Date: Sat Jun 1 13:03:18 2024 +0000
c.txt
git/plugin# cd ../
git# git status
On branch master
Your branch is up to date with 'origin/master'.
Changes to be committed:
(use "git restore --staged <file>..." to unstage)
new file: .gitmodules
new file: plugin
git# git submodule
cbb1dd676b4d269e6cf02a4eadb4946391adbfa1 plugin (heads/master)
git# cat .gitmodules
[submodule "plugin"]
path = plugin
url = ssh://root@server/home/plugin
参考
Git版本控制管理, Jon Loeliger, Mattbew McCullougb ↩︎
Git-内部原理 pro-git在线文档 ↩︎
[万字串讲git版本控制底层原理及实战分享]( https://www.bilibili.com/video/BV1Gu4y1u7ut/, https://zhuanlan.zhihu.com/p/670878449) ↩︎