Git的常用操作及原理浅析Git的常用操作及原理浅析 基础概念 三个分区 git中有三个分区,工作区,版本库,暂存区。我
Git的常用操作及原理浅析
基础概念
三个分区
git中有三个分区,工作区,版本库,暂存区。我们可以把一个git托管的项目成两部分:
- .git目录中保存了工程所有的commit节点和分支信息,我们把这部分叫做版本库
- 其余部分是项目中的文件,我们将其叫做工作区,我们日常开发都是修改这些文件
版本库中有一个暂存区,它会保存当前被修改的文件,我们平时调用 git add .
就是将工作区中修改的文件同步到暂存区,紧接着我们会调用 git commit
将暂存区的文件记录到当前分支中,他们之间的关系如下:
commit
git是通过commit来记录工程的每一个历史的版本,我们可以通过切换commit来回退到任意一个版本,看上去很神奇,那commit的本质是什么呢? 实际上,git会为每个文件创建一个blob对象(本质上也是一个文件),blob中保存着这个文件的二进制数据,通过SHA1算法计算出二进制数据的hash值作为该blob对象的索引(blob对象的文件名就是这个hash值)
当我们运行git commit -m "xxx"
时,如果有文件发生改变,就会创建新的blob对象,老版本的blob对象也会保留
接着,git还会创建一个commit对象,记录工作区中所有文件对应的blob对象。commit实际保存的是一串blob对象的索引,以此间接持有blob对象,而blob对象中保存着原始文件的数据,因此,我们就可以通过commit对象还原出工程中所有的文件。commit也会将其记录的内容用SHA1计算出hash值,作为索引
commit除了保存blob对象的索引,还保存了上一个commit的索引,这样就形成了一个类似链表的结构,我们可以追溯到当前commit之上的任意一个版本。除此之外,commit对象还保存了提交信息,作者信息等数据
这里只是大致阐述了下原理,实际情况要稍微复杂些,具体可以参考:GIT对象模型
branch
branch是git的主要功能之一,方便团队管理项目,并行开发不同的需求。 git允许我们随意的创建分支,并能够快速的在分支间切换,仿佛没有任何性能开销。实际上,我们可以将branch看做是一个指针,指向某一个commit
当我们创建新的commit时,branch会自动指向新建的commit
本文的git操作演示是基于学习 Git 分支
通过上文我们了解到,commit是一个类似链表的结构,因此我们可以把branch回退到任意一个历史版本
多个commit可能会有同一个父节点(例如基于develop分支创建多个feature分支协同开发),一个commit也可能会有多个父节点(通常是因为用merge合并分支),所以当多个分支交织在一起时,commit构成了一个网状结构,而不是一条链。这里需要注意,网状结构是自下而上的,一个commit只知道他的父节点而不知道它有哪些子节点
HEAD
一个工程中会同时存在多个分支,那么我们是如何知道当前处于哪个分支下呢?在git中,有一个特殊的指针HEAD,它往往指向某一个branch,不同于branch指针,HEAD指针全局只有一个,他最终会指向一个commit,我们当前工作区的文件都来源于这个commit
当我们切换分支时,HEAD指针指向了另一个branch指针,从而间接引用到另一个commit,使工作区的文件发生了变化
HEAD也可以直接指向一个commit,此时的分支处于detach状态,如果创建新的commit则不属于任何一个分支
TAG
当项目要发布时,我们通常会基于当前commit创建一个TAG,用于标记要发布的版本。其实TAG本质上也是一个指针,指向一个commit,TAG内包含TAG名和commit索引
不同于branch指针创建后可以重新指向任何commit,TAG创建后始终指向一个commit,相当于锚定了commit网络中的一个节点
git命令
通常情况下,git命令有一个固定的模式:
git subcommand [options] hash|ref
- 这里的
subcommand
表示子命令,如checkout
,reset
,merge
,rebase
等(我们接下来会提到这些命令) options
是一些可选参数,不同的子命令会有一些独有的参数,提供了一些额外的控制能力。例如git checkout -b dev
,-b
选项意味着先创建dev分支,再把HEAD指向dev,不指定此选项则不会有创建分支的行为- 在命令的末尾,指定要针对哪一个commit进行操作。定位一个commit可以用它的hash值索引(完整的hash值有41位,我们只用前几位就可以),也可以通过branch,HEAD,TAG等指向commit的指针来定位commit,我们可称之为引用
引用有两个特殊的操作符,可以用相对位置的方式来定位历史节点。这里我们以main
分支为例,main
指向commit c2
,
main^
表示c2
的父节点,即commitc1
main~n
表示c2
之上的第n个父节点,例如main~2
是commitc0
常用操作
checkout
通过上文我们了解到,当前工作区中的文件来源于HEAD指向的commit。我们可以通过checkout命令来改变HEAD指向的commit,从而将工作区中的文件回溯到对应的历史版本
当运行git checkout c0
,HEAD会指向hash值为c0
的commit(为了简化我们只取两位hash值作为commit的标识)
当运行git checkout main
,HEAD会指向main分支(图中当HEAD指向某个分支时,会在分支名右侧出现*),这就是git切换分支时执行的操作
当运行git checkout dev^
,HEAD会指向commit c1
当运行git checkout dev~2
,HEAD会指向commit c0
撤销
reset
有时候出于某些原因,我们需要撤销最近的commit修改,从而将工程回退到之前的版本,这时可以用reset命令
reset本质上是让当前分支(HEAD指向的分支)指向其他的commit,当运行git reset c0
,dev分支会指向commit c0,从而让工程回退到c0
的版本
这里我们需要注意,虽然项目回退到了c0
,但这并不意味这c1
,c2
节点被删除,reset仅仅只是改变了branch指向的commit,原来的commit依然存在
revert
reset虽然很好用,但是存在两个问题:
- 我们无法感知到项目发生过回退
- 无法只还原历史中某个commit的改动
针对上述两种情况,我们可以使用revert。当我们想还原commit c0
的改动,运行git revert c0
通过动画演示我们可以看出,revert
本质上做了两件事:
- 自动生成commit
c0’
,用于还原commitc0
中的改动 - 将当前分支指向commig
c0‘
执行完revert命令后,我们能够从提交历史中看出项目曾经发生过回退,这便于我们日后追踪工程文件的变动
合并
merge
在团队协同开发的过程中,我们常常基于一个分支(通常基于develop分支或main分支)来创建新分支,用来开发新功能,不同的人开发不同的模块,最终把所有的分支合并到一起
我们最常见的合并分支命令是merge。举个例子,假设有两个分支main和feat1,他们有一个公共commit节点c1
,且当前分支是main
当执行git merge feat1
merge会创建一个新的commit节点c4
并将main分支指向它。main和feat1有一个公共祖先节点c1
,merge命令会自动比较自c1
之后两个分支的修改,来决定c4
采用哪个commit中的改动
举例来说,假设c1
中有三个文件a.txt
,b.txt
,c.txt
,commit c2
修改了a.txt
,commit c3
修改了a.txt
,b.txt
。merge命令会比较c2
,c3
与其公共祖先节点c1
的差异,发现c.txt
在c2
,c3
中都没有发生过改动,所以在生成的commit c4
内直接复用c1
中的c.txt
。b.txt
在只在c3
中发生了改动,因此c4
会使用c3
中修改的b.txt
。c2
和c3
都修改了a.txt
,因此无法自动决定使用哪个版本,git会让我们手动解决冲突,之后生成新的blob对象来保存合并冲突后的a.txt
,c4
会引用这个新版本的a.txt
这里只列举了merge中最常见的情况,还存在一些较为复杂的场景,merge也提供了一些参数让我们控制合并的策略,具体内容可以参考:git合并原理
rebase
除了merge外,我们还可以用rebase来合并分支。我们继续使用上面的例子,执行git rebase feat1
rebase执行的操作可以拆分三步:
- 查找公共祖先节点(在本例中为
c1
) - 复制当前分支自公共祖先节点之后的所有commit(本例中创建commit
c3'
来复制c3
),把它们移动到要合并的分支的后面(本例中把c3'
的父节点改为c2
) - 将main分支指向最后一个复制的commit(本例中将main分支指向
c3'
)
类似merge,如果两个分支都修改了同一个文件,则需要手动合并冲突,解决冲突后的文件会包含在被复制的那些commit中。执行rebase后,main分支当前commit的父节点由c1
变成了c2
,相当于重新改变了基底,这也是re-base这一名称的含义。同时整个分枝树是一条没有分叉的链,相比较与merge更加简洁,缺点是无法从提交记录中看出分支发生了合并
整理提交记录
cherry pick
有时候出于某些原因,我们需要把其他分支的某几个commit合并到当前分支中,这时可以使用cherry pick指令,当执行git cherry-pick c2 c3
顾名思义,cherry pick让我们像“挑选樱桃”那样选出几个我们想要合并的commit(本例中我们挑选了c2
,c3
),git会复制这些选中的commit(创建c2'
来复制c2
,创建c3'
来复制c3
)并将它们依次移动到当前分支之后(合并前main分支指向c4
,先将c2'
的父节点改为c4
,再将c3'
的父节点改为c2'
),同时将当前分支指向最后一个复制的commit(main分支最终指向c3'
)
交互式rebase
某些情况下,我们想要把多个连续的commit压缩成一个,或者想要调整前几个commit的顺序,像这种修改提交历史的操作,我们可以用交互式rebase来实现。前面我们提到如何用rebase合并分支,交互式rebase就是添加-i
参数,我们通常用它来修改当前分支的提交记录。假设我们想移除倒数第二个commit,运行git rebase -i HEAD~2
注:这里只是用动画的方式来演示交互式rebase,真实的情况需要用VIM编辑器来操作
从演示动画可以看出,rebase复制了我们选择留下的commit(本例中我们选择只留下c2
, git创建c2'
来复制c2
),然后将第一个复制的commit指向公共祖先节点(将c2'
指向c0
),最后将当前分支指向最后一个复制的commit(main
分支最终指向c2'
)
交互式rebase还有很多处理commit的操作:
上面的例子中我们使用了pick和drop命令,常用的还有squash命令,用来把多个commit压缩成一个,这里我们就不一一细说了
查看Tag
我们常常用TAG来标记要发布版本的commit,有时候需要查看最近一个TAG的信息来确认当前的版本号(ci脚本中经常会有这个需求),可以用describe命令来查看距离指定的引用或commit最近的tag信息,返回结构:tag_num_hash,tag是TAG名,num为当前commit节点距离此tag的提交数,hash为当前引用指向的commit节点索引,举例来说:
当运行git describe
会得到v1.0.0_2_gC3
,代表离HEAD最近的TAG是v1.0.0
,HEAD与之相差两个提交且HEAD指向c3
。当运行git describe main
时得到结果v1.0.0_1_gC2
,这里就留给大家思考其含义
远程仓库
在工作中,我们会有一个远程仓库来存放我们的代码,所有开发人员都会clone这个仓库到本地,并在本地修改好代码后再推送到远程仓库。那远程仓库跟本地仓库的关系是怎样的呢?
远端
git中有一个概念叫做远端,它代表与本地仓库关联的远程仓库,我们可以用remote命令添加多个远端,每个远端都有自己的名称,代表一个远程仓库。假设我们的远程仓库有三个分支:main
,feat1
,feat2
,当执行clone命令后,我们会在本地创建一个git仓库(假设左侧为本地仓库,右侧为远程仓库):
在本地仓库中,git会默认创建一个名叫origin
的远端,代表我们复制的远程仓库(这里我们简写为o
)
远程分支和上游分支
当clone完成时,我们的本地仓库实际上有两种分支,远程分支(remote branch,也被叫做remote tracking branch)和本地分支(local branch)。远程分支会关联到远程仓库中的某一个分支,我们把它叫做上游分支(upstream branch),他们之间的关系如下:
远程分支的结构为<remote_name>/<feature_name>
,例如o/main
,意味着它会追踪远端o
的main
分支(即上游分支main
),上游分支main
发生的改动会同步到远程分支main
(下一节会详细讲到)
本地分支可能会关联一个远程分支,例如本地仓库中的main
,它是clone后自动创建,关联远程分支o/main
,如果此时我们checkout到本地分支main
,我们可以感知到它关联了远端o
中的main
分支(即上游分支main
)
这里需要注意,我们可以基于远程分支来创建新的本地分支,但不可以直接在远程分支上创建commit。实际上,如果你运行checkout命令切换到远程分支,会自动进入detach模式(HEAD直接指向commit而不是branch)
这里我们执行了git checkout o/feat1
,HEAD指针直接指向commit c3
,如果此时我们修改代码并执行git commit
,会创建一个新的commit并指向c3
,HEAD会指向这个新的commit,而o/feat1
依然指向c3
这意味着我们无法修改o/feat1
,它对我们来说是“只读”的,因为远程分支与上游分支关联,只有上游分支的改动才会影响到远程分支,维护了本地仓库与远程仓库的一致性
拉取
fetch
远程分支保存在本地仓库,当远程仓库更新时,远程分支并不会自动更新,我们需要使用fetch命令来同步远端的修改,执行git fetch
从演示中可以看出,fetch会先将远程仓库中新增的commit复制到本地仓库,然后将远程分支指向最新的commit,这里将o/main
指向了c3
,使得o/main
与远程仓库中的main
看起来一模一样。这里需要注意,fetch并不会修改本地分支,比如本地仓库中的main
依然指向c1
,如果我们想要更新本地分支需要手动合并远程分支,这看起来很麻烦,我们可以用pull来代替fetch
pull
pull命令可以拆分成两步:
- fetch同步远程仓库
- 将远程分支合并到本地分支
上文我们提到了两种合并分支的方式,merge和rebase,pull允许我们指定用哪一种方式来合并远程分支。假如我们在本地分支创建了commit c2
,远程仓库此时也新增了commit c3
,执行git pull
,效果等同于fetch + merge
执行git pull -r
,效果等同于fetch + rebase
实际上,完整的pull命令如下:
git pull [<options>] [<repository> [<refspec>...]]
options通常用来指定合并分支的行为,例如之前我们使用的-r
,指定用rebase合并
repository指定从哪个远端拉取代码,我们之前的例子从未设置过此参数,因为当前分支是main
关联了远程分支o/main
,所以此时的repository是远端o
refspec的结构是src:dest
,src
告诉我们拉取远端的哪个分支,dest
表示同步到本地仓库的哪个分支。在之前的例子中,当前分支main
关联了远程分支o/main
,o/main
追踪了上游分支main
,因此这里可以省略refspec参数,git会将上游分支main
同步到o/main
。如果当前分支不存在关联的远程分支,我们需要指定refspec参数。举个例子,假设当前分支dev
没有任何关联的远程分支,我们想要将上游分支main
同步到本地分支main
,可以运行git pull origin main:main
从演示中我们看出,git将上游分支main
新增的commit c2
同步到了本地分支main
,并将它合并到当前分支dev
,而远端分支o/main
并没有发生改变。这里要注意,refspect中的dest
分支必须是src
分支的某一个历史版本(就像远程分支和上游分支的关系),否则无法执行同步操作(同步操作类似更新而不是合并)。dest
也不能是当前分支,我们之前提到,pull命令是fetch+合并分支,如果dest
就是当前分支,那么在fetch完成后无法将自己合并到自己,这从逻辑上就说不通
推送
当我们修改了本地分支后,使用push指令将本地的改动推送给远端。假设远程仓库有一个main
分支,本地仓库的main
分支基于远程分支 o/main
,并创建了c2
,c3
两个commit,执行git push
,
在本地仓库中,通过对比本地分支和与之关联的远程分支,计算出都新增了哪些commit,将这些commit同步到远程仓库的上游分支,最后将远程分支指向最新的commit。在本例中,发现本地分支main
新增了c2
,c3
,于是将它们推送到上游分支的main
,最后将o/main
指向了c3
这里有一个问题需要注意,如果本地仓库落后于远程仓库(即远程仓库有了新的改动,但本地仓库中的远程分支并没有同步),这时候执行push会操作失败,git警告我们要先同步远程仓库的改动,这个时候我们要先使用pull来拉取远程仓库的改动,然后才能执行push
举个例子,假设在本地仓库中,远程分支o/main
指向c1
,main
基于o/main
创造了commit c2
,这时远程仓库中的main
新增了commit c3
,我们要先执行git pull --rebase
(rebase会让你的分支树看起来更整洁),这时本地分支main
看起来像是在远程分支main
之上添加了commit c2
,现在我们可以执行git push
将c2
同步到远程仓库的main
分支中
push命令的结构类似pull,假如当前分支有关联的远程分支,可以省略repository和refspec(之前例子里,main
分支关联了o/main
),否则需要指定这两个参数。举个例子,假设当前分支是feat1
,它并没有关联任何远程分支,我们要将它推送到远端的dev
,可以执行git push origin feat1:dev
从演示动画可以看出,git将本地分支feat1
指向的commit c3
同步到了上游分支dev
,然后更新远程分支o/dev
,让其指向c3
如果我们要推送的本地分支并没有关联的上游分支,git会自动创建相应的上游分支和远程分支,并关联被推送的本地分支。举例来说,假如当前分支feat
没有关联任何远程分支且远程仓库中也不存在名叫feat
的分支,执行git push
git自动创建了上游分支feat
和远程分支o/feat
,并将本地分支关联了o/feat
关联远程分支
从前两个小节中我们可以发现,本地分支如果有关联的远程分支,在执行pull或push的时候会很方便。我们可以创建新的本地分支来关联远程分支,假设本地仓库有一个远程分支o/main
,执行git checkout -b feat o/main
我们新建了一个名叫feat
的本地分支并关联了远程分支o/main
,然后在feat
分支上创建commit c2
,最后直接执行git push
,可以看出,我们新建的commit c2
直接被推送到了上游分支main
,远程分支o/main
也更新到commit c2
的位置
我们也可以为已有的本地分支设置关联的远程分支,通过执行git branch -u o/main feat1
,给本地分支feat1
关联了远程分支o/main
,接下来就可以像上面的例子一样,使用默认的参数来执行push命令,这里就不详细赘述了
git工作流
在团队的日常工作中,我们遇到多种开发场景,包括:协同开发新特性,发布版本,修复线上bug等,大家都往一个git仓库中提交代码,如果没有协作规范(也就是git工作流),那将是一场灾难。下面我简单介绍一下业界常用的三种git工作流
git flow
git flow采用严格的分支管理策略,适用于周期性发布的项目,我们在项目开发中就是采用git flow工作模式,它是最早提出的一种git工作流程,覆盖了开发新特性,bug追踪,版本发布等场景,结构如下:
- master:保持在发布状态,打tag记录版本
- develop:日常开发,所有的提交最终都要合并到这里
- feature:开发新特性,基于develop,开发完成最终要合并到develop
- hotfix:修复bug,基于main,修复完后合并到main并打tag升级版本号,也要合并到develop
- release:用于发布版本,当新特性开发完成并合并到develop后,基于develop创建release分支,后续跟此版本相关的commit全部提交到此分支,当版本稳定后,合并到main分支发版,打tag记录版本号,也要合并到develop
详情参考Gitflow Workflow
github flow
main始终保持在发布状态,轻量,适用于频繁部署的项目
- 基于main创建新分支(新特性,bug修复,重构等,全部基于main),分支名要具有描述性
- 修改,提交
- 同步代码,提交pull request
- code review
- 合并到main
- deploy
详情参考Understanding the GitHub flow
gitlab flow
频繁部署的项目
适用于前端或后台项目,认为feature分支合并到main分支后即可发布,分支合并后可自动部署
开发新特性:
- 基于master创建feature分支,开发新特性
- 新特性开发完毕,提交merge request,合并到main
- code review后合并到main
- 将main分支合并到production分支,自动发布
- 删除feature分支
修改bug:
- 基于master创建bugfix分支,修改bug
- bug修复完毕后提交merge request,合并到main
- code review后合并到main,如果先合并到production,很可能忘记合并到main,导致后续发布还是会存在此bug
- 将bugfix的修改cherry-pick到production分支
- 删除bugfix分支
还有一个变种,适用于多个环境的发布:
- pre-production是用来同步main分支修改的环境,可以叫其他名字
- 部署pre-production需要创建merge request将main合并到pre-production
- 上线时将pre-production合并到production
- 合并顺序是从上游到下游
周期性发布的项目
适用于移动端,桌面软件项目,很多开源项目都采用这种协作模式
- 根据master开发新特性
- 新特性开发完后,创建release分支,分支名包含小版本号
- bugfix分支修复了release分支的bug,先合并到main,再cherry pick到release分支,这样确保之后开发的新特性的分支一定修复了此bug
- 每个发布的版本都维护有一个分支,不用在main上打tag,更快定位历史发布版本
详情参考Introduction to GitLab Flow
总结
本文从原理层面介绍了git中一些核心概念,并介绍了一些常用操作,以及底层的含义(这里强烈建议大家在 学习git分支 上动手操作一下),最后简略的介绍了几种常见的git工作流,如果有不足之处还请大家指正,希望本文能对大家有所帮助
转载自:https://juejin.cn/post/7126528295046381581