git基本原理和操作
基本原理
git最常用的使用场景就是git add
添加文件到暂存区,然后用git commit
提交到版本库。许多文章都会用类似下面的图来阐述这个过程:
这种理解对于git的基本使用很有帮助,但是想要更熟练的使用git或者规避一些使用误区,对git的理解仅停留在这种抽象层面是不够的。
基本原理这一节先梳理版本的逻辑结构,然后从文件的角度剖析暂存区和版本库究竟是什么。
版本库逻辑结构
版本库的核心是提交(commit)构成的有向无环图。提交是有向图的顶点,提交的parent指针是有向图的边。每个提交都对应一个工作区快照(工作区就是在文件系统层面看到的文件夹/目录),快照可以恢复成完整的工作区,所以每一个提交都对应工作区的一个“版本”,Commit History也被称为版本库。
下图是一个版本库示例:
C1的parent指针指向C0,意味着C1是在C0的基础上进行了更改。有一些具有多个parent指针的提交如C9、C11等,则是由合并(merge)操作产生的(修改文件的时候只能在某一个工作区上进行修改)。
提交散落在版本库中,难以定位和管理,所以git使用打标记的方式管理提交。标记有两种:分支(branch)和标签(tag)。标签和提交绑定之后,一般情况下就不会修改了,除非手动强制修改;而分支在大多数情况下会自动指向新的提交。也就是说标签用来静态地标记提交,分支则用来动态地“追更”。
为什么要叫做分支呢?因为从每个提交开始沿parent指针回溯,都可以形成一条或多条最终到达第一个提交的路径,这个路径是逆向回溯的,如果从正向看,那就像树枝从树干上长出来一样,所以形象的叫做分支。
图中main分支后面的*表示这是当前所在的分支。
计算机中没有无根之萍,这个“当前”是需要某个东西来记录的。什么东西呢?一个特殊的标记,叫做HEAD。大部分操作都是相对于HEAD完成的。例如git commit
提交一个新的提交,那么这个新提交的parent指针就会指向HEAD指向的提交,并且马上将HEAD指向这个新的提交,这样就完成了一次更新操作。
在上图中,HEAD和main是绑定的,main会随着HEAD一起修改(同样的HEAD也会随main修改),这样当进行新提交时,main这条分支就跟着更新了。
但是要注意,HEAD是可以分离的。在分离状态下,HEAD没有绑定任何分支,在这个状态下新的提交没有一个稳定的标记来跟踪它,当HEAD指向其它的提交时,这些新的提交就很难被找到了。
o/main
表示的是远程指针,现在可以先忽略它。理解本地版本库的操作之后,远程管理自然水到渠成。
数据模型
这一节要讨论的是文件夹快照和暂存库的底层表示。
git所有的数据都保存在git仓库下的.git
文件夹下,其主要结构如下:
objects
下面有三种文件:commit、tree和blob。commit文件就是提交这个概念所对应的实体;tree和blob实现了一个面向内容的树形文件系统。blob对应文件,但是它只记录文件内容,不关心文件名,不同文件名但是内容相同的文件对应一个blob;tree对应文件夹,它记录了文件夹的名字和这个文件夹下包含文件夹(tree)和文件(blob)。如下图:
tree中记录有哪些blob的时候,记录的是<file_name> - <blob>
键值对,这样只要给出一个tree就可以完整的还原一个文件夹(包括子文件夹),也就是前面提到的文件夹快照的概念。
git add
命令会将文件添加到“暂存区”。在这个过程中git会生成新文件或者有改动文件对应的blob,并保存到objects
下,同时更新index
文件的对应内容。
index
文件记录了文件的文件类型与权限、文件内容hash值(可以据此找到对应的blob)、暂存状态、路径(相对于当前仓库根目录的相对路径)。
git commit
命令根据index
的内容生成对应的tree文件和commit文件并保存到objects
下,这样就完成了一次提交操作。注意,git commit
会忽略那些
上节提交与分支说的是提交与提交之间的关系,这里说的是提交如何记录整个文件夹的快照的。
blob是全量存储而非增量存储的,每次增加了一个内容和已有blob都不同的文件时,就会生成一个新的blob。这样做的好处是根据commit对应的tree就可以快速恢复该版本的文件,不需要回溯前序commit进行增量计算。当然坏处就是会占用更多的存储空间,但是git仓库的本意就是用来管理代码文件的版本,不向版本库中提交体积较大的数据文件是使用git的基本共识,因此使用较小的额外空间换取更灵活的特性(比如可以任意修改提交顺序以保持提交链整洁)是利大于弊的。
这个“增加”操作是什么呢?git add
:没错正是在下。
commit、tree和blob都有一个根据其内容计算出的40位16进制hash值(SHA-1算法),并以该hash作为标识来存储,前两位作为文件夹名字,后面的作为文件名:
-
- dcd6ddaf8cb520c533c10a046526c086c21e60
- ec0dd7ff52fc32ce971bb59d9172a26ab2ea80
-
- 3daa46f04f51f1da103130bfc2eba4e80ae602
可以使用命令git cat-file -t <hash_string>
来查看objects
内的文件的类型(commit,tree或blob),也可以把-t
改为-p
来查看文件内容。
<hash_string>
不需要完整输入,只需要可以在objects
中唯一标识一个文件的长度即可,一般来说4位就足够了,例如git cat-file -t 1fec
。
由于hash值是commit、tree和blob的唯一标识,所以记录分支的标签的时候,只需要在refs
文件内建立对应的文本文件,内容是commit的hash值即可。例如:
-
- main
-
main
文件中的内容为1fdcd6ddaf8cb520c533c10a046526c086c21e60
即可。
HEAD
指针也是一个文本文件,在分离状态下,文件内容为commit的hash值;切换到某个分支上时,内容就是该分支对应的文件的路径,这样当然是“绑定”的,例如切换到main
分支上时,HEAD文件的内容是:ref: refs/heads/main
。
暂存、提交与撤销
下图展示了创建橙色框线内文件再git add .
之后的objects
与index
:
由于1.txt
与foler/11.txt
的内容相同,所以objects
中只有两个blob文件,同时index
追踪了所有的文件。所谓暂存区,就是index
文件及其指向的blob文件。
使用git add <file_name> ...
可以指定要添加到暂存区的文件,git add .
和git add -A|--all
均表示把工作区所有文件添加到暂存区。
只有添加到暂存区的文件,才会创建对应的blob文件(或复用之前的blob文件),index
才会追踪该文件。
添加到暂存区之后,并没有新增相应的tree文件,tree文件会在提交时创建。
下图展示了继续执行git commit -m "first commit"
之后的情况:
可以看到,objects
新增了一个commit文件和两个tree文件。commit文件主要有三个部分:指向的tree文件;作者和提交者信息;提交的注释。tree文件的内容也符合直觉。橙色框线代表工作区的根文件夹,绿色框线代表folder
文件夹。HEAD
文件和refs/heads/main
的内容也符合预期。
parent
字段,除了第一个提交之外,其它提交均有一个或多个parent
字段。git commit -am "<commmit_comment>"
将所有已被index
追踪的文件保存到暂存区并提交。下面修改1.txt
,新增一行111,然后执行git add 1.txt
:
objects
下新增了一个内容为两行111的blob文件,同时index中的1.txt
指向了这个blob。这样,暂存区的内容就发生了修改。可以预见,如果此时进行提交,会新增一个commit文件和一个tree文件,构成一个新的文件快照。
foler
文件夹对应的tree文件不需要新建,只需要新建一个根文件夹tree文件,指向foler
对应的tree文件和2bbe
这个新的blob文件即可。
如果提交时某文件有修改但不在暂存区内,提交时是不会更新该文件的,因为objects
中没有新的blob文件,index
内对应条目也没发生改变,而提交时git只关注index
以及它追踪的那些blob文件。
撤销可能发生在三个阶段:暂存之前;暂存之后;提交之后。
-
暂存之前的修改很好撤销,可以在编辑器中一直
ctrl + z
,也可以git restore
命令进行恢复。基本用法:
git restore [--source=<tree-like>] <file_name> ...
可以一次指定多个文件,
--source
用于指定要恢复到的版本,默认为HEAD
指针指向的版本,<tree-like>
可以是:HEAD
、<branch>
、<commit>
(参考下节的引用提交)以及<tree_hash>
,它们都可以定位到一个tree文件上从而恢复指定的文件。 -
暂存之后的修改可以用
git restore --stage
选项进行恢复,也可以指定--source
。--stage
不会改动工作区的内容,只是修改了index
文件的对应内容,同时index
之前追踪的blob文件也不会删除。 -
提交之后的撤销涉及对版本库的操作,在下节讨论。
git restore
命令可以用.
代表所有文件,但是一般不建议这么做,除非你确实想重置整个工作区或暂存区。git的操作大多不会删除objects
下的文件,所以许多“永久性”的删除/回退操作其实是可以通过指定<commit_hash>
或者<tree_like>
的方式找回。但是当仓库中文件和提交多了之后,我们几乎无法通过hash值找到那些隐藏的提交,所以可以认为那些操作确实是永久的。
git实际操作中直接使用hash值的时候少之又少,大多数时候还是通过分支指针来操作,因此实际使用时只需关注commit和分支就够了,不需要太过考虑背后blob和tree。但是明白这些原理可以更透彻的掌握git命令,减少使用错误。
checkout
功能过多容易混淆,git在2.23版本(2019年发布)新增了switch
和restore
用于替代checkout
的部分功能,试图让每个命令的功能尽量纯粹。本文在相关操作中优先使用这两个命令。标签
和分支指针一样,标签也用于标记commit
,只不过标签是静态的,不会随提交而改变。标签分为两种:
- 轻量标签:
git tag <tag_name> [<commit>]
- 带注释标签:
git tag -a <tag_name> -m <comment> [<commit>]
第一种只会在refs/tags
下创建一个文本文件,用于记录标签指向的commit文件。第二种会在objects
下创建tag文件(和commit文件非常类似),refs/tags
下对应的文本文件记录该tag文件的hash值。
<commit>
默认为HEAD
指针指向的提交。
版本库操作
git的核心功能就是版本管理,因此对版本库的操作是重中之重。只要理解了版本库就是许多个commit构成的无向图(这时候就可以忽略tree和blob),然后理清每个命令干了下面哪些事就可以掌握它:
- 新建/复制/修改/删除 commit(无向图的顶点)。
- parent指针(无向图的边)。
- 分支指针(包括
HEAD
指针)。
引用提交
许多操作需要精确的定位特定的提交,因此要先了解一下如何引用提交。引用提交分为绝对引用和相对引用。
绝对引用是指使用分支指针或提交hash值进行引用。
在本节中,hash值简单地用C0 C1
这样的形式代替。
即使是这样,使用hash值引用也非常麻烦,需要先用git log
命令查看提交的hash值。
更常用的方式是相对引用,相对引用就是通过parent指针定位的过程,也分为两种:^
和~
。
^
表示父提交,使用方式为<commit>^
,其中<commit>
可以是提交hash值、分支指针或者HEAD
指针。例如HEAD^
表示HEAD
指向的提交的父提交,如果存在多个父提交,默认为第一个父提交,可以在^
后加数字指定具体的父提交。可以使用git log --pretty=%P -n 1 <commit>
查看父提交的顺序。
^
可以连续使用,例如HEAD^^3^^
。
~
表示沿parent指针回溯,使用方式为<commit>~<n>
,其中<n>
表示回溯的步数,可以省略。当省略<n>
时默认回退一步,效果和^
相同。如果回溯过程中遇到多个父提交,默认选择第一个且无法指定其它父提交。
^
和~
可以混用和连用,例如HEAD^2~3
。
<commit>
占位的地方都可以填入分支名。但是用<branch>
占位的不一定可以填入commit。HEAD分离
许多进阶操作需要分离HEAD
,可以使用命令git switch <commit> --detach
。
分离HEAD
会改变工作区和index
。
如果没有及时用分支指针标记分离状态下的修改,在移动HEAD
时可能会难以再找到这些修改,实际生产中慎用。
回退提交
当提交后发现提交内容有问题想要回退提交,有三种解决方法:
-
直接修改工作区,然后再次暂存并提交。这时候上一个提交还在,新提交修正了错误。
-
如果觉得方法1没有直接解决问题,可以在方法1中再次提交的时候使用
--amend
选项,这个时候新提交的parent会直接跳过上一次的提交,指向上上次的提交。这样从分支的角度来看,上一次的错误提交就是不存在的。 -
使用
git reset [options] [<commit>]
。这个命令的真实作用是移动
HEAD
指针到<commit>
(如果是绑定状态,分支指针也会移动),由于分支指针只能靠提交的parent指针(单链表)进行回溯,如果分支指针移动到了当前提交的上一个提交,那么当前提交就不会出现在分支的回溯链中,也就达到了“撤销”当前提交的效果。options
则是指定在移动指针时工作区和暂存区的变化:--soft
:不改动任何内容。--mixed
:默认选项。重置index
到<commit>
,不改动工作区。--hard
:重置index
和工作区到<commit>
。
--amend
或者reset
再提交之后,版本库的状态都如下图(作为对比的是方法1的状态):
分支基础
-
列出分支:
git branch [--list] [-r|-a]
默认列出本地分支;
-r
:列出远程分支;-a
:列出所有分支。--list
均可省略。 -
新建分支:
git branch <new_banch> [<branch>]
在
<branch>
(默认为HEAD
)处创建一个新分支<new_branch>
。 -
删除分支:
git branch -d <branch_name>
-
切换分支:
git switch <branch_name>
可以在切换时直接创建一个分支:
git switch -c <new_branch> <commit>
分支合并
分支合并是非常常用的功能,可以使用merge或者rebase合并,merge合并可能会使分支产生分叉,rebase则可以使分支保持线性。
merge合并
基本用法:git merge <commit>
,将<commit>
对应的提交树合并到当前分支上。前面提到过,HEAD
其实是一个特殊的分支,所以合并时HEAD
是可以处于分离状态的(可以但没必要)。
下图展示了git merge main
后的状态:
merge合并时会新建一个提交,这个提交有两个parent指针,分别指向合并前的提交,然后将HEAD
移动到这个新提交上,上图处于绑定状态,所以test
分支也随着更新。
但是不是所有的merge合并都会新建提交,当两个分支是父子关系时,git会启用Fast-Forward方式合并,此时只会移动分支指针。下面是继续执行git switch main; git merge test
后的状态:
除了两两合并之外,git还可以采取Octopus方式一次性合并多个分支。但是如果有谁在生产环境中用这个,请打死。
rebase合并
merge合并方式可能造成分支分叉,会使分支会显得混乱,rebase则可以保持分支是一条单链。
基本用法:git rebase <commit>
将提交链从<commit>
开始向后延伸,git会自动判断需要将哪些提交放到提交链上。
如图是执行git rebase main
的状态:
除了这种理想情况之外,更常见的情形可能是test
分支本身的提交链已经有分叉了,这时候执行git rebase main
后的状态是这样的:
C4
这个专门用来合并的提交没有被复制,不知道为什么要这么设计,有空再研究。
自定义提交链
虽然rebase可以保持提交记录是线性的,但是开发分支往往有很多关于琐碎细节的提交,主分支不需要这些细节,只需要保留每个稳定版本对应的提交就够了,这时候就可以选择性的只把开发分支的最后一个提交合并到主分支。
使用命令git cherry-pick dev
即可(注意是在main
分支上执行),效果如图:
这样就可以保持主分支的简洁。
移动分支
前面说到的回退提交的原理其实就是移动了分支指针,让“被回退”的提交不出现在分支指针的回溯链上。那如果不想改动现在的工作区,又想移动其它的分支呢?没错,用git branch -f <branch> <commit>
就可以了。强制创建一个指向<commit>
的分支覆盖掉原来的分支,从而达到移动的效果。
远程仓库
使用远程仓库就是从远程仓库拉取分支或者将分支推送到远程仓库。操作逻辑如下图:
<r>/<b>
是在.git/refs/remotes
中记录的特殊的本地分支,<r>
表示远程仓库以<r>
这个名字关联到本地仓库上,<b>
表示分支名。
按照这个逻辑,使用远程仓库其实本地的分支<b>
和本地的<r>/<b>
分支进行交互,本质上和操作两个本地分支没有区别。
远程仓库管理
前面提到的远程仓库需要进行添加:git remote add <remote_name> <remote_addr>
。如果是使用git clone
克隆的仓库,会自动设置远程仓库,默认<remote_name>
为origin
。
git clone <remote_addr> [<folder_name>]
。文件夹名可以任意指定,仓库和文件夹名无关。远程仓库重命名与移除:git rename <old_name> <new_name>
,git remove <remote_name>
。
信息查看:
- 列出所有remote:
git remote
- 查看远程地址:
git get-url <remote_name>
修改远程地址():
- 添加:
git remote set-url --add <remote_name> <remote_addr>
- 删除:
git remote set-url --delete <remote_name> <remote_addr>
- 重新设置:
git remote set-url <remote_name> <remote_addr> [<old_addr>]
origin
远程仓库,视情况添加多个地址即可。远程分支管理
关联远程分支:git branch -u <remote_name> <remote_branch> [<local_branch>]
<local_branch>
默认为当前分支。关联之前需要fetch对应的分支到本地。
如果本地没有对应的分支,可以使用git siwtch <remote_branch>
,会创建一个新的同名本地分支并自动关联。同样需要对应的远程分支已fetch到本地。
克隆的仓库会自动关联主分支。
取消关联:git branch --unset-upstream [<local_branch>]
。这将取消该本地分支与其远程的关联,如果不指定具体分支,将取消所有分支的远程关联。
创建远程分支:git push <remote_name> <local_branch>:<remote_branch>
。
删除远程分支:推荐先取消本地分支与该远程分支的关联,然后执行git push <remote_name> --delete <remote_branch>
。
拉取分支
拉取分支非常简单,假设有一个远程仓库origin
,要拉取它的main
分支,只需要执行git fetch origin main
即可。这个操作会把origin
上main
分支中本地没有的提交和对应文件树下载到本地,同时会创建(第一次fetch)或更新refs/remotes/origin/main
文件,这样本地就新增或者更新了一个名为origin/main
的分支,这个分支就代表了远程仓库origin
上的main
分支。
在当前处于本地分支main
,且本地分支main
和远程分支main
已经关联的情况下,可以使用git pull origin
,fetch之后马上merge,即git fetch origin main; git merge origin/main
。如果想使用rebase合并(main
rebase到origin/main
上),可以使用git pull origin --rebase
。
git fetch origin
会拉取所有的远程分支到本地。有事没事git fetch
一下是个好习惯。推送分支
命令:git push <remote_name> [<local_branch>]
。其实就是先更新<local_branch>
对应的本地的<remote_name>/<branch_name>
,再上传到远程仓库。<local_branch>
默认为当前分支。