☰
Git+Repo+Gerrit:多仓库代码评审与合入门禁实战
2026/10/17 7:23:00 网站建设 项目流程

第一次把Git、Repo、Gerrit三样工具组合到同一条工作流里,是因为一个很现实的问题:一个移动系统源码工程,几十个独立模块仓库铺开,十几个团队并行开发,单靠Git仓库加中心服务器已经完全管不过来了。分支冲突每天发生,权限又粗,想给某个模块设置“需要评审才能合入”的规则,在纯Git上做起来非常别扭。最后我们引入了Repo来统一管理多仓库清单,用Gerrit作为评审门禁,形成了一条“本地Commit→推送评审→评审通过→自动合入”的链路。这篇文章不是官方文档的复述,而是我在这条链路里从部署到日常使用踩过的坑和沉淀下来的经验,适合正在评估是否要上这套组合、或者已经在用但经常被Change-Id和refs/for搞晕的团队参考。

1. 为什么单Git仓库撑不住,非要Repo和Gerrit一起上

经常有人问我:“我们Git用得挺好的,干嘛还要加Repo和Gerrit?”我的回答一般都很简单——规模和协作方式变了。

1.1 单仓库在大规模并行下为什么先崩溃

单Git仓库在小团队里玩得很顺:三五个人各自拉分支,改完合并,冲突量小,处理起来也快。可一旦仓库数量上到几十个、团队扩到十几个,三个问题会立刻浮出水面。

第一,分支与合入的冲突无法收敛。几十个模块并行时,每次merge都要处理一堆非功能性冲突,比如自动生成文件的变动、目录结构调整、格式化差异。这些冲突跟业务逻辑毫无关系,却要消耗大量工程师时间,而且A团队改了公共接口,B团队半夜合入才发现编译不过的情况特别多。

第二,权限粒度太粗。Git原生对仓库的权限控制主要集中在“能否推送”,它很难表达“谁可以创建分支、谁可以推送到主干分支、谁必须经过评审才能合入”。一旦开发人员变多,很容易出现有人绕过评审直接push到受保护分支的事故,事后你想查是谁合入的、谁批准的,都无从下手。

第三,审计和追溯能力弱。代码评审中产生的讨论、谁给了+2、谁触发了合入——这些信息在纯Git日志里基本是缺失的。项目复盘时想还原一个改动的完整决策过程,只能靠聊天记录,这显然不合理。

1.2 Repo补上的“多仓库清单”短板

Repo解决的问题很粗暴但很有效:它给多仓库工程加了一个“清单层”。

这个清单通常是一个独立的manifest仓库,里面用XML文件记录着所有子仓库的远程地址、默认分支、固定版本。每个开发者只要拉到同一份manifest,再执行一次repo sync,本地的几十个仓库就能整整齐齐地恢复到同一状态。没有Repo的时候,你新加入一个项目,得手动一个一个git clone,还很容易从不同的远程分支拉到不一致的代码版本;有了Repo之后,“可复现”从口号变成了默认能力。

Repo还提供了跨仓库操作的口子。比如repo start可以一次性为所有仓库创建同名分支,repo status可以一眼看到几十个仓库的整体改动情况,repo grep可以在跨仓库代码里搜索关键字。这些能力本身不复杂,但在多仓库场景下能把日常操作的效率提升一个量级。

1.3 Gerrit填上的“评审与门禁”缺口

Gerrit本质上是一个代码托管加评审服务器,但它最关键的贡献是:把“合入”这件事变成了可以被评审、被记录、被阻止的受控事件。

开发者默认不能直接把commit推到refs/heads/xxx,只能推到refs/for/xxx。Gerrit收到后不会立刻更新目标分支,而是生成一个待评审的Change。评审人在界面上打分、留评论,全部通过后,合入动作才由Gerrit服务端真正执行。这个设计把“修改代码”和“进入主干”彻底分离,权限控制、流程审计、质量门禁全都有了落点。

1.4 什么项目适合,什么项目别生搬硬套

项目类型是否适合这套组合原因
几十个仓库、多团队并行的工程非常适合清单统一管理有收益,评审门禁能挡事故
3到5人的单仓库小团队不适合引入Repo和Gerrit的运维成本超过收益
需要大量外部临时贡献者的项目视情况而定评审有价值,但Gerrit门槛可能吓跑新手
纯个人项目完全不适合一个Git远端备份足够

我见过有团队只有六个仓库也要硬上Gerrit,每天被Change-Id和权限配置折腾得苦不堪言,最后一怒之下退回GitLab。工具组合没有绝对的好坏,只有合不合适的场景。

2. 把Repo同步链跑通:init、sync与manifest的配合

确定要上这套组合之后,第一步就是把这根同步链跑通。这一步看着简单,但版本兼容、manifest写法、本地分支策略都是容易出问题的地方。

2.1 基础工具:Git版本、Repo脚本与Python兼容性

建议把Git升级到较新的稳定版,老版本Git在处理浅克隆、安全策略和部分子命令时行为差异很大,出问题后很难排查。Repo本身是一个Python脚本,早期版本依赖Python 2,现在主流版本都要求Python 3,安装时最好直接用发行版仓库里带的repo,或者从官方渠道拉取并校验版本。

一个常见的坑:直接用apt install repo装上的版本可能偏旧,和某些Gerrit服务端的协议不兼容,表现就是同步时报奇怪的SSL错误或者解析XML失败。遇到这种情况,优先升级repo脚本版本,而不是去折腾服务端。

2.2 manifest仓库设计:remote、default与project的意义

manifest仓库是整个多仓库工程的“总蓝图”,理解它比背命令重要得多。下面是一个典型的manifest文件结构:

<manifest> <remote name="origin" fetch="ssh://git.example.com/" review="https://gerrit.example.com/"/> <default revision="main" remote="origin"/> <project path="app/core" name="modules/core" /> <project path="app/network" name="modules/network" revision="dev-1.2" /> </manifest>

remote标签里的fetch是所有子仓库URL的前缀,review字段是Gerrit地址,供repo upload生成评审目标。default标签里的revision是默认分支,所有没单独写revision的project都会用这个分支。某个模块需要紧跟特定分支时,就在project标签里单独覆盖,像上面的modules/network一样锁定在dev-1.2。

我个人的建议是:manifest里尽量锁定固定revision,至少对关键模块使用tag或SHA,而不是裸的分支名。分支名跟着远端走,今天同步的是这个状态,明天可能就是另一个状态,出了问题很难复现。

2.3 首次走通repo init与sync

初始化一组仓库,命令很简洁,但每一步都有事情在发生:

repo init -u ssh://git.example.com:29418/manifest.git -b main repo sync -j8 -c

repo init会在当前目录生成一个.repo目录,里面存放manifest仓库本体、各项目的引用信息和钩子配置。repo sync的任务是把manifest里记录的每个项目同步到本地,-j8是8个任务并行拉取,-c是只同步当前分支而不是所有远端分支,能省不少时间。

首次同步的体验跟网络环境关系很大,如果之前有人同步过同样的工程,可以配置本地镜像或者缓存目录:

repo init -u <manifest地址> --reference=/path/to/mirror

这能把后续同步的耗时压缩到分钟级。没有镜像的情况下,几十个仓库逐一clone确实会等很久,这是多仓库工作的常态,耐心点就好。

2.4 用repo start建立特性分支

同步完成后,不要直接在默认分支上改代码,而是用repo start为所有仓库创建本地主题分支:

repo start stable-1.0 --all

这条命令的含义是:在每一个仓库里,基于当前manifest指定的版本,创建一个名为stable-1.0的本地分支,并让它追踪对应的远端分支。这样做的好处是,后续你在任何一个仓库里的提交都发生在自己的命名空间里,不会跟某个版本的快照纠缠在一起。改完代码后,Git分支名、Gerrit上显示的目标分支,关系一目了然。

3. 从commit到评审合入的完整闭环

同步链跑通之后,日常工作的核心就是“本地提交→推送评审→通过合入”这个闭环。很多新人在这个阶段被Change-Id和refs/for绕得晕头转向,我拆细一点说。

3.1 Change-Id:Gerrit识别同一笔改动的身份证

Git里每次commit有一个唯一的哈希值,但这个哈希在--amend、rebase之后会变化。如果Gerrit靠commit哈希来识别评审任务,那么同一笔逻辑改动连续修改两次,就会在系统里变成两个互不相关的Change,评审历史和讨论全部断裂。

所以Gerrit定义了Change-Id:以字母I开头,后面跟40位十六进制字符的一行字符串,放在commit message末尾。无论commit哈希怎么变,只要Change-Id还在,Gerrit就把多次推送归入同一个Change,并生成不同的Patch Set。评审人在新Patch Set上继续追评,上下文不断。

3.2 commit-msg hook的安装与验证

Change-Id通常不是人工手写的,而是由Git的commit-msg钩子在每次提交时自动追加。Gerrit服务端提供了这个hook,开发者需要把它安装到仓库的.git/hooks/目录里。

安装命令一般是这样的:

gitdir=$(git rev-parse --git-dir) curl -o "$gitdir/hooks/commit-msg" "https://gerrit.example.com/tools/hooks/commit-msg" chmod +x "$gitdir/hooks/commit-msg"

也可以用scp的方式拉取:

scp -p -P 29418 username@gerrit.example.com:hooks/commit-msg .git/hooks/

安装后验证一下:

ls -l .git/hooks/commit-msg

只要这个文件存在且有执行权限,之后每次git commit,hook都会在提交信息自动追加一行Change-Id。

3.3 推送到refs/for而不是refs/heads

评审流的推送命令是:

git push origin HEAD:refs/for/main

这里的refs/for/main是Gerrit的特殊命名空间,推到这里的commit不会直接更新main分支,而是生成一个待评审的Change。真正更新main分支的动作由Gerrit在评审通过后执行。

新人最容易犯的错是直接执行:

git push origin HEAD:refs/heads/main

这在多数团队里会被权限系统直接拒绝,因为refs/heads/*通常只开放给维护者。如果权限配置不当没有拦住,那就是绕过评审直接改主干,属于流程事故。所以团队里一定要做保护性配置:**普通开发者只有refs/for/的推送权限,refs/heads/只留给机器用户或特定维护组。

3.4 评审、打分、合入生命周期

一个Change的完整旅程大致如下:

  1. 开发者推送refs/for/main,Gerrit生成新Change。
  2. 在Gerrit页面上添加Reviewer,评审人逐行看代码、留评论。
  3. 代码质量通过后,有权限的人给Code-Review +2。
  4. CI或验证人员跑完测试后给Verified +1。
  5. 两个条件都满足后,点Submit合入。

如果合入时发现目标分支已经领先,Gerrit会提示Need Rebase。这时需要在本地把目标分支最新代码拉下来,rebase自己的提交,再重新推送。Gerrit会在同一个Change下生成新的Patch Set,评审记录保持连续。

4. 部署Gerrit侧的准备:账号、权限与合入策略

很多团队在客户端这边折腾了半天,服务端配置却是随手搞的,导致后面流程各种别扭。Gerrit服务端的几个关键点值得好好设计。

4.1 SSH别名与Gerrit账号打通

开发者连Gerrit一般走SSH,端口默认是29418。为了方便,我会在每个人的~/.ssh/config里加一段:

Host gerrit HostName gerrit.example.com Port 29418 User username

这样后面所有带gerrit主机名的操作都会自动用配置的端口和用户名。第一次连接时,Gerrit会提示你把SSH公钥上传到账号里,验证一下:

ssh -p 29418 username@gerrit.example.com

能返回类似**** ****的欢迎信息,账号就通了。

4.2 权限组与Label的设计

Gerrit的权限模型有一个核心概念:权限继承。根项目All-Projects定义了全局权限,所有项目可以从它继承,子项目可以覆盖或追加。这样团队只需要维护一套基线规则,不用每个仓库单独配置。

我常用的配置基线是:

  • refs/for/*:允许所有注册开发者Push。
  • refs/heads/*:默认拒绝Push,仅通过评审Submit合入。
  • Label Code-Review:+2权限只授予主程和维护者组成的组。
  • Label Verified:+1权限通常授予CI机器人。

这一套配下来,就能保证“谁都能提代码,但只有被授权的评审人能放行”。

4.3 三种合入策略怎么选

Gerrit支持好几种Submit策略,影响的是合入后目标分支的历史形态。我把它们放在一起对比:

策略历史形态适用场景
Merge If Necessary保留真实合并记录,存在merge节点并行分支多、不希望频繁rebase
Fast Forward Only线性历史,没有merge节点追求简洁历史,允许本地rebase
Cherry Pick提交被重放到最新主干严格单线开发,版本管理要非常干净

我自己更偏好Fast Forward Only:历史干净,回滚定位容易,代价是每个开发者在推送前需要把目标分支最新代码rebase到本地。如果团队实在不愿意频繁处理rebase冲突,再退回Merge If Necessary。

4.4 让CI去当Verified

很多团队的Verified是负责人手动点的,这很不科学。正确做法是接入CI流水线:每次Change更新时,Gerrit触发一次构建,CI跑完把结果打回Verified +1或Verified -1。没有Verified通过的Change不允许合入,这样合入质量从人肉保证变成了自动化保证。

5. 高频故障的完整排查链路

工具链用得越久,踩坑越多,但这些坑大多是同一类问题反复出现。我把实战中最高频的四个故障和处理过程完整写出来,照着排查能省很多时间。

5.1 缺Change-Id被拒:从现象到根因

现象:推送时出现类似下面的报错:

! [remote rejected] HEAD -> refs/for/main (missing Change-Id in commit message)

这是Gerrit收到commit后发现没有Change-Id,直接拒绝接收。完整的排查链路如下:

  1. 先确认本机是否安装了commit-msg hook:
ls -l .git/hooks/commit-msg

如果文件不存在,安装它。

  1. 如果文件存在但没执行权限,chmod +x .git/hooks/commit-msg。

  2. 查看本地提交信息是否已经有Change-Id:

git log -1 --format=%B
  1. 确认缺失时,对最近一条提交执行:
git commit --amend

让hook重新生成Change-Id并追加到message里。

  1. 再次推送。

这里有一个细节:如果commit message里出现了两行Change-Id,Gerrit会懵,因为它不知道该以哪个为准。所以amend后要检查message,保留一行就好。

5.2 repo sync撞上本地未提交改动

现象:执行repo sync时报错:

error: path/to/repo/: uncommitted changes are present

Repo为了不破坏本地工作,遇到未提交改动会直接中断同步。排查和处理的正确顺序是:

  1. 先进入报错仓库查看具体改动:
cd path/to/repo git status
  1. 如果这些改动是你要保留的,先暂存起来:
git stash
  1. 回到工程根目录重新同步:
repo sync
  1. 同步完成后再恢复暂存内容:
git stash pop

有一个需要提醒的点:不要一上来就加-f强制同步。-f只是跳过出问题的仓库继续同步其他仓库,那个仓库的本地改动并不会被处理,最终容易形成“你以为同步了,其实某个仓库还在旧状态”的假象。

5.3 提交进错了目标分支

现象:本想在release-1.2分支上开发,推送时手滑点成了refs/for/main,Gerrit上生成一个目标分支错误的Change。

排查步骤:

  1. 看当前本地HEAD在哪个分支:
git log --oneline -3 git branch -vv
  1. 确认最近的commit是不是指向了错误方向。

处理方式分两种情况:

  • 如果commit还没推送到Gerrit,直接软回退:
git reset --soft HEAD~1

然后切到正确分支,重新提交。

  • 如果已经推到了Gerrit成了Change,在Gerrit页面上先给这个错误Change点Abandon,本地把commit迁移到正确的目标分支,再重新推送到对应的refs/for/xxx。

预防方法是每次推送前用dry-run看一眼目标:

git push --dry-run origin HEAD:refs/for/release-1.2

这条命令会打印出将要推送的内容和目标,不会真的提交,花一秒钟能挡住大部分手滑。

5.4 rebase后Change-Id丢失的补救

现象:本地rebase或者处理冲突时,commit message被重写,Change-Id行丢了,再次推送时又报missing Change-Id。

处理思路是:让Change-Id回到message里,并保持和Gerrit上原Change一致。

git commit --amend

在打开的编辑器里,把原来的Change-Id行重新粘贴回去,保存退出,再推送。如果连原来的Change-Id都不记得了,可以到Gerrit页面上找到原Change,里面每一行都能看到Change-Id。

我习惯在重新rebase之后批量检查所有提交里是否都还有Change-Id:

git log --format="%H %s %b" | grep -c "Change-Id:"

跟预期提交数量对得上才push,对不上就先处理。

6. 长期协作下来真正有价值的心法

工具层面的坑都聊完了,最后说几个跟流程设计和团队习惯有关的心法。这些是让整套组合真正发挥价值的软性因素。

6.1 一个Change只干一件事

评审人最反感的是一个大Change里既改了接口又修了bug还顺手改了格式。Gerrit的评审界面是按文件逐行展示的,多件事情混在一起,评审人很难判断每一处改动是不是合理。坚持一个Change只解决一个问题,评审速度会快很多,出问题回溯时也清晰。

6.2 Topic把跨仓库改动绑在一起

多仓库场景下,一个功能经常要同时改好几个仓库。Gerrit支持给Change设置Topic,同名Topic的多个Change可以一起展示、一起评审、一起合入,还能在页面上看到它们之间的依赖关系。这比在备注里写“配合xxx分支的xxx提交”靠谱得多。

6.3 让验证变成自动化的声音

人工点Verified这件事又慢又不可靠,人在疲劳时根本不记得点。把CI接入Verified之后,合入门禁有了客观标准,开发者也更愿意在推送前自己先跑一遍静态检查和单测,因为CI没过,推上去也是白推。

6.4 定期修剪:僵尸Change与本地分支

Gerrit上有大量长期不更新的Change会慢慢变成“僵尸”。我每个迭代结束会花半小时清理:确认已经合入的Change不必处理,但长期没动静且功能已经放弃的,果断Abandon。本地仓库里那些repo start开出来的旧特性分支,合入后尽快删掉,否则几十个仓库的本地分支列表会膨胀到没法看。

6.5 代码评审里最有用的沟通习惯

最后一点经验,跟技术无关但非常影响效率:评审意见里多写“为什么”,少写“怎么改”。比如“这里应该用事务包裹,因为两步操作需要原子性”比“这里加个@Transactional”有用得多。前者教的是设计原则,后者只是给了个补丁。评论区一旦变成“按我说的改”的战场,代码质量反而会下降。

回到“Git-Repo-Gerrit”这套组合本身。有人抱怨它门槛高,但一旦团队过了磨合期,它会变成一种“默认有人兜底”的安全感。合入门禁防的是手滑,清单统一防的是沉默的版本漂移,Change-Id防的是补丁在评审中丢失脉络。我们从一开始觉得多了一道工序,到后来完全离不开,中间靠的就是反复踩坑和把这些约定变成肌肉记忆。如果你们团队也在考虑这套组合,希望这篇文章能帮你把第一天到第一百天的路都铺平一点。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询