* cb932a6 commit A
可以看到,使用 rebase
方法形成的提交历史是完全线性的,同时相比 merge
方法少了一次 merge
提交,看上去更加整洁。
一个看上更整洁的提交历史有什么好处?
满足某些开发者的洁癖。
当你因为某些 bug 需要回溯提交历史时,更容易定位到 bug 是从哪一个提交引入。尤其是当你需要通过 git bisect
从几十上百个提交中排查 bug,或者有一些体量较大的功能分支需要频繁的从远程的主分支拉取更新时。
使用 rebase
来将远程的变更整合到本地仓库是一种更好的选择。用 merge
拉取远程变更的结果是,每次你想获取项目的最新进展时,都会有一个多余的 merge
提交。而使用 rebase
的结果更符合我们的本意:我想在其他人的已完成工作的基础上进行我的更改。
当我们仅仅只想修改最近的一次提交时,使用 git commit --amend
会更加方便。
它适用于以下场景:
我们刚刚完成了一次提交,但还没有推送到公共的分支。
突然发现上个提交还留了些小尾巴没有完成,比如一行忘记删除的注释或者一个很小的笔误,我们可以很快速的完成修改,但又不想再新增一个单独的提交。
或者我们只是觉得上一次提交的提交信息写的不够好,想做一些修改。
这时候我们可以添加新增的修改(或跳过),使用 git commit --amend
命令执行提交,执行后会进入一个新的编辑器窗口,可以对上一次提交的提交信息进行修改,保存后就会将所做的这些更改应用到上一次提交。
如果我们已经将上一次提交推送到了远程的分支,现在再执行推送将会提示出错并被拒绝,在确保该分支不是一个公共分支的前提下,我们可以使用 git push --force
强制推送。
注意与 rebase
一样,Git 在内部并不会真正地修改并替换上一个提交,而是创建了一个全新的提交并重新指向这个新的提交。
git rebase
命令有标准和交互两种模式,之前的示例我们用的都是默认的标准模式,在命令后添加 -i
或 --interactive
选项即可使用交互模式。
我们前面提到, rebase
是「在另一个基端之上重新应用提交」,而在重新应用的过程中,这些提交会被重新创建,自然也可以进行修改。在 rebase
的标准模式下,当前工作分支的提交会被直接应用到传入分支的顶端;而在交互模式下,则允许我们在重新应用之前通过编辑器以及特定的命令规则对这些提交进行合并、重新排序及删除等重写操作。
两者最常见的使用场景也因此有所不同:
标准模式常用于在当前分支中集成来自其他分支的最新修改。
交互模式常用于对当前分支的提交历史进行编辑,如将多个小提交合并成大的提交。
虽然我们之前的示例都是在不同的两个分支之间执行 rebase 操作,但事实上 rebase 命令传入的参数并不仅限于分支。
任何的提交引用,都可以被视作有效的 rebase
基底对象,包括一个提交 ID、分支名称、标签名称或 HEAD~1
这样的相对引用。
自然地,假如我们对当前分支的某次历史提交执行 rebase
,其结果就是会将这次提交之后的所有提交重新应用在当前分支,在交互模式下,即允许我们对这些提交进行更改。
终于进入到本文的主题,前面提到,假如我们在交互模式对当前分支的某次提交执行 rebase
,即(间接)实现了对这次提交之后的所有提交进行重写。接下来我们将通过下面的示例进行详细介绍。
假设我们在 feature
分支有如下提交:
74199cebdd34d107bb67b6da5533a2e405f4c330 (HEAD -> feature) commit F
e7c7111d807c1d5209b97a9c75b09da5cd2810d4 commit E
d9623b0ef9d722b4a83d58a334e1ce85545ea524 commit D
73deeedaa944ef459b17d42601677c2fcc4c4703 commit C
c50221f93a39f3474ac59228d69732402556c93b commit B
ef1372522cdad136ce7e6dc3e02aab4d6ad73f79 commit A
接下来我们将要执行的操作是:
将 B、C 合并为一个新的提交 ,并仅保留原提交 C 的提交信息
删除提交 D
将提交 E 移动到提交 F 之后并重新命名(即修改提交信息)为提交 H
在提交 F 中加入一个新的文件更改,并重新命名为提交 G
由于我们需要修改的提交是 B→C→D→E,因此我们需要将提交 A 作为新的「基端」,提交 A 之后的所有提交会被重新应用:
git rebase -i ef1372522cdad136ce7e6dc3e02aab4d6ad73f79 # 参数是提交 A 的 ID
接下来会进入到如下的编辑器界面:
pick c50221f commit B
pick 73deeed commit C
pick d9623b0 commit D
pick e7c7111 commit E
pick 74199ce commit F
# 变基 ef13725..74199ce 到 ef13725(5 个提交)
# 命令:
# p, pick <提交> = 使用提交
# r, reword <提交> = 使用提交,但修改提交说明
# e, edit <提交> = 使用提交,进入 shell 以便进行提交修补
# s, squash <提交> = 使用提交,但融合到前一个提交
# f, fixup <提交> = 类似于 "squash",但丢弃提交说明日志
# x, exec <命令> = 使用 shell 运行命令(此行剩余部分)
# b, break = 在此处停止(使用 'git rebase --continue' 继续变基)
# d, drop <提交> = 删除提交
......
(注意上面提交 ID 之后的提交信息只起到描述作用,在这里修改它们不会有任何效果。)
具体的操作命令在编辑器的注释中已解释的相当详细,所以我们直接进行如下操作:
对提交 B、C 作如下修改:
pick c50221f commit B
f 73deeed commit C
由于提交 B 是这些提交中的第一个,因此我们无法对其执行 squash
或者 fixup
命令(没有前一个提交了),我们也不需要对提交 B 执行 reword
命令以修改其提交信息,因为之后在将提交 C 融合到提交 B 中时,会允许我们对融合之后的提交信息进行修改。
注意该界面提交的展示顺序是从上到下由旧到新,因此我们将提交 C 的命令改为 s(或 squash)
或者 f(或 fixup)
会将其融合到(上方的)前一个提交 B,两个命令的区别为是否保留 C 的提交信息。
删除提交 D:
d d9623b0 commit D
执行 rebase
的过程中可能会发生冲突,这时候 rebase
会暂时中止,需要我们编辑冲突的文件去手动合并冲突。解决冲突后通过 git add/rm <conflicted_files>
将其标记为已解决,然后执行 git rebase --continue
可以继续之后的 rebase
步骤;或者也可以执行 git rebase --abort
放弃 rebase
操作并恢复到操作之前的状态。
由于我们上移了提交 F 的位置,因此接下来将执行对 F 的 edit
操作。这时将进入一个新的 Shell 会话:
停止在 74199ce... commit F
您现在可以修补这个提交,使用
git commit --amend
当您对变更感到满意,执行
git rebase --continue
我们添加一个新的代码文件并执行 git commit --amend
将其合并到当前的上一个提交(即 F),然后在编辑器界面中将其提交信息修改为 commit G
,最后执行 git rebase --continue
继续 rebase
操作。
最后执行对提交 E 的 reword
操作,在编辑器界面中将其提交信息修改为 commit H
。
大功告成!最后让我们确认一下 rebase
之后的提交历史:
64710dc88ef4fbe8fe7aac206ec2e3ef12e7bca9 (HEAD -> feature) commit H
8ab4506a672dac5c1a55db34779a185f045d7dd3 commit G
1e186f890710291aab5b508a4999134044f6f846 commit C
ef1372522cdad136ce7e6dc3e02aab4d6ad73f79 commit A
完全符合预期,同时也可以看到提交 A之后的所有提交 ID 都已经发生了改变,这也印证了我们之前所说的 Git 重新创建了这些提交。
另一种使用 rebase
的常见场景是在推送到远程进行合并之前执行 rebase
,一般这样做的目的是为了确保提交历史的整洁。
我们首先在自己的功能分支里进行开发,当开发完成时需要先将当前功能分支 rebase
到最新的主分支上,提前解决可能出现的冲突,然后再向远程提交修改。 这样的话,远程仓库的主分支维护者就不再需要进行整合且创建一条额外的 merge
提交,只需要执行快进合并即可。即使是在多个分支并行开发的情况,最终也能得到一条完全线性的提交历史。
我们可以通过 rebase
对两个分支进行对比,取出相应的修改,然后应用到另一个分支上。例如:
F---G patch
D---E feature
A---B---C master
假设我们基于 feature
分支的提交 D 创建了分支 patch
,并且新增了提交 F、G,现在我们想将 patch
所做的更改合并到 master
并发布,但暂时还不想合并 feature
,这种情况下可以使用 rebase
的 --onto <branch>
选项:
git rebase —onto master feature patch
以上操作将取出 patch
分支,对比它基于 feature
所做的更改, 然后把这些更改在 master
分支上重新应用,让 patch
看起来就像直接基于 master
进行更改一样。执行后的 patch
如下:
A---B---C---F'---G' patch
然后我们可以切换到 master
分支,并对 patch
执行快进合并:
git checkout master
git merge patch
Git 在最近的某个版本起,直接运行 git pull
会有如下提示消息:
warning: 不建议在没有为偏离分支指定合并策略时执行 pull 操作。 您可以在执行下一次 pull 操作之前执行下面一条命令来抑制本消息:
git config pull.rebase false # 合并(缺省策略)
git config pull.rebase true # 变基
git config pull.ff only # 仅快进
......
原来 git pull
时也可以通过 rebase
来进行合并,这是因为 git pull
实际上等于 git fetch
+ git merge
,我们可以在第二步直接用 git rebase
替换 git merge
来合并 fetch
取得的变更,作用同样是避免额外的 merge
提交以保持线性的提交历史。
两者的区别在上文中已进行过对比,我们可以把对比示例中的 Matser
分支当成远程分支,把 Feature
分支当成本地分支,当我们在本地执行 git pull
时,其实就是拉取 Master
的更改然后合并到 Feature
分支。如果两个分支都有不同的提交,默认的 git merge
方式会生成一个单独的 merge 提交以整合这些提交;而使用 git rebase
则相当于基于远程分支的最新提交重新创建本地分支,然后再重新应用本地所添加的提交。
具体的使用方式有多种:
每次执行 pull 命令时添加特定选项: git pull --rebase
。
为当前仓库设定配置项: git config pull.rebase true
,在 git config
后添加 --global
选项可以使该配置项对所有仓库生效。
从以上场景来看 rebase
功能非常强大,但我们也需要意识到它不是万能的,甚至对新手来说有些危险,稍有不慎就会发现 git log
里的提交不见了,或者卡在 rebase
的某个步骤不知道如何恢复。
我们上面已经提到了 rebase
有保持整洁的线性提交历史的优点,但也需要意识到它有以下潜在的弊端:
如果涉及到已经推送过的提交,需要强制推送才能将本地 rebase
后的提交推送到远程。因此绝对不要在一个公共分支(也就是说还有其他人基于这个分支进行开发)执行 rebase
,否则其他人之后执行 git pull
会合并出一条令人困惑的本地提交历史,进一步推送回远程分支后又会将远程的提交历史打乱(详见Rebase and the golden rule explained),较严重的情况下可能会对你的人身安全带来风险。
对新手不友好,新手很有可能在交互模式中误操作「丢失」某些提交(但其实是能够找回的)。
假如你频繁的使用 rebase
来集成主分支的更新,一个潜在的后果是你会遇到越来越多需要合并的冲突。尽管你可以在 rebase
过程中处理这些冲突,但这并非长久之计,更推荐的做法是频繁的合入主分支然后创建新的功能分支,而不是使用一个长时间存在的功能分支。
另外有一些观点是我们应该尽量避免重写提交历史:
有一种观点认为,仓库的提交历史即是 记录实际发生过什么。 它是针对历史的文档,本身就有价值,不能乱改。 从这个角度看来,改变提交历史是一种亵渎,你使用 谎言 掩盖了实际发生过的事情。 如果由合并产生的提交历史是一团糟怎么办? 既然事实就是如此,那么这些痕迹就应该被保留下来,让后人能够查阅。
以及频繁的使用 rebase
可能会使从历史提交中定位 bug 变得更加困难,详见 Why you should stop using Git rebase。
在交互式模式下进行 rebase
并对提交执行 squash
或 drop
等命令后,会从分支的 git log
中直接删除提交。如果你不小心操作失误,会以为这些提交已经永久消失了而吓出一身冷汗。
但这些提交并没有真正地被删除,如上所说,Git 并不会修改(或删除)原来的提交,而是重新创建了一批新的提交,并将当前分支顶端指向了新提交。因此我们可以使用 git reflog
找到并且重新指向原来的提交来恢复它们,这会撤销整个 rebase
。感谢 Git ,即使你执行 rebase
或者 commit --amend
等重写提交历史的操作,它也不会真正地丢失任何提交。
reflogs 是 Git 用来记录本地仓库分支顶端的更新的一种机制,它会记录所有分支顶端曾经指向过的提交,因此 reflogs 允许我们找到并切换到一个当前没有被任何分支或标签引用的提交。
每当分支顶端由于任何原因被更新(通过切换分支、拉取新的变更、重写历史或者添加新的提交),一条新的记录将被添加到 reflogs 中。如此一来,我们在本地所创建过的每一次提交都一定会被记录在 reflogs 中。即使在重写了提交历史之后, reflogs 也会包含关于分支的旧状态的信息,并允许我们在需要时恢复到该状态。
注意 reflogs 并不会永久保存,它有 90 天的过期时间。
我们从上一个例子继续,假设我们想恢复 feature
分支在 rebase
之前的 A→B→C→D→E→F 提交历史,但这时候的 git log
中已经没有后面 5 个提交,所以需要从 reflogs 中寻找,运行 git reflog
结果如下:
64710dc (HEAD -> feature) HEAD@{0}: rebase (continue) (finish): returning to refs/heads/feature
64710dc (HEAD -> feature) HEAD@{1}: rebase (continue): commit H
8ab4506 HEAD@{2}: rebase (continue): commit G
1e186f8 HEAD@{3}: rebase (squash): commit C
c50221f HEAD@{4}: rebase (start): checkout ef1372522cdad136ce7e6dc3e02aab4d6ad73f79
74199ce HEAD@{5}: checkout: moving from master to feature
......
reflogs
完整的记录了我们切换分支并进行 rebase
的全过程,继续向下检索,我们找到了从 git log
中消失的提交 F:
74199ce HEAD@{15}: commit: commit F
接下来我们通过 git reset
将 feature
分支的顶端重新指向原来的提交 F:
# 我们想将工作区中的文件也一并还原,因此使用了--hard选项
$ git reset --hard 74199ce
HEAD 现在位于 74199ce commit F
再运行 git log
会发现一切又回到了从前:
74199cebdd34d107bb67b6da5533a2e405f4c330 (HEAD -> feature) commit F
e7c7111d807c1d5209b97a9c75b09da5cd2810d4 commit E
d9623b0ef9d722b4a83d58a334e1ce85545ea524 commit D
73deeedaa944ef459b17d42601677c2fcc4c4703 commit C
c50221f93a39f3474ac59228d69732402556c93b commit B
ef1372522cdad136ce7e6dc3e02aab4d6ad73f79 commit A
git rebase | Atlassian Git Tutorial
git amend | Atlassian Git Tutorial
Git Pull | Atlassian Git Tutorial
Git - 变基
Git - git-rebase Documentation
Git - git-reflog Documentation
Why you should stop using Git rebase
Understand how does git rebase work and compare with git merge and git interactive rebase