章节 ▾ 第二版

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 工具,你可以在每次要修改的提交之后停止,并更改消息,添加文件或执行你希望的任何操作。 你可以通过将 `-i` 选项添加到 `git rebase` 来交互式运行 rebase。 你必须指示你想要重写提交的范围,方法是告诉命令要变基到哪个提交。

例如,如果你想修改最后三个提交信息,或者那一组提交信息中的任何一个,你需要为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