记我的第一次GitHub Pull Request(下篇)拉取与推送

2020-03-07

Previously On This Serie

第一篇:提交与分支

在本系列的第一篇文章,为了讲清楚什么是Pull Request我们首先介绍了『提交』(Commit)和『分支』(Branch)的概念,简单来说,提交是某一时刻的快照可供后续检出,而分支记录则是不同的开发路线,允许在非最近提交上建立新的提交,在从而使得不同的开发任务得以同时进行——在同一个项目上.

第二篇:合并与冲突处理

而在本系列的第二篇文章,我们介绍了『分支』(Branch)这个概念,我们还演示了一次分支的合并,以及,演示了合并过程中遇到的冲突,遇到冲突时如何处理,这是第二篇的内容.

进入远程工作时代

联想到这篇文章写就的时间,以及这篇文章所要介绍的,确实可以这么说,Git也为国家的疫情防控工作尽了一份力.回到话题,最开始啊,我们曾经提到过了,Git是分布式的版本控制系统,还说Git使得来自全球各地的开发者通过互联网在同一个项目上共事称为可能,而目前来看,就我们只介绍过了的『提交』,『分支』和『分支的合并』这几个概念,好像,还不足以进行跨大洲的合作啊?

远程分支

克隆和远程分支

Git首先提供了『克隆』(Clone)功能,什么是克隆呢?其实也就是字面意义上的克隆,一个仓库(Repository)原样地复制到另外一个地方,计算机A路径/path1上的仓库,原样地复制一份到计算机B路径/path2上,这就完成了一个克隆的过程.当然,也有本地的克隆,就跟我们复制粘贴文件是一样的,但是也有不一样的地方,来试一下

git clone https://github.com/hsiaofongw/learngit

会得到输出

Cloning into 'learngit'...
remote: Enumerating objects: 21, done.
remote: Counting objects: 100% (21/21), done.
remote: Compressing objects: 100% (9/9), done.
Unpacking objects: 100% (21/21), done.
remote: Total 21 (delta 2), reused 21 (delta 2), pack-reused 0

由克隆得到的仓库,除了保存了原先所有的提交和分支以外,Git还会自动在这个克隆得到的仓库创建一个『远程分支』,可以通过命令

git branch -vv

查看,会看到

* a 085e927 [origin/a] c6 (merged c4p and c5)

这里的a就是分支a,就是上篇文章中的分支a,而origin/a,就是『远程分支』,实际上,远程分支是一类特殊的分支,你可以创建新的普通分支指向远程分支的最新提交,但是你不能修改远程分支.

注意了,这里说的『远程分支』,语义上是指本地的一类特殊的分支,而不是『远程的分支』,如果我们想要表达『远程的分支』,我们会说『服务器上的分支』,或者『服务器分支』.

现在在本地我们有两个分支,一个叫做分支a,一个叫做远程分支origin/a,事实上,这里的远程分支origin/a,正是git最近一次和服务器交互时,git所看到的,服务器上的服务器分支a的样子,远程分支origin/a是服务器分支a的一个『照片』,记录了那一时刻的状态.也就是说,origin/a就是服务器的a在本地的『代表』.

在服务器上

服务器Git仓库视图

在本地

本地Git仓库视图

远程仓库和远程的仓库

远程分支origin/a是说,这个叫做origin/a的分支,跟踪着一个叫做origin的远程的仓库上的叫做a的分支,这里的『跟踪』(tracked)是什么意思,我们后面会讨论,这里呢,我先说origin并不是远程的仓库在本地的唯一名字,我们可以看到

https://github.com/hsiaofongw/learngit.git

这个远程的仓库是叫做learngit,而在本地

git remote

看到的是

origin

我们也可以给本地的远程仓库重命名(同样的,这里的远程仓库也不是『远程的仓库』,而只是远程的仓库在本地的一个『外号』,顺带着记录着远程的仓库的网址URL),把origin重命名为learngit1

git remote rename origin learngit1

这样,不管这个远程仓库原来叫什么,也不管这个远程仓库所指向的『远程的仓库』真正叫什么,用了刚才那个命令重命名之后,以后我们就可以把放置在https://github.com/hsiaofongw/learngit.git的远程的仓库在本地叫做learngit1了,『服务器上的仓库』或者『远程的仓库』https://github.com/hsiaofongw/learngit.git在本地对应着的『远程仓库』就叫做learngit1了!

跟踪与抓取

到目前为止,我们认识了本地的远程仓库learngit1只不过记录着真正的远程的仓库https://github.com/hsiaofongw/learngit.git的真实地址(方便我们联系它),我们还认识了远程的仓库https://github.com/hsiaofongw/learngit.git的分支a在本地有一个远程分支learngit1/a跟踪着,但是learngit1保存着远程的仓库的地址又有什么用呢?learngit1/a又是拿来做什么的呢?

在本篇文章中我们要始终区分『远程的仓库』和『远程仓库』,『远程的分支』和『远程分支』,你可以这么理解,在C语言中,数组是(至少在应用程序看起来是)内存中一段连续的内存区域,而指针则指向数组的头地址方便我们真正访问数组,远程仓库也是指向『远程的仓库』的这么一类指针,同理,远程分支也是指向『远程的分支』的『指针』.

现在既然https://github.com/hsiaofongw/learngit.git这个远程的仓库是放在GitHub上的一个开源项目的Git仓库,那应该可以共同参与吧?我们打开另外一个终端(用另外一台电脑就不必了,直接打开另外一个终端模拟器的窗口即可),执行以下命令:

git clone https://github.com/hsiaofongw/learngit.git learngit2
cd learngit2
git rename origin learngit2

这实际上是在模拟另外一名开发者克隆learngit这个GitHub服务器上远程的仓库的情形,我们刚才把远程的仓库learngit克隆到了本地,并且给它起了个别名叫做learngit2,也就是说又有另一个叫做learngit2的远程仓库指向learngit这个远程的仓库,这是另一名(虚拟的)开发者的视角,假设这个开发者叫做david:

另外一名开发者的视角

现在让david来做一些贡献,比如说给这个计算器项目添加一个除法功能,david在a分支提交c6的基础上写除法模块的代码,并且给代码加了注释

# author: david
# last_modified: 2020-03-07T23:29:17+08:00

# 加法
def add(x, y):
    return x + y

# 减法
def sub(x, y):
    return x - y

# 乘法
def mul(x, y):
    return x * y

# 除法
def div(x, y):
    return x / y

然后提交:

git add calc.py
git commit -a -m "(c7) added div function"
# 这里的c7是我们为了方便讲述临时添加的,在实际环境中不要这样

David用git commit命令提交了之后,在David电脑本地,learngit2这个仓库现在应该是

After David Commited Changed

图片中的c1和c2只是因为不怎么提及所以没画出来,但并不是在David提交c7之后消失了,我们说过了远程分支learngit2/a在本地是不可写的(只能由Git软件自己内部操作,开发者不能直接访问,比如说不能直接在上面建新分支后者进行提交),所以提交还是在本地普通分支a上面进行的.

这是如果我们用

git log

命令来查看提交历史,会得到验证

git log验证

日志的意思是,当前分支(HEAD)指向分支a,而分支a又指向c7提交,远程分支learngit2/a还是指向c6(因为这是我们最后一次和服务器通信时所看到的),同时最后一次我们和服务器通信时(也就是git clone那次)服务器上的默认分支也是分支a(因为learngit2/HEAD是和learngit2/a在同一行的).

推送

现在我们用git remote命令看一下服务器上的状态是怎么样的(注意,这仍不会更新learngit2/a),我们会看到

* remote learngit2
  Fetch URL: https://github.com/hsiaofongw/learngit.git
  Push  URL: https://github.com/hsiaofongw/learngit.git
  HEAD branch: a
  Remote branch:
    a tracked
  Local branch configured for 'git pull':
    a merges with remote a
  Local ref configured for 'git push':
    a pushes to a (fast-forwardable)

主要看最后一行,如果我们push服务器,即推送的话,会是fast-forwardable的,说明我们本地的分支a是领先服务器的分支a的,提交之后不会有冲突,服务器那边只需把服务器的分支a移到新提交的c7,然后把服务器的HEAD移动到移动到c7之后的分支a就行了,那么现就做一下这次推送(push)吧

git push

显示了这个

Enumerating objects: 5, done.
Counting objects: 100% (5/5), done.
Delta compression using up to 4 threads
Compressing objects: 100% (2/2), done.
Writing objects: 100% (3/3), 392 bytes | 196.00 KiB/s, done.
Total 3 (delta 0), reused 0 (delta 0)
To https://github.com/hsiaofongw/learngit.git
   085e927..d13e1f8  a -> a

前面是关于Git怎么处理数据的,我们看后两行

To https://github.com/hsiaofongw/learngit.git
   085e927..d13e1f8  a -> a

“对于 https://github.com/hsiaofongw/learngit.git 这个远程的仓库,这个服务器上的仓库,最新的提交由085e927变成了d13e1f8,服务器上的分支a现在同步成了David本地的分支a(a -> a)”

我们看前面那幅图,刚才我们用git log打出来的日志(不是现在打出来的)

刚才git log打出来的日志

看到了吗?085e927 就是c6的提交id,而d13e1f8就是c7的提交id,前者是服务器自己觉得的最新的版本(其实相对David来说已经落后了,直到David推送),后者是David机器上刚刚实现了除法模块的最新版本.现在我们再看git log

再看一下git log

可以看到learngit2/a确实记录的是最后一次与服务器通信时服务器的分支a的状态.现在如果David继续提交(commit),但不推送(push),就会看到learngit2/a和learngit2/HEAD的位置一直不变,而a始终在变.

与此同时,原开发者,也就是一开始就clone learngit这个项目的开发者(不是David),他本地的git log,看起来还会是这个样子:

原开发者的git log

这就验证了learngit1/a这个远程分支是不会自动更新的,远程分支只是本地的一类特殊分支,开发者不主动要求git与服务器通信的话,git不会偷摸摸的和服务器通信的,also Keep it Simple and Stupid.

我们再从learngit1的角度,运行

git remote show learngit1

看服务器会告诉我们什么

* remote learngit1
  Fetch URL: https://github.com/hsiaofongw/learngit.git
  Push  URL: https://github.com/hsiaofongw/learngit.git
  HEAD branch: a
  Remote branch:
    a tracked
  Local branch configured for 'git pull':
    a merges with remote a
  Local ref configured for 'git push':
    a pushes to a (local out of date)

最后一行给出了信息,他说本地的分支已经过期了,所以不能推送,这是当然的,本地的分支现在是learngit1/a,指向c6,如果我们提交的话,会是在c6的基础上提交,也就是说会有一个c7我们尝试去提交,而服务器已经有c7了,所以这是不允许的,所以服务器首先告诉我们local out of date,本地的已经过期啦,不要再提交啦,当然如果硬要提交,服务器也会提示我们怎么做,毕竟如果是一个多人参与的git项目,每时每刻都可能有人提交的,这会使得服务器几乎总是最新的,而本地总有人local out of date.

现在我们试着在learngit1的角度,添加一个比较模块,如果$x < y$,返回$-1$,如果$x = y$,返回$0$,如果$x > y$,返回$1$.我们先不拉取(fetch或者pull),我们先假装不知道,硬要往远程的仓库推送试试.我们假设原来的开发者,也就是本地是learngit1的那个开发者,叫做Sam,而本地是learngit2的那位开发者,我们已经说了,叫David.

vi calc.py

然后calc.py变成

# 加法
def add(x, y):
    return x + y

def sub(x, y):
    return x - y

def mul(x, y):
    return x * y

# 比较两个整数x, y
# 如果x < y 返回-1
# 如果x = y 返回0
# 如果x > y 返回1
def compare(x, y):
    if (x < y):
        return -1
    elif (x == y):
        return 0
    elif (x > y):
        return 1

然后Sam在命令行执行

git add calc.py
git commit -a -m "added compare function by Sam"
git push

然后,实际上,服务器当然会拒绝Sam的推送(push),并且Sam会看到

然后Sam会看到

一行一行的看:

 ! [rejected]        a -> a (fetch first)

在本地分支a推送到服务器的分支a的过程中,被拒绝了,先拉取再推送吧!(fetch first)

error: failed to push some refs to 'https://github.com/hsiaofongw/learngit.git'

错误(这个应该是Git软件的诊断信息,前面的是服务器返回的信息),推送某些本地的引用(『引用』其实是Git版本管理系统中比较重要的一个概念,以后我们有机会会讲)到https://github.com/hsiaofongw/learngit.git时失败了.

然后是最后几行hint,别跳过,软件给出的诊断信息和提示信息很值得我们认真看,而且很多时候,给出的往往就直接是解决方法,我们照着做,问题就能很快解决,而不是盲目地去搜索引擎搜,或者去社区里边问(如果本地解决不了,自己看了但看不明白提示,再去搜,去问)

Updates were rejected because the remote contains work that you do
not have locally. This is usually caused by another repository pushing
to the same ref. You may want to first integrate the remote changes
(e.g., 'git pull ...') before pushing again.
See the 'Note about fast-forwards' in 'git push --help' for details.

大意是:更新被拒绝了,因为呢远程的仓库包含着你本地没有的工作量(这我们知道,其实就是David推送的c7,这我们Sam的本地确实没有),这通常是因为呢,另外一个仓库正在推送到同样的引用(其实没错,另外的仓库就是learngit2,就是David本地的那个,他推送到了远程的仓库learngit上的分支a,分支也可以叫做引用,其实在git里边很多东西都叫着引用),你或许可以先把远程仓库上的更新(也就是c7)整合到你本地来(比如说用 git pull 命令),然后你再推送一次试试.

后面的git push --help读者有时间可以认真看.然后Note about fast-forwards的内容Git SCM的网站好像有讲到过,或者可以去搜一下.

这个建议真的是非常详细,非常中肯了,不仅告诉了你可能的原因,还提示你运行什么命令来解决这个问题!Git真的是很优秀的一款开源软件!

拉取与合并

下面我们来概述一下,Git是说呢我们得先把服务器上的最新的提交给拉取(fetch)下来,然后再整合到我们本地,然后比如说我们就把这个最新的c7整合了,我们再创建一个c8提交上去,这时就没有问题了,这里的『整合』,Git软件给出的提示中的原文是integrate,其实就和我们上一篇文章说的『合并』(merge),是差不多的,相信到了这里,你已经明白得差不多了!

现在我们来看一下Sam看到Git提示消息的时候,通过拉取进行整合之前,Sam所看到的视图

Sam看到的在服务器上:

服务器Git仓库视图

在Sam的本地:

本地Git仓库视图

好的,但是当Sam按照Git给出的提示进行提示之前,假设他比较细心,还会去翻一下文档,看git pull到底是什么意思,有这么一句话

git pull 文档预览

原来git pull就是先git fetch,然后再git merge啊!为了一探究竟,我们分步试试,看git fetch之后,git merge之前是怎样的!

git fetch

会看到

remote: Enumerating objects: 5, done.
remote: Counting objects: 100% (5/5), done.
remote: Compressing objects: 100% (2/2), done.
remote: Total 3 (delta 0), reused 3 (delta 0), pack-reused 0
Unpacking objects: 100% (3/3), done.
From https://github.com/hsiaofongw/learngit
   085e927..d13e1f8  a          -> learngit1/a

注意最后一行,那个箭头右边,看样子是本地的learngit1/a被更新了!原来之前我们一直说的learngit1/a追踪远程服务器上的分支a,追踪就是这个意思!如果我们把这个learngit1/a看做只不过是另外一个分支,地位相当于a,就好像上一篇文章中的分支b相当于a一样,那么现在我们只要按照在上一篇文章把分支b并入a那样,这次把learngit1/a也并入a,不就实现了Git所说的:整合服务器上的更新了吗?!试一下吧!

git merge learngit1/a

出现了这个

Auto-merging calc.py
CONFLICT (content): Merge conflict in calc.py
Automatic merge failed; fix conflicts and then commit the result.

挺熟悉的,我们遇到过这种情况!

我们遇到过这种情况

提示消息是一模一样的(除了语言不一样,GitHub果然国际范儿,参与一个开源项目,语言都变英语了!),还能记得解决方式,是Git会把冲突列在有冲突的文件中,我们进入有代码冲突的文件,手动解决冲突,然后再git add 有冲突的文件,再提交就可以了!由于这次随着项目的开发,代码量有点大了,用VS Code打开吧.

在VS Code中解决冲突

VS Code还是很人性化的,看着界面也非常舒服,HEAD部分和要被合并的learngit1/a部分都分别用不同颜色做了标记,上边还有按钮,解决完冲突之后点一下就可以了,都不用写代码!

我们是这样解决的,我们看到其他代码好像都没什么问题(类型声明之类的放到以后再说,不建议在一次提交中做太多更改),就是David添加的除法好像没有考虑到$y=0$的情形,我们给他的代码加个错误处理,然后再并入!

Sam处理了冲突,calc.py现在看起来是这个样子:

Sam处理了冲突

现在应该没问题了!于是Sam在命令行执行

git add calc.py
git commit -a
# 然后默认会有Merge remote-tracking branch 'learngit1/a' into a
git push

Sam刚刚向远程仓库推送(push)了自己的贡献!并且还优雅地处理了分支合并时遇到的矛盾,把别人写的代码合理地与自己的代码整合到了一起.沾沾自喜之余,忍不住去GitHub看一下:这命令行的玩意儿,总怕输错了什么,我只是输了几行命令啊,git真的就帮我把代码推送到GitHub上了么?还是直观的东西看起来踏实,就让Sam去看一下吧!

Sam去看了一下GitHub

GitHub上显示的信息确实和自己命令行界面运行git命令看到的差不多,再看一下代码

Sam看了GitHub上是代码

心中只是感到欣慰.

小结

在这篇文章中,我们演示了一次完整的,也是典型的通过git分布式版本管理系统和互联网在同一个开源项目上与他人合作并向服务器推送自己的贡献的过程,我们先是介绍了如何克隆(git clone)一个仓库,然后我们通过案例,引入并讲解了『推送』(git push)和『拉取』(git fetch)的概念,并且在最后,还回顾并联系到了我们在上一篇文章中讲到的『分支合并』和『冲突处理』的内容.希望读者朋友们现在已经理解了我们介绍过的这几个git的基本概念和用法.

参考文献

[1] Git - Remote Branches

[2] Git - Working with Remotes

概念普及githubgit

记我的第一次GitHub Pull Request(完结篇)Fork与PR

记我的第一次GitHub Pull Request(中篇)合并与冲突处理