Git 仓库突然变大,先分清松散对象、pack 和 LFS

176 次浏览11 条回复

适用于 Git 2.36+,在完整本地 clone 的仓库根目录执行;下面这些先做只读检查。Git LFS 是可选项,没安装就跳过。

git --version
git rev-parse --is-inside-work-tree
git rev-parse --is-shallow-repository
du -sh .git
git count-objects -vH
command -v git-lfs >/dev/null && git lfs ls-files | head -n 20

countsize 高,通常是松散对象多;size-pack 高,空间主要在 pack。工作树里删掉大文件并不等于历史中的 blob 消失,LFS 当前有指针也不代表迁移前的旧 blob 已经离开历史。完整 clone 里可以继续找逻辑尺寸最大的对象:

git rev-list --objects --all \
  | git cat-file --batch-check='%(objecttype) %(objectname) %(objectsize) %(rest)' \
  | sort -k3nr | head -n 20

OID='粘贴上一步的对象 ID'
git log --all --find-object="$OID" --oneline --decorate

这里的 objectsize 是未压缩逻辑尺寸,不等同于网络传输量,但很适合定位误提交的镜像、归档和数据库文件。先别直接删 .git/objects,也别一上来重写历史:重写会改变提交 ID,还要同步处理分支、标签、保护规则和其他 clone。单纯 git gc 能整理对象并回收符合条件的不可达对象,不能让仍被引用的大 blob 消失。

处理后至少跑 git fsck --fullgit count-objects -vH;clone 或 CI 变快则要用同一远端、相近网络和全新目录复测,避免把本地缓存命中当成修复。

补一个容易误判的点:上面按 objectsize 排的是解压后的逻辑大小;如果 size-pack 才是主项,可以再按 pack 中的压缩后尺寸看一次:

OBJDIR=$(git rev-parse --git-path objects)
git verify-pack -v "$OBJDIR"/pack/*.idx \
  | awk '$2 ~ /^(blob|tree|commit|tag)$/ {print}' \
  | sort -k4,4nr | head -n 20

verify-pack 第 3 列是逻辑大小,第 4 列才是 pack 内占用;delta 对象后面还会带深度和基对象。用 --git-path objects 也能避开 linked worktree 或自定义 gitdir 下直接写 .git/objects 的路径误判。这个检查是只读的,尾部统计行已由 awk 排除。

还得把 shallow clone 和 partial clone 分开看:git rev-parse --is-shallow-repository 返回 false,也可能是用了 blob:none 之类过滤器的 partial clone。先看一下:

git config --get remote.origin.promisor
git config --get remote.origin.partialclonefilter
git rev-list --objects --all --missing=print | sed -n '/^?/p' | head

如果存在 promised 但本地缺失的对象,后面的 cat-file 尺寸扫描可能触发懒加载,把大 blob 从远端拉下来。网络敏感的 CI 机器上别直接跑,换到完整 mirror 上统计更稳;验证时也要把这次额外下载算进仓库体积变化。

还有一种常见情况:删分支或强推后,旧对象已经不在普通 refs 里,却还被本地 reflog 保留。可以先只读看:

git log -g --all --date=iso --oneline --decorate | head -n 50
git fsck --no-reflogs --unreachable --no-progress

第二条是“忽略 reflog 后判断不可达”,不代表列出的对象现在就能安全删除;stash、待恢复提交和仓库的保留策略都要一起核对。如果只是某一个 clone 变大,而同一远端的全新 clone 正常,这条线通常比立刻重写远端历史更值得先查。真正做 reflog expire 或带 prune 的 gc 前,先确认恢复窗口和团队约定。

再补一个 git count-objects 覆盖不到的地方:LFS 的本地对象缓存。size-pack 不大但仓库目录仍然很大时,如果使用 Git LFS 3.x,先看实际存储位置;仓库可能配置过 lfs.storage,不能直接假定它一定在 .git/lfs/objects

git lfs version
git lfs env
git lfs prune --dry-run

git lfs env 里的 LocalMediaDir 才是当前缓存目录,可以对那个路径再跑 du -sh -- <LocalMediaDir>prune --dry-run 只列候选,不会删除;正式清理前还要确认该目录没有被多个仓库共享。需要逐个核验远端副本时可用 git lfs prune --verify-remote,但它会访问远端并删除符合条件的本地缓存,不适合拿来做第一步。清理后可用 git lfs fsck 检查当前检出所需的 LFS 对象,再复查 LocalMediaDir 的占用。

如果 countsize 持续涨,还可以查一下自动维护是不是一直失败。Git 2.36+ 遇到较新的 gc.log 时会暂缓后续自动 gc,CI 缓存或长期工作目录里可能因此一直堆松散对象:

git config --show-origin --get gc.auto
git config --show-origin --get gc.autoPackLimit
git config --show-origin --get gc.logExpiry
GCLOG=$(git rev-parse --git-path gc.log)
test ! -f "$GCLOG" || sed -n '1,120p' "$GCLOG"

配置项没有输出时只是采用默认值,不等于自动维护一定关闭。gc.log 里通常能看到上次失败原因;先处理权限、剩余空间或并发 Git 进程等根因,别把直接删除日志当成修复。之后按仓库原有维护流程在没有并发写入的窗口执行,再用 git fsck --fullgit count-objects -vH 复查。

git count-objects -vH 里还有三个字段挺容易漏:prune-packablegarbagesize-garbageprune-packable 高,表示不少松散对象已经有 pack 副本,不等于历史里的大 blob 还被引用;size-garbage 高,则说明不能只盯 size-pack。Git 2.36+ 可以先把警告路径和完整性检查一起看:

git count-objects -vH 2>&1 | sed -n '1,160p'
git fsck --full

count-objects 会在 stderr 报对象库里识别不了的文件。先核对文件时间、属主,以及当时是否有 fetch、repack 或 gc 被中断;别按 .tmp.keep 之类扩展名直接删除,也不要在仍有 Git 写入时清理。处理后再跑同两条命令,确认 garbagesize-garbage 和完整性结果都符合预期。

还有个容易让 du -sh .git 和顶层 git count-objects 对不上的来源:子模块仓库。Git 2.36+、Linux/GNU du 下,可以先按公共 Git 目录拆一下占用:

COMMON=$(git rev-parse --path-format=absolute --git-common-dir)
du -xh --max-depth=2 -- "$COMMON" | sort -h | tail -n 30

如果大头落在 modules/,那是子模块各自的对象库,顶层仓库的 git count-objects -vH 不会替它们汇总。可以再逐个只读核对:

git submodule foreach --recursive '
  printf "%s\t" "$displaypath"
  du -sh -- "$(git rev-parse --absolute-git-dir)"
'

这时要在对应子模块里分别跑 git count-objects -vH、查 LFS 或维护失败;不要因为父仓库路径下的 modules/ 很大,就直接清那个目录。处理后复查同一份 du 输出,并确认 git submodule status --recursive 仍能正常解析。

再补一个 reference clone / alternates 分支。Git 2.36+ 用 git clone --reference 或共享对象池时,部分对象可能借自外部对象库,仓库本地一开始会显得很小:

git count-objects -vH | sed -n '/^alternate:/p'
ALT=$(git rev-parse --git-path objects/info/alternates)
test ! -f "$ALT" || sed -n '1,80p' "$ALT"

如果之后执行不带 --localgit repack -a,或者 clone 使用了 --dissociate,借来的对象会被复制进本地对象库;即使没有新增大 blob,.git 也可能突然变大。这个场景先对照自动维护或构建日志确认时间点,别直接改 alternates 文件,也别清理仍被仓库依赖的共享对象池。完成既定迁移后,再用 git fsck --fullgit count-objects -vH 检查完整性与本地占用。

git rev-list --objects --all 还会漏掉只被索引引用、尚未进入提交的 blob。比如大文件执行过 git add 但没提交时,count-objects 可能已经涨了,前面的 --all 扫描却看不到。Git 2.36+ 可以只读检查索引里的对象:

git ls-files --stage |
  while read -r mode oid stage path; do
    [ "$stage" = 0 ] && printf '%s %s\n' "$oid" "$path"
  done |
  git cat-file --batch-check='%(objecttype) %(objectname) %(objectsize) %(rest)' |
  sort -k3,3nr | head -n 20

第 3 列仍是未压缩逻辑尺寸,最后一列保留路径,配合 git status --short 能确认是不是当前暂存内容。若文件已经从索引撤回,对象会变成不可达,此时再接前面回复里的 git fsck --no-reflogs --unreachable 分支;不要为了回收这一点空间直接缩短全仓库的保留窗口。

calmLv1#9

git rev-list --objects --all 还会漏掉只被索引引用、尚未进入提交的 blob。比如大文件执行过 git add 但没提交时,count-objects 可能已经涨了,前面的 --all 扫描却看不到。Git 2.36+ 可以只读检查索引里的对象:

git ls-files --stage |
  while read -r mode oid stage path; do
    [ "$stage" = 0 ] && printf '%s %s\n' "$oid" "$path"
  done |
  git cat-file --batch-check='%(objecttype) %(objectname) %(objectsize) %(rest)' |
  sort -k3,3nr | head -n 20

第 3 列仍是未压缩逻辑尺寸,最后一列保留路径,配合 git status --short 能确认是不是当前暂存内容。若文件已经从索引撤回,对象会变成不可达,此时再接前面回复里的 git fsck --no-reflogs --unreachable 分支;不要为了回收这一点空间直接缩短全仓库的保留窗口。

这个索引检查还要留意 linked worktree:各 worktree 有独立索引,但共用对象库。大文件只在另一个 worktree 里 git add 时,当前目录执行这段扫描仍会漏掉。Git 2.36+ 可以先列出它们:

git worktree list --porcelain
WT='/上一步列出的绝对路径'
git -C "$WT" status --short
git -C "$WT" ls-files --stage |
  while read -r mode oid stage path; do
    [ "$stage" = 0 ] && printf '%s %s\n' "$oid" "$path"
  done |
  git -C "$WT" cat-file --batch-check='%(objecttype) %(objectname) %(objectsize) %(rest)' |
  sort -k3,3nr | head -n 20

WT 依次替换为每个 worktree 的路径再查;处理前也先确认其他 worktree 没有待提交内容。复查时从各 worktree 跑 git status --short,再回公共对象库看 git count-objects -vH,避免把别处仍需的暂存对象当成可清理对象。