git 原理

数据模型

基本概念

BLOB(binary large object):二进制大对象,是一个可以存储二进制文件的容器。

Untracked:文件刚创建,从未被执行过 git add,在工作区(Working Directory)

Satged:文件被 git add 之后的状态,在暂存区(Stage)

Commited:文件被 git commit 之后的状态,在版本库中(Commit History)

git cat-file:查看 git 对象,能转移二进制文件为可读文件。

git hash-object -w :查看 git 已经存储的数据

.git

当执行完 git init 之后,会在当前目录下生成一个 .git 文件夹。几乎 git 相关的所有内容都在该文件夹内。

.git 文件夹的结构如下:

.git
├── HEAD
├── branches
├── config
├── description
├── hooks
   ├── applypatch-msg.sample
   ├── commit-msg.sample
   ├── post-update.sample
   ├── pre-applypatch.sample
   ├── pre-commit.sample
   ├── pre-push.sample
   ├── pre-rebase.sample
   ├── prepare-commit-msg.sample
   └── update.sample
├── info
   └── exclude
├── objects
   ├── info
   └── pack
└── refs
    ├── heads
    └── tags

git add

比如我新建一个文件 1.txt,内容是 first line,这个时候文件状态是 Untracked,查看 .git 文件夹,发现并没有什么变化。然后执行 git add将这个文件加入暂存区,然后查看 .git 目录,发现有了改变。

.git
├── HEAD
├── branches
├── config
├── description
├── hooks
   ├── applypatch-msg.sample
   ├── commit-msg.sample
   ├── post-update.sample
   ├── pre-applypatch.sample
   ├── pre-commit.sample
   ├── pre-push.sample
   ├── pre-rebase.sample
   ├── prepare-commit-msg.sample
   └── update.sample
├── index
├── info
   └── exclude
├── objects
   ├── 08
      └── fe2720d8e3fe3a5f81fbb289bc4c7a522f13da
   ├── info
   └── pack
└── refs
    ├── heads
    └── tags

多了 index 文件和 object/08/ 目录。

我们知道,git 有三个重要的地方存储文件,一个是我们常见的工作区(Working Directory),一个是暂存区(Staging Directory),最后就是版本目录(Repository),三个区的关系如下

图片来自https://hackernoon.com/understanding-git-index-4821a0765cf

其中 Staging Directory 并不是一个真正的目录,只是一个文件,这个文件就是 .git/index,它描述了在暂存区的内容。这个文件是一个二进制文件,我们无法直接查看(index 文件的具体格式可以参考https://github.com/git/git/blob/master/Documentation/technical/index-format.txt)。

但是我们可以通过命令 git ls-files --state 查看这个文件里的内容。

$ git ls-files --stage
100644 08fe2720d8e3fe3a5f81fbb289bc4c7a522f13da 0       1.txt

这里显示了我们有个文件名是 1.txt。前面还有一串编码 08fe2720d8e3fe3a5f81fbb289bc4c7a522f13da,这个编码其实就对应了之前看到的 .git/objects 下面的一个文件(.git/objects里把08变成文件夹名了)。这个文件里实际上是我们添加的 1.txt 的内容,只是被压缩了,无法直接查看。而这串字符串就是这个文件内容的哈希值。

这样来看,当我们 git add 一个文件,那么在 .git/objects 目录下生成了这个文件的 BLOB 文件。这个 BLOB 文件的内容是被 git add 的文件的内容,文件名是该内容的哈希值。同时在 .git/index 里本质上记录了我们创建文件的文件名和其对应的 BLOB 文件名。

同样我们可以通过 git cat-file -p <blob-hash> 可以查看 blob 文件的真实内容

$ git cat-file -p 08fe2720d8e3fe3a5f81fbb289bc4c7a522f13da
first line

git commit

然后我们提交刚刚的文件

$ git commit -m 'add 1.txt'
[master (root-commit) b407bd8] add 1.txt
 1 file changed, 1 insertion(+)
 create mode 100644 1.txt

在查看 .git 目录

.git
├── COMMIT_EDITMSG
├── HEAD
├── branches
├── config
├── description
├── hooks
   ├── applypatch-msg.sample
   ├── commit-msg.sample
   ├── post-update.sample
   ├── pre-applypatch.sample
   ├── pre-commit.sample
   ├── pre-push.sample
   ├── pre-rebase.sample
   ├── prepare-commit-msg.sample
   └── update.sample
├── index
├── info
   └── exclude
├── logs
   ├── HEAD
   └── refs
       └── heads
           └── master
├── objects
   ├── 08
      └── fe2720d8e3fe3a5f81fbb289bc4c7a522f13da
   ├── 10
      └── 8e9fab48346d6ec09b24c4e7fcd47cc91ac2e5
   ├── b4
      └── 07bd8ddb4d958c658353745d6565ed8b4a335a
   ├── info
   └── pack
└── refs
    ├── heads
       └── master
    └── tags

发现多了 COMMIT_EDITMSGlogs/objects/ 下的几个文件。

我们先来看看 .git/objects/ 目录下多个几个内容分别是什么

前一个表示有个叫 08fe2720d8e3fe3a5f81fbb289bc4c7a522f13da 的 blob 文件。这个文件我们前面分析过,它的内容里是 1.txt 的内容。

后一个记录了提交信息,同时表示有个叫 108e9fab48346d6ec09b24c4e7fcd47cc91ac2e5 的 tree 格式的文件,这个文件名正好是前面那个文件。

所以这一组文件的结构如下:

如果我添加一个 2.txt 再做一次提交。然后所有的 .git/objects 文件结构如下:

最上层带有 commit info 的文件,被称为 commit 对象。

总结一下:

我们每个 commit 对应一个 commit 对象,commit 对象里包含了 commit 信息,以及一个 tree 对象。

接着这个 tree 对象里包含了其他 tree 对象或者 blob 对象(这个例子里只包含了 blob 对象)。

blob 对象则代表了一个文件内容,文件的每次修改都会产生新的 blob 对象。

除了第一个 commit 对象,其余的都有一个 parent 值,记录上一个 commit 对象的哈希值。

blob 对象代表文件,tree 对象可以理解为代表文件夹。

git 记录的最小单位是文件,所以空文件夹无法被记录。

git branch

再看其余几个文件

我们新建一个分支 test,然后做一次提交。然后查看 git log。提交结构如下:

$ cat .git/refs/heads/test
455590db0c6739b1694954fee476040c2ffe3915
$ cat ./git/HEAD
refs/heads/test

如果我们切回到 master 分支,然后执行查看上面的文件

$ cat .git/refs/heads/master
f1d86ed334b8cfe4646258dbe932186999f82bef
$ cat ./git/HEAD
refs/heads/master

这里可以发现,当前所在分支是由 HEAD 决定的,HEAD 指向了 .git/refs/heads/ 目录下的一个文件。而 .git/refs/heads 目录下的文件的名称正是分支名。内容则是一个 commit 对象的哈希值。这个哈希值就是该分支下的最后一次提交。由于除了第一个 commit 对象,每个 commit 对象都有一个 parent 值,便可以根据这最后一次 commit 的信息回溯到整个分支的 commit 内容。

合并

git merge

git merge 命令把两个不同的分支合并起来。比如把 dev 分支合并到 master 分支,如果 master 分支的提交时 dev 分支的祖先节点,那么合并什么也不同做,把 master 的 HEAD 指向 dev 分支的最新提交就行(这个就叫做 fast-forward合并)。

如果不是祖先节点,就会真正的做一次合并。默认把当前提交,另一个提交和他们共同的祖先节点进行一次三方合并。

git cherry-pick

cherry-pick 命令是复制一个提交叫节点并在当前分支做一次完全一样的提交。

git rebase

变基是另一种合并命令。它将另一个分支上的提交历史在本分支的提交记录之后重新提交一遍,是合并之后的提交历史是线性的。

本质上,rebase 是线性化的自动的 cherry-pick。

参考:

https://marklodato.github.io/visual-git-guide/index-zh-cn.html

https://juejin.im/post/5b9238135188255c7c653e01

https://hackernoon.com/https-medium-com-zspajich-understanding-git-data-model-95eb16cc99f5

https://hackernoon.com/understanding-git-branching-2662f5882f9