cherry-pick和revert到底改了什么?用parent、tree和真实Git实验回答

发布时间:2026/10/16 6:27:15
cherry-pick和revert到底改了什么?用parent、tree和真实Git实验回答
feature 上有一个修复我只想把它带到 master。发现修复不对又想撤销它。如果把这两个动作理解成“把提交搬过来再把提交删掉”看日志时就容易困惑原提交明明还在master 却多了两个提交。文件内容可以恢复为什么历史没恢复这次不用中文比喻猜命令的行为。我们在一个临时仓库里观察两件东西parent 决定提交接在哪条历史上tree 决定提交保存的文件快照。下面只需要标准 Git 和 PowerShell不需要下载我的 mini-git也不依赖未公开源码。本轮使用 Git for Windows 2.45.2.windows.1。提交 ID 会受文件编码、提交时间等影响读者应该比较关系不必与图片里的哈希相同。1. 先把“搬提交”拆成三个问题准备这样的分叉A 有 a.txtB 在 master 增加 main.txtC 在 feature 增加 fix.txt。两边改不同文件先排除冲突干扰。C (feature): 增加 fix.txt / A---B (master): 增加 main.txtcherry-pick C 时要问的不是“C 有没有过去”而是变化来自哪两个快照变化应用到哪个快照新提交的 parent 是谁先把文件内容记住再执行命令。下面要看的是main.txt会不会丢fix.txt从哪里来撤销后历史还在不在。2. 建一个不碰原项目的实验仓库下面各个 PowerShell 块按顺序在同一个窗口执行。第一个块创建临时目录不要把创建目录的步骤删掉后直接在自己的项目里试验。$ErrorActionPreference Stop $PSNativeCommandUseErrorActionPreference $false function G { git args; if ($LASTEXITCODE -ne 0) { throw git failed: $args } } $lab Join-Path $env:TEMP (git-pick- [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 blogexample.invalid G config core.autocrlf false Set-Content a.txt base G add a.txt G commit -m A G switch -c feature Set-Content fix.txt feature fix G add fix.txt G commit -m C $C G rev-parse HEAD G switch master Set-Content main.txt main only G add main.txt G commit -m B $B G rev-parse HEADPowerShell 各版本的 Set-Content 默认编码不完全相同所以本篇不固定某个提交哈希。实验关心文件是否存在、内容是否保留以及 parent 和 tree 的相等关系。辅助函数G不改变Git行为只把非零退出转成异常后续命令不会在上一步失败后继续执行。仅设置$ErrorActionPreferenceStop不足以在所有PowerShell版本中捕获原生命令失败因此这里显式检查$LASTEXITCODE。3. cherry-pick拿来的是变化不是整棵treeG diff ${C}^ $C --stat G cherry-pick $C $picked G rev-parse HEAD G diff $B $picked --stat picked_parent_is_B ((G rev-parse HEAD^) -eq $B) feature_still_points_to_C ((G rev-parse feature) -eq $C) picked_id_differs_from_C ($picked -ne $C) Get-Content main.txt,fix.txt两次 diff 的方向很重要第一次看 C 相对其 parent 引入的变化第二次看这份变化应用到 B 后的结果。本例都是增加 fix.txtmain.txt 仍然存在。观察本轮结果说明C 的 parentB新提交接在当前历史上feature 的引用仍是 C原分支没有被移动C 与 C 的 ID不同parent不同commit内容就不同master 专有的 main.txt保留不是拿C的完整快照替换Bfeature 的 fix.txt出现在masterC引入的变化被应用过来这里说“变化”是理解行为的方式不是说 Git 的 commit 对象只存补丁。commit 指向 treetree 表示快照比较两个 tree 才得到它们之间的变化。本例也不证明“所有 cherry-pick 都生成不同 ID”。真实 Git 还支持快进、只应用不提交、处理空变化等选项我们验证的是默认调用在这个分叉样例中的行为。Git cherry-pick手册4. revert新增反向修改不删除原提交现在立即撤销刚得到的 CG revert --no-edit $picked $R G rev-parse HEAD revert_parent_is_picked ((G rev-parse HEAD^) -eq $picked) revert_tree_equals_B ((G rev-parse HEAD^{tree}) -eq (G rev-parse ${B}^{tree})) fix_file_removed (-not (Test-Path fix.txt)) G log --oneline --graph --all这轮得到两个同时成立、却表达不同含义的等式R.parent C 历史上仍然保留那次修复 R.tree B.tree 这个样例的文件快照恢复到修复前C 没有被删除master 也没有退回 B而是向前移动到 R。“内容回到从前”和“引用回到从前”不是同一个动作。tree 恰好相同是因为 C 只引入这份独立修复后面还没有别的修改。接下来故意改变这个前提。现在把刚才的文件变化串起来看。parent箭头朝向祖先R的内容与B相同不代表R就是B也不代表C被删除。5. 后面还有工作时revert会把它一起抹掉吗从 C 创建一个新的实验分支加一个 later.txt再撤销 C。这不会改动刚才 master 上的 R。G switch -c later-case $picked Set-Content later.txt work after the fix G add later.txt G commit -m L $L G rev-parse HEAD G revert --no-edit $picked $R2 G rev-parse HEAD new_parent_is_L ((G rev-parse HEAD^) -eq $L) later_file_retained (Test-Path later.txt) fix_file_removed (-not (Test-Path fix.txt)) tree_still_equals_B ((G rev-parse HEAD^{tree}) -eq (G rev-parse ${B}^{tree})) Get-Content later.txt,main.txt这次前三项是 True第四项是 False。later.txt 保留了因此 R2 的 tree 不等于 B 的 tree。这里的“不等于”不是撤销失败而是后续工作没有被一并丢弃。场景新提交接在哪新tree是否等于B后续工作立即撤销独立修复C之后本例相等尚无后续修改增加later.txt后再撤销L之后本例不相等later.txt保留所以不能把 revert 描述成“把整个仓库恢复到目标提交的父版本”。它尝试反向应用目标引入的变化。如果后续提交又改了同一个位置仍可能冲突这个不同文件的实验不代表任何历史都能无冲突撤销。Git revert手册6. 不看“成功”提示直接检查关系下面是同一轮命令的执行记录整理图原样保留实际 PowerShell 标准输出中的检查结果排版后截图不冒充桌面终端窗口截图。它与前面的示意图承担不同任务。把口头结论写成断言才能让复现真正有通过或失败的标准。紧接前面的块运行if ((G rev-parse ${picked}^) -ne $B) { throw pick parent mismatch } if ((G rev-parse feature) -ne $C) { throw feature was moved } if ((G rev-parse ${R}^) -ne $picked) { throw revert parent mismatch } if ((G rev-parse ${R}^{tree}) -ne (G rev-parse ${B}^{tree})) { throw immediate revert tree mismatch } if ((G rev-parse ${R2}^) -ne $L) { throw later revert parent mismatch } if (-not (Test-Path later.txt)) { throw later work was lost } if ((G rev-parse ${R2}^{tree}) -eq (G rev-parse ${B}^{tree})) { throw later tree unexpectedly equals B } if (Test-Path fix.txt) { throw fix still exists } PICK_AND_REVERT_CHECKS_PASSED这个验证程序的设计并不复杂先人为隔离变量再分别检查引用、parent、tree和工作区文件。只比较最终文件不够因为“移动引用”和“新增反向提交”可以得到相同文件却留下不同历史。它只覆盖这两个小场景不是 Git 的完整兼容测试。运行期间没有接触远程仓库也没有执行强制推送或清理原项目。用完以后可自行保留临时目录继续观察。下次遇到“摘取”“撤销”“回退”这些词先问清它在说文件快照还是提交入口。这个区别比记两个命令的中文名称更有用。相关stash里的三份状态如何保存。下一步再看连续多个提交怎样按顺序重放以及冲突时如何暂停。