背景

在游戏开发过程中,设计资源占用了很大一部分空间. 像png,psd等文件是二进制(blob)的,体积也很庞大. 但git的diff/patch等是基于文件行的.对于二进制文件来说. git需要存储每次commit的改动.

每次当二进制文件修改,发生变化的时候. 都会产生额外的提交量.导致clone和pull的数据量大增.在线仓库的体积也会迅速增长.

LFS(Large File Storage) 就是为了解决这一问题而产生的工具.

它将你所标记的大文件保存至另外的仓库,而在主仓库仅保留其轻量级指针.

那么在你检出版本时,根据指针的变化情况下更新对应的大文件.而不是在本地保存所有版本的大文件

介绍

Git LFS(Large File Storage, 大文件存储)是可以把音乐、图片、视频等指定的任意文件存在 Git 仓库之外,而在 Git 仓库中用一个占用空间 1KB 不到的文本指针来代替的小工具。通过把大文件存储在 Git 仓库之外,可以减小 Git 仓库本身的体积,使克隆 Git 仓库的速度加快,也使得 Git 不会因为仓库中充满大文件而损失性能。

Git LFS 对象在服务器上可以存储在 Git 仓库之外的任何地方,具体位置由 Git LFS Server 的配置决定,而与 Git 客户端无关。如在 GitLab 中,可以配置文件系统中的一个目录来存储 Git LFS 对象,这个目录是与 Git 仓库无关的,所有的 Git LFS 对象都会根据哈希值存放在其中。

克隆仓库时,git lfs fetch 会作为 git clone 的一部分被执行来获取 Git LFS 对象。而 git lfs fetch 会与 git clone 一并执行则是因为在安装 Git LFS 时,一并安装了相关的 post-checkout hook。在执行 git pull, git checkout 等命令时也是同理。

使用 Git LFS,在默认情况下,只有当前签出的 commit 下的 LFS 对象的当前版本会被下载。此外,我们也可以做配置,只取由 Git LFS 管理的某些特定文件的实际内容,而对于其他由 Git LFS 管理的文件则只保留文件指针,从而节省带宽,加快克隆仓库的速度;也可以配置一次获取大文件的最近版本,从而能方便地检查大文件的近期变动。详见后文进阶使用。

Git LFS 被主流的 Git 服务如 GitHub 和 GitLab 支持。

要使用 Git LFS 只需要经过一次下载安装后,指定需要由 Git LFS 管理的文件即可。

安装

1
注意:安装 Git LFS 需要 Git 的版本不低于 1.8.5

Linux

1
2
3
curl -s https://packagecloud.io/install/repositories/github/git-lfs/script.deb.sh | sudo bash
`
sudo apt-get install git-lfs

Mac

HomeBrew:

1
brew install git-lfs

常用命令

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
# 查看当前使用 Git LFS 管理的匹配列表
git lfs track

# 使用 Git LFS 管理指定的文件
git lfs track "*.psd"

# 不再使用 Git LFS 管理指定的文件
git lfs untrack "*.psd"

# 类似 `git status`,查看当前 Git LFS 对象的状态
git lfs status

# 枚举目前所有被 Git LFS 管理的具体文件
git lfs ls-files

# 检查当前所用 Git LFS 的版本
git lfs version

# 针对使用了 LFS 的仓库进行了特别优化的 clone 命令,显著提升获取
# LFS 对象的速度,接受和 `git clone` 一样的参数。 [1] [2]
git lfs clone https://github.com/user/repo.git

git lfs lock
# 锁定一个或者一些文件, 只允许当前的用户对这些文件进行修改, 防止在多人协作的场景下冲突
git lfs unlock
# 同上, 解锁一个或者一些文件

git lfs migrate
# 用来将当前已经被 git 储存库保存的文件以 git lfs 的保存 (将 git 对象转为 lfs 对象)
# 例如如果将当前远程不存在的的所有 pdf 文件清除
# git lfs migrate import --include="*.pdf"
#
# 如果是已经上传到中心服务器的内容, 则需要指定分支 (可能需要 push --force)
# git lfs migrate import --include="*.mp4" --include-ref=refs/origin/master --include-ref=refs/origin/dev --include-ref=refs/origin/test
#
# 然后使用如下命令清理 .git 目录
# git reflog expire --expire-unreachable=now --all && git gc --prune=now

git lfs prune
# 删除全部旧的 Git LFS 文件

$ git lfs fetch
$ git lfs pull
$ git lfs push
$ git lfs checkout
# 正常情况下会随着 git pull/push 一起执行
# 如果在 git pull/push 的过程中断了, 导致二进制文件没有被拉取的时候, 可以使用这些命令(支持断点续传,速度不慢)

[1] git lfs clone 通过合并获取 LFS 对象的请求,减少了 LFS API 的调用,并行化 LFS 对象的下载,从而达到显著的速度提升。git lfs clone 命令同样也兼容没有使用 LFS 的仓库。即无论要克隆的仓库是否使用 LFS,都可以使用 git lfs clone 命令来进行克隆。 [2] 目前最新版本的 git clone 已经能够提供与 git lfs clone 一致的性能,因此自 Git LFS 2.3.0 版本起,git lfs clone 已不再推荐使用。

可以通过 git lfs –help 指令看到 git lfs 的全部指令

基本使用

  • 执行 git lfs install 开启lfs功能,通过运行该命令设置Git LFS及其相应的挂钩:
  • 使用 git lfs track 命令进行大文件追踪 例如git lfs track "*.png" 追踪所有后缀为png的文件
  • 使用 git lfs track 查看现有的文件追踪模式
  • 提交代码需要将gitattributes文件提交至仓库. 它保存了文件的追踪记录
  • 提交后运行git lfs ls-files 可以显示当前跟踪的文件列表
  • 将代码 push 到远程仓库后,LFS 跟踪的文件会以『Git LFS』的形式显示:
  • clone 时 使用’git clone’ 或 git lfs clone均可

进阶使用

使用 Git LFS 的核心思想就是把需要进行版本管理、但又占用很大空间的那部分文件独立于 Git 仓库进行管理。从而加快克隆仓库本身的速度,同时获得灵活的管理 LFS 对象的能力。

默认情况下,只有当前 commit 下的 LFS 对象的当前版本才会被获取。

只获取仓库本身,而不获取任何 LFS 对象

如果自己的相关工作不涉及到被 Git LFS 所管理的文件的话,可以选择只获取 Git 仓库自身的内容,而完全跳过 LFS 对象的获取。

1
2
3
GIT_LFS_SKIP_SMUDGE=1 git clone https://github.com/user/repo.git
# 或
git -c filter.lfs.smudge= -c filter.lfs.required=false clone https://github.com/user/repo.git

注:GIT_LFS_SKIP_SMUDGE=1 及 git -c filter.lfs.smudge= -c filter.lfs.required=false 同样适用于其他 git 命令,如 checkout, reset 等。

获取当前 commit 下包含的 LFS 对象的当前版本

如果起初获取代码时,没有一并获取 LFS 对象,而随后又需要这些被 LFS 管理的文件时,可以单独执行 LFS 命令来获取并签出 LFS 对象:

1
2
3
4
git lfs fetch
git lfs checkout
# 或
git lfs pull

仅获取指定目录下的 LFS 对象

比如说,我们有一仓库,里面包含了许多源代码文件,以及一些图像、视频等资源文件,其目录结构如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
zzz.buzz
├── images
│   ├── cat.png
│   ├── dog.gif
│   └── rabbit.webp
├── src
│   ├── buzz.css
│   ├── index.html
│   └── zzz.js
└── videos
    ├── chameleon.mp4
    └── iguana.webm

其中的 images/** 以及 videos/** 是被 LFS 所管理的。

但是,如果只想取 images 文件夹,而不想获取 videos 文件夹下的文件的话,我们就可以选择配置 LFS 下载对象时仅包含 images 文件夹:

1
git config lfs.fetchinclude 'images/**'

随后,git checkout, git reset, git lfs fetch, git lfs pull 等命令就都会只处理所指定的文件夹。

类似地,我们也可以选择仅排除指定的文件夹:

1
git config lfs.fetchexclude 'videos/**'

也可以同时使用黑白名单规则,这样只有同时满足 include 规则和 exclude 规则的大文件才会被获取:

1
2
3
git config lfs.fetchinclude 'videos/**'
git config lfs.fetchexclude 'videos/chameleon.mp4'
# 在此例中,如此配置将只会获取 videos/iguana.webm 一个文件。

一次获取 LFS 对象的最近版本

Git LFS 相关命令在获取 LFS 对象时,默认仅会获取该对象当前被引用的版本,如果想要一次获取 LFS 对象的当前及最近版本的话,我们首先需要对最近进行定义:

1
git config lfs.fetchrecentcommitsdays 7

7 表示同时下载过去 7 天内的版本(相对于获取的 LFS 对象的时间),该项配置默认值为 0,即不获取过去的版本,而仅获取指定的版本。

有了对最近的定义后,我们可以选择在执行 git lfs fetch 命令时,加上 --recent 参数以同时获取最近版本;

或者配置

1
git config lfs.fetchrecentalways true

从而总是同时获取 LFS 对象的最近版本。

常见问题

  • 在安装 Git LFS 之前,克隆了使用 Git LFS 的仓库,则被 Git LFS 管理的文件会被显示为文本指针,而非具体的文件。

    查看这些文件指针,会发现类似如下内容:

    1
    2
    3
    
    version https://git-lfs.github.com/spec/v1
    oid sha256:4b99dbe6fe6f646b2026de93481045bbf34f995559db15fce34d192f1f320ef4
    size 156154
    

    解决办法就是,手动执行获取 Git LFS 对象的命令:

    1
    2
    3
    4
    
    git lfs fetch
    git lfs checkout
    # 或
    git lfs pull
    
  • Git LFS 对象在本地仓库的存放位置?

    通过 Git LFS 所管理的对象实际在本地的存储位置是在 .git/lfs/objects 目录下,该目录根据对象的 sha256 值来组织。

    作为对比,Git 自身所管理的对象则是存储在 .git/objects 目录下,根据 commit, tree, blob, tag 的 sha1 值来组织。

  • 已经使用 git lfs track somefile 追踪了某个文件,但该文件并未以 LFS 存储。

    如果被 LFS 追踪管理的文件的大小为 0 的话,则该文件不会以 LFS 的形式存储起来。

    只有当一个文件至少有 1 个字节时,其才会以 LFS 的形式存储。

    注:一般使用 LFS 时,我们也不会用其追踪空文件,即使追踪了空文件,对于使用也没有任何影响。提到这点主要是为了消除在测试使用 LFS 时可能遇到的困惑。

  • 执行 git lfs fetchgit lfs pull 时报错

    1
    
    batch request: exit status 255: Permission denied (publickey,gssapi-keyex,  gssapi-with-mic).
    

    如果在克隆仓库时使用了 SSH 协议,而本地的 SSH 私钥又有密码保护,那么向服务器获取文件时就会报 错,因为目前 Git LFS 不会向用户请求密码,从而导致认证失败。

    解决办法是使用 ssh-add 命令,预先加载好本地的 SSH 私钥,从而使得 Git LFS 能够访问到私钥。

    使用 Git LFS 时,报错缺失协议 (missing protocol)或协议不支持 (unsupported protocol scheme)。

    出现这种错误通常有两种原因:

    其中第一种是克隆仓库时使用的地址没有包含用户名,如克隆时使用了类似 git clone github.com:user/repo.git 的命令,从而导致 Git LFS 错误地将服务器地址当作协议名来看待,而报出协议不支持的错误:

    1
    2
    3
    4
    5
    
    [z@zzz.buzz lfs-test]$ git push
    Git LFS: (0 of 1 files) 0 B / 1 B
    Post git.zzz.buzz:z/lfs-test.git/info/lfs: unsupported protocol scheme  "git.zzz.buzz"
    Post git.zzz.buzz:z/lfs-test.git/info/lfs: unsupported protocol scheme  "git.zzz.buzz"
    error: failed to push some refs to 'git.zzz.buzz:z/lfs-test.git'
    

    解决办法就是在仓库地址中加上 git 用户名,如:

    1
    
    git remote set-url origin git@github.com:user/repo.git
    

    第二种原因则是克隆仓库时使用的是 SSH 协议,而使用的 Git 服务器不支持在 SSH 下使用 Git LFS (如低于 8.12 版本的 GitLab),其解决办法为将克隆仓库时使用的 SSH 协议换为 HTTPS 协议即可。

    如原先 origin 设置为 git@github.com:user/repo.git,则可以运行如下命令:

    1
    
    git remote set-url origin https://github.com/user/repo.git
    

    随后再执行 Git LFS 相关的命令。

    或使用 HTTPS 协议重新克隆仓库:

    1
    
    git clone https://github.com/user/repo.git
    

限制

为了防止滥用, 各个平台对 Git LFS 有不同的限制

GitHub

GitHub 的全部 Repo 的 Git LFS 内容不得超过 1G, 流量限制 1G, 限制的还是比较严格的, 详情 可以使用每个月 $5 增加限制, 每个月订阅的费用每上升 $5, 内容大小和流量限制上升 50G.

BitBucket

BitBucket 免费账号 File Storage 限制为 1GB, $2/month 的 Standard 账号为 5GB $5/month 的Premium 账号为 10GB

GitLab

GitLab 没有找到 Git LFS 限制的相分描述, 但是他们有提到单个 Repo 的储存限制为 10G, 想必是包含了 Git LFS 内容的大小在内.

Gitee

码云中, Git LFS 功能只对付费企业和个人开放, 内容大小限制未知.

迁移实践

转换

找到大文件的扩展名

查找所有大于 10KiB 的文件扩展名

fish

1
find Assets -type f -size +10K | grep -o -E "\.[^\.]+\$" | sort | uniq -c | sort -rn

bash

1
find Assets -type f -size +10K | grep -o -E "\.[^\.]+$" | sort | uniq -c | sort -rn

转换工具

git-lfs-migrate 会生成转换完成的仓库,在本文中选择使用这个工具。

转换前统计

转换前,当前裸仓库 33.74 GiB,40800+ 提交

1
2
3
4
5
6
7
8
$ git clone --mirror client.git
Cloning into bare repository 'client.git'...
remote: Counting objects: 618522, done.
remote: Compressing objects: 100% (112618/112618), done.
Receiving objects: 100% (618522/618522), 33.74 GiB | 33.66 MiB/s, done.
remote: Total 618522 (delta 505343), reused 618354 (delta 505242)
Resolving deltas: 100% (505343/505343), done.
用时 20m 47s

转换

转换命令,可以多开启线程进行操作,建议 16 线程以上,示例中开启了 64 线程:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
java -jar git-lfs-migrate.jar \
     -s client.git \
     -d client_converted.git \
     -g git@gitlab.com:example/client_converted.git \
    --write-threads 64 \
    "*.FBX"\
    "*.TGA"\
    "*.a"\
    "*.aar"\
    "*.bmp"\
    "*.bundle"\
    "*.bytes"\
    "*.chm"\
    "*.chw"\
    "*.cubemap"\
    "*.dds"\
    "*.dll"\
    "*.exe"\
    "*.fbx"\
    "*.gif"\
    "*.jpg"\
    "*.mdb"\
    "*.mel"\
    "*.mp3"\
    "*.otf"\
    "*.pdf"\
    "*.png"\
    "*.psd"\
    "*.swatch"\
    "*.tga"\
    "*.tif"\
    "*.ttf"\
    "*.wav"\
    "*.xlsx"\
    "*.zip"

转换用时 4h 19m 10s

GC 统计

对转换后的仓库执行 git gc

1
2
3
4
5
6
7
8
$ git gc
Counting objects: 619464, done.
Delta compression using up to 4 threads.
Compressing objects: 100% (618794/618794), done.
Writing objects: 100% (619464/619464), done.
Total 619464 (delta 485448), reused 0 (delta 0)
Removing duplicate objects: 100% (256/256), done.
Checking connectivity: 619464, done.

用时 19m 44s

  • 压缩前 大小 4.6G 文件数量 619629
  • 压缩后 大小 1.5G 文件数量 7

注意:大小使用 du -sh . 统计,文件数量使用 find . -type f | wc -l 统计

推送

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
$ git push --mirror git@gitlab.com:example/client_converted.git
Counting objects: 618620, done.
Delta compression using up to 4 threads.
Compressing objects: 100% (133190/133190), done.
Writing objects: 100% (618620/618620), 1.46 GiB | 19.56 MiB/s, done.
Total 618620 (delta 484760), reused 618620 (delta 484760)
remote: Resolving deltas: 100% (484760/484760), done.
remote: Checking connectivity: 618620, done.
To git@gitlab.com:example/client_converted.git
 * [new branch] master -> master

推送用时 3m 48s

转换期间推送的提交

尝试

由于需要尽量减少停机时间,需要将转换期间产生的新提交移动到转换后的仓库中。 尝试使用 cherry-pick 与 am 命令迁移,都出现二进制文件无法识别问题,即 Git 认为在 LFS 的仓库中二进制文件内容与生成的 binary diff 的源不同。 因为 LFS 仓库中只存二进制文件的指针,不存在文件内容了。

1
2
3
4
5
6
git remote add old old.git
git fetch old
git cherry-pick --strategy=recursive --strategy-option=theirs 1fdsa27..old/master

git format-patch --break-rewrites 1fdsa27..HEAD --stdout > transfer.patch
git am --ignore-space-change --ignore-whitespace --whitespace=nowarn --utf8 transfer.patch

可行方法

使用文件比较工具或 rsync 同步两个仓库,然后将变化提交。 需要特别注意旧仓库在拉取更新后一定要保证工作目录是干净的,否则会将错误的内容同步到新仓库中。

结果比较

转换前 - 整个大小 52G 裸仓库 35G objects 35G - GitLab 统计 Storage used: 33.8 GB ( 33.8 GB repository, 0 Bytes build artifacts, 0 Bytes LFS )

转换后 - 整个大小 24G 裸仓库 11G 其中 objects1.5G lfs 9.9G - GitLab 统计 Storage used: 94.2 GB ( 1.5 GB repository, 0 Bytes build artifacts, 92.7 GB LFS )

注意:大小统计使用 du -sh . 命令

检查钩子

提交时检查是否包含过大文件

GitHub - Ninjaccount/git-big-lfs-hook: Git hook to prevent commit if a file is too big and not tracked by lfs.

同时需要增加服务器端钩子检查文件是否被 LFS 管理,不被管理的话拒绝推送

mgit-at/git-max-filesize: A pre-receive hook to enforce usage of git-lfs

迁移

所有人停止提交与推送。克隆仓库不再需要使用 git lfs clone 命令了

1
2
3
4
5
WARNING: 'git lfs clone' is deprecated and will not be updated
          with new flags from 'git clone'

'git clone' has been updated in upstream Git to have comparable
speeds to 'git lfs clone'.

可以直接使用 git clone

以下是两次克隆的结果,由于受网速影响,用时差异较大:

1
2
3
4
5
6
7
8
9
$ git clone git@gitlab.com:example/client_converted.git
Cloning into 'client_converted'...
remote: Counting objects: 618085, done.
remote: Compressing objects: 100% (133260/133260), done.
Receiving objects: 100% (618085/618085), 1.46 GiB | 48.10 MiB/s, done.
remote: Total 618085 (delta 484307), reused 617897 (delta 484155)
Resolving deltas: 100% (484307/484307), done.
Checking out files: 100% (30239/30239), done.
Filtering content: 100% (4917/4917), 9.80 GiB | 21.18 MiB/s, done.

用时 8m 55s

1
2
3
4
5
6
7
8
9
$ git clone git@gitlab.com:example/client_converted.git
Cloning into 'client_converted'...
remote: Counting objects: 618929, done.
remote: Compressing objects: 100% (133427/133427), done.
remote: Total 618929 (delta 484984), reused 618741 (delta 484832)
Receiving objects: 100% (618929/618929), 1.46 GiB | 51.48 MiB/s, done.
Resolving deltas: 100% (484984/484984), done.
Checking out files: 100% (30345/30345), done.
Filtering content: 100% (4932/4932), 9.86 GiB | 60.66 MiB/s, done.

用时 3m 44s

参考:
https://www.jianshu.com/p/493b81544f80
https://zzz.buzz/zh/2016/04/19/the-guide-to-git-lfs/
https://juejin.im/post/5d03a92d5188257152111502
https://networm.me/2018/05/13/migrate-to-gitlfs/