之前我对Git的印象仅限于一个简单的版本管理工具,VS Code之类的开发工具会将常用的Git命令封装到UI界面上。彼时的我也就只是简单地接触了commit、add、push之类的封装好的操作,当遇到需要版本回滚或者冲突合并的时候,很可能就会傻眼,以删除.git文件夹结局。我在机缘巧合的时候看了The Missing Semester of Your CS Education中Version Control的一课,讲师用结构体的方式描述了Git的数据模型,给我留下了很深印象。正好最近也重翻了Pro Git,以本文记录一下自己对Git数据模型和常用命令的理解。
工作目录、暂存区和仓库
Git中的文件可分为三种状态:
- 已修改(modified):文件已经被修改,但还未提交到暂存区。
- 已暂存(staged):文件已经被修改并提交到暂存区,但还未提交到仓库。
- 已提交(committed):文件已经被提交到仓库。
以此,我们可以将Git的工作流程简单描述为:
仓库即.git
文件夹,其中包含了Git的所有数据,包括版本库、暂存区、分支信息等。我们可以通过git init
命令将一个文件夹初始化为Git仓库,或者通过git clone
命令克隆一个远程仓库到本地。
暂存区即index
,是一个二进制文件,记录了下一次提交的文件列表和文件快照。我们可以通过git add
命令将文件添加到暂存区,或者通过git rm
命令将文件从暂存区移除。
工作目录是项目版本的checkout(从仓库中检出),即我们当前所处的工作目录。我们可以通过git checkout
命令将工作目录切换到某个分支或者某个commit。
在通常的Git工作流程中,我们会修改工作目录中的文件,将之加入暂存区,然后将暂存区中的文件提交到仓库。
Git数据模型
Git对象
Git将整个项目分为两种对象:blob
和tree
。blob
即文件快照,可简单视作连续存储的字节,tree
即文件夹快照,其中可包含新的文件夹或者文件。而一个commit
则是整个项目的快照,因此包含了一个tree
对象,此外,还将包含父commit
、作者、commit message等信息。
此外,Git中还有object
的概念,前面的三种对象其实都是object
。在某种程度上,Git可以被视为一个键值对数据库,其中的键为object
的SHA-1哈希值(这在计算机网络或其他领域被广泛用来进行内容校验),值为object
的内容。
我们用Go语言的结构体来描述上述内容(当然,实际上的Git对象模型要复杂的多,这里只是简单反应对象之间的关系):
var objects map[string]object // key: hash, value: object
func store(object object) {
hash := sha1(object)
objects[hash] = object
}
func load(hash string) object {
return objects[hash]
}
type object interface {
hash string
}
type blob struct {
object
content []byte
}
type tree struct {
object
entries map[string]string // key: filename, value: hash of blob or tree
}
type commit struct {
object
tree string // hash of tree object
parents []string // hash of parent commit
author string
message string
}
可见,object
用hash值来索引(而非直接存储其他的object
),其用法类似于指针。
commit history
我们可以用git log --oneline --decorate --graph --all
可视化一个Git仓库的commit history(当然一些软件也有更漂亮的可视化界面,但命令行无疑更通用),可以看到,所有的commit在事实上组成了一个有向无环图(directed acyclic graph, DAG)。
图中第一列视作DAG的结构,第二列为commit的hash值(前几位),第三列若有括号,则代表括号内的分支(本地或者远程)的HEAD指针指向该commit,第四列为commit message。
让我们更深入的看一下object
之间的关系,我们注意到图中排序算法总结
这个commit
的hash为205c8e1,我们可以用git cat-file -p 205c8e1
查看该commit
的内容:
可见该commit
包含tree、parent、author、committer、commit message等信息,同我们前面提到的结构基本一致。如果我们用类似的命令查看tree
对象,可以看到:
可见,这个commit
包含的tree
对象实际上是整个项目的快照,其中既有嵌套的tree
,也有文件blob
。我们不妨更进一步,查看其中的blob
对象,以package.json
为例:
显而易见我们得到了该blob
对象的内容。
指针与分支机制
移动指针
当我们有了这样一个DAG结构后,我们做的一系列操作实际上就变成了在DAG上移动指针或者修改图结构的过程。Git中的指针指向的是commit,例如上图中,我们看到的HEAD -> working-on-it
即代表本地的working-on-it
分支的HEAD指针指向该commit,而origin/working-on-it
即代表远程的working-on-it
分支的HEAD指针指向该commit。HEAD指针代表的是我们目前所在的commit
。
我们当然也可以直接使用commit
对象的hash值来快速移动,如git checkout 205c8e1
:
此时我们再用git log
查看指针状态,可见:
HEAD指针指向了排序算法总结
这个commit,而working-on-it
分支仍然指向之前的位置。由此可见,HEAD总是代表我们当前所在的位置(默认状态下也会随着文件的修改向前移动,除非和我们一样强行checkout到一个新的commit
),而分支名所代表的指针指向该分支的最新commit。利用hash值来移动指针的好处是,我们可以更加灵活地在DAG上移动,而带有语义的分支指针则方便我们理解where we are。
本地分支
既然分支本质上就是指向DAG结点(即commit
对象)的指针,本地分支之间的切换,其实也就是把HEAD指针指向不同的分支指针而已。例如,我们可以用git checkout -b new-branch
来创建一个新的分支,此时HEAD指针指向了新的分支指针,而新的分支指针指向了HEAD指针之前所指向的commit,即:
分支使得我们可以同时在不同的commit上进行工作,而不会相互影响。例如,我们可以在main
分支上进行一些bug修复,而在working-on-it
分支上进行新功能的开发,而不会相互影响。当我们在working-on-it
分支上完成了新功能的开发后,我们可以将其合并到main
分支上,这样就完成了新功能的开发。
当我们需要将分支合并时,则只需要切换到目标分支(即被合并到的分支),使用git merge
即可。例如,如果要把working-on-it
分支合并到main
分支,则只需要切换到main
分支,然后使用git merge working-on-it
即可。
合并时,Git会自动找到两个分支的最近的共同祖先,然后将两个分支的修改合并到一起,如果没有冲突,则会自动完成合并,否则需要手动解决冲突,然后再次提交。这在DAG上体现为两个树枝被合并到同一个新的子结点。
远程分支
BTW,Pro Git的3.4节介绍了常用的与分支有关的工作流,可以参考。
远程分支实际上也是DAG上的指针而已,只不过它所在的DAG在远程仓库上,由于多人合作等原因,这个DAG和本地DAG并不能时刻保持一致,需要我们通过fetch、pull和push等操作来保持同步。
我们本地保存的实际上是上一次同步时,远程分支在DAG上的位置,而不是远程分支的最新位置。当我们使用git clone
克隆一个远程仓库时,Git会自动创建一个名为origin
的远程服务器,它所指代的是该远程仓库的url,这和用branch名指代hash值异曲同工。而远程分支则是以origin/branch-name
的形式存在的,例如origin/working-on-it
。
当我们使用git fetch
时,Git会自动将远程仓库的最新状态下载到本地,此时origin/working-on-it
指针会指向远程仓库的最新状态,但此时本地的working-on-it
分支仍然指向当前工作的位置。如果我们此时使用git merge origin/working-on-it
,则会将远程仓库的最新状态合并到本地的working-on-it
分支上,此时本地的working-on-it
分支指针会指向远程仓库的最新状态,而origin/working-on-it
指针则不会改变。
比git fetch
更常见的git pull
操作,实际上就是git fetch
和git merge
的组合(有一种语法糖的感觉),即先将远程仓库的最新状态下载到本地,然后再将其合并到本地分支上。git push
与之相反,它会先fetch
、merge
,然后再将本地的最新状态推送到远程仓库,从而修改远程分支的位置。
在执行git push
时,我们往往是将本地分支的进展推送到一个对应的远程分支上,因此我们需要为本地分支设置追踪分支(tracking branch或upstream branch),能实现类似效果的方法有下面几种:
# creare a new branch and set upstream branch
git checkout -b new-branch origin/branch-name
git checkout --track origin/branch-name
# change or set upstream branch of current branch
git branch --set-upstream-to=origin/branch-name
git branch -u origin/branch-name
git rebase
git rebase
也是一种整合分支的手段,它是将一个分支上的修改以补丁的方式应用到目标分支上,类似于Redis的AOF持久化模式,将数据库操作记录下来,恢复数据库状态时,只需要将操作记录重新执行一遍即可。从DAG的角度来看,rebase命令实际上删改了一些节点,在图结构上,就好像是目标分支一路修改过来一样,另一个分支就像是从来没存在过一样。
这样做可以给我们带来更加简洁的commit history,毕竟图结构变得更线性,不再乱糟糟的,但它仍然存在弊端:如果对已经存在于远程仓库上的commit
进行rebase操作,修改了远程的DAG结构,那么远程DAG和其他合作者的本地DAG结构将存在冲突,他们必须重新将自己的工作整合到新的DAG结构上,然后我们再pull他们的成果,这样就会造成很大的麻烦。
借用Pro Git的一句话:
Do not rebase commits that exist outside your repository and that people may have based work on.