tree 对象记录了我们的文件结构,更形象的说法是,某个 tree 对象记录了某个文件夹的结构,包含文件以及子文件夹。tree 对象的名称也是一个40位的哈希值,文件名依据内容生成,因此如果一个文件夹中的结构有所改变,在 .git/object/ 中就会出现一个新的 tree object, 一个典型的 tree object 的内容如下:
➜ git ls-tree bb4a8638f1431e9832cfe149d7f32f31ebaa77ef
100644 blob 4be9cb419da86f9cbdc6d2ad4db763999a0b86f2 .gitignore
040000 tree dccea6a66df035ac506ab8ca6d2735f9b64f66c1 01_introduction_to_algorithms
040000 tree 363813a5406b072ec65867c6189e6894b152a7e5 02_selection_sort
040000 tree 5efc07910021b8a2de0291218cb1ec2555d06589 03_recursion
040000 tree cc15fd67f464c29495437aa81868be67cd9688b2 04_quicksort
040000 tree 9f09206e367567bf3fe0f9b96f3609eb929840f1 05_hash_tables
040000 tree c8b7b793b0318d13b25098548effde96fc9f1377 06_breadth-first_search
040000 tree 7f111006c8a37eab06a3d8931e83b00463ae0518 07_dijkstras_algorithm
040000 tree 9f6d831e5880716e0eda2d9312ea2689a8cc1439 08_greedy_algorithms
040000 tree 692a9b39721744730ad1b29c052e288aeb89c2ac 09_dynamic_programming
100644 blob 290689b29c24d3406a1ed863077a01393ae2aff3 LICENSE
100644 blob 9017b1121945799e97825f996bc0cefe3422cbaf README.md
040000 tree ce710aa0b6c23b7f81dbd582aad6f9435988a8b4 images
Git是一个CLI(Common line interface),我们与其的交互常常发生在命令行,(当然有时候也会使用GUI,如sourcetree,Github等等),由于我们的使用方式,我们常常会忽略git仓库本身是一个没那么复杂的文件系统,我们输入git命令时其实就是对这个文件系统进行操作。
Git做为文件系统长什么样子
找一个空文件夹,执行
git init
后我们会发现其中会多出一个隐藏文件夹.git
,其文件结构如下:几乎 Git 相关的所有操作都和这个文件夹相关,如果你是第一次见到这个文件系统,觉得陌生也很正常,不过读完本文,每一项都会变得清晰了。
我们先想另外一个问题,做为版本控制系统的 Git ,究竟会存储那些内容在上述文件系统中,这些内容又是如何被存储的呢?
好吧,不卖关子,实际上在上述文件系统中 Git 为我们存储了五种对象,这些对象存储在
/objects
和/refs
文件夹中。Git 中存储的五种对象
blobs
,tree
,commit
,以及声明式的tag
这四种对象会存储在.git/object
文件夹中。这些对象的名称是一段40位的哈希值,此名称由其内容依据sha-1
算法生成,具体到.git/object
文件夹下,会取该hash值的前 2 位为子文件夹名称,剩余 38 位为文件名,这四类对象都是二进制文件,其内容格式依据类型有所不同。下面我们一项项来看:Blobs
我们都常用
git add
这个命令,也都听说过,此命令会把文件添加到缓存区(index
)。但是有没有想过「把文件添加到缓存区」是一种很奇怪的说法,如果说这个文件我们曾经add
过,为什么我们需要在修改过后再次添加到缓存区?我们确实需要把文件重新添加到缓存区,其实每次修改后的文件,对 git 来说都是一个新文件,每次
add
一个文件,就会添加一个Blob
对象。blobs
是二进制文件,我们不能直接查看,不过通过 Git 提供的一些更底层的命令如git show [hash]
或者git cat-file -p [hash]
我们就可以查看.git/object
文件夹下任一文件的内容。从上面的内容中就可以看出,
blob
对象中仅仅存储了文件的内容,如果我们想要完整还原工作区的内容,我们还需要把这些文件有序组合起来,这就涉及到 Git 中存储的另外一个重要的对象:tree
。Tree objects
tree
对象记录了我们的文件结构,更形象的说法是,某个tree
对象记录了某个文件夹的结构,包含文件以及子文件夹。tree
对象的名称也是一个40位的哈希值,文件名依据内容生成,因此如果一个文件夹中的结构有所改变,在.git/object/
中就会出现一个新的tree object
, 一个典型的tree object
的内容如下:我们可以看过,
tree
中包含两种类型的文件,tree
和blob
,这就把文件有序的组合起来了,如果我们知道了根tree
(可以理解为root
文件夹对应的tree
),我们就有能力依据此tree
还原整个工作区。可能我们很早就听说过 Git 中的每一个
commit
存储的都是一个「快照」。理解了tree
对象,我们就可以较容易的理解「快照」这个词了 ,接下来我们看看commit object
。commit object
我们知道,
commit
记录了我们的提交历史,存储着提交时的 message,Git 分支中的一个个的节点也是由 commit 构成。一个典型的commit object
内容如下:我们来看看其中每一项的意义:
tree
:告诉我们当前commit
对应的根tree
,依据此值我们还原此commit
对应的工作区;parent
:父commit
的 hash 值,依据此值,我们可以记录提交历史;author
:记录着此commit
的修改内容由谁修改;committer
:记录着当前 commit 由谁提交;...bc
:commit message
;commit
常常位于 Git 分支上,分支往往也是由我们主动添加的,Git 提供了一种名为References
的对象供我们存储「类分支」资源。References
References
对象存储在/git/refs/
文件夹下,该文件夹结构如下:其中 heads 文件夹中的每一个文件其实就对应着一条本地分支,已我们最熟悉的 master 分支为例,我们看看其中的内容:
有没有发现,文件 master 中的内容看起来好眼熟,它其实是就是一个指针,指向当前分支最新的 commit 对象。所以说 Git 中的分支是非常轻量级的,弄清分支在 Git 内部是这样存储之后,也许我们可以更容易理解类似下面这种图了。
我们再看看
.git/refs
文件夹中其它的内容:.git/refs/remotes
中记录着远程仓库分支的本地映射,其内容只读;.git/refs/stash
与git stash
命令相关,后文会详细讲解;.git/refs/tag
, 轻量级的tag,与git tag
命令相关,它也是一个指向某个commit
对象的指针;Tag objects
上文已经说过 Git 中存在两种
tag
:.git/refs/tag/
文件夹下;tag object
,此种 tag 能记录更多的信息;两种 tag 的内容差别较大:
对比可以发现,声明式的 tag 不仅记录了对应的 commit ,标签号,额外还记录了打标签的人,而且还可以额外添加
tag message
(上面的-m 'Tagged1.0'
)。至此,我们已经理解了 Git 中的这几类资源,接下来我们看看 Git 命令是如何操作这些资源的。
常见git命令与上述资源间的映射
依据场景,我们可以粗略按照操作的是本地仓库还是远程仓库,把 Git 命令分为本地命令和远程命令,我们先看本地命令,我们本地可供操作的 Git 仓库往往是通过
git clone
或者git init
生成。我们先看git init
做了些什么。本地命令
git init
&&git init --bare
git init
:在当前文件夹下新建一个本地仓库,在文件系统上表现为在当前文件夹中新增一个.git
的隐藏文件夹 如:Git 中还存在一种被称为裸仓库的特殊仓库,使用命令
git init --bare
可以初始化一个裸仓库其目录结构如下:
和普通仓库相比,裸仓库没有工作区,所以并不会存在在裸仓库上直接提交变更的情况,这种仓库会直接把
.git
文件夹中的内容置于初始化的文件夹下。此外在 config 文件下我们会看到
bare = true
这表明当前仓库是一个裸仓库:git add
我们都知道
git add [file]
会把文件添加到缓存区。那缓存区本质上是什么呢?为了理清这个问题,我们先看下图:
很多地方会说,git 命令操作的是三棵树。三棵树对应的就是上图中的工作区( working directory )、缓存区( Index )、以及 HEAD。
工作区比较好理解,就是可供我们直接修改的区域,
HEAD
其实是一个指针,指向最近的一次 commit 对象,这个我们之后会详述。Index
就是我们说的缓存区了,它是下次 commit 涉及到的所有文件的列表。回到
git add [file]
,这个命令会依次做下面两件事情:.git/object/
文件夹中添加修改或者新增文件对应的blob
对象;.git/index
文件夹中写入该文件的名称及对应的blob
对象名称;通过命令
git ls-files -s
可以查看所有位于.git/index
中的文件,如下:其中各项的含义如下:
100644
:100
代表regular file,644
代表文件权限8baef1b4abc478178b004d62031cf7fe6db6f903
:blob对象的名称;0
:当前文件的版本,如果出现冲突,我们会看到1
,2
;data/d.txt
: 该文件的完整路径 Git 还额外提供了一个命令来帮我我们查看文件在这三棵树中的状态,git status
。git status
git status
有三个作用:一般来说
.git/HEAD
文件中存储着 Git 仓库当前位于的分支:当我们
git add
某个文件后,git 下一步往往会提示我们commit
它。我们接下来看看,commit
过程发生了什么。git commit
对应到文件层面,
git commit
做了如下几件事情:tree
对象,有多少个修改过的文件夹,就会添加多少个tree
对象;commit
对象,其中的的tree
指向最顶端的tree,此外还包含一些其它的元信息,commit
对象中的内容,上文已经见到过,tree
对象中会包含一级目录下的子tree
对象及blob
对象,由此可构建当前commit的文档快照;;当我们
git add
某个文件后,下一步我们往往需要执行git commit
。接下来我们看看,commit
过程发生了什么。git branch
前文我们提到过,分支在本质上仅仅是「指向提交对象的可变指针」,其内容为所指对象校验和(长度为 40 的 SHA-1 值字符串)的文件(一个commit对象),所以分支的创建和销毁都异常高效,创建一个新分支就相当于往一个文件中写入 41 个字节(40 个字符和 1 个换行符),足见 Git 的分支多么轻量级。 此外上文中提到的 HEAD 也可以看做一个指向当前所在的本地分支的特殊指针。 在开发过程中我们会创建很多分支,所有的分支都存在于
.git/refs
文件夹中。存在两种分支,本地分支和远程分支。 本地分支:
远程分支:
.git/config
文件中信息进一步指明了远程分支与本地分支之间的关系:使用
git branch [newBranchName]
可以创建新分支newBranchName
。不过一个更常见的用法是git checkout -b [newBranchName]
,此命令在本地创建了分支newBranchName
,并切换到了分支newBranchName
。我们看看git checkout
究竟做了些什么git checkout
还记得前面我们提到过的
HEAD
吗?git checkout
实际上就是在操作HEAD
。 前文中我们提到过一般情况下.git/HEAD
指向本地仓库当前操作的分支。那只是一般情况,更准确的说法是.git/HEAD
直接或者间接指向某个commit
对象。 我们知道每一个commit
对象都对应着一个快照。可依据其恢复本地的工作目录。HEAD
指向的commit
是判断工作区有何更改的基础。 Git 中有一个比较难理解的概念叫做「HEAD分离」,映射到文件层面,其实指的是.git/HEAD
直接指向某个commit
对象。 我们来看git checkout
的具体用法git checkout <file>
: 此命令可以用来清除未缓存的更改,它可以看做是git checkout HEAD <file>
的简写, 映射到文件层面,其操作为恢复文件<file>
的内容为,HEAD对应的快照时的内容。其不会影响已经缓存的更改的原因在于,其实缓存过的文件就是另外一个文件啦。 相应的命令还有git checkout <commit> <file>
可以用来恢复某文件为某个提交时的状态。git checkout <branch>
切换分支到.git/HEAD
中的内容为<branch>
,更新工作区内容为<branch>
所指向的commit
对象的内容。git checkout <hash|tag>
HEAD直接指向一个commit
对象,更新工作区内容为该commit
对象对应的快照,此时为HEAD
分离状态,切换到其它分支或者新建分支git branch -b new-branch
||git checkout branch
可以使得HEAD
不再分离。在分支上进行了一些操作后,下一步我们要做的就是合并不同分支上的代码了,接下来我们看看
git merge
是如何工作的。git merge
Git 中分支合并有两种算法,快速向前合并 和 三路合并。
快速向前合并:
三路合并:
和普通的 commit 对象的区别在于其有两个
parent
,分别指向被合并的两个commit
。不过三路合并往往没有那么顺利,往往会有冲突,此时需要我们解决完冲突后,再合并,三路合并的详细过程如下(为了叙述便利,假设合并发生在 master 分支与 feature 分支之间):
.git/MERGE_HEAD
。此文件的存在说明 Git 正在做合并操作。(记录合并提交的状态)git add
以更新 index 被提交,git commit
基于此 index 生成新的commit
;.git/refs/heads/master
中的内容指向第8步中新生成的commit
,至此三路合并完成;git cherry-pick(待进一步补充)
Git 中的一些命令是以引入的变更即提交这样的概念为中心的,这样一系列的提交,就是一系列的补丁。 这些命令以这样的方式来管理你的分支。
git cherry-pick
做的事情是将一个或者多个commit应用到当前commit的顶部,复制commit,会保留对应的二进制文件,但是会修改parent
信息。在D commit上执行,
git cherry-pick F
会将F复制一份到D上,复制的原因在于,F的父commit变了,但是内容又需要保持不可变。一个常见的工作流如下:
git revert 命令本质上就是一个逆向的 git cherry-pick 操作。 它将你提交中的变更的以完全相反的方式的应用到一个新创建的提交中,本质上就是撤销或者倒转。
有时候我们会想要撤销一些
commit
,这时候我们就会用到git reset
。git reset
git reset
具有以下常见用法:git reset <file>
:从缓存区移除特定文件,但是不会改变工作区的内容git reset
: 重设缓存区,会取消所有文件的缓存git reset --hard
: 重置缓存区和工作区,修改其内容对最新的一次 commit 对应的内容git reset <commit>
: 移动当前分支的末端到指定的commit
处git reset --hard <commit>
: 重置缓存区和工作区,修改其内容为指定 commit 对应的内容 相对而言,git reset
是一个相对危险的操作,其危险之处在于可能会让本地的修改丢失,可能会让分支历史难以寻找。我们看看
git reset
的原理HEAD
所指向的分支的指向:如果你正在 master 分支上工作,执行git reset 9e5e64a
将会修改master
让指向 哈希值为9e5e64a
的commit object
。git reset
,上述过程都会发生,不同用法的区别在于会如何修改工作区及缓存区的内容,如果你用的是git reset --soft
,将仅仅执行上述过程;git reset
本质上是撤销了上一次的git commit
命令。加上
—mixed
会更新索引:git reset --mixed
和git reset
效果一致,这是git reset
的默认选项,此命令除了会撤销一上次提交外,还会重置index
,相当于我们回滚到了git add
和git commit
前的状态。添加
—hard
会修改工作目录中的内容:除了发生上述过程外,还会恢复工作区为 上一个commit
对应的快照的内容,换句话说,是会清空工作区所做的任何更改。如果你给
git reset
指定了一个路径,git reset
将会跳过第 1 步,将它的作用范围限定为指定的文件或文件夹。 此时分支指向不会移动,不过索引和工作目录的内容则可以完成局部的更改,会只针对这些内容执行上述的第 2、3 步。git stash
有时候,我们在新分支上的
feature
开发到一半的时候接到通知需要去修复一个线上的紧急bug🐛,这时候新feature
还达不到该提交的程度,命令git stash
就派上了用场。git stash
被用来保存当前分支的工作状态,便于再次切换回本分支时恢复。其具体用法如下:feature
分支上执行git stash 或 git stash save
,保存当前分支的工作状态;feature
分支,执行git stash list
,列出保存的所有stash
,执行git stash apply
,恢复最新的stash
到工作区;关于
git stash
还有其它一些值得关注的点:git stash
会恢复所有之前的文件到工作区,也就是说之前添加到缓存区的文件不会再存在于缓存区,使用git stash apply --index
命令,则可以恢复工作区和缓存区与之前一样;git stash
只会储藏已经在索引中的文件。 使用git stash —include-untracked
或git stash -u
命令,Git 才会将任何未跟踪的文件添加到stash
;git stash pop
命令可以用来应用最新的stash
,并立即从stash
栈上扔掉它;git stash —patch
,可触发交互式stash
会提示哪些改动想要储藏、哪些改动需要保存在工作目录中。git stash branch <new branch>
:构建一个名为new branch
的新分支,并将stash中的内容写入该分支说完了
git stash
的基本用法,我们来看看,其在底层的实现原理:上文中我们提到过,Git 操作的是 工作区,缓存区及 HEAD 三棵文件树,我们也知道,
commit
中包含的根tree
对象指向,可以看做文档树的快照。当我们执行
git stash
时,实际上我们就是依据工作区,缓存区及HEAD这三棵文件树分别生成commit
对象,之后以这三个commit 为parent
生成新的commit
对象,代表此次stash
,并把这个 commit 的 hash值存到.git/refs/stash
中。当我们执行
git stash apply
时,就可以依据存在.git/refs/stash
文件中的 commit 对象找到stash
时工作区,缓存区及HEAD这三棵文件树的状态,进而可以恢复其内容。git clean
使用
git clean
命令可以去除冗余文件或者清理工作目录。 使用git clean -f -d
命令可以用来移除工作目录中所有未追踪的文件以及空的子目录。此命令真的会从工作目录中移除未被追踪的文件。 因此如果你改变主意了,不一定能找回来那些文件的内容。 一个更安全的命令是运行
git stash --all
来移除每一项更新,但是可以从stash
栈中找到并恢复它们。。git clean -n
命令可以告诉我们git clean
的结果是什么,如下:所有在不知道
git clean
命令的后果是什么的时候,不要使用-f
,推荐先使用-n
来看看会有什么后果。讲到这里,常用的操作本地仓库的命令就基本上说完了,下面我们看看 Git 提供的一些操作远程仓库的命令。
远程命令
如果我们是中途加入某个项目,往往我们的开发会建立在已有的仓库之上。如果使用
github
或者gitlab
,像已有仓库提交代码的常见工作流是fork
一份主仓库的代码到自己的远程仓库;clone
自己远程仓库代码到本地;git remote add ...
,便于之后保持本地仓库与主仓库同步git pull
;git push
;MR
,待review
通过合并代码到主仓库;这期间涉及很多远程命令,我们接触到的第一个命令很可能是
git clone
,我们先看这个命令做了些什么git clone
git clone
的一般用法为git clone <url>
<url>
部分支持四种协议:本地协议(Local),HTTP 协议,SSH(Secure Shell)协议及 Git 协议。典型的用法如下:git clone
做了以下三件事情objects/
文件夹中的内容到本地仓库; (对应Receiving objects
);Resolving deltas
);.git/refs/remote/xxx/
下;.git/HEAD
文件中存储的内容);git pull
,保证当前分支和工作区与远程分支一致;除此之外,
git
会自动在.git/config
文件中写入部分内容,默认情况下会把clone的源仓库取名
origin
,在.git/config
中存储其对应的地址,本地分支与远程分支的对应规则等。除了
git clone
另一个与远程仓库建立连接的命令为git remote
。git remote
git remote
为我们提供了管理远程仓库的途径。 对远程仓库的管理包括,查看,添加,移除,对远程分支的管理等等。git remote
git remote add <shortname> <url>
git remote rename
git remote rm <name>
本地对远程仓库的记录存在于
.git/config
文件中,在.git/config
中我们可以看到如下格式的内容:[remote] "github"
:代表远程仓库的名称;url
:代表远程仓库的地址fetch
:代表远程仓库与本地仓库的对应规则,这里涉及到另外一个 Git 命令,git fetch
git fetch
我们先看
git fetch
的作用:git fetch <some remote branch>
:同步某个远程分支的改变到本地,会下载本地没有的数据,更新本地数据库,并移动本地对应分支的指向。git fetch --all
会拉取所有的远程分支的更改到本地我们继续看看
git fetch
是如何工作的:fetch
的格式为fetch = +<src>:<dst>
,其中+
号是可选的,用来告诉 Git 即使在不能采用「快速向前合并」也要(强制)更新引用;<src>
代表远程仓库中分支的位置;<dst>
远程分支对应的本地位置。我们来看一个
git fetch
的实例,看看此命令是怎么作用于本地仓库的:git fetch origin
.git/refs/remotes/origin
文件夹;.git/FETCH_HEAD
的特殊文件,其中记录着远程分支所指向的commit
对象;git fetch origin feature-branch
,Git并不会为我们创建一个对应远程分支的本地分支,但是会更新本地对应的远程分支的指向;git checkout feature-branch
,git 会基于记录在.git/FETCH_HEA
中的内容新建本地分支,并在.git/config
中添加如下内容,用以保证本地分支与远程分支future-branch
的一致上述
fetch
的格式也能帮我们理解git push
的一些用法git push
我们在本地某分支开发完成之后,会需要推送到远程仓库,这时候我们会执行如下代码:
git push origin featureBranch:featureBranch
此命令会帮我们在远程建立分支featureBranch
,之所以要这样做的原因也在于上面定义的fetch
模式。 因为引用规格(的格式)是<src>:<dst>
,所以其实会在远程仓库建立分支featureBranch
,从这里我们也可以看出,分支确实是非常轻量级的。此外,如果我们执行
git push origin :topic
:,这里我们把<src>
留空,这意味着把远程版本库的topic
分支定义为空值,也就说会删除对应的远程分支。回到
git push
,我们从资源的角度看看发生了什么?.git/objects/
目录,上传到远程仓库的/objects/
下;refs/heads/master
内容,指向本地最新的commit;.git/refs/remotes/delta/master
内容,指向最新的commit
;说完
git push
,我们再来看看git pull
。git pull
此命令的通用格式为
git pull <remote> <branch>
它做了以下几件事情:git fetch <remote>
:下载最新的内容.git/FETCH_HEAD
找到应该合并到的本地分支;git merge
git pull
在大多数情况下它的含义是一个git fetch
紧接着一个git merge
命令。至此,常用的
git
命令原理我们都基本讲解完了。如果大家有一些其它想要了解的命令,我们可以再一起探讨,补充。一些推荐的 git 资料
Home · geeeeeeeeek/git-recipes Wiki · GitHub gitlet.js git-from-the-inside-out A Hacker’s Guide to Git | Wildly Inaccurate githug