12k
All articles

使用 git history 修复旧提交

Git 2.55 的 git history 可用 fixup、reword 或 split 重写旧提交,更新堆叠分支,并通过 dry-run 安全预览。

OpenReplay Team
OpenReplay Team
使用 git history 修复旧提交

实验性的 git history 命令可以重写单个旧提交——将暂存的更改合并进去、替换其提交信息,或者将其一分为二——并且会同步移动所有构建在该提交之上的本地分支,全程无需启动交互式变基。

如果你习惯将多个分支层层堆叠,你大概认得出它所要替代的那种失败场景:你发现三个提交之前有个拼写错误,于是启动 rebase -i,结果在一个你根本没打算改动的提交上撞上了冲突,此时你正卡在变基中途,HEAD 处于分离状态,还得做出决断。git history 的设计初衷就是避免让你陷入这种境地。它的各个子命令共享同一个保证:操作要么完整完成,要么报错中止且不做任何改动。

本文将逐一介绍 Git 2.55 中的三个子命令并附上输出示例,展示用于在任何改动发生前预览重写结果的 --dry-run 标志,同时说明它的局限:不支持合并历史、不支持可能产生冲突的操作、不运行钩子。

要点速览

  • 在 Git 2.55 中,git history 有三个子命令:fixup 将暂存更改合并进旧提交,reword 替换其提交信息,split 将其一分为二。
  • 每个操作都是原子性的:如果重写会在被重放的历史中的任何位置产生冲突,命令将中止且不做任何改动。
  • 默认情况下,所有派生自被重写提交的本地分支都会被更新;--update-refs=head 则只移动当前 HEAD。
  • --dry-run 不移动任何引用,而是打印出可供 git update-ref 稍后应用的引用更新,这是预览重写的安全方式。
  • 该命令属于实验性功能,不运行任何钩子,并且拒绝处理包含合并提交的历史;那种情况请使用 git rebase --rebase-merges

git history 命令为何存在?

git history 的存在是为了修复某个旧提交,而无需承担交互式变基的繁琐与风险。官方手册将它与 git rebase 作了对比:选项更少,当改动范围很窄时更容易上手。它同时被标记为实验性功能,因此标志和行为在不同版本之间仍可能发生变化。

有两个特性使它值得学习。第一是原子性:任何可能以合并冲突收场的操作根本就不提供。这是一个有意为之的设计选择,因为该命令把重写视为一次性动作,而不是一个需要你逐步推进的会话。这里没有 --continue,没有 --abort,也没有需要你从中恢复的半成品状态。第二是作用范围:默认情况下,它会更新所有指向被重写提交的后代提交的本地分支,而这正是堆叠分支工作流所需要的。

该命令分两个阶段登场。rewordsplit 在 Git 2.54 中发布;Git 2.55 加入了 fixup。因此截至本文所覆盖的 2.55 版本,共有三个子命令:

子命令(Git 2.55)功能是否可在裸仓库中运行
git history fixup将暂存更改合并进旧提交否,它需要读取索引
git history reword替换旧提交的提交信息
git history split将一个提交拆分为两个

预计这份列表还会扩充。Git 正在开发中的 git history 手册已经记录了第四个子命令 drop,它会删除一个提交并将其后代重放到其父提交之上。因此在假定只有三个子命令之前,请先针对你自己的版本运行 git history -h 核对一下。

fixup:将暂存更改合并进旧提交

git history fixup <commit> 会取出索引中已暂存的内容,并把它并入目标提交。其底层是一次三方合并,以 HEAD、目标提交,以及由你的暂存更改构建出的树作为三个输入。除非你用 --reedit-message 要求打开编辑器,否则目标提交会保留其原始信息和作者。这在效果上相当于 git commit --fixup 加上 git rebase --autosquash,被压缩为一个不会让你半途搁浅的步骤。

假设两个提交之前落地的一个配置加载器漏掉了一个默认值,而一个功能分支正堆叠在其上:

$ git log --oneline --branches
c41f9e2 (feature/retries) add retry logic
7b2d8a0 (HEAD -> main) add http client
3e59c11 add config loader

$ git add src/config.js
$ git history fixup 3e59c11

$ git log --oneline --branches
f8a01d3 (feature/retries) add retry logic
92c6b7e (HEAD -> main) add http client
5d40e19 add config loader

三个哈希全都变了,mainfeature/retries 现在都指向重写后的历史。这正是 --update-refs=branches 默认行为的效果:所有派生自目标提交的本地分支都会移动,而不仅仅是你当前检出的那一个。传入 --update-refs=head 则只移动 HEAD。这比 git rebase --update-refs 的作用范围更广,后者只更新指向被变基范围内提交的引用。

在你重视的堆叠分支上运行之前,加上 --dry-run。不会有任何引用被移动。你得到的是一份打印出来的清单,列出它本来会执行的移动,其格式便于你随后交给 git update-ref 处理。Git 仍会写入它所需的新对象,这正是之后重放该清单通常没有问题的原因。这是在真正动手之前,如实查看一次重写会波及哪些分支的方式。

注意它与修补提交在作用范围上的差异:git commit --amend 只能触及 HEAD,而 fixup 可以触及线性历史中的任意提交。它也是唯一需要用到索引的子命令,这就是为什么它无法像另外两个那样在裸仓库中运行。

reword:替换旧提交的提交信息

git history reword <commit> 只改变目标提交的一样东西:它的提交信息。提交的其余部分原样保留,每个后代提交都会在其上被重放,分支引用也随之跟进。

$ git history reword 7b2d8a0

你的编辑器会打开并预填当前的提交信息 add http client。保存一条更好的信息,整个堆栈就会重建:

$ git log --oneline --branches
3ba90cf (feature/retries) add retry logic
a1d27e8 (HEAD -> main) add http client with timeout handling
5d40e19 add config loader

由于 reword 不会触碰索引和工作树,它在裸仓库中也能正常运行。你还可以修改某个未检出分支上的提交信息,而不会打扰你手头正在进行的工作。

split:把一个提交变成两个

git history split <commit> 会带你逐个 hunk 地浏览该提交引入的差异。你选中的部分会进入一个新提交,该提交被插入到原提交下方,成为它的新父提交。原提交保留你未选中的那些 hunk。选中全部 hunk 或一个都不选都会被拒绝:这两种选择都会导致其中一个提交内容为空。

假设有一个提交把限流器和无关的指标代码混在了一起:

$ git history split 91b04c7

对指标相关的 hunk 回答 y,对限流器相关的 hunk 回答 n。编辑器会提示你输入两条提交信息,作者身份沿用原提交,最终得到一对清爽的提交:

$ git log --oneline
e7d3f21 (HEAD -> main) add rate limiter
b19c8a4 add request metrics

在末尾附加路径限定符(git history split 91b04c7 -- src/metrics.js)可将拆分范围收窄到你指定的文件。该列表之外的内容会原封不动地留在原提交中。与 reword 一样,split 纯粹在提交图上操作,可在裸仓库中运行。

git history 不会做什么?

手册中的 LIMITATIONS 一节篇幅很短,值得逐字当真。合并提交不在支持范围内:如果你想重写的历史中包含任何合并提交,手册会引导你改用 git rebase --rebase-merges。任何可能以冲突收场的操作都会被拒绝——如果 Git 将来支持一等公民式的冲突,这一约束或许会被放宽。目前钩子也不会运行,而手册为这一点将来发生变化留了余地。

fixup 还有一种边界情况,由 --empty=(drop|keep|abort) 处理。这里有两条通向空提交的路径:你暂存的修复可能完全抵消掉目标提交,或者某个后续提交可能已经包含了你刚刚下推到祖先提交中的相同更改。drop 是默认值,会在历史重建过程中丢弃这类提交;keep 会将它们原样保留;abort 则以错误终止命令,而不是替你做决定。目前尚不支持丢弃根提交。

什么时候应该改用 rebase?

git history 编辑单个提交;git rebase 仍然是处理更大范围场景的工具。当一整段提交范围需要换一个新基底时,答案是 rebase;当你想一次性处理多个提交时,答案是交互式变基。而如果你真正想要的是一个能把冲突状态贯穿变基过程、留待稍后解决的工具,那就是 jj 的一等公民冲突模型,这恰恰是 git history 有意不去尝试的方向。

总结

对于最常见的那类历史编辑——修复一个其上堆叠了分支的提交——git history 用一个要么完全成功、要么拒绝执行的操作,取代了充满风险的交互式变基。找一个真实的堆叠分支,对它运行 git history fixup <commit> --dry-run,然后读一读它本来会做出的引用更新。这个五分钟的实验会告诉你,这个命令是否值得纳入你的日常工作流。只是在做决定时别忘了它的实验性标签,因为标志和行为在版本之间仍可能变化。

常见问题

git history fixup 与配合 autosquash 的 git commit --fixup 有什么区别?

git history fixup 在单个原子步骤中将暂存更改合并进目标提交。git commit --fixup 只是记录一个 fixup! 提交,还需要后续的 git rebase --autosquash 来压缩它,而那次交互式变基可能因冲突而中途停下。git history fixup 默认还会移动所有后代本地分支,并且如果重写会产生冲突,它会中止且不做任何改动。

git history 会更新远程分支或我已经推送的提交吗?

不会。git history 只更新本地分支;远程跟踪引用不受影响。由于被重写的提交会获得新的哈希值,已推送的提交需要强制推送(git push --force-with-lease)并与拉取过旧历史的人协调,这与任何重写操作一样。它最安全的使用场景是尚未推送的提交,或只有你一个人在其上工作的分支。

如何撤销一次 git history 重写?

使用 reflog。git history 会把分支引用移到新提交上,但原始提交仍保留在仓库中,并且在普通的非裸仓库中,每个被移动的分支都会在其 reflog 中记录该次更新。运行 git reflog show 并加上分支名以找到重写前的哈希值,然后在已检出的分支上用 git reset --hard 恢复它,或在其他情况下使用 git update-ref。

git history 能彻底删除一个提交吗?

在 Git 2.55 中不能。它的三个子命令(fixup、reword、split)只能修改或拆分提交,无法移除提交。要在当下删除一个提交,请使用 git rebase -i 并将该提交标记为 'drop',或使用 git rebase --onto 跳过它。Git 正在开发中的 git history 文档里出现了 drop 子命令,因此原生选项可能会在未来版本中到来。

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.