Git 理论与实践:能做什么以及为什么可以这么做
作为几乎已经一统天下的版本控制系统 Git,其精妙之处却不在版本控制本身,这真是一件顶有趣的事。
Linus,作为 Git 的设计和开发者,也是 Linux 的缔造者,有这样一句蔑视天下大部分程序员的名言:
程序本身不重要,数据结构才是关键。
在了解过 Git 的精髓之后,对于此话有了更深的理解。作为凡人的我,大部分时间都是在和程序缠斗,能写出易读易维护的程序已是难上加难的事。但 Linus 说了,数据结构才是关键。
事实也证明,Git 的灵魂的确是它那简单清晰,灵活轻盈的数据结构。正是有了这样的数据结构,才能让 Git 可以轻轻松松的支撑起成千上万条分支的并行开发,分支之间的切换,迅如闪电。
毫无疑问,Git 的学习曲线异常陡峭,遥想自己当初刚刚接触 Git 时,愣是看不明白那一个个简单又灵活的指令应该怎么用。后来,岁月流转,经过一次次尝试使用,终于基本掌握了几个最基础的 Git 命令的使用。比如:
- git add 添加一些未跟踪和跟踪已修改的文件
- git commit 将修改提交
- git push 将本地提交推送到远端服务器
- git pull 拉取远端服务器代码
- git checkout 切换分支,或者放弃某个文件已修改但未提交的部分
- git branch 新建本地新分支
- git merge develop 将 develop 上的新提交合并到当前所在的分支
- git reset HEAD file 撤销某个文件的 add 操作
- git reset –hard <commit-hash> 撤销并丢弃 <commit-hash> 之后的所有本地提交、add、modified
- git stash 将当前已有的 modified 暂存在 stash 缓存区
- git stash pop 将 stash 缓存区最后一次的缓存结果重新应用到当前分支所在的工作区
- git rebase develop 将 当前所在分支的提交变基到 develop 分支的最新提交
- git rebase -i HEAD~2 在当前分支重新改写上两次的历史提交
- git fetch –all & git reset –hard origin/master 强制性的将本地代码与远端最新代码同步
- git reset –hard <old-enough-commit-hash> & git pull 与上一条命令作用一样,强制性的将本地代码与远端最新代码同步
- git cherry-pick <commit-hash> 将某一次提交复制一份到当前分支
- 等等等
所有这些命令看着不少,其实也不多,但能做的事情已经非常多。大部分使用 Git 进行代码版本控制的程序员也都多少对其中大部分命令有所耳闻,有所使用。但是其中有多少命令,只是会用而不知个中门道呢?
我自己的情况是,在了解 Git 内部原理之前,以上几乎所有命令的掌握均是来自于死记硬背和用成惯性,而个中门道,只对个别的有些模糊又说不出来的直觉性感知,并且不知对错。
稍微生僻一些的命令,非要用到的时候,要么依赖同事的把关,要么到网上找到几乎一模一样的案例,然后照抄其解决方案。
真要自己写,心里是一点底也没有。
但是呢,我现在心里有底了。因为我已经初步窥探到 Git 内部原理的精髓之处。记在这里,一是为了巩固,二是为了方便自己日后查看。
以下便是 Git 内部原理的精髓之处:
- 不是版本控制系统
- Git 是一种键值型对象文件系统
- hash - object(blob)
- 如何实现版本控制?
- 每次提交生成当前文件对象的快照
- 最新提交也会指向上一次提交
- 所谓的版本控制系统就是一个指针关联起来的对象树(有向无环图)
- 如何提高版本控制的效率?
- 对于多个相似的文件对象,Git 有办法只保留一个完整版本,其余只保留相应的 delta
- Git 是一种键值型对象文件系统
- 四种对象
- 文件存储对象(版本控制无关)
- tree object – 文件夹
- blob object – 文件
- 例子
- tree
- tree
- blob
- blob
- blob
- blob
- tree
- tree
- 版本控制对象
- commit object
- commit 3(指向 commit 2)
- tree1
- tree1
- blob2
- blob1
- blob3
- blob3
- tree1
- tree1
- commit 2(指向 commit 1)
- tree1
- tree1
- blob2
- blob1
- blob2
- blob1
- tree1
- tree1
- commit 1(版本控制的开始)
- tree1
- tree1
- blob1
- blob1
- blob1
- blob1
- tree1
- tree1
- commit 3(指向 commit 2)
- tag object
- commit object
- 文件存储对象(版本控制无关)
- 三种引用(refs)
- .git/refs/heads(代表分支)
- .git/refs/heads/master
- .git/refs/heads/develop
- .git/refs/tags(代表标签)
- 打标签,比如 1.0.0
- 指向「指向 commit 的 tag 对象」
- .git/refs/remotes
- .git/refs/remotes/origin/master
- 远端服务器
- .git/refs/heads(代表分支)
- 一个 HEAD
- 专门的 HEAD file,用以存储当前头指针或者某次commit的hash值
- 已有的头指针存在 .git/refs/heads/ 下
- .git/refs/heads/master
- .git/refs/heads/develop
- 或者某一个具体的 commit 对象的 hash 值
- 头指针指向某一个 commit 对象,代表 git 版本控制系统当前的入口
- 四个区域
- working directory
- untracked(不受 git reset –hard 影响)
- unstaged
- staged snapshot
- staged
- stash
- a buffer area which can store our uncommited modifications(but already tracked)
- git stash
- git stash pop
- git stash list
- commit history
- commited
- working directory
- 主动压缩
- git gc
尝试使用自己的语言简单描述一番。首先,最核心之处在于,Git 本质上不是版本控制系统,它是一个键值型文件存储系统。对于存储而言,只使用两种文件对象:tree 和 blob。tree 的作用是我们熟悉的文件夹,blob 的作用是我们熟悉的文件,所有类型的文件在 git 存储体系下都是 blob 对象。
在此基础之上,git 的版本控制通过一次次的文件对象快照来实现。为了实现版本控制,又额外多了两种类型的对象:commit 和 tag。其中最核心也最频繁使用的是 commit 对象。每一次 commit 都会新生成一个 commit 对象,此 commit 对象指向当前的文件对象快照。虽然 commit 前后文件不变的话,两次快照是共用一个文件对象,但每一次文件快照都是完备的,相互之间都是独立的。
是的,git 的版本控制是通过文件快照实现。聪明的你,或许开始质疑其存储空间的利用率。如果一个文件特别大,我只改变一点点时,git 的快照系统便会保存两个内容相近的大文件。这岂不是特别浪费且低效?
不用担心,git 有自己的解决办法:delta 压缩。这和版本控制本身无关,所以此处可以略过不讲。你只需知道,git 的存储一点也不浪费,一点也不低效就可以了。
还是回到版本控制系统,git 使用一次又一次的 commit 生成一个个相互间独立的文件对象快照,这一句话便是其核心所在。
接下来,就是如何通过一些辅助性手段来让这个版本控制系统更加灵活且易用。
首先,在这样一个 commit 对象和文件快照构成的版本控制网中,到底哪一个 commit 才是这个网络的入口呢?
答案是:任何一个都可以。
因此,我们需要一个单独的文件来存储这个入口,这个文件的名字就是 HEAD,具体来说是 .git/HEAD。
我们新增 commit 时会通过这个 HEAD 确定相应的挂靠点。
所以,HEAD 会关联到一个具体的 commit 上。
此处,该到分支出场了。什么是分支?分支在 git 里面非常简单:仅仅只是一个指向某个 commit 对象的指针。
因为大部分情况下,我们都是在某个分支的头部开展工作,这些个分支也就被放在了 .git/refs/heads/ 下。默认情况下,git 会建立一个 master 默认分支,因此会有一个 .git/refs/heads/master 引用文件存在。当我们新建一个develop分支时,git 会新建一个 .git/refs/heads/develop 引用文件。这些引用文件指向自己所在分支的头部 commit 对象。
而之前提到的 HEAD 在大部分情况下会指向某一个分支引用。因此,当我们切换分支时,其实只是把 HEAD 关联到相应的分支引用上。
到这里,整个 git 版本控制系统的大部分主要功能所需的数据结构均已到位。剩下的事情不过是具体的实施细节而已。
比如 checkout 命令,当作用到 commit 对象时,只是操作一下 HEAD 指针的指向。
比如 reset 命令,只是操作一下相应分支引用所指向的 commit 对象,并根据具体参数做一些额外处理工作而已。
比如 commit 命令,不过是在当前分支尝试新建一个 同时指向两个分支头部 commit 对象的 commit 提交而已。
比如 rebase 命令,不过是将某些历史提交(也就是一些文件快照)拎出来,重新接续到目标分支的头部 commit 对象后面,并允许我们在这个过程中重新整理、编辑这些历史提交而已。
至于 cherry-pick,那就不用我说了吧?
除了这些之外,再稍微理解一下工作区,暂存区,stash 缓存区和 commit 历史这几个概念,便能清楚的明白所谓 add、commit、stash、stash pop、reset HEAD、checkout file等等命令的作用和原理。
有了这些理解,对 git 才算是真正入门。
最后,欢迎来到强大无比又异常灵活的 Git 世界。
不知是该恭喜,还是该怎样,总之阅读到该文的,你是第 人。每一次刷新,都是不同的自己。