从git对象存储模型理解git常用命令

本文是从git对象存储模型出发搞懂git常用命令的原理,主要参考了《Git权威指南》1和网上的一些文章234

众所周知,一个使用git管理的代码库,其commit历史等信息是存储在隐藏目录.git下面,如果要了解git常用命令的原理从这个文件夹的结构和内容讲起比较合适。

git files
1. git对象存储模型

上图1中展示了commit提交是如何保存在.git目录下(图中对实际中40个十六进制commit id、tree id、blob id简化成type_id_n的形式):

  • git对象库

    存储在.git/objects目录下(id的前两位作为目录名,后38位作为文件名)存储,主要是有commit、tree、blob类型组成,其对应的内容如下:

    1. blob(binary large object)类型: 文件,存储了使用gzip压缩后的文件内容和实际大小。在git管理的文件都以该类型保存,如何使用脚本解压后查看blob文件内容2。同时也说明git存储的是文件的快照而非diff3
    2. tree类型: 目录树,存储了*个blob类型的id及对应的文件名权限等,和*个tree类型的id及对应的文件夹名称权限等。其保存了文件目录将blob表示的文件串起来。blob和tree类型有些类似inode和目录文件,文件名等信息实际存储在tree中。
    3. commit: 提交,存储了tree id和一个或多个parent提交的id及一些commit msg等信息。对于merge消息就会产生多个parent提交id。

    以上对象库对象内容及其类型可通过如下命令查看:

    shell

    $git cat-file -t 7c16725a3529a00feecfb81ecb6ecedaef2fa60c # 查看id类型
      commit
    $git cat-file -p 7c16725a3529a00feecfb81ecb6ecedaef2fa60c # 查看id内容
      tree 0e9755249e11e845e99ffd46f43056e869eb50e7
      parent 6fd19369e33bb37bdf1fb06debe44d634b287dd1
      parent ad7b317c479c01e593593b573752e4ac9b1509fd
      author ImportMengjie <mengjie@hotmail.com> 1696338677 +0800
      committer ImportMengjie <mengjie@hotmail.com> 1696338677 +0800
    
      Merge branch 'bB'
  • HEAD和refs/heads/master

    实际都是存储在.git目录下的文本文件:

    1. refs/heads/master: 存储了master分支当前的commit id。
    2. HEAD: 存储了当前checkout的commit id或分支。

git提交列表是一个多叉树结构,其每个commit节点保存有一个或多个父commit节点,对当前commit节点的多个父节点或祖宗节点git有很方便的表示方式,可以用在git checkoutgit reset等命令中。

  • commit_id~: 表示当前提交的父提交,如果有多个父提交选择第一个父提交;可以使用commit_id~~或commit_id~2方式表示父父提交等方式
  • commit_id^: 同样表示当前提交的父提交,和~波浪号的区别在于当提交含有多个父提交时可以通过commit_id^2方式表示第二个父提交

commit_id当然可以是分支名或者HEAD,因为其背后都引用某个commit_id,常用的一种是git checkout HEAD^checkout到当前提交的第一个父提交。

git中有三个重要的目录树即: 工作区、暂存区和当前commit(HEAD)中的目录树。

  1. 工作区目录树

    即为当前文件系统的目录树,不包含未被git跟踪的文件(Untracked files)。

  2. 暂存区目录树

    存储在.git/index文件内,使用git commit之前调用git add就是增加文件在暂存区目录树中。需要调用git write-tree可以将暂存区目录树写入并且返回其tree id。可以理解git commit会先调用git write-tree写入目录树并将其id保存在commit的tree id。

  3. 当前commit(HEAD)中的目录树

    即当前HEAD文件中保存的commit id或者引用的分支指向的commit id中保存的tree id指向的目录树。

git diff命令可以对两个目录树内的文件对比差异,实际工作中我们一般对比工作区、暂存区、HEAD中的目录树两两之间比较差异。

git diff
2. git diff命令

上图2中展示git diff常用参数:

  1. git diff id_2 id_3是对id_2和id_3指向的目录树对比
  2. git diff是对暂存区和工作区目录树对比
  3. git diff --cache是对HEAD和暂存区目录树对比
  4. git diff HEAD是对HEAD和工作区目录树对比

git checkout命令会对工作区和暂存区目录树进行检出,即会对工作区和暂存区替换。主要有如下三种用法:

  1. git checkout <commit_id>

    git diff
    3. git checkout commit_id命令

    checkout最常用的命令就是切换分支(commit_id),如上图3中所示,checkout命令会将暂存区和工作区替换成commit_id所指向的目录树。同时设置HEAD为commit_id。

    git checkout -可以很方便的切回之前的commit(分支),类似于cd -

  2. git checkout [<commit_id>] [--] <path>

    该命令主要是用于将指定commit的目录树中的某些文件覆盖工作区和暂存区的文件,其不会改变HEAD的值,如果省略commit_id则设置为暂存区的目录树。路径前加上–主要是防止路径和引用或者commit id同名造成歧义,如果不冲突则可省略。

    该用法最常用的是git checkout .将当前工作区替换暂存区的目录树,此时未add进暂存区的修改都会消失!

  3. git checkout -b <new_branch> [<commit_id>]

    该命令主要用于新建分支(new_branch)即在/refs/heads/下面增加以新分支命名的文件内容保存着commit_id,然后将HEAD指向新分支。

    值得注意该命令不会对工作区和暂存区进行替换。如果后边省略commit_id则设置为HEAD。

git reset命令可以将当前分支指向另一个指定提交,并且有选择的替换暂存区和工作区,类似checkout可以对单独文件进行操作。主要有以下两种用法:

  1. git reset [--soft|--mixed|--hard] [<commit_id>]

    当HEAD中保存的是分支的引用时此用法会替换分支引用保存的commit_id为用户的设置,这就是该命令和checkout的最大区别同样commit_id如果省略则为HEAD,如下图4所示,有三种参数soft/mixed/hard会设置是否替换暂存区和工作区。

    git reset
    4. git reset [--soft|--mixed|--hard] commit_id命令

    • git reset --soft id_2只进行master分支指向commit替换而不修改暂存区和工作区,即只执行了图中的①。

      对于最新提交的进行修改并且将add进暂存区的修改一并重新提交的命令git commit --amend就是如下两个命令的的组合

      shell

      # 将当前分支指向的提交替换为父提交,不替换暂存区和工作区,所以之前add进暂存区的修改还在
      $git reset --soft HEAD^ 
      # .git/COMMIT_EDITMSG保存当前提交的commit_msg,此时之前add进暂存区的修改也在提交中
      $git commit -e -F .git/COMMIT_EDITMSG

      还有常用的一个用法,即将多个提交修改合并成一个提交,可以执行如下命令将当前提交和其父提交压缩成一个提交:

      shell

      $git reset --soft HEAD~2
      $git commit -m "compression commit"
    • git reset [--mixed] id_2在–soft的基础上还对暂存区进行替换,即图中①和②。默认行为,–mixed可以省略。最常用的命令是将add进暂存区的修改都重置,即git reset,替换暂存区为HEAD指向的目录树,之前add进暂存区的修改都会被重置,而分支的引用替换到HEAD相当于没有替换分支引用。

    • git reset --hard id_2在–mixed基础上还进行工作区的替换,即图中①、②和③。由于会将工作区进行替换是个比较危险的用法。可以执行类似git reset --hard HEAD^撤销当前commit。

  2. git reset [<commit_id>] [--] <path>

该命令不会替换分支引用和工作区的目录树,而是用指定提交下的文件<path>替换掉暂存区的文件。注意与git checkout [<commit_id>] [--] <path>的区别,checkout命令还会覆盖工作区的文件。该命令最常用的用法是git reset filename将filename文件的改动撤出暂存区,相当于git add filename的反向操作。

该命令特别好理解,就是将一些commit在当前分支提交一次一样的commit。

git cherry-pick
4. git cherry-pick

如上图4,当前在main分支,执行git cherry-pick F,F为commit_id,会在main分支提交一次一样的commit,但是commit id会不同。可以一次cherry-pick多个commit,以空格分割。当然cherry-pick会将暂存区和工作区替换成pick之后的提交

如果遇到冲突需要用户解决冲突后执行git cherry-pick --continue或者git cherry-pick --abort终止。

该命令是主要用途就是批量的cherry-pick提交,全量的命令格式是git rebase --onto base from to,将from到to“范围”内的提交cherry-pick到base上。

git rebase
5. git rebase main

上图5是很常用的rebase用法,可以取代merge命令将main分支的新增修改带入到dev分支中并且保持提交线性。git rebase --onto dev main dev命令在当前分支是dev时可以省略为git rebase main就和git merge main类似了。即git rebase from会被展开为git rebase --onto from from HEAD

结合上图5的命令git rebase --onto main main dev,rebase操作的过程如下:

  1. 首先会执行git checkout to切换到to分支,需要注意的是rebase之后HEAD引用的分支是to分支上,如果to不是分支,则会出现detached HEAD状态。在上图5中,会checkout到dev分支上,最终rebase作用到的分支也是dev分支。
  2. 在to分支所有历史提交中但是不在from分支所有历史提交范围的提交列表保存在临时文件中,在加上-i交互模式参数时可以看到提交的列表。在1中对范围的阐释是:包括to的所有历史提交排出from及from的历史提交。在上图5中,这个范围就是在dev分支,但是不在main分支的提交列表: E、F提交。
  3. 将当前分支强制重置到base上,相当于执行git reset --hard base。在上图5中,就是git reset --hard main将dev分支指向的提交强制改为main分支指向的提交,即dev分支增加了C、D提交但是少了E、F提交。
  4. 从保存在临时文件中的提交列表逐一cherry-pick到重置之后的分支上。如果提交已经在分支中包含则跳过该提交。在上图5中,就是将缺少的E、F提交cherry-pick到dev分支上,此时dev上的修改就基于最新的main分支。
  5. 如果cherry-pick过程中遇到冲突,则rebase暂停,用户解决冲突后执行git rebase --continue继续rebase或者git rebase --skip跳过此提交或者git rebase --abort终止rebase,所有改动回滚。
  • 基于不稳定分支上的提交rebase到稳定分支上,例子来源4

    git rebase
    6. git rebase 一般化

    如上图6中,想将基于next分支提交的topic分支,变基为基于main分支。例如在topic一些feature基于开发分支next测试完成,想将其基于更稳定的分支main上准入测试,就可以执行git rebase --onto main next topic

  • 将本地修改提交到指定历史commit

    该例子是我在实际工作遇到: 在本地提交了多个commit,每个commit中包含不同功能。需要修改某个功能的提交,如果直接新建一个commit肯定比较难看,想优雅的直接修改历史commit。当然如果该提交是最近一次提交,可直接使用git commit --amend修改,如果不是的话就需要rebase命令了。

    1. 首先将本地的修改git stash保存
    2. 找到要修改的commit id,假设为f744c32。则可在当前分支执行git rebase -i f744c32^git rebase -i --onto f744c32^ f744c32^ HEAD。在编辑器中找到需要修改的commit id,将前面的pick修改为edit。
    3. 此时rebase就会在需要修改的commit上暂停,可以执行git stash pop将需要的修改pop出来,然后git add等命令将修改添加进暂存区,最后执行git commit --amend将修改保存到当前提交中,最终执行git rebase --continue继续rebase命令。

画图有参考图解git