git原理浅析

git 历史

linux 之父 Linus Torvalds 大家应该都知道,而 git 也是由 Linus 开发的。从 1991 年发布了第一版的 linux 内核,Linux 内核开源项目有着众多的参与者,但绝大多数的 Linux 内核维护工作都花在了提交补丁和保存归档的繁琐事务上(19912002年间)。到 2002 年,整个项目组开始启用一个专有的分布式版本控制系统 BitKeeper 来管理和维护代码,之前市面上也有其他的版本管理系统,比如 CSVSVN,但是 Linus 觉得它们很蠢,直到有了 BitKeeper 才开始使用版本管理系统。

至于为什么又自己开发了 git ,看完下边对 Linus 的采访就明白了。

你为什么要开发 Git?

Torvalds:我从来没有想过去做版本控制软件,因为在我看来那是计算机世界里最无聊的事了(如果数据库除外的话 ;^),我天生就不喜欢 source-control management (SCM)。但是 BitKeeper(BK) 的诞生改变了我对版本控制的认识。BK 在大多数方面是正确的,在本地保存一个仓库的副本,分布式合并确实是一大创新。这个分布式版本控制的创新完美地解决了 SCM 的通病:“谁可以修改代码”的难题。BK 告诉我们,你只要给每个人一个仓库,问题就解决了。但是 BK 也存在一些问题,技术上的问题(例如重命名很麻烦)还不算什么,它最大的坏处是不开源,很多人因为这个不使用它。所以即使我们有几个核心维护者使用 BK——开源项目可以免费使用——但它也没有普及。虽然它帮助过我们开发内核,但依然有不少痛点没有解决。

当 Tridge 违反 BK 的使用协议反编译 BK 的时候,我们到达了紧急关头。我花了几个周(还是几个月来着?)试图调解 Tridge 和 Larry McVoy(注:他是 Bitkeeper 的 老大),最后也没有成功。我意识到我不能继续使用 BK 了,但我真的不想回到没有 BK 的黑暗时代。遗憾的是,我们想用其他 SCM 来代替它,却没有找到能在远程方面工作得好的。现有的软件不能满足我对远程方面的需求,我又担心整个流程和代码的完整性,所以最后我决定自己写一个。

总结就是,本来 BK 免费给他们用,但是有 linux 内核有成员开始反编译 BKBK 就不让他们用了,然后 Linus 就用了几周的时间自己写了一个,git 就此诞生。。。然后 linus 就专心又去搞 linux 了,把 git 交给团队成员 Junio Hamano 进行后期的迭代维护。

git 原理浅析

首先在一个空文件夹中执行 git init 命令初始化 git 仓库,然后会自动生成一个隐藏文件夹 .git ,目录树如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
.git
├── HEAD
├── config
├── description
├── hooks
│   ├── applypatch-msg.sample
│   ├── commit-msg.sample
│   ├── fsmonitor-watchman.sample
│   ├── post-update.sample
│   ├── pre-applypatch.sample
│   ├── pre-commit.sample
│   ├── pre-merge-commit.sample
│   ├── pre-push.sample
│   ├── pre-rebase.sample
│   ├── pre-receive.sample
│   ├── prepare-commit-msg.sample
│   └── update.sample
├── info
│   └── exclude
├── objects
│   ├── info
│   └── pack
└── refs
├── heads
└── tags

下边依次分析下上边的文件。

description 文件

description 文件仅供 GitWeb 程序使用,一般用不到。

info文件夹

info 目录包含一个全局性排除(global exclude)文件, 用以放置那些不希望被记录在 .gitignore 文件中的忽略模式(ignored patterns),和 .gitignore文件是 一个作用。

config 文件

默认的配置文件,打开后显示的是下边的内容。

1
2
3
4
5
6
7
[core]
repositoryformatversion = 0
filemode = true
bare = false
logallrefupdates = true
ignorecase = true
precomposeunicode = true

主要是当前仓库的一些配置,git 除了在这里有配置文件,还存在于 ~/.gitconfig ,打开后看一下。

1
2
3
4
5
6
7
[http]
proxy = socks5://127.0.0.1:1080
[https]
proxy = socks5://127.0.0.1:1080
[user]
name = windliang
email = 6489178757@qq.com

/etc/gitconfig 也是 git 的一个配置文件,但由于没有配置过这个文件,所以我电脑里这个文件不存在。

git 为我们提供了 config 命令用来配置上边的文件。

git config --list 是展示配置文件中已有的配置项,输出如下

1
2
3
4
5
6
7
8
9
10
http.proxy=socks5://127.0.0.1:1080
https.proxy=socks5://127.0.0.1:1080
user.name=windliang
user.email=6489178757@qq.com
core.repositoryformatversion=0
core.filemode=true
core.bare=false
core.logallrefupdates=true
core.ignorecase=true
core.precomposeunicode=true

可以看到就是把之前两个配置文件的内容按一定的格式输出。

上边讲到配置文件分布在三个文件中,git 为我们提供了三个参数 --local--global--system ,分别处理 git 当前仓库下的 config 文件、 ~/.gitconfig 、以及/etc/gitconfig,如果存在同名的配置项,当前仓库下的配置文件优先级最高,其次是~/.gitconfig/etc/gitconfig 优先级最低。

举几个例子。

比如我们只想查看当前仓库下配置文件的配置项,可以执行 git config --local --list ,输出如下。

1
2
3
4
5
6
core.repositoryformatversion=0
core.filemode=true
core.bare=false
core.logallrefupdates=true
core.ignorecase=true
core.precomposeunicode=true

~/.gitconfig 中增加一个配置项,git config --global alias.ss status,执行后再打开 ~/.gitconfig ,可以看到就增加了一个配置项。

1
2
3
4
5
6
7
8
9
[http]
proxy = socks5://127.0.0.1:1080
[https]
proxy = socks5://127.0.0.1:1080
[user]
name = windliang
email = 6489178757@qq.com
[alias]
ss = status

通过上边 alias 的配置,下次如果我们想执行 git status 只需要输入 git ss 就可以了,也就是别名。

如果想删除某个配置项,可以添加 --unset 参数,比如执行 git config --global --unset alias.ss

也可以单独查看某个配置项,例如输入 git config --global user.name,输出如下

1
windliang

底层命令和上层命令

我们经常使用的命令其实是上层命令(porcelain commands),参考下边的表格。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
git-add                 git-rebase              git-cherry
git-am git-reset git-count-objects
git-archive git-revert git-difftool
git-bisect git-rm git-fsck
git-branch git-shortlog git-get-tar-commit-id
git-bundle git-show git-help
git-checkout git-stash git-instaweb
git-cherry-pick git-status git-merge-tree
git-citool git-submodule git-rerere
git-clean git-tag git-rev-parse
git-clone git-worktree git-show-branch
git-commit gitk git-verify-commit
git-describe git-config git-verify-tag
git-diff git-fast-export git-whatchanged
git-fetch git-fast-import gitweb
git-format-patch git-filter-branch git-archimport
git-gc git-mergetool git-cvsexportcommit
git-grep git-pack-refs git-cvsimport
git-gui git-prune git-cvsserver
git-init git-reflog git-imap-send
git-log git-relink git-p4
git-merge git-remote git-quiltimport
git-mv git-repack git-request-pull
git-notes git-replace git-send-email
git-pull git-annotate git-svn
git-push git-blame

其实还有我们没有用过的底层命令(plumbing commands),多数底层命令并不面向最终用户,它们更适合作为新工具的组件和自定义脚本的组成部分。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
git-apply               git-for-each-ref        git-receive-pack
git-checkout-index git-ls-files git-shell
git-commit-tree git-ls-remote git-upload-archive
git-hash-object git-ls-tree git-upload-pack
git-index-pack git-merge-base git-check-attr
git-merge-file git-name-rev git-check-ignore
git-merge-index git-pack-redundant git-check-mailmap
git-mktag git-rev-list git-check-ref-format
git-mktree git-show-index git-column
git-pack-objects git-show-ref git-credential
git-prune-packed git-unpack-file git-credential-cache
git-read-tree git-var git-credential-store
git-symbolic-ref git-verify-pack git-fmt-merge-msg
git-unpack-objects git-daemon git-interpret-trailers
git-update-index git-fetch-pack git-mailinfo
git-update-ref git-http-backend git-mailsplit
git-write-tree git-send-pack git-merge-one-file
git-cat-file git-update-server-info git-patch-id
git-diff-files git-http-fetch git-sh-i18n
git-diff-index git-http-push git-sh-setup
git-diff-tree git-parse-remote git-stripspace

下边用底层命令来进行 git 的相关操作,以便对 git 原理有个更深的了解。

objects 文件夹

这个文件夹顾名思义,就是存储对象的。git 主要有三种对象,blob 对象,tree 对象,commit 对象。和文件有关的东西都会存到这个文件夹中,相当于一个键值对的数据库。

blob 对象

首先新建一个 test.txt,写入 hello worldecho 'hello world' > test.txt

然后执行 git hash-object -w test.txt 命令,得到

1
3b18e512dba79e4c8300dd08aeb37f8e728b8dad

hash-object 命令会返回生成对象的键值,-w 会把该对象写入数据库,也就是 objects 文件夹中。

键值其实就是【头部信息】加上【文件原始内容】做了 SHA-1 得到的 40 位的哈希值,其中「头部信息」指的是 对象类型+空格+数据的字节数+空字节

我们来看一下 objects 文件夹的变化。

1
2
3
4
5
objects
├── 3b
│   └── 18e512dba79e4c8300dd08aeb37f8e728b8dad
├── info
└── pack

可以看到多了一个文件夹 3b 和一个文件 18e512dba79e4c8300dd08aeb37f8e728b8dadf ,组合起来刚好就是我们得到的键值。

通过指令 git cat-file -p 3b18e512d 看一下该文件的内容。

1
hello world

cat-file 可以解码刚刚生成的对象,-p 参数会自动选择内容的编码,3b18e512d 是键值的前几位。

然后我们修改一下文件的内容,echo 'hello world 2' > test.txt,再次执行 git hash-object -w test.txt

再看一下 objects 文件夹。

1
2
3
4
5
6
7
objects
├── 3b
│   └── 18e512dba79e4c8300dd08aeb37f8e728b8dad
├── d0
│   └── e1e95455754bd31d56260d19a7774fd7aebe5d
├── info
└── pack

可以看到我们又多了一个对象,此时我们把本地的 test.txt 文件删除,rm test.txt

然后看一下之前写的内容还在不在,git cat-file -p d0e1e954557

1
hello world 2

可以看到还是能取到之前的内容,git 把之前所有的内容都存了起来,这就是简单的版本管理。但有个问题就是,当前我们只存了键值,并没有存文件名,这种类型的对象我们叫做「数据对象」blob object,通过 git cat-file -t 命令加上 SHA-1 的键值前几位,就能查看该对象的内部类型。git cat-file -t d0e1e954557

1
blob

tree 对象

tree 对象记录了文件名以及文件之间的关系,相当于就是文件夹的作用,可以理解为下边的图。

下边演示如何用底层命令生成一个 tree 对象。

生成 tree 对象之前,我们需要将文件加入到暂存区。

新建一个空项目,然后 git init,新建文件 test.txt

执行 git update-index --add test.txt,这个命令会生成相应的对象存入 objects 文件夹中,并将 test.txt 加入暂存区,

可以执行 git status 看一下。

1
2
3
4
5
6
7
On branch master

No commits yet

Changes to be committed:
(use "git rm --cached <file>..." to unstage)
new file: test.txt

同时可以看到 objects 文件夹中多了一个文件。

1
2
3
4
5
objects
├── 95
│   └── d09f2b10159347eece71399a7e2e907ea3df4f
├── info
└── pack

暂存区有了文件以后,就可以生成一个 tree 对象了,执行 git write-tree,生成当前暂存区的一个快照,返回值如下:

1
f03546f10f086a5cbc7b8580632ca6db2ba9411d

会返回 tree 对象的 key 值,看一下 objects 文件夹,新生成了一个文件。

1
2
3
4
5
6
7
objects
├── 95
│   └── d09f2b10159347eece71399a7e2e907ea3df4f
├── f0
│   └── 3546f10f086a5cbc7b8580632ca6db2ba9411d
├── info
└── pack

然后我们通过 git cat-file -p f0354 看一下 tree 对象的内容。

1
100644 blob 95d09f2b10159347eece71399a7e2e907ea3df4f	test.txt

可以看到当前 tree 对象包含有一个 blob 对象,文件名是 test.txt100644 表示普通文件,还包括:100755,表示一个可执行文件;120000,表示一个符号链接。

有了 tree 对象我们就有了文件名,以及文件之间的关系,但是更改文件后我们可能还需要一些注释,如果是多人合作,还需要指明是谁生成的快照。因此我们需要将当前 tree 对象再包装一层,生成 commit 对象。

commit 对象

执行 echo 'first commit' | git commit-tree f0354 将之前的 tree 对象包装成一个 commit 对象。f0354 是之前 tree 对象的 key,返回值如下:

1
84340eaeccbc0854bdec82f6b07f05eb01bd4dcd

同样的给我们返回了 commit 对象的 key,同时看一下 objects 文件夹,也会多一个文件。

1
2
3
4
5
6
7
8
9
objects
├── 84
│   └── 340eaeccbc0854bdec82f6b07f05eb01bd4dcd
├── 95
│   └── d09f2b10159347eece71399a7e2e907ea3df4f
├── f0
│   └── 3546f10f086a5cbc7b8580632ca6db2ba9411d
├── info
└── pack

我们看一下这个 commit 对象的内容,git cat-file -p 8434

1
2
3
4
5
tree f03546f10f086a5cbc7b8580632ca6db2ba9411d
author windliang <6489178757@qq.com> 1594200559 +0800
committer windliang <6489178757@qq.com> 1594200559 +0800

first commit

此外 git commit-tree 还有一个 -p 参数,用来指定当前 commit 对象的父 commit 对象。

比如我们修改一下文件,再生成新的 tree 对象,依次执行下边的命令。

1
2
3
echo 'hello world2' > test2.txt 
git update-index --add test2.txt
git write-tree

此时就得到了一个 tree 对象。

1
ab00cb505b3f955ab5fb245b7ca155a5820d2cd4

接下再生成新的 commit 对象,并且指定父 commit 对象,echo 'second commit' | git commit-tree ab00 -p 8434

1
44d318147e6b7bf2c3a5268018390440b2beae56

然后我们通过这个 commit 对象的 key 查看一下 loggit log 44d3

1
2
3
4
5
6
7
8
9
10
11
commit 44d318147e6b7bf2c3a5268018390440b2beae56
Author: windliang <6489178757@qq.com>
Date: Wed Jul 8 17:47:31 2020 +0800

second commit

commit 84340eaeccbc0854bdec82f6b07f05eb01bd4dcd
Author: windliang <6489178757@qq.com>
Date: Wed Jul 8 17:29:19 2020 +0800

first commit

因为设置了父 commit 对象,所以第一次的提交也可以看到。

refs 文件夹,HEAD 文件

heads

我们刚刚执行 git log 命令的时候,写的是 git log 44d3,多加了 commit 对象的 key44d3,写起来很麻烦,我们可以给它起一个别名,这个别名就是我们一直用的分支了。

我们将 commit 对象的 key 值写入 .git/refs/heads/master 文件中

1
echo 44d318147e6b7bf2c3a5268018390440b2beae56 > .git/refs/heads/master

然后执行 git log master 就可以看到 log 了。

1
2
3
4
5
6
7
8
9
10
11
commit 44d318147e6b7bf2c3a5268018390440b2beae56 (HEAD -> master)
Author: windliang <6489178757@qq.com>
Date: Wed Jul 8 17:47:31 2020 +0800

second commit

commit 84340eaeccbc0854bdec82f6b07f05eb01bd4dcd
Author: windliang <6489178757@qq.com>
Date: Wed Jul 8 17:29:19 2020 +0800

first commit

可以省略 master ,直接执行 git log ,默认查询的就是当前分支的 log

我们也可以给另外一个 commit 对象创建一个别名,换句话说创建一个新的分支。

1
echo 84340eaeccbc0854bdec82f6b07f05eb01bd4dcd > .git/refs/heads/dev

然后执行 git checkout dev 会发现可以成功的切换分支,说明分支创建成功了。

这里我们直接操控了文件,git 其实给我提供了一个命令,会更加安全

git update-ref refs/heads/dev 84340eaeccbc0854bdec82f6b07f05eb01bd4dcd

回想一下我们之前创建分支的命令,会执行 git branch fix ,注意到我们并没有指定 commit 对象的 key 值,为什么可以成功创建分支呢?

HEAD 文件!它里边始终保存着最新的 commit 对象的 key 值,当有新的 commit 的时候它会更新,当切换分支的时候它也会更新。

打开 HEAD 文件可以看一下。

1
ref: refs/heads/master

他保存了一个引用,refs/heads/master 文件保存的就是当前分支最新的 commit 对象的 key 值。

如果我们切换分支,git checkout dev ,可以看到 HEAD 中的值也会相应的变化。

1
ref: refs/heads/dev

tags

上边介绍了 blob 对象,tree 对象,commit 对象。commit 对象是包装了 tree 对象,还有个 tag 对象,通常是对 commit 对象的包装。

标签的话主要分为附注标签和轻量标签。 可以像创建分支那样创建一个轻量标签:

git update-ref refs/tags/v1.0 44d318147e6b7bf2c3a5268018390440b2beae56

轻量标签的话相当于就是对 commit 的一个引用,没有创建新的对象。

我们再来创建一个附录对象,可以添加一些注释。

git tag -a v1.1 84340eaeccbc0854bdec82f6b07f05eb01bd4dcd -m 'test tag'

看一下新创建对象的 key 值,cat .git/refs/tags/v1.1

1
b518af197d2a925b668043edf1af88b82664e19f

然后查看一下该对象,git cat-file -p b518

1
2
3
4
5
6
object 84340eaeccbc0854bdec82f6b07f05eb01bd4dcd
type commit
tag v1.1
tagger windliang <6489178757@qq.com> 1594208408 +0800

test tag

我们顺便看一下这个对象的类型,git cat-file -t b518

1
tag

另外要注意的是,标签对象并非必须指向某个 commit 对象,它可以对任意类型的 git 对象打标签。

remotes

如果有远程仓库,并对其执行过推送操作,git 会记录下最近一次推送操作时的分支,并保存在 refs/remotes 目录下。

1
2
3
4
5
6
7
8
9
10
11
$ git remote add origin git@github.com:wind-liang/test.git
$ git push -u origin master
Enumerating objects: 6, done.
Counting objects: 100% (6/6), done.
Delta compression using up to 12 threads
Compressing objects: 100% (3/3), done.
Writing objects: 100% (6/6), 467 bytes | 467.00 KiB/s, done.
Total 6 (delta 0), reused 0 (delta 0)
To github.com:wind-liang/test.git
* [new branch] master -> master
Branch 'master' set up to track remote branch 'master' from 'origin'.

然后查看 remotes 里的文件,cat .git/refs/remotes/origin/master

1
44d318147e6b7bf2c3a5268018390440b2beae56

这个值就是最新的 commit 对象的 key 值。远程引用和分支(位于 refs/heads 目录下的引用)之间最主要的区别在于,远程引用是只读的。 虽然可以 git checkout 到某个远程引用,但是 Git 并不会将 HEAD 引用指向该远程引用。

objects/pack

前边我们讲了每一个文件都作为一个对象存到 objects 目录下,如果只修改了文件的某一行,然后进行提交,依旧会新生成一个 object。如果 git 只保存其中一个,再保存另一个对象与之前版本的差异内容,不是能省些空间吗?

事实上 Git 可以那样做。 但 Git 最初向磁盘中存储对象时所使用的格式被称为「松散(loose)」对象格式。 Git 会时不时地将多个这些对象打包成一个称为“包文件(packfile)”的二进制文件,以节省空间和提高效率。 当版本库中有太多的松散对象,或者手动执行 git gc 命令,或者你向远程服务器执行推送时,Git 都会这样做。

Git 打包对象时,会查找命名及大小相近的文件,并只保存文件不同版本之间的差异内容。可以找一个项目看一下 pack 下的目录。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
pack
├── pack-0efa08fc30b8d98dff42203c71c6afe0533ce468.idx
├── pack-0efa08fc30b8d98dff42203c71c6afe0533ce468.pack
├── pack-19571818da01df976f52298facf362dc93d61026.idx
├── pack-19571818da01df976f52298facf362dc93d61026.pack
├── pack-725ab685133ed6e35083c5b3dcaf02ebc238489c.idx
├── pack-725ab685133ed6e35083c5b3dcaf02ebc238489c.pack
├── pack-7c01d29f0cb068c617aa49471cbf9f6eb1cb2156.idx
├── pack-7c01d29f0cb068c617aa49471cbf9f6eb1cb2156.pack
├── pack-a59c4fce103fb83cec0f513ab32cd92e6122e7a4.idx
├── pack-a59c4fce103fb83cec0f513ab32cd92e6122e7a4.pack
├── pack-db0d185ea0e7d96bbad911bb371c67869d8599b0.idx
├── pack-db0d185ea0e7d96bbad911bb371c67869d8599b0.pack
├── pack-f07ea07e30bb0aa4dfbb1fcb08da4cd5e5e5f793.idx
└── pack-f07ea07e30bb0aa4dfbb1fcb08da4cd5e5e5f793.pack

可以是两种类型,一种是打包文件,另一种就是索引文件,用来记录不同版本之间的差异。更详细的可以看一下 Git 内部原理 - 包文件

packed-refs文件

执行 gc 以后,会将 refs 文件夹中的引用打包到这个文件中。

index

当我们执行了 git add 或者上边讲到的 git update-index --add 命令,我们就会发现 .git 目录下增加了一个 index 文件。这个文件存储的东西就是我们常说的「暂存区」。它主要存储了每个文件的索引,也就是在 objects 目录下生成的对象的 SHA-1 哈希值。还有生成 tree 对象的一些信息,比如文件名以及文件之间的关系,为下一步生成 tree 对象做准备。

hooks 文件夹

这里主要是 git 为我们提供了一些钩子函数,把下边的 .sample 去掉,当前钩子就会生效。可以编辑各个钩子文件,就可以在执行 pushcommit 等操作时完成一些自己想要的一些动作。

1
2
3
4
5
6
7
8
9
10
11
12
13
hooks
├── applypatch-msg.sample
├── commit-msg.sample
├── fsmonitor-watchman.sample
├── post-update.sample
├── pre-applypatch.sample
├── pre-commit.sample
├── pre-merge-commit.sample
├── pre-push.sample
├── pre-rebase.sample
├── pre-receive.sample
├── prepare-commit-msg.sample
└── update.sample

通过钩子,可以实现提交代码前自动格式化代码、规范化 commit-msg 等功能,还可以做到当远程仓库 github 更新后,让服务器端自动拉取最新项目,实现一些 web 项目的自动更新。

COMMIT_EDITMSG

存储最后一次提交的信息内容。git commit 命令之后打开的编辑器就是在编辑此文件,退出编辑器保存后,git 会把此文件内容写入 commit 记录。一般直接在 commit 命令后添加 -m 选项,附加提交信息。

ORIG_HEAD 文件

相当于 HEAD 文件的一个备份,会指向 HEAD 之前的一个 commit 对象。当执行一些危险的操作,比如 git rebase 等,需要先记录 ORIG_HEAD 再执行其他的操作。

FETCH_HEAD 文件

FETCH_HEAD 记录了 fetch 时候远程分支的 key 值,也就是 commit 对象的 SHA-1 哈希值。当执行 git pull 的时候相当于先执行 git fetch ,然后执行 git merge FETCH_HEAD ,也就是和拉取下来的远程分支合并。

打开 FETCH_HEAD 文件,第一行就是 FETCH_HEAD 的值,用于 merge,其它行是同时拉取下来的分支。

1
2
3
4
d6a81fdb23503d5e85cb8f74ea77cd4ab20e0659		branch 'master' of ssh://git.github.com/ed-f2e/test
5ce98b8e6f832382417c5a1ef55f1f1ca303f86d not-for-merge branch 'foodsafetab-20200701' of ssh://git.github.com/ed-f2e/test
d46bec5fea5af996d75497b05592802ef31fe63b not-for-merge branch 'overview-20200601' of ssh://git.github.com/ed-f2e/test
005ef114aa680401681f582b8f71dfe020417989 not-for-merge branch 'visual-20200622' of ssh://git.github.com/ed-f2e/test

关键字 not-for-merge,表明 git pull 时只 fetch,不 merge

logs 文件夹

记录了操作信息,git reflog 命令以及像 HEAD@{1} 形式的路径会用到。如果删除此文件夹,那么依赖于 reflog 的命令就会报错。

文件夹总

基本上把 .git 目录总结完了,下边汇总一下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
.
├── COMMIT_EDITMSG // git commit 时候编辑的文件
├── FETCH_HEAD // git fetch 保存从远程仓库抓取下来的 commit 对象的键值
├── HEAD // 保存当前 commit 对象的键值
├── ORIG_HEAD // 执行危险操作时 HEAD 的备份
├── config // 当前 git 仓库的相关配置,优先级最高
├── description // 仅供 GitWeb 程序使用
├── hooks // 保存 git 的所有钩子
│   ├── applypatch-msg.sample
│   ├── commit-msg
│   ├── commit-msg.git-flow
│   ├── commit-msg.sample
│   ├── fsmonitor-watchman.sample
│   ├── post-update.sample
│   ├── pre-applypatch.sample
│   ├── pre-commit
│   ├── pre-commit.git-flow
│   ├── pre-commit.mt-eslint-check
│   ├── pre-commit.sample
│   ├── pre-merge-commit.sample
│   ├── pre-push
│   ├── pre-push.git-flow
│   ├── pre-push.sample
│   ├── pre-rebase.sample
│   ├── pre-receive.sample
│   ├── prepare-commit-msg.sample
│   └── update.sample
├── index // 暂存区,保存对象的索引和 tree 对象的相关信息
├── info
│   └── exclude // 和 .gitignore 一样的作用
├── logs // 记录历史的一些操作,git reflog 命令依赖于此目录
├── objects // git 的数据库,存放所有对象
│   ├── 0f
│   │   ├── 01ae962d3a527bfd692175ee5600501eef43fb
│   │   ├── 5ef114aa680401681f582b8f71dfe020417989
│   │   ├── 71685e674a8190ba2f94391cae5f99ea4854fb
│   │   └── cef7d6ec44ae5ffabf2514bd19ccb1ec303eb4
│   ├── 34
│   │   ├── 621a5fb9cdb057a09c559586702a4d20388c71
│   │   └── 6e1d53b34578f0bfcef11dd2d59dc25072e79e
│   ├── 55
│   │   ├── 137e5e9f4e37218033af62bc9fcc2fded5545b
│   │   ├── c0d6f9e2d8df065ccb512d45cbbd61d327e7fd
│   │   └── eeddf196be55abc4292f562b8bd4fb19bbb2d4
│   ├── info
│   └── pack
│   ├── pack-679830bc13d16192a07ddaa0f51f49b0163b7578.idx
│   └── pack-679830bc13d16192a07ddaa0f51f49b0163b7578.pack
├── packed-refs // 执行 gc 以后,将 refs 中的引用进行打包
└── refs // 对 commit 对象的引用
├── heads //所有分支
│   ├── master
├── remotes // 远程分支所对应的 key 值
│   └── origin
│   ├── HEAD
│   └── master
└── tags // 所有标签

对象总结

主要包括 blob 对象,tree 对象,commit 对象,还有 tag 对象。通过 commit 对象以链表的形式连接在了一起。

可以看到第一次 commit 的时候,创建了 README.mdindex.htmljs 文件夹以及 index.js

第二次 commit 的时候,仅仅修改了 index.html,其他文件仍旧指向原来的对象。并且用当前 commit 包装了一个 tag 对象。

第三次 commit 的时候,增加了 index.css 文件,其他文件仍旧指向原来的对象。此外当前 commit 对象是当前操作的对象,所以 HEAD 指向当前 commit 对象,另外 mater 分支也指向当前 commit 对象。

换一种眼光看命令

这一节回顾一下 git 经常用的命令和上边介绍的文件的一些关系。为了方便监测每个命令改变了哪些文件,我们在 .git 目录中再执行一次 git init,也就是将 .git 目录看作我们的另外一个项目,操作如下:

新建一个目录,learnGit,在里边新建 index.htmlREADME.mdjs 文件夹,js 文件夹中新建 index.js。目录结构如下:

1
2
3
4
5
.
├── README.md
├── index.html
└── js
└── index.js

然后初始化当前目录为 git 仓库。

1
2
learnGit % git init
Initialized empty Git repository in /Users/learnGit/.git/

此时就会自动生成 .git 目录,进入 .git 目录再执行一次 git initgit add .git commit -m "init",来监测后续 .git 目录的变化情况。然后回到我们的根目录learnGit 中进行下边的实验。

git add .

此时会发现每个文件会生成一个对象,因此 objects 文件夹中多了 3 个文件(如果是 mac 系统会发现多了 4 个文件,原因是系统自动生成了一个 .DS_Store 文件,这里就不考虑了),也就是 3blob 对象。此外,增加了 index 文件,也就是暂存区,会存储每个 commit 对象的索引,以及生成 tree 对象的相关信息。

1
2
3
4
create mode 100644 index
create mode 100644 objects/4d/7a16a7949cf8206f6f910535fd6811d4a5e3d2
create mode 100644 objects/94/a127e7307c6562a2bdbf2d156589572c31963e
create mode 100644 objects/c3/b573586becc940e02cd0914ef2eaf6d1ff7a28

相当于执行了 git hash-object -w 文件名 生成对象,以及 git update-index --add 文件名 命令,将文件加入暂存区。

git commit -m

执行 git commit -m "first",文件变化情况如下。

1
2
3
4
5
6
7
create mode 100644 COMMIT_EDITMSG
create mode 100644 logs/HEAD
create mode 100644 logs/refs/heads/master
create mode 100644 objects/74/900affe800f97c02e9cad8a9b2304e21f0a412 //commit 对象
create mode 100644 objects/bc/4a821da58aa317d1790199b98b1e1b638baebb //tree 对象
create mode 100644 objects/d2/eb8f97312f90fe39586e6deefb6b41b4d8340f //tree 对象
create mode 100644 refs/heads/master

会发现 objects 文件夹中多了三个文件,其中两个是 tree 对象,因为我们的目录有两个文件夹。另一个就是包装了 tree 对象的 commit 对象。

增加了 COMMIT_EDITMSG,也就是 commit 时候写的提交信息,在这里的话里边内容就是 “first”。

logs 目录发生了一些变化,reflog 命令依赖这里的文件。

自动为我们创建了 mater 分支,因此增加了 refs/heads/master 文件,里边的内容就是我们刚刚生成的 commit 对象的 hash 值,也就是 74900affe800f97c02」9cad8a9b2304e21f0a412

git push

我们先执行 git remote add origin git@github.com:wind-liang/learnGit.git 添加一个远程仓库。此时 config 文件多了三行,记录了远程仓库。

1
2
3
[remote "origin"]
url = git@github.com:wind-liang/learnGit.git
fetch = +refs/heads/*:refs/remotes/origin/*

记录了远程仓库的名字 origin,以及 url 地址,还有就是执行 fetch 时候的默认操作,从远程取回所有分支的更新,可以看下一节fetch 命令的介绍。

格式:git push <远程主机名> <本地分支名>:<远程分支名>

我们执行 git push origin master,可以省略远程分支名,默认和本地分支名一致。

1
2
create mode 100644 logs/refs/remotes/origin/master
create mode 100644 refs/remotes/origin/master

logs 就不说了。会发现本地新建了一个远程分支 origin/master,里边内容就是我们刚刚推送的本地 mater 分支指向的 commit 对象的 hash 值,也就是 74900affe800f97c02e9cad8a9b2304e21f0a412

此时我们修改 index.html ,然后执行 git add . 加到暂存区。看一下文件的变化。

1
2
index                                             | Bin 396 -> 377 bytes
objects/07/51aaed4e8f37c1f84eb7780ca08989029ec504 | Bin 0 -> 247 bytes

此时会多一个对象,也就是新一版的 index.html ,以及 index 文件会发生变化。

接着执行 git commit -m "second",会根据暂存区的信息生成当前的树对象以及 commit 对象,objects 文件夹中应该会增加两个对象,一个 tree 对象,一个 commit 对象。

1
2
3
4
5
6
7
COMMIT_EDITMSG                                    |   2 +-
index | Bin 377 -> 396 bytes
logs/HEAD | 1 +
logs/refs/heads/master | 1 +
objects/63/53b5966f6bbf3f6a840b1261030519b67fdf51 | Bin 0 -> 150 bytes
objects/af/f7b5ebae94eba99e5fa0ef245595c21686562b | Bin 0 -> 154 bytes
refs/heads/master | 2 +-

refs/heads/master 也会更新,指向最新的 commit 对象。

如果我们想把当前改变再推送到远程仓库,又需要执行 git push origin master ,有些长。git 为我们提供了 --set-upstream 参数,简写是 -u,可以让本地分支关联都某个远程分支,这样的话如果下次想把当前分支推送到远程,只需要执行 git push 就可以了。

我们执行一下 git push -u origin master ,看一下哪些文件会变化。

1
2
3
config                          | 3 +++
logs/refs/remotes/origin/master | 1 +
refs/remotes/origin/master | 2 +-

看一下 config 文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
[core]
repositoryformatversion = 0
filemode = true
bare = false
logallrefupdates = true
ignorecase = true
precomposeunicode = true
[remote "origin"]
url = git@github.com:wind-liang/learnGit.git
fetch = +refs/heads/*:refs/remotes/origin/*
[branch "master"]
remote = origin
merge = refs/heads/master

可以看到,它记录了本地 mater 分支和远程仓库 origin 中的 refs/heads/mater 关联。除了在 git push 起作用,git fetchgit pull 的默认操作也会依赖这里的配置,可以继续看下边的小节。

此外,这里建立的远程分支必须要和本地分支同名,因为在 Git 2.0 之后 git push 不加任何参数的话,默认模式为 simple,推送当前分支到upstream 分支上,必须保证本地分支与 upstream 分支同名,不然的话 git push 是没有用的。

比如我们将 mater 分支和远程仓库的 dev 分支关联,执行 git branch -u origin/dev,再执行 git push 就会得到下边的提示。

1
2
3
4
5
6
7
8
9
10
11
fatal: The upstream branch of your current branch does not match
the name of your current branch. To push to the upstream branch
on the remote, use

git push origin HEAD:dev

To push to the branch of the same name on the remote, use

git push origin HEAD

To choose either option permanently, see push.default in 'git help config'.

还有其他的模式,nothing, current, upstream, matching,一般就用默认的 simple,这里就不介绍了。

git fetch

为了更详细的看 git fetch 命令的作用。我新建了另一个远程仓库 origin2,关联到了当前本地仓库,并且在远程仓库中添加了 index2.txt

同时在原来 origin 的远程仓库中,在 mater 分支新增了 index.css 文件。增加了 dev 分支,并且修改了 index.html

当前本地仓库的配置文件如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
[core]
repositoryformatversion = 0
filemode = true
bare = false
logallrefupdates = true
ignorecase = true
precomposeunicode = true
[remote "origin"]
url = git@github.com:wind-liang/learnGit.git
fetch = +refs/heads/*:refs/remotes/origin/*
[branch "master"]
remote = origin
merge = refs/heads/master
[remote "origin2"]
url = git@github.com:wind-liang/learnGit2.git
fetch = +refs/heads/*:refs/remotes/origin2/*

git fetch 的命令格式为 git fetch <远程主机名> <分支名>

我们不加参数,只执行 git fetch 看一下效果。

1
2
3
4
5
6
7
8
9
10
11
12
FETCH_HEAD                                        |   2 ++
logs/refs/remotes/origin/dev | 1 +
logs/refs/remotes/origin/master | 1 +
objects/08/f4162aa74066285166b9b46f845ea648941943 | Bin 0 -> 151 bytes
objects/36/619ae122d6e01b7c82838247754173c1b6930a | Bin 0 -> 177 bytes
objects/88/688aebd84396af325f55f82a4bdf0e283a8e4a | Bin 0 -> 251 bytes
objects/b1/be33b8a27e1fcfa2c9f01b0fcb76a28b091071 | 2 ++
objects/be/5678cb94b6da6f94cad10077739a850cd893b5 | Bin 0 -> 38 bytes
objects/ff/383d0247ea2c27dd2d44753dd5b18ca4ecfeaf | Bin 0 -> 177 bytes
refs/remotes/origin/dev | 1 +
refs/remotes/origin/master | 2 +-
11 files changed, 8 insertions(+), 1 deletion(-)

默认抓取了远程仓库 origin 的两个分支。由于远程仓库新增了 index.css 文件,并且修改了 dev 分支中的 index.html ,所以是 2blob 对象,2tree 对象,2commit 对象,所以 objects 文件中增加了 6 个对象。

FETCH_HEAD 记录了两个分支指向的最新 commit 对象的 hash 值。

1
2
b1be33b8a27e1fcfa2c9f01b0fcb76a28b091071		branch 'master' of github.com:wind-liang/learnGit
08f4162aa74066285166b9b46f845ea648941943 not-for-merge branch 'dev' of github.com:wind-liang/learnGit

refs/remotes/origin/devrefs/remotes/origin/master 分别记录了分支所对应的 commit 对象。

git merge

git fetch 仅仅把远程分支拉取了下来,我们还需要通过 git merge 将远程分支的内容和本地内容进行合并。

我们将本地的 mater 分支和远程的 mater 分支进行合并。

首先可以执行 git diff origin/mater 看一下和远程仓库代码的区别。

然后可以执行 git merge origin/master 将刚刚拉下来的远端分支和当前分支合并。

1
2
3
4
5
6
ORIG_HEAD              |   1 +
index | Bin 396 -> 468 bytes
logs/HEAD | 1 +
logs/refs/heads/master | 1 +
refs/heads/master | 2 +-
5 files changed, 4 insertions(+), 1 deletion(-)

可以看到 index 文件进行了更新,也就是更新了暂存区。refs/heads/master 文件进行了更新,也就是将 mater 分支指向了最新的 commit 对象。查看 refs/heads/master 文件中的内容是 b1be33b8a27e1fcfa2c9f01b0fcb76a28b091071 ,和我们刚刚 FETCH_HEAD 中远端 mater 分支的 commit 对象的 hash 值一致。新增的 ORIG_HEAD 文件是 HEAD 的备份。

这种合并方式属于 Fast Forward,合并的时候直接将 mater 分支指向了最新的提交。是因为要合并过来的分支是之前 mater 分出去的,并且分出去之后 mater 分支没有再产生新的 commit 对象,也就是下面的情况。

1
2
3
       ------------ origin/master
/
-----master

这种情况合并的话,直接把 mater 指向 origin/master 即可。

还有另外一种情况,如下图。

1
2
3
       ------------ origin/master
/
-------------master

分出去以后,mater 分支又进行了几次提交,此时我们再执行 git merge origin/master 看一下会是什么情况。

1
2
3
4
5
6
7
Merge remote-tracking branch 'origin/master'
# Please enter a commit message to explain why this merge is necessary,
# especially if it merges an updated upstream into a topic branch.
#
# Lines starting with '#' will be ignored, and an empty message aborts
# the commit.
~

此时会进入一个编辑文件,让我们填写 commit 对象的信息,填写退出后,文件变化如下。

1
2
3
4
5
6
7
ORIG_HEAD                                         |   2 +-
index | Bin 468 -> 468 bytes
logs/HEAD | 1 +
logs/refs/heads/master | 1 +
objects/09/1d34a18801e88da14b609d29ac6d6ee8ea9079 | Bin 0 -> 209 bytes
objects/dd/c0e866456daf833034119e6a797eb63614cb4a | Bin 0 -> 178 bytes
refs/heads/master | 2 +-

相比之前的 Fast Forward 模式,这里我们相当于多进行了一个 commit 操作,增加了 tree 对象和 commit 对象。

而且这个 commit 对象比较特殊,它有两个 parent 对象, 通过命令 git cat-file -p 091d 来看一下。

1
2
3
4
5
6
7
tree ddc0e866456daf833034119e6a797eb63614cb4a
parent b9ba3b492e1fad20acd003ea4dad463a012b357a
parent 5f4cfe0f32a1580924a1b5a2d2673d6e6b13639c
author windliang <6489178757@qq.com> 1595149755 +0800
committer windliang <6489178757@qq.com> 1595149755 +0800

Merge remote-tracking branch 'origin/master'

所以最后合并后的情况相当于下边的样子:

1
2
3
4
       ------------ origin/master
/ \
- master
------------- /

git pull

dev 分支下执行 git pull 命令。

理解了 git fetchgit mergegit pull 就好说了。它相当于先执行 git fetch 拉取下了所有分支,然后再执行 git merge FETCH_HEADFETCH_HEAD 就是当前分支跟踪的远程分支的 commit 对象的 HASH 值。它怎么知道当前分支追踪的远程分支是哪个呢?就是我们之前 git push 设置的。

1
2
3
[branch "dev"]
remote = origin
merge = refs/heads/dev

这样的话,如下所示,FETCH_HEAD 文件第一行存储的是远端 dev 分支的 commit 对象的 HASH 值,做为 FETCH_HEAD 的引用,用于接下来的 git merge FETCH_HEAD 操作。

1
2
cc28c96446ecf074671a0521c917025d5942ef9c		branch 'dev' of github.com:wind-liang/learnGit
0932e6eb2fc387f7eeb94147a98cf16f8a0c27b8 not-for-merge branch 'master' of github.com:wind-liang/learnGit

如果我们执行git branch -u origin/master ,让当前分支 dev 去追踪远程仓库的 master 分支。此时再执行 git fetchFETCH_HEAD 第一行记录的就是远程仓库 mater 分支了。

1
2
a2837cd3f45d3f9ebe7d5d6a3f90ff633938a40b		branch 'master' of github.com:wind-liang/learnGit
79d8411ee2466e7e6f361a18601b81d5b7a98156 not-for-merge branch 'dev' of github.com:wind-liang/learnGit

上边是 git pull/fetch 的默认操作,git pull 的完整格式为 git pull <远程主机名> <远程分支名>:<本地分支名>。如果不指定本地分支名,则默认为当前分支。

如果我们指定了远程的分支,执行git pull origin mater ,就相当于先执行 git fetch origin master,此时就不会拉取所有分支,FETCH_HEAD 就指向这个唯一拉下来的分支了。

主要从两个角度介绍了 git ,一方面介绍了 .git 目录中每个文件的作用以及相关的底层命令,另一方面介绍了常用的一些命令对 .git 目录的影响。花了不少时间总结下来,自己对 git 有了更深的理解,希望对大家也能够有所帮助。

参考链接:

Git维基百科

Git 10 周年访谈:Linus Torvalds 讲述背后故事

10 Years of Git: An Interview with Git Creator Linus Torvalds

Torvalds on Version Control: Git good, SVN terrible!

探秘git隐藏文件夹

图解git原理的几个关键概念

Git 内部原理 - 底层命令与上层命令)

Which are the plumbing and porcelain commands?

Git 是怎样生成 diff 的:Myers 算法

Git 原理入门

.git文件夹探秘,理解git运作机制

Use of index and Racy Git problem

Git index format

Refs and the Reflog

Git中的push和pull的默认行为

windliang wechat