• 深入 git rebase
  • 发布于 2个月前
  • 200 热度
    0 评论
  • 李白羽
  • 23 粉丝 48 篇博客
  •   
Git 的核心功能之一就是可以编辑历史。与其它将历史视为铁律的版本控制系统不同,在 git 中我们可以根据需要更改历史记录。它为我们提供了许多强大的工具,允许我们像使用重构来维护良好的软件设计一样策划良好的提交历史。这些工具对新手甚至中级用户来说可能有点难度,但指南将帮助解开 git-rebase 的神秘面纱。

一句警告:通常不建议改变公共、共享或稳定分支的历史。编辑功能分支和个人复刻的分支是一个不错的选择,编辑你尚未推送的分支肯定是没问题的。在编辑提交后,使用 git push -f 强制提交,会将更改推送到个人复刻或功能分支。

尽管有这样严肃的警告,但值得一提的是,本指南中提到的都是非破坏性操作。在 git 中永久丢失数据实际上非常困难。指南末尾就介绍了在出错时如何修复问题。

本指南由 sourcehut (骇客联盟)提供。100% 开源且由 Mercurial 托管,持续集成,有邮件列表,且没有 JavaScript!现在就试试吧!

配置沙箱
我们不想搞砸你原有的仓库,因此本指南中我们将全程使用沙箱仓库。从运行下面的命令开始:
git init /tmp/rebase-sandbox
cd /tmp/rebase-sandbox
git commit --allow-empty -m"Initial commit" # 1

如果遇到麻烦,只需要运行 rm -rf /tmp/rebase-sandbox 并在此运行上面的步骤即可重新开始。本指南的每个步骤都可以在新的沙箱中运行,因此没有必要重新执行每项任务。


修改你的上一次提交
让我们从简单的修改开始:修复你最近的一次提交。我们在沙箱中添加一个文件(并且做了一个错误操作):
echo "Hello wrold!" >greeting.txt
git add greeting.txt
git commit -m"Add greeting.txt"

修复这个错误很容易。我们可以编辑文件并使用 --amend 提交,如下所示:
echo "Hello world!" >greeting.txt
git commit -a --amend

使用 -a 指令(即 git add)会自动暂存所有 git 已知的文件,而 --amend 会将更改压缩到最近的提交中。保存并退出编辑器(如果你愿意,也可以现在修改提交信息)。你可以通过运行 git show 查看这次修复的提交:
commit f5f19fbf6d35b2db37dcac3a55289ff9602e4d00 (HEAD -> master)
Author: Drew DeVault 
Date:   Sun Apr 28 11:09:47 2019 -0400

    Add greeting.txt

diff --git a/greeting.txt b/greeting.txt
new file mode 100644
index 0000000..cd08755
--- /dev/null
+++ b/greeting.txt
@@ -0,0 +1 @@
+Hello world!

修复旧有提交
上面的修改仅适用最近的提交。如果你需要更正旧的提交又会怎样么?我们首先对沙箱做出相应的设置:
echo "Hello!" >greeting.txt
git add greeting.txt
git commit -m"Add greeting.txt"

echo "Goodbye world!" >farewell.txt
git add farewell.txt
git commit -m"Add farewell.txt"
看起来 greeting.txt 缺少了 "world" 单词。我们来写一个正常的提交来修复这点:
echo "Hello world!" >greeting.txt
git commit -a -m"fixup greeting.txt"

所以现在文件看起来是正确的,但我们的历史还可以优化一下(使用一个新的提交来 "fixup" 最后一次提交)。为此,我们需要引入一个新的工具:交互式 rebase。我们将使用这种方式修改最后三个提交,运行 git rebase -i HEAD~3(-i 用与交互)。这会打开文本编辑器,如下所示:
pick 8d3fc77 Add greeting.txt
pick 2a73a77 Add farewell.txt
pick 0b9d0bb fixup greeting.txt

# Rebase f5f19fb..0b9d0bb onto f5f19fb (3 commands)
#
# Commands:
# p, pick <commit> = use commit
# f, fixup <commit> = like "squash", but discard this commit's log message

这个是 rebase 执行计划,通过编辑此文件,你可以指示 git 如何编辑历史记录。我已经将摘要信息修改为只与 rebase 指南中这一部分相关的摘要信息,但是你可以随意阅览文本编辑器中的完整摘要。

当我们保存并关闭编辑器时,git 将从历史记录中删除这里全部的提交记录,然后依次执行每一行。默认情况下,它会找到每一次提交,从堆中将其取出并添加到分支中。如果我们根本没有编辑这个文件,最终会回到开始的地方,按原有选择每个提交。现在来使用一个我最喜欢的功能:fixup。编辑第三行,将 "pick" 修改为 "fixup",并立即将其移动到我们想要 "fix up" 为位置:
pick 8d3fc77 Add greeting.txt
fixup 0b9d0bb fixup greeting.txt
pick 2a73a77 Add farewell.txt

提示:我们同样可以将其简写为 "f",来加快速度。

保存并退出编辑器(git 会运行这些命令)。我们可以检查日志来验证结果:
$ git log -2 --oneline
fcff6ae (HEAD -> master) Add farewell.txt
a479e94 Add greeting.txt

压缩多个提交至一个

你在工作时,可能会发现在你达成一个小的功能节点或修复先前提交中的错误时,编写了大量有用的提交。但是,将这些提交压缩("squash")在一起会更好,可以使你的工作在合并到 master 之前历史更清晰。为此,我们将使用 "squash" 操作。我们先写一堆提交(如果你想快一点,直接复制粘贴即可):
git checkout -b squash
for c in H e l l o , ' ' w o r l d; do
    echo "$c" >>squash.txt
    git add squash.txt
    git commit -m"Add '$c' to squash.txt"
done
这里会创建一个文件,说 "Hello world",做了很多次提交。我们开启另一个交互式的 rebase 将它们合并到一起。注意,我们首先要检出一个分支然后再尝试此操作。正是这样,由于我们检出了一个新分支,所以允许使用 git rebase -i master来快速修改所有自分支检出之后的提交。结果:
pick 1e85199 Add 'H' to squash.txt
pick fff6631 Add 'e' to squash.txt
pick b354c74 Add 'l' to squash.txt
pick 04aaf74 Add 'l' to squash.txt
pick 9b0f720 Add 'o' to squash.txt
pick 66b114d Add ',' to squash.txt
pick dc158cd Add ' ' to squash.txt
pick dfcf9d6 Add 'w' to squash.txt
pick 7a85f34 Add 'o' to squash.txt
pick c275c27 Add 'r' to squash.txt
pick a513fd1 Add 'l' to squash.txt
pick 6b608ae Add 'd' to squash.txt

# Rebase 1af1b46..6b608ae onto 1af1b46 (12 commands)
#
# Commands:
# p, pick <commit> = use commit
# s, squash <commit> = use commit, but meld into previous commit

提示:你的本地 master 分支独立于远程 master 分支之外,且 git 将远程分支存储为 origin/master。结合这个技巧,使用 git rebase -i origin/master 来修改尚未合并到上游的所有提交是一种非常方便的方法。
我们要将这些更改全部压缩到第一次提交中。要做到这一点,需要将除第一行之外的每个 "pick" 操作修改为 "squash",如下所示:
pick 1e85199 Add 'H' to squash.txt
squash fff6631 Add 'e' to squash.txt
squash b354c74 Add 'l' to squash.txt
squash 04aaf74 Add 'l' to squash.txt
squash 9b0f720 Add 'o' to squash.txt
squash 66b114d Add ',' to squash.txt
squash dc158cd Add ' ' to squash.txt
squash dfcf9d6 Add 'w' to squash.txt
squash 7a85f34 Add 'o' to squash.txt
squash c275c27 Add 'r' to squash.txt
squash a513fd1 Add 'l' to squash.txt
squash 6b608ae Add 'd' to squash.txt

当你保存并关闭编辑器时,git 会处理一小会儿,然后再次打开编辑器来修改最终的提交消息。你会看到这样:
# This is a combination of 12 commits.
# This is the 1st commit message:

Add 'H' to squash.txt

# This is the commit message #2:

Add 'e' to squash.txt

# This is the commit message #3:

Add 'l' to squash.txt

# This is the commit message #4:

Add 'l' to squash.txt

# This is the commit message #5:

Add 'o' to squash.txt

# This is the commit message #6:

Add ',' to squash.txt

# This is the commit message #7:

Add ' ' to squash.txt

# This is the commit message #8:

Add 'w' to squash.txt

# This is the commit message #9:

Add 'o' to squash.txt

# This is the commit message #10:

Add 'r' to squash.txt

# This is the commit message #11:

Add 'l' to squash.txt

# This is the commit message #12:

Add 'd' to squash.txt

# Please enter the commit message for your changes. Lines starting
# with '#' will be ignored, and an empty message aborts the commit.
#
# Date:      Sun Apr 28 14:21:56 2019 -0400
#
# interactive rebase in progress; onto 1af1b46
# Last commands done (12 commands done):
#    squash a513fd1 Add 'l' to squash.txt
#    squash 6b608ae Add 'd' to squash.txt
# No commands remaining.
# You are currently rebasing branch 'squash' on '1af1b46'.
#
# Changes to be committed:
#	new file:   squash.txt
#

默认这就是所有被压缩后的提交消息的组合,但是保留这样的消息并不是你想要的。不过,旧的提交消息在编写新提交时可能有参考意义。

提示:你在上一节中了解到的 "fixup" 命令也可以用在这里(但是它会丢弃压缩提交的消息)。
现在我们删除所有内容并用更好的提交消息替换,如下所示:
Add squash.txt with contents "Hello, world"

# Please enter the commit message for your changes. Lines starting
# with '#' will be ignored, and an empty message aborts the commit.
#
# Date:      Sun Apr 28 14:21:56 2019 -0400
#
# interactive rebase in progress; onto 1af1b46
# Last commands done (12 commands done):
#    squash a513fd1 Add 'l' to squash.txt
#    squash 6b608ae Add 'd' to squash.txt
# No commands remaining.
# You are currently rebasing branch 'squash' on '1af1b46'.
#
# Changes to be committed:
#	new file:   squash.txt
保存并退出编辑器,然后检查你的 git 日志,成功了!
commit c785f476c7dff76f21ce2cad7c51cf2af00a44b6 (HEAD -> squash)
Author: Drew DeVault 
Date:   Sun Apr 28 14:21:56 2019 -0400

    Add squash.txt with contents "Hello, world"
在继续之前,我们将作出的更改拉入 master 分支并清除这个新分支的痕迹。我们可以像使用 git merge 一样使用 git rebase,但是这避免了合并提交操作:
git checkout master
git rebase squash
git branch -D squash

除非是我们合并两个不相关的历史记录,否则通常还是希望避免使用 git merge。如果你有两个不同的分支,git merge 对于记录它们于何时被合并是很有用的。在正常工作过程中,使用 rebase 通常更合适。

将一次提交拆分成多个

有时会遇到相反的问题(一次提交太多了)。我们来试着把它分开。这一次,编写一点实际的代码。从一个简单的 C 程序开始(你仍然可以复制粘贴到 shell 来快速完成):

cat <<EOF >main.c
int main(int argc, char *argv[]) {
    return 0;
}
EOF

我们先做第一次提交。
git add main.c
git commit -m"Add C program skeleton"

之后,对程序做一些扩展:
cat <<EOF >main.c
#include <stdio.h>

const char *get_name() {
    static char buf[128];
    scanf("%s", buf);
    return buf;
}

int main(int argc, char *argv[]) {
    printf("What's your name? ");
    const char *name = get_name();
    printf("Hello, %s!\n", name);
    return 0;
}
EOF

提交之后,我们就为学习如何拆分提交做好准备了:
git commit -a -m"Flesh out C program"

第一步是启动一个交互式 rebase。我们使用 git rebase -i HEAD~2 来 rebase 两次提交,下面给出这个 rebase 计划:
pick 237b246 Add C program skeleton
pick b3f188b Flesh out C program

# Rebase c785f47..b3f188b onto c785f47 (2 commands)
#
# Commands:
# p, pick <commit> = use commit
# e, edit <commit> = use commit, but stop for amending

将第二个提交命令由 "pick" 改为 "edit",然后保存并关闭编辑器。Git 会花费一会时间处理,然后显示这个:
Stopped at b3f188b...  Flesh out C program
You can amend the commit now, with

  git commit --amend

Once you are satisfied with your changes, run

  git rebase --continue

我们可以按照这些说明向提交中添加新的更改,不过这里让我们使用 git reset HEAD^ 来做一个 "soft reset"。如果你在这之后运行了 git status,你会看到git将最后一次提交的内容退还到编辑后还没有stage的状态,并将其加到工作树中:
Last commands done (2 commands done):
   pick 237b246 Add C program skeleton
   edit b3f188b Flesh out C program
No commands remaining.
You are currently splitting a commit while rebasing branch 'master' on 'c785f47'.
  (Once your working directory is clean, run "git rebase --continue")

Changes not staged for commit:
  (use "git add ..." to update what will be committed)
  (use "git checkout -- ..." to discard changes in working directory)

  modified:   main.c

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

要拆分这个提交,我们需要做一个交互式 commit。这样我们就可以有选择地只修改工作树中的特定更改。运行 git commit -p 来启动此过程,你将看到一下提示:
diff --git a/main.c b/main.c
index b1d9c2c..3463610 100644
--- a/main.c
+++ b/main.c
@@ -1,3 +1,14 @@
+#include <stdio.h>
+
+const char *get_name() {
+    static char buf[128];
+    scanf("%s", buf);
+    return buf;
+}
+
 int main(int argc, char *argv[]) {
+    printf("What's your name? ");
+    const char *name = get_name();
+    printf("Hello, %s!\n", name);
     return 0;
 }
Stage this hunk [y,n,q,a,d,s,e,?]? 

Git 只给了一个 "大块头"(即单个的变更)来展示提交。不过这太多了,所以我们来使用 "s" 命令把这个大块头 "split" 成小的部分。
Split into 2 hunks.
@@ -1 +1,9 @@
+#include <stdio.h>
+
+const char *get_name() {
+    static char buf[128];
+    scanf("%s", buf);
+    return buf;
+}
+
 int main(int argc, char *argv[]) {
Stage this hunk [y,n,q,a,d,j,J,g,/,e,?]? 
提示:如果你对其他选项比较好奇,按 "?" 来了解它们的描述。

这个大块头看起来也不错,单一且变化自成一体。输入 "y" 回复这个问题(并暂存这个 "大块头"),然后 "q" 退出交互式会话并继续提交。你的编辑器突然提示并要求你输出一个合适的提交信息。
Add get_name function to C program

# Please enter the commit message for your changes. Lines starting
# with '#' will be ignored, and an empty message aborts the commit.
#
# interactive rebase in progress; onto c785f47
# Last commands done (2 commands done):
#    pick 237b246 Add C program skeleton
#    edit b3f188b Flesh out C program
# No commands remaining.
# You are currently splitting a commit while rebasing branch 'master' on 'c785f47'.
#
# Changes to be committed:
#	modified:   main.c
#
# Changes not staged for commit:
#	modified:   main.c
保存并关闭你的编辑器,然后我们将进行第二次提交。我们可以打开另一个交互式 commit,但由于我们只想在这个提交中包含其余的更改,只需要这么做:
git commit -a -m"Prompt user for their name"
git rebase --continue
最后一个命令告诉 git,我们已经完成了对此提交的编辑,并继续下一个 rebase 命令。就这样!运行 git log 命令来看看你的劳动成果:
$ git log -3 --oneline
fe19cc3 (HEAD -> master) Prompt user for their name
659a489 Add get_name function to C program
237b246 Add C program skeleton
对提交重新排序

这个很容易了!我们从设置沙箱开始:
echo "Goodbye now!" >farewell.txt
git add farewell.txt
git commit -m"Add farewell.txt"

echo "Hello there!" >greeting.txt
git add greeting.txt
git commit -m"Add greeting.txt"

echo "How're you doing?" >inquiry.txt
git add inquiry.txt
git commit -m"Add inquiry.txt"
现在 git 日志应该是这样的:
f03baa5 (HEAD -> master) Add inquiry.txt
a4cebf7 Add greeting.txt
90bb015 Add farewell.txt

显然,这里排序错乱了。我们要针对过去的 3 个提交进行交互式 rebase,来解决这个问题。运行 git rebase -i HEAD~3 会出现这个 rebase 计划:
pick 90bb015 Add farewell.txt
pick a4cebf7 Add greeting.txt
pick f03baa5 Add inquiry.txt

# Rebase fe19cc3..f03baa5 onto fe19cc3 (3 commands)
#
# Commands:
# p, pick <commit> = use commit
#
# These lines can be re-ordered; they are executed from top to bottom.
这个修复过程很简单:只需要按照你希望的提交顺序重新排序这些行。看起来应该是这样:
pick a4cebf7 Add greeting.txt
pick f03baa5 Add inquiry.txt
pick 90bb015 Add farewell.txt

保存并关闭你的编辑器,git 会完成剩下的工作。注意,当你在实际工作中执行这个操作时,最终可能会发生冲突,点击这里查看冲突的帮助。


git pull --rebase

如果你一直在已经更新的上游分支创建提交,通常 git pull 会创建一个和并提交。这方便,git pull 的行为默认相当于:
git fetch origin
git merge origin/master
还有另一个选择,往往更有用且使历史保持简洁:git pull —rebase。与合并方式不同,这相当于:
git fetch origin
git rebase origin/master
合并的方式更简单且更容易理解,但是如果你会使用 git rebase 的话,这种 rebase 方式则更容易达成你的目的。如果你愿意,可以将将其设置为某些默认行为:
git config --global pull.rebase true
当你执行此操作时,技术上来说,你正在使用我们在下一节将讨论的内容。所以我们来解释一下这么做的意义是什么。

对 rebase 使用 rebase

讽刺的是,我使用最少的 git rebase 功能就是其中一个名为分支变基功能。比如你有一下分支:

A--B--C--D--> master
   \--E--F--> feature-1
      \--G--> feature-2

事实证明,feature-2 并不依赖于 feature-1 中的任何变更,而是依赖于 E 提交,因此你可以将其 rebase 到 master。解决方式为:
git rebase --onto master feature-1 feature-2
非交互式 rebase 对所有相关提交("pick")都会执行默认操作,它会在 feature-2 重复这些在 master 顶部而非 feature-1 中的提交。你的提交历史现在看起来像这样:
A--B--C--D--> master
   |     \--G--> feature-2
   \--E--F--> feature-1

解决冲突

解决合并冲突的细节超出了本指南的范围(留意之后针对此功能的指南)。假设你熟悉解决一般冲突,下面是使用 rebase 的细节。

有时,在执行 rebase 时会遇到合并冲突,你可以像处理其他冲突一样处理 rebase。Git 会在受影响的文件中设置冲突标记,git status 可以显示这些待解决的问题,并且你可以将解决冲突后的文件使用 git add 或 git rm 标记。然而,在 git rebase 的背景下,有两个选择你要注意。

第一个就是如何彻底解决冲突。不同于 git commit,你将要解决的是由 git merge 造成的冲突,重新 rebase 的适用命令是 git rebase --continue。然而,这里还有其他的可用的选择:git rebase --skip。它会跳过你正在处理的提交,并将其从 rebase 中移除。在交互式 rebase 中,当 git 没有意识到提交是从其他分支来出来的,且我们当前分支已有一个更新的版本,这是很常见的。

来帮帮忙!我把这个搞砸了!

毫无疑问,rebase 操作有时可能会很困难。如果你犯一个错误并因此丢失了一个你需要的提交,而这里有一个git reflog 命令可以保存当天的历史。运行这个命令你会看到你所有变更操作的 ref,或者是分支、标签的引用。每一行都显示了引用的指向,并且一旦你认为提交丢失了,就可以使用 git cherry-pick,git checkout,git show或其他操作来处理。
用户评论