Git 2.55 的 history 命令实操:fixup、reword、split 如何改写提交图
一、起因:Git 核心开始提供更直接的历史编辑入口
今天 Hacker News 上一篇介绍 git history 的文章拿到 324 分、186 条评论。它不是第三方插件,而是 Git 核心在 2.54 和 2.55 中加入的一组实验命令:reword、split 在 2.54 出现,fixup 在 2.55 补齐。
这组命令要解决的不是“如何查看历史”,而是一个更具体的问题:当一串本地提交已经分叉到多个分支后,怎样修改较早的提交,同时让后代分支一起跟上。传统做法通常是 commit --fixup、rebase --autosquash 和 --update-refs 的组合;git history 把它收窄成三个意见明确的动作。
我具体做了两件事:先对照 Git 2.55.0 官方手册和源码,再在当前 Linux 环境里从 v2.55.0 标签编译一份独立二进制。系统自带 Git 是 2.34.1,确实没有该命令;编译出的版本返回 git version 2.55.0,git history -h 能看到三个子命令。
# 系统版本:没有 git history
git --version
# git version 2.34.1
# 从 v2.55.0 源码构建到独立目录,不替换系统 Git
git clone --depth 1 --branch v2.55.0 \
https://github.com/git/git.git /root/git-2.55-src
cd /root/git-2.55-src
make -j2 prefix=/root/git-2.55-install all \
NO_GETTEXT=YesPlease NO_TCLTK=YesPlease NO_OPENSSL=YesPlease \
NO_CURL=YesPlease NO_EXPAT=YesPlease NO_RUST=YesPlease
make prefix=/root/git-2.55-install install \
NO_GETTEXT=YesPlease NO_TCLTK=YesPlease NO_OPENSSL=YesPlease \
NO_CURL=YesPlease NO_EXPAT=YesPlease NO_RUST=YesPlease
/root/git-2.55-install/bin/git --version
/root/git-2.55-install/bin/git history -h
这里关闭 OpenSSL、curl、Rust 等组件,只是为了在缺开发头文件的隔离环境中验证本地历史改写命令,并不适合作为日常 Git 的完整构建配置。
二、三个子命令分别处理什么
官方 synopsis 很短:
git history fixup <commit> [--dry-run] [--update-refs=(branches|head)]
git history reword <commit> [--dry-run] [--update-refs=(branches|head)]
git history split <commit> [--dry-run] [--update-refs=(branches|head)] [--] [<pathspec>...]
1. fixup:把暂存区改动折叠进旧提交
先把修复放进暂存区,再指定要改的旧提交:
git add src/parser.c
git history fixup HEAD~2
它会在 HEAD、目标提交与暂存树之间做三方合并,保留目标提交的作者和消息,然后重放其后代。默认的 --update-refs=branches 会更新所有指向原提交后代的本地分支;如果只想动当前分支,显式写:
git history fixup --update-refs=head HEAD~2
这点比 git commit --fixup=<commit> 后再手动 autosquash 更直接,也正是它与 git rebase --update-refs 容易混淆的地方。后者围绕当前 rebase 范围工作;git history 默认寻找原提交的全部本地后代分支。
2. reword:只改旧提交消息
git history reword HEAD~3
它打开编辑器,改完后重建后代提交。官方手册称“其他细节保持不变”,这里更准确的理解是作者等元数据保持,但提交对象和后代哈希一定会变化,因为提交哈希包含消息和父提交。
reword 不需要修改工作树或 index,因此可以在 bare repository 中工作。这对服务端仓库维护脚本有价值,不过它目前不会执行任何 Git hook。
3. split:按 hunk 把一个提交拆成两个
git history split HEAD~1
它会进入类似 git add -p 的逐 hunk 选择界面。选中的部分形成新的父提交,剩余部分保留在原提交中;选全部或一个都不选都不合法,因为那会产生空提交。也可以先用 pathspec 缩小范围:
git history split HEAD~1 -- src/api.py tests/test_api.py
过去完成同一件事,常见流程是启动交互式 rebase,把提交标成 edit,reset 到父提交,再分两次 add/commit,最后 continue。新命令把这条多步骤链压成一次动作。
三、我跑了一次多分支 fixup
我建了一个最小仓库:main 上有 A、B、C 三个提交,从 B 再分出 side 并添加 D。然后在 main 的暂存区修正 B 引入的文件,把改动折叠回 B。
G=/root/git-2.55-install/bin/git
rm -rf /tmp/git-history-demo
mkdir /tmp/git-history-demo && cd /tmp/git-history-demo
$G init -q
$G config user.name demo
$G config user.email demo@example.com
printf 'a\n' > a.txt && $G add . && $G commit -qm A
printf 'b\n' > b.txt && $G add . && $G commit -qm B
B=$($G rev-parse HEAD)
$G branch side
printf 'c\n' > c.txt && $G add . && $G commit -qm C
$G switch -q side
printf 'd\n' > d.txt && $G add . && $G commit -qm D
$G switch -q main
printf 'b-fixed\n' > b.txt
$G add b.txt
$G history fixup "$B"
$G log --graph --all --oneline --decorate
执行前,两个分支共享 B:
* C (main)
| * D (side)
|/
* B
* A
执行后,B、C、D 都得到新哈希,main 和 side 同时落在重写后的图上:
* C* (main)
| * D* (side)
|/
* B*
* A
这次本地运行还验证了两个行为。第一,操作会原子更新 refs;无法无冲突重放时直接终止,不会把仓库留在“rebase 做到一半”的状态。第二,--dry-run 只是“不移动 refs”,新对象仍会写进对象库,并输出可交给 git update-ref 的更新格式,所以它不是零副作用预览。
四、它和 rebase、Jujutsu 的边界
| 能力 | git history | git rebase -i | Jujutsu |
|---|---|---|---|
| 修改单个旧提交 | 三个专用命令 | todo 列表完成 | 原生 change 操作 |
| 自动更新后代分支 | 默认全部本地分支 | 依赖范围与 --update-refs |
bookmark 随演化处理 |
| 冲突处理 | 遇到潜在冲突即终止 | 进入中间状态后 --continue |
冲突可记录进提交 |
| 操作级撤销 | 没有独立 op log | 依赖 reflog / 备份 ref | jj undo / jj op restore |
| merge commit | 当前不支持 | --rebase-merges |
支持 |
| hook | 当前不执行 | 按具体流程执行 | 模型不同 |
Git 官方明确把它定位为“更 opinionated、更简单”的历史修改入口,而不是 rebase 的替代品。要换 base、批量重排一段提交,仍然应该用 git rebase -i;要把冲突当成可继续传播的一等状态,Jujutsu 仍领先一大截。
HN 评论里有两个实测值得注意。shepmaster 用分叉图对比后确认,git history fixup 会把 rebase 范围外、但同样源于目标提交的本地分支一起重写。另一个用户 chandlerswift 发现,git history reword 重写后原先的 GPG 签名消失,于是回到 rebase -i。后者与“当前不执行 hook”一起说明:签名与合规流水线不能只看命令是否成功。
五、目前的局限与待验证项
- merge commit 不支持(不足):历史中包含 merge 时,官方建议继续使用
git rebase --rebase-merges。大型长期分支很容易碰到这一边界。 - 冲突即终止(待验证):原子性减少了半完成状态,但也意味着复杂改写无法暂停后人工解决。官方把未来可能的解法寄托在 Git 获得 first-class conflicts 上。
- 提交签名会怎样恢复(坑点):HN 已有 GPG 签名丢失的最小复现;我这次构建关闭了 OpenSSL,未在本机复跑签名链。受保护分支和 SLSA 流程先不要直接采用。
- hook 完全不执行(不足):依赖
commit-msg、签名或审计 hook 的团队,需要在命令后另跑验证;这不是普通 rebase 的无缝替换。 - 默认更新全部后代分支(坑点):有人会把
feature.58、feature.59这类分支当历史快照。默认重写会破坏这种约定,应使用--update-refs=head,或把快照改为明确的 tag。 - 实验命令的兼容性(还在调研):手册首段就写着 behavior may change。自动化脚本至少应锁定 Git 2.55.x,并先跑
--dry-run检查 ref 更新清单。 - 大仓库性能(待验证):这次只跑了 4 个提交、2 个分支的最小样例;数千后代提交与数百本地分支下的对象写入量和耗时还没有数据。
六、适用场景建议
我会先在个人 feature stack、尚未 push 的本地分支上使用它,尤其是“把一个小修复折回前面提交”和“拆开混在一起的提交”这两类任务。它最有价值的地方不是少敲几条命令,而是把操作限定成明确意图,并在冲突出现前整体拒绝。
暂时不建议把它直接放进共享仓库的自动化修史脚本。命令处于实验状态、会默认更新多条本地分支、不运行 hook,且签名行为还存在明确问题。团队采用前可以加三道保护:只处理未发布分支、先 --dry-run 保存 ref 清单、完成后执行 git fsck 与签名策略检查。
# 更保守的团队试用骨架
git branch backup/before-history-$(date +%Y%m%d-%H%M%S)
git history fixup --dry-run --update-refs=head HEAD~2 > /tmp/ref-updates.txt
cat /tmp/ref-updates.txt
# 人工确认后,去掉 --dry-run 再执行
git fsck --full
git log --show-signature --oneline -10
如果工作流本来就是严格线性、最终只 squash 一个提交,这个命令带来的收益有限;如果日常维护 stacked branches,它值得在隔离仓库里认真试一次。
七、参考链接
- Git 官方
git-history手册:https://git-scm.com/docs/git-history - Lalit Maganti:The git history command deserves more attention:https://lalitm.com/post/git-history/
- Hacker News 讨论:https://news.ycombinator.com/item?id=48901010
- Jujutsu operation log:https://docs.jj-vcs.dev/latest/operation-log/
- Jujutsu first-class conflicts:https://docs.jj-vcs.dev/latest/conflicts/
- Git 2.55 标签:https://github.com/git/git/releases/tag/v2.55.0
浙公网安备 33010602011771号