YoloKokura

之前我对Git的印象仅限于一个简单的版本管理工具,VS Code之类的开发工具会将常用的Git命令封装到UI界面上。彼时的我也就只是简单地接触了commit、add、push之类的封装好的操作,当遇到需要版本回滚或者冲突合并的时候,很可能就会傻眼,以删除.git文件夹结局。我在机缘巧合的时候看了The Missing Semester of Your CS Education中Version Control的一课,讲师用结构体的方式描述了Git的数据模型,给我留下了很深印象。正好最近也重翻了Pro Git,以本文记录一下自己对Git数据模型和常用命令的理解。

工作目录、暂存区和仓库

Git中的文件可分为三种状态:

  1. 已修改(modified):文件已经被修改,但还未提交到暂存区。
  2. 已暂存(staged):文件已经被修改并提交到暂存区,但还未提交到仓库。
  3. 已提交(committed):文件已经被提交到仓库。

以此,我们可以将Git的工作流程简单描述为:

分区

仓库即.git文件夹,其中包含了Git的所有数据,包括版本库、暂存区、分支信息等。我们可以通过git init命令将一个文件夹初始化为Git仓库,或者通过git clone命令克隆一个远程仓库到本地。

暂存区即index,是一个二进制文件,记录了下一次提交的文件列表和文件快照。我们可以通过git add命令将文件添加到暂存区,或者通过git rm命令将文件从暂存区移除。

工作目录是项目版本的checkout(从仓库中检出),即我们当前所处的工作目录。我们可以通过git checkout命令将工作目录切换到某个分支或者某个commit。

在通常的Git工作流程中,我们会修改工作目录中的文件,将之加入暂存区,然后将暂存区中的文件提交到仓库。

Git数据模型

Git对象

Git将整个项目分为两种对象:blobtreeblob即文件快照,可简单视作连续存储的字节,tree即文件夹快照,其中可包含新的文件夹或者文件。而一个commit则是整个项目的快照,因此包含了一个tree对象,此外,还将包含父commit、作者、commit message等信息。

此外,Git中还有object的概念,前面的三种对象其实都是object。在某种程度上,Git可以被视为一个键值对数据库,其中的键为objectSHA-1哈希值(这在计算机网络或其他领域被广泛用来进行内容校验),值为object的内容。

我们用Go语言的结构体来描述上述内容(当然,实际上的Git对象模型要复杂的多,这里只是简单反应对象之间的关系):

go
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)。

git log

图中第一列视作DAG的结构,第二列为commit的hash值(前几位),第三列若有括号,则代表括号内的分支(本地或者远程)的HEAD指针指向该commit,第四列为commit message。

让我们更深入的看一下object之间的关系,我们注意到图中排序算法总结这个commit的hash为205c8e1,我们可以用git cat-file -p 205c8e1查看该commit的内容:

208c8e1

可见该commit包含tree、parent、author、committer、commit message等信息,同我们前面提到的结构基本一致。如果我们用类似的命令查看tree对象,可以看到:

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 fetchgit merge的组合(有一种语法糖的感觉),即先将远程仓库的最新状态下载到本地,然后再将其合并到本地分支上。git push与之相反,它会先fetchmerge,然后再将本地的最新状态推送到远程仓库,从而修改远程分支的位置。

在执行git push时,我们往往是将本地分支的进展推送到一个对应的远程分支上,因此我们需要为本地分支设置追踪分支(tracking branch或upstream branch),能实现类似效果的方法有下面几种:

shell
# 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.

Tags: