git基本原理和操作

 2024-01-01
 8591字
 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文件夹下,其主要结构如下:

  • HEAD —HEAD指针
  • index —索引
  • 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 .之后的objectsindex

    由于1.txtfoler/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文件。

    撤销可能发生在三个阶段:暂存之前;暂存之后;提交之后。

    1. 暂存之前的修改很好撤销,可以在编辑器中一直ctrl + z,也可以git restore 命令进行恢复。

      基本用法:git restore [--source=<tree-like>] <file_name> ...

      可以一次指定多个文件,--source用于指定要恢复到的版本,默认为HEAD指针指向的版本,<tree-like>可以是:HEAD<branch><commit>(参考下节的引用提交)以及<tree_hash>,它们都可以定位到一个tree文件上从而恢复指定的文件。

    2. 暂存之后的修改可以用git restore --stage选项进行恢复,也可以指定--source--stage不会改动工作区的内容,只是修改了index文件的对应内容,同时index之前追踪的blob文件也不会删除。

    3. 提交之后的撤销涉及对版本库的操作,在下节讨论。

    警告
    git restore命令可以用.代表所有文件,但是一般不建议这么做,除非你确实想重置整个工作区或暂存区。
    注意

    git的操作大多不会删除objects下的文件,所以许多“永久性”的删除/回退操作其实是可以通过指定<commit_hash>或者<tree_like>的方式找回。但是当仓库中文件和提交多了之后,我们几乎无法通过hash值找到那些隐藏的提交,所以可以认为那些操作确实是永久的。

    git实际操作中直接使用hash值的时候少之又少,大多数时候还是通过分支指针来操作,因此实际使用时只需关注commit和分支就够了,不需要太过考虑背后blob和tree。但是明白这些原理可以更透彻的掌握git命令,减少使用错误。

    信息
    由于checkout功能过多容易混淆,git在2.23版本(2019年发布)新增了switchrestore用于替代checkout的部分功能,试图让每个命令的功能尽量纯粹。本文在相关操作中优先使用这两个命令。

    标签

    和分支指针一样,标签也用于标记commit,只不过标签是静态的,不会随提交而改变。标签分为两种:

    1. 轻量标签:git tag <tag_name> [<commit>]
    2. 带注释标签:git tag -a <tag_name> -m <comment> [<commit>]

    第一种只会在refs/tags下创建一个文本文件,用于记录标签指向的commit文件。第二种会在objects下创建tag文件(和commit文件非常类似),refs/tags下对应的文本文件记录该tag文件的hash值。

    <commit>默认为HEAD指针指向的提交。

    版本库操作

    git的核心功能就是版本管理,因此对版本库的操作是重中之重。只要理解了版本库就是许多个commit构成的无向图(这时候就可以忽略tree和blob),然后理清每个命令干了下面哪些事就可以掌握它:

    1. 新建/复制/修改/删除 commit(无向图的顶点)。
    2. parent指针(无向图的边)。
    3. 分支指针(包括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的,因此在后面的命令中,大多数用<commit>占位的地方都可以填入分支名。但是用<branch>占位的不一定可以填入commit。

    HEAD分离

    许多进阶操作需要分离HEAD,可以使用命令git switch <commit> --detach

    注意

    分离HEAD会改变工作区和index

    如果没有及时用分支指针标记分离状态下的修改,在移动HEAD时可能会难以再找到这些修改,实际生产中慎用。

    回退提交

    当提交后发现提交内容有问题想要回退提交,有三种解决方法:

    1. 直接修改工作区,然后再次暂存并提交。这时候上一个提交还在,新提交修正了错误。

    2. 如果觉得方法1没有直接解决问题,可以在方法1中再次提交的时候使用--amend选项,这个时候新提交的parent会直接跳过上一次的提交,指向上上次的提交。这样从分支的角度来看,上一次的错误提交就是不存在的。

    3. 使用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依靠提交的parent指针确定提交链,所以这里不能改动原来的那些commit文件,因此rebase的时候是将这些提交复制了一份,修改其parent字段而其他信息保持不变。

    如图是执行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>]
    技巧
    一个远程仓库可以有多个地址,比如同时与github和gitlab同步,所以使用时一般只设置一个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即可。这个操作会把originmain分支中本地没有的提交和对应文件树下载到本地,同时会创建(第一次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合并(mainrebase到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>默认为当前分支。