ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

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

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.txt;B 在 master 增加 main.txt;C 在 feature 增加 fix.txt。两边改不同文件,先排除冲突干扰。

C (feature): 增加 fix.txt / A---B (master): 增加 main.txt

cherry-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 'blog@example.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 HEAD

PowerShell 各版本的 Set-Content 默认编码不完全相同,所以本篇不固定某个提交哈希。实验关心文件是否存在、内容是否保留,以及 parent 和 tree 的相等关系。

辅助函数G不改变Git行为,只把非零退出转成异常;后续命令不会在上一步失败后继续执行。仅设置$ErrorActionPreference='Stop'不足以在所有PowerShell版本中捕获原生命令失败,因此这里显式检查$LASTEXITCODE。

3. cherry-pick拿来的是变化,不是整棵tree

G 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.txt;main.txt 仍然存在。

观察本轮结果说明
C' 的 parentB新提交接在当前历史上
feature 的引用仍是 C原分支没有被移动
C' 与 C 的 ID不同parent不同,commit内容就不同
master 专有的 main.txt保留不是拿C的完整快照替换B
feature 的 fix.txt出现在masterC引入的变化被应用过来

这里说“变化”是理解行为的方式,不是说 Git 的 commit 对象只存补丁。commit 指向 tree,tree 表示快照;比较两个 tree 才得到它们之间的变化。

本例也不证明“所有 cherry-pick 都生成不同 ID”。真实 Git 还支持快进、只应用不提交、处理空变化等选项;我们验证的是默认调用在这个分叉样例中的行为。Git cherry-pick手册

4. revert新增反向修改,不删除原提交

现在立即撤销刚得到的 C':

G 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里的三份状态如何保存。下一步再看:连续多个提交怎样按顺序重放,以及冲突时如何暂停。

RELATED READING

延伸阅读

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