分支管理

创建与合并分支

在版本回退里,你已经知道,每次提交,Git 都把它们串成一条时间线,这条时间线就是一个分支。截止到目前,只有一条时间线,在 Git 里,这个分支叫主分支,即 master 分支。

HEAD 严格来说不是指向提交,而是指向 mastermaster 才是指向提交的,所以,HEAD 指向的就是当前分支。

所有也可以把 master 分支抽象地理解成一个指针,它指向在 master 提交的版本中最新的那一个版本

除了 master 分支,我们也可以创建其他的分支,比如 dev 分支,其实这就相当于创建了一个 dev 指针,它会指向在所有 dev 提交的版本中最新的版本,但是我们没有任何 commit,因此它也会指向 master

创建分支

git checkout -b dev

# Switched to a new branch 'dev'


# -b 选项表示创建分支并切换到该分支
# git branch dev
# git switch dev / git checkout dev


# 新版本提供了 git switch -c dev 能实现一样的功能

使用命令查看当前分支

git branch


# * dev
#   master

现在 commit 一次修改后的文件, 再切换回 master 分支

git checkout master
# git switch master

我们会发现,工作区的文件又恢复回 master 的状态了, 因为刚刚的提交是在 dev 分支下提交,master 分区的提交点并没有改变

合并分支

现在我们回到 master 分支 git checkout master

如果要把 dev 分支的工作成果合并到 master

git merge dev

# Updating a11a2e6..18ce4dd
# Fast-forward
#  readme.md | 2 ++
#  1 file changed, 2 insertions(+)

这次操作把 master 指针指向了 dev 指针, 且工作区的文件也变成了在 dev 里提交的状态

TIP

注意到上面的 Fast-forward 信息,Git 告诉我们,这次合并是“快进模式”,也就是直接把 master 指向 dev 的当前提交,所以合并速度非常快。

当然,也不是每次合并都能 Fast-forward,我们后面会讲其他方式的合并。

合并完成后,我们可以删除掉 dev 分支

git branch -d dev

# Deleted branch dev (was 18ce4dd).

再使用 git branch 查看,发现只剩下 master 分支了

因为创建、合并和删除分支非常快,所以 Git 鼓励你使用分支完成某个任务,合并后再删掉分支,这和直接在 master 分支上工作效果是一样的,但过程更安全。

解决冲突

然而在实际情况中合并分支都不会是顺利的。

现在手动创建一个 merge conflict

git switch -c feature

# vim readme.md 这步操作把任意一行内容改动


git commit -am 'feature change a line'

# 提交


git branch master

# 把同一行改成不同的内容


git commit -am 'master change a line'

# 提交

把 dev 分支合并到 master

git merge dev

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

现在冲突发生了,merge 失败, git 进入了 merge 模式,文件被改成了这样

<<<<<<< HEAD
nqhuqhn
=======
nsqyushuqhns yqusqwuy uy
>>>>>>> dev

显示出不同分支的冲突内容

现在有两个选项:

  1. 输入git status,git 会提示我们使用 git merge --abort 撤销 merge 操作
  2. 直接修改文件冲突的位置,改成我们想要的内容,再 add 后提交

如果你解决了冲突,现在使用git log --graph 就可以看到分支的合并图

分支管理策略

通常合并分支时,如果可能,git 会用Fast forward 模式,但这种模式下,删除分支后,会丢掉分支信息。

如果要强制禁用 Fast forward 模式,Git 就会在 merge 时生成一个新的 commit,这样,从分支历史上就可以看出分支信息。

使用--no-ff方式的git merge即可:

git merge --no-ff -m "merge with no-ff" dev

因为本次合并要创建一个新的 commit,所以加上-m 参数,把 commit 描述写进去。

合并后, 使用 git log 查看分支历史

分支策略

在实际开发中,我们应该按照几个基本原则进行分支管理:

首先,master 分支应该是非常稳定的,也就是仅用来发布新版本,平时不能在上面干活;

那在哪干活呢?干活都在 dev 分支上,也就是说,dev 分支是不稳定的,到某个时候,比如 1.0 版本发布时,再把 dev 分支合并到 master 上,在 master 分支发布 1.0 版本;

你和你的小伙伴们每个人都在 dev 分支上干活,每个人都有自己的分支,时不时地往 dev 分支上合并就可以了。

所以,团队合作的分支看起来就像这样:

img

Stash

软件开发中,bug 就像家常便饭一样。有了 bug 就需要修复,在 Git 中,由于分支是如此的强大,所以,每个 bug 都可以通过一个新的临时分支来修复,修复后,合并分支,然后将临时分支删除。

当你接到一个修复一个代号 101 的 bug 的任务时,很自然地,你想创建一个分支 issue-101 来修复它,但是,等等,当前正在 dev 上进行的工作还没有提交:

并不是你不想提交,而是工作只进行到一半,还没法提交,预计完成还需 1 天时间。但是,必须在两个小时内修复该 bug,怎么办?

幸好,Git 还提供了一个 stash 功能,可以把当前工作现场“储藏”起来,等以后恢复现场后继续工作:

现在,用 git status 查看工作区,就是干净的(除非有没有被 Git 管理的文件),因此可以放心地创建分支来修复 bug。

首先确定要在哪个分支上修复 bug,假定需要在 master 分支上修复,就从 master 创建临时分支:

git checkout master
# Switched to branch 'master'
# Your branch is ahead of 'origin/master' by 6 commits.
#   (use "git push" to publish your local commits)

git checkout -b issue-101
# Switched to a new branch 'issue-101'

修复完成, 提交后,开始合并分支

git switch master
# Switched to branch 'master'
# Your branch is ahead of 'origin/master' by 6 commits.
#   (use "git push" to publish your local commits)

git merge --no-ff -m "merged bug fix 101" issue-101
# Merge made by the 'recursive' strategy.
#  readme.txt | 2 +-
#  1 file changed, 1 insertion(+), 1 deletion(-)

现在,是时候接着回到 dev 分支干活了!

git switch dev

git status
# On branch dev
# nothing to commit, working tree clean
# 切换回 dev 分支,工作区是干净的


git stash list
# stash@{0}: WIP on dev: f52c633 add merge
# 查看 stash 状态,刚刚工作区的状态储存在了这里

有两种恢复方法:

  1. git stash apply 是恢复后,stash 内容并不删除,你需要用 git stash drop 来删除
  2. 另一种方式是用git stash pop,恢复的同时把 stash 内容也删了

TIP

可以多次 stash,恢复的时候,先用 git stash list 查看,然后恢复指定的 stash,用命令: git stash apply stash@{0}

Cherry-pick

master 分支上修复了 bug 后,我们要想一想,dev 分支是早期从 master 分支分出来的,所以,这个 bug 其实在当前 dev 分支上也存在。

同样的 bug,要在 dev 上修复,我们只需要把 4c805e2 fix bug 101 这个提交所做的修改“复制”到 dev 分支。注意:我们只想复制 4c805e2 fix bug 101 这个提交所做的修改,并不是把整个 master 分支 merge 过来。

为了方便操作,Git 专门提供了一个 cherry-pick 命令,让我们能复制一个特定的提交到当前分支:

git branch
# * dev
#   master
git cherry-pick 4c805e2
# [master 1d4b803] fix bug 101
#  1 file changed, 1 insertion(+), 1 deletion(-)

Git 自动给 dev 分支做了一次提交,注意这次提交的 commit 是 1d4b803,它并不同于 master 的 4c805e2,因为这两个 commit 只是改动相同,但确实是两个不同的 commit。用 git cherry-pick,我们就不需要在 dev 分支上手动再把修 bug 的过程重复一遍。

Feature

软件开发中,总有无穷无尽的新的功能要不断添加进来。

添加一个新功能时,你肯定不希望因为一些实验性质的代码,把主分支搞乱了,所以,每添加一个新功能,最好新建一个 feature 分支,在上面开发,完成后,合并,最后,删除该 feature 分支。

如果你开发到一半, 发现又不需要新功能,需要删除 feature 分支

如果要丢弃一个没有被合并过的分支,可以通过 git branch -D <name>强行删除。

多人协作

当你从远程仓库克隆时,实际上 Git 自动把本地的 master 分支和远程的 master 分支对应起来了,并且,远程仓库的默认名称是 origin。

要查看远程库的信息,用 git remote:

git remote
# origin

或者使用 git remote -v显示更详细的信息:

git remote -v
# origin  git@github.com:michaelliao/learngit.git (fetch)
# origin  git@github.com:michaelliao/learngit.git (push)

上面显示了可以抓取和推送的 origin 的地址。如果没有推送权限,就看不到 push 的地址。

推送分支

推送分支,就是把该分支上的所有本地提交推送到远程库。推送时,要指定本地分支,这样,Git 就会把该分支推送到远程库对应的远程分支上:

git push origin master

如果要推送其他分支,比如dev,就改成:

git push origin dev

但是,并不是一定要把本地分支往远程推送,那么,哪些分支需要推送,哪些不需要呢?

  • master 分支是主分支,因此要时刻与远程同步;

  • dev 分支是开发分支,团队所有成员都需要在上面工作,所以也需要与远程同步;

  • bug 分支只用于在本地修复 bug,就没必要推到远程了,除非老板要看看你每周到底修复了几个 bug;

  • feature 分支是否推到远程,取决于你是否和你的小伙伴合作在上面开发。

总之,就是在 Git 中,分支完全可以在本地自己藏着玩,是否推送,视你的心情而定!

抓取分支

多人协作时,大家都会往 master 和 dev 分支上推送各自的修改。

现在我们需要在 dev 上开发,就必须创建远程origindev 分支到本地:

git checkout -b dev origin/dev


# 名字最好和远程仓库的一样

现在我们可以在 dev 上继续修改

假设同时有另一个人也在他本地创建了 dev 分支,并且先你一步push 到远程

现在你完成了你的任务,也需要 push 到远程

git push origin dev
# To github.com:michaelliao/learngit.git
#  ! [rejected]        dev -> dev (non-fast-forward)
# error: failed to push some refs to 'git@github.com:michaelliao/learngit.git'
# hint: Updates were rejected because the tip of your current branch is behind
# hint: its remote counterpart. Integrate the remote changes (e.g.
# hint: 'git pull ...') before pushing again.
# hint: See the 'Note about fast-forwards' in 'git push --help' for details.

推送失败, 因为现在dev的最新提交和你试图推送的提交有冲突,解决办法也很简单,Git 已经提示我们,先用 git pull 把最新的提交从 origin/dev 抓下来,然后,在本地合并,解决冲突,再推送:

git pull
# There is no tracking information for the current branch.
# Please specify which branch you want to merge with.
# See git-pull(1) for details.
#
#     git pull <remote> <branch>
#
# If you wish to set tracking information for this branch you can do so with:
#
#     git branch --set-upstream-to=origin/<branch> dev

git pull也失败了,原因是没有指定本地dev分支与远程origin/dev分支的链接,根据提示,设置devorigin/dev的链接,再次 pull

git branch --set-upstream-to=origin/dev dev
# Branch 'dev' set up to track remote branch 'dev' from 'origin'.

git pull
# Auto-merging env.txt
# CONFLICT (add/add): Merge conflict in env.txt
# Automatic merge failed; fix conflicts and then commit the result.

这回 git pull 成功,但是合并有冲突,需要手动解决,解决的方法和分支管理中的解决冲突完全一样。解决后,提交,再 push

git commit -m "fix env conflict"
# [dev 57c53ab] fix env conflict
#
# $ git push origin dev
# Counting objects: 6, done.
# Delta compression using up to 4 threads.
# Compressing objects: 100% (4/4), done.
#     Writing objects: 100% (6/6), 621 bytes | 621.00 KiB/s, done.
# Total 6 (delta 0), reused 0 (delta 0)
#     To github.com:michaelliao/learngit.git
#     7a5e5dd..57c53ab  dev -> dev

Rebase

合并冲突

在上一节我们看到了,多人在同一个分支上协作时,很容易出现冲突。即使没有冲突,后 push 的童鞋不得不先 pull,在本地合并,然后才能 push 成功。

每次合并再 push 后,分支变成了这样:

git log --graph --pretty=oneline --abbrev-commit
# * d1be385 (HEAD -> master, origin/master) init hello
# *   e5e69f1 Merge branch 'dev'
# |\
# | *   57c53ab (origin/dev, dev) fix env conflict
# | |\
# | | * 7a5e5dd add env
# | * | 7bd91f1 add new env
# | |/
# * |   12a631b merged bug fix 101
# |\ \
# | * | 4c805e2 fix bug 101
# |/ /
# * |   e1e9c68 merge with no-ff
# |\ \
# | |/
# | * f52c633 add merge
# |/
# *   cf810e4 conflict fixed
# 比如你和小伙伴都从A创建了一个新分支B与C,你还在开发,但是小伙伴已经开发完毕,并且merge到了A
# A --> B
# |--> C -->A`

# 那么你开发完毕后,想要提交,但是A已经领先B一个版本了,
# 执行 (在B分支) git merge A`
# A --> B --> A``
# |           |
# |--> C -->A`

但是,这些合并的记录是完全无效的信息,那么用 rebase 就可以达到优雅的直线记录。

# 比如
# A   (C)     (B)
# |--> A` --> A``
# 如何实现呢?
# 返回到这个步骤,小伙伴已经开发完毕了,你也开发完了,并且他已经提交到了A`
# A --> B
# |--> C -->A`
# 执行 (在B分支) git rebase A (当然前提是A已经是pull后的,即A`)
# 现在 记录已经变这样了,非常优雅
# A --> A` --> A``

rebase 是如何做到的? 首先,git 会把 B 分支里面的每个 commit 取消掉; 其次,把上面的操作临时保存成 patch 文件,存在 .git/rebase 目录下; 然后,把 B 分支更新到最新的 A 分支; 最后,把上面保存的 patch 文件应用到 B 分支上;

原本分叉的提交现在变成一条直线了

通过push操作把本地分支推送到远程

再用git log看看效果:

git log --graph --pretty=oneline --abbrev-commit
# * 7e61ed4 (HEAD -> master, origin/master) add author
# * 3611cfe add comment
# * f005ed4 set exit=1
# * d1be385 init hello
# ...

远程分支的提交历史也是一条直线。

在 rebase 的过程中,也许会出现冲突 conflict。在这种情况,git 会停止 rebase 并会让你去解决冲突。在解决完冲突后,用 git add 命令去更新这些内容。 再执行 git rebase --continue

DANGER

根据上文来看,git-rebase 很完美,解决了我们的两个问题: 1.合并 commit 记录,保持分支整洁; 2.相比 merge 来说会减少分支合并的记录;

但是一切的前提是你正在操作的分支必须是仅仅存在本地的分支,如果不是,那么你提交到远程仓库后,实际上这条分支就会消失了。没错!提交的历史会不见。 那又怎么样?我的目的不就是让远程仓库提交记录看起来好看吗?OK,那么你是一个实打利己主义者,但问题出现的地方不在于你的分支和远程仓库,而是在小伙伴那里。 如果小伙伴同时也在这条分支上开发,他 pull 了 master,可能就会丢失 commit 记录了。

所以,使用 rebase 操作的前提是,所有的操作的 commits 都没提交到仓库过。

编辑 commit 信息

有时候,为了让我们的 commit 信息看起来好看点或更改一些已经提交的 commit 信息,需要使用 rebase 去编辑。

DANGER

请注意你对于 commit 做的任何操作都是有风险的,请明白你在做什么,最坏的情况会丢失了重要的 commit! (再看一眼,发现我废话好多 💩)

假如我想要修改前 3 个版本提交

# -i 表示交互模式,下面的指令输入后会使用编辑器打开一个文本页面
git rebase -i HEAD~3
pick 1f19e7c 4
pick 9feca99 Revert "2"
pick f0d40a6 new new 3
pick a7a7a80 add more trash

# Rebase a07f46c..e8d3b22 onto a07f46c (4 commands)
#
# Commands:
# p, pick <commit> = use commit
# r, reword <commit> = use commit, but edit the commit message
# e, edit <commit> = use commit, but stop for amending
# s, squash <commit> = use commit, but meld into previous commit
# f, fixup [-C | -c] <commit> = like "squash" but keep only the previous
#                    commit's log message, unless -C is used, in which case
#                    keep only this commit's message; -c is same as -C but
#                    opens the editor
# x, exec <command> = run command (the rest of the line) using shell
# b, break = stop here (continue rebase later with 'git rebase --continue')
# d, drop <commit> = remove commit
# l, label <label> = label current HEAD with a name
# t, reset <label> = reset HEAD to a label
# m, merge [-C <commit> | -c <commit>] <label> [# <oneline>]
#         create a merge commit using the original merge commit's
#         message (or the oneline, if no original merge commit was
#         specified); use -c <commit> to reword the commit message
# u, update-ref <ref> = track a placeholder for the <ref> to be updated
#                       to this position in the new commits. The <ref> is
#                       updated at the end of the rebase
#
# These lines can be re-ordered; they are executed from top to bottom.
#
# If you remove a line here THAT COMMIT WILL BE LOST.
#
# However, if you remove everything, the rebase will be aborted.
#

可以看到,这些 commit 记录是倒序输出的,和 log 看到的顺序相反,为什么呢? 因为 rebase 是从以前的 commit 开始向后逐个编辑的,最先编辑的放在最上面。

为什么要从旧的往前编辑呢? 因为这是一个变基命令——在 HEAD~3..HEAD 范围内的每一个修改了提交信息的提交及其 所有后裔 都会被重写。

WARNING

不要涉及任何已经推送到中央服务器的提交——这样做会产生一次变更的两个版本,因而使他人困惑。

现在我们可以通过编辑想要操作的 commit 前的 pick 变成相应的单词,再保存退出,git 就会按顺序执行我们的编辑。

相应的操作已经写在注释里了,非常方便。 如果你不小心退出了 vim,可以使用 git rebase --edit-todo 返回继续编辑,编辑完保存继续 git rebase --continue

进入 rebase 的执行状态时,输入git rebase --abort强制退出,或git rebase --skip跳过当前 commit 继续处理后面的 commit。

假如我现在想要编辑 a7a7a80 的trash单词为 garbage,则把该记录前的pick改成reword,再保存退出。

现在进入了 rebase 状态,途中如果有冲突出现了,便会中途停止,等解决了冲突(add/rm),便可以继续执行git rebase --continue