深入理解 Git Rebase

深入理解 Git Rebase

Git 的核心价值之一就是能够编辑历史记录。与其他将历史记录视为神圣不可更改的版本控制系统不同,在 Git 中,我们可以根据需求修改历史记录。这为我们提供了许多强大的工具,让我们能够精心维护清晰的提交历史,就像通过重构来维护良好的软件设计规范一样。对于 Git 新手甚至中级使用者来说,这些工具可能有些复杂,但本指南将帮助你揭开强大的 git-rebase 命令的神秘面纱。

需要注意的是:通常不建议修改公开、共享或稳定分支的历史记录。修改功能分支和个人分支的历史记录是可行的,修改尚未推送的提交记录也完全没问题。编辑完提交记录后,可使用 git push -f 命令强制将更改推送到个人分支或功能分支。

尽管有上述提醒,但值得一提的是,本指南中提到的所有操作都是非破坏性的。在 Git 中,要永久丢失数据其实相当困难。指南末尾会介绍如何修复操作过程中出现的错误。

本指南由 sourcehut 提供,sourcehut 是开发者的代码托管平台,提供 100% 开源的 Git 和 Mercurial 代码托管、持续集成、邮件列表服务,且不依赖 JavaScript!现在就来试试吧!

搭建测试环境

我们不想影响你实际的代码仓库,因此在整个指南中,我们将使用一个测试仓库进行操作。执行以下命令开始搭建:

git init /tmp/rebase-sandbox
cd /tmp/rebase-sandbox
git commit --allow-empty -m"Initial commit" # 1

如果操作过程中遇到问题,只需执行 rm -rf /tmp/rebase-sandbox 命令,然后重新执行上述步骤即可。本指南的每一步操作都可以在新的测试环境中进行,无需重复完成所有任务。

1. 修改最近一次提交

我们从简单的操作开始:修复最近一次的提交。先在测试环境中添加一个文件,并故意制造一个错误:

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 <sir@cmpwn.com>
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!

2. 修复较早的提交

--amend 选项仅适用于最近一次提交。如果需要修复较早的提交该怎么办呢?首先按如下步骤搭建测试环境:

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"

现在文件内容是正确的,但提交历史可以更简洁 —— 我们可以用这个新提交来 “修正” 上一个提交。为此,我们需要引入一个新工具:交互式变基。我们将通过这种方式编辑最近三次的提交,执行命令 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> = 使用该提交
# f, fixup <commit> = 类似 "squash",但丢弃该提交的日志信息

这就是变基计划,通过编辑这个文件,你可以告知 Git 如何编辑历史记录。为了突出本部分变基指南的重点,我简化了文件内容,你可以在文本编辑器中查看完整内容。

保存并关闭编辑器后,Git 会从历史记录中移除所有这些提交,然后逐行执行每条命令。默认情况下,Git 会选择每个提交,将其从暂存区取出并添加到分支中。如果不编辑这个文件,最终会回到初始状态,所有提交都会保持原样。

现在我们来使用一个实用功能:fixup。将第三行的操作从 "pick" 改为 "fixup",并将其移到需要 “修正” 的提交后面:

pick 8d3fc77 Add greeting.txt
fixup 0b9d0bb fixup greeting.txt
pick 2a73a77 Add farewell.txt

提示:下次操作时,我们可以使用缩写 "f" 来提高效率。

保存并退出编辑器,Git 会执行这些命令。执行 git log 命令即可验证结果:

$ git log -2 --oneline
fcff6ae (HEAD -> master) Add farewell.txt
a479e94 Add greeting.txt

3. 使用 git rebase --autosquash 命令

结合 git commit 命令的 --fixup 选项和 git rebase 命令的 --autosquash 选项,可以更自动化地完成上述步骤:

git commit -a --fixup HEAD^
git rebase -i --autosquash HEAD~3

执行后会自动生成变基计划,其中包含重新排序的提交和预设的操作:

pick 8d3fc77 Add greeting.txt
fixup 0b9d0bb fixup! Add greeting.txt
pick 2a73a77 Add farewell.txt

# Rebase f5f19fb..0b9d0bb onto f5f19fb (3 commands)
#
# Commands:
# p, pick <commit> = 使用该提交
# f, fixup <commit> = 类似 "squash",但丢弃该提交的日志信息

除了 --fixup 选项外,git commit 命令还有 --squash 选项,使用该选项可以编辑提交信息。

最后,若要默认启用 --autosquash 功能,可以通过以下配置实现:

git config --global rebase.autosquash true

4. 将多个提交合并为一个

在工作过程中,你可能会在达成小的里程碑或修复之前提交中的错误时创建多个提交。但在将代码合并到主分支(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” 的文件,就产生了这么多提交!接下来我们通过交互式变基将它们合并。注意,我们先切换到了一个新分支进行操作。因此,执行 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> = 使用该提交
# s, squash <commit> = 使用该提交,但将其合并到前一个提交中

提示:本地主分支(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 log 命令查看结果 —— 操作成功!

commit c785f476c7dff76f21ce2cad7c51cf2af00a44b6 (HEAD -> squash)
Author: Drew DeVault <sir@cmpwn.com>
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 命令可以记录它们的合并时间。在日常工作中,git rebase 命令往往更适用。

5. 将一个提交拆分为多个

有时会遇到相反的情况 —— 一个提交包含的内容过多,这时就需要将其拆分。接下来我们通过编写一段简单的 C 语言代码来演示(你仍可以直接复制粘贴以下命令快速执行):

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"

第一步是启动交互式变基。执行 git rebase -i HEAD~2 命令,对这两个提交进行变基操作,此时会显示以下变基计划:

pick 237b246 Add C program skeleton
pick b3f188b Flesh out C program

# Rebase c785f47..b3f188b onto c785f47 (2 commands)
#
# Commands:
# p, pick <commit> = 使用该提交
# e, edit <commit> = 使用该提交,但暂停以进行修改

将第二个提交的操作从 “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

我们可以按照上述提示向该提交添加新的修改,但这里我们执行 “软重置”^{3},命令为 git reset HEAD^。执行 git status 命令后会发现,该操作将最近一次提交的内容从提交记录中移除,并将修改内容添加到工作区:

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 restore ..." to discard changes in working directory)

  modified:   main.c

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

要拆分提交,我们需要执行交互式提交。通过这种方式,可以选择性地仅提交工作区中的部分修改。执行 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 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
#

保存并关闭编辑器,然后创建第二个提交。我们可以再次执行交互式提交,但这里我们直接提交剩余的修改:

git commit -a -m"Prompt user for their name"
git rebase --continue

最后这条命令会告知 Git,我们已完成对该提交的编辑,可以继续执行后续的变基命令。操作完成!执行 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

6. 调整提交顺序

调整提交顺序非常简单。首先按如下步骤搭建测试环境:

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 log 命令,会看到如下提交记录:

f03baa5 (HEAD -> master) Add inquiry.txt
a4cebf7 Add greeting.txt
90bb015 Add farewell.txt

显然,这些提交的顺序混乱了。我们通过交互式变基调整最近三次提交的顺序。执行 git rebase -i HEAD~3 命令,会显示以下变基计划:

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> = 使用该提交
#
# 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 会自动完成剩余操作。请注意,在实际操作中,调整提交顺序可能会引发冲突,若需解决冲突,可点击此处查看帮助。

7. git pull --rebase 命令的使用

如果你在分支 <branch> 上创建了一些提交,而该分支的上游版本(例如远程仓库 origin 上的对应分支)已更新,默认情况下执行 git pull 命令会创建一个合并提交。从这个角度来说,git pull 命令的默认行为等同于以下两条命令:

git fetch origin <branch>
git merge origin/<branch>

这一前提是本地分支 <branch> 已配置为追踪远程仓库 origin 上的 <branch> 分支,可通过以下命令查看配置:

$ git config branch.<branch>.remote
origin
$ git config branch.<branch>.merge
refs/heads/<branch>

还有另一种更实用的方式,能让提交历史更清晰,即使用 git pull --rebase 命令。与合并方式不同,该命令的行为在很大程度上 ^{4} 等同于以下两条命令:

git fetch origin
git rebase origin/<branch>

合并方式更简单易懂,但如果你了解 git rebase 命令的使用方法,变基方式通常是更优选择。若要将 git pull --rebase 设置为默认行为,可执行以下命令:

git config --global pull.rebase true

执行上述配置后,实际上你采用的是下一部分将介绍的操作方式 —— 下面我们来详细解释如何有目的地执行该操作。

8. 使用 git rebase 进行分支变基

颇具讽刺意味的是,在 git rebase 命令的所有功能中,我使用最少的恰恰是它命名所指向的功能:分支变基。假设你有以下分支结构:

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

实际上,feature-2 分支不依赖于 feature-1 分支中的任何修改(即不依赖提交 E),因此可以将其基于 master 分支。执行以下命令即可实现:

git rebase --onto master feature-1 feature-2

非交互式变基会对所有相关提交执行默认操作(“pick”)^{5},即仅将 feature-2 分支中不属于 feature-1 分支的提交(此处为提交 G)重新应用到 master 分支上。变基后的分支结构如下:

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

9. 解决冲突

解决合并冲突的详细方法超出了本指南的范围,后续会推出专门的指南介绍该内容。假设你已基本了解如何解决冲突,以下是变基过程中解决冲突的具体方法。

在变基过程中,有时会遇到合并冲突,解决方法与其他合并冲突类似。Git 会在受影响的文件中添加冲突标记,执行 git status 命令可以查看需要解决的冲突文件,你可以使用 git add 或 git rm 命令标记已解决冲突的文件。但在 git rebase 操作的场景下,有几个特殊选项需要了解。

首先是如何完成冲突解决。与解决 git merge 操作引发的冲突时使用 git commit 命令不同,在变基过程中,应使用 git rebase --continue 命令。此外,还有一个可用选项:git rebase --skip,该命令会跳过当前处理的提交,且该提交不会被包含在变基后的分支中。在执行非交互式变基时,若 Git 未识别出 “对方” 分支中的某个提交是 “我方” 分支中存在冲突提交的更新版本,通常会使用该选项。

如果想查看 Git 尝试应用但失败(引发冲突)的提交的差异内容,可以使用 git rebase --show-current-patch 命令,或其等效命令 git show REBASE_HEAD

最后需要注意的是,在使用 git checkout --ours 或 git checkout --theirs 命令,通过从暂存区中选择特定版本快速解决冲突路径时,这两个选项的含义与常规 git merge 冲突解决中的含义相反:在变基过程中,--theirs 指的是被变基分支(REBASE_HEAD)中的修改,--ours 指的是变基目标分支(HEAD)中的修改 ^{6}。

10. 求助!我搞砸了!

毫无疑问,变基操作有时确实很复杂。如果你操作失误,导致丢失了需要的提交,可以使用 git reflog 命令挽救。执行该命令会显示所有修改引用(即分支和标签)的操作记录。每一行记录都会显示旧引用指向的内容,对于那些曾经以为丢失的提交,你可以执行 git cherry-pickgit checkoutgit show 等命令进行操作。

注释

  1. 我们添加一个空的初始提交,是为了简化本教程后续的操作,因为对仓库的初始提交进行变基需要使用特殊命令(即 git rebase --root)。↩
  2. 若要编译该程序,执行 cc -o main main.c 命令,然后执行 ./main 命令运行程序。↩
  3. 实际上,这是一次 “混合重置”。“软重置”(通过 git reset --soft 命令执行)会保留暂存的修改,无需再次执行 git add 命令,可一次性提交所有修改。但我们此处不需要该操作,我们需要选择性地暂存部分修改,以实现提交拆分。↩
  4. 实际上,这取决于上游分支是否本身经过变基操作,或是否有提交被删除 / 合并。git pull --rebase 命令会尝试通过 git rebase 和 git merge-base 中的 “分叉点”(fork-point)机制来恢复,以避免对非本地提交进行变基。↩
  5. 实际上,这取决于 Git 的版本。在 2.26.0 版本之前,非交互式变基的默认行为与交互式变基略有不同,但通常不影响使用。↩
  6. 如果你难以记住这一点,可以使用一个小技巧:先随意选择其中一个选项,然后执行 git checkout --merge . 命令重新生成冲突。冲突标记会被重新生成,且会明确标注 “ours”(我方)和 “theirs”(对方)。↩
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值