git中的三颗树
理解 reset
和 checkout
的最简方法,就是以 Git 的思维框架(将其作为内容管理器)来管理三棵不同的树。 “树 ” 在我们这里的实际意思是 “文件的集合 ”,而不是指特定的数据结构 。 (在某些情况下索引看起来并不像一棵树,不过我们现在的目的是用简单的方式思考它。)
Git 作为一个系统,是以它的一般操作来管理并操纵这三棵树的:
树
用途
HEAD
上一次提交的快照,下一次提交的父结点
Index
预期的下一次提交的快照
Working Directory
沙盒
HEAD
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-file
与 ls-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-file
与 ls-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 主要的目的是通过操纵这三棵树 来以更加连续的状态记录项目的快照 。
让我们来可视化这个过程:假设我们进入到一个新目录,其中有一个文件。 我们称其为该文件的 v1 版本,将它标记为蓝色。 现在运行 git init
,这会创建一个 Git 仓库。
此时其中的 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
此时,只有工作目录有内容:
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
接着运行 git commit
,它首先会移除索引中 (Index
)的内容 并将它保存为一个永久的快照,然后创建一个指向该快照的提交对象,最后更新 master
来指向本次提交。
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
如果现在运行 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
此时,由于索引(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(+)
现在运行 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 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
三次提交后,文件内容如下:
此时git仓库的三棵树的示意图如下:
查看此时的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
可以看到,此时HEAD
、Index
、工作区 的内容完全一致,此时:
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
工作区:
此时的git仓库示意图如下:
现在看一眼上图,理解一下发生的事情:它本质上是撤销了上一次 git commit
命令,Index
和工作区的内容不变,仍然是v3
版本。因为HEAD
与Index
的内容不一致,因此如果此时我们运行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
因为:
第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
工作区:
可以看到HEAD
指针被移动到v2
版本,且Index
也被置位和HEAD
相同的内容,此时仓库的示意图如下:
在再看一眼上图,理解一下发生的事情:
它依然会撤销 一上次 提交
(因为HEAD
指针移动了)
但还会取消暂存 所有的东西(索引的内容被重置为v2
版本的)
工作区的v3
版本的改动仍然存在
于是,我们回滚到了所有 git add
和 git 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" )
因为:
第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
工作区:
可以看到三棵树的状态完全一致,因此此时执行git status
命令结果如下:
1 2 3 $ git status On branch master nothing to commit, working tree clean
此时git仓库的示意图如下:
--hard
标记撤销了最后的提交、git add
和 git 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
命令会以特定的顺序重写这三棵树,在你指定以下选项时停止:
移动 HEAD
分支的指向 (若指定了 --soft
,则到此停止)
使**索引(暂存区)**看起来像 HEAD
(若未指定 --hard
,则到此停止)
使工作目录看起来像索引(指定 了--hard
)
重置前:
重置后:
通过路径来重置
将某个文件恢复成上一个版本
前面讲述了 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.txt
从 HEAD
~ 复制到索引 中。
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
工作区:
此时的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
可以看到指定了文件后,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
此时查看文件内容:
查看Index
中的内容:
1 2 3 4 5 $ git ls-files -s 100644 626799f0f85326a8c1fc522db584e86cdfccd51f 0 file.txt $ git show 626799f0f85326a8c1fc522db584e86cdfccd51f v1
Index
中file.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