- 引言 — rebase 旁边多了一扇门
- 落地了什么、什么时候落地的
- 为什么要做 — 写在提交信息里
- 和 rebase 到底有什么不同
- fixup 的三方合并方向是怎样的
- 底层 — 建立在 git replay 之上,而不是 sequencer
- 诚实的局限 — 这还只是实验
- 卡住的那个名字 — first-class conflicts
- 那么,现在该不该用
- 结语
- 参考资料
引言 — rebase 旁边多了一扇门
你想在倒数第三个提交里补上一行漏掉的代码。放在今天的 Git 里,你的手得这么动 — 用 git commit --fixup 造一个 fixup 提交,用 git rebase --autosquash 把它折叠进去,如果上面还叠着分支,还得确认 --update-refs 是不是真的生效了。把一个提交拆成两个更麻烦。得打开交互式 rebase,把指令改成 edit,用 git reset HEAD~ 解开这个提交,分两次暂存、分两次提交,再结束 rebase。
Git 2.54 加入了一个专门针对这些操作的新命令,而且是 实验性的。它叫 git history。2.55 又多加了一个子命令。本文核对发行说明、文档、提交信息与源码这些一手资料,梳理实际落地的内容、底层是怎么搭建的,以及它卡在哪里。
先把结论放在前面 — 这不是"rebase 的替代品"。文档自己就是这么说的。
落地了什么、什么时候落地的
时间线是这样的。日期以 git/git 仓库里标签的 tagger 日期为准。
| 时间 | 内容 |
|---|---|
| 2026-01-13 | Patrick Steinhardt 通过 builtin: add new "history" command 提交引入该命令(含 reword) |
| 2026-03-02 | 实现 split 子命令 |
| 2026-04-20 | Git 2.54.0 发布 — git history 首次随 reword + split 一起发布 |
| 2026-04-27 | 实现 fixup 子命令 |
| 2026-06-29 | Git 2.55.0 发布 — 加入 fixup |
2.54 发行说明里的表述只有一行 — "git history" history rewriting (experimental) command has been added. 2.55 的说明也只有一行 — "git history" learned "fixup" command. 发行说明写得这么简略,反倒说明了它目前的状态。
截至 2.55,目前有三个子命令。
git history fixup <commit> [--dry-run] [--update-refs=(branches|head)] [--reedit-message] [--empty=(drop|keep|abort)]
git history reword <commit> [--dry-run] [--update-refs=(branches|head)]
git history split <commit> [--dry-run] [--update-refs=(branches|head)] [--] [<pathspec>...]
reword— 只改该提交的信息,其余保持不变。split— 交互式地把一个提交拆成两个,挑选 hunk 并把它们拆到一个新的父提交里。(本文全程保留 "hunk" 这个原词,避免和后面出现的"钩子(hook)"混淆。)fixup— 把 已暂存的变更 推入指定的提交。用文档的说法,它的性质大致相当于先执行git commit --fixup=<commit>,再执行git rebase --autosquash <commit>~。
执行 git history split HEAD,会弹出和 git add -p 一样的 hunk 选择提示;被选中的 hunk 会拆到一个新的父提交里。按文档的例子,只选 bar 拆出来,原提交就只剩下 foo,而拆出来的 bar 提交会成为它的父提交。全选或者一个都不选都会被拒绝,因为那样会让某一方变成空提交。
为什么要做 — 写在提交信息里
不需要去猜动机。Steinhardt 亲自把它写在了引入该功能的提交信息里。他先列举了人们用 rebase 常做的操作(调换提交顺序、拆分提交、丢弃提交、把多个提交压缩到一起、修改并非最新的提交),然后写道:
While these operations are all doable, it often feels needlessly kludgey to do so by doing an interactive rebase, using the editor to say what one wants, and then perform the actions.
接着是关键的一段。
Rebases also do not update dependent branches. The use of stacked branches has grown quite common with competing version control systems like Jujutsu though, so it clearly is a need that users have.
一条 Git 提交信息把 Jujutsu 称作"竞争版本控制系统",还拿那边已经变得常见的工作流当作用户需求的证据 — 这么坦率的承认并不多见。
split 的提交信息更偏实务。它把拆分提交的既有七步流程完整列了出来,然后收尾写道:
This is quite complex, and overall I would claim that most people who are not experts in Git would struggle with this flow.
也就是说,这个命令瞄准的对象是"不是 Git 专家的人"。文档说的也是同一件事 — git history 追求的是一种比 rebase 更强主见(opinionated)、更简单 的方式,并明确写明,如果你想把一段提交范围挪到别的基点上,或者想一次编辑多个提交,那就该用 rebase。
和 rebase 到底有什么不同
文档列出的差异有三点,每一点都值得拆开来看。
1. 不动工作区和索引。 reword 和 split 即便在裸仓库(bare repository)里也能运行。唯一的例外是 fixup,因为它得从索引里读取已暂存的变更。这和 rebase 每处理一个提交就要把工作区翻一遍形成对比。
2. 默认会更新后代分支。 --update-refs=branches 是默认值,凡是原提交的后代的本地分支,都会被挪动去指向重写后的提交。rebase 的 --update-refs 是个得手动打开的选项,而在这里它就是默认行为。也可以用 --update-refs=head 只挪动 HEAD。
3. 不执行钩子。 文档原文是这样写的 — git-history(1) does not execute any githooks(5) at the current point in time. This may change in the future. 这不是优点,只是 当前的状态。后面还会再谈到这一点。
除此之外,还有一处文档没提、只有看源码才能发现的差异。ref 的更新是原子性的。 builtin/history.c 会把所有需要更新的 ref 收集起来,放进单个 ref_transaction,最后一次性提交。如果挂着五个堆叠分支,那就要么五个全部挪动,要么一个都不动,不会留下半途而废的破损状态。
--dry-run 也很实用。它不会碰任何 ref,只会以 git update-ref --stdin 能直接吃进去的格式,打印出更新计划。文档解释说,由于所需的对象已经写进了仓库,之后再应用打印出来的内容也是安全的。想提前预览重写结果时用得上。
fixup 的三方合并方向是怎样的
这是 fixup 有意思的地方。源码里的注释自己就说明白了。
/*
* Perform the three-way merge to reapply changes in the index onto the
* target commit. This is using basically the same logic as a
* cherry-pick, where the base commit is our HEAD, ours is the original
* tree and theirs is the index tree.
*/
init_basic_merge_options(&merge_opts, repo);
merge_opts.ancestor = "HEAD";
merge_opts.branch1 = argv[0];
merge_opts.branch2 = "staged";
merge_incore_nonrecursive(&merge_opts, head_tree,
original_tree, index_tree, &merge_result);
把方向整理出来就是这样。
base (合并基点) = HEAD 的树
ours (side1) = 目标提交的树
theirs (side2) = 索引树 (= HEAD 的树 + 已暂存的变更)
想清楚为什么这样是对的,会觉得很干净。因为 base 是 HEAD,theirs 是索引,所以 theirs 相对 base 的变更,正好就是你暂存的那份 diff。而 ours 是目标提交的树。于是这次合并的结果就变成"目标提交的树 + 你暂存的 diff"。这和把暂存的变更 cherry-pick 到一个过去的提交上是一回事 — 这也是注释说"basically the same logic as a cherry-pick"的原因。
关键就在 merge_incore_nonrecursive 这个名字里的 incore。合并全程发生在内存里,不经过工作区 — 这和 rebase 每个提交都要把文件签出再还原,是根本不同的一点。
要是合并不干净会怎样?代码给出的答案非常短。
if (!merge_result.clean) {
ret = error(_("fixup would produce conflicts; aborting"));
goto out;
}
直接中止。这不是 bug,是设计。这个话题后面还会继续。
--empty 选项也是在这个语境下出现的。fixup 的结果导致提交变空,有两种情形 — 暂存的变更恰好抵消了目标提交的变更,或者某个后代提交本来就已经包含了你刚推进祖先里的那份变更 — 默认值 drop 会去掉空提交,keep 会保留它,abort 会以错误终止。不过,丢弃根提交这件事目前还做不到。源码里的 TODO 注释把这称为"纯属技术限制、没有存在理由的限制"。
底层 — 建立在 git replay 之上,而不是 sequencer
这是这个功能里最安静、却也最重要的一个决定。
git history 重放后代提交的工作,由 replay_revisions() 完成。git replay 是 Elijah Newren 在 2023 年引入、于 2.44.0 首次发布的实验性命令,它把一段提交范围搬到新的基点上时,"不触碰工作区和索引",ref 则以"原子事务"的方式更新。git history 继承下来的,正是这些特性。
不过,git replay 原本是个内建命令(builtin),而不是库。所以 Steinhardt 在造 git history 之前,先把路铺好了。看 2026-01-13 那批补丁系列提交的顺序,意图表露无遗。
6aeda3cf5 builtin/replay: move core logic into "libgit.a"
410e37806 replay: small set of cleanups
542577156 replay: support empty commit ranges
48a72f61f replay: support updating detached HEAD
475ade1cd wt-status: provide function to expose status for trees
a675183d4 builtin: add new "history" command
d205234cb builtin/history: implement "reword" subcommand
先把 git replay 的核心逻辑抽到 libgit.a 里,把对空提交范围和分离(detached) HEAD 的支持填进 replay,然后才在此之上搭出 git history。为了造出新的瓷器层(porcelain),把既有的管道层(plumbing)提升成了库。
这一点之所以值得注意,是因为它和 2025 年 Git 贡献者峰会上讨论的方向完全吻合。Taylor Blau 于 2025-10-06 发到邮件列表的峰会笔记里,"The future of history rewriting" 这场会议(由 Phillip Wood 主持)有这样一段:
Git history vs git replay Replay is plumbing used by server. History is porcelain used by user.
而写了 merge-ort 和 replay 的 Elijah Newren,其立场被记录为这样 — 他想要超越 sequencer,理由是 sequencer 更新工作区的频率太高,并不理想;git history 可以走使用 git replay 这条路。当时的笔记里也写下了"先在 sequencer 的底层上落地一个实验版本"这个选项,最终的结论是"先落地 UI,底层以后再迭代"。
看实际发布出来的代码,就能得到答案。v2.55.0 的 builtin/history.c 虽然 include 了 sequencer.h,但从那里用到的只有一个东西 — 用来整理提交信息的辅助函数 cleanup_message()。它并不调用 rebase 的 sequencer 机制。重写由 replay_revisions() 完成,合并由 merge_incore_nonrecursive() 完成。也就是说,它已经按照峰会上讨论的"用 replay,而不是 sequencer"这个方向造出来了。
这会给用户带来实际的差别。如果是基于 sequencer 的,那势必会伴随工作区签出和状态文件(比如 .git/rebase-merge/),在裸仓库上运行也就不可能了。
诚实的局限 — 这还只是实验
从这里开始才是真正重要的部分。文档专门设了一个 LIMITATIONS 小节,内容分量不轻。
有合并提交就用不了。 原文是 This command does not (yet) work with histories that contain merges. 建议改用 git rebase --rebase-merges。源码里有这样一段代码 — 提前用 revwalk 检查目标提交的后代里是否存在父提交数超过一个的提交,如果有就以 replaying merge commits is not supported yet! 拒绝执行。如果你在混有合并提交的分支上工作,这个命令目前还不是给你用的。
一旦出现冲突就直接中止。 这不是没实现,而是明确的设计选择。文档原文连理由都写清楚了。
Furthermore, the command does not support operations that can result in merge conflicts. This limitation is by design as history rewrites are not intended to be stateful operations. The limitation can be lifted once (if) Git learns about first-class conflicts.
核心在于"历史重写不该是有状态的操作"。git rebase 遇到冲突时,会让仓库停在 rebase --continue 之前那种半吊子状态里等着,git history 干脆拒绝这套模型。取而代之的是要么成功、要么什么都不变。括号里的 (if) 也值得留意 — 作者自己并没有把它写成板上钉钉的事。
钩子不会执行。 对于用钩子来强制执行政策的团队来说,这是个悄无声息的陷阱。用 git history 重写出来的提交会绕过钩子。峰会笔记里给出了这个张力从何而来的线索 — 里面提到了加入 --update-refs 时钩子被弄坏的先例,还有"不想再重演一次"这样的说法。目前选择的是干脆完全不执行,文档也留了个口子,写着"未来可能会改变"。
无法丢弃根提交。 --empty=drop 导致根提交变空的这种情况,目前还处理不了。
还有一点最微妙。文档用大写字母喊着 EXPERIMENTAL,运行时却没有任何警告。 在 command-list.txt 里,git-history 被登记为 mainporcelain,所以它会照常出现在 git help 的主要命令列表里。它也没有被配置开关锁住 — 执行的时候,连一行警告都没有,直接就运行了。相比之下,git replay 在 SYNOPSIS 里直接钉上了 (EXPERIMENTAL!),这里的标记要弱得多。考虑到峰会笔记里"先落地一个实验版本,让大家试用,不承诺兼容性"这个结论,这看起来是有意为之,但在把它塞进脚本之前,你得先知道这一点。把文档里的那句 THE BEHAVIOR MAY CHANGE. 原样当真,才是稳妥的做法。
卡住的那个名字 — first-class conflicts
fixup 提交信息的最后一段,把这篇文章的线索又重新系在了一起。
Especially the second item limits the usefulness of this command a bit. But there are plans to introduce first-class conflicts into Git, which will help use cases like this one.
"第二项"指的就是遇冲突即中止。也就是说,作者自己也清楚这个命令的实用性受到了限制,而他指出的解法是 first-class conflicts。git history 的文档指向的也是同一样东西。
first-class conflicts 是一种把冲突当作 可以提交的一级对象、而不是"解决之前先凑合抱着的临时状态"来处理的模型。jj 就是这么运作的 — 把冲突状态原样提交下来,在它之上继续工作,之后再逐步解决。这样一来,fixup 遇到冲突就不必中止了,只要把冲突记下来继续往前走就行。
那么,这有没有可能进入 Git 呢?2025 年贡献者峰会上,这个主题单独开了一场会议,主持人是 Martin von Zweigbergk — jj 的作者。笔记里记录的几段讨论,能让人感受到当时的气氛。
- Elijah Newren 说,他想要 first-class conflicts,是为了在编辑堆叠中间部分时保留上下文,或者把冲突解决工作转交给同事。理由是这样能把一个大冲突拆开来各个击破。
- 笔记里也说明了 jj 目前的做法 — 在树里写入特殊的 header,并按
.left/、.right/这样的约定,把冲突存进 OS 隐藏的目录里;只要存在那个特殊 header,客户端就会拒绝推送。jj 之所以费这个劲用这棵特殊的树,是因为冲突对象绝不能被 GC 掉;笔记还指出,一旦 Git 自己学会不去删除它们,jj 能施展的魔法就会变少。 - 笔记里也讨论了把冲突放进提交的 header、而不是树里的方案,并提到这样做的好处是不用遍历整棵树,就能知道是否存在冲突。
- 面对"这要是落地了,岂不是让好几个 Git 命令都变得过时"这个问题,Elijah 回答说他已经在着手拆掉 rebase、用 replay 重新开始了,Patrick 补充说
git history也是同一回事。
笔记的最后一行是这样的:
sounds like no broad opposition should we aim for 3.0?
没有出现大范围的反对,会议在"要不要把目标定在 Git 3.0"这个问题上结束。这里要说清楚 — 这不是已经达成共识的路线图,只是峰会上冒出来的一个问题。没有时间表,也没有敲定的设计。应该用和文档里 once (if) Git learns about first-class conflicts 同样的温度去读它。
那么,现在该不该用
值得一试的情况
- 用的是 Git 2.55 及以上,且在线性历史上工作。
- 你堆叠着分支,经常修改中间的提交。把后代分支原子性地一起挪动,正是这里真正的收益所在。
- 像修正消息里的错字(
reword)这种几乎零风险的操作。顺带还能在裸仓库里做。 - 拆分提交(
split)一直没练熟,每次都得去查那七步流程。
还不到时候的情况
- 历史里混有合并提交。那就是根本用不了。
- 预计会产生冲突的重写。反正只会中止,用 rebase 才对。
- 用钩子来强制执行政策的团队。钩子不会执行。
- 想把它塞进脚本或 CI 里。文档已经明确写了行为可能会变。
- 已经在用 jj。这只是 jj 所做工作的一个子集,而 jj 处理冲突时不需要中止。
第一次用的时候,建议带上 --dry-run,先看看哪些 ref 会挪到哪里去。反正对象无论如何都会被写入,检查的成本很低,在你还没对这套重写用熟之前,这是最靠谱的安全带。也值得记住,git reflog 依然会在后面兜底 — Git 是怎么把数据牢牢抱住的,我在Git 到底是怎么存储你的数据的一文里讲过。
结语
归纳起来是这样。git history 不是 rebase 的替代品,而是针对几个用 rebase 做起来很麻烦的常见操作、一件 主见很强的窄口径工具。它在 2.54 里以 reword/split 的形式落地,2.55 又加上了 fixup。它不动工作区,原子性地挪动后代分支,底层建立在 git replay 加内存内(in-core)的 merge-ort 之上,而不是 sequencer。
与此同时,它也确实还不完整。处理不了合并,一遇冲突就停,钩子也不执行。而这些限制会在哪里被解开,文档和提交信息里也已经写明了 — first-class conflicts。在峰会上主持这场讨论的人,正是 jj 的作者 Martin von Zweigbergk;而引入该功能的提交,把动机直接归给了 Jujutsu — 这两点,恰好概括了眼下 Git 里正在发生的事。一个先由竞争版本控制系统证明可行的工作流,正在向上游倒灌回来。
所以此刻的 git history,与其说是"一个已经完工的功能",不如说更接近 一个已经落地的问题。这正是峰会那个"先把 UI 端出来、看看反应"结论的原样体现。要是你用起来觉得哪里不顺手,那种别扭感,也许正是一个还在邮件列表上被讨论、尚未定论的答案。
参考资料
- git-history(1) 官方文档 — EXPERIMENTAL 标记、LIMITATIONS、子命令与选项
- Git v2.54 发行说明 (raw) —
git history引入、split加入 - Git v2.55 发行说明 (raw) — 加入
fixup - git-replay(1) 官方文档 —
git history所依附的管道层(plumbing) - builtin/history.c (v2.55.0 源码) — 三方合并的方向、原子性的 ref 事务、遇冲突即中止
- 提交 a675183d — builtin: add new "history" command — 点名 Jujutsu 与堆叠分支的动机
- 提交 c6c22579 — builtin/history: introduce "fixup" subcommand — 对 first-class conflicts 的提及
- Git 贡献者峰会 2025 笔记 02/11 — First-class conflicts in Git? (主持人 Martin von Zweigbergk)
- Git 贡献者峰会 2025 笔记 03/11 — The future of history rewriting (主持人 Phillip Wood)
- 现代 Git 工具 — 含 jj/Jujutsu(相关文章)
현재 단락 (1/114)
你想在倒数第三个提交里补上一行漏掉的代码。放在今天的 Git 里,你的手得这么动 — 用 `git commit --fixup` 造一个 fixup 提交,用 `git rebase --autos...