git cherry-pick 重新应用一次提交
cherry-pick 把选定提交引入的改动应用到当前开发线,适合旧发布分支只需要一个修复、不希望同时接收整条开发分支的场景。它不会搬走或删除源提交,也不会为目标建立与源分支的合并关系。移植后的结果必须在目标环境中重新审阅;源分支上已经通过的测试,只能说明原来的组合,不能直接证明换了依赖与历史之后仍然正确。 选择的是相对父提交的变化 对于普通的单父提交,相关变化是它相对父级的差异。Git 利用可用历史和合并机制,把这份变化应用到目标,而不是简单地用源提交的完整文件快照覆盖目标。目标原本已有的其他修改应继续保留,除非选定变化或人工解决明确要求改变它们。审阅时既要看搬来了什么,也要确认没有误把目标自己的工作一起替换掉。 通常,cherry-pick 会在目标末端后面记录新提交。父级不同,结果目录树也可能不同,因此对象身份会变化。原作者身份和作者日期通常保留,而提交者表示实际记录这次移植的人;消息不一定变化。不能把这项操作解释成必然换作者、必然改写消息,更不能根据对象标识不同就断言原作者署名已经丢失。 默认行为会创建提交,但不提交选项会把变化留在索引与工作树中,供审阅或组合;使用允许快进的选项时,满足条件的操作还可以直接前进到已有源提交。这些明确模式说明,“所有 cherry-pick 都一定生成新对象”也不是普遍成立的规则。解释一次具体结果时,应把实际参数与所处历史一起纳入判断。 移植之前检查依赖 修复可能依赖目标分支尚不存在的接口、数据结构、配置或前置重构。补丁在文本上干净落地,并不能证明行为正确。应阅读源变化及其前提,在真正的维护分支运行受影响行为测试,并只引入该修复实际必需的依赖。如果需要适配,说明为何不能原样采用,以及新分支上的实现如何满足同一修复目标。 在共享分支之间干净应用时,-x 会把源提交标识写进新消息,方便追踪,但它不是等价性或批准状态的密码学证明。手动解决冲突后也要检查最终消息,确认来源关系没有丢失。原提交的签名不会自动成为新对象的有效签名;目标要求签名时,应遵循目标发布流程,而不是把保留作者信息等同于继承了所有验证证据。 坏掉的样子 发生冲突时,HEAD 停在最后一个已成功创建的提交,Git 用 CHERRY_PICK_HEAD 记录当前难以应用的源提交。能够干净应用的路径可能已经更新,冲突路径则在索引中保留未合并条目。文本冲突标记便于查看,但二进制冲突及其他类型不能只靠搜索标记字符串识别,还应检查工作区状态和索引,确认当前到底有哪些未完成事项。 解决时应确定目标需要的行为,暂存具体已解决路径,运行相关测试,再继续序列。文件已经暂存只说明冲突被标记为解决,不证明代码逻辑正确。中止会返回序列开始之前的状态;退出序列管理则只是遗忘操作状态,并不执行相同的回退。开始前应保证工作树干净且状态明确,不能把中止功能当作保存无关未提交工作的替代品。 目标已经包含某项变化时,应用结果可能为空。跳过之前,要确认目标确实具有预期行为,而不是只看到类似代码就作判断。选取合并提交还会多一个语义选择:主线参数指定以编号中的哪个父级作为差异基准。这不是通用冲突绕过开关,必须依据想移植的实际变化选择,而且结果不会重现原合并提交的多父级拓扑。 快速验证 使用 Python 3 与 Git 2.28 或更新版本运行下面实验。它建立私有临时仓库,排除个人 Git 配置和继承的 Git 路由变量,使用示例身份,关闭钩子与签名,没有远端。一条目标线能够接收修复;另一条故意把同一行改成不同内容,用来观察冲突停止及中止恢复。全部修改都限于这份临时数据,不触碰当前项目的提交或文件。 import os import subprocess import tempfile from pathlib import Path env = {key: value for key, value in os.environ.items() if not key.startswith("GIT_")} env.update(GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_GLOBAL=os.devnull) with tempfile.TemporaryDirectory(prefix="git-pick-demo-") as directory: def git(*args, check=True): return subprocess.run( ["git", "-c", "user.name=Backport Example", "-c", "user.email=backport@example.invalid", "-c", "commit.gpgsign=false", "-c", "core.hooksPath=/dev/null", *args], cwd=directory, env=env, text=True, capture_output=True, check=check, ) def commit(text, message): (Path(directory) / "value.txt").write_text(text) git("add", "value.txt") git("commit", "-qm", message) git("init", "-q", "-b", "main") commit("old\n", "base") base = git("rev-parse", "HEAD").stdout.strip() git("switch", "-qc", "source") (Path(directory) / "value.txt").write_text("fixed\n") git("add", "value.txt") git("-c", "user.name=Original Example", "-c", "user.email=original@example.invalid", "commit", "-qm", "fix value") source = git("rev-parse", "HEAD").stdout.strip() git("switch", "-q", "main") git("commit", "--allow-empty", "-qm", "target progress") target = git("rev-parse", "HEAD").stdout.strip() git("cherry-pick", "-x", source) print("change-applied:", (Path(directory) / "value.txt").read_text() == "fixed\n") print("new-object-on-target:", git("rev-parse", "HEAD").stdout.strip() != source and git("rev-parse", "HEAD^").stdout.strip() == target) print("author-preserved:", git("show", "-s", "--format=%ae", "HEAD").stdout.strip() == "original@example.invalid") print("committer-is-backporter:", git("show", "-s", "--format=%ce", "HEAD").stdout.strip() == "backport@example.invalid") print("provenance-recorded:", source in git("show", "-s", "--format=%B", "HEAD").stdout) git("switch", "-qc", "conflict", base) commit("other\n", "target alternative") before = git("rev-parse", "HEAD").stdout.strip() conflict = git("cherry-pick", source, check=False) print("conflict-stopped:", conflict.returncode != 0 and bool(git("ls-files", "-u").stdout) and git("rev-parse", "CHERRY_PICK_HEAD").stdout.strip() == source and git("rev-parse", "HEAD").stdout.strip() == before) git("cherry-pick", "--abort") print("abort-restored:", git("rev-parse", "HEAD").stdout.strip() == before and (Path(directory) / "value.txt").read_text() == "other\n" and not git("status", "--porcelain").stdout) 七个判断都应为真。干净目标包含修复后的值,新提交的父级是目标原来的末端;作者仍为源作者,提交者是移植者,消息记录来源标识。另一条目标线停在冲突状态,索引存在未合并条目;中止后,它恢复原文本、原末端和干净状态。这些结果验证改动转移、身份信息与恢复机制,不冒充真实业务修复已经在维护分支完成验收。 ...