基于Git的Unity协作开发指南: 以Unity 6 LTS为例

Git 非常适合于文本文件的版本管理。

当文件修改发生时, 它会记录修改的元数据——即在何处发生了何种修改。它不会生成原文件的副本, 也不会改动项目原始文件, 所以每次修改记录都非常节省空间。同时因为大多面向文本, Git 在 push 前压缩数据, 在 pull (其本质上是 fetch+merge 的组合)或 rebase 的时候在本地解压缩, 这样就有效地减少了本地IO和网络IO(fetch 远程仓库时)。

不过, 结合Unity项目使用时, 就会遇到大量资源文件的带来的问题: 资源文件压缩率低, 压缩速度慢, 对 Git 的使用体验来说是一种打击。由于大部分资源文件都是二进制文件, 也会遇到更改放大效应, 即一个较小的更改都会使整个文件的二进制数据产生很大的变化, 让文件更改的对比变得极为困难(主要是充满人类不可读的大量更改); 大文件存储在仓库中的话——即使使用浅克隆——仅克隆指定深度的数据对象( blob )也会变得很慢。

因此, 若使用 MSYS GitGit For Windows 直接管理 Unity 项目主要存在以下几个主要问题:

  • 文件杂乱: 项目文件有许多杂乱的文件, 我们并不希望因为 Git 跟踪它们而导致提交记录历史变得一团乱麻, 或者在项目中只要求我们跟踪特定的某些工程文件, 这一需求是非常普遍的。
  • 大文件难以管理: 有的素材资产 (图片/字体/音乐/3D模型等) 文件尺寸较大, 且难以序列化为纯文本来对比更改之处, 储存为 blob 又会占用大量存储空间和传输带宽。
  • 场景文件相关的合并冲突: Unity的场景文件是一个YAML文件 (可见内容示例), 它存储的是在Unity编辑器中的各种值和对象。这样的文件构成虽然让Unity虽然能轻易地序列化场景文件, 但也使得要在规模稍大的场景文件中手工解决合并冲突变成了几乎是不可能做到的事情, 因为某个GUID所引用的对象具体在场景中是一个什么物件是很难想象的, 这种不直观的特性使得文本化的场景文件并不具备人类可读性。
  • 对象引用难以保持: Unity编辑器的场景文件中通过随机 GUID 来保持对对象的跟踪, 这个功能需要 .meta 文件的支持。由于场景文件引用是 GUID 而非资产的文件名/路径/哈希值, 因此一个资产文件需要在同名的 .meta 文件中记录其 GUID 才能维持场景文件对其的引用, 否则文件内容的冲突会导致项目文件损坏。

针对以上四个主要问题, 靠着 .gitignore 文件,  Git Large File Storage System (Git LFS) 和 Unity Smart Merge(即 UnityYAMLMerge, 常简称 Smart Merge, 智能合并) 便能构建一种基本可用的 Git for Unity 解决方案。

解决文件杂乱问题

首先当然是在Unity项目中用 git init 引入 .git 文件夹, 并使用 git remote add <shortname> <url> 配置远程仓库 (Git文档见此处), 然后便可以为该项目添加 .gitignore文件了。

Windows上可以用 Git for Windows, 它基于 MSYS2 Git 且直接内置了 git-lfs, Git Credential Manager, mingw64git-bash; Linux当然是用 apt/yum/dnf 直接安装; 而 Mac OS 则常用 homebrew 安装。具体操作详见 Git 中文文档的安装 Git 一章。以下命令可用于验证 Git 的安装:

❯ git --version
git version 2.47.0.windows.2

.gitignoreGit 文档中被称为忽略文件。在Unity项目中主要是为了让 Git 忽略 Unity 自动生成的各种项目中间文件, 忽略文件内容其规范如下:

  • 所有空行或者以 # 开头的行都会被 Git 忽略, 它表示注释行。
  • 可以使用标准的 glob 模式匹配,它会递归地应用在整个工作区中。
  • 匹配模式可以以 / 开头防止递归。
  • 匹配模式可以以 / 结尾指定目录。
  • 要忽略指定模式以外的文件或目录,可以在模式前加上感叹号(!)取反。

其内容示例可见 Git 中文文档, 针对常见语言/工程的通用 .gitignore 文件, 这个叫 gitignore 的 Github 项目中均有收录。由于本文关注的是 Unity 项目, 你可以使用 gitignore 项目中的 Unity .gitignore 模板 (存档) 来过滤掉绝大部分 Unity 自动生成的文件。如果你在Windows 上开发 Unity 项目, 那么还需要在这个.gitignore 模板文件中额外添加以下几行:

# Windows 缩略图缓存文件
Thumbs.db
Thumbs.db:encryptable
ehthumbs.db
ehthumbs_vista.db

# 转储文件
*.stackdump

# 桌面配置文件
[Dd]esktop.ini

# 隐藏的回收站目录
$RECYCLE.BIN/

# Windows 安装包文件
*.cab
*.msi
*.msix
*.msm
*.msp

# Windows 快捷方式文件
*.lnk

而 Mac OS 上的 Unity 项目则应该在这个模板中附上这几行:

# 通用缓存文件
.DS_Store
.AppleDouble
.LSOverride

# 图标, 后面带两个 \r
Icon

# 缩略图
._*

# 卷根目录中会出现的各种文件
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent

# AFP文件共享会生成的东西
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk

改好 .gitignore 之后将其保存在项目根目录中 (.git 目录旁边也行) 即可被 Git 识别。

第二步是在Unity中更改项目设置以适配版本控制功能, 此处以 Unity 6 LTS(6000.0.27f1)为例。要注意的是, 这里的版本控制并不是指下图中的 Unity Version Control 服务。

Unity Hub中的Unity Version Control服务, 是一种收费的在线服务, 并不处于本文的讨论范围内

首先在 Edit > Project Settings中找到 Version Control 页, 将 Mode 选项改为 Visible Meta Files, 目的是让 .meta 文件在目录中不被隐藏。

.meta文件的可见性设置

然后在 Editor 页中将资产序列化(Asset Serialization)的 Mode 选项置为 Force Text, 以确保资产使用文本方式序列化资产。

纯文本资产序列化设置

最后保存项目设置即可。


解决大文件存储问题

Git Large File Storage 的官网上, 对 Git-LFS 的定义是 “一种用于对大文件进行版本控制的开源 Git 扩展”。如果你系统中的 Git 是某种版本控制软件自带或者是你从源码自己构建的, 那可能并没有内置该扩展而需要单独安装。安装 Git-LFS 的步骤可见官网首页, GitHub 上也有一个简短的安装指南, 安装完成后用以下命令可验证 Git-LFS 的安装:

❯ git lfs --version
git-lfs/3.5.1 (GitHub; windows amd64; go 1.21.7; git e237bb3a)

虽然网上随处可见的教程会告诉你使用这行

> git lfs track '*.bin'

来追踪项目中全部后缀名为 .bin 的文件, 然后可以用无参数的 git lfs track 直接列出 Git-LFS 跟踪的模式列表。

❯ git lfs track
Listing tracked patterns
    *.jpg (.gitattributes)
    *.gif (.gitattributes)
    *.zip (.gitattributes)
    *.bin (.gitattributes)
Listing excluded patterns

但事实上 Git-LFS 会用一个叫做 .gitattributes 的文件来跟踪项目中的大文件, git-lfs track 指令只是将你指定的匹配模式加上 filter=lfs diff=lfs merge=lfs -text 参数, 在拼成一行后写入该文件而已, 所以我们大可无视 Git-LFS 的 track/untrack 命令直接手动修改 .gitattributes 文件。

为了实现省心的项目配置, 便于在项目中直接部署 .gitattributes 文件, 在此总结了一个Unity项目常用的 .gitattributes 文件作以基本模板:

# 换行符, 即 CR(Carriage-Return, 回车) 和 LF(Line-Feed, 换行)
# DOS 和 Windows 采用回车+换行, CRLF 来表示下一行
# UNIX/Linux 采用换行符 LF 表示下一行,
# MAC OSX 也采用换行符 LF 表示下一行。
# CR 用符号 \r 表示, LF 使用 \n 符号表示
# Git 中提供了一个 core.autocrlf 设置, 它是 Git 中负责处理行尾的变量
# 如此便可启用之:
# $git config --global core.autocrlf true
# 这时将文件添加到 Git 仓库(即提交操作)时, Git会将其视为文本文件, 它将把CRLF改成LF
# 当然这功能关掉也行:
# $git config --global core.autocrlf false
# 如果设置为 input 会导致 Git 将治下的文件所有行尾均改为 LF:
# $git config --global core.autocrlf input
# 若要在该文件中强制指定某个或某类文件的换行符, 则可以用 eol 属性指定, 可选值为 crlf 和 lf
# *.txt text eol=lf


# 自动标准化换行符
* text=auto

# Unity文件
*.cs diff=csharp text
*.cginc text
*.shader text

*.mat -text merge=unityyamlmerge diff
*.anim -text merge=unityyamlmerge diff
*.unity -text merge=unityyamlmerge diff
*.prefab -text merge=unityyamlmerge diff
*.physicsMaterial2D -text merge=unityyamlmerge diff
*.physicMaterial -text merge=unityyamlmerge diff
*.asset -text merge=unityyamlmerge diff
*.meta -text merge=unityyamlmerge diff
*.controller -text merge=unityyamlmerge diff

# 3D模型
*.3dm filter=lfs diff=lfs merge=lfs -text
*.3ds filter=lfs diff=lfs merge=lfs -text
*.blend filter=lfs diff=lfs merge=lfs -text
*.c4d filter=lfs diff=lfs merge=lfs -text
*.collada filter=lfs diff=lfs merge=lfs -text
*.dae filter=lfs diff=lfs merge=lfs -text
*.dxf filter=lfs diff=lfs merge=lfs -text
*.fbx filter=lfs diff=lfs merge=lfs -text
*.jas filter=lfs diff=lfs merge=lfs -text
*.lws filter=lfs diff=lfs merge=lfs -text
*.lxo filter=lfs diff=lfs merge=lfs -text
*.ma filter=lfs diff=lfs merge=lfs -text
*.max filter=lfs diff=lfs merge=lfs -text
*.mb filter=lfs diff=lfs merge=lfs -text
*.obj filter=lfs diff=lfs merge=lfs -text
*.ply filter=lfs diff=lfs merge=lfs -text
*.skp filter=lfs diff=lfs merge=lfs -text
*.stl filter=lfs diff=lfs merge=lfs -text
*.ztl filter=lfs diff=lfs merge=lfs -text

# 音频文件
*.aif filter=lfs diff=lfs merge=lfs -text
*.aiff filter=lfs diff=lfs merge=lfs -text
*.it filter=lfs diff=lfs merge=lfs -text
*.mod filter=lfs diff=lfs merge=lfs -text
*.mp3 filter=lfs diff=lfs merge=lfs -text
*.ogg filter=lfs diff=lfs merge=lfs -text
*.s3m filter=lfs diff=lfs merge=lfs -text
*.wav filter=lfs diff=lfs merge=lfs -text
*.xm filter=lfs diff=lfs merge=lfs -text

# 字体文件
*.otf filter=lfs diff=lfs merge=lfs -text
*.ttf filter=lfs diff=lfs merge=lfs -text

# 图像文件
*.bmp filter=lfs diff=lfs merge=lfs -text
*.exr filter=lfs diff=lfs merge=lfs -text
*.gif filter=lfs diff=lfs merge=lfs -text
*.hdr filter=lfs diff=lfs merge=lfs -text
*.iff filter=lfs diff=lfs merge=lfs -text
*.jpeg filter=lfs diff=lfs merge=lfs -text
*.jpg filter=lfs diff=lfs merge=lfs -text
*.pict filter=lfs diff=lfs merge=lfs -text
*.png filter=lfs diff=lfs merge=lfs -text
*.psd filter=lfs diff=lfs merge=lfs -text
*.tga filter=lfs diff=lfs merge=lfs -text
*.tif filter=lfs diff=lfs merge=lfs -text
*.tiff filter=lfs diff=lfs merge=lfs -text

如果使用 GitHub 作为 Unity 项目的远程仓库, 那么可以在 Unity 相关文件行尾加上一个 linguist-generated=true告诉 GitHub 这是自动生成的文件, 不应纳入语言统计:

*.asset linguist-generated
*.mat linguist-generated
*.meta linguist-generated
*.prefab linguist-generated
*.unity linguist-generated

不过截至 2025-04-27, GitHub 免费帐户会有1G的LFS容量和带宽限制。当然, 每 $5 可以叠加 50 GiB/月的带宽和 50 GiB/月的存储空间(充钱你就能变得更强

此处推荐安装 git-for-unity 插件, 它能在 Unity 编辑器中以 GUI 插件的形式直接实现 pull/push/commit 等操作, 颇为便利且支持多种私有存储库, 其指南在此处可供细读。GitHub 用户 nemotoo 也提供了一份Unity项目的 .gitattributes, 他加入了对 Shader 和控制器等文件类型的配置, 在你配置自己的项目时可供参考。

最后, 和 .gitignore 一样, 改好 .gitattributes 之后放在项目根目录中即可生效(PS: 不同目录层级可以存在多个 .gitattributes 文件, Git 会按照类似最长匹配原则的规律确定配置的优先级)。

此外, Git-LFS还有几个值得一提的基本功能:

  • 使用 git lfs ls-files 可以列出 git-LFS 正在追踪的文件列表:
❯ git lfs ls-files
194159bea5 * 5a4c7c05b313d.pdf
306edf2e90 * 1561590177000_xg_0.jpg
2f6d7fc6a0 * 4e9730d3b8e22e8a.jpg
  • 上传比较慢的话需要适当延长TCP读写的超时限制, 也可以把它设为0来关闭超时机制:
# 默认30秒, 延长为60秒
git config lfs.activitytimeout 60
  • 使用 git lfs untrack 可以取消对某种文件的跟踪, 随后再用 git rm --cached 可将其从暂存区中删除:
> git lfs untrack "*.pdf"
> git rm --cached "*.pdf"

如果将它们从 LFS 中移除后还想使用普通地用 Git 管理这些文件, 那么再 git add 它们一次即可。

  • 对于已经用 Git 管理工作目录中项目文件的存储库, 仅仅是添加文件跟踪指令并不会将这个文件转换为LFS对象。它们已经存在于 Git 历史记录中, 把现有文件转换为LFS对象的唯一方法是重写历史记录。此时用官方提供的 git lfs migrate 命令便可将所需的文件迁移到LFS上:
> git lfs migrate import --include="*.mp4"

指定转换多个分支并且强行推送到远程仓库也是可以的, 只要你权限足够:

# 'refs/heads/master'表示master分支, 'refs/heads/new-added-feature'表示new-added-feature, 意即将这两个分支上的所有后缀名为mp4的文件都迁移到LFS上去
> git lfs migrate import --include="*.mp4" --include-ref=refs/heads/master --include-ref=refs/heads/new-added-feature

# 强行推送
> git push --force

# 清理旧的、不再可访问的引用日志(reflog), 仅影响引用日志条目, 而不直接删除对象文件
> git reflog expire --expire-unreachable=now --all

# 执行垃圾回收操作,清理存储库中的不必要的文件和对象, 移除不可达的对象, 合并拆分的包文件
> git gc --prune=now
Enumerating objects: 29244, done.
Counting objects: 100% (29244/29244), done.
Compressing objects: 100% (20328/20328), done.
Writing objects: 100% (29244/29244), done.
Total 29244 (delta 9370), reused 23111 (delta 8585), pack-reused 0 (from 0)

# 删除不再需要的 LFS 对象(文件),这些对象包括已被替换或在当前检出分支不可达的 LFS 对象, 仅适用于 Git LFS 管理的文件
> git lfs prune
prune: 2505 local objects, 2503 retained, done.
prune: Deleting objects: 100% (2/2), done.

如果你只是删除暂存区中的文件再重新添加并提交, 那么历史记录中的 .mp4 文件依然以 blob 方式存储, 这种更改方式只会在最近一次提交中将这些 .mp4 作为 LFS 对象管理起来:

# 追踪 mp4 文件
> git lfs track '*.mp4'
> git add .gitattributes
> git commit -m "添加 .mp4 文件追踪"

# 删除暂存区中的 mp4 文件, 并重新作为 LFS 对象添加并提交
> git rm --cached *.mp4
> git add *.mp4
> git commit -m "转为 LFS"

# 会发现命令显示文件被加入LFS对象列表了
> git lfs ls-files

因此建议使用 git lfs migrate 命令来将已有的 Git 项目迁移到 LFS。

  • LFS还有个较为实用的文件锁定功能, 只要在添加跟踪的时候告诉LFS文件可锁定:
> git lfs track "*.pdf" --lockable

此后便能用 git lfs lock 锁定文件, 以防止其他人更改它。这样就能使 Git 支持独占式文件更改, 虽然这有些不够 Git, 不过这功能对美术和设计师颇为有用:

> git lfs lock 'wow meow.pdf'

这时可以输入 git lfs locks 查看锁定文件列表:

> git lfs locks
wow meow.pdf     laoliu     ID:250
assets/porn.pdf  xiaocheng  ID:768

随时可以用 git lfs unlock 解除锁定, 当然这一切都是建立在你的 Git 服务器支持 verifylock API 和权限足够这两个基础之上的:

> git lfs unlock 'wow meow.pdf'

GitHub 上有一个分享了针对 Unity 项目中 .gitignore.gitattributes 文件的项目 FrankNine/RepoConfig, 作者在 .gitignore.gitattributes 文件加入了不少文件类型, 他在博客中自述统一了 CRLF 问题, 并提供了VS可用的 .editorconfig 文件, 此外还添加了对Wwise插件的支持等功能, 博客中也聊到了他在实践 Git with Unity 后的一些经验和思考, 不妨在配置你自己的项目时用以参考。这是另一个用户bitinn分享的配置: bitinn/.a-unity-git-config.md, 同样可供参考。


解决合并冲突和引用丢失问题

最后是最棘手的问题, 项目文件的合并冲突和合并后的资产引用丢失。在 Windows 上使用 Git 时, YAML 格式的场景文件, 部分是二进制而部分文本内容的 .assets 文件和时不时变更的 LocalIDGUID 让人痛苦万分。为了解决这个问题, Unity在 Unity 5的初期就引入了 UnityYAMLMerge 工具来应对文件引用和内容冲突相互混合的复杂合并场景。

Unity 文档中的描述中, UnityYAMLMerge 工具(又称Smart Merge, 智能合并工具) 主要是用来合并场景(scene)文件和 prefab 文件中的更改, 它随 Unity 编辑器一起提供, 即 Unity 编辑器自带智能合并工具:

…\Unity\Editor\{Unity编辑器版本号}\Data\Tools\ 目录下的 UnityYAMLMerge 工具

它一般都在Unity编辑器的 \Data\Tools 目录下, 如果你的 Unity 6 LTS 在 Windows 安装中用的是默认路径, 那么该工具会在这个位置:

C:\Program Files\Unity\Hub\Editor\6000.0.27f1\Editor\Data\Tools\UnityYAMLMerge.exe

Mac OS 的话则通常在:

/Applications/Unity/Unity.app/Contents/Tools/UnityYAMLMerge

要把它用起来的做法很简单, 需要做的就是告诉 Git 如果指定的文件遇到更改需要合并时, 应该以何种参数调用何处的 UnityYAMLMerge 工具来对比变更, 执行合并操作。不难想到, 编辑 Git 配置文件让 Git 去通知 UnityYAMLMerge 就是解决之道。修改项目中的 .git/config 或在全局 Git 配置文件 .gitconfig 均可实现工具间的链接, Windows 下的全局 Git 配置文件 .gitconfig 位置可在 Powershell 中使用 [System.Environment]::GetFolderPath("UserProfile") 来确定:

# 此处假定 Windows 用户名为 Administrator
> [System.Environment]::GetFolderPath("UserProfile")
C:\Users\Administrator

该命令会输出 $UserProfile 的目录, 所以示例中的 .gitconfig 位置就是 C:\Users\Administrator\.gitconfig 了。 根据官方指南, 需要在配置文件末尾加上这一段:

[merge]
    tool = unityyamlmerge

[mergetool "unityyamlmerge"]
    trustExitCode = false
    # 本例中Unity 6 LTS 的路径是 C:\Program Files\Unity\Hub\Editor\6000.0.27f1\Editor\Data\Tools\UnityYAMLMerge.exe
    # 所以这行命令会是这样:
    # cmd = 'C:\\Program Files\\Unity\\Hub\\Editor\\6000.0.27f1\\Editor\\Data\\Tools\\UnityYAMLMerge.exe' merge -p "$BASE" "$REMOTE" "$LOCAL" "$MERGED"
    cmd = '<UnityYAMLMerge的绝对路径>' merge -p "$BASE" "$REMOTE" "$LOCAL" "$MERGED"

就能正确调用了 UnityYAMLMerge 了。为便于修改, 我写了一段小脚本, 用来自动添加这段配置。不过 .git 目录的位置不可捉摸你得自行设置项目路径, 这段脚本默认是向全局 Git 配置文件 .gitconfig 中注册 UnityYAMLMerge 工具:

# 设置 UnityYAMLMerge 工具的绝对路径, 路径中的分隔符\必须经过转义, 因此需替换为双斜杠\\, 否则Git配置文件将无法识别
# 示例:
# $unityYAMLMergePath = 'C:\\Program Files\\Unity\\Hub\\Editor\\6000.0.27f1\\Editor\\Data\\Tools\\UnityYAMLMerge.exe'
$unityYAMLMergePath = '<请填入你的UnityYAMLMerge工具的绝对路径后再执行本脚本, 无需保留尖括号>'

# 选择是写入全局配置或是仓库配置 
$useGlobalConfig = $true # 将其设置为 $false 来使用仓库 Git 配置

# 设置 Git 仓库路径
$repoPath = '<请填入你的Unity项目路径>'  
$repoConfigPath = "$repoPath\.git\config"


# 获取用户主目录
$userHome = [System.Environment]::GetFolderPath("UserProfile")
# 定义 .gitconfig 文件路径
$gitConfigPath = "$userHome\.gitconfig"

# 判断配置写入位置
if (-not $useGlobalConfig) 
{ 
    $configPath = $repoConfigPath 
}
else
{
    $configPath = $gitConfigPath
}

# 向 Git 注册 UnityYAMLMerge 工具
$mergeToolConfig = @"
[merge]
    tool = unityyamlmerge

[mergetool "unityyamlmerge"]
    trustExitCode = false
    cmd = '$unityYAMLMergePath' merge -p `"`$BASE`" `"`$REMOTE`" `"`$LOCAL`" `"`$MERGED`"
"@

# 将内容追加到配置文件
Add-Content -Path $configPath -Value $mergeToolConfig
# 输出提示
Write-Host "Unity智能合并配置已添加到 $configPath 文件"

将以上代码保存成一个 .ps1 文件, 改好工具路径再执行一次此脚本(多次执行会添加多次, 此时请手动删除多余配置)即可将 UnityYAMLMerge 注册到 Git。这时可以通过 git config --list 命令可验证配置文件是否正确配置:

❯ git config --list
......
branch.main.merge=refs/heads/main
branch.main.vscode-merge-base=origin/main
lfs.https://<私有GitLab地址打码>/git-unity-test.git/info/lfs.access=basic
lfs.repositoryformatversion=0
merge.tool=unityyamlmerge
mergetool.unityyamlmerge.trustexitcode=false
mergetool.unityyamlmerge.cmd='C:\Program Files\Unity\Hub\Editor\6000.0.27f1\Editor\Data\Tools\UnityYAMLMerge.exe' merge -p $BASE $REMOTE $LOCAL $MERGED

下一步是告诉 UnityYAMLMerge 在哪些文件冲突时需要介入, 不过我们已经配置了这些内容。前文提到的 .gitattributes 文件中, 这一段内容就是在告诉 Git, 文件中列出的 .meta.unity.prefab 等文件并非文本文件, 所以当需要合并时, 应该调用 UnityYAMLMerge 工具来解析文件, 向用户展示冲突内容:

*.mat -text merge=unityyamlmerge diff
*.anim -text merge=unityyamlmerge diff
*.unity -text merge=unityyamlmerge diff
*.prefab -text merge=unityyamlmerge diff
*.physicsMaterial2D -text merge=unityyamlmerge diff
*.physicMaterial -text merge=unityyamlmerge diff
*.asset -text merge=unityyamlmerge diff
*.meta -text merge=unityyamlmerge diff
*.controller -text merge=unityyamlmerge diff

这时可以用 git check-attr 检查在 .gitattributes 中是否正确设置了 Git 的 merge / text / lfs 等属性:

❯ git check-attr -a .jpg
.jpg: diff: lfs
.jpg: merge: lfs
.jpg: text: unset
.jpg: filter: lfs

❯ git check-attr -a .unity
.unity: diff: set
.unity: merge: unityyamlmerge
.unity: text: unset

到这个程度基本上已经可以正常使用了, 但有时导入某些包之后会引入一些从未配置过的文件类型, 这会导致 UnityYAMLMerge 陷入茫然不知该如何处理, 随之就会报错:

截图来自Unity官方论坛中的一个求助提问

所以 UnityYAMLMerge 工具的官方文档中提到, 他们为此提供了 mergespecfile.txt 文件来配置后备的更改合并器:

UnityYAMLMerge is shipped with a default fallback file (called mergespecfile.txt, also in the Tools folder) that specifies how it should proceed with unresolved conflicts or unknown files. This also allows you to use it as the main merge tool for version control systems (such as git) that don’t automatically select merge tools based on file extensions. The most common tools are already listed by default in mergespecfile.txt but you can edit this file to add new tools or change options.

mergespecfile.txt 就放在 UnityYAMLMerge 的同目录中, 其主要内容如下:

#
# UnityYAMLMerge fallback file
#
......
# 这是.unity和.prefab的后备合并工具配置
unity use "%programs%\YouFallbackMergeToolForScenesHere.exe" "%l" "%r" "%b" "%d"
prefab use "%programs%\YouFallbackMergeToolForPrefabsHere.exe" "%l" "%r" "%b" "%d"

#
# Default fallbacks for unknown files. First tool found is used.
#

# 以下是遇到没见过的文件时的后备合并工具配置

# Apple File Merge
* use "/usr/bin/opendiff" %r %l -ancestor %b -merge %d

......

# Beyond Compare
* use "%programs%\Beyond Compare 4\bcomp.exe" "%r" "%l" "%b" "%d"

......

# Perforce merge
* use "%programs%\Perforce\p4merge.exe" "%b" "%r" "%l" "%d"
* use "%programs%/p4merge.app/Contents/Resources/launchp4merge" "%b" "%r" "%l" "%d"

......

在其中可以指定合并/合并工具读取文件失败之后的后备工具地址, 例如这行 unity use "%programs%\YouFallbackMergeToolForScenesHere.exe" "%l" "%r" "%b" "%d" 就是用于指定场景文件的后备工具的, 其中几个参数的含义请自行阅读原文件注释。

mergespecfile.txt 文件中的工具配置顺序是较为重要的, 因为 UnityYAMLMerge 会按这个配置文件中的顺序, 一个一个地尝试访问并启动后备合并工具。若是放了太多无关的后备合并工具配置条目, 会让每次合并都卡一小会儿(其他工具没装的话它找不到嘛)。其他工具我太不了解, 不过 P4M (Perforce merge, 官方叫 Helix Visual Merge Tool) 是来自Unity官方集成的工具 Perforce (在Unity的版本控制设置中配置了使用Perforce的话, 它其实也是自动填写配置来调用 UnityYAMLMerge, 省心但价格感人), 可靠性和成熟度比较高。此外, P4M 是免费软件, 作为后备合并工具算是上佳之选。下载 P4M 并使用默认路径安装, 之后删掉 mergespecfile.txt 中多余的配置条目(其实不删也行, 依旧能用)后保存一下, Git for Unity 的全部配置就算完成啦 (撒花)。

搞定配置の香蕉微笑
高清の搞定配置の微笑

总的来说, 处理Unity项目文件合并不是很难, 但是合并冲突了的话则非常麻烦。毕竟冲突内容到底在资产( .asset 之类的文件)和场景中如何呈现是很不直观的, 同时这些 YAML 文件中的合并冲突基本无法阅读, 导致手动解除这些冲突非常非常困难。因此在使用 Git 协作时, 原则上就应该避免合并冲突, 避免场景文件中的合并冲突有两种常见方法:

  1. 将大的场景分解为 Prefab
  2. 使用叠加式场景加载

第一种做法很好理解, 将场景分成很多个小区域塞进 prefab, 每个人都在自己的 prefab 中工作, 场景只是引用了来自多个开发者的 prefab 罢了, 益处是非常直观且易于理解, 不会带来什么协作负担。

而第二种做法与 Prefab 分解法类似, 但不同之处在于, 不是将主场景分解为数个 Prefab 而是多个小的场景, 每个人都在自己的小场景中工作, 以此隔离每个人的更改, 最后在主场景中用 SceneManager.LoadScene(sceneName, LoadSceneMode.Additive); 的方式叠加显示多个场景来实现场景组合效果。这样做虽然避免了 Prefab 嵌套的问题, 但如果叠加了太多场景显然会导致性能表现下降。JohannesMP 在 GitHub 上分享了他的 unity-scene-reference 项目, 它通过在场景管理器中引用各个序列化后的小场景实现了叠加加载, 他还给这个功能加了上了便于配置的 GUI, 很酷。其大致用法就是在主场景里放个空的 GameObject 然后挂上 sceneLoader.cs (它依赖于 SceneReference.cs), 然后在 Unity 检查器里配置需要加载的场景就行了。因为距这两个文件的最近更新也是4年有余, 代码里主要是基于 IMGUI 来定制检查器, 已经不再符合当前版本的最佳实践, 等有契机也许我会去把它重写为 Unity UI 版本。顺便一提, 若是使用 ECS 范式进行开发, 可以使用子场景功能来叠加场景: UnityEditor.SceneManagement.EditorSceneManager.OpenScene(theSubScene.EditableScenePath, UnityEditor.SceneManagement.OpenSceneMode.Additive);

关于叠加式场景加载, Unity 论坛上有不少既往讨论, 人们纷纷集思广益拿出来不少方案:

Unity 的 Addressable AssetNavMesh 会生成二进制文件便于加载, 光照数据和地形数据一般也应被视为二进制内容。值得单独一提的是 TextMesh Pro 的字体文件和 ProBuilder 导出的网格, 这两种内容比较特殊, 因为前者在 YAML 中编码了二进制数据, 而后者则把网格编码成了二进制数据。所以要么把以上文件均视为二进制文件一股脑塞进 LFS, 不要去试图合并, 要么按照文件内容组织目录结构来具体细化配置, 以便适配项目需求(意即: 请自行寻找出路), 由此可见 .asset 类的文件并无较好的银弹配置可供参考。

正篇完结


Bonus: RE:COMMIT 从对象开始的GIT-LFS理解

众所周知, Git 在本地计算机上主要是通过

  • 工作区 (工作目录, 又称工作树或 Working Tree 或 Work Space, 项目目录下的工程文件们)
  • 暂存区 (INDEX, 又名 Stage 或索引, 是 .git 目录下的 index 文件) 和
  • 本地仓库 (Local Repository, 即 .git 目录中的其他文件)

间的交互来实现本地版本控制的。

基于常用的高层(porcelain) Git 操作命令构成的Git工作流
图片引自Oliver SteeleMy Git Workflow

当在一个目录下执行 git init 后, Git 会新建一个 .git 目录, 这个目录包含了 Git 所有会存储和操作的对象:

❯ ls


    Directory: D:\git-lfs-test\.git


Mode                 LastWriteTime         Length Name
----                 -------------         ------ ----
d-----        2024-11-16      5:16                hooks
d-----        2024-11-19      7:57                info
d-----        2024-11-16      5:21                lfs
d-----        2024-11-19      7:58                logs
d-----        2024-11-19     10:33                objects
d-----        2024-11-16      5:16                refs
-a----        2024-11-19      7:46             17 COMMIT_EDITMSG
-a----        2024-11-19     10:32            720 config
-a----        2024-11-16      5:13             73 description
-a----        2024-11-19     10:33            109 FETCH_HEAD
-a----        2024-11-16      5:16             21 HEAD
-a----        2024-11-19     10:33           9156 index
-a----        2024-11-19     10:29             41 ORIG_HEAD
-a----        2024-11-19      7:54            169 packed-refs

config 文件保存了针对这个项目的 Git 配置; info 目录包含的是排除(exclude)文件, 可以被视作 .gitignore 文件补充; hooks 目录包含的则是钩子脚本 (hook scripts, 在特定的动作触发后执行的自定义脚本); objects 目录会存储所有数据内容, 其中 objects/pack 中存储着 git gc 生成的打包文件, objects/info 则维护着这些打包文件的信息; refs 目录存储指向数据(分支)的提交对象的指针; HEAD 文件标示目前被检出(checkout)的分支; index 文件保存则暂存区信息。

《Pro Git》中提到: “从根本上来讲, Git 是一个内容寻址(content-addressable)文件系统”, “内容寻址文件系统” 这个概念的含义是: Git 的核心是一个可以将任意数据保存进 .git 目录中并返回其储存键值, 还可以稍后靠着这个键值(它是一个长度为40的 SHA-1 哈希值)随时再次检索出(retrieve)其已存入内容的键值对数据库(key-value data store), 剩下的其他部分不过是用以交互的 UI (即 Git 命令行 CLI), 原书中以 git hash-objectgit cat-file 命令演示了如何直接存取其中的内容:

# 向工作区的 test.txt 中 写入 "test content" 这行文本
> echo 'test content' > test.txt
# 存入键值对数据库, Git 会返回 SHA-1 哈希值 d670460b4b4aece5915caf5c68d12f560a9fe3e4
> git hash-object -w test.txt
d670460b4b4aece5915caf5c68d12f560a9fe3e4

# 查看存储在何处, 会发现 SHA-1 校验和前2位是子目录名称, 后38位就是文件名
# 输出的目录 /d6/70460b4b4aece5915caf5c68d12f560a9fe3e4 中拼起来正好就是d670460b4b4aece5915caf5c68d12f560a9fe3e4
❯ tree /F .\.git\objects\d6\
Folder PATH listing for volume OS
Volume serial number is 000000E9 8060:B89F
D:\GIT-LFS-TEST\.GIT\OBJECTS\D6
    70460b4b4aece5915caf5c68d12f560a9fe3e4

# 取出 SHA-1 为 d670460b4b4aece5915caf5c68d12f560a9fe3e4 对象中的数据, 其内容为 test content
> git cat-file -p d670460b4b4aece5915caf5c68d12f560a9fe3e4
test content

而给定数据对象(blob)的 SHA-1 值也能用 git cat-file 来查看 Git 内存储的对象类型:

> git cat-file -t d670460b4b4aece5915caf5c68d12f560a9fe3e4
blob

由于 Git 将一切都存储为对象, 那么用来存储其他信息的容器也是对象。Git 用树对象(tree object)储存目录结构, 而基于树对象的提交对象(commit object)则用来记录每次提交:

# bash 下可以直接用 git cat-file -p master^{tree}, 但 powershell 得使用`转义{和}
❯ git cat-file -p main^`{tree`}
100644 blob 9e0bd4e395b7f6410610f9a6e75d1b3fd1feba54    5a4c7c05b313d.gif
100644 blob 94b05d85960fae05f780e6ee29be8b3d191f752c    A320 FCOM.pdf
040000 tree 40c3d020911f726c1bd28f7704996d7da06ff203    GitTestProject
100644 blob 1965c032d92f5f12cebc71c7192814874c8359fa    "HTTP.pdf"
100644 blob f606c21e8aba23facc8c85abe0a9b77243d2bd36    README.md

树对象中包含着指向其他树对象的指针, 以此建立一个树形目录关系, 因此它很像 Unix 中目录项的概念:

❯ git cat-file -p 40c3d020911f726c1bd28f7704996d7da06ff203
100644 blob 64b10148bac7a25f856fd1d29531638313fb5fd0    .gitattributes
100644 blob 31a171ff66872450ba4e03ec580561443c46cc91    .gitignore
040000 tree 852d3c12a25108163ff73458d3bd58760612d67f    .vscode
040000 tree 2611ebc6e783a137d55592ad6cdb48f9f6c3d6ab    Assets
040000 tree 563c5e47850549b283447f34206078be3628b2bb    Packages
040000 tree 9117485c0c9c45574b12d4bb8156cea9f4b5e730    ProjectSettings

❯ git cat-file -t 2611ebc6e783a137d55592ad6cdb48f9f6c3d6ab
tree

Git通过暂存区记录的状态来创建多个树对象, 以此解决文件名保存的问题, 也使其能够组织多个文件。而保存每次快照, 维护每次提交信息的对象则是提交对象。我们从 git log 中就能轻易找到这些提交对象的ID:

❯ git log main
commit a4194a1379a02c5ab8f0ae1d2c38bc3a836b13e8 (HEAD -> main)
Author: wiige <提交邮箱打码>
Date:   Tue Nov 19 07:43:48 2024 +0800

    Changes to be committed:
            modified:   GitTestProject/.gitattributes

commit c9e76ba2cb4a046bd5a965e4c8ce232f75d36cfa
Author: wiige <提交邮箱打码>
Date:   Tue Nov 19 06:15:31 2024 +0800

    re- commit files
......

# 格式化的 log 输出
❯ git log --pretty=oneline  main
a4194a1379a02c5ab8f0ae1d2c38bc3a836b13e8 (HEAD -> main) Changes to be committed:        modified:   GitTestProject/.gitattributes
c9e76ba2cb4a046bd5a965e4c8ce232f75d36cfa re- commit files
19b1a39afdfc48de91ebedee618f11caa9207d08 clean INDEX
dc04b2ca08f6968b8abc0305e9d0263d141c78d5 git change
215b31014f80c42153be45324197c20de43617b1 git change
d477b73f104afb6be3861b08daeaef4504520ead gitignore 更新

使用 git cat-file 同样能查看这些提交对象:

❯ git cat-file -p 29efecab7c64630da2cc28598a58defaff62abd7
tree c93880dabde37a0dae6b7bad2b467e2cb0b8273b
parent 0206b642637796fdaf8cdc0a59740ac5a8917979
parent 4b931b50619bfa50709ebeadb260ecd7f244574f
author wiige <提交邮箱打码> 1731529328 +0800
committer wiige <提交邮箱打码> 1731529328 +0800
gpgsig -----BEGIN PGP SIGNATURE-----

 <GPG签名内容打码, 显示这一段是因为我设置了本地提交时候使用GPG签名>
 <GPG签名内容打码, 显示这一段是因为我设置了本地提交时候使用GPG签名>
 <GPG签名内容打码, 显示这一段是因为我设置了本地提交时候使用GPG签名>
 <GPG签名内容打码, 显示这一段是因为我设置了本地提交时候使用GPG签名>
 <GPG签名内容打码, 显示这一段是因为我设置了本地提交时候使用GPG签名>
 -----END PGP SIGNATURE-----

Merge branch 'junk feature' into main

可以看到这就是提交对象的全部内容: 第一行记录一个顶层树对象(即 c93880dabde37a0dae6b7bad2b467e2cb0b8273b), 代表当前项目的快照; 第二三行是两个父提交对象(0206b642637796fdaf8cdc0a59740ac5a89179794b931b50619bfa50709ebeadb260ecd7f244574f), 因为示例中的提交是一次分支合并, 因此包含两个父对象, 正常的文件更改应该只有一行父对象引用, 如果是初始提交则不会有父对象记录; 第四五行是作者/提交者信息, 紧跟着的是好几行GPG签名, 签名用于验证提交者是否可信; 最后一行则是简单的提交注释。因此 Git 在提交时所做的实质工作, 就是将被改动的文件保存为数据对象, 更新暂存区, 然后记录树对象, 最后创建一个保存了顶层树对象和父提交对象的提交对象, Git 再将数据对象、树对象和提交对象以文件的形式保存在 .git/objects 目录下

但如若每次 Git 操作都需要各种对象ID, 必然会导致用户狂化, 这样做既不利于记忆也不便于使用, 因此 Git 使用了引用(references, 缩写为 refs, 存于 .git/refs 中)来存储各种 SHA-1 值。

有了这个基础, 便能够理解 Scott Chacon 在《Pro Git》Git 内部原理一章中指出的——Git 分支的本质——分支本质是一个指向某一系列提交之首的指针或引用。许多文章或讲座花篇幅阐述的行为多变的 HEAD 指针, 也变得简单易懂了起来——它并非是一个包含 SHA-1 值的引用, 而是一个指向其他引用的指针——一个符号引用(symbolic reference), 它会在 git checkout 后指向新的分支引用:

❯ cat .git/HEAD
ref: refs/heads/main

> git checkout newbranch

❯ cat .git/HEAD
ref: refs/heads/newbranch

git commit 后会创建一个提交对象, Git 会用 HEAD 保存的那个引用所指向的 SHA-1 值来设置这次提交的父提交字段。基于此, 常用的标签功能的本质也昭然若揭: git tag 会创建一个标签对象(tag object), 记录标签和注释信息并指向一个提交对象。简而言之, 标签就是一个提交对象的易读别名。

得益于 Git 三个分区的设计, 从对象存储和引用的思路去理解高层指令, 便不再需要像下面这种表格那样死记硬背命令了:

命令含义
git diff工作区 对比 暂存区
git diff head工作区 对比 版本库
git diff --cached暂存区 对比 版本库
git reset --soft暂存区 覆盖 工作区
git reset --mixed版本库 覆盖 暂存区
git reset --hard版本库 覆盖 暂存区 覆盖 工作区
网上随意截取的一张 Git 命令速查表

而 Git LFS 在存储对象上则不太一样, Git LFS 并不会将完整的文件属性和内容进行计算写入暂存区, 而是会生成一个Git LFS 指针文件, 该文件指向了真正的 LFS 存储对象名称。在跟踪并向 LFS 提交了一个 .docx 文件后, 可以执行 git show HEAD 命令查看变更内容, 也就是这个 Game Design Document Template.docx 文件指针中的内容:

❯ git show HEAD
commit 7c2e97bc8cc94193bb175fad1d8121e8a8637afa (HEAD -> main)
Author: wiige <提交邮箱打码>
Date:   Thu Nov 11 03:32:59 2024 +0800

    add DOCX to LFS

diff --git a/Game Design Document Template.docx b/Game Design Document Template.docx
new file mode 100644
index 00000000..4794033a
--- /dev/null
+++ b/Game Design Document Template.docx
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:ec3af6e466684277310ede1f41d5a7eaac8cc8a69c93031bee2da0ad19c23087
+size 129180

@@ -0,0 +1,3 @@ 表示在原文件中, 删除操作在第0行开始且没有任何后续行(因为是创建新文件), 而添加的内容从第 1 行开始, 添加了 3 行。其中高亮的三行指针内容表示:

  • version https://git-lfs.github.com/spec/v1 : 代表 git-lfs 协议的版本
    • version…v1: 代表当前 git-lfs 服务端使用的协议版本
  • oid sha256:ec3af6e466684277310ede1f41d5a7eaac8cc8a69c93031bee2da0ad19c23087
    • oid: 即 Git LFS 对象id
    • sha256: 64位16进制值, 代表其真实文件的名称, 通过 SHA256 生成, 全局唯一
  • size 129180: 代表以字节计的文件实际大小

和通常的 Git 不同的是, git-LFS 有一个转换为 LFS 存储的过程。在跟踪某个或者某类文件后, 只是意味着在后续提交中这些文件将是 LFS 文件, 但这并不意味着既往提交中的文件将被转换为LFS。如果之前创建过一个没有 LFS 的存储库, 并在其中提交了文件, 那么在使用 git lfs track 跟踪并提交过某些文件之后, 历史记录中仍然以传统 Git 对象存储的方式保有这些大文件的全部旧版本, 且不会将这些旧版本文件存储在 LFS 中。因而前文中会试图用 git lfs migrate 迁移提交历史, 该操作通常被称为历史记录重写(History Rewriting)。

在 LFS 指针文件被添加到暂存区的同时, 真正的的大文件 Game Design Document Template.docx 本体被存储在存储库的 LFS 目录下, 名称被修改为 LFS 指针文件中保存的 oid 字符串, 用 tree .\.git\lfs\ 便能看见目录结构, 不过要注意Linux下的 tree 命令和 Powershell 中的行为有所不同, Powershell 下的 tree 默认只显示文件夹结构, 需要加上 /F 参数才会显示各目录中的文件:

❯ tree /F .\.git\lfs\
Folder PATH listing for volume OS
Volume serial number is 000000AC 8060:B89F
D:\GIT-LFS-TEST\.GIT\LFS
├───cache
│   └───locks
│       └───refs
│           └───heads
│               └───main
│                       verifiable
│
├───incomplete
├───objects
│   ├───00
│   │   └───cb
│   │           00cb8bc2c04197d2b351e2dd578520755ea04fd9b78da096f181a99f43820618
│   │
│   ├───19
│   │   └───41
│   │           194159bea596a4f01c8c19dea9ba1e9d319e25950760aef41e8bd5a3ecf9ba37
......
│   │
│   └───b4
│       └───de
│               b4dec77f0cff21952d0931e15c863d259fb31f9f5f9e2fc9088a4e71c8c6a133
│
└───tmp

由于 Git-LFS 的存储是割裂于 blob 对象的, 因此用 git push 将 Git-LFS 项目上传到远程仓库的过程就会分成两个阶段。Git 首先会上传带有 oid 的 Git-LFS 文件对象, 此时会显示 Uploading LFS objects: 67% (18155/27116), 12.1 GB | 5 MB/s 的字样; 第二阶段则才会上传传统的数据对象, 树对象, 提交对象和 LFS 对象指针文件, 进度提示就变为了: Writing objects: 12% (212/4421), 410 bytes | 410.00 KiB/s。这个两阶段的推送中失败任意一个阶段都将导致整个推送操作失败, 同时也侧面体现了 Git LFS 的推送行为: 在 LFS 推送时, Git LFS 文件被单独上传到 LFS 服务器上, 而指针文件则被原封不动推送到 Git 服务器上。因此 Git-LFS 中才会有 lfs.urllfs.pushurl 这种专用于设置 LFS 远程上传地址的参数, 这也同时意味着 LFS 对于本地管理可以更快体积更小, 但远程存储服务器却不会因此受益, 因为要记录更改的磁盘空间并不会因此而减少。

就有一个不得不提的是 Git 的自定义过滤器功能。Git 中的过滤器均由 cleansmudge 这两个子过滤器组成, 这两个词原本的语义是分别是清理和涂污, 但在 Git 的场景下, clean 指从要暂存和提交的文件中剥离数据的操作(清理数据), 而 smudge 则在恢复文件时(比如 git reset) 用某些值来填充相关字段的过程(玷污数据)。由此, 《Pro Git》中才会不止一次提示 clean 过滤器会在暂存文件时触发, smudge 过滤器则是在检出文件时触发。原书中用了一个筛选 .c 文件并向其中自动写入时间戳字符串的例子, 以此说明自定义的 Git 过滤器如何实现关键字展开这一功能。不难看出, 重写了这两个过滤器就等于控制了从工作区出入暂存区的大门, 考虑到在 Git 工作流中, 文件都是先暂存到暂存区中再提交给本地仓库, 过滤器这个名称也确实起得恰如其分。另一个例子是阿里云文档中的一篇文章, 它介绍了基于过滤器实现的部分克隆功能 (Partial clone): 部分克隆介绍

而作为 Git 的轻量扩展, Git-LFS 正是靠 cleansmudge 这两个子过滤器搭配上 Git 钩子来实现的。在执行了 git lfs install 这个命令后, Git 就做了两件事: 第一件是在全局配置文件 .gitconfig 或者项目设置文件 .git/config 中添加 LFS 的定义配置:

......
[gpg]
	program = C:\\Program Files (x86)\\GnuPG\\bin\\gpg.exe
[credential]
	helper = store
[filter "lfs"]
	required = true
	clean = git-lfs clean -- %f
	smudge = git-lfs smudge -- %f
	process = git-lfs filter-process
[gui]
	encoding = utf-8
[i18n "commit"]
	encoding = utf-8
......

第二件事是在项目目录中添加下面高亮的几行 Git 钩子。这等价于注入了 Git 的某些命令, 比如当执行 git push 时, Git 将首先运行 pre-push 脚本, 脚本执行完后才会执行实际的推送操作, 若脚本执行时有错误导致其退出, 那么推送操作也会不会开始:

> ls .\.git\hooks\

    Directory: D:\GIT-LFS-TEST\.GIT\HOOKS

Mode                 LastWriteTime         Length Name
----                 -------------         ------ ----
-a---          2024-11-16     5:13            478 applypatch-msg.sample
-a---          2024-11-16     5:13            896 commit-msg.sample
-a---          2024-11-16     5:13           4655 fsmonitor-watchman.sample
-a---          2024-11-17    12:15            351 post-checkout
-a---          2024-11-17    12:15            347 post-commit
-a---          2024-11-17    12:15            345 post-merge
-a---          2024-11-16     5:13            189 post-update.sample
-a---          2024-11-16     5:13            424 pre-applypatch.sample
-a---          2024-11-16     5:13           1643 pre-commit.sample
-a---          2024-11-16     5:13            416 pre-merge-commit.sample
-a---          2024-11-17    12:15            341 pre-push
-a---          2024-11-16     5:13           1348 pre-push.sample
-a---          2024-11-16     5:13           4898 pre-rebase.sample
-a---          2024-11-16     5:13            544 pre-receive.sample
-a---          2024-11-16     5:13           1492 prepare-commit-msg.sample
-a---          2024-11-16     5:13           3635 update.sample

最后用户自己修改本地仓库中的 .gitattributes 文件, 以达到告知 Git 在哪些文件上应用 LFS 过滤器的目的:

......
*.bmp filter=lfs diff=lfs merge=lfs -text
*.exr filter=lfs diff=lfs merge=lfs -text
*.gif filter=lfs diff=lfs merge=lfs -text
*.hdr filter=lfs diff=lfs merge=lfs -text
......

这段内容觉得眼熟吗? 它正是截取自正文中为 Git-LFS 配置的 .gitattributes 文件。项目目录中的 .gitattributes 文件是 Git 匹配过滤器请求的唯一的位置, 当在 shell 中执行 git lfs track *.gif 时, Git-LFS 会在 .gitattributes 中添加这行 *.gif filter=lfs diff=lfs merge=lfs -text, 这告诉 Git 对 .gif 文件使用 lfs 配置中的 cleansmudge 过滤器 (即 .gitconfig 里那段 [filter "lfs"]), 并附上其他的 Git 属性。在设置好 lfs 过滤器后, 每当你暂存 .gif 文件时, 该文件都会被 clean 过滤器筛选出来, 执行对应脚本来把文件替换为包含 oid 值的指针文件, 同时将文件本身按 oid 哈希值存储到 .git/lfs/objects 目录中, 方便以后来找。

这也解释了为什么很多文章中会讲到, 那么如果先不想下载某个远程仓库里面的大文件, 可以暂时用一个指针文件作为 LFS 中大文件的占位符, 配置的就是 GIT_LFS_SKIP_SMUDGE=1 这个环境变量:

# bash 写法
GIT_LFS_SKIP_SMUDGE=1 git clone https://huggingface.co/Qwen/Qwen2.5-72B-Instruct
# 而 PowerShell 可以用 $Env 来设置环境变量
> $Env:GIT_LFS_SKIP_SMUDGE="true"
> git clone https://huggingface.co/Qwen/Qwen2.5-72B-Instruct

# 同理  $Env:GIT_TRACE="true", $Env:GIT_TRANSFER_TRACE="true", $Env:GIT_CURL_VERBOSE="false" 等环境变量也可以这么设置

从变量名的字面含义就可以推测出, 它实际上是在 fetch/pull 这样包含签出操作的过程中显式跳过了 smudge 过滤器, 所以 Git 不会按照指针文件内容, 按 oid 哈希值把实际的 LFS 文件翻出来下载到本地。这样仓库中有多个大文件而只需要下载某几个使用时——例如仓库中包含 model-01.binmodel02.bin 两个大文件, 这时用 git lfs pull 就能单独下载之:

# 单独下载所有的 .bin 文件
git lfs pull --include="*.bin"

# 单独下载某一个 .bin 文件
git lfs pull --include="model-01.bin"

和 Git 浅克隆(Shallow Clone)不同, 浅克隆所做的是截断仓库历史, 仅下载给定深度内的提交用于最新构建, 以此大幅降低拉取时间; LFS 中的 GIT_LFS_SKIP_SMUDGE=1 只是跳过了 oid 对象的克隆, 传统的 Git 三大对象仍旧是完整获取的, 限制三大对象的克隆内容由部分克隆特性负责, GitHub团队有一篇博文对此有详细解释, 可见 Get up to speed with partial clone and shallow clone 一文。

哦对了, 设置 Git-LFS 的 fetchexclude 变量来触发过滤器, 还可使 Git 拉取时忽略掉你不需要的文件:

git config lfs.fetchexclude "*.jpg,*.png,*.gif,*.hdr"

以上这些就是 Git-LFS 完成其工作的主要机制。

PS: Git-LFS 有一些小小的限制, 可见: Limitations-git-lfs-Wiki-GitHub


Bonus 2: 提交时在做什么? 有没有UI? 可以来Lock吗?

美术或其他非技术开发者在参与项目时不免要基于 LFS 的锁定功能来防止其他人更改, 但因为缺乏对Git的了解, 常会反馈说 git-lfs lock 命令难用得要死, 非常影响体验。那么有没有基于GUI的解决方案呢?

当然是有的, 即曾经的 github-for-unity(项目已废弃), 现在的 git-for-unity。它就提供了在 Unity 编辑器中直接右键资产文件, 在菜单中直接锁定和解锁文件的功能, 常用的 Git 操作也能用它在 Unity 中通过 GUI 完成, 十分推荐非IT背景的开发者安装。

Unity AssetStore 中的付费插件 GitL FS Locks, 售价 $22 …… 我不能理解

但你并不需要常见的 Git 功能而只是希望使用 LFS 中的文件锁定功能的话, 不妨试试这个叫做 unity-git-locks 的项目, 因为和 git-for-unity 一样直接调用本地的 Git 命令, 插件只是提供了一个显示在 Unity 中的界面, 因此也非常简洁轻量。直接用 https://github.com/TomDuchene/unity-git-locks.git 这个 URL 在 Unity 的包管理器中安装即可, 设置好用户名便能立刻开用。

填写用户名是它唯一的配置, 仅用作区分 Git 操作者
Unity中的界面, 挺像那么回事儿嚯~
亮点: 功能单一且支持菜单操作


真·全文完


参考资料

  1. Assets, Resources and AssetBundles (繁中译本)
  2. Working with YAMLMerge – Unity Learn
  3. Setting up Git Bash / MINGW / MSYS2 on Windows
  4. Git LFS Specification
  5. My Git Workflow
  6. Unity and Git – Northern Wind (PDF存档)
  7. Pro Git (英文原版 原版PDF)
  8. Understanding Git Part 1
  9. Understanding Git Part 2
  10. Tracking huge files with Git LFS (LinuxCon Japan, 2016)
  11. How Git LFS Works
  12. Git 钩子列表: githooks.txt
  13. Revert and undo changes – Gitlab Docs

Published by