跳到主要内容
预计阅读 42 分钟

大型项目的Git实践 —— 当仓库变得庞大,规则需要升级

一个人写代码时,Git是你的笔记本。十个人协作时,Git是你们的协调中心。而当团队扩展到上百人、代码量达到数百万行、仓库里混杂着代码、设计稿、模型文件——这时候,你需要的不仅是Git的基础技能,还有一整套经过大规模项目验证的工程实践。

📋 开篇自测:你已经知道多少?

  1. Monorepo和Polyrepo各自的优势是什么?Google和Meta为什么选择Monorepo?
  2. Git LFS解决了什么问题?它的工作原理是什么?
  3. git clone --depth 1 会带来什么效果?它有什么局限性?
  4. 为什么要对Git提交进行GPG签名?GitHub上的”Verified”标记意味着什么?
  5. 如果你不小心将数据库密码提交到了Git仓库,仅仅删除文件并提交一次新的commit够不够?为什么?

一、Monorepo vs Polyrepo:代码该住在一起还是分开住?

当你的项目从一个应用成长为一个包含前端、后端、共享库、配置工具的产品生态时,第一个需要做的决策就是:这些代码是放在一个仓库里,还是分散在多个仓库中?

两种策略的对比

Polyrepo(多仓库)                    Monorepo(单一仓库)

┌─────────┐ ┌─────────┐             ┌─────────────────────┐
│ app-web  │ │app-mobile│             │    my-project       │
│  (repo)  │ │  (repo)  │             │  ├── apps/          │
└─────────┘ └─────────┘             │  │   ├── web/       │
┌─────────┐ ┌─────────┐             │  │   └── mobile/    │
│  api     │ │shared-ui │             │  ├── packages/      │
│  (repo)  │ │  (repo)  │             │  │   ├── ui/       │
└─────────┘ └─────────┘             │  │   └── utils/     │
                                     │  └── tools/         │
每个项目独立仓库                      │      └── scripts/   │
独立版本、独立CI                      └─────────────────────┘
                                     所有项目共享一个仓库
                                     统一版本、统一CI
维度PolyrepoMonorepo
代码共享通过发布npm包共享,有版本延迟直接引用,改了立刻生效
依赖管理各自管理,容易出现版本碎片化统一管理,一次升级全局生效
CI/CD各仓库独立配置,简单但重复需要智能判断哪些项目受影响
代码评审跨仓库变更需要多个PR一个PR可以包含全栈变更
仓库体积单个仓库较小仓库可能非常大
权限控制天然隔离,各仓库独立授权需要额外工具实现目录级权限
上手成本克隆快,上下文简单克隆慢,但能看到全貌

何时选Monorepo:团队之间协作频繁、共享代码多、需要原子性跨项目变更(比如改一个API接口,前后端一起改一起发)。Google、Meta、Microsoft的很多项目都采用Monorepo。

何时选Polyrepo:各项目之间相对独立、团队之间交集少、或者需要严格的代码权限隔离。

Monorepo工具链

裸用Git管理Monorepo会遇到很多问题:所有项目的依赖混在一起、CI每次变更都要全量构建、无法单独发版。成熟的Monorepo工具链解决了这些问题。

Nx:功能最全面的Monorepo工具,支持智能构建缓存和任务编排。

# 创建Nx工作区
npx create-nx-workspace@latest my-org

# 只构建受影响的项目(核心能力)
npx nx affected --target=build

# 任务依赖图可视化
npx nx graph

Nx的”受影响分析”是其核心卖点——当你修改了 packages/utils 中的一个函数,Nx会分析依赖图,只重新构建和测试那些依赖了 packages/utils 的项目,而不是全量构建。

Turborepo:Vercel出品,更轻量,专注于构建加速。

// turbo.json
{
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**"]
    },
    "test": {
      "dependsOn": ["build"]
    },
    "lint": {}
  }
}
# 并行执行所有包的lint
turbo run lint

# 只运行受变更影响的任务
turbo run build --filter=...[HEAD~1]

Lerna:老牌Monorepo工具,现已被Nx接管维护,主要用于npm包的版本管理和发布。

# 查看自上次发版以来哪些包有变更
npx lerna changed

# 自动升级版本号并发布
npx lerna publish

🤔 想一想 在Monorepo中,如果前端开发者只需要前端代码,后端开发者只需要后端代码,有没有办法只克隆自己需要的部分?(提示:看看下面的”部分克隆”章节)


二、Git LFS:给大文件找个专属仓库

Git对文本文件的版本管理堪称完美,但它的设计天生不适合大文件。当你把一个50MB的PSD设计稿提交到Git仓库时,每次修改都会在 .git 目录中保存一份完整的副本。改十次,仓库就膨胀了500MB。

Git LFS(Large File Storage)的解决方案是”偷梁换柱”——在Git仓库中只存储一个指针文件,真正的大文件存放在单独的LFS服务器上。

普通Git:                            Git LFS:

仓库 .git/objects/                   仓库 .git/objects/
├── 版本1:design.psd (50MB)         ├── 版本1:design.psd.pointer (150B)
├── 版本2:design.psd (50MB)         ├── 版本2:design.psd.pointer (150B)
├── 版本3:design.psd (50MB)         ├── 版本3:design.psd.pointer (150B)
总计:150MB+                         总计:450B

                                     LFS服务器(单独存储)
                                     ├── 版本1大文件 (50MB)
                                     ├── 版本2大文件 (50MB)
                                     ├── 版本3大文件 (50MB)

区别在于:克隆仓库时,LFS只下载当前版本的大文件,历史版本的大文件不会被下载。这让仓库克隆速度大幅提升。

配置与使用

# 安装Git LFS(macOS)
brew install git-lfs

# 在仓库中启用LFS
git lfs install

# 追踪特定类型的大文件
git lfs track "*.psd"
git lfs track "*.zip"
git lfs track "*.woff2"
git lfs track "models/**"

# 上述命令会创建或修改 .gitattributes 文件
cat .gitattributes
# *.psd filter=lfs diff=lfs merge=lfs -text
# *.zip filter=lfs diff=lfs merge=lfs -text
# *.woff2 filter=lfs diff=lfs merge=lfs -text
# models/** filter=lfs diff=lfs merge=lfs -text

# 务必将 .gitattributes 提交到仓库
git add .gitattributes
git commit -m "chore: configure Git LFS for binary files"

# 之后正常使用git命令即可,LFS会自动介入
git add design.psd
git commit -m "feat: add homepage design draft"
git push

常见使用场景

  • 游戏开发:3D模型、纹理贴图、音频文件
  • 设计协作:PSD、Sketch、Figma导出文件
  • 机器学习:训练数据集、模型权重文件
  • 文档管理:PDF文档、Office文件
  • 嵌入式开发:固件二进制文件

三、性能优化:让Git克隆不再等到天荒地老

一个有十年历史、上万次提交的仓库,完整克隆可能需要几十分钟甚至几个小时。以下是四种实用的加速方案。

1. 浅克隆(Shallow Clone)

只获取最近的N条提交历史:

# 只获取最近1条提交(最快)
git clone --depth 1 https://github.com/example/large-repo.git

# 获取最近50条提交
git clone --depth 50 https://github.com/example/large-repo.git

# 后续如果需要完整历史,可以"加深"
git fetch --unshallow

适用场景:CI/CD环境中只需要最新代码来构建,不需要完整历史。GitHub Actions的 actions/checkout@v6 默认就是浅克隆(depth=1)。

局限性:无法查看完整的提交历史,git log 只能看到浅克隆深度内的提交,git blame 的结果可能不准确。

2. 单分支克隆

只克隆需要的分支,不下载其他分支的数据:

# 只克隆main分支
git clone --single-branch --branch main https://github.com/example/repo.git

# 后续如果需要其他分支
git remote set-branches --add origin feature-x
git fetch origin feature-x

3. 部分克隆(Partial Clone)

Git 2.22+引入的特性,支持按需下载对象:

# 无Blob克隆:只下载树结构和提交,文件内容按需获取
git clone --filter=blob:none https://github.com/example/repo.git

# 无树克隆:更激进,连目录树也按需获取
git clone --filter=tree:0 https://github.com/example/repo.git

# 限制Blob大小:只跳过超过1MB的文件
git clone --filter=blob:limit=1m https://github.com/example/repo.git

部分克隆保留了完整的提交历史(git log 正常工作),只是文件内容在你第一次 checkout 到对应目录时才会下载。这在Monorepo场景下特别有用——前端开发者克隆仓库后,只有当他们进入前端目录时才会下载前端代码。

4. fsmonitor:让Git感知文件系统变化

在包含数万个文件的大型仓库中,git status 需要逐个检查工作区中的每个文件是否被修改,这会非常慢。fsmonitor让Git借助操作系统的文件监控能力,只检查实际变更过的文件。

# 启用内置的fsmonitor
git config core.fsmonitor true
git config core.untrackedcache true

# 在大型仓库中效果显著
# git status 从 5秒 → 0.2秒

⚠️ 常见误区

  • 误区一:“浅克隆和部分克隆是一回事”。浅克隆截断的是历史深度(只保留最近N次提交),部分克隆截断的是对象完整性(保留所有提交记录但延迟下载文件内容)。两者解决的问题不同。
  • 误区二:“clone慢就换个网络就好了”。网络带宽只是一个因素,仓库本身的体积才是根源。一个包含大量二进制文件历史的仓库,即使在内网也会克隆得很慢。

四、签名提交:证明”这段代码确实是我写的”

在开源社区和安全敏感的项目中,一条提交信息说”Author: Linus Torvalds”,真的就是Linus写的吗?实际上,Git的作者信息完全可以伪造:

# 任何人都可以这样做(但这是不道德的)
git -c user.name="Linus Torvalds" -c user.email="torvalds@linux-foundation.org" \
  commit -m "I am definitely Linus"

签名提交通过密码学手段解决了身份验证问题——使用你的私钥对提交进行签名,任何人都可以用你的公钥验证这个签名。GitHub上的”Verified”绿色标记就是签名验证通过的标志。

使用SSH密钥签名(推荐,更简单)

Git 2.34+ 支持使用SSH密钥进行签名,比传统的GPG方式简单很多(建议使用 OpenSSH 8.8+,更早版本存在已知签名兼容性问题):

# 配置使用SSH签名
git config --global gpg.format ssh
git config --global user.signingkey ~/.ssh/id_ed25519.pub

# 让所有提交自动签名
git config --global commit.gpgsign true

# 让所有标签自动签名
git config --global tag.gpgsign true

然后把你的SSH公钥添加到GitHub的”SSH and GPG keys”设置中(注意选择Key type为”Signing Key”)。

使用GPG签名(传统方式)

# 生成GPG密钥对
gpg --full-generate-key
# 选择RSA and RSA,4096位,填入你的GitHub邮箱

# 查看密钥ID
gpg --list-secret-keys --keyid-format=long
# sec   rsa4096/3AA5C34371567BD2 2024-01-01 [SC]
#                ↑ 这就是密钥ID

# 配置Git使用GPG签名
git config --global user.signingkey 3AA5C34371567BD2
git config --global commit.gpgsign true

# 导出公钥,添加到GitHub
gpg --armor --export 3AA5C34371567BD2

验证签名

# 查看提交的签名状态
git log --show-signature -1

# 验证某个标签的签名
git tag -v v1.0.0

为什么要签名? 在供应链安全日益重要的今天,签名提交确保了代码的来源可信。Linux内核、Kubernetes等项目都要求贡献者签名提交。企业内部如果出现代码安全事故,签名也是追溯责任的有力证据。


五、Git安全最佳实践

凭证管理

不要在命令行中明文输入密码,使用凭证管理器:

# macOS:使用钥匙串
git config --global credential.helper osxkeychain

# Windows:使用Windows凭证管理器
git config --global credential.helper manager

# Linux:使用libsecret
git config --global credential.helper /usr/lib/git-core/git-credential-libsecret

# 更推荐:使用SSH协议或Personal Access Token代替密码

Secrets泄露检测

不小心把API密钥、数据库密码提交到代码仓库,是安全事故的头号来源。

git-secrets(AWS出品)可以在提交前自动扫描敏感信息:

# 安装
brew install git-secrets

# 在仓库中启用
git secrets --install

# 添加AWS密钥的检测规则
git secrets --register-aws

# 添加自定义规则
git secrets --add 'password\s*=\s*.+'
git secrets --add 'PRIVATE_KEY'
git secrets --add --allowed 'password\s*=\s*<placeholder>'

# 扫描整个提交历史
git secrets --scan-history

GitHub的Secret Scanning:GitHub会自动扫描公开仓库中的已知密钥格式(如AWS Access Key、Slack Token等),发现后会通知仓库所有者和密钥提供方。

用BFG清理已泄露的敏感数据

一旦密钥被提交到Git仓库,仅仅在新提交中删除文件是不够的——历史提交中仍然包含那个文件。你需要从整个Git历史中彻底清除它。

# BFG是一个专门清理Git历史的工具,比git filter-branch快得多
# 安装(需要Java运行环境)
brew install bfg

# 先做一个镜像克隆
git clone --mirror https://github.com/your/repo.git

# 从所有历史中删除包含密码的文件
bfg --delete-files 'credentials.json' repo.git

# 从所有历史中替换指定的文本
echo "old-api-key-value" > passwords.txt
bfg --replace-text passwords.txt repo.git

# 清理并推送
cd repo.git
git reflog expire --expire=now --all
git gc --prune=now --aggressive
git push --force

💡 替代工具:除了 BFG,Git 官方推荐的现代替代工具是 git filter-repo。它功能更强大、速度更快,且不依赖 Java 环境。用法示例:git filter-repo --path credentials.json --invert-paths(从历史中删除指定文件)。对于新项目,建议优先考虑 git filter-repo

重要提醒:清理Git历史后,你需要立即轮换(更换)所有已泄露的密钥。因为在你清理之前,任何克隆过仓库的人都已经拥有了这些密钥。清理历史只是防止未来的泄露,已经暴露的密钥必须当作已被盗用来处理。


六、.gitattributes:仓库的行为规范手册

.gitignore 告诉Git哪些文件不要管,.gitattributes 告诉Git对特定文件应该怎么管。

行尾处理(CRLF/LF)

Windows使用 \r\n(CRLF)作为换行符,而macOS和Linux使用 \n(LF)。当团队成员使用不同操作系统时,换行符的差异会导致大量虚假的文件变更。

# .gitattributes

# 文本文件:入库时转为LF,检出时根据操作系统转换
* text=auto

# 强制指定某些文件始终使用LF(推荐)
*.js    text eol=lf
*.ts    text eol=lf
*.jsx   text eol=lf
*.tsx   text eol=lf
*.css   text eol=lf
*.html  text eol=lf
*.json  text eol=lf
*.yml   text eol=lf
*.yaml  text eol=lf
*.md    text eol=lf
*.sh    text eol=lf

# Windows批处理文件保持CRLF
*.bat   text eol=crlf
*.cmd   text eol=crlf

二进制文件标记

# 告诉Git这些是二进制文件,不要尝试diff或合并
*.png   binary
*.jpg   binary
*.gif   binary
*.ico   binary
*.pdf   binary
*.woff  binary
*.woff2 binary
*.ttf   binary

自定义Diff驱动

# 让Git用特定方式对比某些文件
*.lock  linguist-generated=true   # GitHub上在PR diff中默认折叠,且不计入语言统计
*.min.js linguist-generated=true  # 压缩文件在PR diff中默认折叠,且不计入语言统计

⚠️ 常见误区 linguist-generated=true 并不会让文件在 diff 中完全隐藏。它的实际效果有两个:一是让 GitHub 在仓库语言占比统计中排除该文件;二是在 Pull Request 的 diff 视图中将该文件默认折叠(用户仍可手动展开查看)。如果你需要完全排除某文件的 diff,应该在代码审查流程中通过其他方式处理。


七、Git邮件工作流与补丁

在GitHub和GitLab普及之前,Linux内核社区就已经用邮件列表协作开发了——这套工作方式至今仍在使用。虽然大多数团队不需要邮件工作流,但理解它能帮你处理一些特殊场景:向没有协作平台访问权限的人发送代码变更、在无法联网的环境中传递补丁。

生成补丁文件

# 将最近3次提交导出为补丁文件
git format-patch -3
# 生成:
# 0001-feat-add-user-authentication.patch
# 0002-fix-login-redirect-issue.patch
# 0003-docs-update-api-documentation.patch

# 导出指定范围的提交
git format-patch v1.0..v1.1

# 导出一个分支相对于main的所有变更
git format-patch main..feature-branch

# 将所有补丁打包成一个文件
git format-patch main..feature-branch --stdout > all-changes.patch

应用补丁

# 查看补丁内容(不实际应用)
git apply --stat 0001-feat-add-user-authentication.patch

# 检查补丁是否能干净地应用
git apply --check 0001-feat-add-user-authentication.patch

# 应用补丁并保留原始提交信息(推荐)
git am 0001-feat-add-user-authentication.patch

# 批量应用一个目录下的所有补丁
git am patches/*.patch

# 如果应用时遇到冲突
git am --abort      # 中止
# 或者
git am --skip       # 跳过这个补丁
# 或者手动解决冲突后
git am --continue   # 继续

Linux内核每天通过邮件列表处理数百个补丁。Linus Torvalds和子系统维护者使用 git am 将通过邮件收到的补丁应用到内核代码中。这套工作流虽然看起来”原始”,但它有一个独特优势——代码评审完全在邮件中以纯文本形式进行,不依赖任何商业平台。

🤔 想一想 在什么情况下,你可能更倾向于使用补丁文件而不是Pull Request来贡献代码?


八、大型团队的分支策略选择

不同规模的团队、不同的发布节奏,适合不同的分支策略。以下是一个决策框架:

你的团队有多少人?

├─ 1-5人(小团队)
│  └─ 推荐:GitHub Flow
│     一个main分支 + 功能分支
│     每个功能分支通过PR合并到main
│     main始终可部署
│     简单直接,适合持续部署

├─ 5-20人(中型团队)
│  └─ 你的发布节奏是什么?
│     ├─ 持续发布(随时可上线)
│     │  └─ 推荐:GitHub Flow + 发布标签
│     │     在main上打Tag标记版本
│     │
│     └─ 定期发布(如每两周一版)
│        └─ 推荐:Git Flow 简化版
│           main + develop + 功能分支
│           develop集成日常开发
│           发版时从develop创建release分支

└─ 20人以上(大团队)
   └─ 你需要同时维护多个版本吗?
      ├─ 是(如开源库、SDK)
      │  └─ 推荐:完整Git Flow
      │     main + develop + feature + release + hotfix
      │     支持多版本并行维护

      └─ 否(如SaaS产品)
         └─ 推荐:Trunk-Based Development
            所有人向main(trunk)提交
            使用Feature Flags控制功能发布
            极短生命周期的功能分支(<1天)
            Google、Meta的大规模团队使用此策略

Trunk-Based Development值得特别说明——它看似”激进”,所有人都往主干提交,但配合Feature Flags(功能开关),它反而是最适合大型团队的策略。因为长期存在的功能分支最终合并时会产生大量冲突,而频繁向主干集成可以把冲突控制在最小范围。


九、实战:为Monorepo项目配置完整的Git工作流

让我们综合运用本章所学,为一个包含前端应用、后端API和共享工具库的Monorepo项目配置完整的Git工作流。

项目结构

my-platform/
├── .github/
│   └── workflows/
│       └── ci.yml
├── .gitattributes
├── .gitignore
├── .husky/
│   ├── pre-commit
│   └── commit-msg
├── apps/
│   ├── web/              # React前端应用
│   └── api/              # Node.js后端API
├── packages/
│   ├── ui/               # 共享UI组件库
│   └── utils/            # 共享工具函数
├── assets/               # 设计资源(大文件)
│   ├── icons/
│   └── mockups/
├── turbo.json
└── package.json

配置 .gitattributes

# 统一换行符
* text=auto eol=lf

# Windows脚本保持CRLF
*.bat text eol=crlf

# 二进制文件
*.png binary
*.jpg binary
*.svg text
*.ico binary

# LFS追踪大文件
assets/mockups/** filter=lfs diff=lfs merge=lfs -text
*.psd filter=lfs diff=lfs merge=lfs -text
*.sketch filter=lfs diff=lfs merge=lfs -text

# 锁文件在PR diff中默认折叠,且不计入语言统计
package-lock.json linguist-generated=true
pnpm-lock.yaml linguist-generated=true

配置 CI 工作流

# .github/workflows/ci.yml
name: Monorepo CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  detect-changes:
    name: 检测变更范围
    runs-on: ubuntu-latest
    outputs:
      web: ${{ steps.changes.outputs.web }}
      api: ${{ steps.changes.outputs.api }}
      ui: ${{ steps.changes.outputs.ui }}
      utils: ${{ steps.changes.outputs.utils }}
    steps:
      - uses: actions/checkout@v6
      - uses: dorny/paths-filter@v3
        id: changes
        with:
          filters: |
            web:
              - 'apps/web/**'
              - 'packages/ui/**'
              - 'packages/utils/**'
            api:
              - 'apps/api/**'
              - 'packages/utils/**'
            ui:
              - 'packages/ui/**'
            utils:
              - 'packages/utils/**'

  test-web:
    name: 测试前端应用
    needs: detect-changes
    if: needs.detect-changes.outputs.web == 'true'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - uses: actions/setup-node@v5
        with:
          node-version: 20
          cache: 'npm'
      - run: npm ci
      - run: npx turbo run lint test --filter=web...

  test-api:
    name: 测试后端API
    needs: detect-changes
    if: needs.detect-changes.outputs.api == 'true'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - uses: actions/setup-node@v5
        with:
          node-version: 20
          cache: 'npm'
      - run: npm ci
      - run: npx turbo run lint test --filter=api...

  build:
    name: 全量构建
    needs: [test-web, test-api]
    if: |
      always() &&
      !contains(needs.*.result, 'failure')
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
        with:
          lfs: true              # 检出LFS文件
      - uses: actions/setup-node@v5
        with:
          node-version: 20
          cache: 'npm'
      - run: npm ci
      - run: npx turbo run build

这个CI配置的关键设计是变更检测detect-changes 任务)——通过 paths-filter 分析PR中改了哪些文件,只为受影响的项目运行测试。改了前端代码不会触发后端测试,改了共享工具库则前后端测试都会运行。

配置保护规则

在GitHub仓库设置中配置分支保护规则:

main分支保护规则:
  ✅ Require a pull request before merging
     ✅ Require approvals: 2(至少2人审批)
     ✅ Dismiss stale pull request approvals(代码更新后需重新审批)
  ✅ Require status checks to pass
     ✅ test-web(如有变更)
     ✅ test-api(如有变更)
     ✅ build
  ✅ Require signed commits(要求签名提交)
  ✅ Require linear history(禁止merge commit,使用squash或rebase)
  ✅ Do not allow bypassing the above settings

这套配置确保了:任何代码进入main分支都经过了至少两人审核、自动化测试通过、提交经过签名验证。


📝 掌握度自测

  1. 你的团队包含3个前端、2个后端、1个设计师,开发一个SaaS产品,各端之间有共享的类型定义和工具函数。你会选择Monorepo还是Polyrepo?说明理由。

  2. 一个游戏项目包含大量3D模型文件(单个文件几十MB到几百MB),直接用Git管理会有什么问题?Git LFS如何解决这些问题?LFS的指针文件中存储了什么信息?

  3. 解释浅克隆(--depth)、单分支克隆(--single-branch)和部分克隆(--filter)的区别。在CI/CD环境中,你会选择哪种方式?为什么?

  4. 你发现团队的一个旧提交中包含了一个数据库连接字符串(含密码),这个仓库是公开的。请描述完整的应急处理流程(提示:不只是清理Git历史)。

  5. 一个20人的团队开发一个SaaS产品,采用每两周一次的发布节奏。请为他们推荐一套分支策略,并画出分支模型的简要示意图。

💡 自我评估

  • 全部答对:你已经具备了管理大型项目Git仓库的能力。从工具链选型到安全实践,从性能优化到团队协作,这些知识将在你的职业生涯中持续发挥价值。
  • 答对3-4题:核心知识已经掌握。建议选择一个你参与的真实项目,实践其中的一两项配置(比如Git LFS或签名提交),加深理解。
  • 答对1-2题:大型项目实践需要经验积累。先从自己的项目开始,逐步尝试配置 .gitattributes、启用签名提交,在实践中学习效果最好。

购买课程解锁全部内容

版本控制不翻车:Git 从基础到团队协作

¥29.90