用 git 管理 Unity 專案有好一陣子了,剛好最近公司的 Open-source Unity Builder mimiron-lite 發布新版。同時公開了新的作為公司內標準的 git 設定檔。想分享一些設定檔背後的思維還有 git 使用的經驗。

TL;DR 的話可以直接參考已經編輯好的 git 設定檔,現在最新的設定包含 Wwise 的 .gitignore 已經獨立更新到: https://github.com/FrankNine/RepoConfig
這個 Repo Fork 自: https://github.com/rayark/repo-config
公司將這個設定以 MIT 授權 釋出。

如果對 git 操作不熟悉的話,目前中文教學最推薦的還是 為你自己學 Git by 高見龍。除了線上版教的基本操作外,付費版內很難得有 git 資料結構(Commit、Tree、Blob)的介紹,對於更加熟悉 git 非常有幫助。

用戶端

之前為了公司需要同時滿足技術人員與非技術背景人員操作 git 的需求 Survey 了很多 git 用戶端,目前選定的是需要付費的 Fork。在這之前用的是最常見免費的 SourceTree,不過登入上常常會出現 問題 需要排解,還有有的時候檔案多的時候會變很慢,對圖片與 LFS 檔案也沒有預覽支援。另外有些程式同事會選用 Git for Windows 附帶的 Git Extensions 但是這個對美術使用上有些不友善,習慣用戶端有一個視窗的也可能會不習慣 Shell Extension。另一個免費的 Shell Extension 選擇是 TortoiseGit,對美術素材支援到顯示 LFS 內的圖片,但是在 Blame、Rebase 之類比較複雜的操作比較不順手。GitHub Desktop 是功能太陽春,GitKraken 雖然有做很多美術相關的功能但是可能是因為 Electron 先天問題,在大 Repo 上還蠻喘的。SmartGit 則是功能雖多但是對美術素材支援算是沒有,而且年費不便宜。

Fork

目前 Fork 提供 Windows 跟 Mac 的原生用戶端,平常速度上算是不錯。付費目前是一次性 50 USD 一個序號最多啟動三台電腦,也比需要訂閱的工具負擔低(雖然不知道以後會不會改)。介面上相當簡潔但可以滿足大部分的開發需求,內建的 Rebase 跟 Merge 介面難得做得算好用。對於遊戲開發算是 Killer Feature 是內建的圖片預覽跟 diff(diff Mac 版還只有 Side-by-side 沒有 Swipe 跟 Onion Skin),而且在 LFS 的配置下也能正常運作。

https://git-fork.com/blog/posts/forkwin-1.38/

伺服器端

一般的 git Hosting 服務在 LFS 的容量與流量限制較多(GitHub 的 LFS 條款),對遊戲專案比較不友善。Riot 的《符文大地傳說》在 分享文章 中提到他們是使用 GitHub Enterprise,搭配使用 Artifactory 作為 LFS Server。使用 Hosting 服務代表 git 操作要走公司聯外網路,在素材大一些的專案以台灣網路狀態也不太實際。如果情況許可推薦用主機或是 NAS 架設 GitLab。GitLab 可以滿足 git、CI Runner專案管理Code Review 等功能,雖然可能專案管理介面沒有像 Trello 或其他工具漂亮、順手。但是這幾個功能在 GitLab 裡可以相互 Reference,像是在 Issue 上面 Reference Merge Request 的進度或是特定 CI Pipeline 的建置結果,這種整合是難以取代的。只是走 Self-hosting IT 方面工就會比較多,備份方面也要注意。

https://about.gitlab.com/stages-devops-lifecycle/issueboard/

External Merger

合併工具大家一般可能用內建的 KDiff3,不過我自己從以前第一份工作用 Perforce 的經驗就非常喜歡 Perforce 的合併工具。幸好 Perforce 的合併工具 P4Merge 可以單獨安裝使用且不收費。大部分的 git 用戶端都可以設定要用哪個外部合併工具,也大多可以識別 P4Merge。程式合併挑選改動跟編輯合併結果相當好用,diff 的判讀也比其他工具好。再加上有非常強大的圖片 diff,可以標出有差異的像素在哪裡。要說明顯的缺點就是非英文字符不太支援,會有亂碼跟 UI 錯亂現象。

P4Merge image diff

P4Merge three way merge

設定

Unity

要進行版本控制 Unity 內的 AssetSerialization 要設定成 Force Text 和 Visible Meta Files 算是基本,Force Text Unity 資源才會存成可以 diff 的 YAML,Visible Meta Files 才能跟其他機器上工作的人同步 GUID。只有在很久以前 Unity 4.X 的時代因為編輯器是 32 位元不能定址超過 4G 的記憶體而 Force Text 會增加記憶體用量會考慮關掉。現在的 Unity 編輯器都是 64 位元不會有這個問題,Force Text 和 Visible Meta Files 後來也變成是預設選項。

.gitconfig

core.ignorecase

最重要會要求團隊所有成員設定:

1
git config --global core.ignorecase true

讓 git 把大小寫不同的檔案視為同一檔案。因為 Mac、Linux 等系統上有可能是 Case-sensitive 檔案系統,大小寫不同的檔案視為不同檔案,但是在 Windows 上卻視為相同檔案。如果沒有這樣設定則 Mac 的使用者可以推名稱只有大小寫不同的檔案進到 Repo,然後用 Windows 的成員會拉不下來,或是遇到檔案變動 Revert 後馬上恢復成 Modified 的狀態。如果有任何非得做檔名大小寫改變的動作,請使用 git mv 不要自己刪除檔案 Commit 再改名 Commit,檔案歷史的認定上可能會錯亂。

參考資料:

因為我們設定了 core.ignorecase true,所以在設定 .gitignore.gitattributes 也就不會特別加入 Match 大小寫的 Pattern。

core.fsmonitor / feature.manyFiles

1
2
git config --global core.fsmonitor true
git config --global feature.manyFiles true

以遊戲專案的大小,任何有助於 git 操作速度的設定都該使用。File System Monitor 可以利用作業系統功能加速檔案修改的偵測,Many Files 則可以加速 Untracked 檔案的偵測。

參考資料:

.gitignore

.gitignore 主要是忽略「應該留在電腦上,但是不應該共用出去的檔案」除了 Unity 的 TempLibrary 之外,主要是程式編輯器的設定。使用過後不應該保留的檔案,像是 Unity 產生的 Xcode 專案目錄或是 APK 我是習慣不加 .gitignore,讓它們被認定為是 Untracked File 方便本地或是 CI Runner 上以 git clean 清除。一個小細節是如果 Pattern 以 / 開頭只會 Match Repo 根目錄下的檔案或目錄,像是 Unity 的 Library 我會寫成 /Library/ 以免忽略到其他目錄裡面叫做 Library 的目錄。設定的範例可以參考:

https://github.com/FrankNine/RepoConfig/blob/master/.gitignore

.gitattributes

.gitattributes 因為牽涉到 LFSCRLF 所以就比較複雜了。

LFS (Large File Storage)

先說 LFS,基本上是把特定副檔名的檔案搬出 git 移動到 LFS Server,而原來的 git 路徑只留下 Pointer File,Pointer File 裡有 oid。當有需要時 LFS 會用 Pointer File 裡的 oid 到 LFS Server 查找、下載檔案並取代(Smudge)掉 Pointer File。

Pointer File 的內容範例如下,容量大約 130 bytes 左右。記得這個容量大小,如果看到檔案變成這個大小要想是不是 LFS 的替換沒有正常執行。

1
2
3
4
version https://git-lfs.github.com/spec/v1
oid sha256:4d7a214614ab2935c943f9e0ff69d22eadbb8f32b1258daaa5e2ca24d17e2393
size 12345
(ending \n)

現在 git 用戶端通常都會偵測 LFS 並在 Clone 的時候做好 LFS 的設定,不過如果 LFS 替換有問題可以手動執行:

1
2
git lfs install
git lfs pull

使用 LFS 可以降低 git 歷史的大小,因為 git 本身變成只儲存 Pointer File。這樣可以降低 Clone 在本機硬碟上的大小,並增進效率。降低 Clone 大小這件事對 Binary 檔案大的 3D 遊戲專案非常有感。但是缺點有:

  • git 操作變成要持續跟 LFS Server 溝通,喪失 git 完全分散式與可離線工作的優點。
  • 有時需要排除 LFS 製造的問題,最常見的是 Clone 或是 Fetch 的時候 LFS 沒有觸發替換 Pointer File 為真正的檔案內容。有些罕見有遇過的是 GitLab 的 LFS Server 弄丟檔案導致 Fetch 不下來。或是在開啟 LFS 又 Fork 的情況下,GitLab LFS Repo 可能需要 重新同步。目前建議使用 GitLab LFS 跟 Fork 功能不要同時使用。
  • 有些功能可能在 LFS 環境下會不能使用,包含部分工具可能會無法 diff LFS 檔案,gitlab-runner exec 在有 LFS 的環境下還無法使用

以遊戲 Binary 檔案佔 Repo 大部分容量的情況下是建議遊戲專案使用 LFS 的,而且越早用越好,因為已經推進 git 歷史裡的 Binary 檔案會一直留在歷史裡,修改 .gitattributes 只會影響到後面增加的檔案。如果 Repo 已經大到受不了了,只能做 Migration 一途,但是變成所有人都要重新 Clone,是超大的工程。

要進行 Migration 首先要在本地 Track 所有的分支,因為 lfs migrate 只會作用在本地分支:
https://stackoverflow.com/questions/379081/track-all-remote-git-branches-as-local-branches

然後執行(以改寫 .png.psd 歷史成為 LFS 作示範):

1
2
3
4
git lfs migrate import --everything --include=".png,.psd"
git reflog expire --expire=now --all && git gc --prune=now
git remote set-url origin <新 repo 位置>
git push --all origin --force

另外一種常見的狀況是修改了 .gitattributes 把新的附檔名納入 LFS 範圍,但是已經存在的檔案沒有置換成 Pointer File,這時候在 Clone 或是 Reset 的時候都會收到警告:

1
Encountered 1 file(s) that should have been pointers, but weren't

如果沒有要往回整理歷史只是要把目前檔案換成 Pointer File,可以用:

1
2
3
# from https://stackoverflow.com/a/51626808
git rm --cached -r .
git reset --hard

或是使用

1
2
# from https://github.com/git-lfs/git-lfs/issues/3421#issuecomment-610489798
git add --renormalize .

然後把檔案變動 Commit / Push 即可。

只要在 .gitattributes 加入

1
*.png filter=lfs diff=lfs merge=lfs

像這樣就可以把 PNG 圖檔交給 LFS 管理(因為有設定 core.ignorecase true 所以我們不用寫成 *.[pP][nN][gG])。基本上所有不能直接文字編輯的二進位檔案都該加入 LFS,但是 Unity Prefab (.prefab) 跟 Scene (.unity) 我會選擇不加入 LFS。在 AssetSerialization 設定為 Force Text 後 Unity 的 GameObject 結構 YAML 算是可以判讀與做文字 diff 的,很多時候可以這樣確認 Prefab 或是 Scene 的修改有沒有意外改到別的東西。

YAML 判讀方面可以參考官方的文章來入門:Understanding Unity’s serialization language, YAML

如果你也習慣這樣判讀 Scene 跟 Prefab 的改動,記得在升級 Unity 的時候要對所有 Scene 跟 Prefab 下 AssetDatabase.ForceReserializeAssets.prefab 或是 .unity 升級到新的 Unity 的格式。因為 Unity 預設行為會 Lazy 升級後要等到有改動才用新格式儲存(這是 Unity 編輯器的設計理念,Material 檔案儲存也有類似的 Lazy 行為),但這樣會變成你的改動跟升級的改動混在一起難以判讀 diff,所以需要在剛升級 Unity 還沒有修改的時候強制檔案升級。

Unity 有提供自己的 YAML merger,不過試用之後覺得結果偶而怪怪的。目前還是只有判讀 diff,沒有在對 Scene 跟 Prefab 使用自動合併。

另外一個特例是 .dll,當 DLL 沒有正確被替換時會造成編譯錯誤,如果 Unity 在抱怨找不到應該定義在 DLL 內的 Symbol 時記得先檢查專案內的 DLL 是不是只有 Pointer File 的 130 bytes 大小。

CRLF

.gitattributes 另一個大坑是 CRLF,因為 Windows (CRLF \r\n)、 Mac (OSX 以前 CR \r,以後 LF \n) 與 Linux (LF \n) 的行尾不同,git 預設會在 Checkout 與 Commit 時幫你做轉換,也就是 autocrlf。但是如果沒有小心設定會導致兩個問題:

  • 轉換到根本不是文字的 Binary 檔案,這樣檔案本地開起來會是壞的
  • 有些檔案行尾不斷改變,最常見的是 .meta,造成很多不是人為編輯造成的改動

很多人可能會用這個很熱門的 Unity .gitattribute 樣本:
https://gist.github.com/nemotoo/b8a1c3a0f1225bb9231979f389fd4f3f

但是它將了 Unity 的 .asset 檔案設定為 LF,而 Unity 有非常多種 .asset 檔案,之前調查過的結果

強制轉換成 LF 會產生問題,底下也有人回報,但是一直沒有更新,所以不建議用這份設定。

我自己最後的設定是開頭:

* -text

預設先將所有檔案的 Text 屬性 拔掉關閉行尾轉換,之後再利用 .gitattributes 可以用下方的規則覆寫上方的規則的特性用白名單方式加回純文字程式碼的行尾轉換。

沒有寫成

* binary

是因為 Binary 代表 -text -diff,我們希望拔掉行尾轉換但是希望對 .scene.prefab.meta 留住文字 diff。看 diff 可以抓到 GameObject、Component 的變化,或是 .meta 的 GUID 跑掉的情況。

Prefab 在沒有加入 LFS 沒有 -diff 的情況下會在 git 用戶端裡顯示像這樣的 diff。可以看到我們把 RectTransform 大小改成 200 X 200,但同時不小心把 GameObject 關了。

看到歷史裡面有像這樣 `.meta` 裡的 GUID 變化是非常危險的,代表 Reference 會掉。

至於行尾常常亂跳的 .meta 因為實驗出來在任何平台 Unity Editor 都是存成以 LF 換行,所以寫死

*.meta text eol=lf

.mat 也應該是 LF 換行,不過它不常亂跳所以沒有像 .meta 寫死。

至於文字編輯的原始碼檔案則應該啟用 Text 與行尾設定壓過 * -text 設定,確保這些檔案在 repo 裡的行尾一致,以免像是 git blame 之類的工具受到整個原始碼的行尾跳動干擾難以閱讀。

以前是設定回 autocrlf,更新的版本我們統一使用 LF:

*.cs text eol=lf

因為我們試圖替 AssetBundle 計算自製的來源特徵 Hash,Hash 輸入除了 AssetBundle 的所有原始素材也包含所有程式原始碼檔案,因為程式可能有 AssetImporter 會影響 Import 結果。為了讓 Hash 計算結果在 Windows 與 Mac 相同,現在統一使用 LF 行尾使原始碼檔案 Binary Representation 在上雙平台相同。Windows 上大部分的程式編輯器其實遇到 LF 結尾的原始碼還是能正常運作,影響不大。

P.S.
(asmdef 改動會影響 Assembly 名稱會使 AssetBundle 無法 Dereference 附著在 GameObject 上的 MonoBehaviour,如果想靠自製特徵 Hash 來控制要不要更新 AssetBundle 也要追蹤 asmdef 變化)

想要使用普通的 Windows CRLF,Mac LF 可以對程式檔案套用這個設定:

*.cs text=auto

最後整套 .gitattributes 設定可以參考: https://github.com/FrankNine/RepoConfig/blob/master/.gitattributes

EditorConfig

https://editorconfig.org/

這個跟 git 本身無關,不過也是可以放在 Repo 裡的設定檔就順便提一下。目前我們使用的設定很陽春:

1
2
3
4
5
6
7
8
9
root = true

[*]
charset = utf-8

indent_style = space
indent_size  = 4

trim_trailing_whitespace = true

.gitattributes 可以控制行尾,但是程式碼還可能會有文字編碼、BOM、或是 Tab / Space 不一致的問題。而編碼與 Tab / Space 可以靠在專案內放置 .editorconfig 提示程式編輯工具如何處理來完成。目前 Visual Studio、MonoDevelop 與 Rider 都會自動偵測 .editorconfig 的設定,而 Visual Studio Code 與 Notepad++ 則是需要安裝 Plugin。

更進一步可以把 Visual Studio 或是 ReSharper 的設定也放進 Repo,從使用工具讓行尾、Tab / Space 之類的統一提升到 Naming 或是 Coding Convention 的統一。不過因為公司還沒有統一導入 JetBrains 的編輯器,所以沒有這方面的實務經驗。

Worktree

如果是開發多平台遊戲的人可能會習慣 clone 同一個專案一次以上,然後把平台設定成不同,例如一個 Clone 平台設定成 Android 一個平台設定成 iOS。雖然 Cache Server / Accelerator 或是 AssetDatabase V2 可以降低平台切換的成本,但是在硬碟上保留兩份專案還是最快最直觀的。缺點很直接是硬碟用量很大,其實可以 Clone 一次然後開一個以上的 Worktree,這樣雖然還是有多個專案目錄,但是只會有一個 .git 資料夾,即電腦上只有一份 git 歷史。如果有容量的疑慮又電腦裝有容量大的傳統硬碟跟有限的 SSD,可以考慮把 git 歷史放在傳統硬碟,專案 Worktree 放在 SSD 上的安排。

在 git 根目錄輸入:

1
git worktree add ../project-ios

即在上層目錄再開一個叫 project-ios 的 Worktree

額外的 Worktree 一般的 git 用戶端都可以識別,操作起來跟操作普通的 Clone 沒有太大差別。

列舉 Worktree

1
git worktree list

當不需要使用時只要把 Worktree 刪除然後執行

1
git worktree prune

可以參考:

Hooks

git 可以在設定裡加入自動觸發的 Shell Script 來作檢查或清理,常見的需求有清除空目錄與其 .meta 跟檢查檔案 Commit 時有沒有加入對應的 .meta。不過這個我沒有推廣到整個團隊的經驗所以就沒有什麼個人經驗分享,可以參考:

如果有機會自己做的話會比較想用 Python 之類的語言實現,這樣邏輯會比較好懂。另外空目錄的 .meta 平常不會造成問題,因此我沒有特別急著要用 Hook 檢查(Commit 內缺少 .meta 則是宣導後蠻少發生的,不過當然如果有自動檢查總是好)。但是有個特別要注意的是 iOS 與 Mac 環境的 .framework.bundle 資料夾,這些資料夾裡面裝的是 Plugin 而 Import Settings 放在目錄 .meta 裡,如果刪除時沒有正確清理目錄 .meta 則 Xcode 會試圖 Import 空的 Plugin 然後建置失敗。

Rebase

使用 git 到現在算是適應 Fork 後 Fetch Upstream、Push Origin、開 Merge Request 的流程(可以參考 【狀況題】怎麼跟上當初 fork 專案的進度?),甚至是加其他同事的 fork 當作 Remote。在我自己的 fork 上面還蠻習慣用 Rebase另一種合併方式(使用 rebase))或是 Cherrypick【狀況題】如果你只想要某個分支的某幾個 Commit?)改造歷史的,如果有人在我合併之前早一步合併到主要分支,我會把自己分支 Rebase 到別人的 Merge 後重開 Merge Request,然後開 --no-ffno fast-forward)弄出一串小耳多形狀的歷史([狀況題]為什麼我的分支都沒有「小耳朵」?),方便以後回頭查找。另外有一種情況是可能某個 Feature 把 X 改成 Y,後來發現做錯了又從 Y 改成 Z。這樣的情況我會試著在發 Merge Request 之前把歷史改成 X -> Z 而非 X -> Y -> Z 減少 Reviewer 的負擔。感覺這些操作可能可以加入 Code Review 標準,不過目前時程壓力狀況下還是只有我自己會要求自己,還沒有推廣的經驗。

Branch 直接使用 Merge 與 Rebase 後 merge --no-ff 的歷史差別,右邊 Rebase 的歷史比較好懂(歷史僅供示範用,工作的時候應該情況會更複雜)

關於 Rebase 有很多很好的中文文章,可以參考:

對非技術人員的話操作 Rebase 可能會比較困難,但是如果非程式都用 Pull 的話會產生大量的 Merge Commit。有一派的作法是替非技術人員設定 pull.rebaserebase.autoStash

1
2
git config pull.rebase true
git config rebase.autoStash true

這樣在 Pull 的時候會觸發 Stash、Rebase 和 Apply Stash,對於減少 Merge Commit 蠻有效的。但是麻煩的是如果有衝突會從 Merge Conflict 變成更難排除的 Rebase Conflict。公司有些專案是要求這樣運作的,這可能要看各團隊非技術人員的接受度。

如果對 Pull Rebase 有興趣可以參考:

往前 Rebase 同時有接受主要分支的改動的功能,另一種做法是主要分支往 Feature Branch 做 Merge。但這樣 Merge 要交代所有成員小心發生衝突時的 Resolve,如果更新時的 Merge Resolve 做錯,在 Feature Branch 結束反著合向主要分支時是不會有任何警告,因為前面已經 Marked as Resolved 了。非技術人員偶爾會做錯,但是發生就會污染主要分支造成嚴重後果。如果不用 Rebase 要特別小心這點。

Sparse checkout & Shallow clone

在 Checkout 的時候其實不一定要 Checkout 出整個專案,可以將路徑 Pattern 寫入 .git/info/sparse-checkout 只 Checkout 特定的檔案或資料夾。以下是只 Clone 之後只 Checkout /Assets//ProjectSettings/ 的示範。

1
2
3
4
5
6
git clone --no-checkout <Repo URI>
cd <Repo 路徑>
git config core.sparseCheckout true
(echo /Assets/) > .git/info/sparse-checkout
(echo /ProjectSettings/) >> .git/info/sparse-checkout # >> 是 Append
git checkout

Git 2.25.0 加入了 git sparse-checkout,可以用 git sparse-checkout init 取代 git config core.sparseCheckout true git sparse-checkout set/add 取代直接編輯 .git/info/sparse-checkout

另外 git clone 可以加入 --depth 參數限制 Clone 下來的歷史深度

1
git clone --depth 5 -b <Branch> <Repo URI>

如果要還原的話執行:

1
git fetch --unshallow

Sparse Checkout 與 Shallow Clone 都可以加快 git 操作的速度與減少空間用量,不過平常直接用在要編輯的 Clone 機會不多。最實用的應用是在設定 Build Pipeline 的 git,控制 Build Server Clone 跟 Checkout 的行為達到加速建置的效果。

可以參考:

git 2.34 加入了更激進的 Sparse Index,如果你是 Monorepo 式的專案可以連 index 都 sparse 取出。但我們目前沒有使用 Monorepo,個人也對 Monorepo 後再剔除東西回到好像非 Monorepo 的狀態的工作流程有點疑惑,就先不延伸討論。

Branching Model

先前流行過的 Git FlowGit Flow 是什麼?為什麼需要這種東西?)在使用過許久之後決定棄用,它帶來的好處沒有大過它的複雜度帶來的困擾(Hacker News 討論),尤其是在不完全是技術背景的遊戲團隊裡更是執行困難。

另外 Git Flow 沒有特別強調 Feature Branch 生命週期必須短或是必須頻繁地向主要分支合併。如果有長的 Feature Branch 就是沒有整合 (Integration)。開長的 Feature Branch 並不會帶給你獨立工作的環境,只是獨立工作的假象。它的真正效果是延遲支付整合的成本,但是到整合的時候就是連本帶利的吐出來。如果有遇過在 Sprint 或是 Milestone 快結束前好幾個人想 Merge 主幹分支互相衝突到死,還有硬是 Merge 後主幹分支上的程式穩定度大爆炸又沒有時間修的情境就知道我的意思。

現在開始往 Trunk-Based Development 過渡,開短的 Feature Branch 快速往主要分支合併,或是長的 Feature Branch 但是用 Branch by AbstractionFeature Toggles 讓未完成的功能還是能定期合併進主要分支又不影響其他人,由此把整合的成本分攤開來。如果有在跑 Scrum 有時會遇到功能在 Sprint 結束的時候還收不了尾,如果都放在很久沒合併過的 Feature Branch 上就這樣帶到下一個 Sprint 會非常困擾,所以變成在 Sprint 尾聲非得整合進去的壓力很大。如果功能是 Feature Toggles 控制的話就可以有一個小段落就合併,下個 Sprint 開始再重新評估規畫即可。然後 Feature 完成之後一般是清除掉 Toggle,但也有沿用 Toggle 的結構做成 A/B Test 或是 Remote Config 的可能。

另外 Code Review 也是在頻繁合併發 Merge Request 的環境比較適合做,一般人一次能 Review 的程式長度其實有限。開發超過一週的改動份量 Review 起來就會非常吃力,更久就是折磨 Reviewer 了。

LFS Lock

在 git LFS v2.0.0 新增了 鎖定 特定檔案的功能,新版的 Fork 也支援從 UI 執行 LFS Lock。在 .gitattributes 加入 lockable 即宣告為可以鎖定的檔案:(實際上實驗起來好像沒有 lockable 屬性也能 git lfs lock,看得到的差別好像只在有宣告 lockable commit 檔案時 git LFS 會自動把沒有 git lfs lock 的檔案加上 read-only 屬性)

1
*.jpg filter=lfs diff=lfs merge=lfs -text lockable

之後便可以用 lock 與 unlock

1
2
git lfs lock <檔案路徑>
git lfs unlock <檔案路徑>

雖然這個功能感覺可以實作類似 Perforce 的 Checkout 同時鎖定檔案功能,但是目前還是需要靠手動檢查 Lock 狀態,不是很方便。所以沒有推廣到專案上,但是覺得是一個有潛力的方向。希望之後的版本能改進,或是有方法靠 Hook 兜出像是 Perforce 偵測到檔案編輯就自動上鎖的功能。

後來試了一下希望自動將檔案設定唯讀,然後在 LFS Lock 時才解除檔案唯讀的仿效 Perforce 的工作流程的自動化。實作到一半發現 Unity Editor 本身就不會尊重系統上的唯讀 Flag,因為 Unity 會隨便解除 Assets 目錄下檔案的唯讀,無法區分是我解鎖的還是 Unity 解鎖的。這樣只能放棄這個想法,也會讓我好奇 Perforce 要怎麼用在 Unity 上,還是無法做好整合?

另外 Per-branch LockAuto Unlock 這兩個使用者期望能實作的改進好像就放置了,感覺這幾個大問題解決前好像沒有繼續投資研究 LFS Lock 的必要。

VFS for Git

VFS for Git 是微軟為了使用 git 管理 Windows 這個歷史悠久又巨大的 Repo 而提出的方案。底層是依賴 ProjFS 虛擬檔案系統,使用起來會看到 gvfs clone 時檔案存在在硬碟上但是沒有內容,一嘗試打開讀檔的 Blocking 就會包含從 Server 下載這段。測試 gvfs clone 的時候真的如同微軟宣稱的非常高速,在一分鐘內結束。實際的內容在 Unity 專案開啟時才會自動下載 HEAD Worktree 的內容,因為是檔案系統層的介入,Unity 2020 以後不用任何修改就能使用。但是因為 Unity 每次開啟都會掃描整個 /Assets/ 資料夾建立 AssetDatabase,等於最後還是把整個 /Assets/ 都摸過一遍抓下來。如果有引擎是只碰編輯有需要的檔案的系統,才能完整享受要用才抓的好處吧。

即使微軟宣稱 Windows repo 很大

老實說跟遊戲比起來還是感覺不大

雖然看起來很厲害但是現階段應該還是不會考量導入,最主要就是支援的 git Server 太少了。只有 Azure Repos 支援,然後 Repo 又有 250GB 大小限制。有限制在還是會有點擔心。GitHubGitLab 看起來沒有積極想要支援的感覺。

如果還是有興趣的人想嘗試,導入時有幾個點要注意:

  • 只有 Azure Repos 支援。
  • git Repo 必須是非 LFS 狀態,已經是 LFS 的專案要反向做 git lfs migrate export 恢復到 Binary 都直接 Commit 在 git 歷史上的狀態。
  • .gitattribute 內容要用 Rebase 從歷史刪除,只留 * -text,VFS for Git 不允許任何 .gitattribute 轉換
  • git Client 必須要使用微軟的 Fork,微軟宣稱就算不用 VFS for Git,一般的 git 操作也能用這個 Fork 獲得一些加速。不過我後來遇到有些不太確定原因的現象,實驗完 VFS for Git 還是回去用 GUI Client 內建的版本。

Single Source of Truth

因為我們曾經是用 Mercurial 來管理專案,所以在導入 git 時有一段時間是程式們改用 git 而美術們繼續用慣用的 Mercurial,用腳本定時複製同步兩邊的改動。結果追查改動難度變超高,要不斷找同步的時間點然後跳到另一邊繼續追查歷史。在急著要出 Hotfix 的時候特別歡樂。後來受不了強制所有成員都改用 git,也因為這樣所以才會有上面為非技術人員 Survey 用戶端的故事。

這個經驗讓我覺得構成一個遊戲的資料應該放在同一個歷史,即同一個 Repo 下。所以也打消了 Survey 一些跨版本控制系統同步工具像是 Git Fusion 或是 git-svn 的念頭。變成都用 git,然後儘量解決非技術人員的痛點。同時也漸漸少用 git submodule,要同步 Submoudle 跟外面的 Repo 的歷史太容易出錯。共用的工具庫現在走回最傳統的上 Tag 上版號出安裝包讓各專案安裝,各專案把工具內容直接 Commit 進各自的 Repo。

構成一個遊戲的資料應該放在同一個歷史的想法繼續推展下去,Server 的原始碼與企劃數值表也都該跟 Client 專案放同一個 Repo。當遊戲資料格式需要改動的時候,一個 Commit 包含 Server、Client、數值表三者的同步更新(Schema 使用類似 ProtoBuf 之類的跨語言定義),徹底解決 Server、Client 與數值資料 Schema 不同步造成的問題。但是苦於數值沒有現成好的編輯工具,企劃還是得在 Google Sheets 上編輯然後下載下來,所以這個想法還是一直處於我自己的空想階段。

關於數值編輯工具的討論,可以參考:

為什麼要用 git?

其實開啟 LFS 或 VFS for Git 就減損了 git 分散式的特性,使用 Thunk-based 對於 Branch 能力的需求就降低不少,然後現在我們試圖在 LFS 上面開啟 Lock。也會有很多人問說為什麼不乾脆用 SVN、Perforce,在這篇文章草稿討論時也有幾位開發者朋友提到他們改用 Plastic。我想了很久想不太到一個好的答案,git 在設計時應該是沒有把遊戲開發常見的大檔案、無法合併的 Binary 使用情境考慮進去,現在是用 LFS 外掛在 Smudge / Clean 系統上湊出來的。而這篇文章很大一部分其實也是在處理這種外掛作法衍生的問題。當初公司從 Mercurial 搬到 git,單純是感覺到 Mercurial 的開發動能比 git 少很多,像是 Merge Request 接到 Code Review 的工具 Mercurial 當時找不太到適用的,而 git 有 Gerrit、GitHub 跟後來我們在用的 GitLab。git 用戶端也是推陳出新,而 Mercurial 就是那幾種。所以這是 Worse is better 裡面所說 Worse 的勝利嗎?也許是(不是說 Mercurial 就是 Better,老實說我也不知道這個問題的 Better 是什麼)。

我想至少不管是什麼原因你選擇了用 git 版本控制你的 Unity 專案,至少我可以提供一些經驗與協助。我沒有覺得非用 git 不可,如果你有更喜歡的做法也很好,然後歡迎分享你的經驗。如果沒有想法的話可能就使用者多的地方資源多些,踩坑的隊友也多些。

希望這篇文章能讓洗了 git 頭的大家少跟 git 搏鬥一分,開發遊戲多一分。有機會再見。

鳴謝

感謝 Review 過這篇文章草稿給過建議的包子、小善學長、小金學長、Hugo、建豪、蒼時、頭皮、Colin、于修、Denny、 JohnSu、Recca、Jonas 與各位朋友們。如果有想要補充討論的也歡迎留言。