这篇能做出什么
先看一个越来越常见的场景:一个仓库里跑着若干自动化编码智能体,每个任务开一个分支、提一次 PR,一天下来几百上千次提交。几个月后你打开仓库会发现三件事同时变糟:
- 新同事或新 CI 机器
git clone要等很久,因为要把全部历史的所有文件版本都拖下来; git status、git log -- 某目录开始有明显的卡顿,历史遍历要一个个解压提交对象;- 服务端握手变慢,因为分支和标签多到引用表本身就成了负担。
OpenAI 那篇讲 Git 扩容的工程文章,思路就是把这三件事分开处理:对象下载用 partial clone 按需取、历史遍历用 commit-graph 预先算好、引用表用分片和协议优化减少广告量。这篇文章把它落成一套可以直接照抄的操作流程。
做完之后,你能得到:
1. 一台新机器克隆仓库的时间显著下降,且磁盘占用变小;
2. 历史查询类命令(git log、merge-base、计算变更集)响应更稳;
3. 分支数上千时,握手和 git fetch 不再随引用数量线性变慢;
4. 一套可重复的压测脚本,改配置前后能拿数据说话,而不是凭感觉。
前置条件清单
动手前先确认这些:
- Git 版本:partial clone、commit-graph、
git maintenance都是较新的特性,请先git --version看本机版本,具体最低版本要求以官方文档当前版本为准。建议客户端和 CI 镜像统一到同一较新版本,避免同一份配置在不同机器上行为不一致。 - 服务端支持 filter:partial clone 依赖服务端的
uploadpack.allowFilter。主流托管平台默认支持;如果是自建 Git 服务,需要确认这一项已开启。 - 磁盘与内存余量:优化过程中会临时产生新的 pack 文件,磁盘要留出至少一份仓库大小的空闲空间。
- 权限:能修改仓库的
.git/config、能在服务端改配置、能改 CI 的 clone 步骤。 - 一次可回退的窗口:所有操作先在镜像仓库或测试分支上验证,确认无误再推到主干流程。
第 0 步:先量化,别盲调
任何优化之前,先记下基线。没有基线,后面的改动就无法判断有效还是有害。
```bash
仓库对象体积与松散对象数量
git count-objects -vH
提交总数、分支与标签总数
git rev-list --count --all
git for-each-ref --format='%(refname)' | wc -l
引用广告量(远端握手时要传多少东西)
git ls-remote origin | wc -l
单次状态查询耗时
time git status
time git log --oneline -n 1 -- .
```
把这些数字记到一个表格里。后面每做完一步,重跑一遍。
第 1 步:partial clone,把"下载全部历史"改成"按需下载"
partial clone 的核心是:克隆时告诉服务端"某些对象我先不要",用到的时候再单独取。最常用的两种过滤级别:
```bash
只要提交和目录树,不下载任何文件内容(blob)
git clone --filter=blob:none <仓库地址>
更激进:连目录树也不下载
git clone --filter=tree:0 <仓库地址>
```
对大多数研发场景,blob:none 是合适的起点:日常 git log、切分支、看目录结构都正常,只有真正 git checkout 或 git diff 到某个文件时才触发按需下载。tree:0 更适合纯 CI 构建机——它只关心某一次构建用的那份代码。
配合稀疏检出,monorepo 的效果会更明显:
```bash
git clone --filter=blob:none --no-checkout <仓库地址>
cd <仓库目录>
git sparse-checkout init --cone
git sparse-checkout set services/my-service
git checkout main
```
已有仓库怎么转过去? 不必重新克隆(虽然重新克隆通常更干净):
```bash
git remote set-url origin <仓库地址>
git config remote.origin.promisor true
git config remote.origin.partialclonefilter blob:none
git fetch --refetch --filter=blob:none origin
```
--refetch 在较新版本的 Git 中提供;如果本机版本不支持,直接重新克隆一份是更稳妥的选择。fetch 完成后,本地已经存在的大文件对象仍然留在 pack 里,需要用较新版本 Git 的 git repack -a -d --filter=blob:none 清出去;同样,版本不支持时以重新克隆为准。
一个必须接受的代价:partial clone 之后,仓库不再自包含。断网、隔离环境、离线构建时,访问没下载过的历史文件会直接失败,报错形如 could not fetch <oid>。如果 CI 在无外网环境跑,要么给 CI 配可访问的镜像源,要么改用 --filter=blob:limit=1m 这类只过滤超大文件的温和策略。
第 2 步:commit-graph,让历史遍历不再逐个解压
git log、git merge-base、CI 里算"这次改了哪些包",本质上都在沿提交图走。默认情况下 Git 要一个个解压提交对象、解析父指针。commit-graph 把这些信息预先算好写成一份二进制索引,走图时直接读。
```bash
生成全量 commit-graph,并预计算每个提交改动的路径(加速 git log -- <path>)
git commit-graph write --reachable --changed-paths
校验生成结果
git commit-graph verify
```
光跑一次不够,要让后续操作自动维护它。推荐写进全局或仓库配置:
```ini
[fetch]
writeCommitGraph = true
[gc]
writeCommitGraph = true
[maintenance]
auto = true
```
fetch.writeCommitGraph 让每次 fetch 后增量更新,gc.writeCommitGraph 让 GC 时一起生成。这样智能体高频提交带来的新提交,会持续被纳入索引,不会出现"新提交没进图、反而更慢"的尴尬。
commit-graph 是派生文件,放在 .git/objects/info/commit-graph(或 commit-graphs/ 目录)。它坏了可以直接删掉重建,不影响仓库数据——这也是备份工具报错时可以先忽略它的原因。唯一需要注意的是:如果你用了 git replace 或 grafts 改写历史视图,commit-graph 的生成会跳过相关提交,验证时要留意 --reachable 的输出。
第 3 步:ref 分片,别让引用表自己变成瓶颈
智能体高频提交最容易被忽略的副作用是引用爆炸:每个任务一个分支、每个 PR 一个预览分支、每次发布一个 tag。引用一多,两个地方会痛:服务端每次握手都要把所有引用广告给客户端;本地 refs/ 目录下堆满松散文件。
3.1 打包松散引用
```bash
git pack-refs --all --prune
```
松散引用被打包进 .git/packed-refs,目录遍历的开销就没了。这件事可以交给后台维护任务定期做:
```bash
git maintenance start
```
它会把 gc、commit-graph、pack-refs、增量 repack 排进计划任务,不用手动记。
3.2 减少广告量:协议 v2
```bash
git config --global protocol.version 2
```
协议 v2 把"一次把全部引用推给客户端"换成了按需的 ls-refs 请求,可以带前缀过滤。引用数量上千时,这一项对握手时间的影响通常比换机器还明显。验证方式:
```bash
GIT_TRACE_PACKET=1 git ls-remote origin 2>&1 | head -n 20
```
输出里出现 version 2 相关的协商行,说明生效了。
3.3 命名空间分片
把海量的临时引用收敛到固定前缀下,客户端只拉自己关心的那一段:
```bash
只列出某个前缀下的引用
git ls-remote origin 'refs/heads/agent/*'
克隆时只取单分支,避免顺带下载几千个引用
git clone --single-branch --branch main <仓库地址>
```
配合定期清理过期引用:
```bash
先列出来看看,别直接删
git for-each-ref --sort=committerdate \
--format='%(committerdate:iso8601) %(refname)' \
'refs/heads/agent/*' | head -n 50
```
确认后批量删除,再 git pack-refs --all --prune 收尾。清理策略建议写进服务端定时任务,而不是靠人记得。
3.4 引用存储后端(可选)
较新版本的 Git 提供了 reftable 这种新的引用存储格式,用块状结构代替"一个引用一个文件",在引用数量极大时表现更稳。可以用 git init --ref-format=reftable 创建新仓库验证,具体可用性和迁移方式以官方文档当前版本为准。老仓库迁移涉及全量重写引用,建议在副本上先演练。
第 4 步:服务端侧的两个开关
如果仓库是自建的 Git 服务,客户端做得再好,服务端不开对应能力也白搭:
```ini
[uploadpack]
allowFilter = true
allowAnySHA1InWant = true
```
第一项让 partial clone 的 filter 生效(不开会报 filtering not recognized by server),第二项允许按对象 ID 直接取单个对象,是按需下载能落地的前提。改完重启服务,用 git clone --filter=blob:none <地址> 验证。
另外建议在服务端定期跑一次全量 repack,把碎片整理掉:
```bash
git repack -a -d --write-bitmap-index
git commit-graph write --reachable --changed-paths
```
--write-bitmap-index 会额外生成位图索引,对 git clone 和 git fetch 的打包阶段有直接帮助。
第 5 步:压测,用数字确认收益
写一个能重复跑的脚本,模拟并发克隆。数字不必精确到毫秒,看趋势就够:
```bash
#!/usr/bin/env bash
set -euo pipefail
URL="$1"
ROUNDS="${2:-3}"
WORK="$(mktemp -d)"
for i in $(seq 1 "$ROUNDS"); do
start=$(date +%s)
git clone --quiet --filter=blob:none "$URL" "$WORK/clone-$i"
end=$(date +%s)
size=$(du -sm "$WORK/clone-$i/.git" | cut -f1)
echo "round=$i elapsed=$((end-start))s gitdir=${size}MB"
rm -rf "$WORK/clone-$i"
done
rm -rf "$WORK"
```
再补一组对照:去掉 --filter=blob:none 跑同样的轮次。两组数字放在一起,partial clone 的收益就一目了然。引用侧的压测用 git ls-remote 计时,分别在有/无协议 v2、清理引用前后各跑一次。
日常还可以打开 trace2 观察慢在哪一段:
```bash
GIT_TRACE2_PERF=1 git clone --filter=blob:none <仓库地址>
```
输出里 fetch、checkout、index 各阶段的耗时是分开的,能直接定位瓶颈是网络、解包还是写工作区。
常见坑与排错
报错 filtering not recognized by server
服务端没开 uploadpack.allowFilter,或者中间有代理/网关把 capability 剥掉了。先在服务端确认配置,再用 GIT_TRACE_PACKET=1 看协商报文里有没有 filter 能力。
离线环境大面积失败,报 could not fetch <oid>
这是 partial clone 的预期行为,不是 bug。要么给环境配镜像源,要么换成 --filter=blob:limit=1m 的温和过滤,要么对 CI 保留完整克隆、只对开发者机器用 partial clone。
用了 tree:0 之后 git log --stat 慢到不可用
tree:0 会让每次看历史都要回源拉目录树。它只适合"拉下来就构建、不看历史"的场景。研发机器请用 blob:none。
commit-graph 生成了但没感觉变快
大概率是新提交没进图。检查 git config --get fetch.writeCommitGraph 是否为 true,以及 CI 是否每轮都从零克隆(从零克隆的仓库本来就没有图,得在镜像层预生成并缓存)。
引用清理完,git fetch 还是慢
客户端本地还留着大量过期远程跟踪分支。跑 git fetch --prune 清掉,再 git pack-refs --all --prune。如果远端本身引用没减,那要回到服务端做清理任务。
浅克隆和 partial clone 混用
--depth 截断的是提交历史,--filter 省略的是对象内容,两者叠加排错会非常痛苦。除非明确知道要什么,否则一次只用一种。
备份或同步工具报错
多半是不认识 .git/objects/info/commit-graph 这类派生文件。它们可以从对象库重建,备份时排除掉、恢复后重跑 git commit-graph write --reachable 即可。
GC 之后磁盘没怎么变小
确认没有被 git replace 或其他引用钉住的历史;用 git count-objects -vH 看 garbage 和 size-garbage 两项,必要时 git prune 清理不可达对象。
下一步建议
到这里,克隆、历史遍历、引用表三处的主要瓶颈已经处理完。再往前可以做这些:
1. 给大文件上 LFS。二进制资源、模型权重、测试数据集放进 Git LFS,从根上避免仓库随资产膨胀。partial clone 是缓解,LFS 是分流,两者可以叠加。
2. 把 CI 的克隆方式分层。构建任务用 --filter=tree:0 加稀疏检出;需要跑全量测试的任务用 blob:none;只有做发布归档的任务才完整克隆。
3. 把维护任务定时化。git maintenance start 加上服务端的定时 repack、引用清理、commit-graph 重建,写成一条固定的运维流水线,而不是等出问题再手动救。
4. 建立监控指标。至少盯四个数:克隆耗时、.git 体积、引用总数、git status 耗时。让它们出现在你的看板上,趋势比绝对值更重要。
5. 考虑仓库拆分。如果单个仓库的提交量和引用数已经让所有优化都只是延缓,那该评估按业务域拆库了。这属于架构决策,不是 Git 配置能解决的。
6. 持续跟进官方文档。partial clone 的过滤器语法、reftable 的成熟度、git maintenance 的子命令都在演进,具体能力和版本要求以官方文档当前版本为准。
最后提醒一句:这些优化都有取舍。partial clone 换来的是速度和体积,付出的是"仓库不再自包含";tree:0 换来的是极致轻量,付出的是历史不可读。先在测试仓库里把每一步的收益和代价量出来,再决定推到哪一档。
