从git对象存储模型理解git常用命令
本文是从git对象存储模型出发搞懂git常用命令的原理,主要参考了《Git权威指南》1和网上的一些文章234。
基础知识
.git下对象存储模型
众所周知,一个使用git管理的代码库,其commit历史等信息是存储在隐藏目录.git下面,如果要了解git常用命令的原理从这个文件夹的结构和内容讲起比较合适。
上图1中展示了commit提交是如何保存在.git目录下(图中对实际中40个十六进制commit id、tree id、blob id简化成type_id_n的形式):
git对象库
存储在.git/objects目录下(id的前两位作为目录名,后38位作为文件名)存储,主要是有commit、tree、blob类型组成,其对应的内容如下:
- blob(binary large object)类型: 文件,存储了使用gzip压缩后的文件内容和实际大小。在git管理的文件都以该类型保存,如何使用脚本解压后查看blob文件内容2。同时也说明git存储的是文件的快照而非diff3。
- tree类型: 目录树,存储了*个blob类型的id及对应的文件名权限等,和*个tree类型的id及对应的文件夹名称权限等。其保存了文件目录将blob表示的文件串起来。blob和tree类型有些类似inode和目录文件,文件名等信息实际存储在tree中。
- commit: 提交,存储了tree id和一个或多个parent提交的id及一些commit msg等信息。对于merge消息就会产生多个parent提交id。
以上对象库对象内容及其类型可通过如下命令查看:
$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目录下的文本文件:
- refs/heads/master: 存储了master分支当前的commit id。
- HEAD: 存储了当前checkout的commit id或分支。
git历史提交的表示~和^
git提交列表是一个多叉树结构,其每个commit节点保存有一个或多个父commit节点,对当前commit节点的多个父节点或祖宗节点git有很方便的表示方式,可以用在git checkout
和git reset
等命令中。
- commit_id~: 表示当前提交的父提交,如果有多个父提交选择第一个父提交;可以使用commit_id~~或commit_id~2方式表示父父提交等方式
- commit_id^: 同样表示当前提交的父提交,和~波浪号的区别在于当提交含有多个父提交时可以通过commit_id^2方式表示第二个父提交
commit_id当然可以是分支名或者HEAD,因为其背后都引用某个commit_id,常用的一种是git checkout HEAD^
checkout到当前提交的第一个父提交。
工作区、暂存区、HEAD中的目录树(tree)
git中有三个重要的目录树即: 工作区、暂存区和当前commit(HEAD)中的目录树。
工作区目录树
即为当前文件系统的目录树,不包含未被git跟踪的文件(Untracked files)。
暂存区目录树
存储在.git/index文件内,使用
git commit
之前调用git add
就是增加文件在暂存区目录树中。需要调用git write-tree
可以将暂存区目录树写入并且返回其tree id。可以理解git commit
会先调用git write-tree
写入目录树并将其id保存在commit的tree id。当前commit(HEAD)中的目录树
即当前HEAD文件中保存的commit id或者引用的分支指向的commit id中保存的tree id指向的目录树。
git diff
git diff
命令可以对两个目录树内的文件对比差异,实际工作中我们一般对比工作区、暂存区、HEAD中的目录树两两之间比较差异。
上图2中展示git diff
常用参数:
git diff id_2 id_3
是对id_2和id_3指向的目录树对比git diff
是对暂存区和工作区目录树对比git diff --cache
是对HEAD和暂存区目录树对比git diff HEAD
是对HEAD和工作区目录树对比
git checkout
git checkout
命令会对工作区和暂存区目录树进行检出,即会对工作区和暂存区替换。主要有如下三种用法:
git checkout <commit_id>
checkout最常用的命令就是切换分支(commit_id),如上图3中所示,checkout命令会将暂存区和工作区替换成commit_id所指向的目录树。同时设置HEAD为commit_id。
git checkout -
可以很方便的切回之前的commit(分支),类似于cd -
。git checkout [<commit_id>] [--] <path>
该命令主要是用于将指定commit的目录树中的某些文件覆盖工作区和暂存区的文件,其不会改变HEAD的值,如果省略commit_id则设置为暂存区的目录树。路径前加上–主要是防止路径和引用或者commit id同名造成歧义,如果不冲突则可省略。
该用法最常用的是
git checkout .
将当前工作区替换暂存区的目录树,此时未add进暂存区的修改都会消失!git checkout -b <new_branch> [<commit_id>]
该命令主要用于新建分支(new_branch)即在/refs/heads/下面增加以新分支命名的文件内容保存着commit_id,然后将HEAD指向新分支。
值得注意该命令不会对工作区和暂存区进行替换。如果后边省略commit_id则设置为HEAD。
git reset
git reset
命令可以将当前分支指向另一个指定提交,并且有选择的替换暂存区和工作区,类似checkout可以对单独文件进行操作。主要有以下两种用法:
git reset [--soft|--mixed|--hard] [<commit_id>]
当HEAD中保存的是分支的引用时此用法会替换分支引用保存的commit_id为用户的设置,这就是该命令和checkout的最大区别同样commit_id如果省略则为HEAD,如下图4所示,有三种参数soft/mixed/hard会设置是否替换暂存区和工作区。
git reset --soft id_2
只进行master分支指向commit替换而不修改暂存区和工作区,即只执行了图中的①。对于最新提交的进行修改并且将add进暂存区的修改一并重新提交的命令
git commit --amend
就是如下两个命令的的组合# 将当前分支指向的提交替换为父提交,不替换暂存区和工作区,所以之前add进暂存区的修改还在 $git reset --soft HEAD^ # .git/COMMIT_EDITMSG保存当前提交的commit_msg,此时之前add进暂存区的修改也在提交中 $git commit -e -F .git/COMMIT_EDITMSG
还有常用的一个用法,即将多个提交修改合并成一个提交,可以执行如下命令将当前提交和其父提交压缩成一个提交:
$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。
git reset [<commit_id>] [--] <path>
该命令不会替换分支引用和工作区的目录树,而是用指定提交下的文件<path>替换掉暂存区的文件。注意与git checkout [<commit_id>] [--] <path>
的区别,checkout命令还会覆盖工作区的文件。该命令最常用的用法是git reset filename
将filename文件的改动撤出暂存区,相当于git add filename
的反向操作。
git cherry-pick
该命令特别好理解,就是将一些commit在当前分支提交一次一样的commit。
如上图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
终止。
git rebase
该命令是主要用途就是批量的cherry-pick提交,全量的命令格式是git rebase --onto base from to
,将from到to“范围”内的提交cherry-pick到base上。
上图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操作的过程如下:
- 首先会执行
git checkout to
切换到to分支,需要注意的是rebase之后HEAD引用的分支是to分支上,如果to不是分支,则会出现detached HEAD状态。在上图5中,会checkout到dev分支上,最终rebase作用到的分支也是dev分支。 - 将在to分支所有历史提交中但是不在from分支所有历史提交范围的提交列表保存在临时文件中,在加上-i交互模式参数时可以看到提交的列表。在1中对范围的阐释是:包括to的所有历史提交排出from及from的历史提交。在上图5中,这个范围就是在dev分支,但是不在main分支的提交列表: E、F提交。
- 将当前分支强制重置到base上,相当于执行
git reset --hard base
。在上图5中,就是git reset --hard main
将dev分支指向的提交强制改为main分支指向的提交,即dev分支增加了C、D提交但是少了E、F提交。 - 从保存在临时文件中的提交列表逐一cherry-pick到重置之后的分支上。如果提交已经在分支中包含则跳过该提交。在上图5中,就是将缺少的E、F提交cherry-pick到dev分支上,此时dev上的修改就基于最新的main分支。
- 如果cherry-pick过程中遇到冲突,则rebase暂停,用户解决冲突后执行
git rebase --continue
继续rebase或者git rebase --skip
跳过此提交或者git rebase --abort
终止rebase,所有改动回滚。
rebase小例子🌰
基于不稳定分支上的提交rebase到稳定分支上,例子来源4
如上图6中,想将基于next分支提交的topic分支,变基为基于main分支。例如在topic一些feature基于开发分支next测试完成,想将其基于更稳定的分支main上准入测试,就可以执行
git rebase --onto main next topic
。将本地修改提交到指定历史commit
该例子是我在实际工作遇到: 在本地提交了多个commit,每个commit中包含不同功能。需要修改某个功能的提交,如果直接新建一个commit肯定比较难看,想优雅的直接修改历史commit。当然如果该提交是最近一次提交,可直接使用
git commit --amend
修改,如果不是的话就需要rebase命令了。- 首先将本地的修改
git stash
保存 - 找到要修改的commit id,假设为f744c32。则可在当前分支执行
git rebase -i f744c32^
即git rebase -i --onto f744c32^ f744c32^ HEAD
。在编辑器中找到需要修改的commit id,将前面的pick修改为edit。 - 此时rebase就会在需要修改的commit上暂停,可以执行
git stash pop
将需要的修改pop出来,然后git add
等命令将修改添加进暂存区,最后执行git commit --amend
将修改保存到当前提交中,最终执行git rebase --continue
继续rebase命令。
- 首先将本地的修改
参考
画图有参考图解git