作为一个计算机专业的学生,在常年的使用中git总是绕不开的一个东西,但是笔者最近发现自己对git的理解相当局限,于是有了这一片文章,意在完全理解git的原理。并对git的使用做一个较为全面的介绍。
本文会先从git的理想模型开始,阐述原理和某些重点实现,最后才会对git的使用和技巧做阐述,读者可以自行按目录跳转
同时,本文不含有具体的代码实现,仅包括函数的使用例。逻辑等笔者尽力使用自然语言描述。笔者作为本专业文章,代码相信读者已经有自主掌握的能力。
Git的本质
git本质上是一个带有历史版本管理和项目间通信的文件管理系统。
所以,我们要先从文件系统的部分讲起。
文件系统,就是实现文件储存寻找功能的计算机程序,用户在内存中捣鼓完文件之后,将文件存入文件系统(ctrl+s)或者从文件系统读出文件。
作为文件系统的Git
知识点1
git是一个内容寻址文件系统,这意味着每一个文件对于git而言,本质是文件内容而不是文件名。对于每一个文件做哈希之后成为唯一标识。
与之相对的是位置寻址文件系统,是基于文件位置的文件寻址,文件名,位置不同即视作不同文件,对于常用的计算机系统就是这样的。
哈希是一个密码学上的算法,可以通过算法将任意一组文件映射为定长字符串且查重率可以通过数学验证的低,具体可以见我的哈希博客。换句话说,哈希算法可以为任意文件生成唯一的**”身份识别码“**
git对每一个文件应用哈希并记录,这使得每一个文件的完整性得到了极大保证——一旦修改/错误,哈希值就会变化,这是git文件安全和版本控制的核心。
文件做好映射之后,git还保存一个树结构,作为项目目录的结构的储存。这个数据结构递归定义一个树,即树上节点有文件和另一棵树两种文件。
有了文件和目录结构,就可以完整的描述一个文件系统了——只需要指明从哪里开始(根位置)递归查询,就可以描述出一个文件夹下的所有细节。
这就是git作文文件系统的本质,文件识别+树结构。
不难看出,这样的系统内部其实是无序的,只要指定了系统开始位置就可以。
Git的版本控制机制
git的版本实际指的是:项目某个时刻的完整的文件结构和文件
由前文来看,很容易想到使用树根来表示一个完整的项目版本,因为一个项目的唯一识别方式就是代表根目录的树根,只有从这里开始,才能完整的访问整个项目。
git就是这样考虑的,对于每一次版本上传(commit),git创建一个新的树结构,然后将树结构的根作为一个版本的代表。这样就完成了一次版本的记录。
换言之,git每一个commit都是一个树,git通过访问树来确定一个版本的所有内容,而内容本身则无序的存放在储存中。
?一个问题:
Q:修改一个文件并commit后,git中的这个文件的原来的版本还在吗?
A:当然还在,因为新版本的树和文件都是独立的,commit之后只是生成了新版本,之前的版本不受影响,如果要回滚版本这些文件还有用呢。
这个过程叫作”保存快照(sanpshot)“
版本间的差异比较
有了不同快照之后就可以进行相互比较了。
git对于tree会递归访问来比较差异,对于结构相同的文件使用靠谱高效的差分算法,比如Myers 算法,来逐行比较它们的内容。可以迅速查询到增量减量修改的行。
Git的优化
以上就是git最核心的原理与其实现方式:保存项目中产生的所有文件,并按照版本描述相关的项目结构。
或许你会觉得这样做太浪费了——如果我有一个文件100mb,每一次保存我的git里就会多一个100mb的文件?时间长了我的项目会膨胀多少倍?
确实,所以git也确实做出了不少的文件压缩措施,来精简git目录条件。
哈希的另一种用法
前面曾经叙述了哈希的作为一种一一对应的映射算法在文件身份识别的用处,现在要着重陈述哈希另一方面的用处。
先说明哈希具有一下性质:
- 对于每一个文件,都有几乎唯一的哈希值,这个冲突率极其微小,小到全世界都在不断使用,也几乎很少有哈希冲突造成的事故。
- “唯一”代表相同的文件内容的哈希值,只要不换哈希算法就是相同的,不会变化。
- 哈希值几乎不可预测,有很好的密码学性质,哪怕内容只差一点,哈希值的差别就会很大,所以哈希对修改和错误很敏感。
在以上的性质下,可以察觉到,对于git系统,是不会储存两个相同的文件的,没有变化的文件在两个版本的树上指向相同的文件。
这就是git的数据复用,同理,对于两个版本,如果一些目录结构没有变化,树也是会复用前有的而不是新建。
松散压缩与增量压缩
在 Git 仓库的生命周期中,对象最初是以独立的、未压缩的松散对象(Loose Objects)
git在进行文件操作或者文件过多(默认多于6700)时,会进行松散对象的压缩。
在打包时,Git 不会简单地复制每个对象的完整内容,而是会智能地识别出那些内容相似的对象(通常是同一个文件的不同版本)。它会选择其中一个作为基线(Base Object),然后只存储其他对象相对于这个基线的差异(Delta)。
这样可以对于一些频繁做小修改的文件在git的体积做极大优化。
上述压缩后合并得到Packfile文件,这个文件体积较大。
从数据库优化角度来看,大文件可以减少io压力。
对于每一个packfile,都有一个单独的idx文件作为索引,方便文件系统查询
gc机制
gc(Garbage Collection)即垃圾回收机制可以让git立即打包项目的松散对象,同时删除无用的未引用文件。
这些文件大多是历史回滚之后重新编辑,剩下的原本的那些版本或者被删除的分支上的版本,这些文件会被查询引用,没有引用的文件会被删除。
Git LFS
处理大二进制文件的一种拓展,将大文件用作文件指针,本地文件历史版本存在专门的仓库中,避免git本地过于巨大。
Git的组织结构
Git的对象
- Blob对象
储存实际文件的对象,可以是文本文档,也可以是二进制文件
不包括文件名,路径等元数据,只有一个哈希值 - Tree对象
一个 tree描述了其儿子的信息,包括文件模式,类型,文件名/目录名,对应对象的哈希值。 - Commit对象
记录一次提交的元数据,将项目状态与特定历史连接起来。
一个Commit对象包含以下内容
指向根的哈希
父提交哈希:指向上一次提交的commit对象,可以有多个父对象,这与git的历史版本策略有关系
作者信息:提交者的姓名邮件时间戳等
提交者信息:执行‘git commit’ 的操作者的信息
提交说明 - tag对象
对于commit对象的永久标签,常用来标记版本号。
tag分为两类
轻量标签:指向commit对象的引用
附注标签:标签本身也包含创建者等信息,并指向一个commit对象
git的三大区域
git有以下三大区域
- 工作区,实际编辑项目结构与文件的区域
- 暂存区,保存了即将提交文件的快照,可以通过add提供内容
- 本地仓库,git目录的所在地,'git commit’之后到这个位置。
平时实际在电脑中的文件就是工作区,他是git系统本身的解压缩产物,某种意义上来说已经脱离了git本体。但是还在git的管辖范围之内,用户直接与这个区域交互
暂存区是用户与git系统之间的缓冲带,可以使用git命令来指定某些文件参与git快照的构建,这个指定由用户完成,所以用户可以只更新一部分文件到快照中,这个决策的缓存区就是暂存区。
本地仓库就是上述的git文件系统的本体了,其与用户的交互仅限于git指令
Git目录
object目录包括git的全部数据
refs目录储存所有引用,如heads,tags,remotes
head文件
index文件:暂存区的本体
config:配置文件
logs:日志文件
hooks:存放git脚本,在特定git指令发生时自动执行自定义脚本
Git的多人协作与版本管理
通过以上的技术我们实现了一个项目的历史版本的线性管理,这之后要解决的是更加复杂的多人协作问题
之后我们不再提本地层面的操作,而只考虑每个用户掌握的git版本以及发送给版本到git的请求。而一个仓库由多个用户维护。
先复习一下commit操作具体的内容:
提交者,作者的信息,本次提交快照,父提交哈希。
换句话说,每次提交都向其父提交连接一个有向边,这形成一个DAG(有向无环图),
Git的分支机制
- git的分支(Branch)本质上是指向一个commit的引用
- head是一个指向当前工作的本地分支的引用,切换工作区就会改变head的值并将工作区更新为当前分支的最新commit
- 合并(Merge)将不同的分支整合到一起,形成一个新的commit对象,这个分支会有复数个父提交,包括被合并的所有分支与当前的工作分支等。
- 变基(Rebase)将一个分支上的一系列提交移动到另一个分支上,与merge不同这一串快照被合并为新的一个commit提交到目的分支
- 值得提到的是,对于git来说,分支标签等在实现上全部是引用,这大大提高了性能。
merge,rebase的使用
首先是结论:两者在有规划的情况下没有明确的优劣,怎么用更多的是一个团队沟通的问题。
merge的 行为逻辑非常的直观,就是将两个分支的变化合并到一起,如果存在冲突就解决,最后分支的数量减少1.
rebase可以让某一分支的若干提交移动到当前分支上,来使得提交更加简洁易读。具体的行为逻辑是:寻找合并分支和当前分支的最近公共祖先,然后从这一处到该分支最新的提交的所有修改逐步对当前分支进行操作。
merge有一个分支选项和rebase很像merge -squash,区别是将所有的修改合并为一个修改commit到待合并分支。而不是分步的
关于分支冲突
分情况讨论,当一个commit是线性的时候,也就是当前分支的所有提交全部是一个分支的子提交,那么不会存在冲突。
当一个提交想要合并两个分支的时候,出现了冲突,解决方案如下:
- 若两个分支对同一个文件有修改时,git无法决定使用哪一方的版本,将决定权留个用户手动介入。、
- 也可以使用指令指定默认的选择策略,选择本分支还是另一个分支。
commit撤销
git提供了对一次修改的reverse,来抵消某一次commit并且保留之后的修正
git也可以直接reset来回到git的某个版本,虽然这不符合git的设计理念,及单向的运行,保留全部历史数据。
git stash
在git上专门设计的一个栈,与三个区相互隔离,存入stash之后的内容可以随时存取,用于紧急情况切换分支时保留未开发状态的作用。
Git远程仓库
原理上,git的远程仓库就是一个普通的仓库,但是远程仓库没有工作区(避免冲突等)
git通过以下途径与用户进行交互:
fetch:用户从远程拉去(不合并)
pull: 用户从远程拉去并更新(fetch+merge本地分支)
push:将本地的commit与分支推送到远程仓库
特别的,为了防止版本错误,在检测到本地没有远程仓库的commit时,git会阻止push,需要先从远程拉去之后再push,换言之,push前需要强制的更新分支
传输协议
git常支持如下通讯协议:
- 本地协议:
/path/to/repo.git - SSH:
git@github.com:user/repo.git(最常用,安全且支持写权限) - HTTPS:
https://github.com/user/repo.git(便于穿越防火墙) - Git 协议(
git://...,只读)
关于SSH的相关知识,我会专门补足。
常见git命令
这里仅包括常见的命令,更加全面的命令请参阅文档。
配置git
git config [--global] user.name/email
创建库
git init
本地文件操作
git add [-m]
# m是添加提交说明
git commit [--amend]
# amend可以修改上一次提交
git log
git status
值得说的是,log的显示格式是可以设置的,但是考虑到实际上的运用场景基本都集成在ide了,这块有更好的可视化,遂不讲。
版本相关
git reset [--soft][--mixed][--hard]
这里set的soft只是移动指针,被跳的版本暂存区和工作区都在,mixed则暂存区删除,但是工作空间代码不变,hard则全部删除,相当于进行了一个merge
特别介绍一些reset符号
- HEAD^ 这是上一个版本
- HEAD~n 这事向上回滚n个版本
分支相关
git check [-b] name
# 此处-b是创建
git branch [-a -D]
git merge
git push name [--delete]
git pull
git fetch
git stash [pop]
# stash是一个栈,pop是出栈的意思
git rebase
git clone
# 获取远程仓库
git diff
# 对比两个分支
git钩子
git钩子(Hooks)是放在git项目文件/hooks文件夹里的一些脚本,这些脚本在git流程里的某些时间点才会运行,比如:commit前/stash前/commit后/服务器接受后/push之前之后……等等等等。
这些脚本的名字是固定的,git只会调用固定名字的脚本,如果想要实现复杂功能,请在入口脚本内调用其他脚本。
脚本的语言是本机能够解析的一切脚本,这些脚本需要在第一行以# 解释器路径 的格式来指定解释器。
一些基本的钩子名字如下:
pre-commitprepare-commit-msgcommit-msgpost-commitpost-checkoutpre-rebase
从命名来看就可以看出脚本被调用的时刻,上述只是部分常用的脚本。
常见的应用比如:
每天从remote的仓库pull下来的时候,自动与本地分支diff等等。
本地钩子与服务端钩子
钩子本身是本地的,不会进行git的文件记录,因此钩子需要另外记录。
但是有一些服务端钩子,只布置在服务器端进行,这个钩子对于服务器本身是本地的,对于用户是远程的。
一些使用git的好原则
- 提交相关更改,对于一个提交尽量做单纯的事情,这样更加便于维护
- 经常提交,不要一次性提交一个大的版本,这样有益于减轻其他合作者的同步压力
- 提交之前测试代码保证可用性
- 写简洁有效的提交信息
- git不是承载备份的作用,尽管她在客观上实现了这个功能
- 使用分支,使用分支可以使得项目有更好的可维护性