一、问题背景:从SVM单干到Git百人混战

我们团队在2023年初从SVN整体迁移到Git,当时拍板的是“全都要”策略——所有人直接在一个master分支上开发。结果第一个月就崩了:20个并行需求,Merge冲突此起彼伏,经常出现“我明明改了A模块,却要和改B模块的人解冲突”的诡异情况。更气人的是,每次发布都要临时冻结代码,因为master上永远有半成品。

那时候我们就明白:Git本身不解决协作问题,解决的是版本追踪问题,协作需要的是工作流约束

二、环境与版本:我们踩过的版本坑

先说一下基础环境,这些都是我们用血泪换来的版本组合:

组件 版本 说明
Git 2.39.1 必须>=2.33,否则--rebase-merges有bug
Gerrit 3.7.2 代码审查必须用,GitLab MR不够严格
Jenkins 2.414.1 发布流水线用,配合Pipeline插件2.4.6
GitLab 15.11 只是代码托管,不开MR功能(避免双轨混乱)

重要警告:如果你还在用Git 2.30以下的老版本,请立刻升级。我们曾经在2.28版本上遇到过rebase --onto合并策略导致提交历史丢失的问题,那个事故直接让我们回滚了3个服务。

三、方案设计:双轨制分支策略

我们最终落地的是双轨制,这个“双轨”不是指两条永久分支,而是按场景分流

3.1 核心分支模型

main(受保护,只能由release分支合并)
├── release/1.x(版本分支,冻结后从main拉出)
├── feature/JIRA-1234-描述(需求分支,从main拉出)
├── bugfix/HOTFIX-5678(紧急修复,从release分支拉出)
└── dev(内部集成分支,允许直接push,但必须通过CI)

3.2 关键决策点

  1. 开发新功能 → 从mainfeature分支,代码必须过Gerrit Review
  2. 修线上Bug → 从release/1.xbugfix分支,修复后同时合入release和main
  3. 预发布集成 → 每周五下班前,所有feature分支合并到dev做集成测试
  4. 正式发版 → 一个迭代结束后,从main拉出release/2.0,之后只允许修Bug

这个模型的核心思想是:main永远是可发布状态,feature分支生命周期不超过2周

四、核心实现:Gerrit Code Review + Git命令配置

4.1 Gerrit Review流程配置

我们使用Gerrit的LABEL: Verified机制,强制要求至少1个+1验证和1个+2审核才能合入main。.gitreview配置如下:

[gerrit]
    host=gerrit.example.com
    port=29418
    project=finance-core
    defaultbranch=main
    defaultrebase=1
    track=1
    scheme=ssh
    # 强制使用change-id,缺了会拒收
    [hook]
    push=gerrit

配合客户端侧的一个实用alias(写入~/.gitconfig):

[alias]
    review = "!f() { \
        git push origin HEAD:refs/for/main%topic=$(git rev-parse --abbrev-ref HEAD) \
        -o l=Verified+1 -o l=Code-Review+1; \
    }; f"
    reviewdry = "!f() { \
        git log --oneline origin/main..HEAD; \
        echo '--- 以上提交将推送到Gerrit ---'; \
    }; f"

4.2 合并冲突的终极武器:--rebase-merges

这是我最想推荐给所有人的。传统git rebase会把你的commits压扁,丢失合并节点。但--rebase-merges可以保留你的merge commit拓扑结构。我们的feature分支内部合并策略是:

# 在feature分支上定期同步main
git fetch origin main
git rebase --rebase-merges --onto origin/main $(git merge-base origin/main HEAD) HEAD
# 这个命令的含义:把当前分支上相对于main的合并结构,完整搬到最新main上

这个命令踩坑记录:--rebase-merges在Git 2.39之前默认不启用--fork-point,所以必须在后面手动指定$(git merge-base...)。我们团队最初用git rebase origin/main,结果把别人合进来的commit全丢了,后来写成脚本固化下来。

4.3 文件级合并策略

对于数据库迁移脚本和配置文件,我们使用自定义合并驱动:

# .gitattributes 配置
migrations/*.sql merge=ours
config/*.yml merge=union

~/.gitconfig中定义合并驱动:

[merge "ours"]
    driver = true
[merge "union"]
    driver = git merge-file -p %A %O %B > %A

这样migrations目录下的SQL冲突永远以本地版本为准(因为迁移脚本是改一次永远不改的),而YAML配置则自动合并行级差异。

五、CI/CD集成:Jenkins Pipeline与GitLab CI的联动

5.1 Jenkins Pipeline(发布构建)

我们的发布构建必须从release分支触发,且每次只构建一个版本。核心Pipeline脚本片段:

pipeline {
    agent any

    parameters {
        string(name: 'RELEASE_TAG', defaultValue: '2.1.0', description: '发布版本号')
        choice(name: 'ENV', choices: ['staging', 'production'], description: '目标环境')
    }

    triggers {
        // 监听release分支push事件
        gitlab(triggerOnPush: true, branchFilter: 'release/*')
    }

    stages {
        stage('校验分支') {
            when {
                expression {
                    // 强制要求当前分支是release/*或main
                    return env.GIT_BRANCH =~ /^(release\/|main$)/ 
                }
            }
            steps {
                echo "构建分支: ${env.GIT_BRANCH}"
            }
        }

        stage('版本号生成') {
            steps {
                script {
                    // 从git describe自动生成构建号
                    def version = sh(script: "git describe --tags --always --dirty", returnStdout: true).trim()
                    // 设置环境变量给后续stage使用
                    env.BUILD_VERSION = "${params.RELEASE_TAG}-${version}-${BUILD_NUMBER}"
                }
            }
        }

        stage('构建与测试') {
            steps {
                sh '''
                    ./gradlew clean build -x test
                    ./gradlew test --tests "com.example.**.*Test" \
                        --junit-xml build/test-results/test
                '''
            }
        }

        stage('发布产物') {
            when {
                expression { params.ENV == 'production' }
            }
            steps {
                sh 'docker build -t registry.example.com/finance:${BUILD_VERSION} .'
                sh 'docker push registry.example.com/finance:${BUILD_VERSION}'
            }
        }
    }

    post {
        success {
            // 更新GitLab tag
            sh "git tag release-${params.RELEASE_TAG}-${BUILD_NUMBER}"
            sh "git push origin --tags"
        }
        failure {
            // 通知群机器人
            sh "curl -X POST http://monitor.example.com/api/notify -d 'status=failed&job=${JOB_NAME}'"
        }
    }
}

5.2 GitLab CI的轻量门禁

对于dev分支,我们只跑静态检查和快速测试,不构建镜像:

# .gitlab-ci.yml
stages:
  - lint
  - test

variables:
  GRADLE_OPTS: "-Xmx1024m"

lint-job:
  stage: lint
  script:
    - ./gradlew spotbugsMain
    - ./gradlew checkstyleMain
  only:
    - dev
    - main
    - /^feature\/.*$/
  allow_failure: false
  timeout: 5m

test-job:
  stage: test
  script:
    - ./gradlew test --tests "com.example.core.*"
  artifacts:
    paths:
      - build/reports/tests/
  only:
    - dev
    - main
  timeout: 10m

这里有个比较独特的做法:GitLab CI只跑测试,不跑构建,因为所有的构建都统一在Jenkins里做。这样可以避免双套构建系统产生版本不一致。

六、踩坑与优化:我们流过的血与泪

6.1 最大的坑:Gerrit的Change-Id和merge commit

Gerrit默认不允许merge commit,它要求每个commit都有Change-Id。但我们的feature分支内部就是有merge commit(为了保持主干的历史整洁)。解决方案:

  1. .gitreview中配置defaultrebase=1
  2. 要求开发者在push前执行git review --rebase(自动把merge commit线性化)
  3. 如果必须保留merge,需要修改Gerrit的receive.maxBatchCommitsreceive.allowGroup

6.2 性能优化:减少fetch次数

我们团队约120人,每天fetch总量有15GB。优化的措施:

# 使用浅克隆+动态拉取
git clone --depth=50 --branch=main https://git.example.com/finance.git
# 每5分钟自动fetch一次最新main,只拉增量
git fetch origin main --shallow-since="2 days ago"

这个改动让CI的代码检出时间从42秒降到了11秒。

6.3 Code Review的节奏控制

我们尝试过强制所有feature分支必须过Review,但结果是小需求被Review阻塞了3天。后来改成分级审查
- 涉及金额计算、支付通道的 migration/ 路径 → 必须2个Senior +1 Gerrit
- 普通业务代码 → 1个Reviewer +1即可
- 纯配置变更(如logback.xml)→ 直接push dev,CI自动检查

这个策略让平均review等待时间从8小时降到了1.5小时,同时生产事故率没有上升。

七、效果数据与总结

双轨制工作流运行了6个月后,我们拿到了这些数字:

指标 改造前(SVN时代) 改造后(当前)
代码冲突率(每周) 23次/周 8.5次/周(↓63%)
发布构建成功率 85.2% 98.5%
平均发布准备时间 4小时 40分钟(↓83%)
紧急修复上线时间 2小时 35分钟
并行开发需求数 8个 22个

总结三条核心经验

  1. 分支策略不是越多越好。我们最终只保留了mainrelease/*feature/*bugfix/*四种前缀,但通过Gerrit的权限控制让不同的分支有不同的写入权限——这是Git作为工具无法做到的事情。

  2. CI/CD必须绑定分支模型。Jenkins只触发release/*main,GitLab CI只跑devfeature,职责分离让构建流程不会互相干扰。

  3. 自动化是手段不是目的。我们最终没有追求100%的自动化,因为代码审查中的人为判断仍然是不可替代的。但把能自动化的(版本号生成、合并冲突检测、基础测试)全部自动化后,人的精力就集中在真正需要判断的地方。

目前这套体系已经稳定运行了6个月,支撑了2个大版本(2.0和2.1)的发布。如果你也在规划Git工作流,我强烈建议先用小团队试点一个迭代周期,把--rebase-merges--shallow-since这些参数跑通,然后再推广到全团队。Git本身足够强大,但工作流的约束才是让团队高效协作的关键。