什么是Git分支?
看看git官网是怎么说的,有人把 Git 的分支模型称为它的“必杀技特性”,也正因为这一特性,使得 Git 从众多版本控制系统中脱颖而出......吧啦吧啦说了一大堆,看得是一脸懵逼。
简单的说,分支就是火影忍者中的影分身之术,可以从本体分出好多个分身,本体可以看做是主干,分身可以看做为主干的分支,能够同时独立的工作,也可以在需要的时候合为一体。不知道火影忍者的,可以参考孙悟空,他也会分身。
这只是简单的打个比方,很多版本控制系统支持分支,唯独 git 的分支模型独领风骚,那么 git 是的分支是如何实现的?
接下来会用图文并茂(文字也请认真看,让自己有个印象)的方式来帮你理解git分支以及它的强大之处。
git 保存数据,保存的不是文件的变化或者差异,而是一系列不同时刻的快照。在进行提交操作时,git 会保存一个提交对象(commit object)。知道了 git 保存数据的方式,我们可以很自然的想到——该提交对象会包含一个指向暂存内容快照的指针。 但不仅仅是这样,该提交对象还包含了作者的姓名和邮箱、提交时输入的信息以及指向它的父对象的指针。 首次提交产生的提交对象没有父对象,普通提交操作产生的提交对象有一个父对象, 而由多个分支合并产生的提交对象有多个父对象。
为了更加形象地说明,我们假设现在有一个工作目录,里面包含了三个将要被暂存和提交的文件。 暂存操作会为每一个文件计算校验和(使用SHA-1 哈希算法),然后会把当前版本的文件快照保存到 git 仓库中 (git 使用 blob 对象来保存它们),最终将校验和加入到暂存区域等待提交:
$ git add README test.rb LICENSE
$ git commit -m 'The initial commit of my project'
当使用 git commit
进行提交操作时,git 会先计算每一个子目录(本例中只有项目根目录)的校验和, 然后在 git 仓库中这些校验和保存为树对象。随后,git 便会创建一个提交对象, 它除了包含上面提到的那些信息外,还包含指向这个树对象(项目根目录)的指针。 如此一来,git 就可以在需要的时候重现此次保存的快照。
别方,现在我们来看看首次提交对象及其树结构:
现在,git 仓库中有五个对象:三个 blob 对象(保存着文件快照)、一个 树 对象 (记录着目录结构和 blob 对象索引)以及一个 提交 对象(包含着指向树对象的指针和所有提交信息)。
注:这里为什么是三个blob对象,是因为前面我们用的git add README test.rb LICENSE
命令暂存了这三个文件,随后就提交了,所以产生了三个blob对象。
做些修改后再次提交,那么这次产生的提交对象会包含一个指向上次提交对象(父对象)的指针。
git 的分支,其实本质上仅仅是指向提交对象的可变指针。 git 的默认分支名字是 master
。 在多次提交操作之后,你其实已经有一个指向最后那个提交对象的 master
分支。 master
分支会在每次提交时自动向前移动。
注:git 的 master
分支并不是一个特殊分支。 它就跟其它分支完全没有区别。 之所以几乎每一个仓库都有 master 分支,是因为 git init
命令默认创建它,并且大多数人都懒得去改动它。
今年,也就是2020年10月1日起,所有在GitHub上新建的仓库默认名字都将改为main
而不是master
。此前的所有仓库不受影响
分支创建
git 创建一个分支,只是为你创建了一个可以移动的新的指针,比如,创建一个testing分支,你需要使用git branch
命令:
$ git branch testing
通常我们会在创建一个新分支后立即切换过去,这可以用 git checkout -b <newbranchname>
一条命令搞定。
这会在当前所在的提交对象上创建一个指针。两个指向相同提交历史的分支:
那么,git 又是怎么知道当前在哪一个分支上的? 也很简单,它有一个名为 HEAD
的特殊指针。 请注意它和许多其它版本控制系统(如 Subversion 或 CVS)里的 HEAD
概念完全不同。 在 git 中,它是一个指针,指向当前所在的本地分支,可以将 HEAD
想象为当前分支的别名。 在本例中,你仍然在 master
分支上。 因为 git branch
命令仅仅 创建 一个新分支,并不会自动切换到新分支中去。
你可以简单地使用 git log
命令查看各个分支当前所指的对象。 提供这一功能的参数是 --decorate
。
$ git log --oneline --decorate
f30ab (HEAD -> master, testing) add feature #32 - ability to add new formats to the central interface
34ac2 Fixed bug #1328 - stack overflow under certain conditions
98ca9 The initial commit of my project
当前 master
和 testing
分支均指向校验和以 f30ab
开头的提交对象。
分支切换
要切换到一个已存在的分支,使用 git checkout
命令。 我们现在切换到新创建的 testing
分支去:
$ git checkout testing
这样 HEAD
就指向 testing
分支了。
这样的实现方式有什么好处?当再次进行提交时:
testing
分支(HEAD
所在分支)随着提交操作自动向前移动,但是master
分支却没有移动,他仍然指向运行git checkout
时所指的对象。再切回master
分支看看:
$ git checkout master
可以看到HEAD
指回了master
分支。这条命令做了两件事,一是使HEAD
指回master
分支,二是将工作目录恢复成master
分支所指向的快照内容。也就是说,现在做修改的话,项目将始于一个较旧的版本。 本质上来讲,这就是忽略 testing
分支所做的修改,以便于向另一个方向进行开发。
此时HEAD
指向master
分支,如果此时进行修改并提交,而之前在testing
分支上也进行了修改并提交,两次改动针对的是不同分支,此时提交历史就产生了分叉:你可以在不同的分支来回切换进行工作,在有需要的时候将他们合并起来,这些工作,需要的命令只有branch
、checkout
、commit
。
你可以简单地使用 git log
命令查看分叉历史。 运行 git log --oneline --decorate --graph --all
,它会输出你的提交历史、各个分支的指向以及项目的分支分叉情况。
由于 git 的分支实质上仅是包含所指对象校验和(长度为 40 的 SHA-1 值字符串)的文件,所以它的创建和销毁都异常高效。 创建一个新分支就相当于往一个文件中写入 41 个字节(40 个字符和 1 个换行符),如此的简单能不快吗?
这与过去大多数版本控制系统形成了鲜明的对比,它们在创建分支时,将所有的项目文件都复制一遍,并保存到一个特定的目录。 完成这样繁琐的过程通常需要好几秒钟,有时甚至需要好几分钟。所需时间的长短,完全取决于项目的规模。 而在 git 中,任何规模的项目都能在瞬间创建新分支。 同时,由于每次提交都会记录父对象,所以寻找恰当的合并基础(即共同祖先)也是同样的简单和高效。 这些高效的特性使得 Git 鼓励开发人员频繁地创建和使用分支。
分支的合并
在实际工作中,可能会有这样的经历:
- 开发某个网站。
- 为实现某个新的用户需求,创建一个分支。
- 在这个分支上开展工作。
这时,出现一个很严重的bug需要紧急修补。那么这时候的流程应该是这样的:
- 切换到你的线上分支(production branch)。
- 为这个紧急任务新建一个分支,并在其中修复它。
- 在测试通过之后,切换回线上分支,然后合并这个修补分支,最后将改动推送到线上分支。
- 切换回你最初工作的分支上,继续工作。
假设在master
分支上已经有了一些提交:
这个时候,领导要你解决公司的bug追踪系统上的#53号问题,这个时候就需要新建一个分支并同时切换到那个分支上去,你可以使用带有 -b
参数的 git checkout
命令:
# 这里我们就使用我们的learngit本地仓库 进行演示
# 先确认工作区是干净的,然后新建分支
$ git checkout -b iss53
Switched to a new branch 'iss53'
它是这两条命令的简写:
$ git branch iss53
$ git checkout iss53
这时已经创建了一个新的分支iss53
你继续在 iss53
分支上工作,并且做了一些提交。 在此过程中,iss53
分支在不断的向前推进,因为你已经检出到该分支 (也就是说,你的 HEAD
指针指向了 iss53
分支)
# 修改readme.txt文件,然后提交
$ git commit -a -m 'modify readme.txt add content study git branch'
[iss53 0526e3d] modify readme.txt add content study git branch
1 file changed, 2 insertions(+), 1 deletion(-)
分支随着工作的进展向前推进:
这时,master
分支上又有一个紧急问题需要你来解决,那么这个时候,你可以直接切换回master
分支解决问题即可,这里有一个前提:在你切换分支之前,保持好一个干净的状态,也就是确保你的工作目录和暂存区里没有未被提交的修改,避免和master
分支产生冲突导致git不让你切换分支。切换至master
:
$ git checkout master
Switched to branch 'master'
切换回master
分支之后,git会重置你的工作目录,使其恢复到你在这个分支最后一次提交的状态,当然你不用担心之前在iss53
分支上的修改会消失,因为不同分支不会相互影响。接下来,要修复master
分支上的问题,只需要再建立一个分支,在该分支上解决问题即可:
$ git checkout -b hotfix
Switched to a new branch 'hotfix'
# 修改 google.txt,并提交
$ git commit -a -m 'modify google.txt add content google voice'
[hotfix d6c79ae] modify google.txt add content google voice
1 file changed, 2 insertions(+), 1 deletion(-)
可以在hotfix
分支上进行测试,确保自己的修改无误,然后将hotfix
分支合并到master
分支部署到线上。合并分支:
$ git checkout master
Switched to branch 'master'
$ git merge hotfix
Updating 6680bd4..d6c79ae
Fast-forward
google.txt | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
上述的合并信息中有 “快进(Fast-forward)”这个词。由于你想要合并的分支 hotfix
所指向的提交 C4
是你所在的提交 C2
的直接后继, 因此 Git 会直接将指针向前移动。换句话说,当你试图合并两个分支时, 如果顺着一个分支走下去能够到达另一个分支,那么 Git 在合并两者的时候, 只会简单的将指针向前推进(指针右移),因为这种情况下的合并操作没有需要解决的分歧——这就叫做 “快进(fast-forward)”。
master
被快进到 hotfix
:
合并完成之后,hotfix
分支就可以删除了,已经不需要它了( master
分支和它已经指向了同一个位置),可以使用带 -d
选项的 git branch
命令来删除分支:
# 如果未合并,但是想要强制删除分支,-d换成-D即可
$ git branch -d hotfix
Deleted branch hotfix (was d6c79ae).
现在可以回到iss53
分支继续之前的工作:
$ git checkout iss53
Switched to branch 'iss53'
# 再次修改readme.txt 文件,并提交
$ git commit -a -m 'modify readme.txt add content merge branch'
[iss53 4418bd6] modify readme.txt add content merge branch
1 file changed, 1 insertion(+), 1 deletion(-)
假设你已经修正了 #53 问题,并且打算将你的工作合并入 master
分支。 这和之前合并 hotfix
分支所做的工作差不多。 你只需要检出到你想合并入的分支,然后运行 git merge
命令:
$ git checkout master
$ git merge iss53 # 如果按照我的步骤来,肯定会出现下面的窗口
Merge made by the 'recursive' strategy.
readme.txt | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
这和之前合并 hotfix
分支的时候看起来有一点不一样。 在这种情况下,你的开发历史从一个更早的地方开始分叉开来(diverged)。 因为,master
分支所在提交并不是 iss53
分支所在提交的直接祖先,git 不得不做一些额外的工作。 出现这种情况的时候,git 会使用两个分支的末端所指的快照(C4
和 C5
)以及这两个分支的公共祖先(C2
),做一个简单的三方合并。
和之前将分支指针向前推进所不同的是,git 将此次三方合并的结果做了一个新的快照并且自动创建一个新的提交指向它。 这个被称作一次合并提交,它的特别之处在于他有不止一个父提交。
既然你的修改已经合并进来了,就不再需要 iss53
分支了。 可以在任务追踪系统中关闭此项任务,并删除这个分支。这里暂时不删除。
合并分支遇到冲突
现在我们再次创建一个分支,对application.txt
文件进行修改:
$ git checkout -b hotfix
Switched to a new branch 'hotfix'
# 对application.txt文件进行修改并提交
$ git commit -a -m 'modify application.txt add android app'
[hotfix 92f4c86] modify application.txt add android app
1 file changed, 2 insertions(+), 1 deletion(-)
# 切换回master分支,合并hotfix分支
$ git checkout master
Switched to branch 'master'
$ git merge hotfix
Updating 110aa9d..92f4c86
Fast-forward
application.txt | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
切换到iss53
分支,同样对application.txt
文件进行修改:
$ git checkout iss53
Switched to branch 'iss53'
# 对application.txt文件进行修改并提交
$ git commit -a -m 'modify application.txt add iphone'
[iss53 cb202c2] modify application.txt add iphone
1 file changed, 2 insertions(+), 1 deletion(-)
$ git checkout master
Switched to branch 'master'
这个时候把iss53
合并到master
分支,就会产生合并冲突:
$ git merge iss53
Auto-merging application.txt
CONFLICT (content): Merge conflict in application.txt
Recorded preimage for 'application.txt'
Automatic merge failed; fix conflicts and then commit the result.
此时 git 做了合并,但是没有自动地创建一个新的合并提交。 git 会暂停下来,等待你去解决合并产生的冲突。 你可以在合并冲突后的任意时刻使用 git status
命令来查看那些因包含合并冲突而处于未合并(unmerged)状态的文件:
$ git status
On branch master
You have unmerged paths.
(fix conflicts and run "git commit")
(use "git merge --abort" to abort the merge)
Unmerged paths:
(use "git add <file>..." to mark resolution)
both modified: application.txt
no changes added to commit (use "git add" and/or "git commit -a")
任何因包含合并冲突而有待解决的文件,都会以未合并状态标识出来。 git 会在有冲突的文件中加入标准的冲突解决标记,这样你可以打开这些包含冲突的文件然后手动解决冲突。 出现冲突的文件会包含一些特殊区段,看起来像下面这个样子(打开application.txt
文件):
application
<<<<<<< HEAD
android app
=======
iphone
>>>>>>> iss53
这表示 HEAD
所指示的版本(也就是你的 master
分支,因为你在运行 merge 命令的时候已经切换到了这个分支)在这个区段的上半部分(=======
的上半部分),而 iss53
分支所指示的版本在 =======
的下半部分。 为了解决冲突,你必须选择使用由 =======
分割的两部分中的一个,或者你也可以自行合并这些内容。 例如,你可以通过把这段内容修改成下面的样子来解决冲突:
application
android app
iphone
这里解决冲突的方案是保留全部内容(当然实际工作中,要看自己的取舍,这里只是举个例子),并且 <<<<<<<
, =======
, 和 >>>>>>>
这些行被完全删除了。 在解决了所有文件里的冲突之后,对每个文件使用 git add
命令来将其标记为冲突已解决。 一旦暂存这些原本有冲突的文件,git 就会将它们标记为冲突已解决。
如果你想使用图形化工具来解决冲突,你可以运行 git mergetool
,该命令会为你启动一个合适的可视化合并工具(默认的很丑,可以到网上找一个),并带领你一步一步解决这些冲突。
冲突解决之后,git会暂存那些文件以表明冲突已解决: 你可以再次运行 git status
来确认所有的合并冲突都已被解决:
$ git status
On branch master
All conflicts fixed but you are still merging.
(use "git commit" to conclude merge)
Changes to be committed:
modified: application.txt
如果觉得没有问题了,并且所有有冲突的文件都已经暂存了,这时可以输入 git commit
来完成合并提交。 默认情况下提交信息看起来像下面这个样子:
# 没有加 -m 的情况下
Merge branch 'iss53'
# Conflicts:
# application.txt
#
# It looks like you may be committing a merge.
# If this is not correct, please remove the file
# .git/MERGE_HEAD
# and try again.
# Please enter the commit message for your changes. Lines starting
# with '#' will be ignored, and an empty message aborts the commit.
#
# On branch master
# All conflicts fixed but you are still merging.
#
# Changes to be committed:
# modified: application.txt
#
如果觉得上述的信息不够充分,不能完全体现分支合并的过程,你可以修改上述信息, 添加一些细节给未来检视这个合并的读者一些帮助,告诉他们是如何解决合并冲突的,以及理由是什么
Git分支管理
之前已经创建、合并、删除了一些分支,现在来看看一些常用的分支管理工具。
git branch
命令不只是可以创建与删除分支。 如果不加任何参数运行它,会得到当前所有分支的一个列表:
$ git branch
hotfix
iss53
* master
master
分支前的 *
字符:它代表现在检出的那一个分支(也就是说,当前 HEAD
指针所指向的分支)。 这意味着如果在这时候提交,master
分支将会随着新的工作向前移动。 如果需要查看每一个分支的最后一次提交,可以运行 git branch -v
命令:
$ git branch -v
hotfix 92f4c86 modify application.txt add android app
iss53 cb202c2 modify application.txt add iphone
* master 27bc1f2 Merge branch 'iss53'
--merged
与 --no-merged
这两个有用的选项可以过滤这个列表中已经合并或尚未合并到当前分支的分支。 如果要查看哪些分支已经合并到当前分支,可以运行 git branch --merged
:
$ git branch --merged
hotfix
iss53
* master
因为之前已经合并了 iss53
分支和hotfix
分支,所以现在看到它们在列表中。 在这个列表中分支名字前没有 *
号的分支通常可以使用 git branch -d
删除掉;你已经将它们的工作整合到了另一个分支,所以并不会失去任何东西。
如果存在未合并的分支,而你又想删除它,这个时候可以使用git branch -D <branchname>
命令强制删除。
Git远程分支
先来了解两个名词:
- 远程引用
- 远程跟踪分支
远程引用:
远程引用是对远程仓库的引用(指针),包括分支、标签等等。可以通过 git ls-remote <remote>
来显式地获得远程引用的完整列表, 或者通过 git remote show <remote>
获得远程分支的更多信息。 然而,更常见的做法是利用远程跟踪分支。
远程跟踪分支:
远程跟踪分支是远程分支状态的引用。它们是你无法移动的本地引用。一旦你进行了网络通信, Git 就会为你移动它们以精确反映远程仓库的状态。请将它们看做书签, 这样可以提醒你该分支在远程仓库中的位置就是你最后一次连接到它们的位置。
远程跟踪分支 <remote>/<branch>
的形式命名。例如,如果你想要看你最后一次与远程仓库 origin
通信时 master
分支的状态,你可以查看 origin/master
分支。 你与同事合作解决一个问题并且他们推送了一个 iss53
分支,你可能有自己的本地 iss53
分支, 然而在服务器上的分支会以 origin/iss53
来表示。简单来说就是本地分支没有仓库名,远程分支是带有仓库名的。
我们再来看一个例子,假设你的网络里有一个在 git.ourcompany.com
的 git 服务器。 如果你从这里克隆,git 的 clone
命令会为你自动将其命名为 origin
,拉取它的所有数据, 创建一个指向它的 master
分支的指针,并且在本地将其命名为 origin/master
。 git 也会给你一个与 origin 的 master
分支在指向同一个地方的本地 master
分支,这样你就有工作的基础。
如果你在本地的 master
分支做了一些工作,在同一段时间内有其他人推送提交到 git.ourcompany.com
并且更新了它的 master
分支,这就是说你们的提交历史已走向不同的方向。 即便这样,只要你保持不与 origin
服务器连接(并拉取数据),你的 origin/master
指针就不会移动。
如果要与给定的远程仓库同步数据,运行 git fetch <remote>
命令(在本例中为 git fetch origin
)。 这个命令查找 “origin” 是哪一个服务器(在本例中,它是 git.ourcompany.com
), 从中抓取本地没有的数据,并且更新本地数据库,移动 origin/master
指针到更新之后的位置。
为了演示有多个远程仓库与远程分支的情况,假定你有另一个内部 git 服务器,仅服务于你的某个敏捷开发团队。 这个服务器位于 git.team1.ourcompany.com
。 你可以运行 git remote add
命令添加一个新的远程仓库引用到当前的项目。 将这个远程仓库命名为 teamone
,作为完整 URL 的缩写。
现在,可以运行 git fetch teamone
来抓取远程仓库 teamone
有而本地没有的数据。 因为那台服务器上现有的数据是 origin
服务器上的一个子集, 所以 Git 并不会抓取数据而是会设置远程跟踪分支 teamone/master
指向 teamone
的 master
分支。
推送
在推送本地分支到远程仓库前,我们先把远程仓库克隆一份到本地备用:
$ git clone https://github.com/tiangoubot/learngit.git learngit2
把本地分支推送到远程仓库,可以使用git push <remote> <branch>
:
$ git push origin iss53
Enumerating objects: 12, done.
Counting objects: 100% (12/12), done.
Delta compression using up to 4 threads
Compressing objects: 100% (8/8), done.
Writing objects: 100% (9/9), 851 bytes | 283.00 KiB/s, done.
Total 9 (delta 5), reused 0 (delta 0), pack-reused 0
remote: Resolving deltas: 100% (5/5), completed with 2 local objects.
remote:
remote: Create a pull request for 'iss53' on GitHub by visiting:
remote: https://github.com/tiangoubot/learngit/pull/new/iss53
remote:
To https://github.com/tiangoubot/learngit.git
* [new branch] iss53 -> iss53
这里有些工作被简化了。 git 自动将 iss53
分支名字展开为 refs/heads/iss53:refs/heads/iss53
, 那意味着,“推送本地的 iss53
分支来更新远程仓库上的 iss53
分支。” 你也可以运行 git push origin iss53:iss53
, 它会做同样的事——也就是说“推送本地的 iss53
分支,将其作为远程仓库的 iss53
分支” 可以通过这种格式来推送本地分支到一个命名不相同的远程分支。 如果并不想让远程仓库上的分支叫做 iss53
,可以运行 git push origin iss53:devTest
来将本地的 iss53
分支推送到远程仓库上的 devTest
分支。
下一次其他协作者从服务器上抓取数据时,他们会在本地生成一个远程分支 origin/iss53
,指向服务器的 iss53
分支的引用:
# 刚才我们把iss53分支推送到远程仓库了,现在,我们先进入刚才克隆下来备份的仓库,在根目录下打开git bash,然后执行
$ git fetch origin
remote: Enumerating objects: 27, done.
remote: Counting objects: 100% (27/27), done.
remote: Compressing objects: 100% (9/9), done.
remote: Total 23 (delta 9), reused 23 (delta 9), pack-reused 0
Unpacking objects: 100% (23/23), 2.12 KiB | 1024 bytes/s, done.
From https://github.com/tiangoubot/learngit
* [new branch] iss53 -> origin/iss53
要特别注意的一点是当抓取到新的远程跟踪分支时,本地不会自动生成一份可编辑的副本(拷贝)。 换一句话说,这种情况下,不会有一个新的 iss53
分支——只有一个不可以修改的 origin/iss53
指针。
$ git branch # 可以看到并没有iss53分支
* master
你可以运行 git merge origin/iss53
将这些工作合并到当前所在的分支。 如果想要在自己的 iss53
分支上工作,可以将其建立在远程跟踪分支之上:
$ git checkout -b iss53 origin/iss53
Switched to a new branch 'iss53'
Branch 'iss53' set up to track remote branch 'iss53' from 'origin'.
这会给你一个用于工作的本地分支,并且起点位于 origin/iss53
。
跟踪分支
从一个远程跟踪分支检出一个本地分支会自动创建“跟踪分支”(它跟踪的分支叫做“上游分支(例如: origin/iss53
)”)。 跟踪分支是与远程分支有直接关系的本地分支。 如果在一个跟踪分支上输入 git pull
,Git 能自动地识别去哪个服务器上抓取、合并到哪个分支。
当克隆一个仓库时,它通常会自动地创建一个跟踪 origin/master
的 master
分支。 然而,如果你愿意的话可以设置其他的跟踪分支,或是一个在其他远程仓库上的跟踪分支,又或者不跟踪 origin/master
分支。 最简单的实例就是像之前看到的那样,运行 git checkout -b <branch> <remote>/<branch>
。 这是一个十分常用的操作所以 git 提供了 --track
快捷方式:
先在GitHub上创建一个新的分支:
$ git fetch origin # 从远程仓库拉取数据
From https://github.com/tiangoubot/learngit
* [new branch] serverfix -> origin/serverfix
# 设置跟踪分支
$ git checkout --track origin/serverfix
Switched to a new branch 'serverfix'
Branch 'serverfix' set up to track remote branch 'serverfix' from 'origin'.
由于这个操作太常用了,该捷径本身还有一个捷径。 如果你尝试检出的分支 (a) 不存在且 (b) 刚好只有一个名字与之匹配的远程分支,那么 git 就会为你创建一个跟踪分支:
$ git checkout serverfix
Branch serverfix set up to track remote branch serverfix from origin.
Switched to a new branch 'serverfix'
如果想要将本地分支与远程分支设置为不同的名字,你可以轻松地使用上一个命令增加一个不同名字的本地分支:
$ git checkout -b sf origin/serverfix
Branch sf set up to track remote branch serverfix from origin.
Switched to a new branch 'sf'
此时,本地分支 sf
会自动从 origin/serverfix
拉取。
设置已有的本地分支跟踪一个刚刚拉取下来的远程分支,或者想要修改正在跟踪的上游分支, 你可以在任意时间使用 -u
或 --set-upstream-to
选项运行 git branch
来显式地设置。
$ git branch -u origin/serverfix
Branch serverfix set up to track remote branch serverfix from origin.
上游快捷方式
当设置好跟踪分支后,可以通过简写@{upstream}
或@{u}
来引用它的上游分支。 所以在master
分支时并且它正在跟踪origin/master
时,可以使用git merge @{u}
命令来取代git merge origin/master
。
如果想要查看设置的所有跟踪分支,可以使用 git branch
的 -vv
选项。 这会将所有的本地分支列出来并且包含更多的信息,如每一个分支正在跟踪哪个远程分支与本地分支是否是领先、落后或是都有。
假如你看到了如下信息:
$ git branch -vv
iss53 7e424c3 [origin/iss53: ahead 2] forgot the brackets
master 1ae2a45 [origin/master] deploying index fix
* serverfix f8674d9 [teamone/server-fix-good: ahead 3, behind 1] this should do it
testing 5ea463a trying something new
这里可以看到 iss53
分支正在跟踪 origin/iss53
并且 “ahead” 是 2,意味着本地有两个提交还没有推送到服务器上。 也能看到 master
分支正在跟踪 origin/master
分支并且是最新的。 接下来可以看到 serverfix
分支正在跟踪 teamone
服务器上的 server-fix-good
分支并且领先 3 落后 1, 意味着服务器上有一次提交还没有合并入本地,同时本地有三次提交还没有推送。 最后看到 testing
分支并没有跟踪任何远程分支。
需要重点注意的一点是这些数字的值来自于你从每个服务器上最后一次抓取的数据。 这个命令并没有连接服务器,它只会告诉你关于本地缓存的服务器数据。 如果想要统计最新的领先与落后数字,需要在运行此命令前抓取所有的远程仓库。 可以像这样做:
$ git fetch --all; git branch -vv
拉取
当 git fetch
命令从服务器上抓取本地没有的数据时,它并不会修改工作目录中的内容。 它只会获取数据然后让你自己合并。 然而,有一个命令叫作 git pull
在大多数情况下它的含义是一个 git fetch
紧接着一个 git merge
命令。 如果有一个设置好的跟踪分支,不管它是显式地设置还是通过 clone
或 checkout
命令为你创建的,git pull
都会查找当前分支所跟踪的服务器与分支, 从服务器上抓取数据然后尝试合并入那个跟踪分支。
git pull
命令会将本地仓库代码更新至远程仓库中最新的版本,简单粗暴。而 fetch
命令只是将本地仓库没有而远程仓库有的代码拉取到本地,你需要使用 merge
命令进行合并,如果本地代码与远程仓库的代码有冲突,你需要解决冲突之后才能进行合并,这样可以避免一些莫名其妙的问题。
删除远程分支
假如你想要删除远程分支,可以运行带有 --delete
选项的 git push
命令来删除一个远程分支。 如果想要从服务器上删除 serverfix
分支,运行下面的命令:
$ git push origin --delete serverfix
To https://github.com/tiangoubot/learngit.git
- [deleted] serverfix
基本上这个命令做的只是从服务器上移除这个指针。 git 服务器通常会保留数据一段时间直到垃圾回收运行,所以如果不小心删除掉了,通常是很容易恢复的。
如果误删了远程分支,可以按照以下步骤恢复:
-
查看reflog,找到最后一次commitid
$ git reflog --date=iso
reflog
是reference log的意思,也就是引用log,记录HEAD在各个分支上的移动轨迹。选项--date=iso
,表示以标准时间格式展示。这里你肯定会问,为什么不用git log?git log是用来记录当前分支的commit log,分支都删除了,找不到commit log了。找到目标分支最后一次的commitid,例如:
$ git reflog --date=iso 5ddc9cb (HEAD -> testing) HEAD@{2020-09-22 11:27:29 +0800}: checkout: moving from master to testing aaf35a8 (master) HEAD@{2020-09-22 11:23:15 +0800}: clone: from https://github.com/tiangoubot/gitTest.git
-
检出分支
$ git checkout -b recovery_branch_name commitid
检出分支,本地就有分支了。
-
push到远程仓库
$ git push origin recovery_branch_name
至此,恢复完成。
变基
在 Git 中整合来自不同分支的修改主要有两种方法:merge
以及 rebase
。rebase
有人把它翻译成“变基”。
变基的基本操作
之前的分支合并中有一个例子,你会看到开发任务分叉到两个不同的分支,又各自提交了更新:
之前介绍过,整合分支最容易的方法是 merge
命令。 它会把两个分支的最新快照(C3
和 C4
)以及二者最近的共同祖先(C2
)进行三方合并,合并的结果是生成一个新的快照(并提交):
另一种方法:提取在C4
中打的补丁和修改,然后在 C3
的基础上应用一次。这个操作,在git中就叫做变基(rebase)。你可以使用 rebase
命令将提交到某一分支上的所有修改都移到另一分支上。
在这个例子中,你可以切换到experiment
分支上,然后将它变基到master
分支上:
$ git checkout experiment
$ git rebase master
它的原理是首先找到这两个分支(即当前分支 experiment
、变基操作的目标基底分支 master
) 的最近共同祖先 C2
,然后对比当前分支相对于该祖先的历次提交,提取相应的修改并存为临时文件, 然后将当前分支指向目标基底 C3
, 最后以此将之前另存为临时文件的修改依序应用。
现在回到 master
分支,进行一次快进合并。
$ git checkout master
$ git merge experiment
这两种整合方法的最终结果没有任何区别,但是变基使得提交历史更加整洁。merge
会保留更多的提交历史,所以查看提交历史(使用git log --pretty=formt:"%h %s" --graph
命令可以看到分叉情况),会看到很多的分叉,而使用rebase
,提交历史就只是一条直线。
一般我们这样做的目的是为了确保在向远程分支推送时能保持提交历史的整洁——例如向某个其他人维护的项目贡献代码时。 在这种情况下,你首先在自己的分支里进行开发,当开发完成时你需要先将你的代码变基到 origin/master
上,然后再向主项目提交修改。 这样的话,该项目的维护者就不再需要进行整合工作,只需要快进合并便可。
请注意,无论是通过变基,还是通过三方合并,整合的最终结果所指向的快照始终是一样的,只不过提交历史不同罢了。 变基是将一系列提交按照原有次序依次应用到另一分支上,而合并是把最终结果合在一起。
有趣的变基例子
假如你创建了一个分支 server
,为服务端添加了一些功能,提交了 C3
和 C4
。 然后从 C3
上创建了分支 client
,为客户端添加了一些功能,提交了 C8
和 C9
。 最后,你回到 server
分支,又提交了 C10
。
你希望将 client
中的修改合并到主分支并发布,但暂时并不想合并 server
中的修改, 因为它们还需要经过更全面的测试。这时,你就可以使用 git rebase
命令的 --onto
选项, 选中在 client
分支里但不在 server
分支里的修改(即 C8
和 C9
),将它们在 master
分支上整合:
$ git rebase --onto master server client
以上命令的意思是:“取出 client
分支,找出它从 server
分支分歧之后的补丁, 然后把这些补丁在 master
分支上整合,让 client
看起来像直接基于 master
修改一样”。
现在可以快进合并 master
分支了。(如图快进合并 master
分支,使之包含来自 client
分支的修改):
$ git checkout master
$ git merge client
接下来你决定将 server
分支中的修改也整合进来。 使用 git rebase <basebranch> <topicbranch>
命令可以直接将主题分支 (即本例中的 server
)变基到目标分支(即 master
)上。 这样做能省去你先切换到 server
分支,再对其执行变基命令的多个步骤。
$ git rebase master server
如图将 server
中的修改变基到 master
上 所示,server
中的代码被“续”到了 master
后面。
然后就可以快进合并主分支 master
了:
$ git checkout master
$ git merge server
# 合并后 client和server分支都不需要了,可以删除
$ git branch -d client
$ git branch -d server
变基的风险
要使用变基,就要遵守一条准则:只对尚未推送或分享给别人的本地修改执行变基操作清理提交历史,不对已推送至别处的提交执行变基操作。
变基操作的实质是丢弃一些现有的提交,然后相应地新建一些内容一样但实际上不同的提交。 如果你已经将提交推送至某个仓库,而其他人也已经从该仓库拉取提交并进行了后续工作,此时,如果你用 git rebase
命令重新整理了提交并再次推送,你的同事因此将不得不再次将他们手头的工作与你的提交进行整合,如果接下来你还要拉取并整合他们修改过的提交,事情就会变得一团糟。
让我们来看一个在公开的仓库上执行变基操作所带来的问题。 假设你从一个中央服务器克隆然后在它的基础上进行了一些开发。 你的提交历史如图所示:
然后,某人又向中央服务器提交了一些修改,其中还包括一次合并。 你抓取了这些在远程分支上的修改,并将其合并到你本地的开发分支,然后你的提交历史就会变成这样:
接下来,这个人又决定把合并操作回滚,改用变基;继而又用 git push --force
命令覆盖了服务器上的提交历史。 之后你从服务器抓取更新,会发现多出来一些新的提交。
结果就是你们两人的处境都十分尴尬。 如果你执行 git pull
命令,你将合并来自两条提交历史的内容,生成一个新的合并提交,最终仓库会如图所示:
此时如果你执行 git log
命令,你会发现有两个提交的作者、日期、日志居然是一样的,这会令人感到混乱。 此外,如果你将这一堆又推送到服务器上,你实际上是将那些已经被变基抛弃的提交又找了回来,这会令人感到更加混乱。 很明显对方并不想在提交历史中看到 C4
和 C6
,因为之前就是他把这两个提交通过变基丢弃的。
变基解决变基
如果真的遇到了类似的问题,那咋办啊?
实际上,Git 除了对整个提交计算 SHA-1 校验和以外,也对本次提交所引入的修改计算了校验和——即 “patch-id”。
如果团队中的某人强制推送并覆盖了一些你所基于的提交,你需要做的就是检查你做了哪些修改,以及他们覆盖了哪些修改。
如果你拉取被覆盖过的更新并将你手头的工作基于此进行变基的话,一般情况下 Git 都能成功分辨出哪些是你的修改,并把它们应用到新分支上。
举个例子,如果遇到前面提到的有人推送了经过变基的提交,并丢弃了你的本地开发所基于的一些提交那种情境(也就是上面的情境):
如果我们不是执行合并(也就是没有C8的提交),而是执行 git rebase teamone/master
, Git 将会:
- 检查哪些提交是我们的分支上独有的(C2,C3,C4,C6,C7)
- 检查其中哪些提交不是合并操作的结果(C2,C3,C4)
- 检查哪些提交在对方覆盖更新时并没有被纳入目标分支(只有 C2 和 C3,因为 C4 其实就是 C4')
- 把查到的这些提交应用在
teamone/master
上面
从而我们将得到与你将相同的内容又合并了一次,生成了一个新的提交 中不同的结果,如图 在一个被变基然后强制推送的分支上再次执行变基 所示:
要想上述方案有效,还需要对方在变基时确保 C4'
和 C4
是几乎一样的。 否则变基操作将无法识别,并新建另一个类似 C4
的补丁(而这个补丁很可能无法整洁的整合入历史,因为补丁中的修改已经存在于某个地方了)。
在本例中另一种简单的方法是使用 git pull --rebase
命令而不是直接 git pull
。 又或者你可以自己手动完成这个过程,先 git fetch
,再 git rebase teamone/master
。
如果你习惯使用 git pull
,同时又希望默认使用选项 --rebase
,你可以执行这条语句 git config --global pull.rebase true
来更改 pull.rebase
的默认配置。
如果你只对不会离开你电脑的提交执行变基,那就不会有事。 如果你对已经推送过的提交执行变基,但别人没有基于它的提交,那么也不会有事。 如果你对已经推送至共用仓库的提交上执行变基命令,并因此丢失了一些别人的开发所基于的提交, 那你就有大麻烦了。
如果你或你的同事在某些情形下决意要这么做,请一定要通知每个人执行 git pull --rebase
命令,这样尽管不能避免伤痛,但能有所缓解。
变基 VS 合并
了解了变基和合并,肯定会有疑惑,都能达到一样的目的,那哪种方式更好呢?
有人认为,仓库的提交历史即是记录实际发生过什么,它是针对历史的文档,本身就有价值,不能乱改。即使由合并产生的提交历史是一团糟,这些痕迹也应该被保留下来,让后人能够查阅。
还有人认为,提交历史是项目过程中发生的事,不必每件事都记录的那么清楚,可以允许丢失一些无关紧要的细节,保留主要的脉络就行了。
其实这个问题没有标准答案, Git 是一个非常强大的工具,它允许你对提交历史做许多事情,但每个团队、每个项目对此的需求并不相同。 既然你已经分别学习了两者的用法,相信你能够根据实际情况作出明智的选择。
总的原则就是:只对尚未推送或分享给别人的本地修改执行变基操作清理提交历史,不对已推送至别处的提交执行变基操作。
Git全文总结
终于要结束了,但是git还没有真正的学完,看完这些,只是了解了一些git比较基础的知识,跟多高级的操作,还需要继续学习,但是也勉强够用了,后续如果遇到一些坑,我会重新写一些文章来进行记录。