Git

Git基本原理

April 13, 2021

把 Git 看作是一个文件系统,这很重要,事实上它就是一个小型的文件系统。

先创建一个空目录my_repo,进入该目录,接下来的所有操作都在这个目录内,建议按顺序阅读。

git init 做了什么

git init 会在当前目录内创建一个 .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
cd .git
❯ tree .
.
├── branches
├── config
├── description
├── HEAD
├── hooks
│   ├── applypatch-msg.sample
│   ├── commit-msg.sample
│   ├── fsmonitor-watchman.sample
│   ├── post-update.sample
│   ├── pre-applypatch.sample
│   ├── pre-commit.sample
│   ├── pre-merge-commit.sample
│   ├── prepare-commit-msg.sample
│   ├── pre-push.sample
│   ├── pre-rebase.sample
│   ├── pre-receive.sample
│   ├── push-to-checkout.sample
│   └── update.sample
├── info
│   └── exclude
├── objects
│   ├── info
│   └── pack
└── refs
    ├── heads
    └── tags

看看 git status 的输出

1
2
3
4
5
6
7
cd ..
❯ git status
位于分支 master

尚无提交

无文件要提交(创建/拷贝文件并使用 "git add" 建立跟踪)

告诉我们当前位于分支 master,git 是如何知道的,其实是存储在 HEAD 文件中

1
2
cat .git/HEAD
ref: refs/heads/master

再看 git branch,结果是空,因为 .git/refs/heads 下还没有任何东西,这里就是存储分支的地方

git add 做了什么

创建一个文件

1
2
3
4
echo 123 > a.txt
❯ ll
总用量 4
-rw-r--r-- 1 head head 4  4月 13 23:51 a.txt

此时 .git 中的内容没有发生任何变化

执行 git add a.txt 后再查看 .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
❯ git add a.txt
❯ tree .git
.git
├── branches
├── config
├── description
├── HEAD
├── hooks
│   ├── applypatch-msg.sample
│   ├── commit-msg.sample
│   ├── fsmonitor-watchman.sample
│   ├── post-update.sample
│   ├── pre-applypatch.sample
│   ├── pre-commit.sample
│   ├── pre-merge-commit.sample
│   ├── prepare-commit-msg.sample
│   ├── pre-push.sample
│   ├── pre-rebase.sample
│   ├── pre-receive.sample
│   ├── push-to-checkout.sample
│   └── update.sample
├── index
├── info
│   └── exclude
├── objects
│   ├── 19
│   │   └── 0a18037c64c43e6b11489df4bf0b9eb6d2c9bf
│   ├── info
│   └── pack
└── refs
    ├── heads
    └── tags

objects 中有新增的东西,用 git cat-file 看一下

1
2
3
4
❯ git cat-file -t 190a18037c64c43e6b11489df4bf0b9eb6d2c9bf
blob
❯ git cat-file -p 190a18037c64c43e6b11489df4bf0b9eb6d2c9bf
123

可以看到该文件类型是 blob,文件内容是 123,就是 a.txt 的内容。

blob是 git 文件系统 中的一种文件类型,即二进制大对象,用于存储文件的内容 (注意,只保存文件的内容,不保存metadata如文件名、创建时间等信息),文件内容使用 zlib的deflat 压缩算法,文件名采用 sha-1 哈希算法(20个字节,160个bit,40个十六进制),取第1个字节作目录名,剩下19个字节作文件名。所以 objects 下最多会有 256 个目录,这样做是为了提高查找速度。

除此之外,还多了一个 index 文件,也就是常说的 stage area (暂存区),它们是一个意思。

The index is a binary file (generally kept in . git/index ) containing a sorted list of path names, each with permissions and the SHA1 of a blob object; git ls-files can show you the contents of the index. Please note that words index, stage, and cache are the same thing in Git: they are used interchangeably.

可以用 git ls-files 查看

1
2
❯ git ls-files
a.txt

在这里存储了相关的metadata,可以家 --debug 查看

1
2
3
4
5
6
7
❯ git ls-files --debug
a.txt
  ctime: 1618345384:304625740
  mtime: 1618345384:304625740
  dev: 65024 ino: 20849651
  uid: 1000  gid: 1000
  size: 8 flags: 0

查看 git status

1
2
3
4
5
6
7
8
❯ git status
位于分支 master

尚无提交

要提交的变更:
  (使用 "git rm --cached <文件>..." 以取消暂存)
        新文件:   a.txt

试试取消暂存

1
2
3
4
5
6
7
8
9
10
11
12
13
14
❯ git rm --cached a.txt
rm 'a.txt'
❯ 
❯ 
❯ git status
位于分支 master

尚无提交

未跟踪的文件:
  (使用 "git add <文件>..." 以包含要提交的内容)
        a.txt

提交为空,但是存在尚未跟踪的文件(使用 "git add" 建立跟踪)

再次查看 index 文件

1
2
❯ git ls-files

没有内容

所以常说的 git add 把文件添加到暂存区就是这个意思

其他部分没有变化

git commit 做了什么

1
2
3
4
❯ git commit -m "add a.txt"
[master(根提交) bb97804] add a.txt
 1 file changed, 1 insertion(+)
 create mode 100644 a.txt

查看 .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
❯ tree .git
.git
├── branches
├── COMMIT_EDITMSG
├── config
├── description
├── HEAD
├── hooks
│   ├── applypatch-msg.sample
│   ├── commit-msg.sample
│   ├── fsmonitor-watchman.sample
│   ├── post-update.sample
│   ├── pre-applypatch.sample
│   ├── pre-commit.sample
│   ├── pre-merge-commit.sample
│   ├── prepare-commit-msg.sample
│   ├── pre-push.sample
│   ├── pre-rebase.sample
│   ├── pre-receive.sample
│   ├── push-to-checkout.sample
│   └── update.sample
├── index
├── info
│   └── exclude
├── logs
│   ├── HEAD
│   └── refs
│       └── heads
│           └── master
├── objects
│   ├── 19
│   │   └── 0a18037c64c43e6b11489df4bf0b9eb6d2c9bf
│   ├── bb
│   │   └── 978046c355c1fa2875c6a8473cea4a60d55814
│   ├── c4
│   │   └── 903888e91347c58a530c1f1987dfc4d203960a
│   ├── info
│   └── pack
└── refs
    ├── heads
    │   └── master
    └── tags

objects 中多了2个文件,分别看下是什么

1
2
3
4
5
6
7
8
❯ git cat-file -t bb978046c355c1fa2875c6a8473cea4a60d55814
commit
❯ git cat-file -p bb978046c355c1fa2875c6a8473cea4a60d55814
tree c4903888e91347c58a530c1f1987dfc4d203960a
author lxh <452228391@qq.com> 1618338183 +0800
committer lxh <452228391@qq.com> 1618338183 +0800

add a.txt

可以看到该文件类型是 commitcommit 是 git 中另一种文件类型,包含了 author、commit message 等信息。还包含了另一种叫作 tree 的文件类型,也就是多出来的另一个文件

1
2
3
4
❯ git cat-file -t c4903888e91347c58a530c1f1987dfc4d203960a
tree
❯ git cat-file -p c4903888e91347c58a530c1f1987dfc4d203960a
100644 blob 190a18037c64c43e6b11489df4bf0b9eb6d2c9bf    a.txt

tree 即目录树的意思,它可以包含 blobtree ,即可以包含文件和子目录,就和文件系统是一样的。

所以 commit 可以理解为当前时刻的文件系统快照 snapshot,这一点很重要,所以 git 可以回到任一时刻的状态。

除此之外,refs/heads 下也多出来一个文件 master,看下是什么

1
2
cat .git/refs/heads/master
bb978046c355c1fa2875c6a8473cea4a60d55814

所以 master 只是一个存储了 commit 文件名的文件,表示指向当前最新的 commit。所以 master 只是一个别名而已,同理任何分支都是这样,比如 dev 分支。它的作用是 HEAD 会指向它,用来表示当前是在哪个分支上。

1
2
cat .git/HEAD
ref: refs/heads/master

改动文件会发生什么

往 a.txt 中新增内容

1
2
3
4
5
6
7
8
9
10
echo 456 >> a.txt
❯ less a.txt
❯ git status
位于分支 master
尚未暂存以备提交的变更:
  (使用 "git add <文件>..." 更新要提交的内容)
  (使用 "git restore <文件>..." 丢弃工作区的改动)
        修改:     a.txt

修改尚未加入提交(使用 "git add" 和/或 "git commit -a"

执行 add

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
❯ git add a.txt
❯ tree .git
.git
├── branches
├── COMMIT_EDITMSG
├── config
├── description
├── HEAD
├── hooks
│   ├── applypatch-msg.sample
│   ├── commit-msg.sample
│   ├── fsmonitor-watchman.sample
│   ├── post-update.sample
│   ├── pre-applypatch.sample
│   ├── pre-commit.sample
│   ├── pre-merge-commit.sample
│   ├── prepare-commit-msg.sample
│   ├── pre-push.sample
│   ├── pre-rebase.sample
│   ├── pre-receive.sample
│   ├── push-to-checkout.sample
│   └── update.sample
├── index
├── info
│   └── exclude
├── logs
│   ├── HEAD
│   └── refs
│       └── heads
│           └── master
├── objects
│   ├── 19
│   │   └── 0a18037c64c43e6b11489df4bf0b9eb6d2c9bf
│   ├── bb
│   │   └── 978046c355c1fa2875c6a8473cea4a60d55814
│   ├── c4
│   │   └── 903888e91347c58a530c1f1987dfc4d203960a
│   ├── ce
│   │   └── 8c77db7f732ddc56661bc5f5cae2e1198978b1
│   ├── info
│   └── pack
└── refs
    ├── heads
    │   └── master
    └── tags

16 directories, 26 files

objects 中多出了一个文件

1
2
3
4
5
❯ git cat-file -t ce8c77db7f732ddc56661bc5f5cae2e1198978b1
blob
❯ git cat-file -p ce8c77db7f732ddc56661bc5f5cae2e1198978b1
123
456

就是 a.txt 最新的内容,而原来的 a.txt 依然还在(190a18037c64c43e6b11489df4bf0b9eb6d2c9bf),所以 git 保存的是整个文件,而不是只是变化的部分,这也是 commit 的基础,所以 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
❯ git commit -m "modify a.txt"
[master 294a462] modify a.txt
 1 file changed, 1 insertion(+)
❯ tree .git
.git
├── branches
├── COMMIT_EDITMSG
├── config
├── description
├── HEAD
├── hooks
│   ├── applypatch-msg.sample
│   ├── commit-msg.sample
│   ├── fsmonitor-watchman.sample
│   ├── post-update.sample
│   ├── pre-applypatch.sample
│   ├── pre-commit.sample
│   ├── pre-merge-commit.sample
│   ├── prepare-commit-msg.sample
│   ├── pre-push.sample
│   ├── pre-rebase.sample
│   ├── pre-receive.sample
│   ├── push-to-checkout.sample
│   └── update.sample
├── index
├── info
│   └── exclude
├── logs
│   ├── HEAD
│   └── refs
│       └── heads
│           └── master
├── objects
│   ├── 19
│   │   └── 0a18037c64c43e6b11489df4bf0b9eb6d2c9bf
│   ├── 29
│   │   └── 4a462e98ac84b47522beb5a6c5b795bad599c2
│   ├── bb
│   │   └── 978046c355c1fa2875c6a8473cea4a60d55814
│   ├── c4
│   │   └── 903888e91347c58a530c1f1987dfc4d203960a
│   ├── ce
│   │   └── 8c77db7f732ddc56661bc5f5cae2e1198978b1
│   ├── e9
│   │   └── bbf146022722173fb1c459daf3a03f211ad3ad
│   ├── info
│   └── pack
└── refs
    ├── heads
    │   └── master
    └── tags

objects 下又多了两个文件,分别看一下

1
2
3
4
5
6
7
8
9
❯ git cat-file -p 294a462e98ac84b47522beb5a6c5b795bad599c2
tree e9bbf146022722173fb1c459daf3a03f211ad3ad
parent bb978046c355c1fa2875c6a8473cea4a60d55814
author lxh <452228391@qq.com> 1618340817 +0800
committer lxh <452228391@qq.com> 1618340817 +0800

modify a.txt
❯ git cat-file -t 294a462e98ac84b47522beb5a6c5b795bad599c2
commit

这是我们刚刚的提交,里面又多了一个 tree

1
2
3
4
❯ git cat-file -t e9bbf146022722173fb1c459daf3a03f211ad3ad
tree
❯ git cat-file -p e9bbf146022722173fb1c459daf3a03f211ad3ad
100644 blob ce8c77db7f732ddc56661bc5f5cae2e1198978b1    a.txt

感觉似曾眼熟,这不跟 c4903888e91347c58a530c1f1987dfc4d203960a 重复了吗?

事实上,基于 hash 的算法,只要内容有任何改动,哈希结果都会发生变化。原tree 中包含了 a.txt,a.txt 发生了变化会导致原 tree 的哈希结果也发生变化,所以产生了一个新的 tree。可能你觉得这里有点多余,我们换个场景就明白了。

( 另外可以自行查看一下,refs/heads/master 中的内容,现在应该是指向了这个新的 commit)

删除 a.txt

1
2
rm a.txt
git add .

此时 objects 没有发生任何变化,只有 index 发生了变化

1
2
❯ git ls-files

index 里没有文件了,即暂存区里没有文件了,因为我们把它删除了,显而易见,工作区里也不会有这个文件了

1
2
3
4
pwd
/home/head/code/my_repo
❯ ll
总用量 0

提交

1
2
3
4
5
6
❯ git commit -m "delete a.txt"
[master 138a68a] delete a.txt
 1 file changed, 2 deletions(-)
 delete mode 100644 a.txt
❯ ll
总用量 0

objects 下又多了两个文件,这里就不展示目录树了

1
2
3
4
5
6
7
8
9
10
11
12
❯ git cat-file -t 138a68ac42b5fde646849df01c1339cfaec874c3
commit
❯ git cat-file -p 138a68ac42b5fde646849df01c1339cfaec874c3
tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904
parent 294a462e98ac84b47522beb5a6c5b795bad599c2
author lxh <452228391@qq.com> 1618342629 +0800
committer lxh <452228391@qq.com> 1618342629 +0800

delete a.txt
❯ git cat-file -t 4b825dc642cb6eb9a060e54bf8d69288fbee4904
tree
❯ git cat-file -p 4b825dc642cb6eb9a060e54bf8d69288fbee4904

一个是最新的提交,一个是最新的tree,这个tree里没有任何东西

现在我们来找回这个文件

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
> git log

commit 138a68ac42b5fde646849df01c1339cfaec874c3 (HEAD -> master)
Author: lxh <452228391@qq.com>
Date:   Wed Apr 14 03:37:09 2021 +0800

    delete a.txt

commit 294a462e98ac84b47522beb5a6c5b795bad599c2
Author: lxh <452228391@qq.com>
Date:   Wed Apr 14 03:06:57 2021 +0800

    modify a.txt

commit bb978046c355c1fa2875c6a8473cea4a60d55814
Author: lxh <452228391@qq.com>
Date:   Wed Apr 14 02:23:03 2021 +0800

    add a.txt
    
❯ git reset 294a462e98ac84b47522beb5a6c5b795bad599c2
重置后取消暂存的变更:
D       a.txt

❯ git status
位于分支 master
尚未暂存以备提交的变更:
  (使用 "git add/rm <文件>..." 更新要提交的内容)
  (使用 "git restore <文件>..." 丢弃工作区的改动)
        删除:     a.txt

修改尚未加入提交(使用 "git add" 和/或 "git commit -a")

❯ git restore .
❯ ll
总用量 4
-rw-r--r-- 1 head head 8  4月 14 03:43 a.txt
❯ cat a.txt
123
456

所以,由于保存了任一时刻的快照,便能很容易的恢复到任一时刻的文件系统状态。

所以,得把 tree理解为系统快照,是带状态的一种对象,不能以普通文件系统的视角,虽然是同一个目录,但是有文件和没文件是两种状态;即便是同一个文件,文件改变了,那整个目录也是一种新的状态。这就是时光穿梭机!

git checkout 做了什么

git checkout -b dev 会在 refs/heads 下新增一个 dev 文件,文件内容为当前最新 commit ,此时应该和 master 内容是一样的,两个分支此时的状态完全一致,objects 下没有任何变化。

除此之外,HEAD 的内容会更改为 ref refs/heads/dev ,表示当前分支为 dev 分支。

这就是 checkout 所做的全部内容了。

所以分支只是一个别名而已,指向不同的 commit,以表示不同时刻的文件系统状态。

从 git 可以学到什么

  • 链表(指针)

    branch -> commit -> tree -> blob (subtree)

    branch 只是一个 commit 的别名,或者叫指针也行

    commit 如果不是第一个提交的话,还有一个 parent 属性,表示之前一个提交

    git 的整个操作过程简化来说就是操作 HEAD 指针在各个 commit 之间跳转,然后根据 commit 中的 tree “渲染” 出整个文件系统

  • 哈希

    基于哈希的消息摘要能快速的标识出整个文件系统,而且 SHA-1 的碰撞概率在版本控制这个用途上来说基本可以认为不会发生

    See Also Git是否考虑到SHA1碰撞的问题了?

  • 存储整体而不是存储变化,好像 React 基本思想也是这样(当状态变化时重新渲染组件,React也可以进行时光穿梭)

    这里的整体指的是文件和相关联的tree这个整体,而不是整个文件系统。其他未发生变化的blob和tree直接引用就行。

常用命令

本地创建分支并推送到远程分支

1
git push origin branch:branch --- 本地分支与远程分支要同名

删除远程分支

1
git push origin --delete branch

See Also

Git internals