分支简介

Git 保存的不是文件的变化或者差异,而是一系列不同时刻文件快照。在进行提交操作时,Git 会保存一个提交对象(commit object)。该提交对象包含如下的内容:

  • 一个指向暂存内容快照指针

  • 作者的姓名和邮箱

  • 提交时输入的信息以及指向它的父对象的指针

    首次提交产生的提交对象没有父对象,普通提交操作产生的提交对象有一个父对象,而由多个分支合并产生的提交对象有多个父对象。

当使用 git commit 进行提交操作时,Git 会先计算每一个子目录的校验和(SHA-1 哈希算法),然后在 Git 仓库中将这些校验和保存为树对象。 随后,Git 便会创建一个提交对象,它除了包含上面提到的那些信息外,还包含指向这个树对象(项目根目录)的指针。如此一来,Git 就可以在需要的时候重现此次保存的快照。

举例

假设现在有一个工作目录,里面包含了三个将要被暂存和提交的文件。 暂存操作会为每一个文件计算校验和(SHA-1 哈希算法),然后会把当前版本的文件快照保存到 Git 仓库中(Git 使用 blob 对象来保存它们),最终将校验和加入到暂存区域等待提交:

1
2
$ git add README test.rb LICENSE
$ git commit -m 'The initial commit of my project'

image-20240322162237470

现在,Git 仓库中有五个对象:

  • 三个 blob 对象(保存着文件快照),每个文件对应一个blob对象

  • 一个树对象(记录着目录结构blob 对象索引)

  • 一个提交对象(包含着指向前述树对象的指针和所有提交信息)

做些修改后再次提交,那么这次产生的提交对象会包含一个指向上次提交对象(父对象)的指针。

image-20240322162833807

注意:首次提交的产生的commit object中的parent为空

Git 的分支,其实本质上仅仅是指向提交对象(commit object)可变指针。 Git 的默认分支名字是 master。 在多次提交操作之后,你其实已经有一个指向最后那个提交对象master 分支。 它会在每次的提交操作中自动向前移动。

Git 的 “master” 分支并不是一个特殊分支。 它就跟其它分支完全没有区别。 之所以几乎每一个仓库都有 master 分支,是因为 git init 命令默认创建它,并且大多数人都懒得去改动它。

下图展示了分支及提交历史的关系:

image-20240322163634261

分支创建

Git 是怎么创建新分支的呢? 很简单,它只是为你创建了一个可以移动的新的指针。 比如,创建一个 testing 分支, 你需要使用 git branch 命令:

1
$ git branch testing

这会在当前所在的提交对象上创建一个指针。

image-20240323205914983

那么,Git 又是怎么知道当前在哪一个分支上呢

也很简单,它有一个名为 HEAD特殊指针。 请注意它和许多其它版本控制系统(如 Subversion 或 CVS)里的 HEAD 概念完全不同。 在 Git 中,它是一个指针,指向当前所在的本地分支

(译注:将 HEAD 想象为当前分支的别名)。

在本例中,你仍然在 master 分支上。 因为 git branch 命令仅仅创建一个新分支,并不会自动切换到新分支中去。

image-20240324132721322

你可以简单地使用 git log 命令查看各个分支当前所指的对象。 提供这一功能的参数是 --decorate

1
2
3
4
$ git log --oneline --decorate
f30ab (HEAD -> master, testing) add feature #32 - ability to add new
34ac2 fixed bug #1328 - stack overflow under certain conditions
98ca9 initial commit of my project

正如你所见,当前 mastertesting分支均指向校验和以 f30ab 开头的提交对象。

1
f30ab (HEAD -> master, testing) add feature

注意:新版本的git中不用添加--decorate也可以显示HEAD指针

分支切换

要切换到一个已存在的分支,你需要使用 git checkout 命令。 我们现在切换到新创建的 testing 分支去:

1
$ git checkout testing

这样 HEAD 就指向 testing 分支了。

image-20240324133312362

此时通过git log命令,可以发现HEAD指针已经指向testing分支

1
2
$ git log --oneline
f30ab (HEAD -> testing, master) add feature #32 - ability to add new

那么,这样的实现方式会给我们带来什么好处呢? 现在不妨再提交一次:

1
2
3
# 当前已经处于testing分支
$ vim test.rb
$ git commit -a -m 'made a change'

此时通过git log命令,发现testing分支向前移动了,但是 master 分支却没有:

1
2
3
4
$ git log --oneline
87ab2 (HEAD -> testing) made a change
f30ab (master) add feature #32 - ability to add new
...

image-20240324134345432

现在我们切换回master分支看看:

1
2
3
4
$ git checkout master
$ git log --oneline
f30ab (HEAD -> master) add feature
....

可见master分支上的log中不不能看到testing分支的相关提交,此时各个提交的关系图如下:

image-20240324164918642

git checkout master命令做了两件事情:

  • 一是使 HEAD 指回 master 分支

  • 二是将工作目录恢复成 master 分支所指向的快照内容

也就是说,你现在做修改的话,项目将始于一个较旧的版本。 本质上来讲,这就是忽略 testing 分支所做的修改,以便于向另一个方向进行开发。

注意:

分支切换会改变工作目录中的文件

在切换分支时,一定要注意你工作目录里的文件会被改变。 如果是切换到一个较旧的分支,你的工作目录会恢复到该分支最后一次提交时的样子。 如果 Git 不能干净利落地完成这个任务,它将禁止切换分支

此时,我们不妨再稍微做些修改并提交:

1
2
$ vim test.rb
$ git commit -a -m 'made other changes'

现在,这个项目的提交历史已经产生了分叉(参见 项目分叉历史)。 因为刚才你创建了一个新分支,并切换过去进行了一些工作,随后又切换回 master 分支进行了另外一些工作。 上述两次改动针对的是不同分支:你可以在不同分支间不断地来回切换和工作,并在时机成熟时将它们合并起来。 而所有这些工作,你需要的命令只有 branchcheckoutcommit

image-20240324165619046

你可以简单地使用 git log 命令查看分叉历史。 运行 git log --oneline --decorate --graph --all ,它会输出你的提交历史、各个分支的指向以及项目的分支分叉情况:

1
2
3
4
5
6
7
$ git log --oneline --decorate --graph --all
* c2b9e (HEAD -> master) made other changes
| * 87ab2 (testing) made a change
|/
* f30ab add feature #32 - ability to add new formats to the
* 34ac2 fixed bug #1328 - stack overflow under certain conditions
* 98ca9 initial commit of my project

此时的HEAD指针指向master分支。

由于 Git 的分支实质上仅是包含所指对象校验和(长度为 40 的 SHA-1 值字符串)的文件,所以它的创建和销毁都异常高效。 创建一个新分支就相当于往一个文件中写入 41 个字节(40 个字符和 1 个换行符),如此的简单能不快吗?

分支的新建与合并

场景描述

让我们来看一个简单的分支新建与分支合并的例子,实际工作中你可能会用到类似的工作流。 你将经历如下步骤:

  1. 开发某个网站。

  2. 为实现某个新的需求,创建一个分支。

  3. 在这个分支上开展工作。

正在此时,你突然接到一个电话说有个很严重的问题需要紧急修补。 你将按照如下方式来处理:

  1. 切换到你的线上分支(production branch)。

  2. 为这个紧急任务新建一个分支,并在其中修复它。

  3. 在测试通过之后,切换回线上分支,然后合并这个修补分支,最后将改动推送到线上分支。

  4. 切换回你最初工作的分支上,继续工作。

新建分支开发正常需求

首先,我们假设你正在你的项目上工作,并且已经有一些提交。

image-20240325122812363

比如:

1
2
3
4
$ git log --oneline
7ed5243 (HEAD -> master) 实现功能XX
cf11a97 提交.gitignore
9292896 初次提交

现在,你已经决定要解决你的公司使用的问题追踪系统中的 #53 问题。 想要新建一个分支并同时切换到那个分支上,你可以运行一个带有 -b 参数的 git checkout 命令:

1
2
$ git checkout -b 'iss53'
Switched to a new branch 'iss53'

这条命令相当于如下2条命令的缩写:

1
2
$ git branch iss53 # 创建分支
$ git checkout iss53 # 切换分支

此时 git log --oneline的输出如下:

1
2
3
4
$ git log --oneline
7ed5243 (HEAD -> iss53, master) 实现功能XX
cf11a97 提交.gitignore
9292896 初次提交

image-20240325123414402

你继续在 #53 问题上工作,并且做了一些提交。 在此过程中,iss53 分支在不断的向前推进,因为你已经检出到该分支(也就是说,你的 HEAD 指针指向了 iss53 分支)

1
2
3
$ git commit -am "实现功能YY [issue 53]"
[iss53 6b79000] 实现功能YY [issue 53]
1 file changed, 1 insertion(+)

此时git log输出如下:

1
2
3
4
5
$ git log --oneline
6b79000 (HEAD -> iss53) 实现功能YY [issue 53]
7ed5243 (master) 实现功能XX
cf11a97 提交.gitignore
9292896 初次提交

image-20240325124954793

创建hotfix分支处理紧急问题

现在你接到那个电话,有个紧急问题等待你来解决。 有了 Git 的帮助,你不必把这个紧急问题和 iss53 的修改混在一起,你也不需要花大力气来还原关于 #53问题的修改,然后再添加关于这个紧急问题的修改,最后将这个修改提交到线上分支。 你所要做的仅仅是切换回 master 分支。

但是,在你这么做之前,要留意你的工作目录和暂存区里那些还没有被提交的修改,它可能会和你即将检出的分支产生冲突从而阻止 Git 切换到该分支。 最好的方法是,在你切换分支之前,保持好一个干净的状态。 有一些方法可以绕过这个问题(即,保存进度(stashing) 和 修补提交(commit amending)),我们会在 储藏与清理 中看到关于这两个命令的介绍。 现在,我们假设你已经把你的修改全部提交了,这时你可以切换回 master 分支了:

1
2
$ git checkout master
Switched to branch 'master'

此时,你的工作目录和你在开始 #53 问题之前一模一样,现在你可以专心修复紧急问题了。

请牢记:当你切换分支的时候,Git 会重置你的工作目录,使其看起来像回到了你在那个分支上最后一次提交的样子。 Git 会自动添加、删除、修改文件以确保此时你的工作目录和这个分支最后一次提交时的样子一模一样

接下来,你要修复这个紧急问题。 让我们建立一个针对该紧急问题的分支(hotfix branch),在该分支上工作直到问题解决

1
2
3
4
5
$ vim A.java # 修复问题

$ git commit -am '修复紧急问题[hotfix]'
[hotfix bf3f569] 修复紧急问题[hotfix]
1 file changed, 1 insertion(+)

此时查看git log日志如下:

1
2
3
4
5
6
7
$ git log --oneline --graph --all
* bf3f569 (HEAD -> hotfix) 修复紧急问题[hotfix]
| * 6b79000 (iss53) 实现功能YY [issue 53]
|/
* 7ed5243 (master) 实现功能XX
* cf11a97 提交.gitignore
* 9292896 初次提交

image-20240325130324493

将hotfix分支合并回master分支

你可以运行你的测试,确保你的修改是正确的,然后将hotfix分支上的改动合并回你的 master 分支来部署到线上。 你可以使用 git merge 命令来达到上述目的:

1
2
3
4
5
6
7
8
$ git checkout master  # 切换回master分支
Switched to branch 'master'

$ git merge hotfix # 合并分支
Updating 7ed5243..bf3f569
Fast-forward
A.java | 1 +
1 file changed, 1 insertion(+)

在合并的时候,你应该注意到了"快进(fast-forward)"这个词。 由于当前 master 分支所指向的提交是你当前提交(有关 hotfix 的提交)的直接上游,所以 Git 只是简单的将指针向前移动

换句话说,当你试图合并两个分支时,如果顺着一个分支走下去能够到达另一个分支,那么 Git 在合并两者的时候,只会简单的将指针向前推进(指针右移),因为这种情况下的合并操作没有需要解决的分歧——这就叫做 “快进(fast-forward)”。

fast-forward模式会直接移动指针,不会创建新的提交记录。

此时git log的输出如下:

1
2
3
4
5
6
7
$ git log --oneline --all --graph
* bf3f569 (HEAD -> master, hotfix) 修复紧急问题[hotfix]
| * 6b79000 (iss53) 实现功能YY [issue 53]
|/
* 7ed5243 实现功能XX
* cf11a97 提交.gitignore
* 9292896 初次提交

现在,最新的修改已经在 master 分支所指向的提交快照中,你可以着手发布该修复了。

image-20240325131420197

删除hotfix分支,恢复原先正常工作

关于这个紧急问题的解决方案发布之后,你准备回到被打断之前时的工作中。 然而,你应该先删除 hotfix 分支,因为你已经不再需要它了 —— master 分支已经指向了同一个位置。 你可以使用带 -d 选项的 git branch 命令来删除分支:

1
2
3
4
5
6
7
8
9
10
11
MINGW64 /d/coding/git-playground (master)
$ git branch -d hotfix
Deleted branch hotfix (was bf3f569).

$ git log --oneline --all --graph
* bf3f569 (HEAD -> master) 修复紧急问题[hotfix]
| * 6b79000 (iss53) 实现功能YY [issue 53]
|/
* 7ed5243 实现功能XX
* cf11a97 提交.gitignore
* 9292896 初次提交

现在你可以切换回你正在工作的分支继续你的工作,也就是针对 #53 问题的那个分支(iss53 分支):

1
2
3
4
5
$ vim B.java

$ git commit -am '修改B.java [issue 53]'
[iss53 6d6d7e6] 修改B.java [issue 53]
1 file changed, 1 insertion(+)

此时的git log输出如下:

1
2
3
4
5
6
7
8
$ git log --oneline --all --graph
* 6d6d7e6 (HEAD -> iss53) 修改B.java [issue 53] # iss53分支往后推进
* 6b79000 实现功能YY [issue 53]
| * bf3f569 (master) 修复紧急问题[hotfix] # master分支中已经合并了之前的hotfix
|/
* 7ed5243 实现功能XX
* cf11a97 提交.gitignore
* 9292896 初次提交

image-20240325132430471

你在 hotfix 分支上所做的工作并没有包含到 iss53 分支中。

  • 如果你需要拉取 hotfix 所做的修改,你可以使用 git merge master 命令将 master 分支合并入 iss53 分支

  • 或者你也可以等到 iss53 分支完成其使命,再将其合并回 master 分支

分支合并

假设你已经修正了 #53 问题,并且打算将你的工作合并入 master 分支。 为此,你需要合并 iss53 分支到 master 分支,这和之前你合并 hotfix 分支所做的工作差不多。 你只需要检出到你想合并入的分支,然后运行 git merge 命令:

1
2
3
4
$ git checkout master
Switched to branch 'master'

$ git merge iss53

此时会弹窗提示你输出一个提交信息:

image-20240325133120168

输入完成后:

1
2
3
4
5
$ git merge iss53
Merge made by the 'ort' strategy.
B.java | 2 ++
1 file changed, 2 insertions(+)

此时再次查看git log

1
2
3
4
5
6
7
8
9
10
$ git log --oneline --all --graph
* a324538 (HEAD -> master) Merge branch 'iss53' : iss53处理完成,需合并回master分支
|\
| * 6d6d7e6 (iss53) 修改B.java [issue 53]
| * 6b79000 实现功能YY [issue 53]
* | bf3f569 修复紧急问题[hotfix]
|/
* 7ed5243 实现功能XX
* cf11a97 提交.gitignore
* 9292896 初次提交

这和你之前合并 hotfix 分支的时候看起来有一点不一样。 在这种情况下,你的开发历史从一个更早的地方开始分叉开来(diverged)。 因为,master 分支所在提交并不是 iss53 分支所在提交的直接祖先,Git 不得不做一些额外的工作。 出现这种情况的时候,Git 会使用两个分支的末端所指的快照(C4C5)以及这两个分支的工作祖先(C2),做一个简单的三方合并

合并前的分支图如下:

image-20240325133814455

和之前将分支指针向前推进所不同的是,Git 将此次三方合并的结果做了一个新的快照并且自动创建一个新的提交指向它。 这个被称作一次合并提交,它的特别之处在于他有不止一个父提交

合并后的分支图如下:

image-20240325135637615

image-20240325134716055

image-20240325135924375

注意:

  • 看每条线上的*即可

image-20240325140319801

和之前将分支指针向前推进所不同的是,Git 将此次三方合并的结果做了一个新的快照(即:C6)并且自动创建一个新的提交指向它。 这个被称作一次合并提交,它的特别之处在于他有不止一个父提交(C6C5C4两个父提交)。

要指出的是,Git 会自行决定选取哪一个提交作为最优的共同祖先,并以此作为合并的基础;这和更加古老的 CVS 系统或者 Subversion (1.5 版本之前)不同,在这些古老的版本管理系统中,用户需要自己选择最佳的合并基础。 Git 的这个优势使其在合并操作上比其他系统要简单很多。

既然你的修改已经合并进来了,你已经不再需要 iss53 分支了。 现在你可以在任务追踪系统中关闭此项任务,并删除这个分支。

1
$ git branch -d iss53

遇到冲突时的分支合并

有时候合并操作不会如此顺利。 如果你在两个不同的分支中,对同一个文件的同一个部分进行了不同的修改,Git 就没法干净的合并它们。 如果你对 hotfix 问题的修改和有关 master 的修改都涉及到同一个文件的同一处,在合并它们的时候就会产生合并冲突,比如我们的master分支如下:

1
2
3
4
5
6
7
8
9
10
11
/d/coding/git-playground (master)
$ ll
total 0
-rw-r--r-- 1 86182 197609 0 3月 25 19:53 A.java
-rw-r--r-- 1 86182 197609 0 3月 25 19:53 B.java
-rw-r--r-- 1 86182 197609 0 3月 25 19:53 README.md

/d/coding/git-playground (master)
$ git log --oneline
519c74f (HEAD -> master) 提交git ignore
dcd138e first commit

此时我们创建了一个hotfix分支,并在该分支上对A.java的第一行做了修改:

1
2
3
4
5
6
7
8
9
10
11
12
$ cat A.java
// 在hotfix上的改动

$ git commit -am '修复XX问题[hotfix]'
[hotfix 068a534] 修复XX问题[hotfix]
1 file changed, 1 insertion(+)

MINGW64 /d/coding/git-playground (hotfix)
$ git log --oneline --all
068a534 (HEAD -> hotfix) 修复XX问题[hotfix]
519c74f (master) 提交git ignore
dcd138e first commit

期间,master分支针对A.java的第一行做了也做了修改:

1
2
3
4
5
6
7
8
9
10
11
MINGW64 /d/coding/git-playground (master)
$ vim A.java

MINGW64 /d/coding/git-playground (master)
$ git commit -am '修改A.java文件[master]'
[master 8507b7f] 修改A.java文件[master]
1 file changed, 1 insertion(+)

MINGW64 /d/coding/git-playground (master)
$ cat A.java
// master上也修改了第一行

此时的git log如下:

1
2
3
4
5
6
$ git log --oneline --all --graph
* 8507b7f (HEAD -> master) 修改A.java文件[master]
| * 068a534 (hotfix) 修复XX问题[hotfix]
|/
* 519c74f 提交git ignore
* dcd138e first commit

此时我们尝试将hotfix分支的内容合并入master分支:

1
2
3
4
5
MINGW64 /d/coding/git-playground (master)
$ git merge hotfix
Auto-merging A.java
CONFLICT (content): Merge conflict in A.java
Automatic merge failed; fix conflicts and then commit the result.

此时 Git 做了合并,但是提示在A.java中存在冲突,因此自动合并失败Automatic merge failed;,此时我们需要根据提示:fix conflicts and then commit the result.先解决A.java中存在的冲突,你可以在合并冲突后的任意时刻使用 git status 命令来查看那些因包含合并冲突而处于未合并(unmerged)状态的文件:

1
2
3
4
5
6
7
8
9
10
11
12
 MINGW64 /d/coding/git-playground (master|MERGING)
$ 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) # 通过git add可以将文件标记为已解决冲突
both modified: A.java

no changes added to commit (use "git add" and/or "git commit -a")

任何因包含合并冲突而有待解决的文件,都会以未合并状态标识出来:

1
2
3
MINGW64 /d/coding/git-playground (master|MERGING) # 注意此处会提示merging
git status -s
UU A.java # 两个红色的UU

image-20240325201334424

此时的git log信息如下:

1
2
3
4
5
6
7
MINGW64 /d/coding/git-playground (master|MERGING)
$ git log --oneline --all --graph
* 8507b7f (HEAD -> master) 修改A.java文件[master]
| * 068a534 (hotfix) 修复XX问题[hotfix]
|/
* 519c74f 提交git ignore
* dcd138e first commit

Git 会在有冲突的文件中加入标准的冲突解决标记,这样你可以打开这些包含冲突的文件然后手动解决冲突。 出现冲突的文件会包含一些特殊区段,比如此处的A.java,看起来像下面这个样子:

1
2
3
4
5
6
7
MINGW64 /d/coding/git-playground (master|MERGING)
$ cat A.java
<<<<<<< HEAD
// master上也修改了第一行
=======
// 在hotfix上的改动
>>>>>>> hotfix

这表示 HEAD 所指示的版本(此处HEAD -> master,也就是你的 master 分支所在的位置,因为你在运行 merge 命令的时候已经检出到了这个分支)在这个区段的上半部分(======= 的上半部分),而 hotfix 分支所指示的版本在 ======= 的下半部分。 为了解决冲突,你必须选择使用由 ======= 分割的两部分中的一个,或者你也可以自行合并这些内容。 例如,你可以通过把这段内容换成下面的样子来解决冲突:

1
2
3
MINGW64 /d/coding/git-playground (master|MERGING)
$ cat A.java
// master上也修改了第一行 + hotfix上的改动

解决冲突时需删除=======<<<<<<< HEAD>>>>>>> hotfix等内容。

上述的冲突解决后, <<<<<<< , ======= , 和 >>>>>>> 这些行被完全删除了。 在你解决了所有文件里的冲突之后,对每个文件使用 git add 命令来将其标记为冲突已解决。 一旦暂存这些原本有冲突的文件,Git 就会将它们标记为冲突已解决。

1
2
3
4
5
6
7
8
9
10
11
MINGW64 /d/coding/git-playground (master|MERGING)
$ git add A.java # 标记为已解决冲突

MINGW64 /d/coding/git-playground (master|MERGING) # 此时仓库的MERGING还在
$ git status # 查看仓库状态
On branch master
All conflicts fixed but you are still merging.
(use "git commit" to conclude merge) # 直接git commit就可以完成合并

Changes to be committed:
modified: A.java

如果你对结果感到满意,并且确定之前有冲突的的文件都已经暂存了,这时你可以输入 git commit 来完成合并提交。 默认情况下提交信息看起来像下面这个样子:

1
2
3
4
5
6
7
8
MINGW64 /d/coding/git-playground (master|MERGING) # MERGING还在
$ git commit
[master 152d12e] Merge branch 'hotfix'

MINGW64 /d/coding/git-playground (master) # MERGING没了
$ git status
On branch master
nothing to commit, working tree clean

此时可以通过git log查看仓库的状态:

1
2
3
4
5
6
7
8
$ git log --oneline --all --graph
* 152d12e (HEAD -> master) Merge branch 'hotfix' # 生成的合并提交
|\
| * 068a534 (hotfix) 修复XX问题[hotfix]
* | 8507b7f 修改A.java文件[master] # id为左侧那条线上的*号的,写不下才在右边
|/
* 519c74f 提交git ignore
* dcd138e first commit

此时如果通过idea中自带的git可视化工具查看,展示如下:

image-20240325203727263

可见:

image-20240325203840607

分支管理

查看有哪些分支

现在已经创建、合并、删除了一些分支,让我们看看一些常用的分支管理工具。

git branch 命令不只是可以创建与删除分支。 如果不加任何参数运行它,会得到当前所有分支(即包含所有合并或者未合并到当前分支的分支)的一个列表:

1
2
3
$ git branch
hotfix
* master

注意 master 分支前的 * 字符:它代表现在检出的那一个分支(也就是说,当前 HEAD 指针所指向的分支):

1
2
3
4
5
6
7
8
9
MINGW64 /d/coding/git-playground (master)
$ git log --oneline --all --graph
* 152d12e (HEAD -> master) Merge branch 'hotfix' #HEAD指向master分支
|\
| * 068a534 (hotfix) 修复XX问题[hotfix]
* | 8507b7f 修改A.java文件[master]
|/
* 519c74f 提交git ignore
* dcd138e first commit

这意味着如果在这时候提交,master 分支将会随着新的工作向前移动。 如果需要查看每一个分支的最后一次提交,可以运行 git branch -v 命令:

1
2
3
$ git branch -v
hotfix 068a534 修复XX问题[hotfix]
* master 152d12e Merge branch 'hotfix'

本例中hotfix已经merged到master上

查看哪些分支已经合并或者尚未合并到本分支

为了更好的演示本例,我们需要额外创建一个分支testing,并在该分支上做一些提交:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
MINGW64 /d/coding/git-playground (master)
$ git branch testing

MINGW64 /d/coding/git-playground (master)
$ git checkout testing
Switched to branch 'testing'

MINGW64 /d/coding/git-playground (testing)
$ touch C.java

MINGW64 /d/coding/git-playground (testing)
$ git commit -m '提交C.java[testing]'
[testing 578f31c] 提交C.java[testing]
1 file changed, 0 insertions(+), 0 deletions(-)
create mode 100644 C.java

此时我们切换回master分支:

1
2
$ git checkout master
Switched to branch 'master'

--merged--no-merged 这两个有用的选项可以过滤这个列表中已经合并尚未合并当前分支的分支。

  • 如果要查看哪些分支已经合并到当前分支,可以运行 git branch --merged:

1
2
3
4
MINGW64 /d/coding/git-playground (master)
$ git branch --merged
hotfix # hotfix之前已经合并到master
* master

此处的当前分支为master

因为之前已经合并了 hotfix 分支,所以现在看到它在列表中。 在这个列表中分支名字前没有 * 号的分支通常可以使用 git branch -d 删除掉;你已经将它们的工作整合到了另一个分支,所以并不会失去任何东西

1
2
3
MINGW64 /d/coding/git-playground (master)
$ git branch -d hotfix
Deleted branch hotfix (was 068a534).
  • 如果要查看哪些分支尚未合并到当前分支,可以运行 git branch --no-merged:

1
2
3
MINGW64 /d/coding/git-playground (master)
$ git branch --no-merged
testing # testing分支尚未合并到当前分支

此处的当前分支为master

因为它包含了还未合并的工作,尝试使用 git branch -d 命令删除它时会失败:

1
2
3
$ git branch -d testing
error: the branch 'testing' is not fully merged.
If you are sure you want to delete it, run 'git branch -D testing'

如果真的想要删除分支并丢掉那些工作,如同帮助信息里所指出的,可以使用 -D 选项强制删除它。

远程分支

基本使用

远程引用是对远程仓库的引用(指针),包括分支标签等等。

你可以通过 git ls-remote (remote) 来显式地获得远程引用的完整列表:

1
2
3
4
5
6
7
8
9
MINGW64 /d/coding/git-playground (master)
$ git ls-remote
From git@gitee.com:terrylikescoding/git-playground.git
ed9cf4a352ba55396c603eb9c0431653305dab5e HEAD
ed9cf4a352ba55396c603eb9c0431653305dab5e refs/heads/master

$ git ls-remote origin # remote可选
ed9cf4a352ba55396c603eb9c0431653305dab5e HEAD
ed9cf4a352ba55396c603eb9c0431653305dab5e refs/heads/master

或者通过 git remote show (remote) 获得远程分支的更多信息:

1
2
3
4
5
6
7
8
9
10
11
$ git remote show origin
* remote origin
Fetch URL: git@gitee.com:terrylikescoding/git-playground.git
Push URL: git@gitee.com:terrylikescoding/git-playground.git
HEAD branch: master
Remote branch:
master tracked
Local branch configured for 'git pull':
master merges with remote master
Local ref configured for 'git push':
master pushes to master (up to date)

然而,一个更常见的做法是利用远程跟踪分支

远程跟踪分支是远程分支状态的引用。 它们是你不能移动的本地引用,当你做任何网络通信操作时,它们会自动移动。 远程跟踪分支像是你上次连接到远程仓库时,那些分支所处状态的书签。

远程分支以 (remote)/(branch) 形式命名。 例如,如果你想要看你最后一次与远程仓库 origin 通信时 master 分支的状态,你可以查看 origin/master 分支。 你与同事合作解决一个问题并且他们推送了一个 iss53 分支,你可能有自己的本地 iss53 分支;但是在服务器上的分支会指向 origin/iss53 的提交。

让我们来看一个例子。 假设你的网络里有一个在 git.ourcompany.com 的 Git 服务器。 如果你从这里克隆,Git 的 clone 命令会为你自动将其命名为 origin,拉取它的所有数据,创建一个指向它的 master 分支的指针,并且在本地将其命名为 origin/master。 Git 也会给你一个与 origin 的 master 分支在指向同一个地方的本地 master 分支,这样你就有工作的基础。

image-20240326100506430

“origin” 并无特殊含义

远程仓库名字 “origin” 与分支名字 “master” 一样,在 Git 中并没有任何特别的含义一样。 同时 “master” 是当你运行 git init 时默认的起始分支名字,原因仅仅是它的广泛使用,“origin” 是当你运行 git clone 时默认的远程仓库名字。 如果你运行 git clone -o booyah,那么你默认的远程分支名字将会是 booyah/master

如果你在本地的 master 分支做了一些工作,然而在同一时间,其他人推送提交到 git.ourcompany.com 并更新了它的 master 分支,那么你的提交历史将向不同的方向前进。

image-20240326101153137

也许,只要你不与 origin 服务器连接,你的 origin/master 指针就不会移动。

演示说明:

本地git clone之后:

1
2
3
4
5
6
7
8
9
MINGW64 /d/tmp
$ git clone git@gitee.com:terrylikescoding/git-playground.git
Cloning into 'git-playground'...
remote: Enumerating objects: 18, done.
remote: Counting objects: 100% (18/18), done.
remote: Compressing objects: 100% (13/13), done.
remote: Total 18 (delta 4), reused 0 (delta 0), pack-reused 0
Receiving objects: 100% (18/18), done.
Resolving deltas: 100% (4/4), done.

在本地分支做了一些改动:

1
2
3
4
5
6
7
8
9
10
11
12
 MINGW64 /d/tmp/git-playground (master)
$ git log --oneline --all --graph
* 09af026 (HEAD -> master) 添加D.java
* 6bfb728 提交C.java
* ed9cf4a (origin/master, origin/HEAD) 提交.gitignore
* 152d12e Merge branch 'hotfix'
|\
| * 068a534 修复XX问题[hotfix]
* | 8507b7f 修改A.java文件[master]
|/
* 519c74f 提交git ignore
* dcd138e first commit

image-20240326103002585

期间其他人往orgin/master上提交了一些内容:

image-20240326101834006

本例中git clone时的版本为ed9cf4a3,此时通过git status命令:

1
2
3
4
5
MINGW64 /d/tmp/git-playground (master)
$ git status
On branch master
Your branch is ahead of 'origin/master' by 2 commits.
(use "git push" to publish your local commits)

可以发现git提示Your branch is ahead of 'origin/master' by 2 commits.,此时的本地master分支相比与刚刚clone时,往后推进了2个版本,而origin/master引用仍然指向版本ed9cf4a3

如果需要同步你的工作,运行 git fetch origin 命令。 这个命令查找 “origin” 是哪一个服务器(在本例中,它是 git.ourcompany.com),从中抓取本地没有的数据,并且更新本地数据库,移动 origin/master 指针指向新的、更新后的位置。

比如

1
2
3
4
5
6
7
8
9
MINGW64 /d/tmp/git-playground (master)
$ git fetch origin
remote: Enumerating objects: 9, done.
remote: Counting objects: 100% (9/9), done.
remote: Compressing objects: 100% (5/5), done.
remote: Total 6 (delta 1), reused 0 (delta 0), pack-reused 0
Unpacking objects: 100% (6/6), 694 bytes | 77.00 KiB/s, done.
From gitee.com:terrylikescoding/git-playground
ed9cf4a..81d81eb master -> origin/master

此时再次运行git log:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
MINGW64 /d/tmp/git-playground (master)
$ git log --oneline --all --graph
* 09af026 (HEAD -> master) 添加D.java
* 6bfb728 提交C.java
| * 81d81eb (origin/master, origin/HEAD) 其他人在master上的提交2 # 新的origin
| * 144b12a 其他人在master上的提交
|/
* ed9cf4a 提交.gitignore # 首次git clone时的版本 !!!!!!
* 152d12e Merge branch 'hotfix'
|\
| * 068a534 修复XX问题[hotfix]
* | 8507b7f 修改A.java文件[master]
|/
* 519c74f 提交git ignore
* dcd138e first commit

可视化的结果如下

image-20240326111149500

此时:

image-20240326111620621

关联多个远程仓库

为了演示有多个远程仓库与远程分支的情况,我们假定你有另一个内部 Git 服务器,仅用于你的 sprint 小组的开发工作。 这个服务器位于 git.team1.ourcompany.com。 你可以运行 git remote add 命令添加一个新的远程仓库引用到当前的项目,这个命令我们会在 Git 基础 中详细说明。 将这个远程仓库命名为 teamone,将其作为整个 URL 的缩写。

1
$ git remote add git.team1.ourcompany.com

image-20240326112058245

现在,可以运行 git fetch teamone 来抓取远程仓库 teamone 有而本地没有的数据。 因为那台服务器上现有的数据是 origin 服务器上的一个子集,所以 Git 并不会抓取数据而是会设置远程跟踪分支 teamone/master 指向 teamonemaster 分支。

image-20240326112254915

推送

当你想要公开分享一个分支时,需要将其推送到有写入权限的远程仓库上。 本地的分支并不会自动与远程仓库同步 - 你必须显式地推送想要分享的分支。 这样,你就可以把不愿意分享的内容放到私人分支上,而将需要和别人协作的内容推送到公开分支。

如果希望和别人一起在名为 serverfix分支上工作,你可以像推送第一个分支那样推送它。 运行 git push (remote) (branch):

1
2
3
4
5
6
7
8
$ git push origin serverfix
Counting objects: 24, done.
Delta compression using up to 8 threads.
Compressing objects: 100% (15/15), done.
Writing objects: 100% (24/24), 1.91 KiB | 0 bytes/s, done.
Total 24 (delta 2), reused 0 (delta 0)
To https://github.com/schacon/simplegit
* [new branch] serverfix -> serverfix

这里有些工作被简化了。 Git 自动将 serverfix 分支名字展开为 refs/heads/serverfix:refs/heads/serverfix,那意味着,“推送本地的 serverfix 分支来更新远程仓库上的 serverfix 分支。

我们将会详细学习 Git 内部原理refs/heads/ 部分,但是现在可以先把它放在儿。 你也可以运行 git push origin serverfix:serverfix,它会做同样的事 - 相当于它说,“推送本地的 serverfix 分支,将其作为远程仓库的 serverfix 分支”

可以通过这种格式来推送本地分支到一个命名不相同的远程分支。 如果并不想让远程仓库上的分支叫做 serverfix,可以运行 git push origin serverfix:awesomebranch 来将本地的 serverfix 分支推送到远程仓库上的 awesomebranch 分支。

下一次其他协作者从服务器上抓取数据时,他们会在本地生成一个远程分支 origin/serverfix,指向服务器的 serverfix 分支的引用:

1
2
3
4
5
6
7
$ git fetch origin
remote: Counting objects: 7, done.
remote: Compressing objects: 100% (2/2), done.
remote: Total 3 (delta 0), reused 3 (delta 0)
Unpacking objects: 100% (3/3), done.
From https://github.com/schacon/simplegit
* [new branch] serverfix -> origin/serverfix

要特别注意的一点是当抓取到新的远程跟踪分支时,本地不会自动生成一份可编辑的副本(拷贝)。 换一句话说,这种情况下,不会有一个新的 serverfix 分支 - 只有一个不可以修改的 origin/serverfix 指针。

可以运行 git merge origin/serverfix 将这些工作合并到当前所在的分支。 如果想要在自己的 serverfix 分支上工作,可以将其建立在远程跟踪分支之上:

1
2
3
$ git checkout -b serverfix origin/serverfix
Branch serverfix set up to track remote branch serverfix from origin.
Switched to a new branch 'serverfix'

这会给你一个用于工作的本地分支,并且起点位于 origin/serverfix

跟踪分支(上游分支upstream)

创建跟踪分支

从一个远程跟踪分支检出一个本地分支会自动创建一个叫做 “跟踪分支”(有时候也叫做 “上游分支”)。 跟踪分支是与远程分支有直接关系的本地分支。 如果在一个跟踪分支上输入 git pull,Git 能自动地识别去哪个服务器上抓取、合并到哪个分支

克隆一个仓库时,它通常会自动地创建一个跟踪 origin/mastermaster 分支。 然而,如果你愿意的话可以设置其他的跟踪分支 - 其他远程仓库上的跟踪分支,或者不跟踪 master 分支。 最简单的就是之前看到的例子,运行 git checkout -b [branch] [remotename]/[branch]。 这是一个十分常用的操作所以 Git 提供了 --track 快捷方式:

1
2
3
4
 MINGW64 /d/tmp/git-playground (master)
$ git checkout --track origin/serverfix
Switched to a new branch 'serverfix'
branch 'serverfix' set up to track 'origin/serverfix'.

可以看到branch 'serverfix' set up to track 'origin/serverfix'.

如果想要将本地分支与远程分支设置为不同名字,你可以轻松地增加一个不同名字的本地分支的上一个命令:

1
2
3
4
MINGW64 /d/tmp/git-playground (serverfix)
$ git checkout -b sf origin/serverfix
Switched to a new branch 'sf'
branch 'sf' set up to track 'origin/serverfix'.

现在,本地分支 sf 会自动从 origin/serverfix 拉取。

设置已有的本地分支跟踪一个刚刚拉取下来的远程分支,或者想要修改正在跟踪的上游分支,你可以在任意时间使用 -u--set-upstream-to 选项运行 git branch 来显式地设置。

git branch

-u

–set-upstream-to=

Set up 's tracking information so is considered 's upstream branch. If no is specified, then it defaults to the current branch.

1
2
$ git branch -u origin/serverfix
Branch serverfix set up to track remote branch serverfix from origin.

查看跟踪分支

如果想要查看设置的所有跟踪分支,可以使用 git branch-vv 选项。 这会将所有的本地分支列出来并且包含更多的信息,如每一个分支正在跟踪哪个远程分支与本地分支是否是领先、落后或是都有。

-v

-vv

–verbose

  • When in list mode, show sha1 and commit subject line for each head, along with relationship to upstream branch (if any).

  • If given twice(即:-vv), print the path of the linked worktree (if any) and the name of the upstream branch, as well (see also git remote show <remote>). Note that the current worktree’s HEAD will not have its path printed (it will always be your current directory).

建议直接使用-vv,可以看到最详细的信息,比如:

1
2
3
4
5
$ 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 分支并且领先 2 落后 1,意味着服务器上有一次提交还没有合并入同时本地有三次提交还没有推送。 最后看到 testing 分支并没有跟踪任何远程分支。

需要重点注意的一点是这些数字的值来自于你从每个服务器上最后一次抓取(fetch)的数据。 这个命令并没有连接服务器,它只会告诉你关于本地缓存的服务器数据。 如果想要统计最新的领先与落后数字,需要在运行此命令前抓取所有的远程仓库。 可以像这样做

1
2
$ git fetch --all # Fetch all remotes.
$ git branch -vv

拉取

git fetch 命令从服务器上抓取本地没有的数据时,它并不会修改工作目录中的内容。 它只会获取数据然后让你自己合并

然而,有一个命令叫作 git pull大多数情况下它的含义是一个 git fetch 紧接着一个 git merge 命令。 如果有一个像之前章节中演示的设置好的跟踪分支,不管它是显式地设置还是通过 clonecheckout 命令为你创建的,git pull 都会查找当前分支所跟踪的服务器与分支,从服务器上抓取数据然后尝试合并入那个远程分支。

由于 git pull 的魔法经常令人困惑所以通常单独显式地使用 fetchmerge 命令会更好一些。

删除远程分支

设你已经通过远程分支做完所有的工作了 - 也就是说你和你的协作者已经完成了一个特性并且将其合并到了远程仓库的 master 分支(或任何其他稳定代码分支)。 可以运行带有 --delete 选项的 git push 命令来删除一个远程分支。 如果想要从服务器上删除 serverfix 分支,运行下面的命令:

1
2
3
$ git push origin --delete serverfix
To https://github.com/schacon/simplegit
- [deleted] serverfix

基本上这个命令做的只是从服务器上移除这个指针。 Git 服务器通常会保留数据一段时间直到垃圾回收运行,所以如果不小心删除掉了,通常是很容易恢复的。