git中的三颗树

理解 resetcheckout 的最简方法,就是以 Git 的思维框架(将其作为内容管理器)来管理三棵不同的树。 “” 在我们这里的实际意思是 “文件的集合”,而不是指特定的数据结构。 (在某些情况下索引看起来并不像一棵树,不过我们现在的目的是用简单的方式思考它。)

Git 作为一个系统,是以它的一般操作来管理并操纵这三棵树的:

用途
HEAD 上一次提交的快照,下一次提交的父结点
Index 预期的下一次提交的快照
Working Directory 沙盒

HEAD当前分支引用的指针,它总是指向该分支上的最后一次提交。 这表示 HEAD 将是下一次提交的父结点(父提交)。 通常,理解 HEAD 的最简方式,就是将它看做你的上一次提交的快照。

比如:

1
2
3
4
5
6
MINGW64 /d/coding/git-playground (master)
$ git log --all --oneline --graph
* ce592c7 (HEAD -> master) 剔除误提交的文件
* ddfa328 删除误提交的文件
* 0be6ec6 添加2个文件
...

本例中,HEAD指针指向当前分支master的最后一次提交ce592c7,可以通过cat-filels-tree来查看该指针:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
$ git cat-file -p HEAD
tree 477422fcec1bd9e1853d1270e0cbf7ea23e47f1f
parent ddfa32845e5d6c247feaf7d29929cc3ddbb374dc
author slimterry <slimterry@qq.com> 1712025707 +0800
committer slimterry <slimterry@qq.com> 1712025795 +0800

剔除误提交的文件

$ git ls-tree -r HEAD
100644 blob 148e347a33cd2aafb4e6aff5db9c55619693e25f .gitignore
100644 blob cb2417e0dc35491097e241451d4400cf9913632e A.java
100644 blob 2ace6f50f4e42509f12a61db477c722965f56120 B.java
100644 blob 3bd9bce7058fdf7b9809c5a87053a5270c6a75ea C.java
100644 blob e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 D.java
100644 blob b7d9ae7ac5e402d486cd2d93f6dd0792eae483ed app.properties
100644 blob e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 bar.txt
100644 blob e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 foo.txt

cat-filels-tree 是底层命令,它们一般用于底层工作,在日常工作中并不使用。不过它们能帮助我们了解到底发生了什么,其作用说明如下:

  • git-ls-tree :-List the contents of a tree object,-r :Recurse into sub-trees.

  • git-cat-file : Provide contents or details of repository objects,-p:Pretty-print the contents of <object> based on its type.

也可以通过git show命令查看该对象:

1
2
3
4
5
6
7
8
9
commit ce592c7e11b6ece40d224d7902e35a292d217597 (HEAD -> master)
Author: slimterry <slimterry@qq.com>
Date: Tue Apr 2 10:41:47 2024 +0800

剔除误提交的文件

diff --git a/bar.txt b/bar.txt
new file mode 100644
index 0000000..e69de29

INDEX (索引)

索引是你的 预期的下一次提交。 我们也会将这个概念引用为 Git 的 “暂存区域”,这就是当你运行 git commit 时 Git 看起来的样子,可以通过git ls-files查看快照内容。

Git 将上一次检出到工作目录中的所有文件填充到索引区(暂存区),它们看起来就像最初被检出时的样子。 之后你会将其中一些文件替换为新版本,接着通过 git commit 将它们转换为树来用作新的提交。

1
2
3
4
5
6
7
8
9
10
11
12
$ git status -s
?? index.html # 未追踪的文件

$ git ls-files -s
100644 148e347a33cd2aafb4e6aff5db9c55619693e25f 0 .gitignore
100644 cb2417e0dc35491097e241451d4400cf9913632e 0 A.java
100644 2ace6f50f4e42509f12a61db477c722965f56120 0 B.java
100644 3bd9bce7058fdf7b9809c5a87053a5270c6a75ea 0 C.java
100644 e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 0 D.java
100644 b7d9ae7ac5e402d486cd2d93f6dd0792eae483ed 0 app.properties
100644 e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 0 bar.txt
100644 e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 0 foo.txt

git-ls-files - Show information about files in the index and the working tree

-s

--stage

Show staged contents’ mode bits, object name and stage number in the output.

注意:本例中未追踪的文件index.html并不会显示在 git ls-files的输出中,因为它并未被索引(暂存)

工作目录

最后,你就有了自己的工作目录。 另外两棵树以一种高效但并不直观的方式,将它们的内容存储在 .git 文件夹中。 工作目录会将它们解包为实际的文件以便编辑。 你可以把工作目录当做 沙盒。在你将修改提交到暂存区并记录到历史之前,可以随意更改。

git工作流程

Git 主要的目的是通过操纵这三棵树来以更加连续的状态记录项目的快照

image-20240402150853129

让我们来可视化这个过程:假设我们进入到一个新目录,其中有一个文件。 我们称其为该文件的 v1 版本,将它标记为蓝色。 现在运行 git init,这会创建一个 Git 仓库。image-20240402151102760

此时其中的 HEAD 尚未存在:

1
2
3
 MINGW64 /d/tmp/git-playground (master)
$ git cat-file -p HEAD
fatal: Not a valid object name HEAD

此时的Index(暂存区)内容也为空:

1
2
3
 MINGW64 /d/tmp/git-playground (master)
$ git ls-files -s
# nothing ...

此时,只有工作目录有内容:

1
2
3
4
5
6
$ ll
total 0
-rw-r--r-- 1 86182 197609 0 4月 2 15:12 file.txt

$ git status -s
?? file.txt # 未追踪

现在我们想要提交这个文件,所以用 git add 来获取工作目录中的内容,并将其复制到索引中。

1
2
3
4
5
6
7
$ git add file.txt

$ git status -s
A file.txt

$ git ls-files -s
100644 e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 0 file.txt

可以看到,此时Index(暂存区)内容中已经包含了文件 file.txt,但是仍然没有HEAD指针:

1
2
$ git cat-file -p HEAD
fatal: Not a valid object name HEAD

image-20240402152442006

接着运行 git commit,它首先会移除索引中(Index)的内容并将它保存为一个永久的快照,然后创建一个指向该快照的提交对象,最后更新 master 来指向本次提交。

image-20240402152550110

1
2
3
4
$ git commit -m '提交file.txt'
[master (root-commit) 7dccb2a] 提交file.txt
1 file changed, 0 insertions(+), 0 deletions(-)
create mode 100644 file.txt

此时查看HEAD指针的内容:

1
2
3
4
5
6
7
8
9
$ git cat-file -p HEAD
tree bdd68b0120ca91384c1606468b4ca81b8f67c728
author slimterry <slimterry@qq.com> 1712042812 +0800
committer slimterry <slimterry@qq.com> 1712042812 +0800

提交file.txt

$ git ls-tree HEAD
100644 blob e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 file.txt

查看Index的内容:

1
2
$ git ls-files -s
100644 e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 0 file.txt

此时如果我们运行 git status,会发现没有任何改动,因为现在三棵树完全相同:

1
2
3
$ git status
On branch master
nothing to commit, working tree clean

现在我们想要对文件进行修改然后提交它。 我们将会经历同样的过程;首先在工作目录中修改文件。 我们称其为该文件的 v2 版本,并将它标记为红色。

修改文件:

1
2
3
4
$ echo '111111' > file.txt

$ git status -s
M file.txt

image-20240402153235775

如果现在运行 git status,我们会看到文件显示在 “Changes not staged for commit,” 下面并被标记为红色,因为该条目在索引与工作目录之间存在不同。 接着我们运行 git add 来将它暂存到索引中:

1
2
3
4
5
6
7
8
9
10
11
$ git status
On branch master
Changes not staged for commit:
(use "git add <file>..." to update what will be committed)
(use "git restore <file>..." to discard changes in working directory)
modified: file.txt

no changes added to commit (use "git add" and/or "git commit -a")

$ git add file.txt

image-20240402153359163

此时,由于索引(Index/暂存区)和 HEAD 不同,若运行 git status 的话就会看到 “Changes to be committed” 下的该文件变为绿色 ——也就是说,现在预期的下一次提交与上一次提交不同。 最后,我们运行 git commit 来完成提交。

1
2
3
4
5
6
7
8
9
$ git status
On branch master
Changes to be committed:
(use "git restore --staged <file>..." to unstage)
modified: file.txt

$ git commit -m '修改file.txt文件'
[master 6f722b3] 修改file.txt文件
1 file changed, 1 insertion(+)

image-20240402153637450

现在运行 git status 会没有输出,因为三棵树又变得相同了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
$ git status
On branch master
nothing to commit, working tree clean

MINGW64 /d/tmp/git-playground (master)
$ git ls-files -s # 查看暂存区(索引)的内容
100644 90d2950097fa1850b6f692ab1095ec9cdd3f7fae 0 file.txt

MINGW64 /d/tmp/git-playground (master)
$ git ls-tree -r HEAD # 查看HEAD指针的内容
100644 blob 90d2950097fa1850b6f692ab1095ec9cdd3f7fae file.txt

MINGW64 /d/tmp/git-playground (master)
$ ll # 查看工作区内容
total 1
-rw-r--r-- 1 86182 197609 7 4月 2 15:32 file.txt

$ git log --all --oneline --graph
* 6f722b3 (HEAD -> master) 修改file.txt文件
* 7dccb2a 提交file.txt

切换分支或克隆的过程也类似。 当检出一个分支时,它会修改 HEAD 指向新的分支引用,将索引填充为该次提交的快照,然后将 索引的内容复制到 工作目录 中。

3种reset模式

在以下情景中观察 reset 命令会更有意义。为了演示这些例子,我们分三次修改了file.txt文件,其提交历史如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
$ git init
$ echo 'v1' > file.txt
$ git commit -m 'v1'
[master (root-commit) d4d805e] v1
1 file changed, 1 insertion(+)
create mode 100644 file.txt

$ echo 'v2' >> file.txt
$ git commit -am 'v2'
[master 448db38] v2
1 file changed, 1 insertion(+)

$ echo 'v3' >> file.txt
$ git commit -am 'v3'
[master 5789903] v3
1 file changed, 1 insertion(+)

$ git log --all --oneline
5789903 (HEAD -> master) v3
448db38 v2
d4d805e v1

三次提交后,文件内容如下:

1
2
3
4
$ cat file.txt
v1
v2
v3

此时git仓库的三棵树的示意图如下:

image-20240402154435434

查看此时的HEAD:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ git cat-file -p HEAD
tree 8cd6823441b40b1947ed4e1fa1fde8fb0a2b3f8c
parent 448db38045cbfdcd17f66f97cdd27398c1851a36
author slimterry <slimterry@qq.com> 1712108715 +0800
committer slimterry <slimterry@qq.com> 1712108715 +0800

v3

$ git ls-tree 8cd6823441b40b1947ed4e1fa1fde8fb0a2b3f8c
100644 blob 4b1d4d4b660c400875dd122de217e942f481a378 file.txt


$ git show 4b1d4d4b660c400875dd122de217e942f481a378
v1
v2
v3

查看Index中的内容:

1
2
3
4
5
6
7
8
$ git ls-files -s
100644 4b1d4d4b660c400875dd122de217e942f481a378 0 file.txt

$ git show 4b1d4d4b660c400875dd122de217e942f481a378
v1
v2
v3

可以看到,此时HEADIndex工作区的内容完全一致,此时:

1
2
3
$ git status
On branch master
nothing to commit, working tree clean

接下来我们将如上的仓库分复制三份,分别演示如下三种不同的reset模式。

第 1种模式 --soft(仅移动HEAD)

1
2
3
4
5
$ git reset --soft HEAD~

$ git log --all --oneline --graph
* 448db38 (HEAD -> master) v2
* d4d805e v1

可以看出:提交v3被撤销了,此时我们再查看三棵树的内容:

HEAD:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ git cat-file -p HEAD
tree 967592420c3025aa7a647ce3eab8bf51cde4f9d1
parent d4d805e23832f896eefb9b8d21f9ec0f632b433a
author slimterry <slimterry@qq.com> 1712108693 +0800
committer slimterry <slimterry@qq.com> 1712108693 +0800

v2

$ git ls-tree 967592420c3025aa7a647ce3eab8bf51cde4f9d1
100644 blob 2139d8be699ff388ab3ec85008a3906675ac67cb file.txt

$ git show 2139d8be699ff388ab3ec85008a3906675ac67cb
v1
v2

Index:

1
2
3
4
5
6
7
$ git ls-files -s
100644 4b1d4d4b660c400875dd122de217e942f481a378 0 file.txt

$ git show 4b1d4d4b660c400875dd122de217e942f481a378
v1
v2
v3

工作区:

1
2
3
4
$ cat file.txt
v1
v2
v3

此时的git仓库示意图如下:

image-20240402161245015

现在看一眼上图,理解一下发生的事情:它本质上是撤销了上一次 git commit 命令,Index和工作区的内容不变,仍然是v3版本。因为HEADIndex的内容不一致,因此如果此时我们运行git status命令,会有如下的输出:

1
2
3
4
5
$ git status
On branch master
Changes to be committed:
(use "git restore --staged <file>..." to unstage)
modified: file.txt

因为:

image-20240403102446226

第2种:更新索引(–mixed)

该模式为默认的模式,即如下2个命令等价:

1
2
$ git reset HEAD~
$ git reset --mixed HEAD~

执行命令:

1
2
3
$ git reset --mixed HEAD~
Unstaged changes after reset:
M file.txt

此时观察三棵树的状态:

HEAD:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ git cat-file -p HEAD
tree 967592420c3025aa7a647ce3eab8bf51cde4f9d1
parent d4d805e23832f896eefb9b8d21f9ec0f632b433a
author slimterry <slimterry@qq.com> 1712108693 +0800
committer slimterry <slimterry@qq.com> 1712108693 +0800

v2

$ git ls-tree 967592420c3025aa7a647ce3eab8bf51cde4f9d1
100644 blob 2139d8be699ff388ab3ec85008a3906675ac67cb file.txt

$ git show 2139d8be699ff388ab3ec85008a3906675ac67cb
v1
v2

Index:

1
2
3
4
5
6
$ git ls-files -s
100644 2139d8be699ff388ab3ec85008a3906675ac67cb 0 file.txt

$ git show 2139d8be699ff388ab3ec85008a3906675ac67cb
v1
v2

工作区:

1
2
3
4
$ cat file.txt
v1
v2
v3

可以看到HEAD指针被移动到v2版本,且Index也被置位和HEAD相同的内容,此时仓库的示意图如下:

image-20240403092045753

在再看一眼上图,理解一下发生的事情:

  1. 它依然会撤销一上次 提交(因为HEAD指针移动了)

  2. 但还会取消暂存 所有的东西(索引的内容被重置为v2版本的)

  3. 工作区的v3版本的改动仍然存在

于是,我们回滚到了所有 git addgit commit 的命令执行之前。此时工作区和Index内容不一致,执行git status的结果如下:

1
2
3
4
5
6
7
8
$ git status
On branch master
Changes not staged for commit:
(use "git add <file>..." to update what will be committed)
(use "git restore <file>..." to discard changes in working directory)
modified: file.txt

no changes added to commit (use "git add" and/or "git commit -a")

因为:

image-20240403105420398

第3种 --hard(不可恢复)

执行命令:

1
2
$ git reset --hard HEAD~
HEAD is now at 448db38 v2

此时查看三棵树:

HEAD

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ git cat-file -p HEAD
tree 967592420c3025aa7a647ce3eab8bf51cde4f9d1
parent d4d805e23832f896eefb9b8d21f9ec0f632b433a
author slimterry <slimterry@qq.com> 1712108693 +0800
committer slimterry <slimterry@qq.com> 1712108693 +0800

v2

$ git ls-tree 967592420c3025aa7a647ce3eab8bf51cde4f9d1
100644 blob 2139d8be699ff388ab3ec85008a3906675ac67cb file.txt

$ git show 2139d8be699ff388ab3ec85008a3906675ac67cb
v1
v2

Index:

1
2
3
4
5
6
$ git ls-files -s
100644 2139d8be699ff388ab3ec85008a3906675ac67cb 0 file.txt

$ git show 2139d8be699ff388ab3ec85008a3906675ac67cb
v1
v2

工作区:

1
2
3
$ cat file.txt
v1
v2

可以看到三棵树的状态完全一致,因此此时执行git status命令结果如下:

1
2
3
$ git status
On branch master
nothing to commit, working tree clean

此时git仓库的示意图如下:

image-20240402162049568

--hard 标记撤销了最后的提交、git addgit commit 命令以及工作目录中的所有工作

必须注意,--hard 标记是 reset 命令唯一的危险用法,它也是 Git 会真正地销毁数据的仅有的几个操作之一。 其他任何形式的 reset 调用都可以轻松撤消,但是 --hard 选项不能,因为它强制覆盖了工作目录中的文件。

在这种特殊情况下,我们的 Git 数据库中的一个提交内还留有该文件的 v3 版本,我们可以通过 reflog 来找回它。但是若该文件还未提交,Git 仍会覆盖它从而导致无法恢复。

本例中的改动之前已经提交过,因此还可以通过reflog找到对应的commit id,然后检出后回退:

1
2
3
4
5
6
7
8
9
10
11
12
13
$ git reflog
448db38 (HEAD -> master) HEAD@{0}: reset: moving to HEAD~
5789903 HEAD@{1}: commit: v3
448db38 (HEAD -> master) HEAD@{2}: commit: v2
d4d805e HEAD@{3}: commit (initial): v1

$ git checkout 5789903
Note: switching to '5789903'.

$ cat file.txt
v1
v2
v3 # 改动被找回

总结

  • --soft : 只移动HEAD ,最后一次提交中的文件变为已暂存(staged

  • --mixed 移动HEAD,而且将Index置为和HEAD一样的内容,最后一次提交中的文件变为待暂存

  • --hard 移动HEAD,将Index和工作区内容置为和HEAD一样的内容,最后一次提交的内容被完全清除

    需要通过reflog才能找回

reset 命令会以特定的顺序重写这三棵树,在你指定以下选项时停止:

  1. 移动 HEAD 分支的指向 (若指定了 --soft,则到此停止)

  2. 使**索引(暂存区)**看起来像 HEAD (若未指定 --hard,则到此停止)

  3. 使工作目录看起来像索引(指定 了--hard

重置前:

image-20240402154435434

重置后:

image-20240403094148992

通过路径来重置

将某个文件恢复成上一个版本

前面讲述了 reset 基本形式的行为,不过你还可以给它提供一个作用路径。 若指定了一个路径,reset不会移动HEAD指针,并且将它的作用范围限定为指定的文件或文件集合

假设我们有如下的仓库:

1
2
3
4
5
6
7
8
9
10
11
12
13
$ git log --all --oneline --graph
* 5789903 (HEAD -> master) v3
* 448db38 v2
* d4d805e v1 # 初始提交

$ ll
total 1
-rw-r--r-- 1 86182 197609 9 4月 3 13:59 file.txt

$ cat file.txt
v1
v2
v3

我们分别在三次提交中往file.txt中依次插入了3行。

现在,假如我们运行 git reset HEAD~ file.txt ,所以它本质上只是将 file.txtHEAD~ 复制到索引中。

1
2
3
$ git reset HEAD~ file.txt
Unstaged changes after reset:
M file.txt

上面提到,git reset的默认模式是--mixed,因此此处的命令相当于:git reset --mixed HEAD file.txt ,但是在新版本的git中已经弃用该选项

1
2
3
4
$ git reset --mixed HEAD~ file.txt
warning: --mixed with paths is deprecated; use 'git reset -- <paths>' instead.
Unstaged changes after reset:
M file.txt

此时查看三棵树中的文件状态:

HEAD

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ git cat-file -p HEAD
tree 8cd6823441b40b1947ed4e1fa1fde8fb0a2b3f8c
parent 448db38045cbfdcd17f66f97cdd27398c1851a36
author slimterry <slimterry@qq.com> 1712108715 +0800
committer slimterry <slimterry@qq.com> 1712108715 +0800

v3

$ git ls-tree 8cd6823441b40b1947ed4e1fa1fde8fb0a2b3f8c
100644 blob 4b1d4d4b660c400875dd122de217e942f481a378 file.txt

$ git show 4b1d4d4b660c400875dd122de217e942f481a378
v1
v2
v3

Index:

1
2
3
4
5
6
$ git ls-files -s
100644 2139d8be699ff388ab3ec85008a3906675ac67cb 0 file.txt

$ git show 2139d8be699ff388ab3ec85008a3906675ac67cb
v1
v2

工作区:

1
2
3
4
$ cat file.txt
v1
v2
v3

此时的git status命令输出如下:

1
2
3
4
5
6
7
8
9
10
$ git status
On branch master
Changes to be committed:
(use "git restore --staged <file>..." to unstage)
modified: file.txt

Changes not staged for commit:
(use "git add <file>..." to update what will be committed)
(use "git restore <file>..." to discard changes in working directory)
modified: file.txt
  • file.txtfile.txt出现在Changes to be committed:下方是因为Index与HEAD的内容不一致,别忘了git reset HEAD~ file.txt的本质是将上一版本的拷贝到Index

  • file.txt出现在Changes not staged for commit:下方是因为工作区Index的内容不一致

可以看到指定了文件后,git reset命令确实不会移动HEAD指针,此时可以通过如下命令舍弃v3版本的改动:

1
2
3
4
5
6
7
8
9
10
11
12
$ git restore file.txt

$ git status
On branch master
Changes to be committed:
(use "git restore --staged <file>..." to unstage)
modified: file.txt


$ cat file.txt
v1
v2

将文件恢复成指定版本

我们的仓库历史提交如下:

1
2
3
4
$ git log --all --oneline --graph
* 5789903 (HEAD -> master) v3
* 448db38 v2
* d4d805e v1

此时执行命令 git reset d4d805e file.txt:

1
2
3
$ git reset d4d805e file.txt
Unstaged changes after reset:
M file.txt

此时查看文件内容:

1
2
3
4
$ cat file.txt
v1
v2
v3

查看Index中的内容:

1
2
3
4
5
$ git ls-files -s
100644 626799f0f85326a8c1fc522db584e86cdfccd51f 0 file.txt

$ git show 626799f0f85326a8c1fc522db584e86cdfccd51f
v1

Indexfile.txt的内容已经被置为与版本d4d805e一样的内容。

此时查看HEAD

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ git cat-file -p HEAD
tree 8cd6823441b40b1947ed4e1fa1fde8fb0a2b3f8c
parent 448db38045cbfdcd17f66f97cdd27398c1851a36
author slimterry <slimterry@qq.com> 1712108715 +0800
committer slimterry <slimterry@qq.com> 1712108715 +0800

v3

$ git ls-tree 8cd6823441b40b1947ed4e1fa1fde8fb0a2b3f8c
100644 blob 4b1d4d4b660c400875dd122de217e942f481a378 file.txt

$ git show 4b1d4d4b660c400875dd122de217e942f481a378
v1
v2
v3

此时查看git status输出如下:

1
2
3
4
5
6
7
8
9
10
$ git status
On branch master
Changes to be committed:
(use "git restore --staged <file>..." to unstage)
modified: file.txt

Changes not staged for commit:
(use "git add <file>..." to update what will be committed)
(use "git restore <file>..." to discard changes in working directory)
modified: file.txt
  • file.txt出现在Changes to be committed:下方是因为Index与HEAD的内容不一致,别忘了git reset d4d805e file.txt的本质是将版本d4d805e中的file.txt拷贝到Index

  • file.txt出现在Changes not staged for commit:下方是因为工作区Index的内容不一致