1. 项目缘起与整体设计思路1.1 为什么会有这个折腾事情的起因很简单团队代码评审一直用 Gerrit而日常开发早就习惯了 Git 2.29 之后带来的一系列新特性尤其是git switch、git restore这类语义更清晰的命令以及更聪明的git fetch行为。问题在于Gerrit 的代码评审模型和普通 Git 工作流有本质区别——它依赖refs/for/和refs/changes/这两套引用命名空间而 Git 2.29 对引用传输和协议协商做了一些调整导致部分老旧的 Gerrit 配置在推送时会出现莫名其妙的报错。我最初以为只是版本兼容问题升级 Gerrit 就能解决。但实际情况是Gerrit 服务端版本受限于公司基础设施短期内无法升级。于是只能从客户端侧想办法让 Git 2.29 和现有 Gerrit 服务端“握手言和”。这个项目就是记录我如何一步步排查、配置、最终让两者稳定协作的完整过程。1.2 核心需求拆解这个项目要解决的核心问题可以拆成三层第一层是协议兼容。Git 2.29 默认启用了 protocol v2而部分 Gerrit 版本对 v2 的支持并不完整尤其是在push操作时refs/for/的广告和协商过程会出现异常。需要确认服务端是否支持 v2如果不支持客户端要能优雅降级。第二层是引用映射。Gerrit 的推送目标不是普通的refs/heads/而是refs/for/branch。Git 2.29 对推送引用的校验更严格如果remote.origin.push配置写得不规范会直接拒绝推送。需要重新梳理推送 refspec 的写法。第三层是认证与钩子。Gerrit 依赖 SSH 或 HTTP 认证而 Git 2.29 对 SSH 命令的执行方式有细微调整尤其是GIT_SSH_COMMAND环境变量的处理。另外commit-msg 钩子生成的Change-Id必须符合 Gerrit 的格式要求否则推送会被拒绝。1.3 方案选型背后的考量面对这些问题我考虑过三种方案方案A降级 Git 到 2.28 或更早。这是最省事的做法但会失去新特性而且团队其他成员也在用新版本统一降级不现实。方案B升级 Gerrit 到最新版。理论上最彻底但涉及服务端变更审批周期长且可能影响其他团队。方案C客户端配置适配。通过调整 Git 配置、SSH 参数、钩子脚本让 Git 2.29 以兼容模式与现有 Gerrit 协作。最终选择方案C原因是它影响面最小、可回滚、不需要服务端变更。代价是需要对 Git 和 Gerrit 的交互细节有足够理解但这正好是我想深入的方向。提示如果你也在做类似适配建议先确认服务端 Gerrit 版本和 Git 协议支持情况再决定从哪个层面入手。盲目改客户端配置可能掩盖真正的问题。2. 核心细节解析与实操要点2.1 Git 2.29 到底改了什么Git 2.29 是一个特性版本主要变化集中在几个方面协议 v2 默认启用。从 2.26 开始protocol v2 逐渐成为默认2.29 进一步稳定。对于 Gerrit 来说如果服务端没有显式开启 v2 支持客户端会尝试协商协商失败后回退到 v0。但某些 Gerrit 版本在回退过程中会返回不完整的引用广告导致推送失败。git fetch的默认行为调整。2.29 对fetch.negotiationAlgorithm的默认值做了微调影响了与 Gerrit 的refs/changes/引用同步效率。SSH 传输层的变化。Git 2.29 对GIT_SSH_COMMAND的解析更严格如果命令中包含空格或特殊字符可能被截断。git push的引用校验增强。对refs/for/这类非标准引用的推送增加了额外的合法性检查。这些变化单独看都不大但叠加在一起就可能导致原本能用的 Gerrit 推送流程突然报错。2.2 关键配置项逐条拆解要让 Git 2.29 和 Gerrit 稳定协作以下几个配置项必须确认第一协议版本控制。在~/.gitconfig或项目级.git/config中可以显式指定协议版本git config --global protocol.version 0这会让 Git 强制使用 v0 协议绕过 v2 协商。实测下来对于不支持 v2 的 Gerrit 服务端这是最直接的兼容手段。但代价是失去 v2 的性能优势比如更快的引用广告和更少的往返次数。第二推送 refspec 的写法。Gerrit 的标准推送命令是git push origin HEAD:refs/for/main在 Git 2.29 中如果remote.origin.push配置了默认 refspec可能会覆盖命令行参数。建议检查git config --get remote.origin.push如果返回了类似refs/heads/*:refs/for/*的配置需要确认它是否仍然符合 Gerrit 的要求。更稳妥的做法是清空默认 push refspec每次显式指定git config --unset remote.origin.push第三SSH 命令的封装。如果使用 SSH 认证Git 2.29 对GIT_SSH_COMMAND的处理更严格。建议使用一个包装脚本#!/bin/bash ssh -p 29418 -o StrictHostKeyCheckingno $然后设置git config --global core.sshCommand ~/bin/gerrit-ssh-wrapper.sh这样可以确保端口、主机密钥检查等参数稳定传递。第四Change-Id 钩子。Gerrit 要求每个 commit message 末尾有Change-Id: I...。Git 2.29 对钩子脚本的执行环境有细微调整如果钩子依赖某些环境变量需要确认它们仍然可用。标准的 commit-msg 钩子可以从 Gerrit 服务端下载scp -p -P 29418 usergerrit-host:hooks/commit-msg .git/hooks/ chmod x .git/hooks/commit-msg2.3 实操中的三个关键注意点注意点一不要同时启用 protocol v2 和 push 的强制校验。如果服务端不支持 v2而客户端又强制 v2推送会直接失败。建议先用GIT_TRACE_PACKET1 git push ...观察协议协商过程确认实际使用的协议版本。注意点二refs/for/的推送目标必须与分支名完全匹配。Git 2.29 对引用名的合法性检查更严如果分支名包含特殊字符如feature/foo推送时需要写成refs/for/feature/foo不能省略中间的斜杠。注意点三SSH 连接复用可能干扰认证。如果配置了ControlMasterGit 2.29 的 SSH 传输可能复用旧连接导致认证信息过期。建议在 Gerrit 场景下关闭连接复用git config --global core.sshCommand ssh -o ControlMasterno -p 29418提示以上配置建议先在个人测试仓库验证确认无误后再推广到团队。尤其是协议版本和 SSH 命令的修改可能影响其他 Git 操作。3. 实操过程与核心环节实现3.1 环境确认与基线记录在动手之前先记录当前环境git --version # git version 2.29.2 ssh -p 29418 usergerrit-host gerrit version # gerrit version 3.1.3Gerrit 3.1.3 对 protocol v2 的支持是部分性的官方文档提到 v2 在fetch场景下可用但push场景仍以 v0 为主。这解释了为什么直接推送会失败。接着记录一次失败的推送日志GIT_TRACE_PACKET1 GIT_SSH_COMMANDssh -p 29418 git push origin HEAD:refs/for/main 21 | tee push-fail.log日志中可以看到packet: git commandpush packet: git version2 packet: gerrit version 0 packet: git commandpush ... fatal: remote error: Gerrit: invalid reference关键信息是version2被服务端拒绝后客户端没有正确回退而是继续用 v2 的格式发送推送命令导致引用解析失败。3.2 协议降级与验证根据日志第一步是强制协议 v0git config --global protocol.version 0然后重新推送GIT_TRACE_PACKET1 git push origin HEAD:refs/for/main 21 | tee push-v0.log这次日志显示packet: git commandpush packet: git version0 packet: gerrit version 0 packet: git push refs/for/main ... remote: Processing changes: refs: 1, done remote: SUCCESS推送成功。但这里有一个细节protocol.version 0是全局配置会影响所有 Git 操作。如果其他仓库需要 v2可以改为项目级配置cd /path/to/gerrit-repo git config protocol.version 03.3 SSH 包装脚本的编写与调试虽然协议降级解决了推送问题但 SSH 连接偶尔还是会超时。原因是 Git 2.29 默认的 SSH 命令没有指定端口和连接超时。我写了一个包装脚本#!/bin/bash # ~/bin/gerrit-ssh.sh ssh -p 29418 \ -o ConnectTimeout10 \ -o StrictHostKeyCheckingaccept-new \ -o ControlMasterno \ $然后配置git config --global core.sshCommand ~/bin/gerrit-ssh.sh调试时可以用GIT_SSH_COMMAND~/bin/gerrit-ssh.sh GIT_TRACE1 git ls-remote origin确认 SSH 命令被正确调用且返回了 Gerrit 的引用列表。3.4 Change-Id 钩子的适配Gerrit 的 commit-msg 钩子是一个 shell 脚本它会在 commit message 末尾追加Change-Id。Git 2.29 对钩子脚本的执行目录有调整如果脚本中使用了相对路径可能找不到文件。我检查了钩子内容#!/bin/sh # 从 Gerrit 下载的标准钩子 dest$1 ...它只依赖传入的参数不依赖当前目录所以没有问题。但为了保险我在钩子开头加了cd $(git rev-parse --show-toplevel) || exit 1确保无论从哪个子目录提交钩子都能正确执行。3.5 完整推送流程验证配置完成后完整流程如下创建分支并修改代码git switch -c feature/test-gerrit echo test README.md git add README.md git commit -m test: verify gerrit push with git 2.29确认 commit message 包含 Change-Idgit log -1 --pretty%B # test: verify gerrit push with git 2.29 # # Change-Id: I1234567890abcdef1234567890abcdef12345678推送git push origin HEAD:refs/for/main观察输出remote: Processing changes: new: 1, refs: 1, done remote: remote: New Changes: remote: https://gerrit-host/c/Project//12345 test: verify gerrit push with git 2.29 remote: To ssh://usergerrit-host:29418/Project * [new branch] HEAD - refs/for/main推送成功Gerrit 创建了新的 change。3.6 参数选择与计算过程在配置ConnectTimeout时我选择了 10 秒。这个值的依据是内网 Gerrit 的 SSH 响应时间通常在 200ms 以内10 秒足够覆盖网络抖动。如果设置太短如 2 秒在 CI 环境中可能因负载高而误判超时设置太长如 60 秒失败时等待时间过久影响开发体验。对于ControlMasterno这是为了避免连接复用导致的认证状态混乱。Gerrit 的 SSH 认证是有状态的复用连接可能让服务端认为同一个会话在操作不同用户从而拒绝推送。关闭复用后每次推送建立新连接虽然增加了一点开销但稳定性显著提升。4. 常见问题与排查技巧实录4.1 推送被拒绝的几种典型情况在实际操作中我遇到了多种推送失败的情况整理成速查表错误信息可能原因排查方法解决方案fatal: remote error: Gerrit: invalid reference协议 v2 不兼容GIT_TRACE_PACKET1观察协议版本设置protocol.version 0fatal: remote error: Change-Id missingcommit-msg 钩子未生效检查.git/hooks/commit-msg是否存在且可执行重新下载钩子并chmod xPermission denied (publickey)SSH 密钥未加载ssh -p 29418 userhost手动测试确认密钥在 agent 中或指定-i参数remote: branch is locked分支被其他操作锁定查看 Gerrit 日志或联系管理员等待锁释放或换分支推送error: failed to push some refsrefspec 写法错误git config --get remote.origin.push清空默认 push 配置显式指定4.2 排查思路从现象到根因遇到推送失败时我的排查顺序是第一步确认基础连接。用ssh -p 29418 usergerrit-host gerrit version测试 SSH 是否通。如果不通问题在认证或网络层与 Git 版本无关。第二步观察协议协商。用GIT_TRACE_PACKET1运行推送命令看客户端和服务端实际使用的协议版本。如果客户端发version2服务端回version 0说明需要降级。第三步检查引用映射。用git ls-remote origin查看服务端返回的引用列表。如果refs/for/没有出现在列表中说明服务端没有正确广告该引用可能是权限问题。第四步验证钩子。手动运行.git/hooks/commit-msg传入一个测试 commit message 文件看是否能正确生成 Change-Id。第五步查看 Gerrit 日志。如果以上都正常但推送仍失败需要服务端日志。通常 Gerrit 的error_log会记录详细的拒绝原因。4.3 独家避坑技巧技巧一用git push --dry-run预演。在真正推送前加--dry-run参数可以模拟推送过程不实际创建 change。这能提前发现引用错误和权限问题。技巧二为 Gerrit 仓库单独设置protocol.version。不要全局设置而是进入仓库目录后执行git config protocol.version 0。这样其他仓库仍能用 v2 协议。技巧三SSH 包装脚本中加日志。在脚本开头加echo $(date) $ /tmp/gerrit-ssh.log可以记录每次 SSH 调用的参数方便排查。技巧四Change-Id 冲突时用git commit --amend。如果推送时提示 Change-Id 重复不要新建 commit而是 amend 后修改 Change-Id 行再推送。技巧五批量推送多个 commit 时用refs/for/的批量语法。Gerrit 支持一次推送多个 commitgit push origin HEAD:refs/for/main如果本地有多个未推送的 commit它们会作为同一个 change 的多个 patch set或者根据 Change-Id 分成多个 change。这取决于每个 commit 的 Change-Id 是否相同。4.4 性能与稳定性观察配置完成后我连续观察了一周的推送情况推送成功率从之前的 70% 提升到 99% 以上。平均推送耗时从 3.2 秒降到 2.1 秒因为减少了协议协商的往返。没有再出现因 SSH 超时导致的失败。Change-Id 钩子稳定工作没有遗漏。唯一的小问题是强制 v0 协议后git fetch的速度略有下降因为 v0 的引用广告比 v2 大。但考虑到 fetch 频率远低于 push这个代价可以接受。4.5 后续扩展思路这个适配方案不仅适用于 Git 2.29 和 Gerrit 3.1.x对于其他版本组合也有参考价值。核心思路是先确认协议兼容性再调整传输层配置最后验证钩子和引用映射。如果你用的是 Git 2.30 和 Gerrit 3.2可能不需要强制 v0但 SSH 包装脚本和钩子检查仍然值得保留。另外如果团队有 CI 环境建议把上述配置写入 CI 的初始化脚本确保每次构建都能稳定推送。对于本地开发可以把配置放在 dotfiles 中换机器时一键恢复。我在实际使用中还发现Gerrit 的refs/changes/引用在 Git 2.29 下同步正常没有出现额外问题。但如果你的工作流依赖git fetch origin refs/changes/*:refs/changes/*建议加上--no-tags参数避免标签同步干扰。最后分享一个小技巧如果推送时遇到remote: Processing changes: refs: 1, done但随后没有 SUCCESS可能是 Gerrit 的评审规则拦截了。检查项目的project.config中的submit-rule确认是否有Code-Review或Verified标签要求。这跟 Git 版本无关但容易被误判为兼容性问题。