Git
章节 ▾ 第二版

7.6 Git 工具 - 重写历史

重写历史

在使用 Git 的过程中,你可能经常需要修改本地提交历史。Git 的一大优点是它允许你在最后一刻做出决定。你可以使用暂存区决定哪些文件包含在哪个提交中,你可以在使用 git stash 后决定你还没有准备好处理某些内容,你还可以重写已发生的提交,使其看起来像是以不同的方式发生的。这可能包括更改提交的顺序、更改消息或修改提交中的文件、合并或拆分提交,或者完全删除提交——所有这些操作都可以在你与他人共享你的工作之前进行。

在本节中,你将学习如何完成这些任务,以便在与他人共享之前使你的提交历史看起来符合你的预期。

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

Git 的基本规则之一是,由于很多工作都存在于你的克隆中,因此你拥有极大的自由来本地重写你的历史。但是,一旦你推送了你的工作,情况就完全不同了,你应该将已推送的工作视为最终状态,除非你有充分的理由更改它。简而言之,在你对工作满意并准备好与世界其他地方共享之前,你应该避免推送你的工作。

更改最近一次提交

更改最近一次提交可能是你最常进行的历史重写操作。你通常希望对你最近一次提交执行两项基本操作:简单地更改提交消息,或者通过添加、删除和修改文件来更改提交的实际内容。

如果你只想修改最近一次提交的消息,这很容易

$ git commit --amend

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

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

你需要谨慎使用此技术,因为修改会更改提交的 SHA-1。这就像一个非常小的变基——如果你已经推送了提交,请不要修改它。

提示
修改后的提交可能(也可能不需要)修改后的提交消息

当你修改提交时,你有机会更改提交消息和提交的内容。如果你大幅修改了提交的内容,你几乎肯定应该更新提交消息以反映修改后的内容。

另一方面,如果你的修改非常琐碎(修复一个愚蠢的拼写错误或添加一个你忘记暂存的文件),以至于之前的提交信息就很好,你可以简单地进行修改,暂存它们,并完全避免不必要的编辑器会话,使用以下命令:

$ git commit --amend --no-edit

修改多个提交信息

要修改历史记录中较早的提交,您必须使用更复杂的工具。Git 没有修改历史记录的工具,但您可以使用 rebase 工具将一系列提交重新定位到它们最初基于的 HEAD,而不是将它们移动到另一个 HEAD。使用交互式 rebase 工具,您可以在要修改的每个提交后停止并更改消息、添加文件或执行任何您希望的操作。您可以通过向 git rebase 添加 -i 选项来交互式地运行 rebase。您必须通过告诉命令将哪些提交重新定位到哪个提交来指示您希望回退到多远。

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

$ git rebase -i HEAD~3

再次提醒,这是一个 rebase 命令——范围内 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

注意反向顺序。交互式 rebase 为您提供了一个它将运行的脚本。它将从您在命令行中指定的提交 (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 都会停止,让您修改提交,并在您完成后继续。

重新排序提交

您还可以使用交互式 rebase 完全重新排序或删除提交。如果您想删除“Add cat-file”提交并更改其他两个提交引入的顺序,您可以将 rebase 脚本从以下内容

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”提交。

压缩提交

还可以使用交互式 rebase 工具将一系列提交压缩成单个提交。脚本在 rebase 消息中提供了有用的说明

#
# 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

如果您指定“squash”而不是“pick”或“edit”,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”并在任何 rebase 更改之前应用,因此 Git 会使提交保持不变。

删除提交

如果您想删除提交,可以使用 rebase -i 脚本删除它。在提交列表中,在要删除的提交之前放置“drop”一词(或仅从 rebase 脚本中删除该行)

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

由于 Git 构建提交对象的方式,删除或更改提交会导致所有后续提交被重写。您在存储库历史记录中回退得越远,需要重新创建的提交就越多。如果您在序列后面有很多依赖于您刚刚删除的提交的提交,这可能会导致大量的合并冲突。

如果您在进行此类 rebase 的过程中中途决定这不是一个好主意,您可以随时停止。键入 git rebase --abort,您的存储库将恢复到开始 rebase 之前的状态。

如果您完成 rebase 并决定这不是您想要的,您可以使用 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="[email protected]";
                git commit-tree "$@";
        else
                git commit-tree "$@";
        fi' HEAD

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

scroll-to-top