Tuần này trên HN có bài về việc Anthropic thiết kế lại hệ thống source control nội bộ. Đọc xong mình thấy quen, vì team nào để monorepo phình lên vài chục GB cũng sẽ gặp đúng những vấn đề đó: git clone mất 40 phút, git status chạy 8 giây, CI thì tốn phần lớn thời gian để kéo code về. Phần lớn chúng ta không cần (và cũng không nên) tự viết VCS riêng. Git bản hiện đại đã có sẵn công cụ để xử lý repo lớn, chỉ là ít người dùng. Bài này tổng hợp những gì mình đã áp dụng cho một monorepo khoảng 18GB, gồm partial clone, sparse-checkout, Scalar và vài config đáng bật.
1. Biết repo nặng vì đâu trước khi tối ưu
Repo chậm thường vì một trong ba lý do sau, hoặc cả ba:
-
Nhiều blob lớn trong history: ai đó từng commit file
.zip, model ML, dump SQL rồi xóa đi, nhưng các blob đó vẫn còn trong history. -
Nhiều file trong working tree: hàng trăm nghìn file khiến
git statusphảistat()từng file một. -
History dài: hàng triệu commit làm
git log,git blamevà merge-base chậm đi.
Để biết repo của mình thuộc loại nào, mình hay dùng script Python sau để liệt kê các blob lớn nhất trong toàn bộ history:
#!/usr/bin/env python3
# find_big_blobs.py - liệt kê top N blob lớn nhất trong history
import subprocess
import sys
TOP_N = int(sys.argv[1]) if len(sys.argv) > 1 else 20
rev_list = subprocess.Popen(
["git", "rev-list", "--objects", "--all"],
stdout=subprocess.PIPE,
)
cat_file = subprocess.run(
["git", "cat-file",
"--batch-check=%(objecttype) %(objectname) %(objectsize) %(rest)"],
stdin=rev_list.stdout,
capture_output=True,
text=True,
)
blobs = []
for line in cat_file.stdout.splitlines():
parts = line.split(" ", 3)
if parts[0] == "blob":
path = parts[3] if len(parts) > 3 else "(unknown)"
blobs.append((int(parts[2]), parts[1][:10], path))
blobs.sort(reverse=True)
total = sum(b[0] for b in blobs)
print(f"Tổng {len(blobs)} blob, {total / 1024**3:.2f} GB (chưa nén)")
for size, sha, path in blobs[:TOP_N]:
print(f"{size / 1024**2:8.1f} MB {sha} {path}")
Chạy python3 find_big_blobs.py 30 ở repo của mình thì thấy khoảng 70% dung lượng nằm ở các file .onnx và .parquet có từ 3 năm trước. Lúc đó có hai lựa chọn. Một là rewrite history bằng git filter-repo, nhưng cách này đau vì mọi người phải clone lại và mọi SHA đều đổi. Hai là giữ nguyên history và để client không tải blob mình không cần. Trên repo dùng chung, mình gần như luôn chọn cách thứ hai.
2. Partial clone: chỉ tải blob khi thật sự cần
Partial clone ổn định từ khoảng Git 2.22 trở đi, và GitHub, GitLab, Bitbucket đều đã hỗ trợ. Có hai filter hay dùng:
# Blobless clone: tải toàn bộ commit + tree, blob thì tải lazy
git clone --filter=blob:none git@github.com:company/monorepo.git
# Treeless clone: chỉ tải commit, tree và blob đều lazy (hợp cho CI build 1 lần)
git clone --filter=tree:0 git@github.com:company/monorepo.git
# Kiểm tra repo hiện tại có phải partial clone không
git config --get remote.origin.promisor # true
git config --get remote.origin.partialclonefilter # blob:none
Với repo 18GB của mình, blobless clone còn khoảng 1.9GB và mất chưa tới 3 phút. Cơ chế hoạt động như sau:
sequenceDiagram
participant Dev as Developer
participant Local as Local repo
participant Remote as Remote (promisor)
Dev->>Local: git clone --filter=blob:none
Remote-->>Local: commits + trees (không có blob)
Dev->>Local: git checkout main
Local->>Remote: fetch các blob của HEAD
Remote-->>Local: blobs cần thiết
Dev->>Local: git log -p old_file.py
Local->>Remote: fetch lazy blob của commit cũ
Remote-->>Local: blob cũ
Cần lưu ý là partial clone đổi dung lượng lấy network. Các lệnh phải đọc nội dung file cũ như git blame hay git log -p trên một file có history dài sẽ phải kéo blob về, nên lần đầu chạy sẽ chậm và cần có mạng. Với developer thì blob:none là lựa chọn hợp lý. Mình không khuyên dùng tree:0 cho máy dev, vì khi đó gần như lệnh nào cũng phải fetch thêm.
3. Sparse-checkout: chỉ checkout phần mình làm
Partial clone giải quyết chuyện dung lượng. Sparse-checkout giải quyết chuyện working tree có quá nhiều file. Nên dùng cone mode (mặc định từ Git 2.27+), vì nó match theo thư mục và nhanh hơn nhiều so với pattern kiểu .gitignore:
# Clone kết hợp cả hai: blobless + sparse
git clone --filter=blob:none --sparse git@github.com:company/monorepo.git
cd monorepo
# Lúc này chỉ có file ở root. Thêm các thư mục cần làm:
git sparse-checkout set services/payment libs/common tools/ci
# Thêm thư mục mà không ghi đè danh sách cũ
git sparse-checkout add services/notification
# Xem đang checkout những gì
git sparse-checkout list
# Muốn quay lại full checkout
git sparse-checkout disable
Kết hợp hai kỹ thuật này thì blob của các thư mục nằm ngoài sparse set sẽ không bao giờ bị tải về. Ở team mình, mỗi service có một file .sparse-profile liệt kê dependency, kèm một target make sparse SERVICE=payment để set cho đúng. Người mới vào chỉ cần clone vài trăm MB thay vì 18GB.
Vẫn còn vài vấn đề cần biết. IDE như IntelliJ hay VS Code có thể báo thiếu import nếu code tham chiếu tới thư mục ngoài sparse set. Build tool như Bazel, Nx hay Turborepo cũng cần biết dependency graph để không đi tìm file không tồn tại. Vì vậy hãy sinh sparse set từ dependency graph của build tool, đừng viết tay.
4. Scalar và các config nên bật
Từ Git 2.38, scalar được tích hợp sẵn trong Git. Thực chất nó là một wrapper bật sẵn các config tốt cho repo lớn:
# Clone mới theo cách của Scalar: blobless + sparse + maintenance
scalar clone git@github.com:company/monorepo.git
# Lưu ý: code nằm trong monorepo/src/
# Hoặc áp dụng cho repo đã clone sẵn
cd ~/work/monorepo
scalar register
# Nếu không muốn dùng scalar, bật tay các config quan trọng:
git config core.fsmonitor true # built-in fsmonitor (macOS/Windows, Git 2.37+)
git config core.untrackedCache true
git config feature.manyFiles true # index v4 + untracked cache
git config fetch.writeCommitGraph true
git commit-graph write --reachable --changed-paths
git maintenance start # chạy prefetch, gc, commit-graph nền
Trên MacBook M3 của mình, git status giảm từ 6.2 giây xuống còn khoảng 0.3 giây chủ yếu nhờ core.fsmonitor. Git chạy một daemon theo dõi file system event, nhờ vậy không phải quét lại cả cây thư mục mỗi lần. Còn --changed-paths tạo Bloom filter trong commit-graph, giúp git log -- path/to/file nhanh lên rõ rệt.
Dưới đây là cách mình chọn chiến lược theo từng tình huống:
flowchart TD
A[Cần clone repo lớn] --> B{Dùng cho việc gì?}
B -->|CI build 1 lần| C[--depth=1 hoặc --filter=tree:0]
B -->|Developer hằng ngày| D{Làm toàn repo?}
D -->|Có| E[--filter=blob:none + fsmonitor]
D -->|Chỉ vài service| F[scalar clone + sparse-checkout set]
B -->|Cần blame/history sâu| G[Full clone + commit-graph]
Với CI, shallow clone --depth=1 vẫn là cách nhanh nhất cho job chỉ build và test. Nếu job cần tính diff với main, ví dụ để chỉ test các package bị ảnh hưởng, thì dùng --filter=blob:none với fetch-depth: 0 sẽ hợp lý hơn shallow, vì shallow clone hay làm hỏng git merge-base.
Kết luận
Trước khi nghĩ tới chuyện tách repo hay đổi VCS, hãy dùng hết những gì Git đã có sẵn:
-
Đo trước: chạy script tìm blob lớn và
time git statusđể biết repo chậm vì dung lượng, số file hay history. -
Developer dùng
--filter=blob:none: dung lượng giảm 5 đến 10 lần mà gần như không mất gì. - Monorepo nhiều team thì thêm sparse-checkout cone mode, và sinh sparse set từ dependency graph của build tool.
-
Bật
core.fsmonitor,feature.manyFilesvàgit maintenance start, hoặc chạyscalar registercho nhanh. Đây là thay đổi tốn ít công nhất mà hiệu quả nhiều nhất. - Với CI, chọn giữa shallow và treeless tùy job có cần history hay không, đừng dùng full clone theo mặc định.
- Chặn blob lớn ngay từ đầu bằng pre-commit hook giới hạn kích thước file, và đưa artifact như model hay dataset ra object storage hoặc Git LFS.
Repo lớn không có gì sai. Vấn đề là dùng công cụ cho repo 50MB để làm việc với repo 50GB. Chỉ cần cấu hình đúng vài lệnh là đủ để team bớt mất thời gian chờ clone mỗi sáng.
Top comments (0)