ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

rebase后旧提交去哪了?用真实Git检查parent链、冲突继续与中止

rebase后旧提交去哪了?用真实Git检查parent链、冲突继续与中止

rebase 之后,提交图整齐了,提交 ID 却变了;旧的 ID 有时还能读出来,冲突时分支又处在一个看起来很奇怪的位置。只说一句“把分支变基”,并没有回答这些问题。

如果已经理解 cherry-pick 是把一个提交引入的变化应用到当前历史上,就可以沿着这个方向想:连续多个提交要按什么顺序重放?中间停下来以后,怎样知道已经做到了哪一步?

下面只使用标准 Git 和 PowerShell。实验在独立临时仓库运行,使用 Git for Windows 2.45.2.windows.1;不依赖我的 mini-git,也不把教学实现的限制套到真实 Git 上。

1. 同一份变化,接到不同parent后还是原提交吗

先准备 A、B、C、D:A 有 base.txt;master 的 B 增加 b.txt;feature 的 C、D 分别增加 c.txt、d.txt。暂时让各次修改互不冲突。

C---D (feature) / A---B (master)

下面各块在同一个 PowerShell 窗口按顺序执行。第一个块会创建临时目录,不要在有价值的现有仓库里省略这一步。

$ErrorActionPreference = 'Stop' $PSNativeCommandUseErrorActionPreference = $false function G { & git @args; if ($LASTEXITCODE -ne 0) { throw "git failed: $args" } } $lab = Join-Path $env:TEMP ('git-rebase-' + [guid]::NewGuid().ToString('N')) New-Item -ItemType Directory -Path $lab | Out-Null Set-Location $lab G init -b master G config user.name 'Blog experiment' G config user.email 'blog@example.invalid' G config core.autocrlf false Set-Content base.txt base G add base.txt G commit -m A G switch -c feature Set-Content c.txt C G add c.txt G commit -m C $oldC = G rev-parse HEAD Set-Content d.txt D G add d.txt G commit -m D $oldD = G rev-parse HEAD G switch master Set-Content b.txt B G add b.txt G commit -m B $B = G rev-parse HEAD G switch feature

现在只在本机重放:

G rebase master $newD = G rev-parse HEAD $newC = G rev-parse 'HEAD^' 'master_still_B=' + ((G rev-parse master) -eq $B) 'new_C_parent_is_B=' + ((G rev-parse "${newC}^") -eq $B) 'new_D_parent_is_new_C=' + ((G rev-parse "${newD}^") -eq $newC) 'C_id_changed=' + ($newC -ne $oldC) 'D_id_changed=' + ($newD -ne $oldD) G cat-file -t $oldD G log --oneline --graph --all

本轮五项关系检查都是 True,旧 D 的类型仍是 commit。实际结构变成下面这样:

对象或入口重放前重放后
master引用B仍为B
feature引用D改为D'
feature的新parent链D → C → AD' → C' → B → A
C、D原ID对应原commit对象仍可作为旧对象ID查询
当前分支日志显示原链显示新链

commit 的内容不只有文件:它还包含 tree、parent、作者和提交说明等信息。新 base 改变了 C' 的 parent,D' 又接到 C' 后面;即便对应修改很相似,这次也得到了新的 commit ID。

不能反过来说“每次 rebase 都重建所有提交”。已经位于目标 base 之上的分支、空提交、已应用补丁、不同重放模式,都有各自规则。本篇只验证这个特意制造的分叉。Git rebase手册

2. 日志不显示旧提交,不等于对象马上被删除

图中保留旧D到C再到A的这一行,是为了区分“对象是否存在”和“当前分支能否到达它”。普通git log --all从当前引用出发,未必再显示失去这些入口的旧链。

我们提前保存了$oldD,所以可以直接用 cat-file 读取;还能检查 feature 的 reflog:

G reflog show feature --oneline G cat-file -p $oldD

这不意味着旧对象永久保留。reflog、其他引用以及后续过期和垃圾回收策略都会影响保留情况。这里没有运行回收,只能证明“刚完成这次rebase后仍能读取”,不能承诺任何时间都能找回。

3. 为什么必须先C,再D

本例 C、D 增加不同文件,可以只看 parent 链检查顺序。若 D 修改的是 C 才新增的文件,D 的变化还依赖 C 提供的前置状态;随意倒过来执行,就不再是同一套操作。

当前base是B 重放C引入的变化 -> C',parent=B 重放D引入的变化 -> D',parent=C' 最后让feature指向D'

因此 rebase 不是把 D 的完整快照盖到 B 上,也不是只挪一下图里的线。新链、文件结果和分支入口需要分别检查。

接下来增加同一行修改,看看顺利路径之外发生什么。

4. 制造冲突,先验证abort

在刚才成功重放的仓库里,分别让 feature 和 master 修改 base.txt 的同一行。这里预期 rebase 返回非零退出码,不把它当作实验失败。

Set-Content base.txt 'feature side' G add base.txt G commit -m 'feature conflict' $beforeConflict = G rev-parse HEAD G switch master Set-Content base.txt 'master side' G add base.txt G commit -m 'master conflict' G switch feature git rebase master $conflictExit = $LASTEXITCODE if ($conflictExit -eq 0) { throw 'expected a conflict' } if (-not (git ls-files -u -- base.txt)) { throw 'rebase failed without the expected unmerged file' } Get-Content base.txt G status --short G rebase --abort 'abort_restores_HEAD=' + ((G rev-parse HEAD) -eq $beforeConflict) 'abort_restores_file=' + ((Get-Content base.txt -Raw).Trim() -eq 'feature side')

本轮能看到冲突标记;abort 后,两项检查都为 True。它放弃这次重放并恢复操作前状态,不是“把冲突标记删掉以后勉强继续”。

辅助函数G会检查Git退出码,使本应成功的步骤在失败时立即停止。只有这里故意制造冲突时使用原始git,先保存退出码,再检查base.txt的未合并index记录;非零退出也可能来自其他错误,不能仅凭“失败”就认定冲突场景成立。设置$PSNativeCommandUseErrorActionPreference=$false是为了让支持该变量的PowerShell版本也能显式处理这些预期失败。

5. continue不是从头再跑一次

因为上一块已经 abort,现在需要重新进入同一场冲突。不能在 abort 以后直接 continue。

git rebase master if ($LASTEXITCODE -eq 0) { throw 'expected the conflict again' } if (-not (git ls-files -u -- base.txt)) { throw 'expected unmerged base.txt again' } Set-Content base.txt resolved G diff G add base.txt G -c core.editor=true rebase --continue 'resolved_content_retained=' + ((Get-Content base.txt -Raw).Trim() -eq 'resolved') 'worktree_clean=' + (-not [bool](G status --porcelain)) G log --oneline --graph --all

git diff用来检查我们打算保留的内容,git add把解决结果写入 index;continue 再据此完成当前重放并处理剩余队列。core.editor=true仅在这次命令中跳过编辑器等待,不是修改全局编辑器配置。

动作实际目的不代表什么
修改冲突文件决定最终内容Git替你判断业务逻辑正确
git add将解决结果放入index测试已通过
rebase --continue继续当前重放流程重新从第一步随意重放
rebase --abort放弃并恢复操作前状态保留本轮手工解决内容

把所有冲突标记删除,不等于解决方案就是对的。Git 能处理提交关系,不能替业务代码选择“应该保留哪一边”。真实项目里仍要检查 diff 和运行测试。

6. 把可观察状态变成断言

下图是本轮实际 PowerShell 执行记录的整理图,保留重放后对象关系、中止恢复与继续完成的标准输出,排版后截图;不是桌面终端窗口截图。

在同一个窗口继续执行验证:

if ((G rev-parse "${newC}^") -ne $B) { throw 'new base mismatch' } if ((G rev-parse "${newD}^") -ne $newC) { throw 'replay order mismatch' } if ($newC -eq $oldC -or $newD -eq $oldD) { throw 'expected rewritten commits' } if ((G cat-file -t $oldD) -ne 'commit') { throw 'old object unexpectedly missing' } if ((Get-Content base.txt -Raw).Trim() -ne 'resolved') { throw 'resolution missing' } if (G status --porcelain) { throw 'worktree not clean' } if (Test-Path .git/rebase-merge) { throw 'merge-backend rebase still active' } if (Test-Path .git/rebase-apply) { throw 'apply-backend rebase still active' } 'REBASE_CHECKS_PASSED'

验证程序分三个阶段:先检查无冲突路径的新 parent 链,再确认旧对象仍可读,最后检查冲突的停止、中止和继续。只跑最后一项“工作区干净”,无法证明前面每一步做对。

本轮没有接触远程仓库,没有强制推送,也没有测试故障断电、并发操作或所有 rebase 选项。所有命令和断言都在正文里,读者只用标准 Git 就能复现,不需要另外索取私有程序。

rebase 真正改变的不是一句“基底”,而是变化重新应用的位置,以及当前分支最终指向的提交链。别把这个实验照搬到别人已经基于它继续工作的共享历史;多人协作时改写历史的代价,是需要协调其他人的入口。

相关:cherry-pick和revert的parent、tree实验、merge为什么有时只是移动指针,以及stash保存的是哪几份状态。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进