章节 ▾ 第二版

7.6 Git 工具 - 重写历史

重写历史

在使用 Git 时,你可能经常会想要修改本地的提交历史。Git 的一个强大之处在于它允许你把决定推迟到最后一刻做出。你可以使用暂存区在提交前决定哪些文件进入哪些提交,你也可以使用 git stash 决定暂时不处理某些工作,还可以重写已经发生的提交,让它们看起来是以不同的方式发生的。这可能涉及到改变提交的顺序、更改提交中的消息或修改文件、合并或拆分提交,或者完全删除提交——所有这些都可以在你与他人分享你的工作之前完成。

在本节中,你将看到如何完成这些任务,以便在与他人分享你的提交历史之前,让它看起来符合你的期望。

注意
在你满意之前不要推送你的工作

Git 的一个基本规则是,由于你的大部分工作都在本地克隆中进行,你有很大的自由在本地重写你的历史。然而,一旦你推送了你的工作,情况就完全不同了,除非你有充分的理由,否则应该将已推送的工作视为最终版本。简而言之,你应该避免推送你的工作,直到你对此感到满意并准备好与世界分享。

修改上次提交

修改最近一次提交可能是最常见的历史重写操作。你通常会想要对最后一次提交做两件基本的事情:简单地修改提交消息,或者通过添加、删除和修改文件来改变提交的实际内容。

如果你只是想修改上次提交的消息,那很简单

$ git commit --amend

上面的命令将上次提交的消息加载到一个编辑器会话中,你可以在其中对消息进行更改,保存这些更改并退出。当你保存并关闭编辑器时,编辑器会写入一个新的提交,其中包含更新后的提交消息,并将其作为你的新最近提交。

另一方面,如果你想更改上次提交的实际内容,过程基本上是一样的——首先进行你认为遗漏的更改,暂存这些更改,然后后续的 git commit --amend 将用你新的、改进的提交替换掉上次提交。

你需要小心这种技术,因为修改提交会改变提交的 SHA-1 值。这就像一次非常小的变基操作——如果你已经推送了上次提交,就不要修改它。

提示
修改过的提交可能(或可能不需要)修改提交消息

当你修改一个提交时,你有机会同时更改提交消息和提交内容。如果你大幅修改了提交内容,那么你几乎肯定应该更新提交消息以反映这些修改过的内容。

另一方面,如果你的修改微不足道(修复一个愚蠢的错别字或添加一个你忘记暂存的文件),以至于之前的提交消息仍然适用,那么你只需进行更改,暂存它们,然后完全避免不必要的编辑器会话,通过

$ git commit --amend --no-edit

修改多个提交消息

要修改历史记录中更早的提交,你必须使用更复杂的工具。Git 没有一个专门的“修改历史”工具,但你可以使用 rebase 工具将一系列提交变基到它们最初基于的 HEAD 上,而不是将它们移动到另一个分支。使用交互式变基工具,你可以在每次你想要修改的提交之后停止,然后修改消息、添加文件或做任何你希望的事情。你可以通过给 git rebase 命令添加 -i 选项来交互式地运行变基。你必须通过告诉命令要变基到哪个提交来指明你想要重写多远历史的提交。

例如,如果你想修改最近三次提交的消息,或者该组中的任何提交消息,你需要将你想要编辑的最后一个提交的父提交作为参数提供给 git rebase -i,即 HEAD~2^HEAD~3。记住 ~3 可能更容易,因为你正试图编辑最近三次提交,但请记住,你实际上指定的是四次提交之前的位置,也就是你想要编辑的最后一次提交的父提交

$ git rebase -i HEAD~3

再次提醒,这是一个变基命令——范围 HEAD~3..HEAD 内的每一个消息被修改的提交,以及它的所有后代提交,都将被重写。不要包含任何你已经推送到中央服务器的提交——这样做会通过提供相同更改的替代版本来混淆其他开发者。

运行此命令会在你的文本编辑器中显示一个提交列表,看起来像这样

pick f7f3f6d Change my name a bit
pick 310154e Update README formatting and add blame
pick a5f4a0d Add cat-file

# Rebase 710f0f8..a5f4a0d onto 710f0f8
#
# 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 <commit> = like "squash", but discard this commit's log message
# 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.
#
# 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.
#
# Note that empty commits are commented out

需要注意的是,这些提交的顺序与你通常使用 log 命令看到的顺序是相反的。如果你运行 log 命令,你会看到类似这样的内容

$ git log --pretty=format:"%h %s" HEAD~3..HEAD
a5f4a0d Add cat-file
310154e Update README formatting and add blame
f7f3f6d Change my name a bit

注意反向顺序。交互式变基会给你一个即将运行的脚本。它将从你在命令行中指定的提交(HEAD~3)开始,并从上到下重放每个提交中引入的更改。它将最旧的列在顶部,而不是最新的,因为这是它将首先重放的提交。

你需要编辑脚本,使其在你想要编辑的提交处停止。为此,对于每个你希望脚本在其后停止的提交,将单词“pick”更改为“edit”。例如,要仅修改第三个提交消息,你将文件更改为如下所示

edit f7f3f6d Change my name a bit
pick 310154e Update README formatting and add blame
pick a5f4a0d Add cat-file

当你保存并退出编辑器时,Git 会将你回退到该列表中的最后一个提交,并在命令行中显示以下消息

$ git rebase -i HEAD~3
Stopped at f7f3f6d... Change my name a bit
You can amend the commit now, with

       git commit --amend

Once you're satisfied with your changes, run

       git rebase --continue

这些说明告诉你确切的操作。输入

$ git commit --amend

更改提交消息,然后退出编辑器。接着,运行

$ git rebase --continue

此命令将自动应用另外两个提交,然后你就完成了。如果你在更多行上将 pick 更改为 edit,则可以为每个更改为 edit 的提交重复这些步骤。每次,Git 都会停止,让你修改提交,并在你完成后继续。

重排提交

你还可以使用交互式变基来完全重排或删除提交。如果你想删除“Add cat-file”提交并更改另外两个提交的引入顺序,你可以将变基脚本从这样更改

pick f7f3f6d Change my name a bit
pick 310154e Update README formatting and add blame
pick a5f4a0d Add cat-file

到这样

pick 310154e Update README formatting and add blame
pick f7f3f6d Change my name a bit

当你保存并退出编辑器时,Git 会将你的分支回退到这些提交的父提交,然后应用 310154e 再应用 f7f3f6d,然后停止。你实际上改变了这些提交的顺序,并完全删除了“Add cat-file”提交。

合并提交

你还可以使用交互式变基工具将一系列提交合并为一个单独的提交。脚本会在变基消息中提供有用的说明

#
# 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 <commit> = like "squash", but discard this commit's log message
# 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.
#
# 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.
#
# Note that empty commits are commented out

如果除了“pick”或“edit”之外,你指定“squash”,Git 会应用该更改以及它之前的直接更改,并让你将提交消息合并在一起。因此,如果你想将这三个提交合并为一个提交,你可以让脚本看起来像这样

pick f7f3f6d Change my name a bit
squash 310154e Update README formatting and add blame
squash a5f4a0d Add cat-file

当你保存并退出编辑器时,Git 会应用所有这三处更改,然后让你回到编辑器中合并这三条提交消息

# This is a combination of 3 commits.
# The first commit's message is:
Change my name a bit

# This is the 2nd commit message:

Update README formatting and add blame

# This is the 3rd commit message:

Add cat-file

当你保存后,你就得到了一个单独的提交,它包含了之前所有三个提交的更改。

拆分提交

拆分提交会撤销一个提交,然后根据你最终想要的提交数量,多次部分暂存和提交。例如,假设你想拆分你三个提交中的中间那个提交。你不想保留“Update README formatting and add blame”,而是想将其拆分成两个提交:第一个是“Update README formatting”,第二个是“Add blame”。你可以在 rebase -i 脚本中通过将你想要拆分的提交上的指令更改为“edit”来做到这一点

pick f7f3f6d Change my name a bit
edit 310154e Update README formatting and add blame
pick a5f4a0d Add cat-file

然后,当脚本将你带到命令行时,你重置该提交,获取已重置的更改,并从这些更改中创建多个提交。当你保存并退出编辑器时,Git 会回退到列表中第一个提交的父提交,应用第一个提交(f7f3f6d),再应用第二个(310154e),然后将你带到控制台。在那里,你可以使用 git reset HEAD^ 对该提交进行混合重置,这实际上撤销了该提交并使修改后的文件处于未暂存状态。现在你可以暂存和提交文件,直到你有了几个提交,并在完成后运行 git rebase --continue

$ git reset HEAD^
$ git add README
$ git commit -m 'Update README formatting'
$ git add lib/simplegit.rb
$ git commit -m 'Add blame'
$ git rebase --continue

Git 会应用脚本中的最后一个提交(a5f4a0d),你的历史记录看起来像这样

$ git log -4 --pretty=format:"%h %s"
1c002dd Add cat-file
9b29157 Add blame
35cfb2b Update README formatting
f7f3f6d Change my name a bit

这会改变你列表中最近三个提交的 SHA-1 值,因此请确保列表中没有你已推送到共享仓库的更改过的提交。请注意,列表中的最后一个提交(f7f3f6d)未发生变化。尽管这个提交在脚本中显示,但因为它被标记为“pick”并在任何变基更改之前应用,Git 会让这个提交保持原样。

删除提交

如果你想删除一个提交,你可以使用 rebase -i 脚本来删除它。在提交列表中,在你想要删除的提交前加上单词“drop”(或者直接从变基脚本中删除该行)

pick 461cb2a This commit is OK
drop 5aecc10 This commit is broken

由于 Git 构建提交对象的方式,删除或更改一个提交将导致其后所有提交的重写。你在仓库历史中回溯得越远,需要重新创建的提交就越多。如果序列中后面有许多提交依赖于你刚刚删除的提交,这可能会导致大量的合并冲突。

如果你在这样的变基过程中途决定这不是一个好主意,你可以随时停止。输入 git rebase --abort,你的仓库将恢复到开始变基之前的状态。

如果你完成变基后发现这不是你想要的,你可以使用 git reflog 来恢复你的分支的早期版本。有关 reflog 命令的更多信息,请参阅 数据恢复

注意

Drew DeVault 制作了一份包含练习的实用动手指南,教你如何使用 git rebase。你可以在这里找到它:https://git-rebase.io/

终极选项:filter-branch

还有另一种重写历史的选项,如果你需要以某种可脚本化的方式重写大量提交——例如,全局更改你的电子邮件地址或从每个提交中删除文件,你可以使用它。这个命令是 filter-branch,它可以重写你历史记录中的大片内容,所以你最好不要使用它,除非你的项目尚未公开,并且其他人也没有基于你即将重写的提交进行工作。然而,它非常有用。你将学习一些常见用法,以便了解它的一些功能。

注意

git filter-branch 有许多陷阱,并且不再是重写历史的推荐方式。相反,请考虑使用 git-filter-repo,这是一个 Python 脚本,对于大多数通常会使用 filter-branch 的应用场景来说,它能做得更好。其文档和源代码可在 https://github.com/newren/git-filter-repo 找到。

从每个提交中删除文件

这种情况相当常见。有人不小心用不假思索的 git add . 提交了一个巨大的二进制文件,而你希望在所有地方删除它。也许你不小心提交了一个包含密码的文件,而你想要开源你的项目。filter-branch 是你可能想用来清理整个历史的工具。要从你的整个历史记录中删除名为 passwords.txt 的文件,你可以使用 filter-branch--tree-filter 选项

$ git filter-branch --tree-filter 'rm -f passwords.txt' HEAD
Rewrite 6b9b3cf04e7c5686a9cb838c3f36a8cb6a0fc2bd (21/21)
Ref 'refs/heads/master' was rewritten

--tree-filter 选项在每次检出项目后运行指定的命令,然后重新提交结果。在这种情况下,你从每个快照中删除名为 passwords.txt 的文件,无论它是否存在。如果你想删除所有不小心提交的编辑器备份文件,你可以运行类似 git filter-branch --tree-filter 'rm -f *~' HEAD 的命令。

你将能够看到 Git 重写树和提交,然后在最后移动分支指针。通常,最好在一个测试分支中执行此操作,并在确定结果是你真正想要的之后,再对你的 master 分支进行硬重置。要在所有分支上运行 filter-branch,你可以向命令传递 --all

将子目录设为新的根目录

假设你已经从另一个源代码控制系统导入,并且有一些没有意义的子目录(如 trunktags 等)。如果你想让 trunk 子目录成为每个提交的新项目根目录,filter-branch 也可以帮助你做到这一点

$ git filter-branch --subdirectory-filter trunk HEAD
Rewrite 856f0bf61e41a27326cdae8f09fe708d679f596f (12/12)
Ref 'refs/heads/master' was rewritten

现在你的新项目根目录就是每次 trunk 子目录中的内容。Git 还会自动删除未影响该子目录的提交。

全局更改电子邮件地址

另一个常见情况是,你在开始工作前忘记运行 git config 设置你的姓名和电子邮件地址,或者你可能想开源一个工作项目,并将所有工作电子邮件地址更改为你的个人地址。无论如何,你也可以使用 filter-branch 批量更改多个提交中的电子邮件地址。你需要小心,只更改属于你自己的电子邮件地址,因此你使用 --commit-filter

$ git filter-branch --commit-filter '
        if [ "$GIT_AUTHOR_EMAIL" = "schacon@localhost" ];
        then
                GIT_AUTHOR_NAME="Scott Chacon";
                GIT_AUTHOR_EMAIL="schacon@example.com";
                git commit-tree "$@";
        else
                git commit-tree "$@";
        fi' HEAD

这将遍历并重写每个提交,使其拥有你的新地址。因为提交包含了其父提交的 SHA-1 值,所以此命令会更改你历史记录中的每个提交 SHA-1 值,而不仅仅是那些拥有匹配电子邮件地址的提交。

scroll-to-top