Git 分支

Git 分支

什么是分支?

Git 保存的不是文件的变化或者差异,而是一系列不同时刻的文件快照。 在进行提交操作时,Git 会保存一个提交对象,该提交对象会包含一个指向暂存内容快照的指针,以及作者的姓名和邮箱、提交时输入的信息以及指向它的父对象的指针。

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

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

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

如下图,此时Git 仓库中有五个对象:三个 blob 对象(保存着文件快照)、一个树对象(记录着目录结构和 blob 对象索引)以及一个提交对象(包含着指向前述树对象的指针和所有提交信息)。

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

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

分支管理

查看分支

当我们使用 git branch 命令,并不加任何参数时,就可以得到当前所有分支的列表:

1
2
3
$ git branch
  master
* test

ps:* 代表当前所在的分支,即当前 HEAD 指针所指向的分支。

我们可以在后面加上这两个参数,来获取到这个列表中已经合并或尚未合并到当前分支的分支。

1
2
git branch --merged 	# 查看哪些分支已经合并到当前分支
git branch --no-merged  # 查看所有包含未合并工作的分支

我们还可以通过 -v 选项,查看每一个分支的最后一次提交:

1
2
3
$ git branch -v
  master f876558 [ahead 1, behind 1] test
* test   da8f5f4 commit

创建分支

当我们要创建一个分支时,可以使用以下命令:

1
git branch [branch_name]

例如我们创建一个 testing 分支,这会在当前所在的提交对象上创建一个指针。

那么,Git 又是怎么知道当前在哪一个分支上呢? 也很简单,它有一个名为 HEAD 的特殊指针。

此时指针关系如下图:

想要新建一个分支并同时切换到那个分支上,你可以运行一个带有 -b 参数的 git checkout 命令:

1
2
3
4
git checkout -b [branch_name]
等价于
git branch [branch_name]
git checkout [branch_name]

切换分支

当我们要切换到一个已经存在的分支上时,可以使用下面这个命令将 HEAD 指向该分支:

1
git checkout [branch_name]

例如我们切换到 testing 分支上,此时指向关系如下图:

如果我们在这个新分支上做了修改,并将修改提交后,此时 testing 分支就会向前移动,如下图:

此时由于两个分支的版本不同,当我们再次切换回 master 分支时,此时会执行两个操作:

  1. 使 HEAD 指向 master。
  2. 将工作目录恢复成 master 分支所指向的快照内容。

如果我们再次在 master 分支上进行修改后提交,此时两个分支就会完全分叉,走上不同的路线,如下图:

此时我们就需要通过 merge 来对分支进行合并,使其重新回到统一的一个版本。

合并分支

当我们在特性分支上完成开发后,此时就需要将特性分支合并入 master 分支。此时我们就需要先切换到主分支,在主分支上执行 git merge

1
2
git checkout master
git merge [branch]

Git 会自行决定选取哪一个提交作为最优的共同祖先,并以此作为合并的基础。接着它会把两个分支的最新快照以及二者最近的共同祖先进行三方合并,合并的结果是生成一个新的快照(并提交)。

但事实上,只有在这些分支没有任何冲突时才能这么顺利的进行合并,如果存在冲突,我们必须要将冲突的内容解决后,才能进行合并。

合并冲突解决

如果你在两个不同的分支中,对同一个文件的同一个部分进行了不同的修改,Git 就没法合并它们。此时 Git 会暂停下来,等待你去解决合并产生的冲突。

1
2
3
4
5
$ git merge test
Removing test.md
Auto-merging README.md
CONFLICT (content): Merge conflict in README.md
Automatic merge failed; fix conflicts and then commit the result.

此时我们可以使用 git status 命令来查看那些因包含合并冲突而处于未合并状态的文件,并查看它的内容:

1
2
3
4
5
<<<<<<< HEAD
Hello world123123:`
=======
HELLO WORLD !!!
>>>>>>> test

此时 Git 用 ======= 分割了两个分支的冲突的内容,<<<<<<< 后面显示了 HEAD 所指向的版本,>>>>>>> 后面展示了 test 指向的版本。此时我们就需要进行权衡,选择应该怎么样去解决冲突。

当我们将 =======<<<<<<<>>>>>>> 删除后,并对冲突内容进行解决。此时我们可以对每个文件使用 git add 命令来将其标记为冲突已解决。 一旦暂存这些原本有冲突的文件,Git 就会将它们标记为冲突已解决。此时我们再进行 git commit,就会自动完成 merge 操作。

删除分支

当我们想要删除某个分支的时候,可以使用下面这个命令:

1
git branch -d [branch name]

如果该分支还未被 merge,则此时会报错

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 选项强制删除分支。

分支开发的工作流程

长期分支

在项目的开发过程中,为了维护不同层次的稳定性,通常会拥有几个长期存在的分支。大多数人采用下面这种开发模式,只在 master 分支上保留完全稳定的代码,而在 develop、next 等分支上进行后续的开发,当这些分支通过测试,达到稳定状态时就可以将它们并入到 master 分支中。

如上图,稳定分支的指针总是在提交历史中落后一大截,而前沿分支的指针往往比较靠前。

特性分支

特性分支是一种短期分支,它被用来实现单一特性或其相关工作。例如我们需要快速实现一个新功能,或者修复一些遗留的问题,就可以在特性分支上进行开发,然后将其并入主分支后再将其删除。

由于工作被分散到不同的流水线中,在不同的流水线中每个分支都仅与其目标特性相关,因此,在做代码审查之类的工作的时候就能更加容易地看出你做了哪些改动。 我们可以把做出的改动在特性分支中保留几分钟、几天甚至几个月,等它们成熟之后再合并,而不用在乎它们建立的顺序或工作进度。对于不想要的分支,也可以直接将其抛弃,并且不会造成任何影响。

远程分支

远程引用是对远程仓库的引用(指针),包括分支、标签等等。 可以通过 git ls-remote (remote) 来显式地获得远程引用的完整列表,或者通过 git remote show (remote) 获得远程分支的更多信息。 然而,一个更常见的做法是利用远程跟踪分支。

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

如上图,例如我们从 Git 服务器上 git clone 一个仓库时,会为自动将其该仓库命名为 origin,拉取它的所有数据,创建一个指向它的 master 分支的指针,并且在本地将其命名为 origin/master。 Git 也会给你一个与 origin 的 master 分支在指向同一个地方的本地 master 分支,这样我们就可以在本地进行开发工作。

推送

当你想要公开分享一个分支时,需要将其推送到有写入权限的远程仓库上,此时可以使用以下命令:

1
2
3
git push [remote] [branch]
# 如果想要修改远程仓库的分支名,可以使用下面这个命令
git push [remote] [local_branch_name]:[remote_branch_name]

跟踪

从一个远程跟踪分支检出一个本地分支会自动创建一个跟踪分支。 跟踪分支是与远程分支有直接关系的本地分支。 如果在一个跟踪分支上输入 git pull,Git 能自动地识别去哪个服务器上抓取、合并到哪个分支。(例如当克隆一个仓库时,自动创建的跟踪 origin/mastermaster 分支)。

如果我们想要设置一个新的跟踪分支,可以使用下面这个命令:

1
git checkout -b [branch] [remote]/[branch]

如果我们想要让已有的本地分支跟踪一个远程分支,或者是修改已有分支的跟踪目标,就可以使用下面这个命令:

1
2
3
git branch -u [remote]/[branch]
或者
git branch --set-upstream-to [remote]/[branch]

如果我们想查看设置的所有跟踪分支,可以使用下面这个命令:

1
git branch -vv

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

1
2
git fetch --all
git branch -vv

拉取

如果我们想要同步远程分支上的数据时,可以使用下面两种方式抓取本地没有的数据,并且更新本地数据库,移动 remote/branch 指针指向新的、更新后的位置。

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

    1
    2
    3
    4
    5
    6
    7
    8
    
    # 下载远端分支到本地
    git fetch [remote] [remote_branch]:[local_branch]
    # 比较差异
    git diff local_branch 
    # 合并分支
    git merge local_branch
    # 旧删除分支
    git branch -d local_branch
    
  • git pull:它其实相当于 git fetch + git merge,它会查找当前分支所跟踪的服务器与分支,从服务器上抓取数据然后尝试合并入那个远程分支。

    1
    
    git pull [remote] [remote_branch]:[local_branch]
    

虽然 git pull 简化了流程,但由于 git pull 会对本地工作目录造成修改,所以通常都会使用 git fetch 来拉取分支。

删除

当我们在某个分支上已经完成了开发,并将其合并到了远程仓库的 master 分支后,此时该分支就失去了作用,可以将其从服务器上删除。此时我们可以使用带有 --delete 选项的 git push 命令来删除一个远程分支:

1
git push [remote] --delete [branch]

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

Rebase

在 Git 中整合不同分支的方法除了 merge 以外,还有 rebase

什么是 Rebase?

例如下图这个场景,此时出现了两个不同的分支,并且各自又提交了更新,导致出现了分叉:

如果我们要整合这两个分支,最简单的方法就是使用 merge,它会两个分支的最新快照(C3 和 C4)以及二者最近的共同祖先(C2)进行三方合并,合并的结果是生成一个新的快照(并提交)。

但除此之外,还有另一种方法,即提取在 C4 中引入的补丁和修改,然后在 C3 的基础上应用一次。在 Git 中,这种操作就叫做 rebase(变基)。 你可以使用 rebase 命令将提交到某一分支上的所有修改都移至另一分支上。

1
2
3
git rebase [topicbranch] # 此时basebranch为当前HEAD指向的分支
or
git rebase [basebranch] [topicbranch]

rebase 的原理是首先找到这两个分支的最近共同祖先,然后对比当前分支相对于该祖先的历次提交,提取相应的修改并存为临时文件,然后将当前分支指向目标基底,最后以此将之前另存为临时文件的修改依序应用。

例如执行下面这个命令:

1
2
3
4
git checkout experiment
git rebase master
或者
git rebase master experiment # 等价于前两句,将master变基到experiment上,避免切换分支

此时就会将 C4 中的修改变基到 C3 上,如下图:

此时 experiment 与 master 处于同一条之间中,我们回到 master 分支上执行一次快进合并,即可再次回到一条直线:

1
2
git checkout master
git merge experiment

此时就已经完成了分支的合并,不仅和 merge 的效果相同,而且还是得提交历史更加整洁,是一条没有分叉的直线。

因此当我们需要确保在向远程分支推送时能保持提交历史的整洁时就可以使用 rebase,我们在自己的分支开发完毕后将代码 rebase 到 master 分支上,master 在进行快进合并即可整合分支。

除了直接 rebase 一个分支,rebase 还支持更细粒度的操作

1
2
3
git rebase --onto [branch] [from] [to]
# from 待合并片段的起始commitId(不包含)
# to 待合并片段的结束commitId(包含)

即取出 to 分支,找出处于 from 分支和 to 分支的共同祖先之后的修改,然后把它们在 base 分支上重放一遍。 以下图为例,执行 git rebase --onto master server client,此时会将 client 的 c8 和 c9 在 master 上重放:

Rebase 的风险

如果要使用 rebase,就必须记住这条原则:只对尚未推送或分享给别人的本地修改执行变基操作清理历史,从不对已推送至别处的提交执行变基操作。

假设这样一个场景,我们与项目的其他成员在同一个远程仓库中进行协作开发,此时我们拉取下来项目,并在其基础上进行开发,如图:

此时用户 A 也在该仓库进行开发,它首先提交了修改 c4 和 c5,并将它们 merge 得到 c6。此时我们使用 pull 将这些修改拉取到本地,此时提交历史如下图:

此时用户 A 对 merge 不满意,他想通过 rebase 来使提交记录更加清晰,于是将 merge 回滚后改为使用 rebase,并使用 git push --force 强制覆盖了提交历史。此时提交记录如下:

如果我们再次使用 pull 拉取数据,此时提交历史如下:

此时就出现了意料之外的情况:

  • 对我们而言,我们将相同的内容又合并了一次,生成了一个新的提交,并且用户 A 使用了 rebase,因此这两次提交的 log 中作者、日期、日志等信息是一模一样的。
  • 对于用户 A 而言,如果我们此时将修改 push 到远程仓库中,又再次将用户 A 丢弃的 c4 和 c6 提交记录找回,而这又违背了用户 A 简化提交历史的初衷。

那在这种情况下,有什么方法能解决这个问题呢?

可以使用 git pull --rebase 命令而不是直接 git pull。 又或者可以自己手动完成这个过程,先 git fetch,再 git rebase teamone/master。但这种方法需要每一个人都执行该命令,因此我们通常把变基命令当作是在推送前清理提交使之整洁的工具,并且只在从未推送至共用仓库的提交上执行变基命令,以避免这种场景的出现。

Licensed under CC BY-NC-SA 4.0
最后更新于 May 23, 2022 18:46 CST
Built with Hugo
主题 StackJimmy 设计