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的父节点,即commitc1main~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