cherry-pick 把选定提交引入的改动应用到当前开发线,适合旧发布分支只需要一个修复、不希望同时接收整条开发分支的场景。它不会搬走或删除源提交,也不会为目标建立与源分支的合并关系。移植后的结果必须在目标环境中重新审阅;源分支上已经通过的测试,只能说明原来的组合,不能直接证明换了依赖与历史之后仍然正确。

选择的是相对父提交的变化

对于普通的单父提交,相关变化是它相对父级的差异。Git 利用可用历史和合并机制,把这份变化应用到目标,而不是简单地用源提交的完整文件快照覆盖目标。目标原本已有的其他修改应继续保留,除非选定变化或人工解决明确要求改变它们。审阅时既要看搬来了什么,也要确认没有误把目标自己的工作一起替换掉。

通常,cherry-pick 会在目标末端后面记录新提交。父级不同,结果目录树也可能不同,因此对象身份会变化。原作者身份和作者日期通常保留,而提交者表示实际记录这次移植的人;消息不一定变化。不能把这项操作解释成必然换作者、必然改写消息,更不能根据对象标识不同就断言原作者署名已经丢失。

默认行为会创建提交,但不提交选项会把变化留在索引与工作树中,供审阅或组合;使用允许快进的选项时,满足条件的操作还可以直接前进到已有源提交。这些明确模式说明,“所有 cherry-pick 都一定生成新对象”也不是普遍成立的规则。解释一次具体结果时,应把实际参数与所处历史一起纳入判断。

移植之前检查依赖

修复可能依赖目标分支尚不存在的接口、数据结构、配置或前置重构。补丁在文本上干净落地,并不能证明行为正确。应阅读源变化及其前提,在真正的维护分支运行受影响行为测试,并只引入该修复实际必需的依赖。如果需要适配,说明为何不能原样采用,以及新分支上的实现如何满足同一修复目标。

在共享分支之间干净应用时,-x 会把源提交标识写进新消息,方便追踪,但它不是等价性或批准状态的密码学证明。手动解决冲突后也要检查最终消息,确认来源关系没有丢失。原提交的签名不会自动成为新对象的有效签名;目标要求签名时,应遵循目标发布流程,而不是把保留作者信息等同于继承了所有验证证据。

坏掉的样子

在默认创建提交的模式中,发生冲突时,HEAD 停在最后一个已成功创建的提交,Git 用 CHERRY_PICK_HEAD 记录当前难以应用的源提交。使用 --no-commit 时不会设置该引用,因此必须检查状态和未合并条目,不能仅凭这个标记是否存在来判断冲突。能够干净应用的路径可能已经更新,冲突路径则在索引中保留未合并条目。文本冲突标记便于查看,但二进制冲突及其他类型不能只靠搜索标记字符串识别,还应检查工作区状态和索引,确认当前到底有哪些未完成事项。

解决时应确定目标需要的行为,暂存具体已解决路径,运行相关测试,再继续序列。文件已经暂存只说明冲突被标记为解决,不证明代码逻辑正确。中止会返回序列开始之前的状态;退出序列管理则只是遗忘操作状态,并不执行相同的回退。开始前应保证工作树干净且状态明确,不能把中止功能当作保存无关未提交工作的替代品。

目标已经包含某项变化时,应用结果可能为空。跳过之前,要确认目标确实具有预期行为,而不是只看到类似代码就作判断。选取合并提交还会多一个语义选择:主线参数指定以编号中的哪个父级作为差异基准。这不是通用冲突绕过开关,必须依据想移植的实际变化选择,而且结果不会重现原合并提交的多父级拓扑。

快速验证

在 POSIX 系统上使用 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)

七个判断都应为真。干净目标包含修复后的值,新提交的父级是目标原来的末端;作者仍为源作者,提交者是移植者,消息记录来源标识。另一条目标线停在冲突状态,索引存在未合并条目;中止后,它恢复原文本、原末端和干净状态。这些结果验证改动转移、身份信息与恢复机制,不冒充真实业务修复已经在维护分支完成验收。

来源