Git 2.55 的 history 命令实操:fixup、reword、split 如何改写提交图

一、起因:Git 核心开始提供更直接的历史编辑入口

今天 Hacker News 上一篇介绍 git history 的文章拿到 324 分、186 条评论。它不是第三方插件,而是 Git 核心在 2.54 和 2.55 中加入的一组实验命令:rewordsplit 在 2.54 出现,fixup 在 2.55 补齐。

这组命令要解决的不是“如何查看历史”,而是一个更具体的问题:当一串本地提交已经分叉到多个分支后,怎样修改较早的提交,同时让后代分支一起跟上。传统做法通常是 commit --fixuprebase --autosquash--update-refs 的组合;git history 把它收窄成三个意见明确的动作。

我具体做了两件事:先对照 Git 2.55.0 官方手册和源码,再在当前 Linux 环境里从 v2.55.0 标签编译一份独立二进制。系统自带 Git 是 2.34.1,确实没有该命令;编译出的版本返回 git version 2.55.0git 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 都得到新哈希,mainside 同时落在重写后的图上:

* 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.58feature.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,它值得在隔离仓库里认真试一次。

七、参考链接

  1. Git 官方 git-history 手册:https://git-scm.com/docs/git-history
  2. Lalit Maganti:The git history command deserves more attention:https://lalitm.com/post/git-history/
  3. Hacker News 讨论:https://news.ycombinator.com/item?id=48901010
  4. Jujutsu operation log:https://docs.jj-vcs.dev/latest/operation-log/
  5. Jujutsu first-class conflicts:https://docs.jj-vcs.dev/latest/conflicts/
  6. Git 2.55 标签:https://github.com/git/git/releases/tag/v2.55.0
posted @ 2026-07-14 19:18  Ninghg  阅读(20)  评论(0)    收藏  举报